Merge branch 'nightly' into issues/2309-set-config-using-env-vars

This commit is contained in:
JonnyWong16 2025-05-10 18:41:23 -07:00 committed by GitHub
commit 0f749035a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1330 additions and 991 deletions

View file

@ -11,6 +11,11 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
if: ${{ !github.event.release.prerelease }} if: ${{ !github.event.release.prerelease }}
steps: steps:
- name: Sync Winget Fork
run: gh repo sync ${{ secrets.WINGET_USERNAME }}/winget-pkgs -b master
env:
GH_TOKEN: ${{ secrets.WINGET_TOKEN }}
- name: Submit package to Windows Package Manager Community Repository - name: Submit package to Windows Package Manager Community Repository
run: | run: |
$wingetPackage = "Tautulli.Tautulli" $wingetPackage = "Tautulli.Tautulli"

View file

@ -234,7 +234,7 @@ ${next.modalIncludes()}
<li><a href="#patreon-donation" role="tab" data-toggle="tab">Patreon</a></li> <li><a href="#patreon-donation" role="tab" data-toggle="tab">Patreon</a></li>
<li><a href="#stripe-donation" role="tab" data-toggle="tab">Stripe</a></li> <li><a href="#stripe-donation" role="tab" data-toggle="tab">Stripe</a></li>
<li><a href="#paypal-donation" role="tab" data-toggle="tab">PayPal</a></li> <li><a href="#paypal-donation" role="tab" data-toggle="tab">PayPal</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab">Crypto</a></li> <li><a href="#crypto-donation" role="tab" data-toggle="tab" id="crypto-donation-tab">Crypto</a></li>
</ul> </ul>
<div class="tab-content"> <div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="github-donation" style="text-align: center"> <div role="tabpanel" class="tab-pane active" id="github-donation" style="text-align: center">
@ -283,7 +283,16 @@ ${next.modalIncludes()}
</div> </div>
<div role="tabpanel" class="tab-pane" id="crypto-donation" style="text-align: center"> <div role="tabpanel" class="tab-pane" id="crypto-donation" style="text-align: center">
<p> <p>
Click the button below to continue to Coinbase. Select a cryptocurrency.
</p>
<select class="form-control" id="crypto-select"></select>
<div id="crypto-qrcode"></div>
<div id="crypto-address" class="form-group">
<label>Address:</label>
<span class="inline-pre" id="crypto-address-value"></span>
</div>
<p>
Or click the button below to continue to Coinbase.
</p> </p>
<a href="${anon_url('https://commerce.coinbase.com/checkout/8a9fa08c-8a38-409e-9220-868124c4ba0c')}" target="_blank" rel="noreferrer" class="donate-with-crypto"> <a href="${anon_url('https://commerce.coinbase.com/checkout/8a9fa08c-8a38-409e-9220-868124c4ba0c')}" target="_blank" rel="noreferrer" class="donate-with-crypto">
<span>Donate with Crypto</span> <span>Donate with Crypto</span>
@ -331,6 +340,7 @@ ${next.modalIncludes()}
<script src="${http_root}js/blurhash_pure_js_port.min.js"></script> <script src="${http_root}js/blurhash_pure_js_port.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script> <script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/ajaxNotifications.js"></script> <script src="${http_root}js/ajaxNotifications.js"></script>
<script src="${http_root}js/kjua.min.js"></script>
<script> <script>
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
$('body').on('click', '#updateDismiss', function() { $('body').on('click', '#updateDismiss', function() {
@ -404,6 +414,42 @@ ${next.modalIncludes()}
checkUpdate(function () { $('#nav-update').html('<i class="fa fa-fw fa-arrow-alt-circle-up"></i> Check for Updates'); }); checkUpdate(function () { $('#nav-update').html('<i class="fa fa-fw fa-arrow-alt-circle-up"></i> Check for Updates'); });
}); });
$('#crypto-donation-tab').one('shown.bs.tab', function (e) {
$.ajax({
url: 'https://tautulli.com/donate/crypto-addresses.json',
type: 'GET',
dataType: 'json',
cache: false,
async: true,
success: function (data) {
$('#crypto-select').empty().append('<option selected disabled>Select Cryptocurrency</option>');
$.each(data, function (index, crypto) {
$('<option/>', {
text: crypto.name + ' (' + crypto.symbol + ')',
value: crypto.address
}).appendTo('#crypto-select');
});
},
error: function () {
$('#crypto-select').empty().append('<option selected disabled>Error: Unable to load addresses</option>');
}
});
});
$('#crypto-select').change(function() {
var address = $(this).val();
$('#crypto-qrcode').empty().kjua({
text: address,
render: 'canvas',
ecLevel: 'H',
size: 256,
fill: '#000',
back: '#eee'
}).show();
$('#crypto-address-value').text(address);
$('#crypto-address').show();
})
% endif % endif
$('.dropdown-toggle').click(function (e) { $('.dropdown-toggle').click(function (e) {

View file

@ -4575,12 +4575,32 @@ a.donate-with-crypto::after {
top: 0; top: 0;
left: 0; left: 0;
} }
#crypto-select {
width: 280px;
margin: 15px auto;
}
#crypto-qrcode {
width: 258px;
padding: 0;
margin: 15px auto;
line-height: 0;
text-align: center;
background-color: #eee;
border: 1px solid #ccc;
border-radius: 4px;
display: none;
}
#crypto-address {
margin: 15px auto;
text-align: center;
display: none;
}
#api_qr_code { #api_qr_code {
width: 100%; width: 100%;
padding: 0; padding: 0;
margin: 0 0 10px; margin: 0 0 10px;
line-height: 1; line-height: 0;
text-align: center; text-align: center;
background-color: #eee; background-color: #eee;
border: 1px solid #ccc; border: 1px solid #ccc;

File diff suppressed because one or more lines are too long

View file

