✨ Merge with master.

This commit is contained in:
Robert Honz
2024-11-29 17:41:25 +01:00
15 changed files with 542 additions and 382 deletions
Generated
+148 -121
View File
@@ -97,13 +97,13 @@ files = [
[[package]] [[package]]
name = "bandit" name = "bandit"
version = "1.7.10" version = "1.8.0"
description = "Security oriented static analyser for python code." description = "Security oriented static analyser for python code."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
files = [ files = [
{file = "bandit-1.7.10-py3-none-any.whl", hash = "sha256:665721d7bebbb4485a339c55161ac0eedde27d51e638000d91c8c2d68343ad02"}, {file = "bandit-1.8.0-py3-none-any.whl", hash = "sha256:b1a61d829c0968aed625381e426aa378904b996529d048f8d908fa28f6b13e38"},
{file = "bandit-1.7.10.tar.gz", hash = "sha256:59ed5caf5d92b6ada4bf65bc6437feea4a9da1093384445fed4d472acc6cff7b"}, {file = "bandit-1.8.0.tar.gz", hash = "sha256:b5bfe55a095abd9fe20099178a7c6c060f844bfd4fe4c76d28e35e4c52b9d31e"},
] ]
[package.dependencies] [package.dependencies]
@@ -367,73 +367,73 @@ cron = ["capturer (>=2.4)"]
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.6.4" version = "7.6.8"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"}, {file = "coverage-7.6.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50"},
{file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"}, {file = "coverage-7.6.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf"},
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"}, {file = "coverage-7.6.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3985b9be361d8fb6b2d1adc9924d01dec575a1d7453a14cccd73225cb79243ee"},
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"}, {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:644ec81edec0f4ad17d51c838a7d01e42811054543b76d4ba2c5d6af741ce2a6"},
{file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"}, {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f188a2402f8359cf0c4b1fe89eea40dc13b52e7b4fd4812450da9fcd210181d"},
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"}, {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e19122296822deafce89a0c5e8685704c067ae65d45e79718c92df7b3ec3d331"},
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"}, {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13618bed0c38acc418896005732e565b317aa9e98d855a0e9f211a7ffc2d6638"},
{file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"}, {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:193e3bffca48ad74b8c764fb4492dd875038a2f9925530cb094db92bb5e47bed"},
{file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"}, {file = "coverage-7.6.8-cp310-cp310-win32.whl", hash = "sha256:3988665ee376abce49613701336544041f2117de7b7fbfe91b93d8ff8b151c8e"},
{file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"}, {file = "coverage-7.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:f56f49b2553d7dd85fd86e029515a221e5c1f8cb3d9c38b470bc38bde7b8445a"},
{file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"}, {file = "coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4"},
{file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"}, {file = "coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94"},
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"}, {file = "coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4"},
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"}, {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1"},
{file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"}, {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb"},
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"}, {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8"},
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"}, {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a"},
{file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"}, {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0"},
{file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"}, {file = "coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801"},
{file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"}, {file = "coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9"},
{file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"}, {file = "coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee"},
{file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"}, {file = "coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a"},
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"}, {file = "coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d"},
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"}, {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb"},
{file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"}, {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649"},
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"}, {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787"},
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"}, {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c"},
{file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"}, {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443"},
{file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"}, {file = "coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad"},
{file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"}, {file = "coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4"},
{file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"}, {file = "coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb"},
{file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"}, {file = "coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63"},
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"}, {file = "coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365"},
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"}, {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002"},
{file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"}, {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3"},
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"}, {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022"},
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"}, {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e"},
{file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"}, {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b"},
{file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"}, {file = "coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146"},
{file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"}, {file = "coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28"},
{file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"}, {file = "coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d"},
{file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"}, {file = "coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451"},
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"}, {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764"},
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"}, {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf"},
{file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"}, {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5"},
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"}, {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4"},
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"}, {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83"},
{file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"}, {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b"},
{file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"}, {file = "coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71"},
{file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"}, {file = "coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc"},
{file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"}, {file = "coverage-7.6.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ac47fa29d8d41059ea3df65bd3ade92f97ee4910ed638e87075b8e8ce69599e"},
{file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"}, {file = "coverage-7.6.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:24eda3a24a38157eee639ca9afe45eefa8d2420d49468819ac5f88b10de84f4c"},
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"}, {file = "coverage-7.6.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4c81ed2820b9023a9a90717020315e63b17b18c274a332e3b6437d7ff70abe0"},
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"}, {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd55f8fc8fa494958772a2a7302b0354ab16e0b9272b3c3d83cdb5bec5bd1779"},
{file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"}, {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f39e2f3530ed1626c66e7493be7a8423b023ca852aacdc91fb30162c350d2a92"},
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"}, {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:716a78a342679cd1177bc8c2fe957e0ab91405bd43a17094324845200b2fddf4"},
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"}, {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:177f01eeaa3aee4a5ffb0d1439c5952b53d5010f86e9d2667963e632e30082cc"},
{file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"}, {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:912e95017ff51dc3d7b6e2be158dedc889d9a5cc3382445589ce554f1a34c0ea"},
{file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"}, {file = "coverage-7.6.8-cp39-cp39-win32.whl", hash = "sha256:4db3ed6a907b555e57cc2e6f14dc3a4c2458cdad8919e40b5357ab9b6db6c43e"},
{file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"}, {file = "coverage-7.6.8-cp39-cp39-win_amd64.whl", hash = "sha256:428ac484592f780e8cd7b6b14eb568f7c85460c92e2a37cb0c0e5186e1a0d076"},
{file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"}, {file = "coverage-7.6.8-pp39.pp310-none-any.whl", hash = "sha256:5c52a036535d12590c32c49209e79cabaad9f9ad8aa4cbd875b68c4d67a9cbce"},
{file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"}, {file = "coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc"},
] ]
[package.dependencies] [package.dependencies]
@@ -636,13 +636,13 @@ pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_ve
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.6.2" version = "2.6.3"
description = "File identification library for Python" description = "File identification library for Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "identify-2.6.2-py2.py3-none-any.whl", hash = "sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3"}, {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"},
{file = "identify-2.6.2.tar.gz", hash = "sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd"}, {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"},
] ]
[package.extras] [package.extras]
@@ -971,13 +971,13 @@ pyyaml = ">=5.1"
[[package]] [[package]]
name = "mkdocs-material" name = "mkdocs-material"
version = "9.5.44" version = "9.5.46"
description = "Documentation that simply works" description = "Documentation that simply works"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "mkdocs_material-9.5.44-py3-none-any.whl", hash = "sha256:47015f9c167d58a5ff5e682da37441fc4d66a1c79334bfc08d774763cacf69ca"}, {file = "mkdocs_material-9.5.46-py3-none-any.whl", hash = "sha256:98f0a2039c62e551a68aad0791a8d41324ff90c03a6e6cea381a384b84908b83"},
{file = "mkdocs_material-9.5.44.tar.gz", hash = "sha256:f3a6c968e524166b3f3ed1fb97d3ed3e0091183b0545cedf7156a2a6804c56c0"}, {file = "mkdocs_material-9.5.46.tar.gz", hash = "sha256:ae2043f4238e572f9a40e0b577f50400d6fc31e2fef8ea141800aebf3bd273d7"},
] ]
[package.dependencies] [package.dependencies]
@@ -1011,24 +1011,24 @@ files = [
[[package]] [[package]]
name = "mkdocstrings" name = "mkdocstrings"
version = "0.24.3" version = "0.27.0"
description = "Automatic documentation from sources, for MkDocs." description = "Automatic documentation from sources, for MkDocs."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
files = [ files = [
{file = "mkdocstrings-0.24.3-py3-none-any.whl", hash = "sha256:5c9cf2a32958cd161d5428699b79c8b0988856b0d4a8c5baf8395fc1bf4087c3"}, {file = "mkdocstrings-0.27.0-py3-none-any.whl", hash = "sha256:6ceaa7ea830770959b55a16203ac63da24badd71325b96af950e59fd37366332"},
{file = "mkdocstrings-0.24.3.tar.gz", hash = "sha256:f327b234eb8d2551a306735436e157d0a22d45f79963c60a8b585d5f7a94c1d2"}, {file = "mkdocstrings-0.27.0.tar.gz", hash = "sha256:16adca6d6b0a1f9e0c07ff0b02ced8e16f228a9d65a37c063ec4c14d7b76a657"},
] ]
[package.dependencies] [package.dependencies]
click = ">=7.0" click = ">=7.0"
Jinja2 = ">=2.11.1" Jinja2 = ">=2.11.1"
Markdown = ">=3.3" Markdown = ">=3.6"
MarkupSafe = ">=1.1" MarkupSafe = ">=1.1"
mkdocs = ">=1.4" mkdocs = ">=1.4"
mkdocs-autorefs = ">=0.3.1" mkdocs-autorefs = ">=1.2"
mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""}
platformdirs = ">=2.2.0" platformdirs = ">=2.2"
pymdown-extensions = ">=6.3" pymdown-extensions = ">=6.3"
[package.extras] [package.extras]
@@ -1038,18 +1038,19 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]] [[package]]
name = "mkdocstrings-python" name = "mkdocstrings-python"
version = "1.10.0" version = "1.12.2"
description = "A Python handler for mkdocstrings." description = "A Python handler for mkdocstrings."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
files = [ files = [
{file = "mkdocstrings_python-1.10.0-py3-none-any.whl", hash = "sha256:ba833fbd9d178a4b9d5cb2553a4df06e51dc1f51e41559a4d2398c16a6f69ecc"}, {file = "mkdocstrings_python-1.12.2-py3-none-any.whl", hash = "sha256:7f7d40d6db3cb1f5d19dbcd80e3efe4d0ba32b073272c0c0de9de2e604eda62a"},
{file = "mkdocstrings_python-1.10.0.tar.gz", hash = "sha256:71678fac657d4d2bb301eed4e4d2d91499c095fd1f8a90fa76422a87a5693828"}, {file = "mkdocstrings_python-1.12.2.tar.gz", hash = "sha256:7a1760941c0b52a2cd87b960a9e21112ffe52e7df9d0b9583d04d47ed2e186f3"},
] ]
[package.dependencies] [package.dependencies]
griffe = ">=0.44" griffe = ">=0.49"
mkdocstrings = ">=0.24.2" mkdocs-autorefs = ">=1.2"
mkdocstrings = ">=0.26"
[[package]] [[package]]
name = "mpegdash" name = "mpegdash"
@@ -1940,23 +1941,23 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "75.5.0" version = "75.6.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages" description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "setuptools-75.5.0-py3-none-any.whl", hash = "sha256:87cb777c3b96d638ca02031192d40390e0ad97737e27b6b4fa831bea86f2f829"}, {file = "setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d"},
{file = "setuptools-75.5.0.tar.gz", hash = "sha256:5c4ccb41111392671f02bb5f8436dfc5a9a7185e80500531b133f5775c4163ef"}, {file = "setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6"},
] ]
[package.extras] [package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.7.0)"] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.7.0)"]
core = ["importlib-metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
cover = ["pytest-cov"] cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
enabler = ["pytest-enabler (>=2.2)"] enabler = ["pytest-enabler (>=2.2)"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (>=1.12,<1.14)", "pytest-mypy"] type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (>=1.12,<1.14)", "pytest-mypy"]
[[package]] [[package]]
name = "shellingham" name = "shellingham"
@@ -2006,13 +2007,13 @@ files = [
[[package]] [[package]]
name = "stevedore" name = "stevedore"
version = "5.3.0" version = "5.4.0"
description = "Manage dynamic plugins for Python applications" description = "Manage dynamic plugins for Python applications"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
files = [ files = [
{file = "stevedore-5.3.0-py3-none-any.whl", hash = "sha256:1efd34ca08f474dad08d9b19e934a22c68bb6fe416926479ba29e5013bcc8f78"}, {file = "stevedore-5.4.0-py3-none-any.whl", hash = "sha256:b0be3c4748b3ea7b854b265dcb4caa891015e442416422be16f8b31756107857"},
{file = "stevedore-5.3.0.tar.gz", hash = "sha256:9a64265f4060312828151c204efbe9b7a9852a0d9228756344dbc7e4023e375a"}, {file = "stevedore-5.4.0.tar.gz", hash = "sha256:79e92235ecb828fe952b6b8b0c6c87863248631922c8e8e0fa5b17b232c4514d"},
] ]
[package.dependencies] [package.dependencies]
@@ -2020,26 +2021,22 @@ pbr = ">=2.0.0"
[[package]] [[package]]
name = "tidalapi" name = "tidalapi"
version = "0.8.1" version = "0.8.2"
description = "Unofficial API for TIDAL music streaming service." description = "Unofficial API for TIDAL music streaming service."
optional = false optional = false
python-versions = "^3.8" python-versions = "<4.0,>=3.8"
files = [] files = [
develop = false {file = "tidalapi-0.8.2-py3-none-any.whl", hash = "sha256:46998ecb9103aee6d1346ad2974df05fe9bc03a429a89d5a701571fe4d9c4525"},
{file = "tidalapi-0.8.2.tar.gz", hash = "sha256:9eab07291f92f506694b6d326dd1e6651349eaa71597cef634a62915312db38c"},
]
[package.dependencies] [package.dependencies]
isodate = "^0.7.2" isodate = ">=0.7.2,<0.8.0"
mpegdash = "^0.4.0" mpegdash = ">=0.4.0,<0.5.0"
python-dateutil = "^2.8.2" python-dateutil = ">=2.8.2,<3.0.0"
ratelimit = "^2.2.1" ratelimit = ">=2.2.1,<3.0.0"
requests = "^2.32.3" requests = ">=2.32.3,<3.0.0"
typing-extensions = "^4.12.2" typing-extensions = ">=4.12.2,<5.0.0"
[package.source]
type = "git"
url = "git@github.com:exislow/python-tidal.git"
reference = "302-return-raw-values"
resolved_reference = "c543cca8db60730038c52db3329c81cec27e340d"
[[package]] [[package]]
name = "tokenize-rt" name = "tokenize-rt"
@@ -2065,13 +2062,43 @@ files = [
[[package]] [[package]]
name = "tomli" name = "tomli"
version = "2.1.0" version = "2.2.1"
description = "A lil' TOML parser" description = "A lil' TOML parser"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"}, {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
{file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
{file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
{file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
{file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
{file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
{file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
{file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
{file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
{file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
{file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
{file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
{file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
{file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
] ]
[[package]] [[package]]
@@ -2114,13 +2141,13 @@ test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"]
[[package]] [[package]]
name = "typer" name = "typer"
version = "0.12.5" version = "0.14.0"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints." description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b"}, {file = "typer-0.14.0-py3-none-any.whl", hash = "sha256:f476233a25770ab3e7b2eebf7c68f3bc702031681a008b20167573a4b7018f09"},
{file = "typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722"}, {file = "typer-0.14.0.tar.gz", hash = "sha256:af58f737f8d0c0c37b9f955a6d39000b9ff97813afcbeef56af5e37cf743b45a"},
] ]
[package.dependencies] [package.dependencies]
@@ -2174,13 +2201,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.27.1" version = "20.28.0"
description = "Virtual Python Environment builder" description = "Virtual Python Environment builder"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"}, {file = "virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0"},
{file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"}, {file = "virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa"},
] ]
[package.dependencies] [package.dependencies]
@@ -2240,4 +2267,4 @@ gui = ["pyqtdarktheme-fork", "pyside6"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<3.13" python-versions = ">=3.10,<3.13"
content-hash = "8cd14249efdfe09f484027075e96b19112fccb535cd0da53229de4823fa87c9a" content-hash = "5d06aa39f3567f397125c1d081fbde9f3ca9eb7f454385cd02bdaeac3dbfd8e1"
+9 -8
View File
@@ -1,7 +1,7 @@
[tool.poetry] [tool.poetry]
name = "tidal-dl-ng" name = "tidal-dl-ng"
authors = ["Robert Honz <cen.sored@gmail.com>"] authors = ["Robert Honz <cen.sored@gmail.com>"]
version = "0.15.6" version = "0.20.3"
description = "TIDAL Medial Downloader Next Generation!" description = "TIDAL Medial Downloader Next Generation!"
repository = "https://github.com/exislow/tidal-dl-ng" repository = "https://github.com/exislow/tidal-dl-ng"
documentation = "https://exislow.github.io/tidal-dl-ng/" documentation = "https://exislow.github.io/tidal-dl-ng/"
@@ -33,14 +33,14 @@ dataclasses-json = "^0.6.7"
pathvalidate = "^3.2.1" pathvalidate = "^3.2.1"
m3u8 = "^6.0.0" m3u8 = "^6.0.0"
coloredlogs = "^15.0.1" coloredlogs = "^15.0.1"
pyside6 = {version = "^6.8.0.2", optional = true} pyside6 = {version = "^6.7.0", optional = true}
pyqtdarktheme-fork = {version = "^2.3.2", optional = true} pyqtdarktheme-fork = {version = "^2.3.2", optional = true}
mpegdash = "^0.4.0" mpegdash = "^0.4.0"
rich = "^13.9.4" rich = "^13.9.4"
toml = "^0.10.2" toml = "^0.10.2"
typer = "^0.12.5" typer = "^0.14.0"
ffmpeg-python = "^0.2.0" ffmpeg-python = "^0.2.0"
tidalapi = { git = "git@github.com:exislow/python-tidal.git", branch = "302-return-raw-values" } tidalapi = "^0.8.2"
[tool.poetry.extras] [tool.poetry.extras]
gui = ["pyside6", "pyqtdarktheme-fork"] gui = ["pyside6", "pyqtdarktheme-fork"]
@@ -70,9 +70,10 @@ black = "^24.10.0"
optional = true optional = true
[tool.poetry.group.docs.dependencies] [tool.poetry.group.docs.dependencies]
mkdocs = "^1.5.3" mkdocs = "^1.6.1"
mkdocs-material = "^9.4.14" mkdocs-material = "^9.5.44"
mkdocstrings = { extras = ["python"], version = "^0.24.0" } mkdocstrings = { extras = ["python"], version = "^0.27.0" }
griffe = "^1.5.1"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
@@ -171,7 +172,7 @@ profile = "black"
multi_line_output = 3 multi_line_output = 3
indent = 4 indent = 4
color_output = true color_output = true
known_third_party = ["Crypto", "PySide6", "coloredlogs", "constants", "dataclasses_json", "ffmpeg", "m3u8", "mutagen", "pathvalidate", "requests", "rich", "tidalapi", "toml", "typer"] known_third_party = ["Crypto", "PySide6", "coloredlogs", "dataclasses_json", "ffmpeg", "m3u8", "mutagen", "pathvalidate", "requests", "rich", "tidalapi", "toml", "typer"]
[tool.mypy] [tool.mypy]
# https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file # https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file
+3 -7
View File
@@ -6,6 +6,9 @@ EXTENSION_LYRICS: str = ".lrc"
UNIQUIFY_THRESHOLD: int = 99 UNIQUIFY_THRESHOLD: int = 99
FILENAME_SANITIZE_PLACEHOLDER: str = "_" FILENAME_SANITIZE_PLACEHOLDER: str = "_"
COVER_NAME: str = "cover.jpg" COVER_NAME: str = "cover.jpg"
BLOCK_SIZE: int = 4096
BLOCKS: int = 1024
CHUNK_SIZE: int = BLOCK_SIZE * BLOCKS
class QualityVideo(StrEnum): class QualityVideo(StrEnum):
@@ -24,13 +27,6 @@ class MediaType(StrEnum):
ARTIST: str = "artist" ARTIST: str = "artist"
class SkipExisting(StrEnum):
Disabled: str = "False"
Filename: str = "exact"
ExtensionIgnore: str = "extension_ignore"
Append: str = "append"
class CoverDimensions(StrEnum): class CoverDimensions(StrEnum):
Px80: str = "80" Px80: str = "80"
Px160: str = "160" Px160: str = "160"
+2 -2
View File
@@ -10,7 +10,7 @@ from tidalapi import Quality as QualityAudio
from tidal_dl_ng import __version__ from tidal_dl_ng import __version__
from tidal_dl_ng.config import Settings from tidal_dl_ng.config import Settings
from tidal_dl_ng.constants import CoverDimensions, QualityVideo, SkipExisting from tidal_dl_ng.constants import CoverDimensions, QualityVideo
from tidal_dl_ng.model.cfg import HelpSettings from tidal_dl_ng.model.cfg import HelpSettings
from tidal_dl_ng.model.cfg import Settings as ModelSettings from tidal_dl_ng.model.cfg import Settings as ModelSettings
from tidal_dl_ng.model.meta import ReleaseLatest from tidal_dl_ng.model.meta import ReleaseLatest
@@ -159,7 +159,6 @@ class DialogPreferences(QtWidgets.QDialog):
def _init_comboboxes(self): def _init_comboboxes(self):
self.parameters_combo = [ self.parameters_combo = [
("skip_existing", SkipExisting),
("quality_audio", QualityAudio), ("quality_audio", QualityAudio),
("quality_video", QualityVideo), ("quality_video", QualityVideo),
("metadata_cover_dimension", CoverDimensions), ("metadata_cover_dimension", CoverDimensions),
@@ -175,6 +174,7 @@ class DialogPreferences(QtWidgets.QDialog):
"extract_flac", "extract_flac",
"metadata_cover_embed", "metadata_cover_embed",
"cover_album_file", "cover_album_file",
"skip_existing",
] ]
def gui_populate(self): def gui_populate(self):
+273 -159
View File
@@ -5,22 +5,31 @@ import shutil
import tempfile import tempfile
import time import time
from collections.abc import Callable from collections.abc import Callable
from concurrent import futures
from uuid import uuid4 from uuid import uuid4
import ffmpeg import ffmpeg
import m3u8 import m3u8
import requests import requests
from constants import COVER_NAME from requests.adapters import HTTPAdapter, Retry
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from rich.progress import Progress, TaskID from rich.progress import Progress, TaskID
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
from tidalapi.exceptions import TooManyRequests
from tidalapi.media import AudioExtensions, Codec, Quality, StreamManifest, VideoExtensions from tidalapi.media import AudioExtensions, Codec, Quality, StreamManifest, VideoExtensions
from tidal_dl_ng.config import Settings from tidal_dl_ng.config import Settings
from tidal_dl_ng.constants import EXTENSION_LYRICS, REQUESTS_TIMEOUT_SEC, MediaType, QualityVideo, SkipExisting from tidal_dl_ng.constants import (
CHUNK_SIZE,
COVER_NAME,
EXTENSION_LYRICS,
REQUESTS_TIMEOUT_SEC,
MediaType,
QualityVideo,
)
from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token
from tidal_dl_ng.helper.exceptions import MediaMissing from tidal_dl_ng.helper.exceptions import MediaMissing
from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_file_sanitize from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_file_sanitize, url_to_filename
from tidal_dl_ng.helper.tidal import ( from tidal_dl_ng.helper.tidal import (
instantiate_media, instantiate_media,
items_results_all, items_results_all,
@@ -30,6 +39,7 @@ from tidal_dl_ng.helper.tidal import (
name_builder_title, name_builder_title,
) )
from tidal_dl_ng.metadata import Metadata from tidal_dl_ng.metadata import Metadata
from tidal_dl_ng.model.downloader import DownloadSegmentResult
from tidal_dl_ng.model.gui_data import ProgressBars from tidal_dl_ng.model.gui_data import ProgressBars
@@ -51,7 +61,7 @@ class RequestsClient:
class Download: class Download:
settings: Settings settings: Settings
session: Session session: Session
skip_existing: SkipExisting = SkipExisting.Disabled skip_existing: bool = False
fn_logger: Callable fn_logger: Callable
progress_gui: ProgressBars progress_gui: ProgressBars
progress: Progress progress: Progress
@@ -61,7 +71,7 @@ class Download:
session: Session, session: Session,
path_base: str, path_base: str,
fn_logger: Callable, fn_logger: Callable,
skip_existing: SkipExisting = SkipExisting.Disabled, skip_existing: bool = False,
progress_gui: ProgressBars = None, progress_gui: ProgressBars = None,
progress: Progress = None, progress: Progress = None,
): ):
@@ -88,27 +98,34 @@ class Download:
def _download( def _download(
self, self,
media: Track | Video, media: Track | Video,
path_file: str, stream_manifest: StreamManifest,
) -> str: path_file: pathlib.Path,
) -> (bool, pathlib.Path):
media_name: str = name_builder_item(media) media_name: str = name_builder_item(media)
urls: [str] urls: [str]
path_base: pathlib.Path = path_file.parent
result_segments: bool = True
dl_segment_results: [DownloadSegmentResult] = []
result_merge: bool = False
# Get urls for media. # Get urls for media.
if isinstance(media, Track): try:
urls = media.get_stream().get_stream_manifest().urls if isinstance(media, Track):
stream_manifest: StreamManifest = media.get_stream().get_stream_manifest() urls = stream_manifest.get_urls()
elif isinstance(media, Video): elif isinstance(media, Video):
m3u8_variant: m3u8.M3U8 = m3u8.load(media.get_url()) m3u8_variant: m3u8.M3U8 = m3u8.load(media.get_url())
# Find the desired video resolution or the next best one. # Find the desired video resolution or the next best one.
m3u8_playlist, codecs = self._extract_video_stream(m3u8_variant, int(self.settings.data.quality_video)) m3u8_playlist, codecs = self._extract_video_stream(m3u8_variant, int(self.settings.data.quality_video))
# Populate urls. # Populate urls.
urls = m3u8_playlist.files urls = m3u8_playlist.files
except Exception:
return False, path_file
# Set the correct progress output channel. # Set the correct progress output channel.
if self.progress_gui is None: if self.progress_gui is None:
progress_stdout: bool = True progress_to_stdout: bool = True
else: else:
progress_stdout: bool = False progress_to_stdout: bool = False
# Send signal to GUI with media name # Send signal to GUI with media name
self.progress_gui.item_name.emit(media_name[:30]) self.progress_gui.item_name.emit(media_name[:30])
@@ -119,8 +136,11 @@ class Download:
progress_total: int = urls_count progress_total: int = urls_count
block_size: int | None = None block_size: int | None = None
elif urls_count == 1: elif urls_count == 1:
# Will be computed later. # Get file size and compute progress steps
progress_total: float = None r = requests.head(urls[0], timeout=REQUESTS_TIMEOUT_SEC)
total_size_in_bytes: int = int(r.headers.get("content-length", 0))
block_size: int | None = 1048576
progress_total: float = total_size_in_bytes / block_size
else: else:
raise ValueError raise ValueError
@@ -128,57 +148,115 @@ class Download:
p_task: TaskID = self.progress.add_task( p_task: TaskID = self.progress.add_task(
f"[blue]Item '{media_name[:30]}'", f"[blue]Item '{media_name[:30]}'",
total=progress_total, total=progress_total,
visible=progress_stdout, visible=progress_to_stdout,
) )
# Write content to file until progress is finished. # Download segments until progress is finished.
# TODO: Compute download speed (https://github.com/Textualize/rich/blob/master/examples/downloader.py)
while not self.progress.tasks[p_task].finished: while not self.progress.tasks[p_task].finished:
with open(path_file, "wb") as f: with futures.ThreadPoolExecutor(
for url in urls: max_workers=self.settings.data.downloads_simultaneous_per_track_max
try: ) as executor:
# Create the request object with stream=True, so the content won't be loaded into memory at once. # Dispatch all download tasks to worker threads
r = requests.get(url, stream=True, timeout=REQUESTS_TIMEOUT_SEC) l_futures: [any] = [
executor.submit(self._download_segment, url, path_base, block_size, p_task, progress_to_stdout)
for url in urls
]
# Report results as they become available
for future in futures.as_completed(l_futures):
# Retrieve result
result_dl_segment: DownloadSegmentResult = future.result()
r.raise_for_status() dl_segment_results.append(result_dl_segment)
# Compute progress iterations based on the file size and update task details. # check for a link that was skipped
if not progress_total: if not result_dl_segment.result and (result_dl_segment.url is not urls[-1]):
# Get file size and compute progress steps # Sometimes it happens, if a track is very short (< 8 seconds or so), that the last URL in `urls` is
total_size_in_bytes: int = int(r.headers.get("content-length", 0)) # invalid (HTTP Error 500) and not necessary. File won't be corrupt.
block_size: int | None = 1048576 # If this is NOT the case, but any other URL has resulted in an error,
progress_total: float = total_size_in_bytes / block_size # mark the whole thing as corrupt.
result_segments = False
self.fn_logger.error(f"Something went wrong while downloading {media_name}. File is corrupt!")
self.progress.update(p_task, total=progress_total) tmp_path_file_decrypted: pathlib.Path = path_file
# Write the content to disk. If `chunk_size` is set to `None` the whole file will be written at once. # Only if no error happened while downloading.
for data in r.iter_content(chunk_size=block_size): if result_segments:
f.write(data) # Bring list into right order, so segments can be easily merged.
# Advance progress bar. dl_segment_results.sort(key=lambda x: x.id_segment)
self.progress.advance(p_task) result_merge: bool = self._segments_merge(path_file, dl_segment_results)
except HTTPError as e:
if url is urls[-1]:
# It happens, if a track is very short (< 8 seconds or so), that the last URL in `urls` is
# invalid (HTTP Error 500) and not necessary. File won't be corrupt.
# Thus, advance progress bar to avoid infinity loops.
self.progress.advance(p_task)
else:
# Finish downloading early and report error.
# TODO: The track should somehow be marked as corrupt.
self.progress.update(p_task, completed=progress_total)
self.fn_logger.error(e)
finally:
# To send the progress to the GUI, we need to emit the percentage.
if not progress_stdout:
self.progress_gui.item.emit(self.progress.tasks[p_task].percentage)
if isinstance(media, Track) and stream_manifest.is_encrypted: if not result_merge:
key, nonce = decrypt_security_token(stream_manifest.encryption_key) self.fn_logger.error(f"Something went wrong while writing to {media_name}. File is corrupt!")
tmp_path_file_decrypted = path_file + "_decrypted" elif result_merge and isinstance(media, Track) and stream_manifest.is_encrypted:
decrypt_file(path_file, tmp_path_file_decrypted, key, nonce) key, nonce = decrypt_security_token(stream_manifest.encryption_key)
else: tmp_path_file_decrypted = path_file.with_suffix(".decrypted")
tmp_path_file_decrypted = path_file decrypt_file(path_file, tmp_path_file_decrypted, key, nonce)
return tmp_path_file_decrypted return result_merge, tmp_path_file_decrypted
def _segments_merge(self, path_file, dl_segment_results) -> bool:
result: bool
# Copy the content of all segments into one file.
try:
with path_file.open("wb") as f_target:
for dl_segment_result in dl_segment_results:
with dl_segment_result.path_segment.open("rb") as f_segment:
# Read and write junks, which gives better HDD write performance
while segment := f_segment.read(CHUNK_SIZE):
f_target.write(segment)
# Delete segment from HDD
dl_segment_result.path_segment.unlink()
result = True
except Exception:
result = False
return result
def _download_segment(
self, url: str, path_base: pathlib.Path, block_size: int | None, p_task: TaskID, progress_to_stdout: bool
) -> DownloadSegmentResult:
result: bool = False
path_segment: pathlib.Path = path_base / url_to_filename(url)
# Calculate the segment ID based on the file name within the URL.
filename_stem: str = str(path_segment.stem).split("_")[-1]
# CAUTION: This is a workaround, so BTS (LOW quality) track will work. They usually have only ONE link.
id_segment: int = int(filename_stem) if filename_stem.isdecimal() else 0
error: HTTPError | None = None
# Retry download on failed segments, with an exponential delay between retries
s = requests.Session()
retries = Retry(total=5, backoff_factor=1) # , status_forcelist=[ 502, 503, 504 ])
s.mount("https://", HTTPAdapter(max_retries=retries))
try:
# Create the request object with stream=True, so the content won't be loaded into memory at once.
r = s.get(url, stream=True, timeout=REQUESTS_TIMEOUT_SEC)
r.raise_for_status()
# Write the content to disk. If `chunk_size` is set to `None` the whole file will be written at once.
with path_segment.open("wb") as f:
for data in r.iter_content(chunk_size=block_size):
f.write(data)
# Advance progress bar.
self.progress.advance(p_task)
result = True
except Exception:
self.progress.advance(p_task)
# To send the progress to the GUI, we need to emit the percentage.
if not progress_to_stdout:
self.progress_gui.item.emit(self.progress.tasks[p_task].percentage)
return DownloadSegmentResult(
result=result, url=url, path_segment=path_segment, id_segment=id_segment, error=error
)
def item( def item(
self, self,
@@ -219,103 +297,139 @@ class Download:
return False, "" return False, ""
# Get extension.
file_extension: str
if isinstance(media, Track):
# If a quality is explicitly set, change it.
if quality_audio:
quality_audio_old: Quality = self.adjust_quality_audio(quality_audio)
file_extension = media.get_stream().get_stream_manifest().file_extension
# Use M4A extension for MP4 audio tracks, because it looks better and is completely interchangeable.
file_extension = AudioExtensions.M4A if file_extension == AudioExtensions.MP4 else file_extension
if self.settings.data.extract_flac:
do_flac_extract = False
if (
media.get_stream().get_stream_manifest().codecs.upper() == Codec.FLAC
and file_extension != AudioExtensions.FLAC
):
file_extension = AudioExtensions.FLAC
do_flac_extract = True
elif isinstance(media, Video):
if quality_video:
quality_video_old: QualityVideo = self.adjust_quality_video(quality_video)
file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS
# Create file name and path # Create file name and path
file_extension_dummy: str = AudioExtensions.FLAC
file_name_relative = format_path_media(file_template, media) file_name_relative = format_path_media(file_template, media)
path_media_dst = os.path.abspath( path_media_dst: pathlib.Path = (
os.path.normpath(os.path.join(os.path.expanduser(self.path_base), file_name_relative)) (pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension_dummy))
.resolve()
.absolute()
) )
# Sanitize final path_file to fit into OS boundaries. # Sanitize final path_file to fit into OS boundaries.
uniquify: bool = self.skip_existing == SkipExisting.Append path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True))
path_media_dst = path_file_sanitize(path_media_dst + file_extension, adapt=True, uniquify=uniquify)
# Compute if and how downloads need to be skipped. # Compute if and how downloads need to be skipped.
if self.skip_existing in (SkipExisting.ExtensionIgnore, SkipExisting.Filename): if self.skip_existing:
extension_ignore: bool = self.skip_existing == SkipExisting.ExtensionIgnore skip_file: bool = check_file_exists(path_media_dst, extension_ignore=True)
file_exists: bool = check_file_exists(path_media_dst, extension_ignore=extension_ignore)
else: else:
file_exists: bool = False skip_file: bool = False
if not skip_file:
# Get extension.
file_extension: str
do_flac_extract = False
# If a quality is explicitly set, change it and remember the previously set quality.
quality_audio_old: Quality = self.adjust_quality_audio(quality_audio) if quality_audio else quality_audio
quality_video_old: QualityVideo = (
self.adjust_quality_video(quality_video) if quality_video else quality_video
)
try:
media_stream = media.get_stream()
stream_manifest: StreamManifest = media_stream.get_stream_manifest()
except TooManyRequests:
self.fn_logger.exception(
f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. "
f"Consider to activate delay between downloads."
)
return False, ""
except Exception:
self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.")
return False, ""
if isinstance(media, Track):
# TODO: HOTFIX! Go back to
# file_extension = media_stream.get_stream_manifest().file_extension
# After tidalapi has fixed #304
if stream_manifest.file_extension is VideoExtensions.TS:
file_extension = stream_manifest.file_extension
elif stream_manifest.dash_info and AudioExtensions.FLAC in stream_manifest.dash_info.first_url:
file_extension = AudioExtensions.FLAC
else:
file_extension = AudioExtensions.M4A
if self.settings.data.extract_flac and (
stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC
):
file_extension = AudioExtensions.FLAC
do_flac_extract = True
elif isinstance(media, Video):
file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS
# Compute file name and create destination directory
path_media_dst = path_media_dst.with_suffix(file_extension)
os.makedirs(path_media_dst.parent, exist_ok=True)
if not file_exists:
# Create a temp directory and file. # Create a temp directory and file.
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir: with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir:
tmp_path_file = os.path.join(tmp_path_dir, str(uuid4())) tmp_path_file: pathlib.Path = pathlib.Path(tmp_path_dir) / str(uuid4())
# Create empty file
tmp_path_file.touch()
# Download media. # Download media.
tmp_path_file = self._download(media=media, path_file=tmp_path_file) result_download, tmp_path_file = self._download(
media=media, stream_manifest=stream_manifest, path_file=tmp_path_file
)
# Convert video from TS to MP4 if result_download:
if isinstance(media, Video) and self.settings.data.video_convert_mp4: # Convert video from TS to MP4
# Convert `*.ts` file to `*.mp4` using ffmpeg if isinstance(media, Video) and self.settings.data.video_convert_mp4:
tmp_path_file = self._video_convert(tmp_path_file) # Convert `*.ts` file to `*.mp4` using ffmpeg
tmp_path_file = self._video_convert(tmp_path_file)
# Extract FLAC from MP4 container using ffmpeg # Extract FLAC from MP4 container using ffmpeg
if isinstance(media, Track) and self.settings.data.extract_flac and do_flac_extract: if isinstance(media, Track) and self.settings.data.extract_flac and do_flac_extract:
tmp_path_file = self._extract_flac(tmp_path_file) tmp_path_file = self._extract_flac(tmp_path_file)
tmp_path_lyrics: pathlib.Path | None = None tmp_path_lyrics: pathlib.Path | None = None
tmp_path_cover: pathlib.Path | None = None tmp_path_cover: pathlib.Path | None = None
# Write metadata to file. # Write metadata to file.
if not isinstance(media, Video): if not isinstance(media, Video):
result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write( result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write(
media, tmp_path_file, is_parent_album media, tmp_path_file, is_parent_album
) )
# Move lyrics file
if self.settings.data.lyrics_file and not isinstance(media, Video) and tmp_path_lyrics:
self._move_lyrics(tmp_path_lyrics, path_media_dst)
# Move cover file
# TODO: Cover is downloaded with every track of the album. Needs refactoring, so cover is only
# downloaded for an album once.
if self.settings.data.cover_album_file and tmp_path_cover:
self._move_cover(tmp_path_cover, path_media_dst)
self.fn_logger.info(f"Downloaded item '{name_builder_item(media)}'.")
# Move final file to the configured destination directory. # Move final file to the configured destination directory.
os.makedirs(os.path.dirname(path_media_dst), exist_ok=True)
shutil.move(tmp_path_file, path_media_dst) shutil.move(tmp_path_file, path_media_dst)
# Move lyrics file if quality_audio:
if self.settings.data.lyrics_file and not isinstance(media, Video): # Set quality back to the global user value
self._move_lyrics(tmp_path_lyrics, path_media_dst) self.adjust_quality_audio(quality_audio_old)
# Move cover file if quality_video:
if self.settings.data.cover_album_file: # Set quality back to the global user value
self._move_cover(tmp_path_cover, path_media_dst) self.adjust_quality_video(quality_video_old)
else: else:
self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'") self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'")
status_download: bool = not file_exists status_download: bool = not skip_file
if quality_audio:
# Set quality back to the global user value
self.adjust_quality_audio(quality_audio_old)
if quality_video:
# Set quality back to the global user value
self.adjust_quality_video(quality_video_old)
# Whether a file was downloaded or skipped and the download delay is enabled, wait until the next download. # Whether a file was downloaded or skipped and the download delay is enabled, wait until the next download.
# Only use this, if you have a list of several Track items. # Only use this, if you have a list of several Track items.
if download_delay: if download_delay and not skip_file:
time_sleep: float = round(random.SystemRandom().uniform(2, 5), 1) time_sleep: float = round(
random.SystemRandom().uniform(
self.settings.data.download_delay_sec_min, self.settings.data.download_delay_sec_max
),
1,
)
self.fn_logger.debug(f"Next download will start in {time_sleep} seconds.") self.fn_logger.debug(f"Next download will start in {time_sleep} seconds.")
time.sleep(time_sleep) time.sleep(time_sleep)
@@ -340,7 +454,7 @@ class Download:
result: bool result: bool
# Check if the file was downloaded # Check if the file was downloaded
if path_file_source and os.path.isfile(path_file_source): if path_file_source and path_file_source.is_file():
# Move it. # Move it.
shutil.move(path_file_source, path_file_destination) shutil.move(path_file_source, path_file_destination)
@@ -350,16 +464,16 @@ class Download:
return result return result
def _move_lyrics(self, path_lyrics: pathlib.Path, file_media_dst: str) -> bool: def _move_lyrics(self, path_lyrics: pathlib.Path, file_media_dst: pathlib.Path) -> bool:
# Build tmp lyrics filename # Build tmp lyrics filename
path_file_lyrics: str = os.path.splitext(file_media_dst)[0] + EXTENSION_LYRICS path_file_lyrics: pathlib.Path = file_media_dst.with_suffix(EXTENSION_LYRICS)
result: bool = self._move_file(path_lyrics, path_file_lyrics) result: bool = self._move_file(path_lyrics, path_file_lyrics)
return result return result
def _move_cover(self, path_cover: pathlib.Path, file_media_dst: str) -> bool: def _move_cover(self, path_cover: pathlib.Path, file_media_dst: pathlib.Path) -> bool:
# Build tmp lyrics filename # Build tmp lyrics filename
path_file_cover: pathlib.Path = pathlib.Path(file_media_dst).parent.absolute() / COVER_NAME path_file_cover: pathlib.Path = file_media_dst.parent / COVER_NAME
result: bool = self._move_file(path_cover, path_file_cover) result: bool = self._move_file(path_cover, path_file_cover)
return result return result
@@ -403,7 +517,7 @@ class Download:
return result return result
def metadata_write( def metadata_write(
self, track: Track, path_media: str, is_parent_album: str self, track: Track, path_media: pathlib.Path, is_parent_album: bool
) -> (bool, pathlib.Path | None, pathlib.Path | None): ) -> (bool, pathlib.Path | None, pathlib.Path | None):
result: bool = False result: bool = False
path_lyrics: pathlib.Path | None = None path_lyrics: pathlib.Path | None = None
@@ -433,14 +547,14 @@ class Download:
print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.") print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.")
if lyrics and self.settings.data.lyrics_file: if lyrics and self.settings.data.lyrics_file:
path_lyrics = self.lyrics_to_file(pathlib.Path(path_media).parent, lyrics) path_lyrics = self.lyrics_to_file(path_media.parent, lyrics)
if self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album): if self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album):
url_cover = track.album.image(int(self.settings.data.metadata_cover_dimension)) url_cover = track.album.image(int(self.settings.data.metadata_cover_dimension))
cover_data = self.cover_data(url=url_cover) cover_data = self.cover_data(url=url_cover)
if cover_data and self.settings.data.cover_album_file and is_parent_album: if cover_data and self.settings.data.cover_album_file and is_parent_album:
path_cover = self.cover_to_file(pathlib.Path(path_media).parent, cover_data) path_cover = self.cover_to_file(path_media.parent, cover_data)
# `None` values are not allowed. # `None` values are not allowed.
m: Metadata = Metadata( m: Metadata = Metadata(
@@ -487,29 +601,22 @@ class Download:
file_name_relative = format_path_media(file_template, media) file_name_relative = format_path_media(file_template, media)
# Get the name of the list and check, if videos should be included. # Get the name of the list and check, if videos should be included.
videos_include: bool = True list_media_name: str = name_builder_title(media)
list_media_name_short: str = list_media_name[:30]
if isinstance(media, Mix):
list_media_name = media.title[:30]
elif video_download:
list_media_name = name_builder_title(media)[:30]
else:
videos_include = False
list_media_name = name_builder_title(media)[:30]
# Get all items of the list. # Get all items of the list.
items = items_results_all(media, videos_include=videos_include) items = items_results_all(media, videos_include=video_download)
# Determine where to redirect the progress information. # Determine where to redirect the progress information.
if self.progress_gui is None: if self.progress_gui is None:
progress_stdout: bool = True progress_stdout: bool = True
else: else:
progress_stdout: bool = False progress_stdout: bool = False
self.progress_gui.list_name.emit(list_media_name[:30]) self.progress_gui.list_name.emit(list_media_name_short[:30])
# Create the list progress task. # Create the list progress task.
p_task1: TaskID = self.progress.add_task( p_task1: TaskID = self.progress.add_task(
f"[green]List '{list_media_name}'", total=len(items), visible=progress_stdout f"[green]List '{list_media_name_short}'", total=len(items), visible=progress_stdout
) )
is_album: bool = isinstance(media, Album) is_album: bool = isinstance(media, Album)
@@ -518,7 +625,7 @@ class Download:
while not self.progress.finished: while not self.progress.finished:
for item_media in items: for item_media in items:
# Download the item. # Download the item.
status_download, result_path_file = self.item( status, result_path_file = self.item(
media=item_media, media=item_media,
file_template=file_name_relative, file_template=file_name_relative,
quality_audio=quality_audio, quality_audio=quality_audio,
@@ -533,22 +640,29 @@ class Download:
if not progress_stdout: if not progress_stdout:
self.progress_gui.list_item.emit(self.progress.tasks[p_task1].percentage) self.progress_gui.list_item.emit(self.progress.tasks[p_task1].percentage)
def _video_convert(self, path_file: str) -> str: self.fn_logger.info(f"Finished list '{list_media_name}'.")
path_file_out = path_file + AudioExtensions.MP4
def _video_convert(self, path_file: pathlib.Path) -> pathlib.Path:
path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4)
result, _ = ( result, _ = (
ffmpeg.input(path_file) ffmpeg.input(path_file)
.output(path_file_out, map=0, c="copy", loglevel="quiet") .output(str(path_file_out), map=0, c="copy", loglevel="quiet")
.run(cmd=self.settings.data.path_binary_ffmpeg) .run(cmd=self.settings.data.path_binary_ffmpeg)
) )
return path_file_out return path_file_out
def _extract_flac(self, path_media_src: str) -> str: def _extract_flac(self, path_media_src: pathlib.Path) -> pathlib.Path:
path_media_out = path_media_src + AudioExtensions.FLAC path_media_out = path_media_src.with_suffix(AudioExtensions.FLAC)
result, _ = ( result, _ = (
ffmpeg.input(path_media_src) ffmpeg.input(path_media_src)
.output( .output(
path_media_out, map=0, movflags="use_metadata_tags", acodec="copy", map_metadata="0:g", loglevel="quiet" str(path_media_out),
map=0,
movflags="use_metadata_tags",
acodec="copy",
map_metadata="0:g",
loglevel="quiet",
) )
.run(cmd=self.settings.data.path_binary_ffmpeg) .run(cmd=self.settings.data.path_binary_ffmpeg)
) )
+2
View File
@@ -516,6 +516,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self._init_dl() self._init_dl()
def search(self, query: str, types_media: SearchTypes) -> [ResultItem]: def search(self, query: str, types_media: SearchTypes) -> [ResultItem]:
query = query.strip()
# If a direct link was searched for, skip search and create the object from the link directly. # If a direct link was searched for, skip search and create the object from the link directly.
if "http" in query: if "http" in query:
media_type = get_tidal_media_type(query) media_type = get_tidal_media_type(query)
+4 -3
View File
@@ -1,4 +1,5 @@
import base64 import base64
import pathlib
from Crypto.Cipher import AES from Crypto.Cipher import AES
from Crypto.Util import Counter from Crypto.Util import Counter
@@ -35,7 +36,7 @@ def decrypt_security_token(security_token: str) -> (str, str):
return key, nonce return key, nonce
def decrypt_file(path_file_encrypted: str, path_file_destination: str, key: str, nonce: str) -> None: def decrypt_file(path_file_encrypted: pathlib.Path, path_file_destination: pathlib.Path, key: str, nonce: str) -> None:
""" """
Decrypts an encrypted MQA file given the file, key and nonce. Decrypts an encrypted MQA file given the file, key and nonce.
TODO: Is it really only necessary for MQA of for all other formats, too? TODO: Is it really only necessary for MQA of for all other formats, too?
@@ -46,9 +47,9 @@ def decrypt_file(path_file_encrypted: str, path_file_destination: str, key: str,
decryptor = AES.new(key, AES.MODE_CTR, counter=counter) decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
# Open and decrypt # Open and decrypt
with open(path_file_encrypted, "rb") as f_src: with path_file_encrypted.open("rb") as f_src:
audio_decrypted = decryptor.decrypt(f_src.read()) audio_decrypted = decryptor.decrypt(f_src.read())
# Replace with decrypted file # Replace with decrypted file
with open(path_file_destination, "wb") as f_dst: with path_file_destination.open("wb") as f_dst:
f_dst.write(audio_decrypted) f_dst.write(audio_decrypted)
+28 -8
View File
@@ -1,8 +1,10 @@
import math import math
import os import os
import pathlib
import posixpath
import re import re
import sys import sys
from pathlib import Path, PosixPath from urllib.parse import unquote, urlsplit
from pathvalidate import sanitize_filename, sanitize_filepath from pathvalidate import sanitize_filename, sanitize_filepath
from pathvalidate.error import ValidationError from pathvalidate.error import ValidationError
@@ -77,8 +79,7 @@ def format_str_media(name: str, media: Track | Album | Playlist | UserPlaylist |
elif hasattr(media, "artist"): elif hasattr(media, "artist"):
result = media.artist.name result = media.artist.name
case "album_artist": case "album_artist":
if isinstance(media, Album): result = name_builder_album_artist(media)
result = name_builder_album_artist(media)
case "track_title": case "track_title":
if isinstance(media, Track | Video): if isinstance(media, Track | Video):
result = name_builder_title(media) result = name_builder_title(media)
@@ -195,7 +196,7 @@ def get_format_template(
def path_file_sanitize(path_file: str, adapt: bool = False, uniquify: bool = False) -> (bool, str): def path_file_sanitize(path_file: str, adapt: bool = False, uniquify: bool = False) -> (bool, str):
# Split into path and filename # Split into path and filename
pathname, filename = os.path.split(path_file) pathname, filename = os.path.split(path_file)
file_extension: str = Path(path_file).suffix file_extension: str = pathlib.Path(path_file).suffix
# Sanitize path # Sanitize path
try: try:
@@ -205,7 +206,7 @@ def path_file_sanitize(path_file: str, adapt: bool = False, uniquify: bool = Fal
except ValidationError: except ValidationError:
# If adaption of path is allowed in case of an error set path to HOME. # If adaption of path is allowed in case of an error set path to HOME.
if adapt: if adapt:
pathname_sanitized: str = Path.home() pathname_sanitized: str = str(pathlib.Path.home())
else: else:
raise raise
@@ -272,10 +273,10 @@ def file_unique_suffix(path_file: str, seperator: str = "_") -> str:
return unique_suffix return unique_suffix
def check_file_exists(path_file: str, extension_ignore: bool = False) -> bool: def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) -> bool:
if extension_ignore: if extension_ignore:
path_file_stem: str = Path(path_file).stem path_file_stem: str = pathlib.Path(path_file).stem
path_parent: PosixPath = Path(path_file).parent path_parent: pathlib.Path = pathlib.Path(path_file).parent
path_files: [str] = [] path_files: [str] = []
for extension in AudioExtensions: for extension in AudioExtensions:
@@ -295,3 +296,22 @@ def resource_path(relative_path):
base_path = os.path.abspath(".") base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path) return os.path.join(base_path, relative_path)
def url_to_filename(url: str) -> str:
"""Return basename corresponding to url.
>>> print(url_to_filename('http://example.com/path/to/file%C3%80?opt=1'))
fileÀ
>>> print(url_to_filename('http://example.com/slash%2fname')) # '/' in name
Taken from https://gist.github.com/zed/c2168b9c52b032b5fb7d
Traceback (most recent call last):
...
ValueError
"""
urlpath: str = urlsplit(url).path
basename: str = posixpath.basename(unquote(urlpath))
if os.path.basename(basename) != basename or unquote(posixpath.basename(urlpath)) != basename:
raise ValueError # reject '%2f' or 'dir%5Cbasename.ext' on Windows
return basename
+5 -3
View File
@@ -25,8 +25,10 @@ def name_builder_album_artist(media: Track | Album) -> str:
return ", ".join(artists_tmp) return ", ".join(artists_tmp)
def name_builder_title(media: Track | Video) -> str: def name_builder_title(media: Track | Video | Mix | Playlist | Album | Video) -> str:
result: str = media.full_name if hasattr(media, "full_name") else media.name result: str = (
media.title if isinstance(media, Mix) else media.full_name if hasattr(media, "full_name") else media.name
)
return result return result
@@ -186,7 +188,7 @@ def instantiate_media(
def quality_audio_highest(media: Track | Album) -> Quality: def quality_audio_highest(media: Track | Album) -> Quality:
quality: Quality quality: Quality
if MediaMetadataTags.hires_lossless in media.media_metadata_tags: if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags:
quality = Quality.hi_res_lossless quality = Quality.hi_res_lossless
elif MediaMetadataTags.lossless in media.media_metadata_tags: elif MediaMetadataTags.lossless in media.media_metadata_tags:
quality = Quality.high_lossless quality = Quality.high_lossless
+3
View File
@@ -21,3 +21,6 @@ class LoggerWrapped:
def critical(self, value): def critical(self, value):
self.fn_print(value) self.fn_print(value)
def exception(self, value):
self.fn_print(value)
+2
View File
@@ -111,6 +111,8 @@ class Metadata:
self.m.tags["LYRICS"] = self.lyrics self.m.tags["LYRICS"] = self.lyrics
def set_mp3(self): def set_mp3(self):
# ID3 Frame (tags) overview: https://exiftool.org/TagNames/ID3.html / https://id3.org/id3v2.3.0
# Mapping overview: https://docs.mp3tag.de/mapping/
self.m.tags.add(TIT2(encoding=3, text=self.title)) self.m.tags.add(TIT2(encoding=3, text=self.title))
self.m.tags.add(TALB(encoding=3, text=self.album)) self.m.tags.add(TALB(encoding=3, text=self.album))
self.m.tags.add(TOPE(encoding=3, text=self.albumartist)) self.m.tags.add(TOPE(encoding=3, text=self.albumartist))
+10 -8
View File
@@ -3,13 +3,13 @@ from dataclasses import dataclass
from dataclasses_json import dataclass_json from dataclasses_json import dataclass_json
from tidalapi import Quality from tidalapi import Quality
from tidal_dl_ng.constants import CoverDimensions, QualityVideo, SkipExisting from tidal_dl_ng.constants import CoverDimensions, QualityVideo
@dataclass_json @dataclass_json
@dataclass @dataclass
class Settings: class Settings:
skip_existing: SkipExisting = SkipExisting.Disabled skip_existing: bool = True
lyrics_embed: bool = False lyrics_embed: bool = False
lyrics_file: bool = False lyrics_file: bool = False
# TODO: Implement API KEY selection. # TODO: Implement API KEY selection.
@@ -25,7 +25,7 @@ class Settings:
quality_video: QualityVideo = QualityVideo.P480 quality_video: QualityVideo = QualityVideo.P480
format_album: str = ( format_album: str = (
"Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}" "Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}"
"{album_track_num}. {artist_name} - {track_title}" "{album_track_num}. {artist_name} - {track_title}{album_explicit}"
) )
format_playlist: str = "Playlists/{playlist_name}/{artist_name} - {track_title}" format_playlist: str = "Playlists/{playlist_name}/{artist_name} - {track_title}"
format_mix: str = "Mix/{mix_name}/{artist_name} - {track_title}" format_mix: str = "Mix/{mix_name}/{artist_name} - {track_title}"
@@ -37,16 +37,15 @@ class Settings:
metadata_cover_embed: bool = True metadata_cover_embed: bool = True
cover_album_file: bool = True cover_album_file: bool = True
extract_flac: bool = True extract_flac: bool = True
downloads_simultaneous_per_track_max: int = 20
download_delay_sec_min: float = 3.0
download_delay_sec_max: float = 5.0
@dataclass_json @dataclass_json
@dataclass @dataclass
class HelpSettings: class HelpSettings:
skip_existing: str = ( skip_existing: str = "Skip download if file already exists."
"Do not download, if file already exists. Possible option false = do not skip, "
"'exact' = if filename already exists, 'extension_ignore' = skip even if a file with a "
"different file extension exists."
)
album_cover_save: str = "Safe cover to album folder." album_cover_save: str = "Safe cover to album folder."
lyrics_embed: str = "Embed lyrics in audio file, if lyrics are available." lyrics_embed: str = "Embed lyrics in audio file, if lyrics are available."
lyrics_file: str = "Save lyrics to separate *.lrc file, if lyrics are available." lyrics_file: str = "Save lyrics to separate *.lrc file, if lyrics are available."
@@ -82,6 +81,9 @@ class HelpSettings:
metadata_cover_embed: str = "Embed album cover into file." metadata_cover_embed: str = "Embed album cover into file."
cover_album_file: str = "Save cover to 'cover.jpg', if an album is downloaded." cover_album_file: str = "Save cover to 'cover.jpg', if an album is downloaded."
extract_flac: str = "Extract FLAC audio tracks from MP4 containers and save them as `*.flac` (uses FFmpeg)." extract_flac: str = "Extract FLAC audio tracks from MP4 containers and save them as `*.flac` (uses FFmpeg)."
downloads_simultaneous_per_track_max: str = "Maximum number of simultaneous chunk downloads per track."
download_delay_sec_min: float = "Lower boundary for the calculation of the download delay in seconds."
download_delay_sec_max: float = "Upper boundary for the calculation of the download delay in seconds."
@dataclass_json @dataclass_json
+13
View File
@@ -0,0 +1,13 @@
import pathlib
from dataclasses import dataclass
from requests import HTTPError
@dataclass
class DownloadSegmentResult:
result: bool
url: str
path_segment: pathlib.Path
id_segment: int
error: HTTPError | None = None
+22 -30
View File
@@ -153,6 +153,24 @@ class Ui_DialogSettings:
self.lv_flags.addLayout(self.lh_flags_4) self.lv_flags.addLayout(self.lh_flags_4)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.lv_flag_skip_existing = QVBoxLayout()
self.lv_flag_skip_existing.setObjectName("lv_flag_skip_existing")
self.cb_skip_existing = QCheckBox(self.gb_flags)
self.cb_skip_existing.setObjectName("cb_skip_existing")
self.lv_flag_skip_existing.addWidget(self.cb_skip_existing)
self.horizontalLayout.addLayout(self.lv_flag_skip_existing)
self.verticalLayout_3 = QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3")
self.horizontalLayout.addLayout(self.verticalLayout_3)
self.lv_flags.addLayout(self.horizontalLayout)
self.lv_main.addWidget(self.gb_flags) self.lv_main.addWidget(self.gb_flags)
self.gb_choices = QGroupBox(DialogSettings) self.gb_choices = QGroupBox(DialogSettings)
@@ -164,38 +182,13 @@ class Ui_DialogSettings:
self.gb_choices.setSizePolicy(sizePolicy3) self.gb_choices.setSizePolicy(sizePolicy3)
self.lv_choices = QVBoxLayout(self.gb_choices) self.lv_choices = QVBoxLayout(self.gb_choices)
self.lv_choices.setObjectName("lv_choices") self.lv_choices.setObjectName("lv_choices")
self.lh_choices_skip_existing = QHBoxLayout()
self.lh_choices_skip_existing.setObjectName("lh_choices_skip_existing")
self.l_icon_skip_existing = QLabel(self.gb_choices)
self.l_icon_skip_existing.setObjectName("l_icon_skip_existing")
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
sizePolicy4.setHorizontalStretch(0)
sizePolicy4.setVerticalStretch(0)
sizePolicy4.setHeightForWidth(self.l_icon_skip_existing.sizePolicy().hasHeightForWidth())
self.l_icon_skip_existing.setSizePolicy(sizePolicy4)
self.lh_choices_skip_existing.addWidget(self.l_icon_skip_existing)
self.l_skip_existing = QLabel(self.gb_choices)
self.l_skip_existing.setObjectName("l_skip_existing")
sizePolicy4.setHeightForWidth(self.l_skip_existing.sizePolicy().hasHeightForWidth())
self.l_skip_existing.setSizePolicy(sizePolicy4)
self.lh_choices_skip_existing.addWidget(self.l_skip_existing)
self.c_skip_existing = QComboBox(self.gb_choices)
self.c_skip_existing.setObjectName("c_skip_existing")
self.lh_choices_skip_existing.addWidget(self.c_skip_existing)
self.lh_choices_skip_existing.setStretch(2, 50)
self.lv_choices.addLayout(self.lh_choices_skip_existing)
self.lh_choices_quality_audio = QHBoxLayout() self.lh_choices_quality_audio = QHBoxLayout()
self.lh_choices_quality_audio.setObjectName("lh_choices_quality_audio") self.lh_choices_quality_audio.setObjectName("lh_choices_quality_audio")
self.l_icon_quality_audio = QLabel(self.gb_choices) self.l_icon_quality_audio = QLabel(self.gb_choices)
self.l_icon_quality_audio.setObjectName("l_icon_quality_audio") self.l_icon_quality_audio.setObjectName("l_icon_quality_audio")
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
sizePolicy4.setHorizontalStretch(0)
sizePolicy4.setVerticalStretch(0)
sizePolicy4.setHeightForWidth(self.l_icon_quality_audio.sizePolicy().hasHeightForWidth()) sizePolicy4.setHeightForWidth(self.l_icon_quality_audio.sizePolicy().hasHeightForWidth())
self.l_icon_quality_audio.setSizePolicy(sizePolicy4) self.l_icon_quality_audio.setSizePolicy(sizePolicy4)
@@ -532,9 +525,8 @@ class Ui_DialogSettings:
self.cb_extract_flac.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) self.cb_extract_flac.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_metadata_cover_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) self.cb_metadata_cover_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_cover_album_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) self.cb_cover_album_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_skip_existing.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.gb_choices.setTitle(QCoreApplication.translate("DialogSettings", "Choices", None)) self.gb_choices.setTitle(QCoreApplication.translate("DialogSettings", "Choices", None))
self.l_icon_skip_existing.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_skip_existing.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_icon_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
+18 -33
View File
@@ -210,6 +210,24 @@
</item> </item>
</layout> </layout>
</item> </item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="lv_flag_skip_existing">
<item>
<widget class="QCheckBox" name="cb_skip_existing">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_3"/>
</item>
</layout>
</item>
</layout> </layout>
</widget> </widget>
</item> </item>
@@ -225,39 +243,6 @@
<string>Choices</string> <string>Choices</string>
</property> </property>
<layout class="QVBoxLayout" name="lv_choices"> <layout class="QVBoxLayout" name="lv_choices">
<item>
<layout class="QHBoxLayout" name="lh_choices_skip_existing" stretch="0,0,50">
<item>
<widget class="QLabel" name="l_icon_skip_existing">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_skip_existing">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="c_skip_existing"/>
</item>
</layout>
</item>
<item> <item>
<layout class="QHBoxLayout" name="lh_choices_quality_audio" stretch="0,0,50"> <layout class="QHBoxLayout" name="lh_choices_quality_audio" stretch="0,0,50">
<item> <item>