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
if: ${{ !github.event.release.prerelease }}
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
run: |
$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="#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="#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>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="github-donation" style="text-align: center">
@ -283,7 +283,16 @@ ${next.modalIncludes()}
</div>
<div role="tabpanel" class="tab-pane" id="crypto-donation" style="text-align: center">
<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>
<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>
@ -331,6 +340,7 @@ ${next.modalIncludes()}
<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/ajaxNotifications.js"></script>
<script src="${http_root}js/kjua.min.js"></script>
<script>
% if _session['user_group'] == 'admin':
$('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'); });
});
$('#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
$('.dropdown-toggle').click(function (e) {

View file

@ -4575,12 +4575,32 @@ a.donate-with-crypto::after {
top: 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 {
width: 100%;
padding: 0;
margin: 0 0 10px;
line-height: 1;
line-height: 0;
text-align: center;
background-color: #eee;
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 icon = '';
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';
} else {
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/Sortable.min.js"></script>
<script src="${http_root}js/jquery.inputaffix.min.js"></script>
<script src="${http_root}js/kjua.min.js"></script>
<script>
function getConfigurationTable() {
$.ajax({

View file

@ -1,4 +1,4 @@
from .core import 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.
# 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"
@ -125,39 +34,6 @@ eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0tHuu2guQOHXvgR1m
0vdXcDazv/wor3ElhVsT/h5/WrQ8
-----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
# Subject: CN=QuoVadis Root CA 2 O=QuoVadis Limited
# Label: "QuoVadis Root CA 2"
@ -245,103 +121,6 @@ mJlglFwjz1onl14LBQaTNx47aTbrqZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK
4SVhM7JZG+Ju1zdXtg2pEto=
-----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
# Subject: CN=DigiCert Assured ID Root CA O=DigiCert Inc OU=www.digicert.com
# Label: "DigiCert Assured ID Root CA"
@ -474,47 +253,6 @@ ZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6LqjviOvrv1vA+ACOzB2+htt
Qc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ
-----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
# Subject: CN=SecureTrust CA O=SecureTrust Corporation
# Label: "SecureTrust CA"
@ -763,35 +501,6 @@ uLjbvrW5KfnaNwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2
XjG4Kvte9nHfRCaexOYNkbQudZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E=
-----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.
# Subject: CN=Microsec e-Szigno Root CA 2009 O=Microsec Ltd.
# Label: "Microsec e-Szigno Root CA 2009"
@ -3100,50 +2809,6 @@ LJstxabArahH9CdMOA0uG0k7UvToiIMrVCjU8jVStDKDYmlkDJGcn5fqdBb9HxEG
mpv0
-----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
# Subject: CN=Microsoft ECC Root Certificate Authority 2017 O=Microsoft Corporation
# Label: "Microsoft ECC Root Certificate Authority 2017"
@ -3485,6 +3150,46 @@ DgQWBBQxCpCPtsad0kRLgLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ
+RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A=
-----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
# Subject: CN=ANF Secure Server Root CA O=ANF Autoridad de Certificacion OU=ANF CA Raiz
# Label: "ANF Secure Server Root CA"
@ -4214,46 +3919,6 @@ ut6Dacpps6kFtZaSF4fC0urQe87YQVt8rgIwRt7qy12a7DLCZRawTDBcMPPaTnOG
BtjOiQRINzf43TNRnXCve1XYAS59BWQOhriR
-----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.
# Subject: CN=Security Communication ECC RootCA1 O=SECOM Trust Systems CO.,LTD.
# Label: "Security Communication ECC RootCA1"
@ -4927,3 +4592,85 @@ Af8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTrQciu/NWeUUj1vYv0hyCTQSvT
4P9mLQlO4E/0BdGF9jVg3PVys0Z9AjBEmEYagoUeYWmJSwdLZrWeqrqgHkHZAXQ6
bkU6iYAZezKYVWOr62Nuk22rGwlgMU4=
-----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
~~~~~~~~~~~~~~
@ -19,6 +18,9 @@ at <https://github.com/Ousret/charset_normalizer>.
:copyright: (c) 2021 by Ahmed TAHRI
:license: MIT, see LICENSE for more details.
"""
from __future__ import annotations
import logging
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
if __name__ == "__main__":

View file

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

View file

@ -1,8 +1,10 @@
from __future__ import annotations
import importlib
from codecs import IncrementalDecoder
from collections import Counter
from functools import lru_cache
from typing import Counter as TypeCounter, Dict, List, Optional, Tuple
from typing import Counter as TypeCounter
from .constant import (
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.
"""
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(
"encodings.{}".format(iana_name)
).IncrementalDecoder
decoder = importlib.import_module(f"encodings.{iana_name}").IncrementalDecoder
p: IncrementalDecoder = decoder(errors="ignore")
seen_ranges: Dict[str, int] = {}
seen_ranges: dict[str, int] = {}
character_count: int = 0
for i in range(0x40, 0xFF):
chunk: str = p.decode(bytes([i]))
if chunk:
character_range: Optional[str] = unicode_range(chunk)
character_range: str | None = unicode_range(chunk)
if character_range is None:
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.
"""
languages: List[str] = []
languages: list[str] = []
for language, characters in FREQUENCIES.items():
for character in characters:
@ -77,13 +77,13 @@ def unicode_range_languages(primary_range: str) -> List[str]:
@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).
This function does the correspondence.
"""
unicode_ranges: List[str] = encoding_unicode_range(iana_name)
primary_range: Optional[str] = None
unicode_ranges: list[str] = encoding_unicode_range(iana_name)
primary_range: str | None = None
for specified_range in unicode_ranges:
if "Latin" not in specified_range:
@ -97,7 +97,7 @@ def encoding_languages(iana_name: str) -> List[str]:
@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).
This function does the correspondence.
@ -118,7 +118,7 @@ def mb_encoding_languages(iana_name: str) -> List[str]:
@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.
"""
@ -135,12 +135,12 @@ def get_target_features(language: str) -> Tuple[bool, bool]:
def alphabet_languages(
characters: List[str], ignore_non_latin: bool = False
) -> List[str]:
characters: list[str], ignore_non_latin: bool = False
) -> list[str]:
"""
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)
@ -170,7 +170,7 @@ def alphabet_languages(
def characters_popularity_compare(
language: str, ordered_characters: List[str]
language: str, ordered_characters: list[str]
) -> float:
"""
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.)
"""
if language not in FREQUENCIES:
raise ValueError("{} not available".format(language))
raise ValueError(f"{language} not available")
character_approved_count: int = 0
FREQUENCIES_language_set = set(FREQUENCIES[language])
@ -214,14 +214,14 @@ def characters_popularity_compare(
character_approved_count += 1
continue
characters_before_source: List[str] = FREQUENCIES[language][
characters_before_source: list[str] = FREQUENCIES[language][
0:character_rank_in_language
]
characters_after_source: List[str] = FREQUENCIES[language][
characters_after_source: list[str] = FREQUENCIES[language][
character_rank_in_language:
]
characters_before: List[str] = ordered_characters[0:character_rank]
characters_after: List[str] = ordered_characters[character_rank:]
characters_before: list[str] = ordered_characters[0:character_rank]
characters_after: list[str] = ordered_characters[character_rank:]
before_match_count: int = len(
set(characters_before) & set(characters_before_source)
@ -249,24 +249,24 @@ def characters_popularity_compare(
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.
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.
"""
layers: Dict[str, str] = {}
layers: dict[str, str] = {}
for character in decoded_sequence:
if character.isalpha() is False:
continue
character_range: Optional[str] = unicode_range(character)
character_range: str | None = unicode_range(character)
if character_range is None:
continue
layer_target_range: Optional[str] = None
layer_target_range: str | None = None
for discovered_range in layers:
if (
@ -288,12 +288,12 @@ def alpha_unicode_split(decoded_sequence: str) -> List[str]:
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.
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 sub_result in 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
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:
language, ratio = result
@ -345,14 +345,14 @@ def filter_alt_coherence_matches(results: CoherenceMatches) -> CoherenceMatches:
@lru_cache(maxsize=2048)
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:
"""
Detect ANY language that can be identified in given sequence. The sequence will be analysed by layers.
A layer = Character extraction by alphabets/ranges.
"""
results: List[Tuple[str, float]] = []
results: list[tuple[str, float]] = []
ignore_non_latin: bool = False
sufficient_match_count: int = 0
@ -371,7 +371,7 @@ def coherence_ratio(
if character_count <= TOO_SMALL_SEQUENCE:
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(
popular_character_ordered, ignore_non_latin

View file

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

View file

@ -1,9 +1,11 @@
from __future__ import annotations
import argparse
import sys
import typing
from json import dumps
from os.path import abspath, basename, dirname, join, realpath
from platform import python_version
from typing import List, Optional
from unicodedata import unidata_version
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:
return valid[choice]
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
:param argv:
@ -58,7 +119,7 @@ def cli_detect(argv: Optional[List[str]] = None) -> int:
)
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(
"-v",
@ -124,7 +185,7 @@ def cli_detect(argv: Optional[List[str]] = None) -> int:
default=0.2,
type=float,
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(
"--version",
@ -259,7 +320,7 @@ def cli_detect(argv: Optional[List[str]] = None) -> int:
dir_path = dirname(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:
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:
fp.write(best_guess.output())
except IOError as e:
except OSError as e:
print(str(e), file=sys.stderr)
if my_file.closed is False:
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 encodings.aliases import aliases
from re import IGNORECASE, compile as re_compile
from typing import Dict, List, Set, Union
from re import IGNORECASE
from re import compile as re_compile
# 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_7": [
b"\x2b\x2f\x76\x38",
@ -25,7 +26,7 @@ TOO_BIG_SEQUENCE: int = int(10e6)
UTF8_MAXIMAL_ALLOCATION: int = 1_112_064
# Up-to-date Unicode ucd/15.0.0
UNICODE_RANGES_COMBINED: Dict[str, range] = {
UNICODE_RANGES_COMBINED: dict[str, range] = {
"Control character": range(32),
"Basic Latin": range(32, 128),
"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",
"Extended",
"Extensions",
@ -392,7 +393,7 @@ IANA_NO_ALIASES = [
"koi8_u",
]
IANA_SUPPORTED: List[str] = sorted(
IANA_SUPPORTED: list[str] = sorted(
filter(
lambda x: x.endswith("_codec") is False
and x not in {"rot_13", "tactis", "mbcs"},
@ -403,7 +404,7 @@ IANA_SUPPORTED: List[str] = sorted(
IANA_SUPPORTED_COUNT: int = len(IANA_SUPPORTED)
# 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"],
"cp1026": ["cp037", "cp1140", "cp273", "cp500"],
"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_jp": "ISO-2022-JP",
"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"}
ZH_NAMES: Set[str] = {"big5", "cp950", "big5hkscs", "hz"}
COMMON_JAPANESE_CHARACTERS = "日一国年大十二本中長出三時行見月分後前生五間上東四今金九入学高円子外八六下来気小七山話女北午百書先名川千水半男西電校語土木聞食車何南万毎白天母火右読友左休父雨"
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
TRACE: int = 5
@ -558,7 +576,7 @@ TRACE: int = 5
# Language label that contain the em dash "—"
# character are to be considered alternative seq to origin
FREQUENCIES: Dict[str, List[str]] = {
FREQUENCIES: dict[str, list[str]] = {
"English": [
"e",
"a",

View file

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

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from functools import lru_cache
from logging import getLogger
from typing import List, Optional
from .constant import (
COMMON_SAFE_ASCII_CHARACTERS,
@ -25,6 +26,7 @@ from .utils import (
is_unprintable,
remove_accent,
unicode_range,
is_cjk_uncommon,
)
@ -68,7 +70,7 @@ class TooManySymbolOrPunctuationPlugin(MessDetectorPlugin):
self._symbol_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
def eligible(self, character: str) -> bool:
@ -92,7 +94,7 @@ class TooManySymbolOrPunctuationPlugin(MessDetectorPlugin):
self._last_printable_char = character
def reset(self) -> None: # pragma: no cover
def reset(self) -> None: # Abstract
self._punctuation_count = 0
self._character_count = 0
self._symbol_count = 0
@ -123,7 +125,7 @@ class TooManyAccentuatedPlugin(MessDetectorPlugin):
if is_accentuated(character):
self._accentuated_count += 1
def reset(self) -> None: # pragma: no cover
def reset(self) -> None: # Abstract
self._character_count = 0
self._accentuated_count = 0
@ -149,7 +151,7 @@ class UnprintablePlugin(MessDetectorPlugin):
self._unprintable_count += 1
self._character_count += 1
def reset(self) -> None: # pragma: no cover
def reset(self) -> None: # Abstract
self._unprintable_count = 0
@property
@ -165,7 +167,7 @@ class SuspiciousDuplicateAccentPlugin(MessDetectorPlugin):
self._successive_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:
return character.isalpha() and is_latin(character)
@ -184,7 +186,7 @@ class SuspiciousDuplicateAccentPlugin(MessDetectorPlugin):
self._successive_count += 1
self._last_latin_character = character
def reset(self) -> None: # pragma: no cover
def reset(self) -> None: # Abstract
self._successive_count = 0
self._character_count = 0
self._last_latin_character = None
@ -201,7 +203,7 @@ class SuspiciousRange(MessDetectorPlugin):
def __init__(self) -> None:
self._suspicious_successive_range_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:
return character.isprintable()
@ -221,15 +223,15 @@ class SuspiciousRange(MessDetectorPlugin):
self._last_printable_seen = character
return
unicode_range_a: Optional[str] = unicode_range(self._last_printable_seen)
unicode_range_b: Optional[str] = unicode_range(character)
unicode_range_a: str | None = unicode_range(self._last_printable_seen)
unicode_range_b: str | None = unicode_range(character)
if is_suspiciously_successive_range(unicode_range_a, unicode_range_b):
self._suspicious_successive_range_count += 1
self._last_printable_seen = character
def reset(self) -> None: # pragma: no cover
def reset(self) -> None: # Abstract
self._character_count = 0
self._suspicious_successive_range_count = 0
self._last_printable_seen = None
@ -346,7 +348,7 @@ class SuperWeirdWordPlugin(MessDetectorPlugin):
self._is_current_word_bad = True
self._buffer += character
def reset(self) -> None: # pragma: no cover
def reset(self) -> None: # Abstract
self._buffer = ""
self._is_current_word_bad = False
self._foreign_long_watch = False
@ -364,35 +366,39 @@ class SuperWeirdWordPlugin(MessDetectorPlugin):
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
can be easily detected. Searching for the overuse of '' and ''.
Detect messy CJK text that probably means nothing.
"""
def __init__(self) -> None:
self._wrong_stop_count: int = 0
self._cjk_character_count: int = 0
self._character_count: int = 0
self._uncommon_count: int = 0
def eligible(self, character: str) -> bool:
return True
return is_cjk(character)
def feed(self, character: str) -> None:
if character in {"", ""}:
self._wrong_stop_count += 1
return
if is_cjk(character):
self._cjk_character_count += 1
self._character_count += 1
def reset(self) -> None: # pragma: no cover
self._wrong_stop_count = 0
self._cjk_character_count = 0
if is_cjk_uncommon(character):
self._uncommon_count += 1
return
def reset(self) -> None: # Abstract
self._character_count = 0
self._uncommon_count = 0
@property
def ratio(self) -> float:
if self._cjk_character_count < 16:
if self._character_count < 8:
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):
@ -406,7 +412,7 @@ class ArchaicUpperLowerPlugin(MessDetectorPlugin):
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
def eligible(self, character: str) -> bool:
@ -454,7 +460,7 @@ class ArchaicUpperLowerPlugin(MessDetectorPlugin):
self._character_count_since_last_sep += 1
self._last_alpha_seen = character
def reset(self) -> None: # pragma: no cover
def reset(self) -> None: # Abstract
self._character_count = 0
self._character_count_since_last_sep = 0
self._successive_upper_lower_count = 0
@ -476,7 +482,7 @@ class ArabicIsolatedFormPlugin(MessDetectorPlugin):
self._character_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._isolated_form_count = 0
@ -501,7 +507,7 @@ class ArabicIsolatedFormPlugin(MessDetectorPlugin):
@lru_cache(maxsize=1024)
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:
"""
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
keywords_range_a, keywords_range_b = unicode_range_a.split(
" "
), unicode_range_b.split(" ")
keywords_range_a, keywords_range_b = (
unicode_range_a.split(" "),
unicode_range_b.split(" "),
)
for el in keywords_range_a:
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.
"""
detectors: List[MessDetectorPlugin] = [
detectors: list[MessDetectorPlugin] = [
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"Ending with: {decoded_sequence[-16::]}")
for dt in detectors: # pragma: nocover
for dt in detectors:
logger.log(TRACE, f"{dt.__class__}: {dt.ratio}")
return round(mean_mess_ratio, 3)

View file

@ -1,8 +1,10 @@
from __future__ import annotations
from encodings.aliases import aliases
from hashlib import sha256
from json import dumps
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 .utils import iana_name, is_multi_byte_encoding, unicode_range
@ -15,9 +17,9 @@ class CharsetMatch:
guessed_encoding: str,
mean_mess_ratio: float,
has_sig_or_bom: bool,
languages: "CoherenceMatches",
decoded_payload: Optional[str] = None,
preemptive_declaration: Optional[str] = None,
languages: CoherenceMatches,
decoded_payload: str | None = None,
preemptive_declaration: str | None = None,
):
self._payload: bytes = payload
@ -25,17 +27,17 @@ class CharsetMatch:
self._mean_mess_ratio: float = mean_mess_ratio
self._languages: CoherenceMatches = languages
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._output_payload: Optional[bytes] = None
self._output_encoding: Optional[str] = None
self._output_payload: bytes | None = 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:
if not isinstance(other, CharsetMatch):
@ -77,9 +79,9 @@ class CharsetMatch:
return self._string
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:
raise ValueError(
"Unable to add instance <{}> as a submatch of a CharsetMatch".format(
@ -95,11 +97,11 @@ class CharsetMatch:
return self._encoding
@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.
"""
also_known_as: List[str] = []
also_known_as: list[str] = []
for u, p in aliases.items():
if self.encoding == u:
also_known_as.append(p)
@ -116,7 +118,7 @@ class CharsetMatch:
return self._has_sig_or_bom
@property
def languages(self) -> List[str]:
def languages(self) -> list[str]:
"""
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'.
@ -177,7 +179,7 @@ class CharsetMatch:
return self._payload
@property
def submatch(self) -> List["CharsetMatch"]:
def submatch(self) -> list[CharsetMatch]:
return self._leaves
@property
@ -185,19 +187,17 @@ class CharsetMatch:
return len(self._leaves) > 0
@property
def alphabets(self) -> List[str]:
def alphabets(self) -> list[str]:
if self._unicode_ranges is not None:
return self._unicode_ranges
# list detected ranges
detected_ranges: List[Optional[str]] = [
unicode_range(char) for char in str(self)
]
detected_ranges: list[str | None] = [unicode_range(char) for char in str(self)]
# filter and sort
self._unicode_ranges = sorted(list({r for r in detected_ranges if r}))
return self._unicode_ranges
@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
encoding.
@ -221,10 +221,11 @@ class CharsetMatch:
patched_header = sub(
RE_POSSIBLE_ENCODING_INDICATION,
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],
1,
count=1,
)
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.
"""
def __init__(self, results: Optional[List[CharsetMatch]] = None):
self._results: List[CharsetMatch] = sorted(results) if results else []
def __init__(self, results: list[CharsetMatch] | None = None):
self._results: list[CharsetMatch] = sorted(results) if results else []
def __iter__(self) -> Iterator[CharsetMatch]:
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).
Raise KeyError upon invalid index or encoding not present in results.
@ -293,7 +294,7 @@ class CharsetMatches:
self._results.append(item)
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].
"""
@ -301,7 +302,7 @@ class CharsetMatches:
return None
return self._results[0]
def first(self) -> Optional["CharsetMatch"]:
def first(self) -> CharsetMatch | None:
"""
Redundant method, call the method best(). Kept for BC reasons.
"""
@ -316,31 +317,31 @@ class CliDetectionResult:
def __init__(
self,
path: str,
encoding: Optional[str],
encoding_aliases: List[str],
alternative_encodings: List[str],
encoding: str | None,
encoding_aliases: list[str],
alternative_encodings: list[str],
language: str,
alphabets: List[str],
alphabets: list[str],
has_sig_or_bom: bool,
chaos: float,
coherence: float,
unicode_path: Optional[str],
unicode_path: str | None,
is_preferred: bool,
):
self.path: str = path
self.unicode_path: Optional[str] = unicode_path
self.encoding: Optional[str] = encoding
self.encoding_aliases: List[str] = encoding_aliases
self.alternative_encodings: List[str] = alternative_encodings
self.unicode_path: str | None = unicode_path
self.encoding: str | None = encoding
self.encoding_aliases: list[str] = encoding_aliases
self.alternative_encodings: list[str] = alternative_encodings
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.chaos: float = chaos
self.coherence: float = coherence
self.is_preferred: bool = is_preferred
@property
def __dict__(self) -> Dict[str, Any]: # type: ignore
def __dict__(self) -> dict[str, Any]: # type: ignore
return {
"path": self.path,
"encoding": self.encoding,

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import importlib
import logging
import unicodedata
@ -5,9 +7,11 @@ from codecs import IncrementalDecoder
from encodings.aliases import aliases
from functools import lru_cache
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 (
ENCODING_MARKS,
@ -16,6 +20,7 @@ from .constant import (
UNICODE_RANGES_COMBINED,
UNICODE_SECONDARY_RANGE_KEYWORD,
UTF8_MAXIMAL_ALLOCATION,
COMMON_CJK_CHARACTERS,
)
@ -23,7 +28,7 @@ from .constant import (
def is_accentuated(character: str) -> bool:
try:
description: str = unicodedata.name(character)
except ValueError:
except ValueError: # Defensive: unicode database outdated?
return False
return (
"WITH GRAVE" in description
@ -43,13 +48,13 @@ def remove_accent(character: str) -> str:
if not decomposed:
return character
codes: List[str] = decomposed.split(" ")
codes: list[str] = decomposed.split(" ")
return chr(int(codes[0], 16))
@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.
"""
@ -66,7 +71,7 @@ def unicode_range(character: str) -> Optional[str]:
def is_latin(character: str) -> bool:
try:
description: str = unicodedata.name(character)
except ValueError:
except ValueError: # Defensive: unicode database outdated?
return False
return "LATIN" in description
@ -78,7 +83,7 @@ def is_punctuation(character: str) -> bool:
if "P" in character_category:
return True
character_range: Optional[str] = unicode_range(character)
character_range: str | None = unicode_range(character)
if character_range is None:
return False
@ -93,7 +98,7 @@ def is_symbol(character: str) -> bool:
if "S" in character_category or "N" in character_category:
return True
character_range: Optional[str] = unicode_range(character)
character_range: str | None = unicode_range(character)
if character_range is None:
return False
@ -103,7 +108,7 @@ def is_symbol(character: str) -> bool:
@lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
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:
return False
@ -130,7 +135,7 @@ def is_case_variable(character: str) -> bool:
def is_cjk(character: str) -> bool:
try:
character_name = unicodedata.name(character)
except ValueError:
except ValueError: # Defensive: unicode database outdated?
return False
return "CJK" in character_name
@ -140,7 +145,7 @@ def is_cjk(character: str) -> bool:
def is_hiragana(character: str) -> bool:
try:
character_name = unicodedata.name(character)
except ValueError:
except ValueError: # Defensive: unicode database outdated?
return False
return "HIRAGANA" in character_name
@ -150,7 +155,7 @@ def is_hiragana(character: str) -> bool:
def is_katakana(character: str) -> bool:
try:
character_name = unicodedata.name(character)
except ValueError:
except ValueError: # Defensive: unicode database outdated?
return False
return "KATAKANA" in character_name
@ -160,7 +165,7 @@ def is_katakana(character: str) -> bool:
def is_hangul(character: str) -> bool:
try:
character_name = unicodedata.name(character)
except ValueError:
except ValueError: # Defensive: unicode database outdated?
return False
return "HANGUL" in character_name
@ -170,7 +175,7 @@ def is_hangul(character: str) -> bool:
def is_thai(character: str) -> bool:
try:
character_name = unicodedata.name(character)
except ValueError:
except ValueError: # Defensive: unicode database outdated?
return False
return "THAI" in character_name
@ -180,7 +185,7 @@ def is_thai(character: str) -> bool:
def is_arabic(character: str) -> bool:
try:
character_name = unicodedata.name(character)
except ValueError:
except ValueError: # Defensive: unicode database outdated?
return False
return "ARABIC" in character_name
@ -190,12 +195,17 @@ def is_arabic(character: str) -> bool:
def is_arabic_isolated_form(character: str) -> bool:
try:
character_name = unicodedata.name(character)
except ValueError:
except ValueError: # Defensive: unicode database outdated?
return False
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))
def is_unicode_range_secondary(range_name: str) -> bool:
return any(keyword in range_name for keyword in UNICODE_SECONDARY_RANGE_KEYWORD)
@ -206,13 +216,13 @@ def is_unprintable(character: str) -> bool:
return (
character.isspace() is False # includes \n \t \r \v
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,
# 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.
"""
@ -221,7 +231,7 @@ def any_specified_encoding(sequence: bytes, search_zone: int = 8192) -> Optional
seq_len: int = len(sequence)
results: List[str] = findall(
results: list[str] = findall(
RE_POSSIBLE_ENCODING_INDICATION,
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_7",
} or issubclass(
importlib.import_module("encodings.{}".format(name)).IncrementalDecoder,
importlib.import_module(f"encodings.{name}").IncrementalDecoder,
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.
"""
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):
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:
"""Returns the Python normalized encoding name (Not the IANA official name)."""
cp_name = cp_name.lower().replace("-", "_")
encoding_alias: str
@ -298,35 +309,17 @@ def iana_name(cp_name: str, strict: bool = True) -> str:
return encoding_iana
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
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:
if is_multi_byte_encoding(iana_name_a) or is_multi_byte_encoding(iana_name_b):
return 0.0
decoder_a = importlib.import_module(
"encodings.{}".format(iana_name_a)
).IncrementalDecoder
decoder_b = importlib.import_module(
"encodings.{}".format(iana_name_b)
).IncrementalDecoder
decoder_a = importlib.import_module(f"encodings.{iana_name_a}").IncrementalDecoder
decoder_b = importlib.import_module(f"encodings.{iana_name_b}").IncrementalDecoder
id_a: IncrementalDecoder = decoder_a(errors="ignore")
id_b: IncrementalDecoder = decoder_b(errors="ignore")
@ -374,7 +367,7 @@ def cut_sequence_chunks(
strip_sig_or_bom: bool,
sig_payload: bytes,
is_multi_byte_decoder: bool,
decoded_payload: Optional[str] = None,
decoded_payload: str | None = None,
) -> Generator[str, None, None]:
if decoded_payload and is_multi_byte_decoder is False:
for i in offsets:

View file

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

View file

@ -8,7 +8,7 @@ from urllib.parse import quote_plus
from typing import Any, Dict, List, Optional, TypeVar
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.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
@ -59,14 +59,11 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash')
self.distance = utils.cast(float, data.attrib.get('distance'))
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '')
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
@ -75,7 +72,6 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin):
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'audio'
self.moods = self.findItems(data, media.Mood)
self.musicAnalysisVersion = utils.cast(int, data.attrib.get('musicAnalysisVersion'))
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
@ -88,6 +84,18 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin):
self.userRating = utils.cast(float, data.attrib.get('userRating'))
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):
""" 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
@ -205,18 +213,45 @@ class Artist(
Audio._loadData(self, data)
self.albumSort = utils.cast(int, data.attrib.get('albumSort', '-1'))
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.labels = self.findItems(data, media.Label)
self.locations = self.listAttrs(data, 'path', etag='Location')
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.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):
for album in self.albums():
@ -355,12 +390,7 @@ class Album(
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
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.labels = self.findItems(data, media.Label)
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
@ -372,12 +402,41 @@ class Album(
self.parentTitle = data.attrib.get('parentTitle')
self.rating = utils.cast(float, data.attrib.get('rating'))
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.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):
for track in self.tracks():
yield track
@ -495,11 +554,8 @@ class Track(
Audio._loadData(self, data)
Playable._loadData(self, data)
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.chapters = self.findItems(data, media.Chapter)
self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection)
self.duration = utils.cast(int, data.attrib.get('duration'))
self.genres = self.findItems(data, media.Genre)
self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey')
@ -507,9 +563,6 @@ class Track(
self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb')
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.parentGuid = data.attrib.get('parentGuid')
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.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
def locations(self):
""" 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.
Parameters:
@ -387,7 +422,7 @@ class PlexObject:
return results
def reload(self, key=None, **kwargs):
""" Reload the data for this object from self.key.
""" Reload the data for this object.
Parameters:
key (string, optional): Override the key to reload.
@ -435,7 +470,7 @@ class PlexObject:
self._initpath = key
data = self._server.query(key)
self._overwriteNone = _overwriteNone
self._loadData(data[0])
self._invalidateCacheAndLoadData(data[0])
self._overwriteNone = True
return self
@ -497,9 +532,35 @@ class PlexObject:
return float(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):
""" Load attribute values from Plex XML response. """
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
def _searchType(self):
return self.TYPE
@ -754,7 +815,7 @@ class PlexPartialObject(PlexObject):
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,
Albums which are all not playable.
@ -764,6 +825,7 @@ class Playable:
"""
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue
@ -931,8 +993,8 @@ class Playable:
return self
class PlexSession(object):
""" This is a general place to store functions specific to media that is a Plex Session.
class PlexSession:
""" This is a mixin to store functions specific to media that is a Plex Session.
Attributes:
live (bool): True if this is a live tv session.
@ -945,23 +1007,44 @@ class PlexSession(object):
"""
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
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.transcodeSession = self.findItem(data, etag='TranscodeSession')
user = data.find('User')
self._username = user.attrib.get('title')
self._userId = utils.cast(int, user.attrib.get('id'))
# 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 []
# `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):
""" Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin)
or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session.
@ -978,18 +1061,11 @@ class PlexSession(object):
"""
return self._reload()
def _reload(self, _autoReload=False, **kwargs):
""" Perform the actual reload. """
# Do not auto reload sessions
if _autoReload:
return self
def _reload(self, **kwargs):
""" Reload the data for the session. """
key = self._initpath
data = self._server.query(key)
for elem in data:
if elem.attrib.get('sessionKey') == str(self.sessionKey):
self._loadData(elem)
break
self._findAndLoadElem(data, sessionKey=str(self.sessionKey))
return self
def source(self):
@ -1010,8 +1086,8 @@ class PlexSession(object):
return self._server.query(key, params=params)
class PlexHistory(object):
""" This is a general place to store functions specific to media that is a Plex history item.
class PlexHistory:
""" This is a mixin to store functions specific to media that is a Plex history item.
Attributes:
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
@ -1021,6 +1097,7 @@ class PlexHistory(object):
"""
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.accountID = utils.cast(int, data.attrib.get('accountID'))
self.deviceID = utils.cast(int, data.attrib.get('deviceID'))
self.historyKey = data.attrib.get('historyKey')
@ -1124,7 +1201,7 @@ class MediaContainer(
setattr(self, key, getattr(__iterable, key))
def _loadData(self, data):
self._data = data
""" Load attribute values from Plex XML response. """
self.allowSync = utils.cast(int, data.attrib.get('allowSync'))
self.augmentationKey = data.attrib.get('augmentationKey')
self.identifier = data.attrib.get('identifier')

View file

@ -115,7 +115,7 @@ class PlexClient(PlexObject):
)
else:
client = data[0]
self._loadData(client)
self._invalidateCacheAndLoadData(client)
return self
def reload(self):
@ -124,7 +124,6 @@ class PlexClient(PlexObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.deviceClass = data.attrib.get('deviceClass')
self.machineIdentifier = data.attrib.get('machineIdentifier')
self.product = data.attrib.get('product')
@ -197,8 +196,7 @@ class PlexClient(PlexObject):
raise NotFound(message)
else:
raise BadRequest(message)
data = utils.cleanXMLString(response.text).encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
return utils.parseXMLString(response.text)
def sendCommand(self, command, proxy=None, **params):
""" 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
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()
if command == 'timeline/poll':
self._last_call = t
@ -606,7 +604,7 @@ class ClientTimeline(PlexObject):
key = 'timeline/poll'
def _loadData(self, data):
self._data = data
""" Load attribute values from Plex XML response. """
self.address = data.attrib.get('address')
self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId'))
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 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.library import LibrarySection, ManagedHub
from plexapi.mixins import (
@ -69,7 +69,7 @@ class Collection(
TYPE = 'collection'
def _loadData(self, data):
self._data = data
""" Load attribute values from Plex XML response. """
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash')
@ -81,12 +81,9 @@ class Collection(
self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0'))
self.content = data.attrib.get('content')
self.contentRating = data.attrib.get('contentRating')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.index = utils.cast(int, data.attrib.get('index'))
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.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey')
@ -105,12 +102,24 @@ class Collection(
self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type')
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self._items = None # cache for self.items
self._section = None # cache for self.section
self._filters = None # cache for self.filters
@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 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
return len(self.items())
@ -162,20 +171,26 @@ class Collection(
def children(self):
return self.items()
@cached_data_property
def _filters(self):
""" Cache for filters. """
return self._parseFilters(self.content)
def filters(self):
""" Returns the search filter dict for smart collection.
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
to get the list of items.
"""
if self.smart and self._filters is None:
self._filters = self._parseFilters(self.content)
return self._filters
@cached_data_property
def _section(self):
""" Cache for section. """
return super(Collection, self).section()
def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to.
"""
if self._section is None:
self._section = super(Collection, self).section()
return self._section
def item(self, title):
@ -192,12 +207,14 @@ class Collection(
return item
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):
""" 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
def visibility(self):

View file

@ -3,7 +3,7 @@
# Library version
MAJOR_VERSION = 4
MINOR_VERSION = 16
PATCH_VERSION = 1
MINOR_VERSION = 17
PATCH_VERSION = 0
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}"

View file

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

View file

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

View file

@ -4,13 +4,12 @@ import html
import threading
import time
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from xml.etree import ElementTree
import requests
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER,
log, logfilter, utils)
from plexapi.base import PlexObject
from plexapi.base import PlexObject, cached_data_property
from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, TwoFactorRequired
from plexapi.library import LibrarySection
@ -144,7 +143,6 @@ class MyPlexAccount(PlexObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self._token = logfilter.add_secret(data.attrib.get('authToken'))
self._webhooks = []
@ -185,7 +183,6 @@ class MyPlexAccount(PlexObject):
subscription = data.find('subscription')
self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active'))
self.subscriptionDescription = data.attrib.get('subscriptionDescription')
self.subscriptionFeatures = self.listAttrs(subscription, 'id', rtag='features', etag='feature')
self.subscriptionPaymentService = subscription.attrib.get('paymentService')
self.subscriptionPlan = subscription.attrib.get('plan')
self.subscriptionStatus = subscription.attrib.get('status')
@ -201,21 +198,31 @@ class MyPlexAccount(PlexObject):
self.profileDefaultSubtitleAccessibility = utils.cast(int, profile.attrib.get('defaultSubtitleAccessibility'))
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
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
def authenticationToken(self):
""" Returns the authentication token for the account. Alias for ``authToken``. """
return self.authToken
def _reload(self, key=None, **kwargs):
def _reload(self, **kwargs):
""" Perform the actual reload. """
data = self.query(self.key)
self._loadData(data)
self._invalidateCacheAndLoadData(data)
return self
def _headers(self, **kwargs):
@ -250,8 +257,7 @@ class MyPlexAccount(PlexObject):
return response.json()
elif 'text/plain' in response.headers.get('Content-Type', ''):
return response.text.strip()
data = utils.cleanXMLString(response.text).encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
return utils.parseXMLString(response.text)
def ping(self):
""" Ping the Plex.tv API.
@ -1206,7 +1212,6 @@ class MyPlexUser(PlexObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.friend = self._initpath == self.key
self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels'))
@ -1225,10 +1230,13 @@ class MyPlexUser(PlexObject):
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title', '')
self.username = data.attrib.get('username', '')
self.servers = self.findItems(data, MyPlexServerShare)
for server in self.servers:
server.accountID = self.id
@cached_data_property
def servers(self):
return self.findItems(self._data, MyPlexServerShare)
def get_token(self, machineIdentifier):
try:
for item in self._server.query(self._server.FRIENDINVITE.format(machineId=machineIdentifier)):
@ -1283,7 +1291,6 @@ class MyPlexInvite(PlexObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.email = data.attrib.get('email')
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.id = utils.cast(int, data.attrib.get('id'))
self.server = utils.cast(bool, data.attrib.get('server'))
self.servers = self.findItems(data, MyPlexServerShare)
self.thumb = data.attrib.get('thumb')
self.username = data.attrib.get('username', '')
for server in self.servers:
server.accountID = self.id
@cached_data_property
def servers(self):
return self.findItems(self._data, MyPlexServerShare)
class Section(PlexObject):
""" This refers to a shared section. The raw xml for the data presented here
@ -1314,7 +1324,7 @@ class Section(PlexObject):
TAG = 'Section'
def _loadData(self, data):
self._data = data
""" Load attribute values from Plex XML response. """
self.id = utils.cast(int, data.attrib.get('id'))
self.key = utils.cast(int, data.attrib.get('key'))
self.shared = utils.cast(bool, data.attrib.get('shared', '0'))
@ -1353,7 +1363,6 @@ class MyPlexServerShare(PlexObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.id = utils.cast(int, data.attrib.get('id'))
self.accountID = utils.cast(int, data.attrib.get('accountID'))
self.serverId = utils.cast(int, data.attrib.get('serverId'))
@ -1437,10 +1446,9 @@ class MyPlexResource(PlexObject):
DEFAULT_SCHEME_ORDER = ['https', 'http']
def _loadData(self, data):
self._data = data
""" Load attribute values from Plex XML response. """
self.accessToken = logfilter.add_secret(data.attrib.get('accessToken'))
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.device = data.attrib.get('device')
self.dnsRebindingProtection = utils.cast(bool, data.attrib.get('dnsRebindingProtection'))
@ -1462,6 +1470,10 @@ class MyPlexResource(PlexObject):
self.sourceTitle = data.attrib.get('sourceTitle')
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(
self,
ssl=None,
@ -1555,7 +1567,7 @@ class ResourceConnection(PlexObject):
TAG = 'connection'
def _loadData(self, data):
self._data = data
""" Load attribute values from Plex XML response. """
self.address = data.attrib.get('address')
self.ipv6 = utils.cast(bool, data.attrib.get('IPv6'))
self.local = utils.cast(bool, data.attrib.get('local'))
@ -1598,7 +1610,7 @@ class MyPlexDevice(PlexObject):
key = 'https://plex.tv/devices.xml'
def _loadData(self, data):
self._data = data
""" Load attribute values from Plex XML response. """
self.name = data.attrib.get('name')
self.publicAddress = data.attrib.get('publicAddress')
self.product = data.attrib.get('product')
@ -1617,7 +1629,10 @@ class MyPlexDevice(PlexObject):
self.screenDensity = data.attrib.get('screenDensity')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
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):
""" 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]
errtext = response.text.replace('\n', ' ')
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
return utils.parseXMLString(response.text)
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'}
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.key = data.attrib.get('key')
self.value = data.attrib.get('value')
@ -1997,6 +2012,7 @@ class UserState(PlexObject):
return f'<{self.__class__.__name__}:{self.ratingKey}>'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.ratingKey = data.attrib.get('ratingKey')
self.type = data.attrib.get('type')
@ -2026,7 +2042,7 @@ class GeoLocation(PlexObject):
TAG = 'location'
def _loadData(self, data):
self._data = data
""" Load attribute values from Plex XML response. """
self.city = data.attrib.get('city')
self.code = data.attrib.get('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 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.mixins import (
RatingMixin,
@ -56,9 +56,7 @@ class Photoalbum(
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art')
self.composite = data.attrib.get('composite')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
@ -75,6 +73,14 @@ class Photoalbum(
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
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):
""" 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.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '')
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
@ -215,7 +219,6 @@ class Photo(
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.listType = 'photo'
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
@ -226,7 +229,6 @@ class Photo(
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.sourceURI = data.attrib.get('source') # remote playlist item
self.summary = data.attrib.get('summary')
self.tags = self.findItems(data, media.Tag)
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('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.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):
""" Returns a filename for use in download. """
if self.parentTitle:

View file

@ -5,7 +5,7 @@ from pathlib import Path
from urllib.parse import quote_plus, unquote
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.library import LibrarySection, MusicSection
from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins
@ -60,7 +60,6 @@ class Playlist(
self.content = data.attrib.get('content')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds'))
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.icon = data.attrib.get('icon')
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.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self._items = None # cache for self.items
self._section = None # cache for self.section
self._filters = None # cache for self.filters
@cached_data_property
def fields(self):
return self.findItems(self._data, media.Field)
def __len__(self): # pragma: no cover
return len(self.items())
@ -133,15 +133,36 @@ class Playlist(
return _item.playlistItemID
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):
""" Returns the search filter dict for smart playlist.
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
to get the list of items.
"""
if self.smart and self._filters is None:
self._filters = self._parseFilters(self.content)
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):
""" 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.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
def item(self, title):
@ -183,28 +186,32 @@ class Playlist(
return item
raise NotFound(f'Item with title "{title}" not found in the playlist')
def items(self):
""" Returns a list of all items in the playlist. """
@cached_data_property
def _items(self):
""" Cache for items. """
if self.radio:
return []
if self._items is None:
key = f'{self.key}/items'
items = self.fetchItems(key)
# 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]
key = f'{self.key}/items'
items = self.fetchItems(key)
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
def get(self, title):

View file

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

View file

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

View file

@ -34,11 +34,10 @@ class Settings(PlexObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
for elem in data:
id = utils.lowerFirst(elem.attrib['id'])
if id in self._settings:
self._settings[id]._loadData(elem)
self._settings[id]._invalidateCacheAndLoadData(elem)
continue
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):
self._data = data
self.deviceClass = data.attrib.get("deviceClass")
self.machineIdentifier = data.attrib.get("machineIdentifier")
self.product = data.attrib.get("product")

View file

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

View file

@ -17,12 +17,12 @@ from getpass import getpass
from hashlib import sha1
from threading import Event, Thread
from urllib.parse import quote
from xml.etree import ElementTree
import requests
from requests.status_codes import _codes as codes
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
try:
from tqdm import tqdm
except ImportError:
@ -718,3 +718,14 @@ _illegal_XML_re = re.compile(fr'[{"".join(_illegal_XML_ranges)}]')
def cleanXMLString(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 -*-
import os
from functools import cached_property
from pathlib import Path
from urllib.parse import quote_plus
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.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
@ -48,13 +47,10 @@ class Video(PlexPartialObject, PlayedUnplayedMixin):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash')
self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid')
self.images = self.findItems(data, media.Image)
self.key = data.attrib.get('key', '')
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
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.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):
""" Returns the full url for something. Typically used for getting a specific image. """
return self._server.url(part, includeToken=True) if part else None
@ -394,41 +398,86 @@ class Movie(
Playable._loadData(self, data)
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
self.chapters = self.findItems(data, media.Chapter)
self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection)
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.editionTitle = data.attrib.get('editionTitle')
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.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.originalTitle = data.attrib.get('originalTitle')
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
self.producers = self.findItems(data, media.Producer)
self.rating = utils.cast(float, data.attrib.get('rating'))
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.similar = self.findItems(data, media.Similar)
self.sourceURI = data.attrib.get('source') # remote playlist item
self.studio = data.attrib.get('studio')
self.tagline = data.attrib.get('tagline')
self.theme = data.attrib.get('theme')
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1'))
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'))
@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
def actors(self):
""" Alias to self.roles. """
@ -573,40 +622,67 @@ class Show(
self.autoDeletionItemPolicyWatchedLibrary = utils.cast(
int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0'))
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1'))
self.episodeSort = utils.cast(int, data.attrib.get('episodeSort', '-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.key = self.key.replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.languageOverride = data.attrib.get('languageOverride')
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.locations = self.listAttrs(data, 'path', etag='Location')
self.network = data.attrib.get('network')
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.originalTitle = data.attrib.get('originalTitle')
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.showOrdering = data.attrib.get('showOrdering')
self.similar = self.findItems(data, media.Similar)
self.slug = data.attrib.get('slug')
self.studio = data.attrib.get('studio')
self.subtitleLanguage = data.attrib.get('subtitleLanguage', '')
self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1'))
self.tagline = data.attrib.get('tagline')
self.theme = data.attrib.get('theme')
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1'))
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
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):
for season in self.seasons():
yield season
@ -759,11 +835,8 @@ class Season(
Video._loadData(self, data)
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
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.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.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
@ -775,13 +848,31 @@ class Season(
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.rating = utils.cast(float, data.attrib.get('rating'))
self.ratings = self.findItems(data, media.Rating)
self.subtitleLanguage = data.attrib.get('subtitleLanguage', '')
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.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):
for episode in self.episodes():
yield episode
@ -942,11 +1033,8 @@ class Episode(
Playable._loadData(self, data)
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
self.chapters = self.findItems(data, media.Chapter)
self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating')
self.directors = self.findItems(data, media.Director)
self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid')
@ -956,25 +1044,16 @@ class Episode(
self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guids = self.findItems(data, media.Guid)
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.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
self.parentTitle = data.attrib.get('parentTitle')
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.ratings = self.findItems(data, media.Rating)
self.roles = self.findItems(data, media.Role)
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
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.writers = self.findItems(data, media.Writer)
self.year = utils.cast(int, data.attrib.get('year'))
# 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._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):
""" Returns the parentKey. Refer to the Episode attributes. """
if self._parentKey:
@ -993,7 +1120,7 @@ class Episode(
return f'/library/metadata/{self.parentRatingKey}'
return None
@cached_property
@cached_data_property
def parentRatingKey(self):
""" Returns the parentRatingKey. Refer to the Episode attributes. """
if self._parentRatingKey is not None:
@ -1006,7 +1133,7 @@ class Episode(
return self._season.ratingKey
return None
@cached_property
@cached_data_property
def parentThumb(self):
""" Returns the parentThumb. Refer to the Episode attributes. """
if self._parentThumb:
@ -1015,7 +1142,7 @@ class Episode(
return self._season.thumb
return None
@cached_property
@cached_data_property
def _season(self):
""" Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """
if self.grandparentKey and self.parentIndex is not None:
@ -1055,7 +1182,7 @@ class Episode(
""" Returns the episode number. """
return self.index
@cached_property
@cached_data_property
def seasonNumber(self):
""" Returns the episode's season number. """
if isinstance(self.parentIndex, int):
@ -1149,12 +1276,10 @@ class Clip(
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
Playable._loadData(self, data)
self._data = data
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.duration = utils.cast(int, data.attrib.get('duration'))
self.extraType = utils.cast(int, data.attrib.get('extraType'))
self.index = utils.cast(int, data.attrib.get('index'))
self.media = self.findItems(data, media.Media)
self.originallyAvailableAt = utils.toDatetime(
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
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.year = utils.cast(int, data.attrib.get('year'))
@cached_data_property
def media(self):
return self.findItems(self._data, media.Media)
@property
def locations(self):
""" 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 '
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 += '('
for w_ in w[1]:
if w_ is None:
c_where += w[0] + ' IS NULL'
elif str(w_).startswith('LIKE '):
c_where += w[0] + ' LIKE ?'
args.append(w_[5:])
elif w[0].endswith(' LIKE'):
c_where += w[0] + ' ?'
args.append(w_)
elif w[0].endswith('<') or w[0].endswith('>'):
c_where += w[0] + '= ?'
args.append(w_)
@ -163,9 +166,9 @@ def build_custom_where(custom_where=[]):
else:
if w[1] is None:
c_where += w[0] + ' IS NULL'
elif str(w[1]).startswith('LIKE '):
c_where += w[0] + ' LIKE ?'
args.append(w[1][5:])
elif w[0].endswith(' LIKE'):
c_where += w[0] + ' ?'
args.append(w[1])
elif w[0].endswith('<') or w[0].endswith('>'):
c_where += w[0] + '= ?'
args.append(w[1])

View file

@ -339,7 +339,8 @@ class Export(object):
'duration': None,
'profile': None,
'samplingRate': None,
'streamIdentifier': None
'streamIdentifier': None,
'visualImpaired': None
},
'subtitleStreams': {
'canAutoSync': None,
@ -362,6 +363,8 @@ class Export(object):
'forced': None,
'format': None,
'headerCompression': None,
'hearingImpaired': None,
'perfectMatch': None,
'providerTitle': None,
'score': None,
'sourceKey': None,
@ -726,7 +729,7 @@ class Export(object):
'frameRate': None,
'frameRateMode': 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,
'level': None,
'pixelAspectRatio': None,
@ -761,7 +764,8 @@ class Export(object):
'duration': None,
'profile': None,
'samplingRate': None,
'streamIdentifier': None
'streamIdentifier': None,
'visualImpaired': None
},
'subtitleStreams': {
'canAutoSync': None,
@ -784,6 +788,8 @@ class Export(object):
'forced': None,
'format': None,
'headerCompression': None,
'hearingImpaired': None,
'perfectMatch': None,
'providerTitle': None,
'score': None,
'sourceKey': None,
@ -1098,7 +1104,9 @@ class Export(object):
'peak': None,
'profile': None,
'samplingRate': None,
'startRamp': None
'startRamp': None,
'streamIdentifier': None,
'visualImpaired': None
},
'lyricStreams': {
'codec': None,

View file

@ -1483,9 +1483,9 @@ def move_to_front(l, value):
return l
def is_hdr(bit_depth, color_space):
def is_hdr(bit_depth, color_trc):
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):

View file

@ -3391,7 +3391,7 @@ class PmsConnect(object):
color_trc = helpers.get_xml_attr(stream, 'colorTrc')
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)
if not HDR and not DV:

View file

@ -1978,9 +1978,9 @@ class WebInterface(object):
pms_connect = pmsconnect.PmsConnect()
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']]
custom_where.append(['session_history_metadata.rating_key OR', rating_keys])
custom_where.append(['session_history_metadata.parent_rating_key OR', rating_keys])
custom_where.append(['session_history_metadata.grandparent_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 IN OR', rating_keys])
custom_where.append(['session_history_metadata.grandparent_rating_key IN OR', rating_keys])
else:
rating_key = helpers.split_strip(kwargs.pop('rating_key', ''))
if rating_key:
@ -2024,7 +2024,7 @@ class WebInterface(object):
if 'guid' in kwargs:
guid = helpers.split_strip(kwargs.pop('guid', '').split('?')[0])
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()
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
beautifulsoup4==4.12.3
bleach==6.2.0
certifi==2024.8.30
certifi==2025.04.26
cheroot==10.0.1
cherrypy==18.10.0
cherrypy-cors==1.7.0
@ -26,7 +26,7 @@ musicbrainzngs==0.7.1
packaging==24.2
paho-mqtt==2.1.0
platformdirs==4.3.6
plexapi==4.16.1
plexapi==4.17.0
portend==3.2.0
profilehooks==1.13.0
PyJWT==2.10.1