@ -162,7 +162,7 @@ export_table_options = {
var tooltip_title = ''; var tooltip_title = '';
var icon = ''; var icon = '';
if (rowData['thumb_level'] || rowData['art_level'] || rowData['logo_level'] || rowData['individual_files']) { if (rowData['thumb_level'] || rowData['art_level'] || rowData['logo_level'] || rowData['individual_files']) {
tooltip_title = 'Zip Archive'; tooltip_title = 'ZIP Archive';
icon = 'fa-file-archive'; icon = 'fa-file-archive';
} else { } else {
tooltip_title = rowData['file_format'].toUpperCase() + ' File'; tooltip_title = rowData['file_format'].toUpperCase() + ' File';

View file

@ -2155,7 +2155,6 @@ Rating: {rating}/10 --> Rating: /10
<script src="${http_root}js/parsley.min.js"></script> <script src="${http_root}js/parsley.min.js"></script>
<script src="${http_root}js/Sortable.min.js"></script> <script src="${http_root}js/Sortable.min.js"></script>
<script src="${http_root}js/jquery.inputaffix.min.js"></script> <script src="${http_root}js/jquery.inputaffix.min.js"></script>
<script src="${http_root}js/kjua.min.js"></script>
<script> <script>
function getConfigurationTable() { function getConfigurationTable() {
$.ajax({ $.ajax({

View file

@ -1,4 +1,4 @@
from .core import contents, where from .core import contents, where
__all__ = ["contents", "where"] __all__ = ["contents", "where"]
__version__ = "2024.08.30" __version__ = "2025.04.26"

View file

@ -1,95 +1,4 @@
# Issuer: CN=GlobalSign Root CA O=GlobalSign nv-sa OU=Root CA
# Subject: CN=GlobalSign Root CA O=GlobalSign nv-sa OU=Root CA
# Label: "GlobalSign Root CA"
# Serial: 4835703278459707669005204
# MD5 Fingerprint: 3e:45:52:15:09:51:92:e1:b7:5d:37:9f:b1:87:29:8a
# SHA1 Fingerprint: b1:bc:96:8b:d4:f4:9d:62:2a:a8:9a:81:f2:15:01:52:a4:1d:82:9c
# SHA256 Fingerprint: eb:d4:10:40:e4:bb:3e:c7:42:c9:e3:81:d3:1e:f2:a4:1a:48:b6:68:5c:96:e7:ce:f3:c1:df:6c:d4:33:1c:99
-----BEGIN CERTIFICATE-----
MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG
A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv
b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw
MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i
YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT
aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ
jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp
xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp
1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG
snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ
U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8
9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B
AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz
yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE
38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP
AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad
DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME
HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==
-----END CERTIFICATE-----
# Issuer: CN=Entrust.net Certification Authority (2048) O=Entrust.net OU=www.entrust.net/CPS_2048 incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
# Subject: CN=Entrust.net Certification Authority (2048) O=Entrust.net OU=www.entrust.net/CPS_2048 incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
# Label: "Entrust.net Premium 2048 Secure Server CA"
# Serial: 946069240
# MD5 Fingerprint: ee:29:31:bc:32:7e:9a:e6:e8:b5:f7:51:b4:34:71:90
# SHA1 Fingerprint: 50:30:06:09:1d:97:d4:f5:ae:39:f7:cb:e7:92:7d:7d:65:2d:34:31
# SHA256 Fingerprint: 6d:c4:71:72:e0:1c:bc:b0:bf:62:58:0d:89:5f:e2:b8:ac:9a:d4:f8:73:80:1e:0c:10:b9:c8:37:d2:1e:b1:77
-----BEGIN CERTIFICATE-----
MIIEKjCCAxKgAwIBAgIEOGPe+DANBgkqhkiG9w0BAQUFADCBtDEUMBIGA1UEChML
RW50cnVzdC5uZXQxQDA+BgNVBAsUN3d3dy5lbnRydXN0Lm5ldC9DUFNfMjA0OCBp
bmNvcnAuIGJ5IHJlZi4gKGxpbWl0cyBsaWFiLikxJTAjBgNVBAsTHChjKSAxOTk5
IEVudHJ1c3QubmV0IExpbWl0ZWQxMzAxBgNVBAMTKkVudHJ1c3QubmV0IENlcnRp
ZmljYXRpb24gQXV0aG9yaXR5ICgyMDQ4KTAeFw05OTEyMjQxNzUwNTFaFw0yOTA3
MjQxNDE1MTJaMIG0MRQwEgYDVQQKEwtFbnRydXN0Lm5ldDFAMD4GA1UECxQ3d3d3
LmVudHJ1c3QubmV0L0NQU18yMDQ4IGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxp
YWIuKTElMCMGA1UECxMcKGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDEzMDEG
A1UEAxMqRW50cnVzdC5uZXQgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgKDIwNDgp
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArU1LqRKGsuqjIAcVFmQq
K0vRvwtKTY7tgHalZ7d4QMBzQshowNtTK91euHaYNZOLGp18EzoOH1u3Hs/lJBQe
sYGpjX24zGtLA/ECDNyrpUAkAH90lKGdCCmziAv1h3edVc3kw37XamSrhRSGlVuX
MlBvPci6Zgzj/L24ScF2iUkZ/cCovYmjZy/Gn7xxGWC4LeksyZB2ZnuU4q941mVT
XTzWnLLPKQP5L6RQstRIzgUyVYr9smRMDuSYB3Xbf9+5CFVghTAp+XtIpGmG4zU/
HoZdenoVve8AjhUiVBcAkCaTvA5JaJG/+EfTnZVCwQ5N328mz8MYIWJmQ3DW1cAH
4QIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV
HQ4EFgQUVeSB0RGAvtiJuQijMfmhJAkWuXAwDQYJKoZIhvcNAQEFBQADggEBADub
j1abMOdTmXx6eadNl9cZlZD7Bh/KM3xGY4+WZiT6QBshJ8rmcnPyT/4xmf3IDExo
U8aAghOY+rat2l098c5u9hURlIIM7j+VrxGrD9cv3h8Dj1csHsm7mhpElesYT6Yf
zX1XEC+bBAlahLVu2B064dae0Wx5XnkcFMXj0EyTO2U87d89vqbllRrDtRnDvV5b
u/8j72gZyxKTJ1wDLW8w0B62GqzeWvfRqqgnpv55gcR5mTNXuhKwqeBCbJPKVt7+
bYQLCIt+jerXmCHG8+c8eS9enNFMFY3h7CI3zJpDC5fcgJCNs2ebb0gIFVbPv/Er
fF6adulZkMV8gzURZVE=
-----END CERTIFICATE-----
# Issuer: CN=Baltimore CyberTrust Root O=Baltimore OU=CyberTrust
# Subject: CN=Baltimore CyberTrust Root O=Baltimore OU=CyberTrust
# Label: "Baltimore CyberTrust Root"
# Serial: 33554617
# MD5 Fingerprint: ac:b6:94:a5:9c:17:e0:d7:91:52:9b:b1:97:06:a6:e4
# SHA1 Fingerprint: d4:de:20:d0:5e:66:fc:53:fe:1a:50:88:2c:78:db:28:52:ca:e4:74
# SHA256 Fingerprint: 16:af:57:a9:f6:76:b0:ab:12:60:95:aa:5e:ba:de:f2:2a:b3:11:19:d6:44:ac:95:cd:4b:93:db:f3:f2:6a:eb
-----BEGIN CERTIFICATE-----
MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJ
RTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYD
VQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoX
DTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMCSUUxEjAQBgNVBAoTCUJhbHRpbW9y
ZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFsdGltb3JlIEN5YmVy
VHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKMEuyKr
mD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjr
IZ3AQSsBUnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeK
mpYcqWe4PwzV9/lSEy/CG9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSu
XmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9XbIGevOF6uvUA65ehD5f/xXtabz5OTZy
dc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjprl3RjM71oGDHweI12v/ye
jl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoIVDaGezq1
BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3
DQEBBQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT92
9hkTI7gQCvlYpNRhcL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3Wgx
jkzSswF07r51XgdIGn9w/xZchMB5hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0
Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsaY71k5h+3zvDyny67G7fyUIhz
ksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9HRCwBXbsdtTLS
R9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp
-----END CERTIFICATE-----
# Issuer: CN=Entrust Root Certification Authority O=Entrust, Inc. OU=www.entrust.net/CPS is incorporated by reference/(c) 2006 Entrust, Inc. # Issuer: CN=Entrust Root Certification Authority O=Entrust, Inc. OU=www.entrust.net/CPS is incorporated by reference/(c) 2006 Entrust, Inc.
# Subject: CN=Entrust Root Certification Authority O=Entrust, Inc. OU=www.entrust.net/CPS is incorporated by reference/(c) 2006 Entrust, Inc. # Subject: CN=Entrust Root Certification Authority O=Entrust, Inc. OU=www.entrust.net/CPS is incorporated by reference/(c) 2006 Entrust, Inc.
# Label: "Entrust Root Certification Authority" # Label: "Entrust Root Certification Authority"
@ -125,39 +34,6 @@ eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0tHuu2guQOHXvgR1m
0vdXcDazv/wor3ElhVsT/h5/WrQ8 0vdXcDazv/wor3ElhVsT/h5/WrQ8
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=AAA Certificate Services O=Comodo CA Limited
# Subject: CN=AAA Certificate Services O=Comodo CA Limited
# Label: "Comodo AAA Services root"
# Serial: 1
# MD5 Fingerprint: 49:79:04:b0:eb:87:19:ac:47:b0:bc:11:51:9b:74:d0
# SHA1 Fingerprint: d1:eb:23:a4:6d:17:d6:8f:d9:25:64:c2:f1:f1:60:17:64:d8:e3:49
# SHA256 Fingerprint: d7:a7:a0:fb:5d:7e:27:31:d7:71:e9:48:4e:bc:de:f7:1d:5f:0c:3e:0a:29:48:78:2b:c8:3e:e0:ea:69:9e:f4
-----BEGIN CERTIFICATE-----
MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
-----END CERTIFICATE-----
# Issuer: CN=QuoVadis Root CA 2 O=QuoVadis Limited # Issuer: CN=QuoVadis Root CA 2 O=QuoVadis Limited
# Subject: CN=QuoVadis Root CA 2 O=QuoVadis Limited # Subject: CN=QuoVadis Root CA 2 O=QuoVadis Limited
# Label: "QuoVadis Root CA 2" # Label: "QuoVadis Root CA 2"
@ -245,103 +121,6 @@ mJlglFwjz1onl14LBQaTNx47aTbrqZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK
4SVhM7JZG+Ju1zdXtg2pEto= 4SVhM7JZG+Ju1zdXtg2pEto=
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=XRamp Global Certification Authority O=XRamp Security Services Inc OU=www.xrampsecurity.com
# Subject: CN=XRamp Global Certification Authority O=XRamp Security Services Inc OU=www.xrampsecurity.com
# Label: "XRamp Global CA Root"
# Serial: 107108908803651509692980124233745014957
# MD5 Fingerprint: a1:0b:44:b3:ca:10:d8:00:6e:9d:0f:d8:0f:92:0a:d1
# SHA1 Fingerprint: b8:01:86:d1:eb:9c:86:a5:41:04:cf:30:54:f3:4c:52:b7:e5:58:c6
# SHA256 Fingerprint: ce:cd:dc:90:50:99:d8:da:df:c5:b1:d2:09:b7:37:cb:e2:c1:8c:fb:2c:10:c0:ff:0b:cf:0d:32:86:fc:1a:a2
-----BEGIN CERTIFICATE-----
MIIEMDCCAxigAwIBAgIQUJRs7Bjq1ZxN1ZfvdY+grTANBgkqhkiG9w0BAQUFADCB
gjELMAkGA1UEBhMCVVMxHjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEk
MCIGA1UEChMbWFJhbXAgU2VjdXJpdHkgU2VydmljZXMgSW5jMS0wKwYDVQQDEyRY
UmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQxMTAxMTcx
NDA0WhcNMzUwMTAxMDUzNzE5WjCBgjELMAkGA1UEBhMCVVMxHjAcBgNVBAsTFXd3
dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2VjdXJpdHkgU2Vy
dmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBB
dXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYJB69FbS6
38eMpSe2OAtp87ZOqCwuIR1cRN8hXX4jdP5efrRKt6atH67gBhbim1vZZ3RrXYCP
KZ2GG9mcDZhtdhAoWORlsH9KmHmf4MMxfoArtYzAQDsRhtDLooY2YKTVMIJt2W7Q
DxIEM5dfT2Fa8OT5kavnHTu86M/0ay00fOJIYRyO82FEzG+gSqmUsE3a56k0enI4
qEHMPJQRfevIpoy3hsvKMzvZPTeL+3o+hiznc9cKV6xkmxnr9A8ECIqsAxcZZPRa
JSKNNCyy9mgdEm3Tih4U2sSPpuIjhdV6Db1q4Ons7Be7QhtnqiXtRYMh/MHJfNVi
PvryxS3T/dRlAgMBAAGjgZ8wgZwwEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0P
BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMZPoj0GY4QJnM5i5ASs
jVy16bYbMDYGA1UdHwQvMC0wK6ApoCeGJWh0dHA6Ly9jcmwueHJhbXBzZWN1cml0
eS5jb20vWEdDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQEwDQYJKoZIhvcNAQEFBQAD
ggEBAJEVOQMBG2f7Shz5CmBbodpNl2L5JFMn14JkTpAuw0kbK5rc/Kh4ZzXxHfAR
vbdI4xD2Dd8/0sm2qlWkSLoC295ZLhVbO50WfUfXN+pfTXYSNrsf16GBBEYgoyxt
qZ4Bfj8pzgCT3/3JknOJiWSe5yvkHJEs0rnOfc5vMZnT5r7SHpDwCRR5XCOrTdLa
IR9NmXmd4c8nnxCbHIgNsIpkQTG4DmyQJKSbXHGPurt+HBvbaoAPIbzp26a3QPSy
i6mx5O+aGtA9aZnuqCij4Tyz8LIRnM98QObd50N9otg6tamN8jSZxNQQ4Qb9CYQQ
O+7ETPTsJ3xCwnR8gooJybQDJbw=
-----END CERTIFICATE-----
# Issuer: O=The Go Daddy Group, Inc. OU=Go Daddy Class 2 Certification Authority
# Subject: O=The Go Daddy Group, Inc. OU=Go Daddy Class 2 Certification Authority
# Label: "Go Daddy Class 2 CA"
# Serial: 0
# MD5 Fingerprint: 91:de:06:25:ab:da:fd:32:17:0c:bb:25:17:2a:84:67
# SHA1 Fingerprint: 27:96:ba:e6:3f:18:01:e2:77:26:1b:a0:d7:77:70:02:8f:20:ee:e4
# SHA256 Fingerprint: c3:84:6b:f2:4b:9e:93:ca:64:27:4c:0e:c6:7c:1e:cc:5e:02:4f:fc:ac:d2:d7:40:19:35:0e:81:fe:54:6a:e4
-----BEGIN CERTIFICATE-----
MIIEADCCAuigAwIBAgIBADANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEh
MB8GA1UEChMYVGhlIEdvIERhZGR5IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBE
YWRkeSBDbGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA0MDYyOTE3
MDYyMFoXDTM0MDYyOTE3MDYyMFowYzELMAkGA1UEBhMCVVMxITAfBgNVBAoTGFRo
ZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28gRGFkZHkgQ2xhc3Mg
MiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQADggEN
ADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XCA
PVYYYwhv2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux6w
wdhFJ2+qN1j3hybX2C32qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLOtXi
EqITLdiOr18SPaAIBQi2XKVlOARFmR6jYGB0xUGlcmIbYsUfb18aQr4CUWWoriMY
avx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmYvLEHZ6IVDd2gWMZEewo+
YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjgcAwgb0wHQYDVR0OBBYEFNLE
sNKR1EwRcbNhyz2h/t2oatTjMIGNBgNVHSMEgYUwgYKAFNLEsNKR1EwRcbNhyz2h
/t2oatTjoWekZTBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYVGhlIEdvIERhZGR5
IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRpZmlj
YXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD
ggEBADJL87LKPpH8EsahB4yOd6AzBhRckB4Y9wimPQoZ+YeAEW5p5JYXMP80kWNy
OO7MHAGjHZQopDH2esRU1/blMVgDoszOYtuURXO1v0XJJLXVggKtI3lpjbi2Tc7P
TMozI+gciKqdi0FuFskg5YmezTvacPd+mSYgFFQlq25zheabIZ0KbIIOqPjCDPoQ
HmyW74cNxA9hi63ugyuV+I6ShHI56yDqg+2DzZduCLzrTia2cyvk0/ZM/iZx4mER
dEr/VxqHD3VILs9RaRegAhJhldXRQLIQTO7ErBBDpqWeCtWVYpoNz4iCxTIM5Cuf
ReYNnyicsbkqWletNw+vHX/bvZ8=
-----END CERTIFICATE-----
# Issuer: O=Starfield Technologies, Inc. OU=Starfield Class 2 Certification Authority
# Subject: O=Starfield Technologies, Inc. OU=Starfield Class 2 Certification Authority
# Label: "Starfield Class 2 CA"
# Serial: 0
# MD5 Fingerprint: 32:4a:4b:bb:c8:63:69:9b:be:74:9a:c6:dd:1d:46:24
# SHA1 Fingerprint: ad:7e:1c:28:b0:64:ef:8f:60:03:40:20:14:c3:d0:e3:37:0e:b5:8a
# SHA256 Fingerprint: 14:65:fa:20:53:97:b8:76:fa:a6:f0:a9:95:8e:55:90:e4:0f:cc:7f:aa:4f:b7:c2:c8:67:75:21:fb:5f:b6:58
-----BEGIN CERTIFICATE-----
MIIEDzCCAvegAwIBAgIBADANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJVUzEl
MCMGA1UEChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMp
U3RhcmZpZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQw
NjI5MTczOTE2WhcNMzQwNjI5MTczOTE2WjBoMQswCQYDVQQGEwJVUzElMCMGA1UE
ChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMpU3RhcmZp
ZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEgMA0GCSqGSIb3
DQEBAQUAA4IBDQAwggEIAoIBAQC3Msj+6XGmBIWtDBFk385N78gDGIc/oav7PKaf
8MOh2tTYbitTkPskpD6E8J7oX+zlJ0T1KKY/e97gKvDIr1MvnsoFAZMej2YcOadN
+lq2cwQlZut3f+dZxkqZJRRU6ybH838Z1TBwj6+wRir/resp7defqgSHo9T5iaU0
X9tDkYI22WY8sbi5gv2cOj4QyDvvBmVmepsZGD3/cVE8MC5fvj13c7JdBmzDI1aa
K4UmkhynArPkPw2vCHmCuDY96pzTNbO8acr1zJ3o/WSNF4Azbl5KXZnJHoe0nRrA
1W4TNSNe35tfPe/W93bC6j67eA0cQmdrBNj41tpvi/JEoAGrAgEDo4HFMIHCMB0G
A1UdDgQWBBS/X7fRzt0fhvRbVazc1xDCDqmI5zCBkgYDVR0jBIGKMIGHgBS/X7fR
zt0fhvRbVazc1xDCDqmI56FspGowaDELMAkGA1UEBhMCVVMxJTAjBgNVBAoTHFN0
YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAsTKVN0YXJmaWVsZCBD
bGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8w
DQYJKoZIhvcNAQEFBQADggEBAAWdP4id0ckaVaGsafPzWdqbAYcaT1epoXkJKtv3
L7IezMdeatiDh6GX70k1PncGQVhiv45YuApnP+yz3SFmH8lU+nLMPUxA2IGvd56D
eruix/U0F47ZEUD0/CwqTRV/p2JdLiXTAAsgGh1o+Re49L2L7ShZ3U0WixeDyLJl
xy16paq8U4Zt3VekyvggQQto8PT7dL5WXXp59fkdheMtlb71cZBDzI0fmgAKhynp
VSJYACPq4xJDKVtHCN2MQWplBqjlIapBtJUhlbl90TSrE9atvNziPTnNvT51cKEY
WQPJIrSPnNVeKtelttQKbfi3QBFGmh95DmK/D5fs4C8fF5Q=
-----END CERTIFICATE-----
# Issuer: CN=DigiCert Assured ID Root CA O=DigiCert Inc OU=www.digicert.com # Issuer: CN=DigiCert Assured ID Root CA O=DigiCert Inc OU=www.digicert.com
# Subject: CN=DigiCert Assured ID Root CA O=DigiCert Inc OU=www.digicert.com # Subject: CN=DigiCert Assured ID Root CA O=DigiCert Inc OU=www.digicert.com
# Label: "DigiCert Assured ID Root CA" # Label: "DigiCert Assured ID Root CA"
@ -474,47 +253,6 @@ ZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6LqjviOvrv1vA+ACOzB2+htt
Qc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ Qc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=SwissSign Silver CA - G2 O=SwissSign AG
# Subject: CN=SwissSign Silver CA - G2 O=SwissSign AG
# Label: "SwissSign Silver CA - G2"
# Serial: 5700383053117599563
# MD5 Fingerprint: e0:06:a1:c9:7d:cf:c9:fc:0d:c0:56:75:96:d8:62:13
# SHA1 Fingerprint: 9b:aa:e5:9f:56:ee:21:cb:43:5a:be:25:93:df:a7:f0:40:d1:1d:cb
# SHA256 Fingerprint: be:6c:4d:a2:bb:b9:ba:59:b6:f3:93:97:68:37:42:46:c3:c0:05:99:3f:a9:8f:02:0d:1d:ed:be:d4:8a:81:d5
-----BEGIN CERTIFICATE-----
MIIFvTCCA6WgAwIBAgIITxvUL1S7L0swDQYJKoZIhvcNAQEFBQAwRzELMAkGA1UE
BhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzEhMB8GA1UEAxMYU3dpc3NTaWdu
IFNpbHZlciBDQSAtIEcyMB4XDTA2MTAyNTA4MzI0NloXDTM2MTAyNTA4MzI0Nlow
RzELMAkGA1UEBhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzEhMB8GA1UEAxMY
U3dpc3NTaWduIFNpbHZlciBDQSAtIEcyMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
MIICCgKCAgEAxPGHf9N4Mfc4yfjDmUO8x/e8N+dOcbpLj6VzHVxumK4DV644N0Mv
Fz0fyM5oEMF4rhkDKxD6LHmD9ui5aLlV8gREpzn5/ASLHvGiTSf5YXu6t+WiE7br
YT7QbNHm+/pe7R20nqA1W6GSy/BJkv6FCgU+5tkL4k+73JU3/JHpMjUi0R86TieF
nbAVlDLaYQ1HTWBCrpJH6INaUFjpiou5XaHc3ZlKHzZnu0jkg7Y360g6rw9njxcH
6ATK72oxh9TAtvmUcXtnZLi2kUpCe2UuMGoM9ZDulebyzYLs2aFK7PayS+VFheZt
eJMELpyCbTapxDFkH4aDCyr0NQp4yVXPQbBH6TCfmb5hqAaEuSh6XzjZG6k4sIN/
c8HDO0gqgg8hm7jMqDXDhBuDsz6+pJVpATqJAHgE2cn0mRmrVn5bi4Y5FZGkECwJ
MoBgs5PAKrYYC51+jUnyEEp/+dVGLxmSo5mnJqy7jDzmDrxHB9xzUfFwZC8I+bRH
HTBsROopN4WSaGa8gzj+ezku01DwH/teYLappvonQfGbGHLy9YR0SslnxFSuSGTf
jNFusB3hB48IHpmccelM2KX3RxIfdNFRnobzwqIjQAtz20um53MGjMGg6cFZrEb6
5i/4z3GcRm25xBWNOHkDRUjvxF3XCO6HOSKGsg0PWEP3calILv3q1h8CAwEAAaOB
rDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
F6DNweRBtjpbO8tFnb0cwpj6hlgwHwYDVR0jBBgwFoAUF6DNweRBtjpbO8tFnb0c
wpj6hlgwRgYDVR0gBD8wPTA7BglghXQBWQEDAQEwLjAsBggrBgEFBQcCARYgaHR0
cDovL3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIB
AHPGgeAn0i0P4JUw4ppBf1AsX19iYamGamkYDHRJ1l2E6kFSGG9YrVBWIGrGvShp
WJHckRE1qTodvBqlYJ7YH39FkWnZfrt4csEGDyrOj4VwYaygzQu4OSlWhDJOhrs9
xCrZ1x9y7v5RoSJBsXECYxqCsGKrXlcSH9/L3XWgwF15kIwb4FDm3jH+mHtwX6WQ
2K34ArZv02DdQEsixT2tOnqfGhpHkXkzuoLcMmkDlm4fS/Bx/uNncqCxv1yL5PqZ
IseEuRuNI5c/7SXgz2W79WEE790eslpBIlqhn10s6FvJbakMDHiqYMZWjwFaDGi8
aRl5xB9+lwW/xekkUV7U1UtT7dkjWjYDZaPBA61BMPNGG4WQr2W11bHkFlt4dR2X
em1ZqSqPe97Dh4kQmUlzeMg9vVE1dCrV8X5pGyq7O70luJpaPXJhkGaH7gzWTdQR
dAtq/gsD/KNVV4n+SsuuWxcFyPKNIzFTONItaj+CuY0IavdeQXRuwxF+B6wpYJE/
OMpXEA29MC/HpeZBoNquBYeaoKRlbEwJDIm6uNO5wJOKMPqN5ZprFQFOZ6raYlY+
hAhm0sQ2fac+EPyI4NSA5QC9qvNOBqN6avlicuMJT+ubDgEj8Z+7fNzcbBGXJbLy
tGMU0gYqZ4yD9c7qB9iaah7s5Aq7KkzrCWA5zspi2C5u
-----END CERTIFICATE-----
# Issuer: CN=SecureTrust CA O=SecureTrust Corporation # Issuer: CN=SecureTrust CA O=SecureTrust Corporation
# Subject: CN=SecureTrust CA O=SecureTrust Corporation # Subject: CN=SecureTrust CA O=SecureTrust Corporation
# Label: "SecureTrust CA" # Label: "SecureTrust CA"
@ -763,35 +501,6 @@ uLjbvrW5KfnaNwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2
XjG4Kvte9nHfRCaexOYNkbQudZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E= XjG4Kvte9nHfRCaexOYNkbQudZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E=
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=SecureSign RootCA11 O=Japan Certification Services, Inc.
# Subject: CN=SecureSign RootCA11 O=Japan Certification Services, Inc.
# Label: "SecureSign RootCA11"
# Serial: 1
# MD5 Fingerprint: b7:52:74:e2:92:b4:80:93:f2:75:e4:cc:d7:f2:ea:26
# SHA1 Fingerprint: 3b:c4:9f:48:f8:f3:73:a0:9c:1e:bd:f8:5b:b1:c3:65:c7:d8:11:b3
# SHA256 Fingerprint: bf:0f:ee:fb:9e:3a:58:1a:d5:f9:e9:db:75:89:98:57:43:d2:61:08:5c:4d:31:4f:6f:5d:72:59:aa:42:16:12
-----BEGIN CERTIFICATE-----
MIIDbTCCAlWgAwIBAgIBATANBgkqhkiG9w0BAQUFADBYMQswCQYDVQQGEwJKUDEr
MCkGA1UEChMiSmFwYW4gQ2VydGlmaWNhdGlvbiBTZXJ2aWNlcywgSW5jLjEcMBoG
A1UEAxMTU2VjdXJlU2lnbiBSb290Q0ExMTAeFw0wOTA0MDgwNDU2NDdaFw0yOTA0
MDgwNDU2NDdaMFgxCzAJBgNVBAYTAkpQMSswKQYDVQQKEyJKYXBhbiBDZXJ0aWZp
Y2F0aW9uIFNlcnZpY2VzLCBJbmMuMRwwGgYDVQQDExNTZWN1cmVTaWduIFJvb3RD
QTExMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA/XeqpRyQBTvLTJsz
i1oURaTnkBbR31fSIRCkF/3frNYfp+TbfPfs37gD2pRY/V1yfIw/XwFndBWW4wI8
h9uuywGOwvNmxoVF9ALGOrVisq/6nL+k5tSAMJjzDbaTj6nU2DbysPyKyiyhFTOV
MdrAG/LuYpmGYz+/3ZMqg6h2uRMft85OQoWPIucuGvKVCbIFtUROd6EgvanyTgp9
UK31BQ1FT0Zx/Sg+U/sE2C3XZR1KG/rPO7AxmjVuyIsG0wCR8pQIZUyxNAYAeoni
8McDWc/V1uinMrPmmECGxc0nEovMe863ETxiYAcjPitAbpSACW22s293bzUIUPsC
h8U+iQIDAQABo0IwQDAdBgNVHQ4EFgQUW/hNT7KlhtQ60vFjmqC+CfZXt94wDgYD
VR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEB
AKChOBZmLqdWHyGcBvod7bkixTgm2E5P7KN/ed5GIaGHd48HCJqypMWvDzKYC3xm
KbabfSVSSUOrTC4rbnpwrxYO4wJs+0LmGJ1F2FXI6Dvd5+H0LgscNFxsWEr7jIhQ
X5Ucv+2rIrVls4W6ng+4reV6G4pQOh29Dbx7VFALuUKvVaAYga1lme++5Jy/xIWr
QbJUb9wlze144o4MjQlJ3WN7WmmWAiGovVJZ6X01y8hSyn+B/tlr0/cR7SXf+Of5
pPpyl4RTDaXQMhhRdlkUbA/r7F+AjHVDg8OFmP9Mni0N5HeDk061lgeLKBObjBmN
QSdJQO7e5iNEOdyhIta6A/I=
-----END CERTIFICATE-----
# Issuer: CN=Microsec e-Szigno Root CA 2009 O=Microsec Ltd. # Issuer: CN=Microsec e-Szigno Root CA 2009 O=Microsec Ltd.
# Subject: CN=Microsec e-Szigno Root CA 2009 O=Microsec Ltd. # Subject: CN=Microsec e-Szigno Root CA 2009 O=Microsec Ltd.
# Label: "Microsec e-Szigno Root CA 2009" # Label: "Microsec e-Szigno Root CA 2009"
@ -3100,50 +2809,6 @@ LJstxabArahH9CdMOA0uG0k7UvToiIMrVCjU8jVStDKDYmlkDJGcn5fqdBb9HxEG
mpv0 mpv0
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=Entrust Root Certification Authority - G4 O=Entrust, Inc. OU=See www.entrust.net/legal-terms/(c) 2015 Entrust, Inc. - for authorized use only
# Subject: CN=Entrust Root Certification Authority - G4 O=Entrust, Inc. OU=See www.entrust.net/legal-terms/(c) 2015 Entrust, Inc. - for authorized use only
# Label: "Entrust Root Certification Authority - G4"
# Serial: 289383649854506086828220374796556676440
# MD5 Fingerprint: 89:53:f1:83:23:b7:7c:8e:05:f1:8c:71:38:4e:1f:88
# SHA1 Fingerprint: 14:88:4e:86:26:37:b0:26:af:59:62:5c:40:77:ec:35:29:ba:96:01
# SHA256 Fingerprint: db:35:17:d1:f6:73:2a:2d:5a:b9:7c:53:3e:c7:07:79:ee:32:70:a6:2f:b4:ac:42:38:37:24:60:e6:f0:1e:88
-----BEGIN CERTIFICATE-----
MIIGSzCCBDOgAwIBAgIRANm1Q3+vqTkPAAAAAFVlrVgwDQYJKoZIhvcNAQELBQAw
gb4xCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQL
Ex9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykg
MjAxNSBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxMjAw
BgNVBAMTKUVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEc0
MB4XDTE1MDUyNzExMTExNloXDTM3MTIyNzExNDExNlowgb4xCzAJBgNVBAYTAlVT
MRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1
c3QubmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxNSBFbnRydXN0LCBJ
bmMuIC0gZm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxMjAwBgNVBAMTKUVudHJ1c3Qg
Um9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEc0MIICIjANBgkqhkiG9w0B
AQEFAAOCAg8AMIICCgKCAgEAsewsQu7i0TD/pZJH4i3DumSXbcr3DbVZwbPLqGgZ
2K+EbTBwXX7zLtJTmeH+H17ZSK9dE43b/2MzTdMAArzE+NEGCJR5WIoV3imz/f3E
T+iq4qA7ec2/a0My3dl0ELn39GjUu9CH1apLiipvKgS1sqbHoHrmSKvS0VnM1n4j
5pds8ELl3FFLFUHtSUrJ3hCX1nbB76W1NhSXNdh4IjVS70O92yfbYVaCNNzLiGAM
C1rlLAHGVK/XqsEQe9IFWrhAnoanw5CGAlZSCXqc0ieCU0plUmr1POeo8pyvi73T
DtTUXm6Hnmo9RR3RXRv06QqsYJn7ibT/mCzPfB3pAqoEmh643IhuJbNsZvc8kPNX
wbMv9W3y+8qh+CmdRouzavbmZwe+LGcKKh9asj5XxNMhIWNlUpEbsZmOeX7m640A
2Vqq6nPopIICR5b+W45UYaPrL0swsIsjdXJ8ITzI9vF01Bx7owVV7rtNOzK+mndm
nqxpkCIHH2E6lr7lmk/MBTwoWdPBDFSoWWG9yHJM6Nyfh3+9nEg2XpWjDrk4JFX8
dWbrAuMINClKxuMrLzOg2qOGpRKX/YAr2hRC45K9PvJdXmd0LhyIRyk0X+IyqJwl
N4y6mACXi0mWHv0liqzc2thddG5msP9E36EYxr5ILzeUePiVSj9/E15dWf10hkNj
c0kCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD
VR0OBBYEFJ84xFYjwznooHFs6FRM5Og6sb9nMA0GCSqGSIb3DQEBCwUAA4ICAQAS
5UKme4sPDORGpbZgQIeMJX6tuGguW8ZAdjwD+MlZ9POrYs4QjbRaZIxowLByQzTS
Gwv2LFPSypBLhmb8qoMi9IsabyZIrHZ3CL/FmFz0Jomee8O5ZDIBf9PD3Vht7LGr
hFV0d4QEJ1JrhkzO3bll/9bGXp+aEJlLdWr+aumXIOTkdnrG0CSqkM0gkLpHZPt/
B7NTeLUKYvJzQ85BK4FqLoUWlFPUa19yIqtRLULVAJyZv967lDtX/Zr1hstWO1uI
AeV8KEsD+UmDfLJ/fOPtjqF/YFOOVZ1QNBIPt5d7bIdKROf1beyAN/BYGW5KaHbw
H5Lk6rWS02FREAutp9lfx1/cH6NcjKF+m7ee01ZvZl4HliDtC3T7Zk6LERXpgUl+
b7DUUH8i119lAg2m9IUe2K4GS0qn0jFmwvjO5QimpAKWRGhXxNUzzxkvFMSUHHuk
2fCfDrGA4tGeEWSpiBE6doLlYsKA2KSD7ZPvfC+QsDJMlhVoSFLUmQjAJOgc47Ol
IQ6SwJAfzyBfyjs4x7dtOvPmRLgOMWuIjnDrnBdSqEGULoe256YSxXXfW8AKbnuk
5F6G+TaU33fD6Q3AOfF5u0aOq0NZJ7cguyPpVkAh7DE9ZapD8j3fcEThuk0mEDuY
n/PIjhs4ViFqUZPTkcpG2om3PVODLAgfi49T3f+sHw==
-----END CERTIFICATE-----
# Issuer: CN=Microsoft ECC Root Certificate Authority 2017 O=Microsoft Corporation # Issuer: CN=Microsoft ECC Root Certificate Authority 2017 O=Microsoft Corporation
# Subject: CN=Microsoft ECC Root Certificate Authority 2017 O=Microsoft Corporation # Subject: CN=Microsoft ECC Root Certificate Authority 2017 O=Microsoft Corporation
# Label: "Microsoft ECC Root Certificate Authority 2017" # Label: "Microsoft ECC Root Certificate Authority 2017"
@ -3485,6 +3150,46 @@ DgQWBBQxCpCPtsad0kRLgLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ
+RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A= +RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A=
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=GLOBALTRUST 2020 O=e-commerce monitoring GmbH
# Subject: CN=GLOBALTRUST 2020 O=e-commerce monitoring GmbH
# Label: "GLOBALTRUST 2020"
# Serial: 109160994242082918454945253
# MD5 Fingerprint: 8a:c7:6f:cb:6d:e3:cc:a2:f1:7c:83:fa:0e:78:d7:e8
# SHA1 Fingerprint: d0:67:c1:13:51:01:0c:aa:d0:c7:6a:65:37:31:16:26:4f:53:71:a2
# SHA256 Fingerprint: 9a:29:6a:51:82:d1:d4:51:a2:e3:7f:43:9b:74:da:af:a2:67:52:33:29:f9:0f:9a:0d:20:07:c3:34:e2:3c:9a
-----BEGIN CERTIFICATE-----
MIIFgjCCA2qgAwIBAgILWku9WvtPilv6ZeUwDQYJKoZIhvcNAQELBQAwTTELMAkG
A1UEBhMCQVQxIzAhBgNVBAoTGmUtY29tbWVyY2UgbW9uaXRvcmluZyBHbWJIMRkw
FwYDVQQDExBHTE9CQUxUUlVTVCAyMDIwMB4XDTIwMDIxMDAwMDAwMFoXDTQwMDYx
MDAwMDAwMFowTTELMAkGA1UEBhMCQVQxIzAhBgNVBAoTGmUtY29tbWVyY2UgbW9u
aXRvcmluZyBHbWJIMRkwFwYDVQQDExBHTE9CQUxUUlVTVCAyMDIwMIICIjANBgkq
hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAri5WrRsc7/aVj6B3GyvTY4+ETUWiD59b
RatZe1E0+eyLinjF3WuvvcTfk0Uev5E4C64OFudBc/jbu9G4UeDLgztzOG53ig9Z
YybNpyrOVPu44sB8R85gfD+yc/LAGbaKkoc1DZAoouQVBGM+uq/ufF7MpotQsjj3
QWPKzv9pj2gOlTblzLmMCcpL3TGQlsjMH/1WljTbjhzqLL6FLmPdqqmV0/0plRPw
yJiT2S0WR5ARg6I6IqIoV6Lr/sCMKKCmfecqQjuCgGOlYx8ZzHyyZqjC0203b+J+
BlHZRYQfEs4kUmSFC0iAToexIiIwquuuvuAC4EDosEKAA1GqtH6qRNdDYfOiaxaJ
SaSjpCuKAsR49GiKweR6NrFvG5Ybd0mN1MkGco/PU+PcF4UgStyYJ9ORJitHHmkH
r96i5OTUawuzXnzUJIBHKWk7buis/UDr2O1xcSvy6Fgd60GXIsUf1DnQJ4+H4xj0
4KlGDfV0OoIu0G4skaMxXDtG6nsEEFZegB31pWXogvziB4xiRfUg3kZwhqG8k9Me
dKZssCz3AwyIDMvUclOGvGBG85hqwvG/Q/lwIHfKN0F5VVJjjVsSn8VoxIidrPIw
q7ejMZdnrY8XD2zHc+0klGvIg5rQmjdJBKuxFshsSUktq6HQjJLyQUp5ISXbY9e2
nKd+Qmn7OmMCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
AQYwHQYDVR0OBBYEFNwuH9FhN3nkq9XVsxJxaD1qaJwiMB8GA1UdIwQYMBaAFNwu
H9FhN3nkq9XVsxJxaD1qaJwiMA0GCSqGSIb3DQEBCwUAA4ICAQCR8EICaEDuw2jA
VC/f7GLDw56KoDEoqoOOpFaWEhCGVrqXctJUMHytGdUdaG/7FELYjQ7ztdGl4wJC
XtzoRlgHNQIw4Lx0SsFDKv/bGtCwr2zD/cuz9X9tAy5ZVp0tLTWMstZDFyySCstd
6IwPS3BD0IL/qMy/pJTAvoe9iuOTe8aPmxadJ2W8esVCgmxcB9CpwYhgROmYhRZf
+I/KARDOJcP5YBugxZfD0yyIMaK9MOzQ0MAS8cE54+X1+NZK3TTN+2/BT+MAi1bi
kvcoskJ3ciNnxz8RFbLEAwW+uxF7Cr+obuf/WEPPm2eggAe2HcqtbepBEX4tdJP7
wry+UUTF72glJ4DjyKDUEuzZpTcdN3y0kcra1LGWge9oXHYQSa9+pTeAsRxSvTOB
TI/53WXZFM2KJVj04sWDpQmQ1GwUY7VA3+vA/MRYfg0UFodUJ25W5HCEuGwyEn6C
MUO+1918oa2u1qsgEu8KwxCMSZY13At1XrFP1U80DhEgB3VDRemjEdqso5nCtnkn
4rnvyOL2NSl6dPrFf4IFYqYK6miyeUcGbvJXqBUzxvd4Sj1Ce2t+/vdG6tHrju+I
aFvowdlxfv1k7/9nR4hYJS8+hge9+6jlgqispdNpQ80xiEmEU5LAsTkbOYMBMMTy
qfrQA71yN2BWHzZ8vTmR9W0Nv3vXkg==
-----END CERTIFICATE-----
# Issuer: CN=ANF Secure Server Root CA O=ANF Autoridad de Certificacion OU=ANF CA Raiz # Issuer: CN=ANF Secure Server Root CA O=ANF Autoridad de Certificacion OU=ANF CA Raiz
# Subject: CN=ANF Secure Server Root CA O=ANF Autoridad de Certificacion OU=ANF CA Raiz # Subject: CN=ANF Secure Server Root CA O=ANF Autoridad de Certificacion OU=ANF CA Raiz
# Label: "ANF Secure Server Root CA" # Label: "ANF Secure Server Root CA"
@ -4214,46 +3919,6 @@ ut6Dacpps6kFtZaSF4fC0urQe87YQVt8rgIwRt7qy12a7DLCZRawTDBcMPPaTnOG
BtjOiQRINzf43TNRnXCve1XYAS59BWQOhriR BtjOiQRINzf43TNRnXCve1XYAS59BWQOhriR
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=Security Communication RootCA3 O=SECOM Trust Systems CO.,LTD.
# Subject: CN=Security Communication RootCA3 O=SECOM Trust Systems CO.,LTD.
# Label: "Security Communication RootCA3"
# Serial: 16247922307909811815
# MD5 Fingerprint: 1c:9a:16:ff:9e:5c:e0:4d:8a:14:01:f4:35:5d:29:26
# SHA1 Fingerprint: c3:03:c8:22:74:92:e5:61:a2:9c:5f:79:91:2b:1e:44:13:91:30:3a
# SHA256 Fingerprint: 24:a5:5c:2a:b0:51:44:2d:06:17:76:65:41:23:9a:4a:d0:32:d7:c5:51:75:aa:34:ff:de:2f:bc:4f:5c:52:94
-----BEGIN CERTIFICATE-----
MIIFfzCCA2egAwIBAgIJAOF8N0D9G/5nMA0GCSqGSIb3DQEBDAUAMF0xCzAJBgNV
BAYTAkpQMSUwIwYDVQQKExxTRUNPTSBUcnVzdCBTeXN0ZW1zIENPLixMVEQuMScw
JQYDVQQDEx5TZWN1cml0eSBDb21tdW5pY2F0aW9uIFJvb3RDQTMwHhcNMTYwNjE2
MDYxNzE2WhcNMzgwMTE4MDYxNzE2WjBdMQswCQYDVQQGEwJKUDElMCMGA1UEChMc
U0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UEAxMeU2VjdXJpdHkg
Q29tbXVuaWNhdGlvbiBSb290Q0EzMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC
CgKCAgEA48lySfcw3gl8qUCBWNO0Ot26YQ+TUG5pPDXC7ltzkBtnTCHsXzW7OT4r
CmDvu20rhvtxosis5FaU+cmvsXLUIKx00rgVrVH+hXShuRD+BYD5UpOzQD11EKzA
lrenfna84xtSGc4RHwsENPXY9Wk8d/Nk9A2qhd7gCVAEF5aEt8iKvE1y/By7z/MG
TfmfZPd+pmaGNXHIEYBMwXFAWB6+oHP2/D5Q4eAvJj1+XCO1eXDe+uDRpdYMQXF7
9+qMHIjH7Iv10S9VlkZ8WjtYO/u62C21Jdp6Ts9EriGmnpjKIG58u4iFW/vAEGK7
8vknR+/RiTlDxN/e4UG/VHMgly1s2vPUB6PmudhvrvyMGS7TZ2crldtYXLVqAvO4
g160a75BflcJdURQVc1aEWEhCmHCqYj9E7wtiS/NYeCVvsq1e+F7NGcLH7YMx3we
GVPKp7FKFSBWFHA9K4IsD50VHUeAR/94mQ4xr28+j+2GaR57GIgUssL8gjMunEst
+3A7caoreyYn8xrC3PsXuKHqy6C0rtOUfnrQq8PsOC0RLoi/1D+tEjtCrI8Cbn3M
0V9hvqG8OmpI6iZVIhZdXw3/JzOfGAN0iltSIEdrRU0id4xVJ/CvHozJgyJUt5rQ
T9nO/NkuHJYosQLTA70lUhw0Zk8jq/R3gpYd0VcwCBEF/VfR2ccCAwEAAaNCMEAw
HQYDVR0OBBYEFGQUfPxYchamCik0FW8qy7z8r6irMA4GA1UdDwEB/wQEAwIBBjAP
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBDAUAA4ICAQDcAiMI4u8hOscNtybS
YpOnpSNyByCCYN8Y11StaSWSntkUz5m5UoHPrmyKO1o5yGwBQ8IibQLwYs1OY0PA
FNr0Y/Dq9HHuTofjcan0yVflLl8cebsjqodEV+m9NU1Bu0soo5iyG9kLFwfl9+qd
9XbXv8S2gVj/yP9kaWJ5rW4OH3/uHWnlt3Jxs/6lATWUVCvAUm2PVcTJ0rjLyjQI
UYWg9by0F1jqClx6vWPGOi//lkkZhOpn2ASxYfQAW0q3nHE3GYV5v4GwxxMOdnE+
OoAGrgYWp421wsTL/0ClXI2lyTrtcoHKXJg80jQDdwj98ClZXSEIx2C/pHF7uNke
gr4Jr2VvKKu/S7XuPghHJ6APbw+LP6yVGPO5DtxnVW5inkYO0QR4ynKudtml+LLf
iAlhi+8kTtFZP1rUPcmTPCtk9YENFpb3ksP+MW/oKjJ0DvRMmEoYDjBU1cXrvMUV
nuiZIesnKwkK2/HmcBhWuwzkvvnoEKQTkrgc4NtnHVMDpCKn3F2SEDzq//wbEBrD
2NCcnWXL0CsnMQMeNuE9dnUM/0Umud1RvCPHX9jYhxBAEg09ODfnRDwYwFMJZI//
1ZqmfHAuc1Uh6N//g7kdPjIe1qZ9LPFm6Vwdp6POXiUyK+OVrCoHzrQoeIY8Laad
TdJ0MN1kURXbg4NR16/9M51NZg==
-----END CERTIFICATE-----
# Issuer: CN=Security Communication ECC RootCA1 O=SECOM Trust Systems CO.,LTD. # Issuer: CN=Security Communication ECC RootCA1 O=SECOM Trust Systems CO.,LTD.
# Subject: CN=Security Communication ECC RootCA1 O=SECOM Trust Systems CO.,LTD. # Subject: CN=Security Communication ECC RootCA1 O=SECOM Trust Systems CO.,LTD.
# Label: "Security Communication ECC RootCA1" # Label: "Security Communication ECC RootCA1"
@ -4927,3 +4592,85 @@ Af8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTrQciu/NWeUUj1vYv0hyCTQSvT
4P9mLQlO4E/0BdGF9jVg3PVys0Z9AjBEmEYagoUeYWmJSwdLZrWeqrqgHkHZAXQ6 4P9mLQlO4E/0BdGF9jVg3PVys0Z9AjBEmEYagoUeYWmJSwdLZrWeqrqgHkHZAXQ6
bkU6iYAZezKYVWOr62Nuk22rGwlgMU4= bkU6iYAZezKYVWOr62Nuk22rGwlgMU4=
-----END CERTIFICATE----- -----END CERTIFICATE-----
# Issuer: CN=D-TRUST BR Root CA 2 2023 O=D-Trust GmbH
# Subject: CN=D-TRUST BR Root CA 2 2023 O=D-Trust GmbH
# Label: "D-TRUST BR Root CA 2 2023"
# Serial: 153168538924886464690566649552453098598
# MD5 Fingerprint: e1:09:ed:d3:60:d4:56:1b:47:1f:b7:0c:5f:1b:5f:85
# SHA1 Fingerprint: 2d:b0:70:ee:71:94:af:69:68:17:db:79:ce:58:9f:a0:6b:96:f7:87
# SHA256 Fingerprint: 05:52:e6:f8:3f:df:65:e8:fa:96:70:e6:66:df:28:a4:e2:13:40:b5:10:cb:e5:25:66:f9:7c:4f:b9:4b:2b:d1
-----BEGIN CERTIFICATE-----
MIIFqTCCA5GgAwIBAgIQczswBEhb2U14LnNLyaHcZjANBgkqhkiG9w0BAQ0FADBI
MQswCQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlE
LVRSVVNUIEJSIFJvb3QgQ0EgMiAyMDIzMB4XDTIzMDUwOTA4NTYzMVoXDTM4MDUw
OTA4NTYzMFowSDELMAkGA1UEBhMCREUxFTATBgNVBAoTDEQtVHJ1c3QgR21iSDEi
MCAGA1UEAxMZRC1UUlVTVCBCUiBSb290IENBIDIgMjAyMzCCAiIwDQYJKoZIhvcN
AQEBBQADggIPADCCAgoCggIBAK7/CVmRgApKaOYkP7in5Mg6CjoWzckjYaCTcfKr
i3OPoGdlYNJUa2NRb0kz4HIHE304zQaSBylSa053bATTlfrdTIzZXcFhfUvnKLNE
gXtRr90zsWh81k5M/itoucpmacTsXld/9w3HnDY25QdgrMBM6ghs7wZ8T1soegj8
k12b9py0i4a6Ibn08OhZWiihNIQaJZG2tY/vsvmA+vk9PBFy2OMvhnbFeSzBqZCT
Rphny4NqoFAjpzv2gTng7fC5v2Xx2Mt6++9zA84A9H3X4F07ZrjcjrqDy4d2A/wl
2ecjbwb9Z/Pg/4S8R7+1FhhGaRTMBffb00msa8yr5LULQyReS2tNZ9/WtT5PeB+U
cSTq3nD88ZP+npNa5JRal1QMNXtfbO4AHyTsA7oC9Xb0n9Sa7YUsOCIvx9gvdhFP
/Wxc6PWOJ4d/GUohR5AdeY0cW/jPSoXk7bNbjb7EZChdQcRurDhaTyN0dKkSw/bS
uREVMweR2Ds3OmMwBtHFIjYoYiMQ4EbMl6zWK11kJNXuHA7e+whadSr2Y23OC0K+
0bpwHJwh5Q8xaRfX/Aq03u2AnMuStIv13lmiWAmlY0cL4UEyNEHZmrHZqLAbWt4N
DfTisl01gLmB1IRpkQLLddCNxbU9CZEJjxShFHR5PtbJFR2kWVki3PaKRT08EtY+
XTIvAgMBAAGjgY4wgYswDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUZ5Dw1t61
GNVGKX5cq/ieCLxklRAwDgYDVR0PAQH/BAQDAgEGMEkGA1UdHwRCMEAwPqA8oDqG
OGh0dHA6Ly9jcmwuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3RfYnJfcm9vdF9jYV8y
XzIwMjMuY3JsMA0GCSqGSIb3DQEBDQUAA4ICAQA097N3U9swFrktpSHxQCF16+tI
FoE9c+CeJyrrd6kTpGoKWloUMz1oH4Guaf2Mn2VsNELZLdB/eBaxOqwjMa1ef67n
riv6uvw8l5VAk1/DLQOj7aRvU9f6QA4w9QAgLABMjDu0ox+2v5Eyq6+SmNMW5tTR
VFxDWy6u71cqqLRvpO8NVhTaIasgdp4D/Ca4nj8+AybmTNudX0KEPUUDAxxZiMrc
LmEkWqTqJwtzEr5SswrPMhfiHocaFpVIbVrg0M8JkiZmkdijYQ6qgYF/6FKC0ULn
4B0Y+qSFNueG4A3rvNTJ1jxD8V1Jbn6Bm2m1iWKPiFLY1/4nwSPFyysCu7Ff/vtD
hQNGvl3GyiEm/9cCnnRK3PgTFbGBVzbLZVzRHTF36SXDw7IyN9XxmAnkbWOACKsG
koHU6XCPpz+y7YaMgmo1yEJagtFSGkUPFaUA8JR7ZSdXOUPPfH/mvTWze/EZTN46
ls/pdu4D58JDUjxqgejBWoC9EV2Ta/vH5mQ/u2kc6d0li690yVRAysuTEwrt+2aS
Ecr1wPrYg1UDfNPFIkZ1cGt5SAYqgpq/5usWDiJFAbzdNpQ0qTUmiteXue4Icr80
knCDgKs4qllo3UCkGJCy89UDyibK79XH4I9TjvAA46jtn/mtd+ArY0+ew+43u3gJ
hJ65bvspmZDogNOfJA==
-----END CERTIFICATE-----
# Issuer: CN=D-TRUST EV Root CA 2 2023 O=D-Trust GmbH
# Subject: CN=D-TRUST EV Root CA 2 2023 O=D-Trust GmbH
# Label: "D-TRUST EV Root CA 2 2023"
# Serial: 139766439402180512324132425437959641711
# MD5 Fingerprint: 96:b4:78:09:f0:09:cb:77:eb:bb:1b:4d:6f:36:bc:b6
# SHA1 Fingerprint: a5:5b:d8:47:6c:8f:19:f7:4c:f4:6d:6b:b6:c2:79:82:22:df:54:8b
# SHA256 Fingerprint: 8e:82:21:b2:e7:d4:00:78:36:a1:67:2f:0d:cc:29:9c:33:bc:07:d3:16:f1:32:fa:1a:20:6d:58:71:50:f1:ce
-----BEGIN CERTIFICATE-----
MIIFqTCCA5GgAwIBAgIQaSYJfoBLTKCnjHhiU19abzANBgkqhkiG9w0BAQ0FADBI
MQswCQYDVQQGEwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlE
LVRSVVNUIEVWIFJvb3QgQ0EgMiAyMDIzMB4XDTIzMDUwOTA5MTAzM1oXDTM4MDUw
OTA5MTAzMlowSDELMAkGA1UEBhMCREUxFTATBgNVBAoTDEQtVHJ1c3QgR21iSDEi
MCAGA1UEAxMZRC1UUlVTVCBFViBSb290IENBIDIgMjAyMzCCAiIwDQYJKoZIhvcN
AQEBBQADggIPADCCAgoCggIBANiOo4mAC7JXUtypU0w3uX9jFxPvp1sjW2l1sJkK
F8GLxNuo4MwxusLyzV3pt/gdr2rElYfXR8mV2IIEUD2BCP/kPbOx1sWy/YgJ25yE
7CUXFId/MHibaljJtnMoPDT3mfd/06b4HEV8rSyMlD/YZxBTfiLNTiVR8CUkNRFe
EMbsh2aJgWi6zCudR3Mfvc2RpHJqnKIbGKBv7FD0fUDCqDDPvXPIEysQEx6Lmqg6
lHPTGGkKSv/BAQP/eX+1SH977ugpbzZMlWGG2Pmic4ruri+W7mjNPU0oQvlFKzIb
RlUWaqZLKfm7lVa/Rh3sHZMdwGWyH6FDrlaeoLGPaxK3YG14C8qKXO0elg6DpkiV
jTujIcSuWMYAsoS0I6SWhjW42J7YrDRJmGOVxcttSEfi8i4YHtAxq9107PncjLgc
jmgjutDzUNzPZY9zOjLHfP7KgiJPvo5iR2blzYfi6NUPGJ/lBHJLRjwQ8kTCZFZx
TnXonMkmdMV9WdEKWw9t/p51HBjGGjp82A0EzM23RWV6sY+4roRIPrN6TagD4uJ+
ARZZaBhDM7DS3LAaQzXupdqpRlyuhoFBAUp0JuyfBr/CBTdkdXgpaP3F9ev+R/nk
hbDhezGdpn9yo7nELC7MmVcOIQxFAZRl62UJxmMiCzNJkkg8/M3OsD6Onov4/knF
NXJHAgMBAAGjgY4wgYswDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUqvyREBuH
kV8Wub9PS5FeAByxMoAwDgYDVR0PAQH/BAQDAgEGMEkGA1UdHwRCMEAwPqA8oDqG
OGh0dHA6Ly9jcmwuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3RfZXZfcm9vdF9jYV8y
XzIwMjMuY3JsMA0GCSqGSIb3DQEBDQUAA4ICAQCTy6UfmRHsmg1fLBWTxj++EI14
QvBukEdHjqOSMo1wj/Zbjb6JzkcBahsgIIlbyIIQbODnmaprxiqgYzWRaoUlrRc4
pZt+UPJ26oUFKidBK7GB0aL2QHWpDsvxVUjY7NHss+jOFKE17MJeNRqrphYBBo7q
3C+jisosketSjl8MmxfPy3MHGcRqwnNU73xDUmPBEcrCRbH0O1P1aa4846XerOhU
t7KR/aypH/KH5BfGSah82ApB9PI+53c0BFLd6IHyTS9URZ0V4U/M5d40VxDJI3IX
cI1QcB9WbMy5/zpaT2N6w25lBx2Eof+pDGOJbbJAiDnXH3dotfyc1dZnaVuodNv8
ifYbMvekJKZ2t0dT741Jj6m2g1qllpBFYfXeA08mD6iL8AOWsKwV0HFaanuU5nCT
2vFp4LJiTZ6P/4mdm13NRemUAiKN4DV/6PEEeXFsVIP4M7kFMhtYVRFP0OUnR3Hs
7dpn1mKmS00PaaLJvOwiS5THaJQXfuKOKD62xur1NGyfN4gHONuGcfrNlUhDbqNP
gofXNJhuS5N5YHVpD/Aa1VP6IQzCP+k/HxiMkl14p3ZnGbuy6n/pcAlWVqOwDAst
Nl7F6cTVg8uGF5csbBNvh1qvSaYd2804BC5f4ko1Di1L+KIkBI3Y4WNeApI02phh
XBxvWHZks/wCuPWdCg==
-----END CERTIFICATE-----

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
""" """
Charset-Normalizer Charset-Normalizer
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
@ -19,6 +18,9 @@ at <https://github.com/Ousret/charset_normalizer>.
:copyright: (c) 2021 by Ahmed TAHRI :copyright: (c) 2021 by Ahmed TAHRI
:license: MIT, see LICENSE for more details. :license: MIT, see LICENSE for more details.
""" """
from __future__ import annotations
import logging import logging
from .api import from_bytes, from_fp, from_path, is_binary from .api import from_bytes, from_fp, from_path, is_binary

View file

@ -1,3 +1,5 @@
from __future__ import annotations
from .cli import cli_detect from .cli import cli_detect
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import logging import logging
from os import PathLike from os import PathLike
from typing import BinaryIO, List, Optional, Set, Union from typing import BinaryIO
from .cd import ( from .cd import (
coherence_ratio, coherence_ratio,
@ -21,8 +23,6 @@ from .utils import (
should_strip_sig_or_bom, should_strip_sig_or_bom,
) )
# Will most likely be controversial
# logging.addLevelName(TRACE, "TRACE")
logger = logging.getLogger("charset_normalizer") logger = logging.getLogger("charset_normalizer")
explain_handler = logging.StreamHandler() explain_handler = logging.StreamHandler()
explain_handler.setFormatter( explain_handler.setFormatter(
@ -31,12 +31,12 @@ explain_handler.setFormatter(
def from_bytes( def from_bytes(
sequences: Union[bytes, bytearray], sequences: bytes | bytearray,
steps: int = 5, steps: int = 5,
chunk_size: int = 512, chunk_size: int = 512,
threshold: float = 0.2, threshold: float = 0.2,
cp_isolation: Optional[List[str]] = None, cp_isolation: list[str] | None = None,
cp_exclusion: Optional[List[str]] = None, cp_exclusion: list[str] | None = None,
preemptive_behaviour: bool = True, preemptive_behaviour: bool = True,
explain: bool = False, explain: bool = False,
language_threshold: float = 0.1, language_threshold: float = 0.1,
@ -62,7 +62,7 @@ def from_bytes(
if not isinstance(sequences, (bytearray, bytes)): if not isinstance(sequences, (bytearray, bytes)):
raise TypeError( raise TypeError(
"Expected object of type bytes or bytearray, got: {0}".format( "Expected object of type bytes or bytearray, got: {}".format(
type(sequences) type(sequences)
) )
) )
@ -76,7 +76,7 @@ def from_bytes(
if length == 0: if length == 0:
logger.debug("Encoding detection on empty bytes, assuming utf_8 intention.") logger.debug("Encoding detection on empty bytes, assuming utf_8 intention.")
if explain: if explain: # Defensive: ensure exit path clean handler
logger.removeHandler(explain_handler) logger.removeHandler(explain_handler)
logger.setLevel(previous_logger_level or logging.WARNING) logger.setLevel(previous_logger_level or logging.WARNING)
return CharsetMatches([CharsetMatch(sequences, "utf_8", 0.0, False, [], "")]) return CharsetMatches([CharsetMatch(sequences, "utf_8", 0.0, False, [], "")])
@ -135,9 +135,9 @@ def from_bytes(
), ),
) )
prioritized_encodings: List[str] = [] prioritized_encodings: list[str] = []
specified_encoding: Optional[str] = ( specified_encoding: str | None = (
any_specified_encoding(sequences) if preemptive_behaviour else None any_specified_encoding(sequences) if preemptive_behaviour else None
) )
@ -149,13 +149,13 @@ def from_bytes(
specified_encoding, specified_encoding,
) )
tested: Set[str] = set() tested: set[str] = set()
tested_but_hard_failure: List[str] = [] tested_but_hard_failure: list[str] = []
tested_but_soft_failure: List[str] = [] tested_but_soft_failure: list[str] = []
fallback_ascii: Optional[CharsetMatch] = None fallback_ascii: CharsetMatch | None = None
fallback_u8: Optional[CharsetMatch] = None fallback_u8: CharsetMatch | None = None
fallback_specified: Optional[CharsetMatch] = None fallback_specified: CharsetMatch | None = None
results: CharsetMatches = CharsetMatches() results: CharsetMatches = CharsetMatches()
@ -189,7 +189,7 @@ def from_bytes(
tested.add(encoding_iana) tested.add(encoding_iana)
decoded_payload: Optional[str] = None decoded_payload: str | None = None
bom_or_sig_available: bool = sig_encoding == encoding_iana bom_or_sig_available: bool = sig_encoding == encoding_iana
strip_sig_or_bom: bool = bom_or_sig_available and should_strip_sig_or_bom( strip_sig_or_bom: bool = bom_or_sig_available and should_strip_sig_or_bom(
encoding_iana encoding_iana
@ -292,7 +292,7 @@ def from_bytes(
early_stop_count: int = 0 early_stop_count: int = 0
lazy_str_hard_failure = False lazy_str_hard_failure = False
md_chunks: List[str] = [] md_chunks: list[str] = []
md_ratios = [] md_ratios = []
try: try:
@ -397,7 +397,7 @@ def from_bytes(
) )
if not is_multi_byte_decoder: if not is_multi_byte_decoder:
target_languages: List[str] = encoding_languages(encoding_iana) target_languages: list[str] = encoding_languages(encoding_iana)
else: else:
target_languages = mb_encoding_languages(encoding_iana) target_languages = mb_encoding_languages(encoding_iana)
@ -462,7 +462,7 @@ def from_bytes(
"Encoding detection: %s is most likely the one.", "Encoding detection: %s is most likely the one.",
current_match.encoding, current_match.encoding,
) )
if explain: if explain: # Defensive: ensure exit path clean handler
logger.removeHandler(explain_handler) logger.removeHandler(explain_handler)
logger.setLevel(previous_logger_level) logger.setLevel(previous_logger_level)
return CharsetMatches([current_match]) return CharsetMatches([current_match])
@ -480,7 +480,7 @@ def from_bytes(
"Encoding detection: %s is most likely the one.", "Encoding detection: %s is most likely the one.",
probable_result.encoding, probable_result.encoding,
) )
if explain: if explain: # Defensive: ensure exit path clean handler
logger.removeHandler(explain_handler) logger.removeHandler(explain_handler)
logger.setLevel(previous_logger_level) logger.setLevel(previous_logger_level)
@ -492,7 +492,7 @@ def from_bytes(
"the beginning of the sequence.", "the beginning of the sequence.",
encoding_iana, encoding_iana,
) )
if explain: if explain: # Defensive: ensure exit path clean handler
logger.removeHandler(explain_handler) logger.removeHandler(explain_handler)
logger.setLevel(previous_logger_level) logger.setLevel(previous_logger_level)
return CharsetMatches([results[encoding_iana]]) return CharsetMatches([results[encoding_iana]])
@ -546,8 +546,8 @@ def from_fp(
steps: int = 5, steps: int = 5,
chunk_size: int = 512, chunk_size: int = 512,
threshold: float = 0.20, threshold: float = 0.20,
cp_isolation: Optional[List[str]] = None, cp_isolation: list[str] | None = None,
cp_exclusion: Optional[List[str]] = None, cp_exclusion: list[str] | None = None,
preemptive_behaviour: bool = True, preemptive_behaviour: bool = True,
explain: bool = False, explain: bool = False,
language_threshold: float = 0.1, language_threshold: float = 0.1,
@ -572,12 +572,12 @@ def from_fp(
def from_path( def from_path(
path: Union[str, bytes, PathLike], # type: ignore[type-arg] path: str | bytes | PathLike, # type: ignore[type-arg]
steps: int = 5, steps: int = 5,
chunk_size: int = 512, chunk_size: int = 512,
threshold: float = 0.20, threshold: float = 0.20,
cp_isolation: Optional[List[str]] = None, cp_isolation: list[str] | None = None,
cp_exclusion: Optional[List[str]] = None, cp_exclusion: list[str] | None = None,
preemptive_behaviour: bool = True, preemptive_behaviour: bool = True,
explain: bool = False, explain: bool = False,
language_threshold: float = 0.1, language_threshold: float = 0.1,
@ -603,12 +603,12 @@ def from_path(
def is_binary( def is_binary(
fp_or_path_or_payload: Union[PathLike, str, BinaryIO, bytes], # type: ignore[type-arg] fp_or_path_or_payload: PathLike | str | BinaryIO | bytes, # type: ignore[type-arg]
steps: int = 5, steps: int = 5,
chunk_size: int = 512, chunk_size: int = 512,
threshold: float = 0.20, threshold: float = 0.20,
cp_isolation: Optional[List[str]] = None, cp_isolation: list[str] | None = None,
cp_exclusion: Optional[List[str]] = None, cp_exclusion: list[str] | None = None,
preemptive_behaviour: bool = True, preemptive_behaviour: bool = True,
explain: bool = False, explain: bool = False,
language_threshold: float = 0.1, language_threshold: float = 0.1,

View file

@ -1,8 +1,10 @@
from __future__ import annotations
import importlib import importlib
from codecs import IncrementalDecoder from codecs import IncrementalDecoder
from collections import Counter from collections import Counter
from functools import lru_cache from functools import lru_cache
from typing import Counter as TypeCounter, Dict, List, Optional, Tuple from typing import Counter as TypeCounter
from .constant import ( from .constant import (
FREQUENCIES, FREQUENCIES,
@ -22,26 +24,24 @@ from .utils import (
) )
def encoding_unicode_range(iana_name: str) -> List[str]: def encoding_unicode_range(iana_name: str) -> list[str]:
""" """
Return associated unicode ranges in a single byte code page. Return associated unicode ranges in a single byte code page.
""" """
if is_multi_byte_encoding(iana_name): if is_multi_byte_encoding(iana_name):
raise IOError("Function not supported on multi-byte code page") raise OSError("Function not supported on multi-byte code page")
decoder = importlib.import_module( decoder = importlib.import_module(f"encodings.{iana_name}").IncrementalDecoder
"encodings.{}".format(iana_name)
).IncrementalDecoder
p: IncrementalDecoder = decoder(errors="ignore") p: IncrementalDecoder = decoder(errors="ignore")
seen_ranges: Dict[str, int] = {} seen_ranges: dict[str, int] = {}
character_count: int = 0 character_count: int = 0
for i in range(0x40, 0xFF): for i in range(0x40, 0xFF):
chunk: str = p.decode(bytes([i])) chunk: str = p.decode(bytes([i]))
if chunk: if chunk:
character_range: Optional[str] = unicode_range(chunk) character_range: str | None = unicode_range(chunk)
if character_range is None: if character_range is None:
continue continue
@ -61,11 +61,11 @@ def encoding_unicode_range(iana_name: str) -> List[str]:
) )
def unicode_range_languages(primary_range: str) -> List[str]: def unicode_range_languages(primary_range: str) -> list[str]:
""" """
Return inferred languages used with a unicode range. Return inferred languages used with a unicode range.
""" """
languages: List[str] = [] languages: list[str] = []
for language, characters in FREQUENCIES.items(): for language, characters in FREQUENCIES.items():
for character in characters: for character in characters:
@ -77,13 +77,13 @@ def unicode_range_languages(primary_range: str) -> List[str]:
@lru_cache() @lru_cache()
def encoding_languages(iana_name: str) -> List[str]: def encoding_languages(iana_name: str) -> list[str]:
""" """
Single-byte encoding language association. Some code page are heavily linked to particular language(s). Single-byte encoding language association. Some code page are heavily linked to particular language(s).
This function does the correspondence. This function does the correspondence.
""" """
unicode_ranges: List[str] = encoding_unicode_range(iana_name) unicode_ranges: list[str] = encoding_unicode_range(iana_name)
primary_range: Optional[str] = None primary_range: str | None = None
for specified_range in unicode_ranges: for specified_range in unicode_ranges:
if "Latin" not in specified_range: if "Latin" not in specified_range:
@ -97,7 +97,7 @@ def encoding_languages(iana_name: str) -> List[str]:
@lru_cache() @lru_cache()
def mb_encoding_languages(iana_name: str) -> List[str]: def mb_encoding_languages(iana_name: str) -> list[str]:
""" """
Multi-byte encoding language association. Some code page are heavily linked to particular language(s). Multi-byte encoding language association. Some code page are heavily linked to particular language(s).
This function does the correspondence. This function does the correspondence.
@ -118,7 +118,7 @@ def mb_encoding_languages(iana_name: str) -> List[str]:
@lru_cache(maxsize=LANGUAGE_SUPPORTED_COUNT) @lru_cache(maxsize=LANGUAGE_SUPPORTED_COUNT)
def get_target_features(language: str) -> Tuple[bool, bool]: def get_target_features(language: str) -> tuple[bool, bool]:
""" """
Determine main aspects from a supported language if it contains accents and if is pure Latin. Determine main aspects from a supported language if it contains accents and if is pure Latin.
""" """
@ -135,12 +135,12 @@ def get_target_features(language: str) -> Tuple[bool, bool]:
def alphabet_languages( def alphabet_languages(
characters: List[str], ignore_non_latin: bool = False characters: list[str], ignore_non_latin: bool = False
) -> List[str]: ) -> list[str]:
""" """
Return associated languages associated to given characters. Return associated languages associated to given characters.
""" """
languages: List[Tuple[str, float]] = [] languages: list[tuple[str, float]] = []
source_have_accents = any(is_accentuated(character) for character in characters) source_have_accents = any(is_accentuated(character) for character in characters)
@ -170,7 +170,7 @@ def alphabet_languages(
def characters_popularity_compare( def characters_popularity_compare(
language: str, ordered_characters: List[str] language: str, ordered_characters: list[str]
) -> float: ) -> float:
""" """
Determine if a ordered characters list (by occurrence from most appearance to rarest) match a particular language. Determine if a ordered characters list (by occurrence from most appearance to rarest) match a particular language.
@ -178,7 +178,7 @@ def characters_popularity_compare(
Beware that is function is not strict on the match in order to ease the detection. (Meaning close match is 1.) Beware that is function is not strict on the match in order to ease the detection. (Meaning close match is 1.)
""" """
if language not in FREQUENCIES: if language not in FREQUENCIES:
raise ValueError("{} not available".format(language)) raise ValueError(f"{language} not available")
character_approved_count: int = 0 character_approved_count: int = 0
FREQUENCIES_language_set = set(FREQUENCIES[language]) FREQUENCIES_language_set = set(FREQUENCIES[language])
@ -214,14 +214,14 @@ def characters_popularity_compare(
character_approved_count += 1 character_approved_count += 1
continue continue
characters_before_source: List[str] = FREQUENCIES[language][ characters_before_source: list[str] = FREQUENCIES[language][
0:character_rank_in_language 0:character_rank_in_language
] ]
characters_after_source: List[str] = FREQUENCIES[language][ characters_after_source: list[str] = FREQUENCIES[language][
character_rank_in_language: character_rank_in_language:
] ]
characters_before: List[str] = ordered_characters[0:character_rank] characters_before: list[str] = ordered_characters[0:character_rank]
characters_after: List[str] = ordered_characters[character_rank:] characters_after: list[str] = ordered_characters[character_rank:]
before_match_count: int = len( before_match_count: int = len(
set(characters_before) & set(characters_before_source) set(characters_before) & set(characters_before_source)
@ -249,24 +249,24 @@ def characters_popularity_compare(
return character_approved_count / len(ordered_characters) return character_approved_count / len(ordered_characters)
def alpha_unicode_split(decoded_sequence: str) -> List[str]: def alpha_unicode_split(decoded_sequence: str) -> list[str]:
""" """
Given a decoded text sequence, return a list of str. Unicode range / alphabet separation. Given a decoded text sequence, return a list of str. Unicode range / alphabet separation.
Ex. a text containing English/Latin with a bit a Hebrew will return two items in the resulting list; Ex. a text containing English/Latin with a bit a Hebrew will return two items in the resulting list;
One containing the latin letters and the other hebrew. One containing the latin letters and the other hebrew.
""" """
layers: Dict[str, str] = {} layers: dict[str, str] = {}
for character in decoded_sequence: for character in decoded_sequence:
if character.isalpha() is False: if character.isalpha() is False:
continue continue
character_range: Optional[str] = unicode_range(character) character_range: str | None = unicode_range(character)
if character_range is None: if character_range is None:
continue continue
layer_target_range: Optional[str] = None layer_target_range: str | None = None
for discovered_range in layers: for discovered_range in layers:
if ( if (
@ -288,12 +288,12 @@ def alpha_unicode_split(decoded_sequence: str) -> List[str]:
return list(layers.values()) return list(layers.values())
def merge_coherence_ratios(results: List[CoherenceMatches]) -> CoherenceMatches: def merge_coherence_ratios(results: list[CoherenceMatches]) -> CoherenceMatches:
""" """
This function merge results previously given by the function coherence_ratio. This function merge results previously given by the function coherence_ratio.
The return type is the same as coherence_ratio. The return type is the same as coherence_ratio.
""" """
per_language_ratios: Dict[str, List[float]] = {} per_language_ratios: dict[str, list[float]] = {}
for result in results: for result in results:
for sub_result in result: for sub_result in result:
language, ratio = sub_result language, ratio = sub_result
@ -321,7 +321,7 @@ def filter_alt_coherence_matches(results: CoherenceMatches) -> CoherenceMatches:
We shall NOT return "English—" in CoherenceMatches because it is an alternative We shall NOT return "English—" in CoherenceMatches because it is an alternative
of "English". This function only keeps the best match and remove the em-dash in it. of "English". This function only keeps the best match and remove the em-dash in it.
""" """
index_results: Dict[str, List[float]] = dict() index_results: dict[str, list[float]] = dict()
for result in results: for result in results:
language, ratio = result language, ratio = result
@ -345,14 +345,14 @@ def filter_alt_coherence_matches(results: CoherenceMatches) -> CoherenceMatches:
@lru_cache(maxsize=2048) @lru_cache(maxsize=2048)
def coherence_ratio( def coherence_ratio(
decoded_sequence: str, threshold: float = 0.1, lg_inclusion: Optional[str] = None decoded_sequence: str, threshold: float = 0.1, lg_inclusion: str | None = None
) -> CoherenceMatches: ) -> CoherenceMatches:
""" """
Detect ANY language that can be identified in given sequence. The sequence will be analysed by layers. Detect ANY language that can be identified in given sequence. The sequence will be analysed by layers.
A layer = Character extraction by alphabets/ranges. A layer = Character extraction by alphabets/ranges.
""" """
results: List[Tuple[str, float]] = [] results: list[tuple[str, float]] = []
ignore_non_latin: bool = False ignore_non_latin: bool = False
sufficient_match_count: int = 0 sufficient_match_count: int = 0
@ -371,7 +371,7 @@ def coherence_ratio(
if character_count <= TOO_SMALL_SEQUENCE: if character_count <= TOO_SMALL_SEQUENCE:
continue continue
popular_character_ordered: List[str] = [c for c, o in most_common] popular_character_ordered: list[str] = [c for c, o in most_common]
for language in lg_inclusion_list or alphabet_languages( for language in lg_inclusion_list or alphabet_languages(
popular_character_ordered, ignore_non_latin popular_character_ordered, ignore_non_latin

View file

@ -1,3 +1,5 @@
from __future__ import annotations
from .__main__ import cli_detect, query_yes_no from .__main__ import cli_detect, query_yes_no
__all__ = ( __all__ = (

View file

@ -1,9 +1,11 @@
from __future__ import annotations
import argparse import argparse
import sys import sys
import typing
from json import dumps from json import dumps
from os.path import abspath, basename, dirname, join, realpath from os.path import abspath, basename, dirname, join, realpath
from platform import python_version from platform import python_version
from typing import List, Optional
from unicodedata import unidata_version from unicodedata import unidata_version
import charset_normalizer.md as md_module import charset_normalizer.md as md_module
@ -42,10 +44,69 @@ def query_yes_no(question: str, default: str = "yes") -> bool:
elif choice in valid: elif choice in valid:
return valid[choice] return valid[choice]
else: else:
sys.stdout.write("Please respond with 'yes' or 'no' " "(or 'y' or 'n').\n") sys.stdout.write("Please respond with 'yes' or 'no' (or 'y' or 'n').\n")
def cli_detect(argv: Optional[List[str]] = None) -> int: class FileType:
"""Factory for creating file object types
Instances of FileType are typically passed as type= arguments to the
ArgumentParser add_argument() method.
Keyword Arguments:
- mode -- A string indicating how the file is to be opened. Accepts the
same values as the builtin open() function.
- bufsize -- The file's desired buffer size. Accepts the same values as
the builtin open() function.
- encoding -- The file's encoding. Accepts the same values as the
builtin open() function.
- errors -- A string indicating how encoding and decoding errors are to
be handled. Accepts the same value as the builtin open() function.
Backported from CPython 3.12
"""
def __init__(
self,
mode: str = "r",
bufsize: int = -1,
encoding: str | None = None,
errors: str | None = None,
):
self._mode = mode
self._bufsize = bufsize
self._encoding = encoding
self._errors = errors
def __call__(self, string: str) -> typing.IO: # type: ignore[type-arg]
# the special argument "-" means sys.std{in,out}
if string == "-":
if "r" in self._mode:
return sys.stdin.buffer if "b" in self._mode else sys.stdin
elif any(c in self._mode for c in "wax"):
return sys.stdout.buffer if "b" in self._mode else sys.stdout
else:
msg = f'argument "-" with mode {self._mode}'
raise ValueError(msg)
# all other arguments are used as file names
try:
return open(string, self._mode, self._bufsize, self._encoding, self._errors)
except OSError as e:
message = f"can't open '{string}': {e}"
raise argparse.ArgumentTypeError(message)
def __repr__(self) -> str:
args = self._mode, self._bufsize
kwargs = [("encoding", self._encoding), ("errors", self._errors)]
args_str = ", ".join(
[repr(arg) for arg in args if arg != -1]
+ [f"{kw}={arg!r}" for kw, arg in kwargs if arg is not None]
)
return f"{type(self).__name__}({args_str})"
def cli_detect(argv: list[str] | None = None) -> int:
""" """
CLI assistant using ARGV and ArgumentParser CLI assistant using ARGV and ArgumentParser
:param argv: :param argv:
@ -58,7 +119,7 @@ def cli_detect(argv: Optional[List[str]] = None) -> int:
) )
parser.add_argument( parser.add_argument(
"files", type=argparse.FileType("rb"), nargs="+", help="File(s) to be analysed" "files", type=FileType("rb"), nargs="+", help="File(s) to be analysed"
) )
parser.add_argument( parser.add_argument(
"-v", "-v",
@ -124,7 +185,7 @@ def cli_detect(argv: Optional[List[str]] = None) -> int:
default=0.2, default=0.2,
type=float, type=float,
dest="threshold", dest="threshold",
help="Define a custom maximum amount of chaos allowed in decoded content. 0. <= chaos <= 1.", help="Define a custom maximum amount of noise allowed in decoded content. 0. <= noise <= 1.",
) )
parser.add_argument( parser.add_argument(
"--version", "--version",
@ -259,7 +320,7 @@ def cli_detect(argv: Optional[List[str]] = None) -> int:
dir_path = dirname(realpath(my_file.name)) dir_path = dirname(realpath(my_file.name))
file_name = basename(realpath(my_file.name)) file_name = basename(realpath(my_file.name))
o_: List[str] = file_name.split(".") o_: list[str] = file_name.split(".")
if args.replace is False: if args.replace is False:
o_.insert(-1, best_guess.encoding) o_.insert(-1, best_guess.encoding)
@ -284,7 +345,7 @@ def cli_detect(argv: Optional[List[str]] = None) -> int:
with open(x_[0].unicode_path, "wb") as fp: with open(x_[0].unicode_path, "wb") as fp:
fp.write(best_guess.output()) fp.write(best_guess.output())
except IOError as e: except OSError as e:
print(str(e), file=sys.stderr) print(str(e), file=sys.stderr)
if my_file.closed is False: if my_file.closed is False:
my_file.close() my_file.close()

View file

@ -1,11 +1,12 @@
# -*- coding: utf-8 -*- from __future__ import annotations
from codecs import BOM_UTF8, BOM_UTF16_BE, BOM_UTF16_LE, BOM_UTF32_BE, BOM_UTF32_LE from codecs import BOM_UTF8, BOM_UTF16_BE, BOM_UTF16_LE, BOM_UTF32_BE, BOM_UTF32_LE
from encodings.aliases import aliases from encodings.aliases import aliases
from re import IGNORECASE, compile as re_compile from re import IGNORECASE
from typing import Dict, List, Set, Union from re import compile as re_compile
# Contain for each eligible encoding a list of/item bytes SIG/BOM # Contain for each eligible encoding a list of/item bytes SIG/BOM
ENCODING_MARKS: Dict[str, Union[bytes, List[bytes]]] = { ENCODING_MARKS: dict[str, bytes | list[bytes]] = {
"utf_8": BOM_UTF8, "utf_8": BOM_UTF8,
"utf_7": [ "utf_7": [
b"\x2b\x2f\x76\x38", b"\x2b\x2f\x76\x38",
@ -25,7 +26,7 @@ TOO_BIG_SEQUENCE: int = int(10e6)
UTF8_MAXIMAL_ALLOCATION: int = 1_112_064 UTF8_MAXIMAL_ALLOCATION: int = 1_112_064
# Up-to-date Unicode ucd/15.0.0 # Up-to-date Unicode ucd/15.0.0
UNICODE_RANGES_COMBINED: Dict[str, range] = { UNICODE_RANGES_COMBINED: dict[str, range] = {
"Control character": range(32), "Control character": range(32),
"Basic Latin": range(32, 128), "Basic Latin": range(32, 128),
"Latin-1 Supplement": range(128, 256), "Latin-1 Supplement": range(128, 256),
@ -357,7 +358,7 @@ UNICODE_RANGES_COMBINED: Dict[str, range] = {
} }
UNICODE_SECONDARY_RANGE_KEYWORD: List[str] = [ UNICODE_SECONDARY_RANGE_KEYWORD: list[str] = [
"Supplement", "Supplement",
"Extended", "Extended",
"Extensions", "Extensions",
@ -392,7 +393,7 @@ IANA_NO_ALIASES = [
"koi8_u", "koi8_u",
] ]
IANA_SUPPORTED: List[str] = sorted( IANA_SUPPORTED: list[str] = sorted(
filter( filter(
lambda x: x.endswith("_codec") is False lambda x: x.endswith("_codec") is False
and x not in {"rot_13", "tactis", "mbcs"}, and x not in {"rot_13", "tactis", "mbcs"},
@ -403,7 +404,7 @@ IANA_SUPPORTED: List[str] = sorted(
IANA_SUPPORTED_COUNT: int = len(IANA_SUPPORTED) IANA_SUPPORTED_COUNT: int = len(IANA_SUPPORTED)
# pre-computed code page that are similar using the function cp_similarity. # pre-computed code page that are similar using the function cp_similarity.
IANA_SUPPORTED_SIMILAR: Dict[str, List[str]] = { IANA_SUPPORTED_SIMILAR: dict[str, list[str]] = {
"cp037": ["cp1026", "cp1140", "cp273", "cp500"], "cp037": ["cp1026", "cp1140", "cp273", "cp500"],
"cp1026": ["cp037", "cp1140", "cp273", "cp500"], "cp1026": ["cp037", "cp1140", "cp273", "cp500"],
"cp1125": ["cp866"], "cp1125": ["cp866"],
@ -492,7 +493,7 @@ IANA_SUPPORTED_SIMILAR: Dict[str, List[str]] = {
} }
CHARDET_CORRESPONDENCE: Dict[str, str] = { CHARDET_CORRESPONDENCE: dict[str, str] = {
"iso2022_kr": "ISO-2022-KR", "iso2022_kr": "ISO-2022-KR",
"iso2022_jp": "ISO-2022-JP", "iso2022_jp": "ISO-2022-JP",
"euc_kr": "EUC-KR", "euc_kr": "EUC-KR",
@ -528,7 +529,7 @@ CHARDET_CORRESPONDENCE: Dict[str, str] = {
} }
COMMON_SAFE_ASCII_CHARACTERS: Set[str] = { COMMON_SAFE_ASCII_CHARACTERS: set[str] = {
"<", "<",
">", ">",
"=", "=",
@ -548,9 +549,26 @@ COMMON_SAFE_ASCII_CHARACTERS: Set[str] = {
")", ")",
} }
# Sample character sets — replace with full lists if needed
COMMON_CHINESE_CHARACTERS = "的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当使点从业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决西被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该铁价严龙飞"
KO_NAMES: Set[str] = {"johab", "cp949", "euc_kr"} COMMON_JAPANESE_CHARACTERS = "日一国年大十二本中長出三時行見月分後前生五間上東四今金九入学高円子外八六下来気小七山話女北午百書先名川千水半男西電校語土木聞食車何南万毎白天母火右読友左休父雨"
ZH_NAMES: Set[str] = {"big5", "cp950", "big5hkscs", "hz"}
COMMON_KOREAN_CHARACTERS = "一二三四五六七八九十百千萬上下左右中人女子大小山川日月火水木金土父母天地國名年時文校學生"
# Combine all into a set
COMMON_CJK_CHARACTERS = set(
"".join(
[
COMMON_CHINESE_CHARACTERS,
COMMON_JAPANESE_CHARACTERS,
COMMON_KOREAN_CHARACTERS,
]
)
)
KO_NAMES: set[str] = {"johab", "cp949", "euc_kr"}
ZH_NAMES: set[str] = {"big5", "cp950", "big5hkscs", "hz"}
# Logging LEVEL below DEBUG # Logging LEVEL below DEBUG
TRACE: int = 5 TRACE: int = 5
@ -558,7 +576,7 @@ TRACE: int = 5
# Language label that contain the em dash "—" # Language label that contain the em dash "—"
# character are to be considered alternative seq to origin # character are to be considered alternative seq to origin
FREQUENCIES: Dict[str, List[str]] = { FREQUENCIES: dict[str, list[str]] = {
"English": [ "English": [
"e", "e",
"a", "a",

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Any
from warnings import warn from warnings import warn
from .api import from_bytes from .api import from_bytes
@ -11,9 +11,9 @@ if TYPE_CHECKING:
from typing_extensions import TypedDict from typing_extensions import TypedDict
class ResultDict(TypedDict): class ResultDict(TypedDict):
encoding: Optional[str] encoding: str | None
language: str language: str
confidence: Optional[float] confidence: float | None
def detect( def detect(
@ -37,8 +37,7 @@ def detect(
if not isinstance(byte_str, (bytearray, bytes)): if not isinstance(byte_str, (bytearray, bytes)):
raise TypeError( # pragma: nocover raise TypeError( # pragma: nocover
"Expected object of type bytes or bytearray, got: " f"Expected object of type bytes or bytearray, got: {type(byte_str)}"
"{0}".format(type(byte_str))
) )
if isinstance(byte_str, bytearray): if isinstance(byte_str, bytearray):

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from logging import getLogger from logging import getLogger
from typing import List, Optional
from .constant import ( from .constant import (
COMMON_SAFE_ASCII_CHARACTERS, COMMON_SAFE_ASCII_CHARACTERS,
@ -25,6 +26,7 @@ from .utils import (
is_unprintable, is_unprintable,
remove_accent, remove_accent,
unicode_range, unicode_range,
is_cjk_uncommon,
) )
@ -68,7 +70,7 @@ class TooManySymbolOrPunctuationPlugin(MessDetectorPlugin):
self._symbol_count: int = 0 self._symbol_count: int = 0
self._character_count: int = 0 self._character_count: int = 0
self._last_printable_char: Optional[str] = None self._last_printable_char: str | None = None
self._frenzy_symbol_in_word: bool = False self._frenzy_symbol_in_word: bool = False
def eligible(self, character: str) -> bool: def eligible(self, character: str) -> bool:
@ -92,7 +94,7 @@ class TooManySymbolOrPunctuationPlugin(MessDetectorPlugin):
self._last_printable_char = character self._last_printable_char = character
def reset(self) -> None: # pragma: no cover def reset(self) -> None: # Abstract
self._punctuation_count = 0 self._punctuation_count = 0
self._character_count = 0 self._character_count = 0
self._symbol_count = 0 self._symbol_count = 0
@ -123,7 +125,7 @@ class TooManyAccentuatedPlugin(MessDetectorPlugin):
if is_accentuated(character): if is_accentuated(character):
self._accentuated_count += 1 self._accentuated_count += 1
def reset(self) -> None: # pragma: no cover def reset(self) -> None: # Abstract
self._character_count = 0 self._character_count = 0
self._accentuated_count = 0 self._accentuated_count = 0
@ -149,7 +151,7 @@ class UnprintablePlugin(MessDetectorPlugin):
self._unprintable_count += 1 self._unprintable_count += 1
self._character_count += 1 self._character_count += 1
def reset(self) -> None: # pragma: no cover def reset(self) -> None: # Abstract
self._unprintable_count = 0 self._unprintable_count = 0
@property @property
@ -165,7 +167,7 @@ class SuspiciousDuplicateAccentPlugin(MessDetectorPlugin):
self._successive_count: int = 0 self._successive_count: int = 0
self._character_count: int = 0 self._character_count: int = 0
self._last_latin_character: Optional[str] = None self._last_latin_character: str | None = None
def eligible(self, character: str) -> bool: def eligible(self, character: str) -> bool:
return character.isalpha() and is_latin(character) return character.isalpha() and is_latin(character)
@ -184,7 +186,7 @@ class SuspiciousDuplicateAccentPlugin(MessDetectorPlugin):
self._successive_count += 1 self._successive_count += 1
self._last_latin_character = character self._last_latin_character = character
def reset(self) -> None: # pragma: no cover def reset(self) -> None: # Abstract
self._successive_count = 0 self._successive_count = 0
self._character_count = 0 self._character_count = 0
self._last_latin_character = None self._last_latin_character = None
@ -201,7 +203,7 @@ class SuspiciousRange(MessDetectorPlugin):
def __init__(self) -> None: def __init__(self) -> None:
self._suspicious_successive_range_count: int = 0 self._suspicious_successive_range_count: int = 0
self._character_count: int = 0 self._character_count: int = 0
self._last_printable_seen: Optional[str] = None self._last_printable_seen: str | None = None
def eligible(self, character: str) -> bool: def eligible(self, character: str) -> bool:
return character.isprintable() return character.isprintable()
@ -221,15 +223,15 @@ class SuspiciousRange(MessDetectorPlugin):
self._last_printable_seen = character self._last_printable_seen = character
return return
unicode_range_a: Optional[str] = unicode_range(self._last_printable_seen) unicode_range_a: str | None = unicode_range(self._last_printable_seen)
unicode_range_b: Optional[str] = unicode_range(character) unicode_range_b: str | None = unicode_range(character)
if is_suspiciously_successive_range(unicode_range_a, unicode_range_b): if is_suspiciously_successive_range(unicode_range_a, unicode_range_b):
self._suspicious_successive_range_count += 1 self._suspicious_successive_range_count += 1
self._last_printable_seen = character self._last_printable_seen = character
def reset(self) -> None: # pragma: no cover def reset(self) -> None: # Abstract
self._character_count = 0 self._character_count = 0
self._suspicious_successive_range_count = 0 self._suspicious_successive_range_count = 0
self._last_printable_seen = None self._last_printable_seen = None
@ -346,7 +348,7 @@ class SuperWeirdWordPlugin(MessDetectorPlugin):
self._is_current_word_bad = True self._is_current_word_bad = True
self._buffer += character self._buffer += character
def reset(self) -> None: # pragma: no cover def reset(self) -> None: # Abstract
self._buffer = "" self._buffer = ""
self._is_current_word_bad = False self._is_current_word_bad = False
self._foreign_long_watch = False self._foreign_long_watch = False
@ -364,35 +366,39 @@ class SuperWeirdWordPlugin(MessDetectorPlugin):
return self._bad_character_count / self._character_count return self._bad_character_count / self._character_count
class CjkInvalidStopPlugin(MessDetectorPlugin): class CjkUncommonPlugin(MessDetectorPlugin):
""" """
GB(Chinese) based encoding often render the stop incorrectly when the content does not fit and Detect messy CJK text that probably means nothing.
can be easily detected. Searching for the overuse of '' and ''.
""" """
def __init__(self) -> None: def __init__(self) -> None:
self._wrong_stop_count: int = 0 self._character_count: int = 0
self._cjk_character_count: int = 0 self._uncommon_count: int = 0
def eligible(self, character: str) -> bool: def eligible(self, character: str) -> bool:
return True return is_cjk(character)
def feed(self, character: str) -> None: def feed(self, character: str) -> None:
if character in {"", ""}: self._character_count += 1
self._wrong_stop_count += 1
return
if is_cjk(character):
self._cjk_character_count += 1
def reset(self) -> None: # pragma: no cover if is_cjk_uncommon(character):
self._wrong_stop_count = 0 self._uncommon_count += 1
self._cjk_character_count = 0 return
def reset(self) -> None: # Abstract
self._character_count = 0
self._uncommon_count = 0
@property @property
def ratio(self) -> float: def ratio(self) -> float:
if self._cjk_character_count < 16: if self._character_count < 8:
return 0.0 return 0.0
return self._wrong_stop_count / self._cjk_character_count
uncommon_form_usage: float = self._uncommon_count / self._character_count
# we can be pretty sure it's garbage when uncommon characters are widely
# used. otherwise it could just be traditional chinese for example.
return uncommon_form_usage / 10 if uncommon_form_usage > 0.5 else 0.0
class ArchaicUpperLowerPlugin(MessDetectorPlugin): class ArchaicUpperLowerPlugin(MessDetectorPlugin):
@ -406,7 +412,7 @@ class ArchaicUpperLowerPlugin(MessDetectorPlugin):
self._character_count: int = 0 self._character_count: int = 0
self._last_alpha_seen: Optional[str] = None self._last_alpha_seen: str | None = None
self._current_ascii_only: bool = True self._current_ascii_only: bool = True
def eligible(self, character: str) -> bool: def eligible(self, character: str) -> bool:
@ -454,7 +460,7 @@ class ArchaicUpperLowerPlugin(MessDetectorPlugin):
self._character_count_since_last_sep += 1 self._character_count_since_last_sep += 1
self._last_alpha_seen = character self._last_alpha_seen = character
def reset(self) -> None: # pragma: no cover def reset(self) -> None: # Abstract
self._character_count = 0 self._character_count = 0
self._character_count_since_last_sep = 0 self._character_count_since_last_sep = 0
self._successive_upper_lower_count = 0 self._successive_upper_lower_count = 0
@ -476,7 +482,7 @@ class ArabicIsolatedFormPlugin(MessDetectorPlugin):
self._character_count: int = 0 self._character_count: int = 0
self._isolated_form_count: int = 0 self._isolated_form_count: int = 0
def reset(self) -> None: # pragma: no cover def reset(self) -> None: # Abstract
self._character_count = 0 self._character_count = 0
self._isolated_form_count = 0 self._isolated_form_count = 0
@ -501,7 +507,7 @@ class ArabicIsolatedFormPlugin(MessDetectorPlugin):
@lru_cache(maxsize=1024) @lru_cache(maxsize=1024)
def is_suspiciously_successive_range( def is_suspiciously_successive_range(
unicode_range_a: Optional[str], unicode_range_b: Optional[str] unicode_range_a: str | None, unicode_range_b: str | None
) -> bool: ) -> bool:
""" """
Determine if two Unicode range seen next to each other can be considered as suspicious. Determine if two Unicode range seen next to each other can be considered as suspicious.
@ -525,9 +531,10 @@ def is_suspiciously_successive_range(
): ):
return False return False
keywords_range_a, keywords_range_b = unicode_range_a.split( keywords_range_a, keywords_range_b = (
" " unicode_range_a.split(" "),
), unicode_range_b.split(" ") unicode_range_b.split(" "),
)
for el in keywords_range_a: for el in keywords_range_a:
if el in UNICODE_SECONDARY_RANGE_KEYWORD: if el in UNICODE_SECONDARY_RANGE_KEYWORD:
@ -580,7 +587,7 @@ def mess_ratio(
Compute a mess ratio given a decoded bytes sequence. The maximum threshold does stop the computation earlier. Compute a mess ratio given a decoded bytes sequence. The maximum threshold does stop the computation earlier.
""" """
detectors: List[MessDetectorPlugin] = [ detectors: list[MessDetectorPlugin] = [
md_class() for md_class in MessDetectorPlugin.__subclasses__() md_class() for md_class in MessDetectorPlugin.__subclasses__()
] ]
@ -622,7 +629,7 @@ def mess_ratio(
logger.log(TRACE, f"Starting with: {decoded_sequence[:16]}") logger.log(TRACE, f"Starting with: {decoded_sequence[:16]}")
logger.log(TRACE, f"Ending with: {decoded_sequence[-16::]}") logger.log(TRACE, f"Ending with: {decoded_sequence[-16::]}")
for dt in detectors: # pragma: nocover for dt in detectors:
logger.log(TRACE, f"{dt.__class__}: {dt.ratio}") logger.log(TRACE, f"{dt.__class__}: {dt.ratio}")
return round(mean_mess_ratio, 3) return round(mean_mess_ratio, 3)

View file

@ -1,8 +1,10 @@
from __future__ import annotations
from encodings.aliases import aliases from encodings.aliases import aliases
from hashlib import sha256 from hashlib import sha256
from json import dumps from json import dumps
from re import sub from re import sub
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union from typing import Any, Iterator, List, Tuple
from .constant import RE_POSSIBLE_ENCODING_INDICATION, TOO_BIG_SEQUENCE from .constant import RE_POSSIBLE_ENCODING_INDICATION, TOO_BIG_SEQUENCE
from .utils import iana_name, is_multi_byte_encoding, unicode_range from .utils import iana_name, is_multi_byte_encoding, unicode_range
@ -15,9 +17,9 @@ class CharsetMatch:
guessed_encoding: str, guessed_encoding: str,
mean_mess_ratio: float, mean_mess_ratio: float,
has_sig_or_bom: bool, has_sig_or_bom: bool,
languages: "CoherenceMatches", languages: CoherenceMatches,
decoded_payload: Optional[str] = None, decoded_payload: str | None = None,
preemptive_declaration: Optional[str] = None, preemptive_declaration: str | None = None,
): ):
self._payload: bytes = payload self._payload: bytes = payload
@ -25,17 +27,17 @@ class CharsetMatch:
self._mean_mess_ratio: float = mean_mess_ratio self._mean_mess_ratio: float = mean_mess_ratio
self._languages: CoherenceMatches = languages self._languages: CoherenceMatches = languages
self._has_sig_or_bom: bool = has_sig_or_bom self._has_sig_or_bom: bool = has_sig_or_bom
self._unicode_ranges: Optional[List[str]] = None self._unicode_ranges: list[str] | None = None
self._leaves: List[CharsetMatch] = [] self._leaves: list[CharsetMatch] = []
self._mean_coherence_ratio: float = 0.0 self._mean_coherence_ratio: float = 0.0
self._output_payload: Optional[bytes] = None self._output_payload: bytes | None = None
self._output_encoding: Optional[str] = None self._output_encoding: str | None = None
self._string: Optional[str] = decoded_payload self._string: str | None = decoded_payload
self._preemptive_declaration: Optional[str] = preemptive_declaration self._preemptive_declaration: str | None = preemptive_declaration
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
if not isinstance(other, CharsetMatch): if not isinstance(other, CharsetMatch):
@ -77,9 +79,9 @@ class CharsetMatch:
return self._string return self._string
def __repr__(self) -> str: def __repr__(self) -> str:
return "<CharsetMatch '{}' bytes({})>".format(self.encoding, self.fingerprint) return f"<CharsetMatch '{self.encoding}' bytes({self.fingerprint})>"
def add_submatch(self, other: "CharsetMatch") -> None: def add_submatch(self, other: CharsetMatch) -> None:
if not isinstance(other, CharsetMatch) or other == self: if not isinstance(other, CharsetMatch) or other == self:
raise ValueError( raise ValueError(
"Unable to add instance <{}> as a submatch of a CharsetMatch".format( "Unable to add instance <{}> as a submatch of a CharsetMatch".format(
@ -95,11 +97,11 @@ class CharsetMatch:
return self._encoding return self._encoding
@property @property
def encoding_aliases(self) -> List[str]: def encoding_aliases(self) -> list[str]:
""" """
Encoding name are known by many name, using this could help when searching for IBM855 when it's listed as CP855. Encoding name are known by many name, using this could help when searching for IBM855 when it's listed as CP855.
""" """
also_known_as: List[str] = [] also_known_as: list[str] = []
for u, p in aliases.items(): for u, p in aliases.items():
if self.encoding == u: if self.encoding == u:
also_known_as.append(p) also_known_as.append(p)
@ -116,7 +118,7 @@ class CharsetMatch:
return self._has_sig_or_bom return self._has_sig_or_bom
@property @property
def languages(self) -> List[str]: def languages(self) -> list[str]:
""" """
Return the complete list of possible languages found in decoded sequence. Return the complete list of possible languages found in decoded sequence.
Usually not really useful. Returned list may be empty even if 'language' property return something != 'Unknown'. Usually not really useful. Returned list may be empty even if 'language' property return something != 'Unknown'.
@ -177,7 +179,7 @@ class CharsetMatch:
return self._payload return self._payload
@property @property
def submatch(self) -> List["CharsetMatch"]: def submatch(self) -> list[CharsetMatch]:
return self._leaves return self._leaves
@property @property
@ -185,19 +187,17 @@ class CharsetMatch:
return len(self._leaves) > 0 return len(self._leaves) > 0
@property @property
def alphabets(self) -> List[str]: def alphabets(self) -> list[str]:
if self._unicode_ranges is not None: if self._unicode_ranges is not None:
return self._unicode_ranges return self._unicode_ranges
# list detected ranges # list detected ranges
detected_ranges: List[Optional[str]] = [ detected_ranges: list[str | None] = [unicode_range(char) for char in str(self)]
unicode_range(char) for char in str(self)
]
# filter and sort # filter and sort
self._unicode_ranges = sorted(list({r for r in detected_ranges if r})) self._unicode_ranges = sorted(list({r for r in detected_ranges if r}))
return self._unicode_ranges return self._unicode_ranges
@property @property
def could_be_from_charset(self) -> List[str]: def could_be_from_charset(self) -> list[str]:
""" """
The complete list of encoding that output the exact SAME str result and therefore could be the originating The complete list of encoding that output the exact SAME str result and therefore could be the originating
encoding. encoding.
@ -221,10 +221,11 @@ class CharsetMatch:
patched_header = sub( patched_header = sub(
RE_POSSIBLE_ENCODING_INDICATION, RE_POSSIBLE_ENCODING_INDICATION,
lambda m: m.string[m.span()[0] : m.span()[1]].replace( lambda m: m.string[m.span()[0] : m.span()[1]].replace(
m.groups()[0], iana_name(self._output_encoding) # type: ignore[arg-type] m.groups()[0],
iana_name(self._output_encoding).replace("_", "-"), # type: ignore[arg-type]
), ),
decoded_string[:8192], decoded_string[:8192],
1, count=1,
) )
decoded_string = patched_header + decoded_string[8192:] decoded_string = patched_header + decoded_string[8192:]
@ -247,13 +248,13 @@ class CharsetMatches:
Act like a list(iterable) but does not implements all related methods. Act like a list(iterable) but does not implements all related methods.
""" """
def __init__(self, results: Optional[List[CharsetMatch]] = None): def __init__(self, results: list[CharsetMatch] | None = None):
self._results: List[CharsetMatch] = sorted(results) if results else [] self._results: list[CharsetMatch] = sorted(results) if results else []
def __iter__(self) -> Iterator[CharsetMatch]: def __iter__(self) -> Iterator[CharsetMatch]:
yield from self._results yield from self._results
def __getitem__(self, item: Union[int, str]) -> CharsetMatch: def __getitem__(self, item: int | str) -> CharsetMatch:
""" """
Retrieve a single item either by its position or encoding name (alias may be used here). Retrieve a single item either by its position or encoding name (alias may be used here).
Raise KeyError upon invalid index or encoding not present in results. Raise KeyError upon invalid index or encoding not present in results.
@ -293,7 +294,7 @@ class CharsetMatches:
self._results.append(item) self._results.append(item)
self._results = sorted(self._results) self._results = sorted(self._results)
def best(self) -> Optional["CharsetMatch"]: def best(self) -> CharsetMatch | None:
""" """
Simply return the first match. Strict equivalent to matches[0]. Simply return the first match. Strict equivalent to matches[0].
""" """
@ -301,7 +302,7 @@ class CharsetMatches:
return None return None
return self._results[0] return self._results[0]
def first(self) -> Optional["CharsetMatch"]: def first(self) -> CharsetMatch | None:
""" """
Redundant method, call the method best(). Kept for BC reasons. Redundant method, call the method best(). Kept for BC reasons.
""" """
@ -316,31 +317,31 @@ class CliDetectionResult:
def __init__( def __init__(
self, self,
path: str, path: str,
encoding: Optional[str], encoding: str | None,
encoding_aliases: List[str], encoding_aliases: list[str],
alternative_encodings: List[str], alternative_encodings: list[str],
language: str, language: str,
alphabets: List[str], alphabets: list[str],
has_sig_or_bom: bool, has_sig_or_bom: bool,
chaos: float, chaos: float,
coherence: float, coherence: float,
unicode_path: Optional[str], unicode_path: str | None,
is_preferred: bool, is_preferred: bool,
): ):
self.path: str = path self.path: str = path
self.unicode_path: Optional[str] = unicode_path self.unicode_path: str | None = unicode_path
self.encoding: Optional[str] = encoding self.encoding: str | None = encoding
self.encoding_aliases: List[str] = encoding_aliases self.encoding_aliases: list[str] = encoding_aliases
self.alternative_encodings: List[str] = alternative_encodings self.alternative_encodings: list[str] = alternative_encodings
self.language: str = language self.language: str = language
self.alphabets: List[str] = alphabets self.alphabets: list[str] = alphabets
self.has_sig_or_bom: bool = has_sig_or_bom self.has_sig_or_bom: bool = has_sig_or_bom
self.chaos: float = chaos self.chaos: float = chaos
self.coherence: float = coherence self.coherence: float = coherence
self.is_preferred: bool = is_preferred self.is_preferred: bool = is_preferred
@property @property
def __dict__(self) -> Dict[str, Any]: # type: ignore def __dict__(self) -> dict[str, Any]: # type: ignore
return { return {
"path": self.path, "path": self.path,
"encoding": self.encoding, "encoding": self.encoding,

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import importlib import importlib
import logging import logging
import unicodedata import unicodedata
@ -5,9 +7,11 @@ from codecs import IncrementalDecoder
from encodings.aliases import aliases from encodings.aliases import aliases
from functools import lru_cache from functools import lru_cache
from re import findall from re import findall
from typing import Generator, List, Optional, Set, Tuple, Union from typing import Generator
from _multibytecodec import MultibyteIncrementalDecoder from _multibytecodec import ( # type: ignore[import-not-found,import]
MultibyteIncrementalDecoder,
)
from .constant import ( from .constant import (
ENCODING_MARKS, ENCODING_MARKS,
@ -16,6 +20,7 @@ from .constant import (
UNICODE_RANGES_COMBINED, UNICODE_RANGES_COMBINED,
UNICODE_SECONDARY_RANGE_KEYWORD, UNICODE_SECONDARY_RANGE_KEYWORD,
UTF8_MAXIMAL_ALLOCATION, UTF8_MAXIMAL_ALLOCATION,
COMMON_CJK_CHARACTERS,
) )
@ -23,7 +28,7 @@ from .constant import (
def is_accentuated(character: str) -> bool: def is_accentuated(character: str) -> bool:
try: try:
description: str = unicodedata.name(character) description: str = unicodedata.name(character)
except ValueError: except ValueError: # Defensive: unicode database outdated?
return False return False
return ( return (
"WITH GRAVE" in description "WITH GRAVE" in description
@ -43,13 +48,13 @@ def remove_accent(character: str) -> str:
if not decomposed: if not decomposed:
return character return character
codes: List[str] = decomposed.split(" ") codes: list[str] = decomposed.split(" ")
return chr(int(codes[0], 16)) return chr(int(codes[0], 16))
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION) @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
def unicode_range(character: str) -> Optional[str]: def unicode_range(character: str) -> str | None:
""" """
Retrieve the Unicode range official name from a single character. Retrieve the Unicode range official name from a single character.
""" """
@ -66,7 +71,7 @@ def unicode_range(character: str) -> Optional[str]:
def is_latin(character: str) -> bool: def is_latin(character: str) -> bool:
try: try:
description: str = unicodedata.name(character) description: str = unicodedata.name(character)
except ValueError: except ValueError: # Defensive: unicode database outdated?
return False return False
return "LATIN" in description return "LATIN" in description
@ -78,7 +83,7 @@ def is_punctuation(character: str) -> bool:
if "P" in character_category: if "P" in character_category:
return True return True
character_range: Optional[str] = unicode_range(character) character_range: str | None = unicode_range(character)
if character_range is None: if character_range is None:
return False return False
@ -93,7 +98,7 @@ def is_symbol(character: str) -> bool:
if "S" in character_category or "N" in character_category: if "S" in character_category or "N" in character_category:
return True return True
character_range: Optional[str] = unicode_range(character) character_range: str | None = unicode_range(character)
if character_range is None: if character_range is None:
return False return False
@ -103,7 +108,7 @@ def is_symbol(character: str) -> bool:
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION) @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
def is_emoticon(character: str) -> bool: def is_emoticon(character: str) -> bool:
character_range: Optional[str] = unicode_range(character) character_range: str | None = unicode_range(character)
if character_range is None: if character_range is None:
return False return False
@ -130,7 +135,7 @@ def is_case_variable(character: str) -> bool:
def is_cjk(character: str) -> bool: def is_cjk(character: str) -> bool:
try: try:
character_name = unicodedata.name(character) character_name = unicodedata.name(character)
except ValueError: except ValueError: # Defensive: unicode database outdated?
return False return False
return "CJK" in character_name return "CJK" in character_name
@ -140,7 +145,7 @@ def is_cjk(character: str) -> bool:
def is_hiragana(character: str) -> bool: def is_hiragana(character: str) -> bool:
try: try:
character_name = unicodedata.name(character) character_name = unicodedata.name(character)
except ValueError: except ValueError: # Defensive: unicode database outdated?
return False return False
return "HIRAGANA" in character_name return "HIRAGANA" in character_name
@ -150,7 +155,7 @@ def is_hiragana(character: str) -> bool:
def is_katakana(character: str) -> bool: def is_katakana(character: str) -> bool:
try: try:
character_name = unicodedata.name(character) character_name = unicodedata.name(character)
except ValueError: except ValueError: # Defensive: unicode database outdated?
return False return False
return "KATAKANA" in character_name return "KATAKANA" in character_name
@ -160,7 +165,7 @@ def is_katakana(character: str) -> bool:
def is_hangul(character: str) -> bool: def is_hangul(character: str) -> bool:
try: try:
character_name = unicodedata.name(character) character_name = unicodedata.name(character)
except ValueError: except ValueError: # Defensive: unicode database outdated?
return False return False
return "HANGUL" in character_name return "HANGUL" in character_name
@ -170,7 +175,7 @@ def is_hangul(character: str) -> bool:
def is_thai(character: str) -> bool: def is_thai(character: str) -> bool:
try: try:
character_name = unicodedata.name(character) character_name = unicodedata.name(character)
except ValueError: except ValueError: # Defensive: unicode database outdated?
return False return False
return "THAI" in character_name return "THAI" in character_name
@ -180,7 +185,7 @@ def is_thai(character: str) -> bool:
def is_arabic(character: str) -> bool: def is_arabic(character: str) -> bool:
try: try:
character_name = unicodedata.name(character) character_name = unicodedata.name(character)
except ValueError: except ValueError: # Defensive: unicode database outdated?
return False return False
return "ARABIC" in character_name return "ARABIC" in character_name
@ -190,12 +195,17 @@ def is_arabic(character: str) -> bool:
def is_arabic_isolated_form(character: str) -> bool: def is_arabic_isolated_form(character: str) -> bool:
try: try:
character_name = unicodedata.name(character) character_name = unicodedata.name(character)
except ValueError: except ValueError: # Defensive: unicode database outdated?
return False return False
return "ARABIC" in character_name and "ISOLATED FORM" in character_name return "ARABIC" in character_name and "ISOLATED FORM" in character_name
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
def is_cjk_uncommon(character: str) -> bool:
return character not in COMMON_CJK_CHARACTERS
@lru_cache(maxsize=len(UNICODE_RANGES_COMBINED)) @lru_cache(maxsize=len(UNICODE_RANGES_COMBINED))
def is_unicode_range_secondary(range_name: str) -> bool: def is_unicode_range_secondary(range_name: str) -> bool:
return any(keyword in range_name for keyword in UNICODE_SECONDARY_RANGE_KEYWORD) return any(keyword in range_name for keyword in UNICODE_SECONDARY_RANGE_KEYWORD)
@ -206,13 +216,13 @@ def is_unprintable(character: str) -> bool:
return ( return (
character.isspace() is False # includes \n \t \r \v character.isspace() is False # includes \n \t \r \v
and character.isprintable() is False and character.isprintable() is False
and character != "\x1A" # Why? Its the ASCII substitute character. and character != "\x1a" # Why? Its the ASCII substitute character.
and character != "\ufeff" # bug discovered in Python, and character != "\ufeff" # bug discovered in Python,
# Zero Width No-Break Space located in Arabic Presentation Forms-B, Unicode 1.1 not acknowledged as space. # Zero Width No-Break Space located in Arabic Presentation Forms-B, Unicode 1.1 not acknowledged as space.
) )
def any_specified_encoding(sequence: bytes, search_zone: int = 8192) -> Optional[str]: def any_specified_encoding(sequence: bytes, search_zone: int = 8192) -> str | None:
""" """
Extract using ASCII-only decoder any specified encoding in the first n-bytes. Extract using ASCII-only decoder any specified encoding in the first n-bytes.
""" """
@ -221,7 +231,7 @@ def any_specified_encoding(sequence: bytes, search_zone: int = 8192) -> Optional
seq_len: int = len(sequence) seq_len: int = len(sequence)
results: List[str] = findall( results: list[str] = findall(
RE_POSSIBLE_ENCODING_INDICATION, RE_POSSIBLE_ENCODING_INDICATION,
sequence[: min(seq_len, search_zone)].decode("ascii", errors="ignore"), sequence[: min(seq_len, search_zone)].decode("ascii", errors="ignore"),
) )
@ -260,18 +270,18 @@ def is_multi_byte_encoding(name: str) -> bool:
"utf_32_be", "utf_32_be",
"utf_7", "utf_7",
} or issubclass( } or issubclass(
importlib.import_module("encodings.{}".format(name)).IncrementalDecoder, importlib.import_module(f"encodings.{name}").IncrementalDecoder,
MultibyteIncrementalDecoder, MultibyteIncrementalDecoder,
) )
def identify_sig_or_bom(sequence: bytes) -> Tuple[Optional[str], bytes]: def identify_sig_or_bom(sequence: bytes) -> tuple[str | None, bytes]:
""" """
Identify and extract SIG/BOM in given sequence. Identify and extract SIG/BOM in given sequence.
""" """
for iana_encoding in ENCODING_MARKS: for iana_encoding in ENCODING_MARKS:
marks: Union[bytes, List[bytes]] = ENCODING_MARKS[iana_encoding] marks: bytes | list[bytes] = ENCODING_MARKS[iana_encoding]
if isinstance(marks, bytes): if isinstance(marks, bytes):
marks = [marks] marks = [marks]
@ -288,6 +298,7 @@ def should_strip_sig_or_bom(iana_encoding: str) -> bool:
def iana_name(cp_name: str, strict: bool = True) -> str: def iana_name(cp_name: str, strict: bool = True) -> str:
"""Returns the Python normalized encoding name (Not the IANA official name)."""
cp_name = cp_name.lower().replace("-", "_") cp_name = cp_name.lower().replace("-", "_")
encoding_alias: str encoding_alias: str
@ -298,35 +309,17 @@ def iana_name(cp_name: str, strict: bool = True) -> str:
return encoding_iana return encoding_iana
if strict: if strict:
raise ValueError("Unable to retrieve IANA for '{}'".format(cp_name)) raise ValueError(f"Unable to retrieve IANA for '{cp_name}'")
return cp_name return cp_name
def range_scan(decoded_sequence: str) -> List[str]:
ranges: Set[str] = set()
for character in decoded_sequence:
character_range: Optional[str] = unicode_range(character)
if character_range is None:
continue
ranges.add(character_range)
return list(ranges)
def cp_similarity(iana_name_a: str, iana_name_b: str) -> float: def cp_similarity(iana_name_a: str, iana_name_b: str) -> float:
if is_multi_byte_encoding(iana_name_a) or is_multi_byte_encoding(iana_name_b): if is_multi_byte_encoding(iana_name_a) or is_multi_byte_encoding(iana_name_b):
return 0.0 return 0.0
decoder_a = importlib.import_module( decoder_a = importlib.import_module(f"encodings.{iana_name_a}").IncrementalDecoder
"encodings.{}".format(iana_name_a) decoder_b = importlib.import_module(f"encodings.{iana_name_b}").IncrementalDecoder
).IncrementalDecoder
decoder_b = importlib.import_module(
"encodings.{}".format(iana_name_b)
).IncrementalDecoder
id_a: IncrementalDecoder = decoder_a(errors="ignore") id_a: IncrementalDecoder = decoder_a(errors="ignore")
id_b: IncrementalDecoder = decoder_b(errors="ignore") id_b: IncrementalDecoder = decoder_b(errors="ignore")
@ -374,7 +367,7 @@ def cut_sequence_chunks(
strip_sig_or_bom: bool, strip_sig_or_bom: bool,
sig_payload: bytes, sig_payload: bytes,
is_multi_byte_decoder: bool, is_multi_byte_decoder: bool,
decoded_payload: Optional[str] = None, decoded_payload: str | None = None,
) -> Generator[str, None, None]: ) -> Generator[str, None, None]:
if decoded_payload and is_multi_byte_decoder is False: if decoded_payload and is_multi_byte_decoder is False:
for i in offsets: for i in offsets:

View file

@ -2,5 +2,7 @@
Expose version Expose version
""" """
__version__ = "3.4.0" from __future__ import annotations
__version__ = "3.4.2"
VERSION = __version__.split(".") VERSION = __version__.split(".")

View file

@ -8,7 +8,7 @@ from urllib.parse import quote_plus
from typing import Any, Dict, List, Optional, TypeVar from typing import Any, Dict, List, Optional, TypeVar
from plexapi import media, utils from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession, cached_data_property
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
from plexapi.mixins import ( from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
@ -59,14 +59,11 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin):
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.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash') self.artBlurHash = data.attrib.get('artBlurHash')
self.distance = utils.cast(float, data.attrib.get('distance')) self.distance = utils.cast(float, data.attrib.get('distance'))
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '') self.key = data.attrib.get('key', '')
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
@ -75,7 +72,6 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin):
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'audio' self.listType = 'audio'
self.moods = self.findItems(data, media.Mood)
self.musicAnalysisVersion = utils.cast(int, data.attrib.get('musicAnalysisVersion')) self.musicAnalysisVersion = utils.cast(int, data.attrib.get('musicAnalysisVersion'))
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
@ -88,6 +84,18 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin):
self.userRating = utils.cast(float, data.attrib.get('userRating')) self.userRating = utils.cast(float, data.attrib.get('userRating'))
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
@cached_data_property
def fields(self):
return self.findItems(self._data, media.Field)
@cached_data_property
def images(self):
return self.findItems(self._data, media.Image)
@cached_data_property
def moods(self):
return self.findItems(self._data, media.Mood)
def url(self, part): def url(self, part):
""" Returns the full URL for the audio item. Typically used for getting a specific track. """ """ Returns the full URL for the audio item. Typically used for getting a specific track. """
return self._server.url(part, includeToken=True) if part else None return self._server.url(part, includeToken=True) if part else None
@ -205,18 +213,45 @@ class Artist(
Audio._loadData(self, data) Audio._loadData(self, data)
self.albumSort = utils.cast(int, data.attrib.get('albumSort', '-1')) self.albumSort = utils.cast(int, data.attrib.get('albumSort', '-1'))
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.collections = self.findItems(data, media.Collection)
self.countries = self.findItems(data, media.Country)
self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.key = self.key.replace('/children', '') # FIX_BUG_50 self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.locations = self.listAttrs(data, 'path', etag='Location')
self.rating = utils.cast(float, data.attrib.get('rating')) self.rating = utils.cast(float, data.attrib.get('rating'))
self.similar = self.findItems(data, media.Similar)
self.styles = self.findItems(data, media.Style)
self.theme = data.attrib.get('theme') self.theme = data.attrib.get('theme')
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
@cached_data_property
def collections(self):
return self.findItems(self._data, media.Collection)
@cached_data_property
def countries(self):
return self.findItems(self._data, media.Country)
@cached_data_property
def genres(self):
return self.findItems(self._data, media.Genre)
@cached_data_property
def guids(self):
return self.findItems(self._data, media.Guid)
@cached_data_property
def labels(self):
return self.findItems(self._data, media.Label)
@cached_data_property
def locations(self):
return self.listAttrs(self._data, 'path', etag='Location')
@cached_data_property
def similar(self):
return self.findItems(self._data, media.Similar)
@cached_data_property
def styles(self):
return self.findItems(self._data, media.Style)
@cached_data_property
def ultraBlurColors(self):
return self.findItem(self._data, media.UltraBlurColors)
def __iter__(self): def __iter__(self):
for album in self.albums(): for album in self.albums():
@ -355,12 +390,7 @@ class Album(
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
Audio._loadData(self, data) Audio._loadData(self, data)
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.collections = self.findItems(data, media.Collection)
self.formats = self.findItems(data, media.Format)
self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.key = self.key.replace('/children', '') # FIX_BUG_50 self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion')) self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
@ -372,12 +402,41 @@ class Album(
self.parentTitle = data.attrib.get('parentTitle') self.parentTitle = data.attrib.get('parentTitle')
self.rating = utils.cast(float, data.attrib.get('rating')) self.rating = utils.cast(float, data.attrib.get('rating'))
self.studio = data.attrib.get('studio') self.studio = data.attrib.get('studio')
self.styles = self.findItems(data, media.Style)
self.subformats = self.findItems(data, media.Subformat)
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@cached_data_property
def collections(self):
return self.findItems(self._data, media.Collection)
@cached_data_property
def formats(self):
return self.findItems(self._data, media.Format)
@cached_data_property
def genres(self):
return self.findItems(self._data, media.Genre)
@cached_data_property
def guids(self):
return self.findItems(self._data, media.Guid)
@cached_data_property
def labels(self):
return self.findItems(self._data, media.Label)
@cached_data_property
def styles(self):
return self.findItems(self._data, media.Style)
@cached_data_property
def subformats(self):
return self.findItems(self._data, media.Subformat)
@cached_data_property
def ultraBlurColors(self):
return self.findItem(self._data, media.UltraBlurColors)
def __iter__(self): def __iter__(self):
for track in self.tracks(): for track in self.tracks():
yield track yield track
@ -495,11 +554,8 @@ class Track(
Audio._loadData(self, data) Audio._loadData(self, data)
Playable._loadData(self, data) Playable._loadData(self, data)
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.chapters = self.findItems(data, media.Chapter)
self.chapterSource = data.attrib.get('chapterSource') self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection)
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.genres = self.findItems(data, media.Genre)
self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentKey = data.attrib.get('grandparentKey')
@ -507,9 +563,6 @@ class Track(
self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle') self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guids = self.findItems(data, media.Guid)
self.labels = self.findItems(data, media.Label)
self.media = self.findItems(data, media.Media)
self.originalTitle = data.attrib.get('originalTitle') self.originalTitle = data.attrib.get('originalTitle')
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'))
@ -525,6 +578,30 @@ class Track(
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@cached_data_property
def chapters(self):
return self.findItems(self._data, media.Chapter)
@cached_data_property
def collections(self):
return self.findItems(self._data, media.Collection)
@cached_data_property
def genres(self):
return self.findItems(self._data, media.Genre)
@cached_data_property
def guids(self):
return self.findItems(self._data, media.Guid)
@cached_data_property
def labels(self):
return self.findItems(self._data, media.Label)
@cached_data_property
def media(self):
return self.findItems(self._data, media.Media)
@property @property
def locations(self): def locations(self):
""" This does not exist in plex xml response but is added to have a common """ This does not exist in plex xml response but is added to have a common

View file

@ -39,7 +39,42 @@ OPERATORS = {
} }
class PlexObject: class cached_data_property(cached_property):
"""Caching for PlexObject data properties.
This decorator creates properties that cache their values with
automatic invalidation on data changes.
"""
def __set_name__(self, owner, name):
"""Register the annotated property in the parent class's _cached_data_properties set."""
super().__set_name__(owner, name)
if not hasattr(owner, '_cached_data_properties'):
owner._cached_data_properties = set()
owner._cached_data_properties.add(name)
class PlexObjectMeta(type):
"""Metaclass for PlexObject to handle cached_data_properties."""
def __new__(mcs, name, bases, attrs):
cached_data_props = set()
# Merge all _cached_data_properties from parent classes
for base in bases:
if hasattr(base, '_cached_data_properties'):
cached_data_props.update(base._cached_data_properties)
# Find all properties annotated with cached_data_property in the current class
for attr_name, attr_value in attrs.items():
if isinstance(attr_value, cached_data_property):
cached_data_props.add(attr_name)
attrs['_cached_data_properties'] = cached_data_props
return super().__new__(mcs, name, bases, attrs)
class PlexObject(metaclass=PlexObjectMeta):
""" Base class for all Plex objects. """ Base class for all Plex objects.
Parameters: Parameters:
@ -387,7 +422,7 @@ class PlexObject:
return results return results
def reload(self, key=None, **kwargs): def reload(self, key=None, **kwargs):
""" Reload the data for this object from self.key. """ Reload the data for this object.
Parameters: Parameters:
key (string, optional): Override the key to reload. key (string, optional): Override the key to reload.
@ -435,7 +470,7 @@ class PlexObject:
self._initpath = key self._initpath = key
data = self._server.query(key) data = self._server.query(key)
self._overwriteNone = _overwriteNone self._overwriteNone = _overwriteNone
self._loadData(data[0]) self._invalidateCacheAndLoadData(data[0])
self._overwriteNone = True self._overwriteNone = True
return self return self
@ -497,9 +532,35 @@ class PlexObject:
return float(value) return float(value)
return value return value
def _invalidateCacheAndLoadData(self, data):
"""Load attribute values from Plex XML response and invalidate cached properties."""
old_data_id = id(getattr(self, '_data', None))
self._data = data
# If the data's object ID has changed, invalidate cached properties
if id(data) != old_data_id:
self._invalidateCachedProperties()
self._loadData(data)
def _invalidateCachedProperties(self):
"""Invalidate all cached data property values."""
cached_props = getattr(self.__class__, '_cached_data_properties', set())
for prop_name in cached_props:
if prop_name in self.__dict__:
del self.__dict__[prop_name]
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
raise NotImplementedError('Abstract method not implemented.') raise NotImplementedError('Abstract method not implemented.')
def _findAndLoadElem(self, data, **kwargs):
""" Find and load the first element in the data that matches the specified attributes. """
for elem in data:
if self._checkAttrs(elem, **kwargs):
self._invalidateCacheAndLoadData(elem)
@property @property
def _searchType(self): def _searchType(self):
return self.TYPE return self.TYPE
@ -754,7 +815,7 @@ class PlexPartialObject(PlexObject):
class Playable: class Playable:
""" This is a general place to store functions specific to media that is Playable. """ This is a mixin to store functions specific to media that is Playable.
Things were getting mixed up a bit when dealing with Shows, Season, Artists, Things were getting mixed up a bit when dealing with Shows, Season, Artists,
Albums which are all not playable. Albums which are all not playable.
@ -764,6 +825,7 @@ class Playable:
""" """
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue
@ -931,8 +993,8 @@ class Playable:
return self return self
class PlexSession(object): class PlexSession:
""" This is a general place to store functions specific to media that is a Plex Session. """ This is a mixin to store functions specific to media that is a Plex Session.
Attributes: Attributes:
live (bool): True if this is a live tv session. live (bool): True if this is a live tv session.
@ -945,23 +1007,44 @@ class PlexSession(object):
""" """
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.live = utils.cast(bool, data.attrib.get('live', '0')) self.live = utils.cast(bool, data.attrib.get('live', '0'))
self.player = self.findItem(data, etag='Player')
self.session = self.findItem(data, etag='Session')
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) self.sessionKey = utils.cast(int, data.attrib.get('sessionKey'))
self.transcodeSession = self.findItem(data, etag='TranscodeSession')
user = data.find('User') user = data.find('User')
self._username = user.attrib.get('title') self._username = user.attrib.get('title')
self._userId = utils.cast(int, user.attrib.get('id')) self._userId = utils.cast(int, user.attrib.get('id'))
# For backwards compatibility # For backwards compatibility
self.players = [self.player] if self.player else []
self.sessions = [self.session] if self.session else []
self.transcodeSessions = [self.transcodeSession] if self.transcodeSession else []
self.usernames = [self._username] if self._username else [] self.usernames = [self._username] if self._username else []
# `players`, `sessions`, and `transcodeSessions` are returned with properties
# to support lazy loading. See PR #1510
@cached_property @cached_data_property
def player(self):
return self.findItem(self._data, etag='Player')
@cached_data_property
def session(self):
return self.findItem(self._data, etag='Session')
@cached_data_property
def transcodeSession(self):
return self.findItem(self._data, etag='TranscodeSession')
@property
def players(self):
return [self.player] if self.player else []
@property
def sessions(self):
return [self.session] if self.session else []
@property
def transcodeSessions(self):
return [self.transcodeSession] if self.transcodeSession else []
@cached_data_property
def user(self): def user(self):
""" Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin) """ Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin)
or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session. or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session.
@ -978,18 +1061,11 @@ class PlexSession(object):
""" """
return self._reload() return self._reload()
def _reload(self, _autoReload=False, **kwargs): def _reload(self, **kwargs):
""" Perform the actual reload. """ """ Reload the data for the session. """
# Do not auto reload sessions
if _autoReload:
return self
key = self._initpath key = self._initpath
data = self._server.query(key) data = self._server.query(key)
for elem in data: self._findAndLoadElem(data, sessionKey=str(self.sessionKey))
if elem.attrib.get('sessionKey') == str(self.sessionKey):
self._loadData(elem)
break
return self return self
def source(self): def source(self):
@ -1010,8 +1086,8 @@ class PlexSession(object):
return self._server.query(key, params=params) return self._server.query(key, params=params)
class PlexHistory(object): class PlexHistory:
""" This is a general place to store functions specific to media that is a Plex history item. """ This is a mixin to store functions specific to media that is a Plex history item.
Attributes: Attributes:
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
@ -1021,6 +1097,7 @@ class PlexHistory(object):
""" """
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.accountID = utils.cast(int, data.attrib.get('accountID')) self.accountID = utils.cast(int, data.attrib.get('accountID'))
self.deviceID = utils.cast(int, data.attrib.get('deviceID')) self.deviceID = utils.cast(int, data.attrib.get('deviceID'))
self.historyKey = data.attrib.get('historyKey') self.historyKey = data.attrib.get('historyKey')
@ -1124,7 +1201,7 @@ class MediaContainer(
setattr(self, key, getattr(__iterable, key)) setattr(self, key, getattr(__iterable, key))
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.allowSync = utils.cast(int, data.attrib.get('allowSync')) self.allowSync = utils.cast(int, data.attrib.get('allowSync'))
self.augmentationKey = data.attrib.get('augmentationKey') self.augmentationKey = data.attrib.get('augmentationKey')
self.identifier = data.attrib.get('identifier') self.identifier = data.attrib.get('identifier')

View file

@ -115,7 +115,7 @@ class PlexClient(PlexObject):
) )
else: else:
client = data[0] client = data[0]
self._loadData(client) self._invalidateCacheAndLoadData(client)
return self return self
def reload(self): def reload(self):
@ -124,7 +124,6 @@ class PlexClient(PlexObject):
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.deviceClass = data.attrib.get('deviceClass') self.deviceClass = data.attrib.get('deviceClass')
self.machineIdentifier = data.attrib.get('machineIdentifier') self.machineIdentifier = data.attrib.get('machineIdentifier')
self.product = data.attrib.get('product') self.product = data.attrib.get('product')
@ -197,8 +196,7 @@ class PlexClient(PlexObject):
raise NotFound(message) raise NotFound(message)
else: else:
raise BadRequest(message) raise BadRequest(message)
data = utils.cleanXMLString(response.text).encode('utf8') return utils.parseXMLString(response.text)
return ElementTree.fromstring(data) if data.strip() else None
def sendCommand(self, command, proxy=None, **params): def sendCommand(self, command, proxy=None, **params):
""" Convenience wrapper around :func:`~plexapi.client.PlexClient.query` to more easily """ Convenience wrapper around :func:`~plexapi.client.PlexClient.query` to more easily
@ -222,7 +220,7 @@ class PlexClient(PlexObject):
proxy = self._proxyThroughServer if proxy is None else proxy proxy = self._proxyThroughServer if proxy is None else proxy
query = self._server.query if proxy else self.query query = self._server.query if proxy else self.query
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244 # Workaround for ptp. See https://github.com/pushingkarmaorg/python-plexapi/issues/244
t = time.time() t = time.time()
if command == 'timeline/poll': if command == 'timeline/poll':
self._last_call = t self._last_call = t
@ -606,7 +604,7 @@ class ClientTimeline(PlexObject):
key = 'timeline/poll' key = 'timeline/poll'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.address = data.attrib.get('address') self.address = data.attrib.get('address')
self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId')) self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId'))
self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay')) self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay'))

View file

@ -3,7 +3,7 @@ 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
from plexapi.base import PlexPartialObject from plexapi.base import PlexPartialObject, cached_data_property
from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection, ManagedHub from plexapi.library import LibrarySection, ManagedHub
from plexapi.mixins import ( from plexapi.mixins import (
@ -69,7 +69,7 @@ class Collection(
TYPE = 'collection' TYPE = 'collection'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash') self.artBlurHash = data.attrib.get('artBlurHash')
@ -81,12 +81,9 @@ class Collection(
self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0')) self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0'))
self.content = data.attrib.get('content') self.content = data.attrib.get('content')
self.contentRating = data.attrib.get('contentRating') self.contentRating = data.attrib.get('contentRating')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
@ -105,12 +102,24 @@ class Collection(
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort', self.title) self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.userRating = utils.cast(float, data.attrib.get('userRating')) self.userRating = utils.cast(float, data.attrib.get('userRating'))
self._items = None # cache for self.items
self._section = None # cache for self.section @cached_data_property
self._filters = None # cache for self.filters def fields(self):
return self.findItems(self._data, media.Field)
@cached_data_property
def images(self):
return self.findItems(self._data, media.Image)
@cached_data_property
def labels(self):
return self.findItems(self._data, media.Label)
@cached_data_property
def ultraBlurColors(self):
return self.findItem(self._data, media.UltraBlurColors)
def __len__(self): # pragma: no cover def __len__(self): # pragma: no cover
return len(self.items()) return len(self.items())
@ -162,20 +171,26 @@ class Collection(
def children(self): def children(self):
return self.items() return self.items()
@cached_data_property
def _filters(self):
""" Cache for filters. """
return self._parseFilters(self.content)
def filters(self): def filters(self):
""" Returns the search filter dict for smart collection. """ Returns the search filter dict for smart collection.
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
to get the list of items. to get the list of items.
""" """
if self.smart and self._filters is None:
self._filters = self._parseFilters(self.content)
return self._filters return self._filters
@cached_data_property
def _section(self):
""" Cache for section. """
return super(Collection, self).section()
def section(self): def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to. """ Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to.
""" """
if self._section is None:
self._section = super(Collection, self).section()
return self._section return self._section
def item(self, title): def item(self, title):
@ -192,12 +207,14 @@ class Collection(
return item return item
raise NotFound(f'Item with title "{title}" not found in the collection') raise NotFound(f'Item with title "{title}" not found in the collection')
@cached_data_property
def _items(self):
""" Cache for the items. """
key = f'{self.key}/children'
return self.fetchItems(key)
def items(self): def items(self):
""" Returns a list of all items in the collection. """ """ Returns a list of all items in the collection. """
if self._items is None:
key = f'{self.key}/children'
items = self.fetchItems(key)
self._items = items
return self._items return self._items
def visibility(self): def visibility(self):

View file

@ -3,7 +3,7 @@
# Library version # Library version
MAJOR_VERSION = 4 MAJOR_VERSION = 4
MINOR_VERSION = 16 MINOR_VERSION = 17
PATCH_VERSION = 1 PATCH_VERSION = 0
__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}"

View file

@ -6,11 +6,10 @@ from typing import Any, TYPE_CHECKING
import warnings import warnings
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from functools import cached_property
from urllib.parse import parse_qs, quote_plus, urlencode, urlparse from urllib.parse import parse_qs, quote_plus, urlencode, urlparse
from plexapi import log, media, utils from plexapi import log, media, utils
from plexapi.base import OPERATORS, PlexObject from plexapi.base import OPERATORS, PlexObject, cached_data_property
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
from plexapi.mixins import ( from plexapi.mixins import (
MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins, MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins,
@ -39,14 +38,13 @@ class Library(PlexObject):
key = '/library' key = '/library'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.identifier = data.attrib.get('identifier') self.identifier = data.attrib.get('identifier')
self.mediaTagVersion = data.attrib.get('mediaTagVersion') self.mediaTagVersion = data.attrib.get('mediaTagVersion')
self.title1 = data.attrib.get('title1') self.title1 = data.attrib.get('title1')
self.title2 = data.attrib.get('title2') self.title2 = data.attrib.get('title2')
self._sectionsByID = {} # cached sections by key
self._sectionsByTitle = {} # cached sections by title
@cached_data_property
def _loadSections(self): def _loadSections(self):
""" Loads and caches all the library sections. """ """ Loads and caches all the library sections. """
key = '/library/sections' key = '/library/sections'
@ -64,15 +62,23 @@ class Library(PlexObject):
sectionsByID[section.key] = section sectionsByID[section.key] = section
sectionsByTitle[section.title.lower().strip()].append(section) sectionsByTitle[section.title.lower().strip()].append(section)
self._sectionsByID = sectionsByID return sectionsByID, dict(sectionsByTitle)
self._sectionsByTitle = dict(sectionsByTitle)
@property
def _sectionsByID(self):
""" Returns a dictionary of all library sections by ID. """
return self._loadSections[0]
@property
def _sectionsByTitle(self):
""" Returns a dictionary of all library sections by title. """
return self._loadSections[1]
def sections(self): def sections(self):
""" Returns a list of all media sections in this library. Library sections may be any of """ Returns a list of all media sections in this library. Library sections may be any of
:class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`, :class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`,
:class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`. :class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`.
""" """
self._loadSections()
return list(self._sectionsByID.values()) return list(self._sectionsByID.values())
def section(self, title): def section(self, title):
@ -87,8 +93,6 @@ class Library(PlexObject):
:exc:`~plexapi.exceptions.NotFound`: The library section title is not found on the server. :exc:`~plexapi.exceptions.NotFound`: The library section title is not found on the server.
""" """
normalized_title = title.lower().strip() normalized_title = title.lower().strip()
if not self._sectionsByTitle or normalized_title not in self._sectionsByTitle:
self._loadSections()
try: try:
sections = self._sectionsByTitle[normalized_title] sections = self._sectionsByTitle[normalized_title]
except KeyError: except KeyError:
@ -110,8 +114,6 @@ class Library(PlexObject):
Raises: Raises:
:exc:`~plexapi.exceptions.NotFound`: The library section ID is not found on the server. :exc:`~plexapi.exceptions.NotFound`: The library section ID is not found on the server.
""" """
if not self._sectionsByID or sectionID not in self._sectionsByID:
self._loadSections()
try: try:
return self._sectionsByID[sectionID] return self._sectionsByID[sectionID]
except KeyError: except KeyError:
@ -385,7 +387,9 @@ class Library(PlexObject):
if kwargs: if kwargs:
prefs_params = {f'prefs[{k}]': v for k, v in kwargs.items()} prefs_params = {f'prefs[{k}]': v for k, v in kwargs.items()}
part += f'&{urlencode(prefs_params)}' part += f'&{urlencode(prefs_params)}'
return self._server.query(part, method=self._server._session.post) data = self._server.query(part, method=self._server._session.post)
self._invalidateCachedProperties()
return data
def history(self, maxresults=None, mindate=None): def history(self, maxresults=None, mindate=None):
""" Get Play History for all library Sections for the owner. """ Get Play History for all library Sections for the owner.
@ -432,7 +436,7 @@ class LibrarySection(PlexObject):
""" """
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.agent = data.attrib.get('agent') self.agent = data.attrib.get('agent')
self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
@ -441,7 +445,6 @@ class LibrarySection(PlexObject):
self.filters = utils.cast(bool, data.attrib.get('filters')) self.filters = utils.cast(bool, data.attrib.get('filters'))
self.key = utils.cast(int, data.attrib.get('key')) self.key = utils.cast(int, data.attrib.get('key'))
self.language = data.attrib.get('language') self.language = data.attrib.get('language')
self.locations = self.listAttrs(data, 'path', etag='Location')
self.refreshing = utils.cast(bool, data.attrib.get('refreshing')) self.refreshing = utils.cast(bool, data.attrib.get('refreshing'))
self.scanner = data.attrib.get('scanner') self.scanner = data.attrib.get('scanner')
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
@ -449,14 +452,12 @@ class LibrarySection(PlexObject):
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.uuid = data.attrib.get('uuid') self.uuid = data.attrib.get('uuid')
# Private attrs as we don't want a reload.
self._filterTypes = None
self._fieldTypes = None
self._totalViewSize = None
self._totalDuration = None
self._totalStorage = None
@cached_property @cached_data_property
def locations(self):
return self.listAttrs(self._data, 'path', etag='Location')
@cached_data_property
def totalSize(self): def totalSize(self):
""" Returns the total number of items in the library for the default library type. """ """ Returns the total number of items in the library for the default library type. """
return self.totalViewSize(includeCollections=False) return self.totalViewSize(includeCollections=False)
@ -464,16 +465,12 @@ class LibrarySection(PlexObject):
@property @property
def totalDuration(self): def totalDuration(self):
""" Returns the total duration (in milliseconds) of items in the library. """ """ Returns the total duration (in milliseconds) of items in the library. """
if self._totalDuration is None: return self._getTotalDurationStorage[0]
self._getTotalDurationStorage()
return self._totalDuration
@property @property
def totalStorage(self): def totalStorage(self):
""" Returns the total storage (in bytes) of items in the library. """ """ Returns the total storage (in bytes) of items in the library. """
if self._totalStorage is None: return self._getTotalDurationStorage[1]
self._getTotalDurationStorage()
return self._totalStorage
def __getattribute__(self, attr): def __getattribute__(self, attr):
# Intercept to call EditFieldMixin and EditTagMixin methods # Intercept to call EditFieldMixin and EditTagMixin methods
@ -489,6 +486,7 @@ class LibrarySection(PlexObject):
) )
return value return value
@cached_data_property
def _getTotalDurationStorage(self): def _getTotalDurationStorage(self):
""" Queries the Plex server for the total library duration and storage and caches the values. """ """ Queries the Plex server for the total library duration and storage and caches the values. """
data = self._server.query('/media/providers?includeStorage=1') data = self._server.query('/media/providers?includeStorage=1')
@ -499,8 +497,10 @@ class LibrarySection(PlexObject):
) )
directory = next(iter(data.findall(xpath)), None) directory = next(iter(data.findall(xpath)), None)
if directory: if directory:
self._totalDuration = utils.cast(int, directory.attrib.get('durationTotal')) totalDuration = utils.cast(int, directory.attrib.get('durationTotal'))
self._totalStorage = utils.cast(int, directory.attrib.get('storageTotal')) totalStorage = utils.cast(int, directory.attrib.get('storageTotal'))
return totalDuration, totalStorage
return None, None
def totalViewSize(self, libtype=None, includeCollections=True): def totalViewSize(self, libtype=None, includeCollections=True):
""" Returns the total number of items in the library for a specified libtype. """ Returns the total number of items in the library for a specified libtype.
@ -531,18 +531,20 @@ class LibrarySection(PlexObject):
def delete(self): def delete(self):
""" Delete a library section. """ """ Delete a library section. """
try: try:
return self._server.query(f'/library/sections/{self.key}', method=self._server._session.delete) data = self._server.query(f'/library/sections/{self.key}', method=self._server._session.delete)
self._server.library._invalidateCachedProperties()
return data
except BadRequest: # pragma: no cover except BadRequest: # pragma: no cover
msg = f'Failed to delete library {self.key}' msg = f'Failed to delete library {self.key}'
msg += 'You may need to allow this permission in your Plex settings.' msg += 'You may need to allow this permission in your Plex settings.'
log.error(msg) log.error(msg)
raise raise
def reload(self): def _reload(self, **kwargs):
""" Reload the data for the library section. """ """ Reload the data for the library section. """
self._server.library._loadSections() key = self._initpath
newLibrary = self._server.library.sectionByID(self.key) data = self._server.query(key)
self.__dict__.update(newLibrary.__dict__) self._findAndLoadElem(data, key=str(self.key))
return self return self
def edit(self, agent=None, **kwargs): def edit(self, agent=None, **kwargs):
@ -871,6 +873,7 @@ class LibrarySection(PlexObject):
self._server.query(key, method=self._server._session.delete) self._server.query(key, method=self._server._session.delete)
return self return self
@cached_data_property
def _loadFilters(self): def _loadFilters(self):
""" Retrieves and caches the list of :class:`~plexapi.library.FilteringType` and """ Retrieves and caches the list of :class:`~plexapi.library.FilteringType` and
list of :class:`~plexapi.library.FilteringFieldType` for this library section. list of :class:`~plexapi.library.FilteringFieldType` for this library section.
@ -880,23 +883,23 @@ class LibrarySection(PlexObject):
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') filterTypes = self.findItems(data, FilteringType, rtag='Meta')
self._fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta') fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta')
if self.TYPE != 'photo': # No collections for photo library if self.TYPE != 'photo': # No collections for photo library
key = _key.format(key=self.key, filter='collections') key = _key.format(key=self.key, filter='collections')
data = self._server.query(key) data = self._server.query(key)
self._filterTypes.extend(self.findItems(data, FilteringType, rtag='Meta')) filterTypes.extend(self.findItems(data, FilteringType, rtag='Meta'))
# Manually add guid field type, only allowing "is" operator # Manually add guid field type, only allowing "is" operator
guidFieldType = '<FieldType type="guid"><Operator key="=" title="is"/></FieldType>' guidFieldType = '<FieldType type="guid"><Operator key="=" title="is"/></FieldType>'
self._fieldTypes.append(self._manuallyLoadXML(guidFieldType, FilteringFieldType)) fieldTypes.append(self._manuallyLoadXML(guidFieldType, FilteringFieldType))
return filterTypes, fieldTypes
def filterTypes(self): def filterTypes(self):
""" Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """ """ Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """
if self._filterTypes is None: return self._loadFilters[0]
self._loadFilters()
return self._filterTypes
def getFilterType(self, libtype=None): def getFilterType(self, libtype=None):
""" Returns a :class:`~plexapi.library.FilteringType` for a specified libtype. """ Returns a :class:`~plexapi.library.FilteringType` for a specified libtype.
@ -918,9 +921,7 @@ class LibrarySection(PlexObject):
def fieldTypes(self): def fieldTypes(self):
""" Returns a list of available :class:`~plexapi.library.FilteringFieldType` for this library section. """ """ Returns a list of available :class:`~plexapi.library.FilteringFieldType` for this library section. """
if self._fieldTypes is None: return self._loadFilters[1]
self._loadFilters()
return self._fieldTypes
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.
@ -1969,7 +1970,7 @@ class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditM
def stations(self): def stations(self):
""" Returns a list of :class:`~plexapi.playlist.Playlist` stations in this section. """ """ Returns a list of :class:`~plexapi.playlist.Playlist` stations in this section. """
return next((hub.items for hub in self.hubs() if hub.context == 'hub.music.stations'), None) return next((hub._partialItems for hub in self.hubs() if hub.context == 'hub.music.stations'), None)
def searchArtists(self, **kwargs): def searchArtists(self, **kwargs):
""" Search for an artist. See :func:`~plexapi.library.LibrarySection.search` for usage. """ """ Search for an artist. See :func:`~plexapi.library.LibrarySection.search` for usage. """
@ -2165,7 +2166,6 @@ class LibraryTimeline(PlexObject):
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.size = utils.cast(int, data.attrib.get('size')) self.size = utils.cast(int, data.attrib.get('size'))
self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
@ -2194,7 +2194,6 @@ class Location(PlexObject):
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.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.path = data.attrib.get('path') self.path = data.attrib.get('path')
@ -2208,9 +2207,10 @@ class Hub(PlexObject):
context (str): The context of the hub. context (str): The context of the hub.
hubKey (str): API URL for these specific hub items. hubKey (str): API URL for these specific hub items.
hubIdentifier (str): The identifier of the hub. hubIdentifier (str): The identifier of the hub.
items (list): List of items in the hub. items (list): List of items in the hub (automatically loads all items if more is True).
key (str): API URL for the hub. key (str): API URL for the hub.
more (bool): True if there are more items to load (call reload() to fetch all items). random (bool): True if the items in the hub are randomized.
more (bool): True if there are more items to load (call items to fetch all items).
size (int): The number of items in the hub. size (int): The number of items in the hub.
style (str): The style of the hub. style (str): The style of the hub.
title (str): The title of the hub. title (str): The title of the hub.
@ -2220,36 +2220,57 @@ class Hub(PlexObject):
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.context = data.attrib.get('context') self.context = data.attrib.get('context')
self.hubKey = data.attrib.get('hubKey') self.hubKey = data.attrib.get('hubKey')
self.hubIdentifier = data.attrib.get('hubIdentifier') self.hubIdentifier = data.attrib.get('hubIdentifier')
self.items = self.findItems(data)
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.more = utils.cast(bool, data.attrib.get('more')) self.more = utils.cast(bool, data.attrib.get('more'))
self.random = utils.cast(bool, data.attrib.get('random', '0'))
self.size = utils.cast(int, data.attrib.get('size')) self.size = utils.cast(int, data.attrib.get('size'))
self.style = data.attrib.get('style') self.style = data.attrib.get('style')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self._section = None # cache for self.section
def __len__(self): def __len__(self):
return self.size return self.size
def reload(self): @cached_data_property
""" Reloads the hub to fetch all items in the hub. """ def _partialItems(self):
if self.more and self.key: """ Cache for partial items. """
self.items = self.fetchItems(self.key) return self.findItems(self._data)
@cached_data_property
def _items(self):
""" Cache for items. """
if self.more and self.key: # If there are more items to load, fetch them
items = self.fetchItems(self.key)
self.more = False self.more = False
self.size = len(self.items) self.size = len(items)
return items
# Otherwise, all the data is in the initial _data XML response
return self._partialItems
def items(self):
""" Returns a list of all items in the hub. """
return self._items
@cached_data_property
def _section(self):
""" Cache for section. """
return self._server.library.sectionByID(self.librarySectionID)
def section(self): def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this hub belongs to. """ Returns the :class:`~plexapi.library.LibrarySection` this hub belongs to.
""" """
if self._section is None:
self._section = self._server.library.sectionByID(self.librarySectionID)
return self._section return self._section
def _reload(self, **kwargs):
""" Reload the data for the hub. """
key = self._initpath
data = self._server.query(key)
self._findAndLoadElem(data, hubIdentifier=self.hubIdentifier)
return self
class LibraryMediaTag(PlexObject): class LibraryMediaTag(PlexObject):
""" Base class of library media tags. """ Base class of library media tags.
@ -2279,7 +2300,6 @@ class LibraryMediaTag(PlexObject):
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.count = utils.cast(int, data.attrib.get('count')) self.count = utils.cast(int, data.attrib.get('count'))
self.filter = data.attrib.get('filter') self.filter = data.attrib.get('filter')
self.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
@ -2668,22 +2688,25 @@ class FilteringType(PlexObject):
return f"<{':'.join([p for p in [self.__class__.__name__, _type] if p])}>" return f"<{':'.join([p for p in [self.__class__.__name__, _type] if p])}>"
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.active = utils.cast(bool, data.attrib.get('active', '0')) self.active = utils.cast(bool, data.attrib.get('active', '0'))
self.fields = self.findItems(data, FilteringField)
self.filters = self.findItems(data, FilteringFilter)
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.sorts = self.findItems(data, FilteringSort)
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self._librarySectionID = self._parent().key self._librarySectionID = self._parent().key
# Add additional manual filters, sorts, and fields which are available @cached_data_property
# but not exposed on the Plex server def fields(self):
self.filters += self._manualFilters() return self.findItems(self._data, FilteringField) + self._manualFields()
self.sorts += self._manualSorts()
self.fields += self._manualFields() @cached_data_property
def filters(self):
return self.findItems(self._data, FilteringFilter) + self._manualFilters()
@cached_data_property
def sorts(self):
return self.findItems(self._data, FilteringSort) + self._manualSorts()
def _manualFilters(self): def _manualFilters(self):
""" Manually add additional filters which are available """ Manually add additional filters which are available
@ -2863,7 +2886,7 @@ class FilteringFilter(PlexObject):
TAG = 'Filter' TAG = 'Filter'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.filter = data.attrib.get('filter') self.filter = data.attrib.get('filter')
self.filterType = data.attrib.get('filterType') self.filterType = data.attrib.get('filterType')
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
@ -2889,7 +2912,6 @@ class FilteringSort(PlexObject):
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.active = utils.cast(bool, data.attrib.get('active', '0')) self.active = utils.cast(bool, data.attrib.get('active', '0'))
self.activeDirection = data.attrib.get('activeDirection') self.activeDirection = data.attrib.get('activeDirection')
self.default = data.attrib.get('default') self.default = data.attrib.get('default')
@ -2914,7 +2936,6 @@ class FilteringField(PlexObject):
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.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
@ -2937,9 +2958,11 @@ class FilteringFieldType(PlexObject):
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.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.operators = self.findItems(data, FilteringOperator)
@cached_data_property
def operators(self):
return self.findItems(self._data, FilteringOperator)
class FilteringOperator(PlexObject): class FilteringOperator(PlexObject):
@ -2976,7 +2999,6 @@ class FilterChoice(PlexObject):
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.fastKey = data.attrib.get('fastKey') self.fastKey = data.attrib.get('fastKey')
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
@ -3006,7 +3028,6 @@ class ManagedHub(PlexObject):
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.deletable = utils.cast(bool, data.attrib.get('deletable', True)) self.deletable = utils.cast(bool, data.attrib.get('deletable', True))
self.homeVisibility = data.attrib.get('homeVisibility', 'none') self.homeVisibility = data.attrib.get('homeVisibility', 'none')
self.identifier = data.attrib.get('identifier') self.identifier = data.attrib.get('identifier')
@ -3020,11 +3041,11 @@ class ManagedHub(PlexObject):
parent = self._parent() parent = self._parent()
self.librarySectionID = parent.key if isinstance(parent, LibrarySection) else parent.librarySectionID self.librarySectionID = parent.key if isinstance(parent, LibrarySection) else parent.librarySectionID
def reload(self): def _reload(self, **kwargs):
""" Reload the data for this managed hub. """ """ Reload the data for this managed hub. """
key = f'/hubs/sections/{self.librarySectionID}/manage' key = f'/hubs/sections/{self.librarySectionID}/manage'
hub = self.fetchItem(key, self.__class__, identifier=self.identifier) data = self._server.query(key)
self.__dict__.update(hub.__dict__) self._findAndLoadElem(data, identifier=self.identifier)
return self return self
def move(self, after=None): def move(self, after=None):
@ -3170,7 +3191,6 @@ class FirstCharacter(PlexObject):
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.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.size = data.attrib.get('size') self.size = data.attrib.get('size')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
@ -3191,6 +3211,7 @@ class Path(PlexObject):
TAG = 'Path' TAG = 'Path'
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.home = utils.cast(bool, data.attrib.get('home')) self.home = utils.cast(bool, data.attrib.get('home'))
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.network = utils.cast(bool, data.attrib.get('network')) self.network = utils.cast(bool, data.attrib.get('network'))
@ -3220,6 +3241,7 @@ class File(PlexObject):
TAG = 'File' TAG = 'File'
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.path = data.attrib.get('path') self.path = data.attrib.get('path')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
@ -3268,41 +3290,83 @@ class Common(PlexObject):
TAG = 'Common' TAG = 'Common'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating') self.contentRating = data.attrib.get('contentRating')
self.countries = self.findItems(data, media.Country)
self.directors = self.findItems(data, media.Director)
self.editionTitle = data.attrib.get('editionTitle') self.editionTitle = data.attrib.get('editionTitle')
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentTitle = data.attrib.get('grandparentTitle') self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.guids = self.findItems(data, media.Guid)
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.labels = self.findItems(data, media.Label)
self.mixedFields = data.attrib.get('mixedFields').split(',') self.mixedFields = data.attrib.get('mixedFields').split(',')
self.moods = self.findItems(data, media.Mood)
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt')) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'))
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentTitle = data.attrib.get('parentTitle') self.parentTitle = data.attrib.get('parentTitle')
self.producers = self.findItems(data, media.Producer)
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.ratings = self.findItems(data, media.Rating)
self.roles = self.findItems(data, media.Role)
self.studio = data.attrib.get('studio') self.studio = data.attrib.get('studio')
self.styles = self.findItems(data, media.Style)
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.tagline = data.attrib.get('tagline') self.tagline = data.attrib.get('tagline')
self.tags = self.findItems(data, media.Tag)
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort') self.titleSort = data.attrib.get('titleSort')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.writers = self.findItems(data, media.Writer)
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@cached_data_property
def collections(self):
return self.findItems(self._data, media.Collection)
@cached_data_property
def countries(self):
return self.findItems(self._data, media.Country)
@cached_data_property
def directors(self):
return self.findItems(self._data, media.Director)
@cached_data_property
def fields(self):
return self.findItems(self._data, media.Field)
@cached_data_property
def genres(self):
return self.findItems(self._data, media.Genre)
@cached_data_property
def guids(self):
return self.findItems(self._data, media.Guid)
@cached_data_property
def labels(self):
return self.findItems(self._data, media.Label)
@cached_data_property
def moods(self):
return self.findItems(self._data, media.Mood)
@cached_data_property
def producers(self):
return self.findItems(self._data, media.Producer)
@cached_data_property
def ratings(self):
return self.findItems(self._data, media.Rating)
@cached_data_property
def roles(self):
return self.findItems(self._data, media.Role)
@cached_data_property
def styles(self):
return self.findItems(self._data, media.Style)
@cached_data_property
def tags(self):
return self.findItems(self._data, media.Tag)
@cached_data_property
def writers(self):
return self.findItems(self._data, media.Writer)
def __repr__(self): def __repr__(self):
return '<%s:%s:%s>' % ( return '<%s:%s:%s>' % (
self.__class__.__name__, self.__class__.__name__,

View file

@ -1,10 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import xml
from pathlib import Path from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
from xml.etree import ElementTree
from plexapi import log, settings, utils from plexapi import log, settings, utils
from plexapi.base import PlexObject from plexapi.base import PlexObject, cached_data_property
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
from plexapi.utils import deprecated from plexapi.utils import deprecated
@ -51,7 +51,6 @@ class Media(PlexObject):
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.aspectRatio = utils.cast(float, data.attrib.get('aspectRatio')) self.aspectRatio = utils.cast(float, data.attrib.get('aspectRatio'))
self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) self.audioChannels = utils.cast(int, data.attrib.get('audioChannels'))
self.audioCodec = data.attrib.get('audioCodec') self.audioCodec = data.attrib.get('audioCodec')
@ -64,7 +63,6 @@ class Media(PlexObject):
self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets')) self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets'))
self.hasVoiceActivity = utils.cast(bool, data.attrib.get('hasVoiceActivity', '0')) self.hasVoiceActivity = utils.cast(bool, data.attrib.get('hasVoiceActivity', '0'))
self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming'))
self.parts = self.findItems(data, MediaPart)
self.proxyType = utils.cast(int, data.attrib.get('proxyType')) self.proxyType = utils.cast(int, data.attrib.get('proxyType'))
self.selected = utils.cast(bool, data.attrib.get('selected')) self.selected = utils.cast(bool, data.attrib.get('selected'))
self.target = data.attrib.get('target') self.target = data.attrib.get('target')
@ -87,6 +85,10 @@ class Media(PlexObject):
parent = self._parent() parent = self._parent()
self._parentKey = parent.key self._parentKey = parent.key
@cached_data_property
def parts(self):
return self.findItems(self._data, MediaPart)
@property @property
def isOptimizedVersion(self): def isOptimizedVersion(self):
""" Returns True if the media is a Plex optimized version. """ """ Returns True if the media is a Plex optimized version. """
@ -138,7 +140,6 @@ class MediaPart(PlexObject):
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.accessible = utils.cast(bool, data.attrib.get('accessible')) self.accessible = utils.cast(bool, data.attrib.get('accessible'))
self.audioProfile = data.attrib.get('audioProfile') self.audioProfile = data.attrib.get('audioProfile')
self.container = data.attrib.get('container') self.container = data.attrib.get('container')
@ -268,7 +269,6 @@ class MediaPartStream(PlexObject):
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.bitrate = utils.cast(int, data.attrib.get('bitrate')) self.bitrate = utils.cast(int, data.attrib.get('bitrate'))
self.codec = data.attrib.get('codec') self.codec = data.attrib.get('codec')
self.decision = data.attrib.get('decision') self.decision = data.attrib.get('decision')
@ -386,6 +386,7 @@ class AudioStream(MediaPartStream):
profile (str): The profile of the audio stream. profile (str): The profile of the audio stream.
samplingRate (int): The sampling rate of the audio stream (ex: xxx) samplingRate (int): The sampling rate of the audio stream (ex: xxx)
streamIdentifier (int): The stream identifier of the audio stream. streamIdentifier (int): The stream identifier of the audio stream.
visualImpaired (bool): True if this is a visually impaired (AD) audio stream.
Track_only_attributes: The following attributes are only available for tracks. Track_only_attributes: The following attributes are only available for tracks.
@ -413,6 +414,7 @@ class AudioStream(MediaPartStream):
self.profile = data.attrib.get('profile') self.profile = data.attrib.get('profile')
self.samplingRate = utils.cast(int, data.attrib.get('samplingRate')) self.samplingRate = utils.cast(int, data.attrib.get('samplingRate'))
self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier')) self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier'))
self.visualImpaired = utils.cast(bool, data.attrib.get('visualImpaired', '0'))
# Track only attributes # Track only attributes
self.albumGain = utils.cast(float, data.attrib.get('albumGain')) self.albumGain = utils.cast(float, data.attrib.get('albumGain'))
@ -523,6 +525,7 @@ class Session(PlexObject):
TAG = 'Session' TAG = 'Session'
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.id = data.attrib.get('id') self.id = data.attrib.get('id')
self.bandwidth = utils.cast(int, data.attrib.get('bandwidth')) self.bandwidth = utils.cast(int, data.attrib.get('bandwidth'))
self.location = data.attrib.get('location') self.location = data.attrib.get('location')
@ -569,7 +572,6 @@ class TranscodeSession(PlexObject):
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.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) self.audioChannels = utils.cast(int, data.attrib.get('audioChannels'))
self.audioCodec = data.attrib.get('audioCodec') self.audioCodec = data.attrib.get('audioCodec')
self.audioDecision = data.attrib.get('audioDecision') self.audioDecision = data.attrib.get('audioDecision')
@ -610,7 +612,7 @@ class TranscodeJob(PlexObject):
TAG = 'TranscodeJob' TAG = 'TranscodeJob'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.generatorID = data.attrib.get('generatorID') self.generatorID = data.attrib.get('generatorID')
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.progress = data.attrib.get('progress') self.progress = data.attrib.get('progress')
@ -629,7 +631,7 @@ class Optimized(PlexObject):
TAG = 'Item' TAG = 'Item'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.id = data.attrib.get('id') self.id = data.attrib.get('id')
self.composite = data.attrib.get('composite') self.composite = data.attrib.get('composite')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
@ -667,7 +669,7 @@ class Conversion(PlexObject):
TAG = 'Video' TAG = 'Video'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.addedAt = data.attrib.get('addedAt') self.addedAt = data.attrib.get('addedAt')
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.chapterSource = data.attrib.get('chapterSource') self.chapterSource = data.attrib.get('chapterSource')
@ -743,7 +745,6 @@ class MediaTag(PlexObject):
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.filter = data.attrib.get('filter') self.filter = data.attrib.get('filter')
self.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
@ -954,7 +955,6 @@ class Guid(PlexObject):
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.id = data.attrib.get('id') self.id = data.attrib.get('id')
@ -972,7 +972,6 @@ class Image(PlexObject):
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.alt = data.attrib.get('alt') self.alt = data.attrib.get('alt')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.url = data.attrib.get('url') self.url = data.attrib.get('url')
@ -994,7 +993,6 @@ class Rating(PlexObject):
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.image = data.attrib.get('image') self.image = data.attrib.get('image')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.value = utils.cast(float, data.attrib.get('value')) self.value = utils.cast(float, data.attrib.get('value'))
@ -1017,7 +1015,7 @@ class Review(PlexObject):
TAG = 'Review' TAG = 'Review'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.filter = data.attrib.get('filter') self.filter = data.attrib.get('filter')
self.id = utils.cast(int, data.attrib.get('id', 0)) self.id = utils.cast(int, data.attrib.get('id', 0))
self.image = data.attrib.get('image') self.image = data.attrib.get('image')
@ -1042,7 +1040,6 @@ class UltraBlurColors(PlexObject):
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.bottomLeft = data.attrib.get('bottomLeft') self.bottomLeft = data.attrib.get('bottomLeft')
self.bottomRight = data.attrib.get('bottomRight') self.bottomRight = data.attrib.get('bottomRight')
self.topLeft = data.attrib.get('topLeft') self.topLeft = data.attrib.get('topLeft')
@ -1063,7 +1060,7 @@ class BaseResource(PlexObject):
""" """
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.provider = data.attrib.get('provider') self.provider = data.attrib.get('provider')
self.ratingKey = data.attrib.get('ratingKey') self.ratingKey = data.attrib.get('ratingKey')
@ -1075,7 +1072,7 @@ class BaseResource(PlexObject):
data = f'{key}?url={quote_plus(self.ratingKey)}' data = f'{key}?url={quote_plus(self.ratingKey)}'
try: try:
self._server.query(data, method=self._server._session.put) self._server.query(data, method=self._server._session.put)
except xml.etree.ElementTree.ParseError: except ElementTree.ParseError:
pass pass
@property @property
@ -1138,7 +1135,7 @@ class Chapter(PlexObject):
return f"<{':'.join([self.__class__.__name__, name, offsets])}>" return f"<{':'.join([self.__class__.__name__, name, offsets])}>"
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.end = utils.cast(int, data.attrib.get('endTimeOffset')) self.end = utils.cast(int, data.attrib.get('endTimeOffset'))
self.filter = data.attrib.get('filter') self.filter = data.attrib.get('filter')
self.id = utils.cast(int, data.attrib.get('id', 0)) self.id = utils.cast(int, data.attrib.get('id', 0))
@ -1172,7 +1169,7 @@ class Marker(PlexObject):
return f"<{':'.join([self.__class__.__name__, name, offsets])}>" return f"<{':'.join([self.__class__.__name__, name, offsets])}>"
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.end = utils.cast(int, data.attrib.get('endTimeOffset')) self.end = utils.cast(int, data.attrib.get('endTimeOffset'))
self.final = utils.cast(bool, data.attrib.get('final')) self.final = utils.cast(bool, data.attrib.get('final'))
self.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
@ -1206,7 +1203,7 @@ class Field(PlexObject):
TAG = 'Field' TAG = 'Field'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.locked = utils.cast(bool, data.attrib.get('locked')) self.locked = utils.cast(bool, data.attrib.get('locked'))
self.name = data.attrib.get('name') self.name = data.attrib.get('name')
@ -1226,7 +1223,7 @@ class SearchResult(PlexObject):
return f"<{':'.join([p for p in [self.__class__.__name__, name, score] if p])}>" return f"<{':'.join([p for p in [self.__class__.__name__, name, score] if p])}>"
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.lifespanEnded = data.attrib.get('lifespanEnded') self.lifespanEnded = data.attrib.get('lifespanEnded')
self.name = data.attrib.get('name') self.name = data.attrib.get('name')
@ -1248,7 +1245,7 @@ class Agent(PlexObject):
return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>"
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.hasAttribution = data.attrib.get('hasAttribution') self.hasAttribution = data.attrib.get('hasAttribution')
self.hasPrefs = data.attrib.get('hasPrefs') self.hasPrefs = data.attrib.get('hasPrefs')
self.identifier = data.attrib.get('identifier') self.identifier = data.attrib.get('identifier')
@ -1256,12 +1253,17 @@ class Agent(PlexObject):
self.primary = data.attrib.get('primary') self.primary = data.attrib.get('primary')
self.shortIdentifier = self.identifier.rsplit('.', 1)[1] self.shortIdentifier = self.identifier.rsplit('.', 1)[1]
@cached_data_property
def languageCodes(self):
if 'mediaType' in self._initpath: if 'mediaType' in self._initpath:
self.languageCodes = self.listAttrs(data, 'code', etag='Language') return self.listAttrs(self._data, 'code', etag='Language')
self.mediaTypes = [] return []
else:
self.languageCodes = [] @cached_data_property
self.mediaTypes = self.findItems(data, cls=AgentMediaType) def mediaTypes(self):
if 'mediaType' not in self._initpath:
return self.findItems(self._data, cls=AgentMediaType)
return []
@property @property
@deprecated('use "languageCodes" instead') @deprecated('use "languageCodes" instead')
@ -1291,10 +1293,14 @@ class AgentMediaType(Agent):
return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>"
def _loadData(self, data): def _loadData(self, data):
self.languageCodes = self.listAttrs(data, 'code', etag='Language') """ Load attribute values from Plex XML response. """
self.mediaType = utils.cast(int, data.attrib.get('mediaType')) self.mediaType = utils.cast(int, data.attrib.get('mediaType'))
self.name = data.attrib.get('name') self.name = data.attrib.get('name')
@cached_data_property
def languageCodes(self):
return self.listAttrs(self._data, 'code', etag='Language')
@property @property
@deprecated('use "languageCodes" instead') @deprecated('use "languageCodes" instead')
def languageCode(self): def languageCode(self):
@ -1325,7 +1331,7 @@ class Availability(PlexObject):
return f'<{self.__class__.__name__}:{self.platform}:{self.offerType}>' return f'<{self.__class__.__name__}:{self.platform}:{self.offerType}>'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.country = data.attrib.get('country') self.country = data.attrib.get('country')
self.offerType = data.attrib.get('offerType') self.offerType = data.attrib.get('offerType')
self.platform = data.attrib.get('platform') self.platform = data.attrib.get('platform')

View file

@ -4,13 +4,12 @@ import html
import threading import threading
import time import time
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from xml.etree import ElementTree
import requests import requests
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER,
log, logfilter, utils) log, logfilter, utils)
from plexapi.base import PlexObject from plexapi.base import PlexObject, cached_data_property
from plexapi.client import PlexClient from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, TwoFactorRequired from plexapi.exceptions import BadRequest, NotFound, Unauthorized, TwoFactorRequired
from plexapi.library import LibrarySection from plexapi.library import LibrarySection
@ -144,7 +143,6 @@ class MyPlexAccount(PlexObject):
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._token = logfilter.add_secret(data.attrib.get('authToken')) self._token = logfilter.add_secret(data.attrib.get('authToken'))
self._webhooks = [] self._webhooks = []
@ -185,7 +183,6 @@ class MyPlexAccount(PlexObject):
subscription = data.find('subscription') subscription = data.find('subscription')
self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active')) self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active'))
self.subscriptionDescription = data.attrib.get('subscriptionDescription') self.subscriptionDescription = data.attrib.get('subscriptionDescription')
self.subscriptionFeatures = self.listAttrs(subscription, 'id', rtag='features', etag='feature')
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')
@ -201,21 +198,31 @@ class MyPlexAccount(PlexObject):
self.profileDefaultSubtitleAccessibility = utils.cast(int, profile.attrib.get('defaultSubtitleAccessibility')) self.profileDefaultSubtitleAccessibility = utils.cast(int, profile.attrib.get('defaultSubtitleAccessibility'))
self.profileDefaultSubtitleForces = utils.cast(int, profile.attrib.get('defaultSubtitleForces')) self.profileDefaultSubtitleForces = utils.cast(int, profile.attrib.get('defaultSubtitleForces'))
self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement')
self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role')
# TODO: Fetch missing MyPlexAccount services # TODO: Fetch missing MyPlexAccount services
self.services = None self.services = None
@cached_data_property
def subscriptionFeatures(self):
subscription = self._data.find('subscription')
return self.listAttrs(subscription, 'id', rtag='features', etag='feature')
@cached_data_property
def entitlements(self):
return self.listAttrs(self._data, 'id', rtag='entitlements', etag='entitlement')
@cached_data_property
def roles(self):
return self.listAttrs(self._data, 'id', rtag='roles', etag='role')
@property @property
def authenticationToken(self): def authenticationToken(self):
""" Returns the authentication token for the account. Alias for ``authToken``. """ """ Returns the authentication token for the account. Alias for ``authToken``. """
return self.authToken return self.authToken
def _reload(self, key=None, **kwargs): def _reload(self, **kwargs):
""" Perform the actual reload. """ """ Perform the actual reload. """
data = self.query(self.key) data = self.query(self.key)
self._loadData(data) self._invalidateCacheAndLoadData(data)
return self return self
def _headers(self, **kwargs): def _headers(self, **kwargs):
@ -250,8 +257,7 @@ class MyPlexAccount(PlexObject):
return response.json() return response.json()
elif 'text/plain' in response.headers.get('Content-Type', ''): elif 'text/plain' in response.headers.get('Content-Type', ''):
return response.text.strip() return response.text.strip()
data = utils.cleanXMLString(response.text).encode('utf8') return utils.parseXMLString(response.text)
return ElementTree.fromstring(data) if data.strip() else None
def ping(self): def ping(self):
""" Ping the Plex.tv API. """ Ping the Plex.tv API.
@ -1206,7 +1212,6 @@ class MyPlexUser(PlexObject):
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.friend = self._initpath == self.key self.friend = self._initpath == self.key
self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels')) self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels'))
@ -1225,10 +1230,13 @@ class MyPlexUser(PlexObject):
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title', '') self.title = data.attrib.get('title', '')
self.username = data.attrib.get('username', '') self.username = data.attrib.get('username', '')
self.servers = self.findItems(data, MyPlexServerShare)
for server in self.servers: for server in self.servers:
server.accountID = self.id server.accountID = self.id
@cached_data_property
def servers(self):
return self.findItems(self._data, MyPlexServerShare)
def get_token(self, machineIdentifier): def get_token(self, machineIdentifier):
try: try:
for item in self._server.query(self._server.FRIENDINVITE.format(machineId=machineIdentifier)): for item in self._server.query(self._server.FRIENDINVITE.format(machineId=machineIdentifier)):
@ -1283,7 +1291,6 @@ class MyPlexInvite(PlexObject):
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.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.email = data.attrib.get('email') self.email = data.attrib.get('email')
self.friend = utils.cast(bool, data.attrib.get('friend')) self.friend = utils.cast(bool, data.attrib.get('friend'))
@ -1291,12 +1298,15 @@ class MyPlexInvite(PlexObject):
self.home = utils.cast(bool, data.attrib.get('home')) self.home = utils.cast(bool, data.attrib.get('home'))
self.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.server = utils.cast(bool, data.attrib.get('server')) self.server = utils.cast(bool, data.attrib.get('server'))
self.servers = self.findItems(data, MyPlexServerShare)
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
self.username = data.attrib.get('username', '') self.username = data.attrib.get('username', '')
for server in self.servers: for server in self.servers:
server.accountID = self.id server.accountID = self.id
@cached_data_property
def servers(self):
return self.findItems(self._data, MyPlexServerShare)
class Section(PlexObject): class Section(PlexObject):
""" This refers to a shared section. The raw xml for the data presented here """ This refers to a shared section. The raw xml for the data presented here
@ -1314,7 +1324,7 @@ class Section(PlexObject):
TAG = 'Section' TAG = 'Section'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.key = utils.cast(int, data.attrib.get('key')) self.key = utils.cast(int, data.attrib.get('key'))
self.shared = utils.cast(bool, data.attrib.get('shared', '0')) self.shared = utils.cast(bool, data.attrib.get('shared', '0'))
@ -1353,7 +1363,6 @@ class MyPlexServerShare(PlexObject):
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.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.accountID = utils.cast(int, data.attrib.get('accountID')) self.accountID = utils.cast(int, data.attrib.get('accountID'))
self.serverId = utils.cast(int, data.attrib.get('serverId')) self.serverId = utils.cast(int, data.attrib.get('serverId'))
@ -1437,10 +1446,9 @@ class MyPlexResource(PlexObject):
DEFAULT_SCHEME_ORDER = ['https', 'http'] DEFAULT_SCHEME_ORDER = ['https', 'http']
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.accessToken = logfilter.add_secret(data.attrib.get('accessToken')) self.accessToken = logfilter.add_secret(data.attrib.get('accessToken'))
self.clientIdentifier = data.attrib.get('clientIdentifier') self.clientIdentifier = data.attrib.get('clientIdentifier')
self.connections = self.findItems(data, ResourceConnection, rtag='connections')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'), "%Y-%m-%dT%H:%M:%SZ") self.createdAt = utils.toDatetime(data.attrib.get('createdAt'), "%Y-%m-%dT%H:%M:%SZ")
self.device = data.attrib.get('device') self.device = data.attrib.get('device')
self.dnsRebindingProtection = utils.cast(bool, data.attrib.get('dnsRebindingProtection')) self.dnsRebindingProtection = utils.cast(bool, data.attrib.get('dnsRebindingProtection'))
@ -1462,6 +1470,10 @@ class MyPlexResource(PlexObject):
self.sourceTitle = data.attrib.get('sourceTitle') self.sourceTitle = data.attrib.get('sourceTitle')
self.synced = utils.cast(bool, data.attrib.get('synced')) self.synced = utils.cast(bool, data.attrib.get('synced'))
@cached_data_property
def connections(self):
return self.findItems(self._data, ResourceConnection, rtag='connections')
def preferred_connections( def preferred_connections(
self, self,
ssl=None, ssl=None,
@ -1555,7 +1567,7 @@ class ResourceConnection(PlexObject):
TAG = 'connection' TAG = 'connection'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.address = data.attrib.get('address') self.address = data.attrib.get('address')
self.ipv6 = utils.cast(bool, data.attrib.get('IPv6')) self.ipv6 = utils.cast(bool, data.attrib.get('IPv6'))
self.local = utils.cast(bool, data.attrib.get('local')) self.local = utils.cast(bool, data.attrib.get('local'))
@ -1598,7 +1610,7 @@ class MyPlexDevice(PlexObject):
key = 'https://plex.tv/devices.xml' key = 'https://plex.tv/devices.xml'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.name = data.attrib.get('name') self.name = data.attrib.get('name')
self.publicAddress = data.attrib.get('publicAddress') self.publicAddress = data.attrib.get('publicAddress')
self.product = data.attrib.get('product') self.product = data.attrib.get('product')
@ -1617,7 +1629,10 @@ class MyPlexDevice(PlexObject):
self.screenDensity = data.attrib.get('screenDensity') self.screenDensity = data.attrib.get('screenDensity')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
self.connections = self.listAttrs(data, 'uri', etag='Connection')
@cached_data_property
def connections(self):
return self.listAttrs(self._data, 'uri', etag='Connection')
def connect(self, timeout=None): def connect(self, timeout=None):
""" Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` """ Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer`
@ -1879,8 +1894,7 @@ class MyPlexPinLogin:
codename = codes.get(response.status_code)[0] codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ') errtext = response.text.replace('\n', ' ')
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}') raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
data = response.text.encode('utf8') return utils.parseXMLString(response.text)
return ElementTree.fromstring(data) if data.strip() else None
def _connect(cls, url, token, session, timeout, results, i, job_is_done_event=None): def _connect(cls, url, token, session, timeout, results, i, job_is_done_event=None):
@ -1939,6 +1953,7 @@ class AccountOptOut(PlexObject):
CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'} CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'}
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.value = data.attrib.get('value') self.value = data.attrib.get('value')
@ -1997,6 +2012,7 @@ class UserState(PlexObject):
return f'<{self.__class__.__name__}:{self.ratingKey}>' return f'<{self.__class__.__name__}:{self.ratingKey}>'
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.ratingKey = data.attrib.get('ratingKey') self.ratingKey = data.attrib.get('ratingKey')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
@ -2026,7 +2042,7 @@ class GeoLocation(PlexObject):
TAG = 'location' TAG = 'location'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.city = data.attrib.get('city') self.city = data.attrib.get('city')
self.code = data.attrib.get('code') self.code = data.attrib.get('code')
self.continentCode = data.attrib.get('continent_code') self.continentCode = data.attrib.get('continent_code')

View file

@ -4,7 +4,7 @@ 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
from plexapi.base import Playable, PlexPartialObject, PlexSession from plexapi.base import Playable, PlexPartialObject, PlexSession, cached_data_property
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
from plexapi.mixins import ( from plexapi.mixins import (
RatingMixin, RatingMixin,
@ -56,9 +56,7 @@ class Photoalbum(
self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.composite = data.attrib.get('composite') self.composite = data.attrib.get('composite')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
@ -75,6 +73,14 @@ class Photoalbum(
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.userRating = utils.cast(float, data.attrib.get('userRating')) self.userRating = utils.cast(float, data.attrib.get('userRating'))
@cached_data_property
def fields(self):
return self.findItems(self._data, media.Field)
@cached_data_property
def images(self):
return self.findItems(self._data, media.Image)
def album(self, title): def album(self, title):
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. """ Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title.
@ -205,9 +211,7 @@ class Photo(
self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.createdAtAccuracy = data.attrib.get('createdAtAccuracy') self.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset')) self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '') self.key = data.attrib.get('key', '')
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
@ -215,7 +219,6 @@ class Photo(
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'photo' self.listType = 'photo'
self.media = self.findItems(data, media.Media)
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'))
@ -226,7 +229,6 @@ class Photo(
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.sourceURI = data.attrib.get('source') # remote playlist item self.sourceURI = data.attrib.get('source') # remote playlist item
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.tags = self.findItems(data, media.Tag)
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort', self.title) self.titleSort = data.attrib.get('titleSort', self.title)
@ -235,6 +237,22 @@ class Photo(
self.userRating = utils.cast(float, data.attrib.get('userRating')) self.userRating = utils.cast(float, data.attrib.get('userRating'))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@cached_data_property
def fields(self):
return self.findItems(self._data, media.Field)
@cached_data_property
def images(self):
return self.findItems(self._data, media.Image)
@cached_data_property
def media(self):
return self.findItems(self._data, media.Media)
@cached_data_property
def tags(self):
return self.findItems(self._data, media.Tag)
def _prettyfilename(self): def _prettyfilename(self):
""" Returns a filename for use in download. """ """ Returns a filename for use in download. """
if self.parentTitle: if self.parentTitle:

View file

@ -5,7 +5,7 @@ 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
from plexapi.base import Playable, PlexPartialObject from plexapi.base import Playable, PlexPartialObject, cached_data_property
from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection, MusicSection from plexapi.library import LibrarySection, MusicSection
from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins
@ -60,7 +60,6 @@ class Playlist(
self.content = data.attrib.get('content') self.content = data.attrib.get('content')
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds')) self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds'))
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.icon = data.attrib.get('icon') self.icon = data.attrib.get('icon')
self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50 self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50
@ -77,9 +76,10 @@ class Playlist(
self.titleSort = data.attrib.get('titleSort', self.title) self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self._items = None # cache for self.items
self._section = None # cache for self.section @cached_data_property
self._filters = None # cache for self.filters def fields(self):
return self.findItems(self._data, media.Field)
def __len__(self): # pragma: no cover def __len__(self): # pragma: no cover
return len(self.items()) return len(self.items())
@ -133,15 +133,36 @@ class Playlist(
return _item.playlistItemID return _item.playlistItemID
raise NotFound(f'Item with title "{item.title}" not found in the playlist') raise NotFound(f'Item with title "{item.title}" not found in the playlist')
@cached_data_property
def _filters(self):
""" Cache for filters. """
return self._parseFilters(self.content)
def filters(self): def filters(self):
""" Returns the search filter dict for smart playlist. """ Returns the search filter dict for smart playlist.
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
to get the list of items. to get the list of items.
""" """
if self.smart and self._filters is None:
self._filters = self._parseFilters(self.content)
return self._filters return self._filters
@cached_data_property
def _section(self):
""" Cache for section. """
if not self.smart:
raise BadRequest('Regular playlists are not associated with a library.')
# Try to parse the library section from the content URI string
match = re.search(r'/library/sections/(\d+)/all', unquote(self.content or ''))
if match:
sectionKey = int(match.group(1))
return self._server.library.sectionByID(sectionKey)
# Try to get the library section from the first item in the playlist
if self.items():
return self.items()[0].section()
raise Unsupported('Unable to determine the library section')
def section(self): def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to. """ Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to.
@ -149,24 +170,6 @@ class Playlist(
:class:`plexapi.exceptions.BadRequest`: When trying to get the section for a regular playlist. :class:`plexapi.exceptions.BadRequest`: When trying to get the section for a regular playlist.
:class:`plexapi.exceptions.Unsupported`: When unable to determine the library section. :class:`plexapi.exceptions.Unsupported`: When unable to determine the library section.
""" """
if not self.smart:
raise BadRequest('Regular playlists are not associated with a library.')
if self._section is None:
# Try to parse the library section from the content URI string
match = re.search(r'/library/sections/(\d+)/all', unquote(self.content or ''))
if match:
sectionKey = int(match.group(1))
self._section = self._server.library.sectionByID(sectionKey)
return self._section
# Try to get the library section from the first item in the playlist
if self.items():
self._section = self.items()[0].section()
return self._section
raise Unsupported('Unable to determine the library section')
return self._section return self._section
def item(self, title): def item(self, title):
@ -183,28 +186,32 @@ class Playlist(
return item return item
raise NotFound(f'Item with title "{title}" not found in the playlist') raise NotFound(f'Item with title "{title}" not found in the playlist')
def items(self): @cached_data_property
""" Returns a list of all items in the playlist. """ def _items(self):
""" Cache for items. """
if self.radio: if self.radio:
return [] return []
if self._items is None:
key = f'{self.key}/items'
items = self.fetchItems(key)
# Cache server connections to avoid reconnecting for each item key = f'{self.key}/items'
_servers = {} items = self.fetchItems(key)
for item in items:
if item.sourceURI:
serverID = item.sourceURI.split('/')[2]
if serverID not in _servers:
try:
_servers[serverID] = self._server.myPlexAccount().resource(serverID).connect()
except NotFound:
# Override the server connection with None if the server is not found
_servers[serverID] = None
item._server = _servers[serverID]
self._items = items # Cache server connections to avoid reconnecting for each item
_servers = {}
for item in items:
if item.sourceURI:
serverID = item.sourceURI.split('/')[2]
if serverID not in _servers:
try:
_servers[serverID] = self._server.myPlexAccount().resource(serverID).connect()
except NotFound:
# Override the server connection with None if the server is not found
_servers[serverID] = None
item._server = _servers[serverID]
return items
def items(self):
""" Returns a list of all items in the playlist. """
return self._items return self._items
def get(self, title): def get(self, title):

View file

@ -2,7 +2,7 @@
from urllib.parse import quote_plus from urllib.parse import quote_plus
from plexapi import utils from plexapi import utils
from plexapi.base import PlexObject from plexapi.base import PlexObject, cached_data_property
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
@ -36,7 +36,7 @@ class PlayQueue(PlexObject):
TYPE = "playqueue" TYPE = "playqueue"
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.identifier = data.attrib.get("identifier") self.identifier = data.attrib.get("identifier")
self.mediaTagPrefix = data.attrib.get("mediaTagPrefix") self.mediaTagPrefix = data.attrib.get("mediaTagPrefix")
self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion")) self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion"))
@ -62,9 +62,12 @@ class PlayQueue(PlexObject):
) )
self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion")) self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion"))
self.size = utils.cast(int, data.attrib.get("size", 0)) self.size = utils.cast(int, data.attrib.get("size", 0))
self.items = self.findItems(data)
self.selectedItem = self[self.playQueueSelectedItemOffset] self.selectedItem = self[self.playQueueSelectedItemOffset]
@cached_data_property
def items(self):
return self.findItems(self._data)
def __getitem__(self, key): def __getitem__(self, key):
if not self.items: if not self.items:
return None return None
@ -254,7 +257,7 @@ class PlayQueue(PlexObject):
path = f"/playQueues/{self.playQueueID}{utils.joinArgs(args)}" path = f"/playQueues/{self.playQueueID}{utils.joinArgs(args)}"
data = self._server.query(path, method=self._server._session.put) data = self._server.query(path, method=self._server._session.put)
self._loadData(data) self._invalidateCacheAndLoadData(data)
return self return self
def moveItem(self, item, after=None, refresh=True): def moveItem(self, item, after=None, refresh=True):
@ -283,7 +286,7 @@ class PlayQueue(PlexObject):
path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}/move{utils.joinArgs(args)}" path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}/move{utils.joinArgs(args)}"
data = self._server.query(path, method=self._server._session.put) data = self._server.query(path, method=self._server._session.put)
self._loadData(data) self._invalidateCacheAndLoadData(data)
return self return self
def removeItem(self, item, refresh=True): def removeItem(self, item, refresh=True):
@ -301,19 +304,19 @@ class PlayQueue(PlexObject):
path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}" path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}"
data = self._server.query(path, method=self._server._session.delete) data = self._server.query(path, method=self._server._session.delete)
self._loadData(data) self._invalidateCacheAndLoadData(data)
return self return self
def clear(self): def clear(self):
"""Remove all items from the PlayQueue.""" """Remove all items from the PlayQueue."""
path = f"/playQueues/{self.playQueueID}/items" path = f"/playQueues/{self.playQueueID}/items"
data = self._server.query(path, method=self._server._session.delete) data = self._server.query(path, method=self._server._session.delete)
self._loadData(data) self._invalidateCacheAndLoadData(data)
return self return self
def refresh(self): def refresh(self):
"""Refresh the PlayQueue from the Plex server.""" """Refresh the PlayQueue from the Plex server."""
path = f"/playQueues/{self.playQueueID}" path = f"/playQueues/{self.playQueueID}"
data = self._server.query(path, method=self._server._session.get) data = self._server.query(path, method=self._server._session.get)
self._loadData(data) self._invalidateCacheAndLoadData(data)
return self return self

View file

@ -1,15 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os import os
from functools import cached_property
from urllib.parse import urlencode from urllib.parse import urlencode
from xml.etree import ElementTree
import requests import requests
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter
from plexapi import utils from plexapi import utils
from plexapi.alert import AlertListener from plexapi.alert import AlertListener
from plexapi.base import PlexObject from plexapi.base import PlexObject, cached_data_property
from plexapi.client import PlexClient from plexapi.client import PlexClient
from plexapi.collection import Collection from plexapi.collection import Collection
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
@ -110,15 +108,11 @@ class PlexServer(PlexObject):
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 or TIMEOUT self._timeout = timeout or TIMEOUT
self._myPlexAccount = None # cached myPlexAccount
self._systemAccounts = None # cached list of SystemAccount
self._systemDevices = None # cached list of SystemDevice
data = self.query(self.key, timeout=self._timeout) data = self.query(self.key, timeout=self._timeout)
super(PlexServer, self).__init__(self, data, self.key) super(PlexServer, self).__init__(self, data, self.key)
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.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
self.allowChannelAccess = utils.cast(bool, data.attrib.get('allowChannelAccess')) self.allowChannelAccess = utils.cast(bool, data.attrib.get('allowChannelAccess'))
self.allowMediaDeletion = utils.cast(bool, data.attrib.get('allowMediaDeletion')) self.allowMediaDeletion = utils.cast(bool, data.attrib.get('allowMediaDeletion'))
@ -172,7 +166,7 @@ class PlexServer(PlexObject):
def _uriRoot(self): def _uriRoot(self):
return f'server://{self.machineIdentifier}/com.plexapp.plugins.library' return f'server://{self.machineIdentifier}/com.plexapp.plugins.library'
@cached_property @cached_data_property
def library(self): def library(self):
""" Library to browse or search your media. """ """ Library to browse or search your media. """
try: try:
@ -183,7 +177,7 @@ class PlexServer(PlexObject):
data = self.query('/library/sections/') data = self.query('/library/sections/')
return Library(self, data) return Library(self, data)
@cached_property @cached_data_property
def settings(self): def settings(self):
""" Returns a list of all server settings. """ """ Returns a list of all server settings. """
data = self.query(Settings.key) data = self.query(Settings.key)
@ -276,11 +270,14 @@ class PlexServer(PlexObject):
timeout = self._timeout timeout = self._timeout
return PlexServer(self._baseurl, token=userToken, session=session, timeout=timeout) return PlexServer(self._baseurl, token=userToken, session=session, timeout=timeout)
@cached_data_property
def _systemAccounts(self):
""" Cache for systemAccounts. """
key = '/accounts'
return self.fetchItems(key, SystemAccount)
def systemAccounts(self): def systemAccounts(self):
""" Returns a list of :class:`~plexapi.server.SystemAccount` objects this server contains. """ """ Returns a list of :class:`~plexapi.server.SystemAccount` objects this server contains. """
if self._systemAccounts is None:
key = '/accounts'
self._systemAccounts = self.fetchItems(key, SystemAccount)
return self._systemAccounts return self._systemAccounts
def systemAccount(self, accountID): def systemAccount(self, accountID):
@ -294,11 +291,14 @@ class PlexServer(PlexObject):
except StopIteration: except StopIteration:
raise NotFound(f'Unknown account with accountID={accountID}') from None raise NotFound(f'Unknown account with accountID={accountID}') from None
@cached_data_property
def _systemDevices(self):
""" Cache for systemDevices. """
key = '/devices'
return self.fetchItems(key, SystemDevice)
def systemDevices(self): def systemDevices(self):
""" Returns a list of :class:`~plexapi.server.SystemDevice` objects this server contains. """ """ Returns a list of :class:`~plexapi.server.SystemDevice` objects this server contains. """
if self._systemDevices is None:
key = '/devices'
self._systemDevices = self.fetchItems(key, SystemDevice)
return self._systemDevices return self._systemDevices
def systemDevice(self, deviceID): def systemDevice(self, deviceID):
@ -312,21 +312,24 @@ class PlexServer(PlexObject):
except StopIteration: except StopIteration:
raise NotFound(f'Unknown device with deviceID={deviceID}') from None raise NotFound(f'Unknown device with deviceID={deviceID}') from None
@cached_data_property
def _myPlexAccount(self):
""" Cache for myPlexAccount. """
from plexapi.myplex import MyPlexAccount
return MyPlexAccount(token=self._token, session=self._session)
def myPlexAccount(self): def myPlexAccount(self):
""" Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same
token to access this server. If you are not the owner of this PlexServer token to access this server. If you are not the owner of this PlexServer
you're likely to receive an authentication error calling this. you're likely to receive an authentication error calling this.
""" """
if self._myPlexAccount is None:
from plexapi.myplex import MyPlexAccount
self._myPlexAccount = MyPlexAccount(token=self._token, session=self._session)
return self._myPlexAccount return self._myPlexAccount
def _myPlexClientPorts(self): def _myPlexClientPorts(self):
""" Sometimes the PlexServer does not properly advertise port numbers required """ Sometimes the PlexServer does not properly advertise port numbers required
to connect. This attempts to look up device port number from plex.tv. to connect. This attempts to look up device port number from plex.tv.
See issue #126: Make PlexServer.clients() more user friendly. See issue #126: Make PlexServer.clients() more user friendly.
https://github.com/pkkid/python-plexapi/issues/126 https://github.com/pushingkarmaorg/python-plexapi/issues/126
""" """
try: try:
ports = {} ports = {}
@ -768,8 +771,7 @@ class PlexServer(PlexObject):
raise NotFound(message) raise NotFound(message)
else: else:
raise BadRequest(message) raise BadRequest(message)
data = utils.cleanXMLString(response.text).encode('utf8') return utils.parseXMLString(response.text)
return ElementTree.fromstring(data) if data.strip() else None
def search(self, query, mediatype=None, limit=None, sectionId=None): def search(self, query, mediatype=None, limit=None, sectionId=None):
""" Returns a list of media items or filter categories from the resulting """ Returns a list of media items or filter categories from the resulting
@ -804,9 +806,9 @@ class PlexServer(PlexObject):
for hub in self.fetchItems(key, Hub): for hub in self.fetchItems(key, Hub):
if mediatype: if mediatype:
if hub.type == mediatype: if hub.type == mediatype:
return hub.items return hub._partialItems
else: else:
results += hub.items results += hub._partialItems
return results return results
def continueWatching(self): def continueWatching(self):
@ -1093,7 +1095,7 @@ class Account(PlexObject):
key = '/myplex/account' key = '/myplex/account'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.authToken = data.attrib.get('authToken') self.authToken = data.attrib.get('authToken')
self.username = data.attrib.get('username') self.username = data.attrib.get('username')
self.mappingState = data.attrib.get('mappingState') self.mappingState = data.attrib.get('mappingState')
@ -1114,7 +1116,7 @@ class Activity(PlexObject):
key = '/activities' key = '/activities'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.cancellable = utils.cast(bool, data.attrib.get('cancellable')) self.cancellable = utils.cast(bool, data.attrib.get('cancellable'))
self.progress = utils.cast(int, data.attrib.get('progress')) self.progress = utils.cast(int, data.attrib.get('progress'))
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
@ -1129,6 +1131,7 @@ class Release(PlexObject):
key = '/updater/status' key = '/updater/status'
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.download_key = data.attrib.get('key') self.download_key = data.attrib.get('key')
self.version = data.attrib.get('version') self.version = data.attrib.get('version')
self.added = data.attrib.get('added') self.added = data.attrib.get('added')
@ -1154,7 +1157,7 @@ class SystemAccount(PlexObject):
TAG = 'Account' TAG = 'Account'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.autoSelectAudio = utils.cast(bool, data.attrib.get('autoSelectAudio')) self.autoSelectAudio = utils.cast(bool, data.attrib.get('autoSelectAudio'))
self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage') self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage')
self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage') self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage')
@ -1183,7 +1186,7 @@ class SystemDevice(PlexObject):
TAG = 'Device' TAG = 'Device'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.clientIdentifier = data.attrib.get('clientIdentifier') self.clientIdentifier = data.attrib.get('clientIdentifier')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
@ -1209,7 +1212,7 @@ class StatisticsBandwidth(PlexObject):
TAG = 'StatisticsBandwidth' TAG = 'StatisticsBandwidth'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.accountID = utils.cast(int, data.attrib.get('accountID')) self.accountID = utils.cast(int, data.attrib.get('accountID'))
self.at = utils.toDatetime(data.attrib.get('at')) self.at = utils.toDatetime(data.attrib.get('at'))
self.bytes = utils.cast(int, data.attrib.get('bytes')) self.bytes = utils.cast(int, data.attrib.get('bytes'))
@ -1251,7 +1254,7 @@ class StatisticsResources(PlexObject):
TAG = 'StatisticsResources' TAG = 'StatisticsResources'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.at = utils.toDatetime(data.attrib.get('at')) self.at = utils.toDatetime(data.attrib.get('at'))
self.hostCpuUtilization = utils.cast(float, data.attrib.get('hostCpuUtilization')) self.hostCpuUtilization = utils.cast(float, data.attrib.get('hostCpuUtilization'))
self.hostMemoryUtilization = utils.cast(float, data.attrib.get('hostMemoryUtilization')) self.hostMemoryUtilization = utils.cast(float, data.attrib.get('hostMemoryUtilization'))
@ -1279,7 +1282,7 @@ class ButlerTask(PlexObject):
TAG = 'ButlerTask' TAG = 'ButlerTask'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.description = data.attrib.get('description') self.description = data.attrib.get('description')
self.enabled = utils.cast(bool, data.attrib.get('enabled')) self.enabled = utils.cast(bool, data.attrib.get('enabled'))
self.interval = utils.cast(int, data.attrib.get('interval')) self.interval = utils.cast(int, data.attrib.get('interval'))
@ -1301,7 +1304,7 @@ class Identity(PlexObject):
return f"<{self.__class__.__name__}:{self.machineIdentifier}>" return f"<{self.__class__.__name__}:{self.machineIdentifier}>"
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.claimed = utils.cast(bool, data.attrib.get('claimed')) self.claimed = utils.cast(bool, data.attrib.get('claimed'))
self.machineIdentifier = data.attrib.get('machineIdentifier') self.machineIdentifier = data.attrib.get('machineIdentifier')
self.version = data.attrib.get('version') self.version = data.attrib.get('version')

View file

@ -34,11 +34,10 @@ class Settings(PlexObject):
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
for elem in data: for elem in data:
id = utils.lowerFirst(elem.attrib['id']) id = utils.lowerFirst(elem.attrib['id'])
if id in self._settings: if id in self._settings:
self._settings[id]._loadData(elem) self._settings[id]._invalidateCacheAndLoadData(elem)
continue continue
self._settings[id] = Setting(self._server, elem, self._initpath) self._settings[id] = Setting(self._server, elem, self._initpath)

View file

@ -47,7 +47,6 @@ class PlexSonosClient(PlexClient):
""" """
def __init__(self, account, data, timeout=None): def __init__(self, account, data, timeout=None):
self._data = data
self.deviceClass = data.attrib.get("deviceClass") self.deviceClass = data.attrib.get("deviceClass")
self.machineIdentifier = data.attrib.get("machineIdentifier") self.machineIdentifier = data.attrib.get("machineIdentifier")
self.product = data.attrib.get("product") self.product = data.attrib.get("product")

View file

@ -63,7 +63,7 @@ class SyncItem(PlexObject):
self.clientIdentifier = clientIdentifier self.clientIdentifier = clientIdentifier
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.id = plexapi.utils.cast(int, data.attrib.get('id')) self.id = plexapi.utils.cast(int, data.attrib.get('id'))
self.version = plexapi.utils.cast(int, data.attrib.get('version')) self.version = plexapi.utils.cast(int, data.attrib.get('version'))
self.rootTitle = data.attrib.get('rootTitle') self.rootTitle = data.attrib.get('rootTitle')
@ -118,7 +118,7 @@ class SyncList(PlexObject):
TAG = 'SyncList' TAG = 'SyncList'
def _loadData(self, data): def _loadData(self, data):
self._data = data """ Load attribute values from Plex XML response. """
self.clientId = data.attrib.get('clientIdentifier') self.clientId = data.attrib.get('clientIdentifier')
self.items = [] self.items = []

View file

@ -17,12 +17,12 @@ from getpass import getpass
from hashlib import sha1 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 xml.etree import ElementTree
import requests import requests
from requests.status_codes import _codes as codes from requests.status_codes import _codes as codes
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
try: try:
from tqdm import tqdm from tqdm import tqdm
except ImportError: except ImportError:
@ -718,3 +718,14 @@ _illegal_XML_re = re.compile(fr'[{"".join(_illegal_XML_ranges)}]')
def cleanXMLString(s): def cleanXMLString(s):
return _illegal_XML_re.sub('', s) return _illegal_XML_re.sub('', s)
def parseXMLString(s: str):
""" Parse an XML string and return an ElementTree object. """
if not s.strip():
return None
try: # Attempt to parse the string as-is without cleaning (which is expensive)
return ElementTree.fromstring(s.encode('utf-8'))
except ElementTree.ParseError: # If it fails, clean the string and try again
cleaned_s = cleanXMLString(s).encode('utf-8')
return ElementTree.fromstring(cleaned_s) if cleaned_s.strip() else None

View file

@ -1,11 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os import os
from functools import cached_property
from pathlib import Path 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
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession, cached_data_property
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
from plexapi.mixins import ( from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
@ -48,13 +47,10 @@ class Video(PlexPartialObject, PlayedUnplayedMixin):
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.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash') self.artBlurHash = data.attrib.get('artBlurHash')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.key = data.attrib.get('key', '') self.key = data.attrib.get('key', '')
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
@ -73,6 +69,14 @@ class Video(PlexPartialObject, PlayedUnplayedMixin):
self.userRating = utils.cast(float, data.attrib.get('userRating')) self.userRating = utils.cast(float, data.attrib.get('userRating'))
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
@cached_data_property
def fields(self):
return self.findItems(self._data, media.Field)
@cached_data_property
def images(self):
return self.findItems(self._data, media.Image)
def url(self, part): def url(self, part):
""" Returns the full url for something. Typically used for getting a specific image. """ """ Returns the full url for something. Typically used for getting a specific image. """
return self._server.url(part, includeToken=True) if part else None return self._server.url(part, includeToken=True) if part else None
@ -394,41 +398,86 @@ class Movie(
Playable._loadData(self, data) Playable._loadData(self, data)
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.chapterSource = data.attrib.get('chapterSource') self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating') self.contentRating = data.attrib.get('contentRating')
self.countries = self.findItems(data, media.Country)
self.directors = self.findItems(data, media.Director)
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.editionTitle = data.attrib.get('editionTitle') self.editionTitle = data.attrib.get('editionTitle')
self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1')) self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1'))
self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.labels = self.findItems(data, media.Label)
self.languageOverride = data.attrib.get('languageOverride') self.languageOverride = data.attrib.get('languageOverride')
self.markers = self.findItems(data, media.Marker)
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.originalTitle = data.attrib.get('originalTitle') self.originalTitle = data.attrib.get('originalTitle')
self.primaryExtraKey = data.attrib.get('primaryExtraKey') self.primaryExtraKey = data.attrib.get('primaryExtraKey')
self.producers = self.findItems(data, media.Producer)
self.rating = utils.cast(float, data.attrib.get('rating')) self.rating = utils.cast(float, data.attrib.get('rating'))
self.ratingImage = data.attrib.get('ratingImage') self.ratingImage = data.attrib.get('ratingImage')
self.ratings = self.findItems(data, media.Rating)
self.roles = self.findItems(data, media.Role)
self.slug = data.attrib.get('slug') self.slug = data.attrib.get('slug')
self.similar = self.findItems(data, media.Similar)
self.sourceURI = data.attrib.get('source') # remote playlist item self.sourceURI = data.attrib.get('source') # remote playlist item
self.studio = data.attrib.get('studio') self.studio = data.attrib.get('studio')
self.tagline = data.attrib.get('tagline') self.tagline = data.attrib.get('tagline')
self.theme = data.attrib.get('theme') self.theme = data.attrib.get('theme')
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.writers = self.findItems(data, media.Writer)
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@cached_data_property
def chapters(self):
return self.findItems(self._data, media.Chapter)
@cached_data_property
def collections(self):
return self.findItems(self._data, media.Collection)
@cached_data_property
def countries(self):
return self.findItems(self._data, media.Country)
@cached_data_property
def directors(self):
return self.findItems(self._data, media.Director)
@cached_data_property
def genres(self):
return self.findItems(self._data, media.Genre)
@cached_data_property
def guids(self):
return self.findItems(self._data, media.Guid)
@cached_data_property
def labels(self):
return self.findItems(self._data, media.Label)
@cached_data_property
def markers(self):
return self.findItems(self._data, media.Marker)
@cached_data_property
def media(self):
return self.findItems(self._data, media.Media)
@cached_data_property
def producers(self):
return self.findItems(self._data, media.Producer)
@cached_data_property
def ratings(self):
return self.findItems(self._data, media.Rating)
@cached_data_property
def roles(self):
return self.findItems(self._data, media.Role)
@cached_data_property
def similar(self):
return self.findItems(self._data, media.Similar)
@cached_data_property
def ultraBlurColors(self):
return self.findItem(self._data, media.UltraBlurColors)
@cached_data_property
def writers(self):
return self.findItems(self._data, media.Writer)
@property @property
def actors(self): def actors(self):
""" Alias to self.roles. """ """ Alias to self.roles. """
@ -573,40 +622,67 @@ class Show(
self.autoDeletionItemPolicyWatchedLibrary = utils.cast( self.autoDeletionItemPolicyWatchedLibrary = utils.cast(
int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0')) int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0'))
self.childCount = utils.cast(int, data.attrib.get('childCount')) self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating') self.contentRating = data.attrib.get('contentRating')
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1')) self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1'))
self.episodeSort = utils.cast(int, data.attrib.get('episodeSort', '-1')) self.episodeSort = utils.cast(int, data.attrib.get('episodeSort', '-1'))
self.flattenSeasons = utils.cast(int, data.attrib.get('flattenSeasons', '-1')) self.flattenSeasons = utils.cast(int, data.attrib.get('flattenSeasons', '-1'))
self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = self.key.replace('/children', '') # FIX_BUG_50 self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.languageOverride = data.attrib.get('languageOverride') self.languageOverride = data.attrib.get('languageOverride')
self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.locations = self.listAttrs(data, 'path', etag='Location')
self.network = data.attrib.get('network') self.network = data.attrib.get('network')
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.originalTitle = data.attrib.get('originalTitle') self.originalTitle = data.attrib.get('originalTitle')
self.rating = utils.cast(float, data.attrib.get('rating')) self.rating = utils.cast(float, data.attrib.get('rating'))
self.ratings = self.findItems(data, media.Rating)
self.roles = self.findItems(data, media.Role)
self.seasonCount = utils.cast(int, data.attrib.get('seasonCount', self.childCount)) self.seasonCount = utils.cast(int, data.attrib.get('seasonCount', self.childCount))
self.showOrdering = data.attrib.get('showOrdering') self.showOrdering = data.attrib.get('showOrdering')
self.similar = self.findItems(data, media.Similar)
self.slug = data.attrib.get('slug') self.slug = data.attrib.get('slug')
self.studio = data.attrib.get('studio') self.studio = data.attrib.get('studio')
self.subtitleLanguage = data.attrib.get('subtitleLanguage', '') self.subtitleLanguage = data.attrib.get('subtitleLanguage', '')
self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1'))
self.tagline = data.attrib.get('tagline') self.tagline = data.attrib.get('tagline')
self.theme = data.attrib.get('theme') self.theme = data.attrib.get('theme')
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1'))
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@cached_data_property
def collections(self):
return self.findItems(self._data, media.Collection)
@cached_data_property
def genres(self):
return self.findItems(self._data, media.Genre)
@cached_data_property
def guids(self):
return self.findItems(self._data, media.Guid)
@cached_data_property
def labels(self):
return self.findItems(self._data, media.Label)
@cached_data_property
def locations(self):
return self.listAttrs(self._data, 'path', etag='Location')
@cached_data_property
def ratings(self):
return self.findItems(self._data, media.Rating)
@cached_data_property
def roles(self):
return self.findItems(self._data, media.Role)
@cached_data_property
def similar(self):
return self.findItems(self._data, media.Similar)
@cached_data_property
def ultraBlurColors(self):
return self.findItem(self._data, media.UltraBlurColors)
def __iter__(self): def __iter__(self):
for season in self.seasons(): for season in self.seasons():
yield season yield season
@ -759,11 +835,8 @@ class Season(
Video._loadData(self, data) Video._loadData(self, data)
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.audioLanguage = data.attrib.get('audioLanguage', '') self.audioLanguage = data.attrib.get('audioLanguage', '')
self.collections = self.findItems(data, media.Collection)
self.guids = self.findItems(data, media.Guid)
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = self.key.replace('/children', '') # FIX_BUG_50 self.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
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'))
@ -775,13 +848,31 @@ class Season(
self.parentThumb = data.attrib.get('parentThumb') self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle') self.parentTitle = data.attrib.get('parentTitle')
self.rating = utils.cast(float, data.attrib.get('rating')) self.rating = utils.cast(float, data.attrib.get('rating'))
self.ratings = self.findItems(data, media.Rating)
self.subtitleLanguage = data.attrib.get('subtitleLanguage', '') self.subtitleLanguage = data.attrib.get('subtitleLanguage', '')
self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1'))
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@cached_data_property
def collections(self):
return self.findItems(self._data, media.Collection)
@cached_data_property
def guids(self):
return self.findItems(self._data, media.Guid)
@cached_data_property
def labels(self):
return self.findItems(self._data, media.Label)
@cached_data_property
def ratings(self):
return self.findItems(self._data, media.Rating)
@cached_data_property
def ultraBlurColors(self):
return self.findItem(self._data, media.UltraBlurColors)
def __iter__(self): def __iter__(self):
for episode in self.episodes(): for episode in self.episodes():
yield episode yield episode
@ -942,11 +1033,8 @@ class Episode(
Playable._loadData(self, data) Playable._loadData(self, data)
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.chapterSource = data.attrib.get('chapterSource') self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating') self.contentRating = data.attrib.get('contentRating')
self.directors = self.findItems(data, media.Director)
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentGuid = data.attrib.get('grandparentGuid')
@ -956,25 +1044,16 @@ class Episode(
self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle') self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guids = self.findItems(data, media.Guid)
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.labels = self.findItems(data, media.Label)
self.markers = self.findItems(data, media.Marker)
self.media = self.findItems(data, media.Media)
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.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.rating = utils.cast(float, data.attrib.get('rating')) self.rating = utils.cast(float, data.attrib.get('rating'))
self.ratings = self.findItems(data, media.Rating)
self.roles = self.findItems(data, media.Role)
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
self.sourceURI = data.attrib.get('source') # remote playlist item self.sourceURI = data.attrib.get('source') # remote playlist item
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.writers = self.findItems(data, media.Writer)
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
# 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.
@ -984,7 +1063,55 @@ class Episode(
self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self._parentThumb = data.attrib.get('parentThumb') self._parentThumb = data.attrib.get('parentThumb')
@cached_property @cached_data_property
def chapters(self):
return self.findItems(self._data, media.Chapter)
@cached_data_property
def collections(self):
return self.findItems(self._data, media.Collection)
@cached_data_property
def directors(self):
return self.findItems(self._data, media.Director)
@cached_data_property
def guids(self):
return self.findItems(self._data, media.Guid)
@cached_data_property
def labels(self):
return self.findItems(self._data, media.Label)
@cached_data_property
def markers(self):
return self.findItems(self._data, media.Marker)
@cached_data_property
def media(self):
return self.findItems(self._data, media.Media)
@cached_data_property
def producers(self):
return self.findItems(self._data, media.Producer)
@cached_data_property
def ratings(self):
return self.findItems(self._data, media.Rating)
@cached_data_property
def roles(self):
return self.findItems(self._data, media.Role)
@cached_data_property
def writers(self):
return self.findItems(self._data, media.Writer)
@cached_data_property
def ultraBlurColors(self):
return self.findItem(self._data, media.UltraBlurColors)
@cached_data_property
def parentKey(self): def parentKey(self):
""" Returns the parentKey. Refer to the Episode attributes. """ """ Returns the parentKey. Refer to the Episode attributes. """
if self._parentKey: if self._parentKey:
@ -993,7 +1120,7 @@ class Episode(
return f'/library/metadata/{self.parentRatingKey}' return f'/library/metadata/{self.parentRatingKey}'
return None return None
@cached_property @cached_data_property
def parentRatingKey(self): def parentRatingKey(self):
""" Returns the parentRatingKey. Refer to the Episode attributes. """ """ Returns the parentRatingKey. Refer to the Episode attributes. """
if self._parentRatingKey is not None: if self._parentRatingKey is not None:
@ -1006,7 +1133,7 @@ class Episode(
return self._season.ratingKey return self._season.ratingKey
return None return None
@cached_property @cached_data_property
def parentThumb(self): def parentThumb(self):
""" Returns the parentThumb. Refer to the Episode attributes. """ """ Returns the parentThumb. Refer to the Episode attributes. """
if self._parentThumb: if self._parentThumb:
@ -1015,7 +1142,7 @@ class Episode(
return self._season.thumb return self._season.thumb
return None return None
@cached_property @cached_data_property
def _season(self): def _season(self):
""" Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """
if self.grandparentKey and self.parentIndex is not None: if self.grandparentKey and self.parentIndex is not None:
@ -1055,7 +1182,7 @@ class Episode(
""" Returns the episode number. """ """ Returns the episode number. """
return self.index return self.index
@cached_property @cached_data_property
def seasonNumber(self): def seasonNumber(self):
""" Returns the episode's season number. """ """ Returns the episode's season number. """
if isinstance(self.parentIndex, int): if isinstance(self.parentIndex, int):
@ -1149,12 +1276,10 @@ class Clip(
""" 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._data = data
self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.extraType = utils.cast(int, data.attrib.get('extraType')) self.extraType = utils.cast(int, data.attrib.get('extraType'))
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime( self.originallyAvailableAt = utils.toDatetime(
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.skipDetails = utils.cast(int, data.attrib.get('skipDetails')) self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
@ -1163,6 +1288,10 @@ class Clip(
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@cached_data_property
def media(self):
return self.findItems(self._data, media.Media)
@property @property
def locations(self): def locations(self):
""" This does not exist in plex xml response but is added to have a common """ This does not exist in plex xml response but is added to have a common

View file

@ -144,14 +144,17 @@ def build_custom_where(custom_where=[]):
and_or = ' OR ' if w[0].endswith('OR') else ' AND ' and_or = ' OR ' if w[0].endswith('OR') else ' AND '
w[0] = w[0].rstrip(' OR') w[0] = w[0].rstrip(' OR')
if isinstance(w[1], (list, tuple)) and len(w[1]): if w[0].endswith(' IN') and isinstance(w[1], (list, tuple)) and len(w[1]):
c_where += w[0] + '(' + ','.join(['?'] * len(w[1])) + ')' + and_or
args += w[1]
elif isinstance(w[1], (list, tuple)) and len(w[1]):
c_where += '(' c_where += '('
for w_ in w[1]: for w_ in w[1]:
if w_ is None: if w_ is None:
c_where += w[0] + ' IS NULL' c_where += w[0] + ' IS NULL'
elif str(w_).startswith('LIKE '): elif w[0].endswith(' LIKE'):
c_where += w[0] + ' LIKE ?' c_where += w[0] + ' ?'
args.append(w_[5:]) args.append(w_)
elif w[0].endswith('<') or w[0].endswith('>'): elif w[0].endswith('<') or w[0].endswith('>'):
c_where += w[0] + '= ?' c_where += w[0] + '= ?'
args.append(w_) args.append(w_)
@ -163,9 +166,9 @@ def build_custom_where(custom_where=[]):
else: else:
if w[1] is None: if w[1] is None:
c_where += w[0] + ' IS NULL' c_where += w[0] + ' IS NULL'
elif str(w[1]).startswith('LIKE '): elif w[0].endswith(' LIKE'):
c_where += w[0] + ' LIKE ?' c_where += w[0] + ' ?'
args.append(w[1][5:]) args.append(w[1])
elif w[0].endswith('<') or w[0].endswith('>'): elif w[0].endswith('<') or w[0].endswith('>'):
c_where += w[0] + '= ?' c_where += w[0] + '= ?'
args.append(w[1]) args.append(w[1])

View file

@ -339,7 +339,8 @@ class Export(object):
'duration': None, 'duration': None,
'profile': None, 'profile': None,
'samplingRate': None, 'samplingRate': None,
'streamIdentifier': None 'streamIdentifier': None,
'visualImpaired': None
}, },
'subtitleStreams': { 'subtitleStreams': {
'canAutoSync': None, 'canAutoSync': None,
@ -362,6 +363,8 @@ class Export(object):
'forced': None, 'forced': None,
'format': None, 'format': None,
'headerCompression': None, 'headerCompression': None,
'hearingImpaired': None,
'perfectMatch': None,
'providerTitle': None, 'providerTitle': None,
'score': None, 'score': None,
'sourceKey': None, 'sourceKey': None,
@ -726,7 +729,7 @@ class Export(object):
'frameRate': None, 'frameRate': None,
'frameRateMode': None, 'frameRateMode': None,
'hasScalingMatrix': None, 'hasScalingMatrix': None,
'hdr': lambda o: helpers.is_hdr(getattr(o, 'bitDepth', 0), getattr(o, 'colorSpace', '')), 'hdr': lambda o: helpers.is_hdr(getattr(o, 'bitDepth', 0), getattr(o, 'colorTrc', '')),
'height': None, 'height': None,
'level': None, 'level': None,
'pixelAspectRatio': None, 'pixelAspectRatio': None,
@ -761,7 +764,8 @@ class Export(object):
'duration': None, 'duration': None,
'profile': None, 'profile': None,
'samplingRate': None, 'samplingRate': None,
'streamIdentifier': None 'streamIdentifier': None,
'visualImpaired': None
}, },
'subtitleStreams': { 'subtitleStreams': {
'canAutoSync': None, 'canAutoSync': None,
@ -784,6 +788,8 @@ class Export(object):
'forced': None, 'forced': None,
'format': None, 'format': None,
'headerCompression': None, 'headerCompression': None,
'hearingImpaired': None,
'perfectMatch': None,
'providerTitle': None, 'providerTitle': None,
'score': None, 'score': None,
'sourceKey': None, 'sourceKey': None,
@ -1098,7 +1104,9 @@ class Export(object):
'peak': None, 'peak': None,
'profile': None, 'profile': None,
'samplingRate': None, 'samplingRate': None,
'startRamp': None 'startRamp': None,
'streamIdentifier': None,
'visualImpaired': None
}, },
'lyricStreams': { 'lyricStreams': {
'codec': None, 'codec': None,

View file

@ -1483,9 +1483,9 @@ def move_to_front(l, value):
return l return l
def is_hdr(bit_depth, color_space): def is_hdr(bit_depth, color_trc):
bit_depth = cast_to_int(bit_depth) bit_depth = cast_to_int(bit_depth)
return bit_depth > 8 and color_space == 'bt2020nc' return bit_depth > 8 and (color_trc == 'smpte2084' or color_trc == 'arib-std-b67')
def version_to_tuple(version): def version_to_tuple(version):

View file

@ -3391,7 +3391,7 @@ class PmsConnect(object):
color_trc = helpers.get_xml_attr(stream, 'colorTrc') color_trc = helpers.get_xml_attr(stream, 'colorTrc')
DOVI_profile = helpers.get_xml_attr(stream, 'DOVIProfile') DOVI_profile = helpers.get_xml_attr(stream, 'DOVIProfile')
HDR = bool(bit_depth > 8 and (color_trc == 'smpte2084' or color_trc == 'arib-std-b67')) HDR = helpers.is_hdr(bit_depth, color_trc)
DV = bool(DOVI_profile) DV = bool(DOVI_profile)
if not HDR and not DV: if not HDR and not DV:

View file

@ -1978,9 +1978,9 @@ class WebInterface(object):
pms_connect = pmsconnect.PmsConnect() pms_connect = pmsconnect.PmsConnect()
result = pms_connect.get_item_children(rating_key=kwargs.pop('rating_key'), media_type=kwargs.pop('media_type')) result = pms_connect.get_item_children(rating_key=kwargs.pop('rating_key'), media_type=kwargs.pop('media_type'))
rating_keys = [child['rating_key'] for child in result['children_list']] rating_keys = [child['rating_key'] for child in result['children_list']]
custom_where.append(['session_history_metadata.rating_key OR', rating_keys]) custom_where.append(['session_history_metadata.rating_key IN OR', rating_keys])
custom_where.append(['session_history_metadata.parent_rating_key OR', rating_keys]) custom_where.append(['session_history_metadata.parent_rating_key IN OR', rating_keys])
custom_where.append(['session_history_metadata.grandparent_rating_key OR', rating_keys]) custom_where.append(['session_history_metadata.grandparent_rating_key IN OR', rating_keys])
else: else:
rating_key = helpers.split_strip(kwargs.pop('rating_key', '')) rating_key = helpers.split_strip(kwargs.pop('rating_key', ''))
if rating_key: if rating_key:
@ -2024,7 +2024,7 @@ class WebInterface(object):
if 'guid' in kwargs: if 'guid' in kwargs:
guid = helpers.split_strip(kwargs.pop('guid', '').split('?')[0]) guid = helpers.split_strip(kwargs.pop('guid', '').split('?')[0])
if guid: if guid:
custom_where.append(['session_history_metadata.guid', ['LIKE ' + g + '%' for g in guid]]) custom_where.append(['session_history_metadata.guid LIKE', [f"{g}%" for g in guid]])
data_factory = datafactory.DataFactory() data_factory = datafactory.DataFactory()
history = data_factory.get_datatables_history(kwargs=kwargs, custom_where=custom_where, history = data_factory.get_datatables_history(kwargs=kwargs, custom_where=custom_where,

View file

@ -2,7 +2,7 @@ apscheduler==3.10.1
arrow==1.3.0 arrow==1.3.0
beautifulsoup4==4.12.3 beautifulsoup4==4.12.3
bleach==6.2.0 bleach==6.2.0
certifi==2024.8.30 certifi==2025.04.26
cheroot==10.0.1 cheroot==10.0.1
cherrypy==18.10.0 cherrypy==18.10.0
cherrypy-cors==1.7.0 cherrypy-cors==1.7.0
@ -26,7 +26,7 @@ musicbrainzngs==0.7.1
packaging==24.2 packaging==24.2
paho-mqtt==2.1.0 paho-mqtt==2.1.0
platformdirs==4.3.6 platformdirs==4.3.6
plexapi==4.16.1 plexapi==4.17.0
portend==3.2.0 portend==3.2.0
profilehooks==1.13.0 profilehooks==1.13.0
PyJWT==2.10.1 PyJWT==2.10.1