From a072821c3d1ee82e8580494906881338f30d8691 Mon Sep 17 00:00:00 2001 From: Dreu LaVelle Date: Sat, 10 Aug 2024 22:22:23 -0500 Subject: [PATCH] feat: release 0.9.3 --- .github/workflows/docker-build-dev.yml | 2 +- .gitignore | 1 + poetry.lock | 693 +++++++++++++++------- pyproject.toml | 3 +- src/controllers/default.py | 28 +- src/controllers/items.py | 187 +++--- src/controllers/settings.py | 28 +- src/controllers/ws.py | 53 ++ src/main.py | 26 +- src/program/content/trakt.py | 15 +- src/program/db/db_functions.py | 90 ++- src/program/downloaders/__init__.py | 13 +- src/program/downloaders/realdebrid.py | 12 +- src/program/downloaders/shared.py | 144 +++++ src/program/downloaders/torbox.py | 14 +- src/program/indexers/trakt.py | 26 +- src/program/libraries/symlink.py | 29 +- src/program/media/item.py | 117 ++-- src/program/media/stream.py | 23 +- src/program/media/subtitle.py | 26 + src/program/post_processing/__init__.py | 24 + src/program/post_processing/subliminal.py | 118 ++++ src/program/program.py | 108 ++-- src/program/scrapers/__init__.py | 33 +- src/program/settings/models.py | 12 + src/program/state_transition.py | 49 +- src/program/symlink.py | 47 +- src/utils/cli.py | 35 ++ src/utils/logger.py | 13 +- 29 files changed, 1400 insertions(+), 569 deletions(-) create mode 100644 src/controllers/ws.py create mode 100644 src/program/downloaders/shared.py create mode 100644 src/program/media/subtitle.py create mode 100644 src/program/post_processing/__init__.py create mode 100644 src/program/post_processing/subliminal.py create mode 100644 src/utils/cli.py diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index 6631e800..e4568475 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -3,7 +3,7 @@ name: Docker Build and Push Dev on: push: branches: - - main + - dev jobs: build-and-push-dev: diff --git a/.gitignore b/.gitignore index 80e556ba..a8d4f80a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ data/ logs/ settings.json +ignore.txt .vscode .git makefile diff --git a/poetry.lock b/poetry.lock index 3b84b790..0786200f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -116,6 +116,38 @@ types-python-dateutil = ">=2.8.10" doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] +[[package]] +name = "babelfish" +version = "0.6.1" +description = "A module to work with countries and languages" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "babelfish-0.6.1-py3-none-any.whl", hash = "sha256:512f1501d4c8f7d38f0921f48660be7542de1a7b24abb6a6a65324a670150293"}, + {file = "babelfish-0.6.1.tar.gz", hash = "sha256:decb67a4660888d48480ab6998309837174158d0f1aa63bebb1c2e11aab97aab"}, +] + +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "cachetools" version = "5.4.0" @@ -138,6 +170,17 @@ files = [ {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + [[package]] name = "charset-normalizer" version = "3.3.2" @@ -251,6 +294,25 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click-option-group" +version = "0.5.6" +description = "Option groups missing in Click" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "click-option-group-0.5.6.tar.gz", hash = "sha256:97d06703873518cc5038509443742b25069a3c7562d1ea72ff08bfadde1ce777"}, + {file = "click_option_group-0.5.6-py3-none-any.whl", hash = "sha256:38a26d963ee3ad93332ddf782f9259c5bdfe405e73408d943ef5e7d0c3767ec7"}, +] + +[package.dependencies] +Click = ">=7.0,<9" + +[package.extras] +docs = ["Pallets-Sphinx-Themes", "m2r2", "sphinx"] +tests = ["pytest"] +tests-cov = ["coverage", "coveralls", "pytest", "pytest-cov"] + [[package]] name = "codecov" version = "2.1.13" @@ -279,68 +341,99 @@ files = [ [[package]] name = "coverage" -version = "7.6.0" +version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, - {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, - {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, - {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, - {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, - {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, - {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, - {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, - {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, - {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, - {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, - {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, - {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, - {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, - {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, - {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, - {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, - {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, - {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, - {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, - {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, - {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, ] [package.extras] toml = ["tomli"] +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + [[package]] name = "deprecated" version = "1.2.14" @@ -373,6 +466,40 @@ files = [ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] +[[package]] +name = "dogpile-cache" +version = "1.3.3" +description = "A caching front-end based on the Dogpile lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "dogpile.cache-1.3.3-py3-none-any.whl", hash = "sha256:5e211c4902ebdf88c678d268e22454b41e68071632daa9402d8ee24e825ed8ca"}, + {file = "dogpile.cache-1.3.3.tar.gz", hash = "sha256:f84b8ed0b0fb297d151055447fa8dcaf7bae566d4dbdefecdcc1f37662ab588b"}, +] + +[package.dependencies] +decorator = ">=4.0.0" +stevedore = ">=3.0.0" + +[package.extras] +pifpaf = ["pifpaf (>=2.5.0)", "setuptools"] + +[[package]] +name = "enzyme" +version = "0.5.2" +description = "Video metadata parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "enzyme-0.5.2-py3-none-any.whl", hash = "sha256:5a85306c136368d78f299bb74bf0c5f5d37e2689adc5caec5aba5ee2f029296b"}, + {file = "enzyme-0.5.2.tar.gz", hash = "sha256:7cf779148d9e66eb2838603eace140c53c3cefc8b8fe5d4d5a03a5fb5d57b3c1"}, +] + +[package.extras] +dev = ["doc8", "mypy", "ruff", "tox", "typos", "validate-pyproject"] +docs = ["myst-parser", "sphinx", "sphinx-rtd-theme"] +test = ["PyYAML", "importlib-metadata (>=4.6)", "mypy", "pytest (>=6.0)", "requests"] + [[package]] name = "fastapi" version = "0.110.3" @@ -463,6 +590,26 @@ files = [ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil"] +[[package]] +name = "guessit" +version = "3.8.0" +description = "GuessIt - a library for guessing information from video filenames." +optional = false +python-versions = "*" +files = [ + {file = "guessit-3.8.0-py3-none-any.whl", hash = "sha256:eb5747b1d0fbca926562c1e5894dbc3f6507c35e8c0bd9e38148401cd9579d83"}, + {file = "guessit-3.8.0.tar.gz", hash = "sha256:6619fcbbf9a0510ec8c2c33744c4251cad0507b1d573d05c875de17edc5edbed"}, +] + +[package.dependencies] +babelfish = ">=0.6.0" +python-dateutil = "*" +rebulk = ">=3.2.0" + +[package.extras] +dev = ["mkdocs", "mkdocs-material", "pyinstaller", "python-semantic-release", "tox", "twine", "wheel"] +test = ["PyYAML", "pylint", "pytest", "pytest-benchmark", "pytest-cov", "pytest-mock"] + [[package]] name = "h11" version = "0.14.0" @@ -1164,6 +1311,33 @@ files = [ arrow = ">=1.3.0,<2.0.0" regex = ">=2023.12.25,<2024.0.0" +[[package]] +name = "pbr" +version = "6.0.0" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-6.0.0-py2.py3-none-any.whl", hash = "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda"}, + {file = "pbr-6.0.0.tar.gz", hash = "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + [[package]] name = "plexapi" version = "4.15.15" @@ -1487,13 +1661,13 @@ dev = ["importlib-metadata", "tox"] [[package]] name = "pyright" -version = "1.1.373" +version = "1.1.374" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.373-py3-none-any.whl", hash = "sha256:b805413227f2c209f27b14b55da27fe5e9fb84129c9f1eb27708a5d12f6f000e"}, - {file = "pyright-1.1.373.tar.gz", hash = "sha256:f41bcfc8b9d1802b09921a394d6ae1ce19694957b628bc657629688daf8a83ff"}, + {file = "pyright-1.1.374-py3-none-any.whl", hash = "sha256:55752bcf7a3646d293cd76710a983b71e16f6128aab2d42468e6eb7e46c0a70d"}, + {file = "pyright-1.1.374.tar.gz", hash = "sha256:d01b2daf864ba5e0362e56b844984865970d7204158e61eb685e2dab7804cb82"}, ] [package.dependencies] @@ -1503,6 +1677,17 @@ nodeenv = ">=1.6.0" all = ["twine (>=3.4.1)"] dev = ["twine (>=3.4.1)"] +[[package]] +name = "pysubs2" +version = "1.7.3" +description = "A library for editing subtitle files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pysubs2-1.7.3-py3-none-any.whl", hash = "sha256:de438c868d2c656781c4a78f220ec3a6fd6d52be49266c81fe912d2527002d44"}, + {file = "pysubs2-1.7.3.tar.gz", hash = "sha256:b0130f373390736754531be4e68a0fa521e825fa15cc8ff506e4f8ca2c17459a"}, +] + [[package]] name = "pytest" version = "8.3.2" @@ -1642,109 +1827,136 @@ regex = ">=2023.12.25,<2024.0.0" [[package]] name = "rapidfuzz" -version = "3.9.4" +version = "3.9.5" description = "rapid fuzzy string matching" optional = false python-versions = ">=3.8" files = [ - {file = "rapidfuzz-3.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c9b9793c19bdf38656c8eaefbcf4549d798572dadd70581379e666035c9df781"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:015b5080b999404fe06ec2cb4f40b0be62f0710c926ab41e82dfbc28e80675b4"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc5ceca9c1e1663f3e6c23fb89a311f69b7615a40ddd7645e3435bf3082688a"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1424e238bc3f20e1759db1e0afb48a988a9ece183724bef91ea2a291c0b92a95"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed01378f605aa1f449bee82cd9c83772883120d6483e90aa6c5a4ce95dc5c3aa"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb26d412271e5a76cdee1c2d6bf9881310665d3fe43b882d0ed24edfcb891a84"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f37e9e1f17be193c41a31c864ad4cd3ebd2b40780db11cd5c04abf2bcf4201b"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d070ec5cf96b927c4dc5133c598c7ff6db3b833b363b2919b13417f1002560bc"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:10e61bb7bc807968cef09a0e32ce253711a2d450a4dce7841d21d45330ffdb24"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:31a2fc60bb2c7face4140010a7aeeafed18b4f9cdfa495cc644a68a8c60d1ff7"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fbebf1791a71a2e89f5c12b78abddc018354d5859e305ec3372fdae14f80a826"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:aee9fc9e3bb488d040afc590c0a7904597bf4ccd50d1491c3f4a5e7e67e6cd2c"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-win32.whl", hash = "sha256:005a02688a51c7d2451a2d41c79d737aa326ff54167211b78a383fc2aace2c2c"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:3a2e75e41ee3274754d3b2163cc6c82cd95b892a85ab031f57112e09da36455f"}, - {file = "rapidfuzz-3.9.4-cp310-cp310-win_arm64.whl", hash = "sha256:2c99d355f37f2b289e978e761f2f8efeedc2b14f4751d9ff7ee344a9a5ca98d9"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:07141aa6099e39d48637ce72a25b893fc1e433c50b3e837c75d8edf99e0c63e1"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db1664eaff5d7d0f2542dd9c25d272478deaf2c8412e4ad93770e2e2d828e175"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc01a223f6605737bec3202e94dcb1a449b6c76d46082cfc4aa980f2a60fd40e"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1869c42e73e2a8910b479be204fa736418741b63ea2325f9cc583c30f2ded41a"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62ea7007941fb2795fff305ac858f3521ec694c829d5126e8f52a3e92ae75526"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:698e992436bf7f0afc750690c301215a36ff952a6dcd62882ec13b9a1ebf7a39"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b76f611935f15a209d3730c360c56b6df8911a9e81e6a38022efbfb96e433bab"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129627d730db2e11f76169344a032f4e3883d34f20829419916df31d6d1338b1"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:90a82143c14e9a14b723a118c9ef8d1bbc0c5a16b1ac622a1e6c916caff44dd8"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ded58612fe3b0e0d06e935eaeaf5a9fd27da8ba9ed3e2596307f40351923bf72"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f16f5d1c4f02fab18366f2d703391fcdbd87c944ea10736ca1dc3d70d8bd2d8b"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:26aa7eece23e0df55fb75fbc2a8fb678322e07c77d1fd0e9540496e6e2b5f03e"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-win32.whl", hash = "sha256:f187a9c3b940ce1ee324710626daf72c05599946bd6748abe9e289f1daa9a077"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8e9130fe5d7c9182990b366ad78fd632f744097e753e08ace573877d67c32f8"}, - {file = "rapidfuzz-3.9.4-cp311-cp311-win_arm64.whl", hash = "sha256:40419e98b10cd6a00ce26e4837a67362f658fc3cd7a71bd8bd25c99f7ee8fea5"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b5d5072b548db1b313a07d62d88fe0b037bd2783c16607c647e01b070f6cf9e5"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf5bcf22e1f0fd273354462631d443ef78d677f7d2fc292de2aec72ae1473e66"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c8fc973adde8ed52810f590410e03fb6f0b541bbaeb04c38d77e63442b2df4c"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2464bb120f135293e9a712e342c43695d3d83168907df05f8c4ead1612310c7"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d9d58689aca22057cf1a5851677b8a3ccc9b535ca008c7ed06dc6e1899f7844"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:167e745f98baa0f3034c13583e6302fb69249a01239f1483d68c27abb841e0a1"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db0bf0663b4b6da1507869722420ea9356b6195aa907228d6201303e69837af9"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd6ac61b74fdb9e23f04d5f068e6cf554f47e77228ca28aa2347a6ca8903972f"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:60ff67c690acecf381759c16cb06c878328fe2361ddf77b25d0e434ea48a29da"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cb934363380c60f3a57d14af94325125cd8cded9822611a9f78220444034e36e"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fe833493fb5cc5682c823ea3e2f7066b07612ee8f61ecdf03e1268f262106cdd"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2797fb847d89e04040d281cb1902cbeffbc4b5131a5c53fc0db490fd76b2a547"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-win32.whl", hash = "sha256:52e3d89377744dae68ed7c84ad0ddd3f5e891c82d48d26423b9e066fc835cc7c"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:c76da20481c906e08400ee9be230f9e611d5931a33707d9df40337c2655c84b5"}, - {file = "rapidfuzz-3.9.4-cp312-cp312-win_arm64.whl", hash = "sha256:f2d2846f3980445864c7e8b8818a29707fcaff2f0261159ef6b7bd27ba139296"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:355fc4a268ffa07bab88d9adee173783ec8d20136059e028d2a9135c623c44e6"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4d81a78f90269190b568a8353d4ea86015289c36d7e525cd4d43176c88eff429"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e618625ffc4660b26dc8e56225f8b966d5842fa190e70c60db6cd393e25b86e"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b712336ad6f2bacdbc9f1452556e8942269ef71f60a9e6883ef1726b52d9228a"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc1ee19fdad05770c897e793836c002344524301501d71ef2e832847425707"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1950f8597890c0c707cb7e0416c62a1cf03dcdb0384bc0b2dbda7e05efe738ec"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a6c35f272ec9c430568dc8c1c30cb873f6bc96be2c79795e0bce6db4e0e101d"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1df0f9e9239132a231c86ae4f545ec2b55409fa44470692fcfb36b1bd00157ad"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:d2c51955329bfccf99ae26f63d5928bf5be9fcfcd9f458f6847fd4b7e2b8986c"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:3c522f462d9fc504f2ea8d82e44aa580e60566acc754422c829ad75c752fbf8d"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:d8a52fc50ded60d81117d7647f262c529659fb21d23e14ebfd0b35efa4f1b83d"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:04dbdfb0f0bfd3f99cf1e9e24fadc6ded2736d7933f32f1151b0f2abb38f9a25"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-win32.whl", hash = "sha256:4968c8bd1df84b42f382549e6226710ad3476f976389839168db3e68fd373298"}, - {file = "rapidfuzz-3.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:3fe4545f89f8d6c27b6bbbabfe40839624873c08bd6700f63ac36970a179f8f5"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9f256c8fb8f3125574c8c0c919ab0a1f75d7cba4d053dda2e762dcc36357969d"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5fdc09cf6e9d8eac3ce48a4615b3a3ee332ea84ac9657dbbefef913b13e632f"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d395d46b80063d3b5d13c0af43d2c2cedf3ab48c6a0c2aeec715aa5455b0c632"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fa714fb96ce9e70c37e64c83b62fe8307030081a0bfae74a76fac7ba0f91715"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc1a0f29f9119be7a8d3c720f1d2068317ae532e39e4f7f948607c3a6de8396"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6022674aa1747d6300f699cd7c54d7dae89bfe1f84556de699c4ac5df0838082"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcb72e5f9762fd469701a7e12e94b924af9004954f8c739f925cb19c00862e38"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ad04ae301129f0eb5b350a333accd375ce155a0c1cec85ab0ec01f770214e2e4"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f46a22506f17c0433e349f2d1dc11907c393d9b3601b91d4e334fa9a439a6a4d"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:01b42a8728c36011718da409aa86b84984396bf0ca3bfb6e62624f2014f6022c"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:e590d5d5443cf56f83a51d3c4867bd1f6be8ef8cfcc44279522bcef3845b2a51"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4c72078b5fdce34ba5753f9299ae304e282420e6455e043ad08e4488ca13a2b0"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-win32.whl", hash = "sha256:f75639277304e9b75e6a7b3c07042d2264e16740a11e449645689ed28e9c2124"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:e81e27e8c32a1e1278a4bb1ce31401bfaa8c2cc697a053b985a6f8d013df83ec"}, - {file = "rapidfuzz-3.9.4-cp39-cp39-win_arm64.whl", hash = "sha256:15bc397ee9a3ed1210b629b9f5f1da809244adc51ce620c504138c6e7095b7bd"}, - {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:20488ade4e1ddba3cfad04f400da7a9c1b91eff5b7bd3d1c50b385d78b587f4f"}, - {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:e61b03509b1a6eb31bc5582694f6df837d340535da7eba7bedb8ae42a2fcd0b9"}, - {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:098d231d4e51644d421a641f4a5f2f151f856f53c252b03516e01389b2bfef99"}, - {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17ab8b7d10fde8dd763ad428aa961c0f30a1b44426e675186af8903b5d134fb0"}, - {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e272df61bee0a056a3daf99f9b1bd82cf73ace7d668894788139c868fdf37d6f"}, - {file = "rapidfuzz-3.9.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d6481e099ff8c4edda85b8b9b5174c200540fd23c8f38120016c765a86fa01f5"}, - {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ad61676e9bdae677d577fe80ec1c2cea1d150c86be647e652551dcfe505b1113"}, - {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:af65020c0dd48d0d8ae405e7e69b9d8ae306eb9b6249ca8bf511a13f465fad85"}, - {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d38b4e026fcd580e0bda6c0ae941e0e9a52c6bc66cdce0b8b0da61e1959f5f8"}, - {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f74ed072c2b9dc6743fb19994319d443a4330b0e64aeba0aa9105406c7c5b9c2"}, - {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aee5f6b8321f90615c184bd8a4c676e9becda69b8e4e451a90923db719d6857c"}, - {file = "rapidfuzz-3.9.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3a555e3c841d6efa350f862204bb0a3fea0c006b8acc9b152b374fa36518a1c6"}, - {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0772150d37bf018110351c01d032bf9ab25127b966a29830faa8ad69b7e2f651"}, - {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:addcdd3c3deef1bd54075bd7aba0a6ea9f1d01764a08620074b7a7b1e5447cb9"}, - {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fe86b82b776554add8f900b6af202b74eb5efe8f25acdb8680a5c977608727f"}, - {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0fc91ac59f4414d8542454dfd6287a154b8e6f1256718c898f695bdbb993467"}, - {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a944e546a296a5fdcaabb537b01459f1b14d66f74e584cb2a91448bffadc3c1"}, - {file = "rapidfuzz-3.9.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4fb96ba96d58c668a17a06b5b5e8340fedc26188e87b0d229d38104556f30cd8"}, - {file = "rapidfuzz-3.9.4.tar.gz", hash = "sha256:366bf8947b84e37f2f4cf31aaf5f37c39f620d8c0eddb8b633e6ba0129ca4a0a"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7659058863d84a2c36c5a76c28bc8713d33eab03e677e67260d9e1cca43fc3bb"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:802a018776bd3cb7c5d23ba38ebbb1663a9f742be1d58e73b62d8c7cace6e607"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da71e8fdb0d1a21f4b58b2c84bcbc2b89a472c073c5f7bdb9339f4cb3122c0e3"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9433cb12731167b358fbcff9828d2294429986a03222031f6d14308eb643c77"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e33e1d185206730b916b3e7d9bce1941c65b2a1488cdd0457ae21be385a7912"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:758719e9613c47a274768f1926460955223fe0a03e7eda264f2b78b1b97a4743"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981cc6240d01d4480795d758ea2ee748257771f68127d630045e58fe1b5545a"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b6cdca86120c3f9aa069f8d4e1c5422e92f833d705d719a2ba7082412f4c933b"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ffa533acb1a9dcb6e26c4467fdc1347995fb168ec9f794b97545f6b72dee733c"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:13eeaeb0d5fe00fa99336f73fb5ab65c46109c7121cf87659b9601908b8b6178"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d7b1922b1403ccb3583218e8cd931b08e04c5442ca03dbaf6ea4fcf574ee2b24"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b0189f691cea4dc9fe074ea6b97da30a91d0882fa69724b4b34b51d2c1983473"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-win32.whl", hash = "sha256:72e466e5de12a327a09ed6d0116f024759b5146b335645c32241da84134a7f34"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:345011cfcafaa3674c46673baad67d2394eb58285530d8333e65c3c9a143b4f4"}, + {file = "rapidfuzz-3.9.5-cp310-cp310-win_arm64.whl", hash = "sha256:5dc19c8222475e4f7f528b94d2fa28e7979355c5cf7c6e73902d2abb2be96522"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c741972d64031535cfd76d89cf47259e590e822353be57ec2f5d56758c98296"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7452d079800cf70a7314f73044f03cbcbd90a651d9dec39443d2a8a2b63ab53"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06f163a0341bad162e972590b73e17f9cea2ed8ee27b193875ccbc3dd6eca2f"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:529e2cf441746bd492f6c36a38bf9fa6a418df95b9c003f8e92a55d8a979bd9c"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9811a741aa1350ad36689d675ded8b34e423e68b396bd30bff751a9c582f586e"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e36c4640a789b8c922b69a548968939d1c0433fa7aac83cb08e1334d4e5d7de"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53fb2f32f14c921d2f673c5b7cd58d4cc626c574a28c0791f283880d8e57022c"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:031806eb035a6f09f4ff23b9d971d50b30b5e93aa3ee620c920bee1dc32827e7"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f6dbe1df0b9334e3cf07445d810c81734ae23d137b5efc69e1d676ff55691351"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:24345826b50aafcea26e2e4be5c103d96fe9d7fc549ac9190641300290958f3b"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bfd3b66ee1f0ebb40c672a7a7e5bda00fb763fa9bca082058084175151f8e685"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6f1df5b0e602e94199cccb5e241bbc2319644003e34f077741ebf48aea7ed1a"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-win32.whl", hash = "sha256:f080d6709f51a8335e73826b96af9b4e3657631eca6c69e1ac501868dcc84b7f"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:bf9ed6988da6a2c1f8df367cb5d6be26a3d8543646c8eba79741ac9e764fbc59"}, + {file = "rapidfuzz-3.9.5-cp311-cp311-win_arm64.whl", hash = "sha256:599714790dfac0a23a473134e6677d0a103690a4e21ba189cfc826e322cdc8d5"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9729852038fb2de096e249899f8a9bee90fb1f92e10b6ccc539d5bb798c703bc"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9dc39435476fb3b3b3c24ab2c08c726056b2b487aa7ee450aee698b808c808ac"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6ceea632b0eb97dac54411c29feb190054e91fd0571f585b56e4a9159c55ab0"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cadd66e6ef9901909dc1b11db91048f1bf4613ba7d773386f922e28b1e1df4da"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63e34fb3586431589a5e1cd7fc61c6f057576c6c6804c1c673bac3de0516dee7"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:181073256faec68e6b8ab3329a36cfa1360f7906aa70d9aee4a39cb70889f73f"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8419c18bbbd67058ca1312f35acda2e4e4592650f105cfd166569a2ebccd01f1"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:191d1057cca56641f7b919fe712cb7e48cd226342e097a78136127f8bde32caa"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fe5a11eefd0ae90d32d9ff706a894498b4efb4b0c263ad9d1e6401050863504d"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b024d9d69bb83e125adee4162991f2764f16acc3fb1ed0f0fc1ad5aeb7e394"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d5a34b8388ae99bdbd5a3646f45ac318f4c870105bdbe42a2f4c85e5b347761"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0e09abc0d397019bba61c8e6dfe2ec863d4dfb1762f51c9197ce0af5d5fd9adb"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-win32.whl", hash = "sha256:e3c4be3057472c79ba6f4eab35daa9f12908cb697c472d05fbbd47949a87aec6"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:0d9fdb74df87018dd4146f3d00df9fca2c27f060936a9e8d3015e7bfb9cb69e4"}, + {file = "rapidfuzz-3.9.5-cp312-cp312-win_arm64.whl", hash = "sha256:491d3d425b5fe3f61f3b9a70abfd498ce9139d94956db7a8551e537e017c0e57"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:518dec750a30f115ba1299ef2547cf468a69f310581a030c8a875257de747c5f"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:252dc3d1c3d613b8db1b59d13381937e420c99f8a351ffa0e78c2f54746e107f"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd17688b75b6fa983e8586cad30f36eb9736b860946cc8b633b9442c9481831"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8032492021b0aa55a623d6f6e739a5d4aaabc32af379c2a5656bf1e9e178bf1"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73362eb1c3d02f32e4c7f0d77eb284e9a13f278cff224f71e8f60e2aff5b6a5d"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a42d1f7b8988f50013e703ed27b5e216ef8a725b2f4ac53754ad0476020b26f4"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4f2e985172bb76c9179e11fb67d9c9ecbee4933740eca2977797094df02498d"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8e943c5cbd10e15369be1f371ef303cb413c1008f64d93bd13762ea06ca84d59"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:0d34b0e8e29f80cb2ac8afe8fb7b01a542b136ffbf7e2b9983d11bce49398f68"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:62b8f9f58e9dffaa86fef84db2705457a58e191a962124f2b815026ba79d9aba"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:ebf682bdb0f01b6b1f9a9ffe918aa3ac84fbdadb998ffbfcd5f9b12bd280170f"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3ed0c17e5b6fdd2ac3230bdefa908579971377c36aa4a2f132700fa8145040db"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-win32.whl", hash = "sha256:ac460d89b9759e37eef23fed05184179654882a241f6b2363df194f8940cc55f"}, + {file = "rapidfuzz-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:cf9aceb4227fd09f9a20e505f78487b2089d6420ce232d288522ea0a78b986b9"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14587df847d0d50bd10cde0a198b5d64eedb7484c72b825f5c2ead6e6ff16eee"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fd94d952299ec73ea63a0fa4b699a2750785b6bb82aa56fd886d9023b86f90ab"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:733bf3d7876bf6d8167e6436f99d6ea16a218ec2c8eb9da6048f20b9cc8733e2"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb28f2b7173ed3678b4630b0c8b21503087d1cd082bae200dc2519ca38b26686"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a4c8a2c5ae4b133fec6b5db1af9a4126ffa6eca18a558fe5b6ab8e330d3d78"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5feb75e905281e5c669e21c98d594acc3b222a8694d9342f17df988766d83748"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d047b01637a31d9bf776b66438f574fd1db856ad14cf296c1f48bb6bef8a5aff"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d9e0a656274ac75ec24499a06c0bc5eee67bcd8276c6061da7c05d549f1b1a61"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:16c982dd3cdd33cf4aac91027a263a081d1a8050dc33a27470367a391a8d1576"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a0c878d0980508e90e973a9cbfb591acc370085f2301c6aacadbd8362d52a36"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1d9bcfec5efd55b6268328cccd12956d833582d8da6385231a5c6c6201a1156a"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8171fc6e4645e636161a9ef5b44b20605adbefe23cd990b68d72cae0b9c12509"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-win32.whl", hash = "sha256:35088e759b083398ab3c4154517476e116653b7403604677af9a894179f1042f"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:6d8cc7e6e5c6fbcacdfe3cf7a86b60dcaf216216d86e6879ff52d488e5b11e27"}, + {file = "rapidfuzz-3.9.5-cp39-cp39-win_arm64.whl", hash = "sha256:506547889f18db0acca787ffb9f287757cbfe9f0fadddd4e07c64ce0bd924e13"}, + {file = "rapidfuzz-3.9.5-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f4e0122603af2119579e9f94e172c6e460860fdcdb713164332c1951c13df999"}, + {file = "rapidfuzz-3.9.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:e46cd486289d1d8e3dab779c725f5dde77b286185d32e7b874bfc3d161e3a927"}, + {file = "rapidfuzz-3.9.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e2c0c8bbe4f4525009e3ad9b94a39cdff5d6378233e754d0b13c29cdfaa75fc"}, + {file = "rapidfuzz-3.9.5-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb47513a17c935f6ee606dcae0ea9d20a3fb0fe9ca597758472ea08be62dc54"}, + {file = "rapidfuzz-3.9.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:976ed1105a76935b6a4d2bbc7d577be1b97b43997bcec2f29a0ab48ff6f5d6b1"}, + {file = "rapidfuzz-3.9.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9cf2028edb9ccd21d1d5aaacef2fe3e14bee4343df1c2c0f7373ef6e81013bef"}, + {file = "rapidfuzz-3.9.5-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:926701c8e61319ee2e4888619143f58ddcc0e3e886668269b8e053f2d68c1e92"}, + {file = "rapidfuzz-3.9.5-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:99eaa8dd8a44664813e0bef014775993ef75a134a863bc54cd855a60622203fd"}, + {file = "rapidfuzz-3.9.5-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7508ef727ef4891141dd3ac7a39a2327384ece070521ac9c58f06c27d57c72d5"}, + {file = "rapidfuzz-3.9.5-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9f33d05db5bba1d076446c51347a6d93ff24d8f9d01b0b8b15ca8ec8b1ef382"}, + {file = "rapidfuzz-3.9.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7252666b85c931d51a59d5308bb6827a67434917ef510747d3ce7e88ec17e7f2"}, + {file = "rapidfuzz-3.9.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d26f7299e2872d18fb7df1bc043e53aa94fc5a4a2a6a9537ad8707579fcb1668"}, + {file = "rapidfuzz-3.9.5-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2b17ecc17322b659962234799e90054e420911b8ca510a7869c2f4419f9f3ecb"}, + {file = "rapidfuzz-3.9.5-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f3e037b9ec621dec0157d81566e7d47a91405e379335cf8f4ed3c20d61db91d8"}, + {file = "rapidfuzz-3.9.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c4d1ba2647c8d2a82313c4dde332de750c936b94f016308339e762c2e5e53d"}, + {file = "rapidfuzz-3.9.5-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:876e663b11d9067e1096ea76a2de87227c7b513aff2b60667b20417da74183e4"}, + {file = "rapidfuzz-3.9.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adee55488490375c1604b878fbc1eb1a51fe5e6f5bd05047df2f8c6505a48728"}, + {file = "rapidfuzz-3.9.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:abb1ac683671000bd4ec215a494aba687d75a198db72188408154a19ea313ff4"}, + {file = "rapidfuzz-3.9.5.tar.gz", hash = "sha256:257f2406a671371bafd99a2a2c57f991783446bc2176b93a83d1d833e35d36df"}, ] [package.extras] full = ["numpy"] +[[package]] +name = "rarfile" +version = "4.2" +description = "RAR archive reader for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "rarfile-4.2-py3-none-any.whl", hash = "sha256:8757e1e3757e32962e229cab2432efc1f15f210823cc96ccba0f6a39d17370c9"}, + {file = "rarfile-4.2.tar.gz", hash = "sha256:8e1c8e72d0845ad2b32a47ab11a719bc2e41165ec101fd4d3fe9e92aa3f469ef"}, +] + +[[package]] +name = "rebulk" +version = "3.2.0" +description = "Rebulk - Define simple search patterns in bulk to perform advanced matching on any string." +optional = false +python-versions = "*" +files = [ + {file = "rebulk-3.2.0-py3-none-any.whl", hash = "sha256:6bc31ae4b37200623c5827d2f539f9ec3e52b50431322dad8154642a39b0a53e"}, + {file = "rebulk-3.2.0.tar.gz", hash = "sha256:0d30bf80fca00fa9c697185ac475daac9bde5f646ce3338c9ff5d5dc1ebdfebc"}, +] + +[package.extras] +dev = ["pylint", "pytest", "tox"] +native = ["regex"] +test = ["pylint", "pytest"] + [[package]] name = "regex" version = "2023.12.25" @@ -1952,6 +2164,17 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, + {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, +] + [[package]] name = "sqla-wrapper" version = "6.0.0" @@ -1969,60 +2192,60 @@ sqlalchemy = ">=2.0,<3.0" [[package]] name = "sqlalchemy" -version = "2.0.31" +version = "2.0.32" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" files = [ - {file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f2a213c1b699d3f5768a7272de720387ae0122f1becf0901ed6eaa1abd1baf6c"}, - {file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9fea3d0884e82d1e33226935dac990b967bef21315cbcc894605db3441347443"}, - {file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ad7f221d8a69d32d197e5968d798217a4feebe30144986af71ada8c548e9fa"}, - {file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2bee229715b6366f86a95d497c347c22ddffa2c7c96143b59a2aa5cc9eebbc"}, - {file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cd5b94d4819c0c89280b7c6109c7b788a576084bf0a480ae17c227b0bc41e109"}, - {file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:750900a471d39a7eeba57580b11983030517a1f512c2cb287d5ad0fcf3aebd58"}, - {file = "SQLAlchemy-2.0.31-cp310-cp310-win32.whl", hash = "sha256:7bd112be780928c7f493c1a192cd8c5fc2a2a7b52b790bc5a84203fb4381c6be"}, - {file = "SQLAlchemy-2.0.31-cp310-cp310-win_amd64.whl", hash = "sha256:5a48ac4d359f058474fadc2115f78a5cdac9988d4f99eae44917f36aa1476327"}, - {file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f68470edd70c3ac3b6cd5c2a22a8daf18415203ca1b036aaeb9b0fb6f54e8298"}, - {file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e2c38c2a4c5c634fe6c3c58a789712719fa1bf9b9d6ff5ebfce9a9e5b89c1ca"}, - {file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd15026f77420eb2b324dcb93551ad9c5f22fab2c150c286ef1dc1160f110203"}, - {file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2196208432deebdfe3b22185d46b08f00ac9d7b01284e168c212919891289396"}, - {file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:352b2770097f41bff6029b280c0e03b217c2dcaddc40726f8f53ed58d8a85da4"}, - {file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56d51ae825d20d604583f82c9527d285e9e6d14f9a5516463d9705dab20c3740"}, - {file = "SQLAlchemy-2.0.31-cp311-cp311-win32.whl", hash = "sha256:6e2622844551945db81c26a02f27d94145b561f9d4b0c39ce7bfd2fda5776dac"}, - {file = "SQLAlchemy-2.0.31-cp311-cp311-win_amd64.whl", hash = "sha256:ccaf1b0c90435b6e430f5dd30a5aede4764942a695552eb3a4ab74ed63c5b8d3"}, - {file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3b74570d99126992d4b0f91fb87c586a574a5872651185de8297c6f90055ae42"}, - {file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f77c4f042ad493cb8595e2f503c7a4fe44cd7bd59c7582fd6d78d7e7b8ec52c"}, - {file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd1591329333daf94467e699e11015d9c944f44c94d2091f4ac493ced0119449"}, - {file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74afabeeff415e35525bf7a4ecdab015f00e06456166a2eba7590e49f8db940e"}, - {file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b9c01990d9015df2c6f818aa8f4297d42ee71c9502026bb074e713d496e26b67"}, - {file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66f63278db425838b3c2b1c596654b31939427016ba030e951b292e32b99553e"}, - {file = "SQLAlchemy-2.0.31-cp312-cp312-win32.whl", hash = "sha256:0b0f658414ee4e4b8cbcd4a9bb0fd743c5eeb81fc858ca517217a8013d282c96"}, - {file = "SQLAlchemy-2.0.31-cp312-cp312-win_amd64.whl", hash = "sha256:fa4b1af3e619b5b0b435e333f3967612db06351217c58bfb50cee5f003db2a5a"}, - {file = "SQLAlchemy-2.0.31-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f43e93057cf52a227eda401251c72b6fbe4756f35fa6bfebb5d73b86881e59b0"}, - {file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d337bf94052856d1b330d5fcad44582a30c532a2463776e1651bd3294ee7e58b"}, - {file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c06fb43a51ccdff3b4006aafee9fcf15f63f23c580675f7734245ceb6b6a9e05"}, - {file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:b6e22630e89f0e8c12332b2b4c282cb01cf4da0d26795b7eae16702a608e7ca1"}, - {file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:79a40771363c5e9f3a77f0e28b3302801db08040928146e6808b5b7a40749c88"}, - {file = "SQLAlchemy-2.0.31-cp37-cp37m-win32.whl", hash = "sha256:501ff052229cb79dd4c49c402f6cb03b5a40ae4771efc8bb2bfac9f6c3d3508f"}, - {file = "SQLAlchemy-2.0.31-cp37-cp37m-win_amd64.whl", hash = "sha256:597fec37c382a5442ffd471f66ce12d07d91b281fd474289356b1a0041bdf31d"}, - {file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dc6d69f8829712a4fd799d2ac8d79bdeff651c2301b081fd5d3fe697bd5b4ab9"}, - {file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:23b9fbb2f5dd9e630db70fbe47d963c7779e9c81830869bd7d137c2dc1ad05fb"}, - {file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21c97efcbb9f255d5c12a96ae14da873233597dfd00a3a0c4ce5b3e5e79704"}, - {file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a6a9837589c42b16693cf7bf836f5d42218f44d198f9343dd71d3164ceeeac"}, - {file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc251477eae03c20fae8db9c1c23ea2ebc47331bcd73927cdcaecd02af98d3c3"}, - {file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2fd17e3bb8058359fa61248c52c7b09a97cf3c820e54207a50af529876451808"}, - {file = "SQLAlchemy-2.0.31-cp38-cp38-win32.whl", hash = "sha256:c76c81c52e1e08f12f4b6a07af2b96b9b15ea67ccdd40ae17019f1c373faa227"}, - {file = "SQLAlchemy-2.0.31-cp38-cp38-win_amd64.whl", hash = "sha256:4b600e9a212ed59355813becbcf282cfda5c93678e15c25a0ef896b354423238"}, - {file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b6cf796d9fcc9b37011d3f9936189b3c8074a02a4ed0c0fbbc126772c31a6d4"}, - {file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:78fe11dbe37d92667c2c6e74379f75746dc947ee505555a0197cfba9a6d4f1a4"}, - {file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc47dc6185a83c8100b37acda27658fe4dbd33b7d5e7324111f6521008ab4fe"}, - {file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a41514c1a779e2aa9a19f67aaadeb5cbddf0b2b508843fcd7bafdf4c6864005"}, - {file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:afb6dde6c11ea4525318e279cd93c8734b795ac8bb5dda0eedd9ebaca7fa23f1"}, - {file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3f9faef422cfbb8fd53716cd14ba95e2ef655400235c3dfad1b5f467ba179c8c"}, - {file = "SQLAlchemy-2.0.31-cp39-cp39-win32.whl", hash = "sha256:fc6b14e8602f59c6ba893980bea96571dd0ed83d8ebb9c4479d9ed5425d562e9"}, - {file = "SQLAlchemy-2.0.31-cp39-cp39-win_amd64.whl", hash = "sha256:3cb8a66b167b033ec72c3812ffc8441d4e9f5f78f5e31e54dcd4c90a4ca5bebc"}, - {file = "SQLAlchemy-2.0.31-py3-none-any.whl", hash = "sha256:69f3e3c08867a8e4856e92d7afb618b95cdee18e0bc1647b77599722c9a28911"}, - {file = "SQLAlchemy-2.0.31.tar.gz", hash = "sha256:b607489dd4a54de56984a0c7656247504bd5523d9d0ba799aef59d4add009484"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c9045ecc2e4db59bfc97b20516dfdf8e41d910ac6fb667ebd3a79ea54084619"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1467940318e4a860afd546ef61fefb98a14d935cd6817ed07a228c7f7c62f389"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5954463675cb15db8d4b521f3566a017c8789222b8316b1e6934c811018ee08b"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167e7497035c303ae50651b351c28dc22a40bb98fbdb8468cdc971821b1ae533"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b27dfb676ac02529fb6e343b3a482303f16e6bc3a4d868b73935b8792edb52d0"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf2360a5e0f7bd75fa80431bf8ebcfb920c9f885e7956c7efde89031695cafb8"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-win32.whl", hash = "sha256:306fe44e754a91cd9d600a6b070c1f2fadbb4a1a257b8781ccf33c7067fd3e4d"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-win_amd64.whl", hash = "sha256:99db65e6f3ab42e06c318f15c98f59a436f1c78179e6a6f40f529c8cc7100b22"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21b053be28a8a414f2ddd401f1be8361e41032d2ef5884b2f31d31cb723e559f"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b178e875a7a25b5938b53b006598ee7645172fccafe1c291a706e93f48499ff5"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723a40ee2cc7ea653645bd4cf024326dea2076673fc9d3d33f20f6c81db83e1d"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:295ff8689544f7ee7e819529633d058bd458c1fd7f7e3eebd0f9268ebc56c2a0"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49496b68cd190a147118af585173ee624114dfb2e0297558c460ad7495f9dfe2"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:acd9b73c5c15f0ec5ce18128b1fe9157ddd0044abc373e6ecd5ba376a7e5d961"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-win32.whl", hash = "sha256:9365a3da32dabd3e69e06b972b1ffb0c89668994c7e8e75ce21d3e5e69ddef28"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-win_amd64.whl", hash = "sha256:8bd63d051f4f313b102a2af1cbc8b80f061bf78f3d5bd0843ff70b5859e27924"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6bab3db192a0c35e3c9d1560eb8332463e29e5507dbd822e29a0a3c48c0a8d92"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:19d98f4f58b13900d8dec4ed09dd09ef292208ee44cc9c2fe01c1f0a2fe440e9"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cd33c61513cb1b7371fd40cf221256456d26a56284e7d19d1f0b9f1eb7dd7e8"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d6ba0497c1d066dd004e0f02a92426ca2df20fac08728d03f67f6960271feec"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2b6be53e4fde0065524f1a0a7929b10e9280987b320716c1509478b712a7688c"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:916a798f62f410c0b80b63683c8061f5ebe237b0f4ad778739304253353bc1cb"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-win32.whl", hash = "sha256:31983018b74908ebc6c996a16ad3690301a23befb643093fcfe85efd292e384d"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-win_amd64.whl", hash = "sha256:4363ed245a6231f2e2957cccdda3c776265a75851f4753c60f3004b90e69bfeb"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b8afd5b26570bf41c35c0121801479958b4446751a3971fb9a480c1afd85558e"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c750987fc876813f27b60d619b987b057eb4896b81117f73bb8d9918c14f1cad"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ada0102afff4890f651ed91120c1120065663506b760da4e7823913ebd3258be"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:78c03d0f8a5ab4f3034c0e8482cfcc415a3ec6193491cfa1c643ed707d476f16"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:3bd1cae7519283ff525e64645ebd7a3e0283f3c038f461ecc1c7b040a0c932a1"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-win32.whl", hash = "sha256:01438ebcdc566d58c93af0171c74ec28efe6a29184b773e378a385e6215389da"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-win_amd64.whl", hash = "sha256:4979dc80fbbc9d2ef569e71e0896990bc94df2b9fdbd878290bd129b65ab579c"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c742be912f57586ac43af38b3848f7688863a403dfb220193a882ea60e1ec3a"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:62e23d0ac103bcf1c5555b6c88c114089587bc64d048fef5bbdb58dfd26f96da"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:251f0d1108aab8ea7b9aadbd07fb47fb8e3a5838dde34aa95a3349876b5a1f1d"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ef18a84e5116340e38eca3e7f9eeaaef62738891422e7c2a0b80feab165905f"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3eb6a97a1d39976f360b10ff208c73afb6a4de86dd2a6212ddf65c4a6a2347d5"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0c1c9b673d21477cec17ab10bc4decb1322843ba35b481585facd88203754fc5"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-win32.whl", hash = "sha256:c41a2b9ca80ee555decc605bd3c4520cc6fef9abde8fd66b1cf65126a6922d65"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-win_amd64.whl", hash = "sha256:8a37e4d265033c897892279e8adf505c8b6b4075f2b40d77afb31f7185cd6ecd"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:52fec964fba2ef46476312a03ec8c425956b05c20220a1a03703537824b5e8e1"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:328429aecaba2aee3d71e11f2477c14eec5990fb6d0e884107935f7fb6001632"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85a01b5599e790e76ac3fe3aa2f26e1feba56270023d6afd5550ed63c68552b3"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf04784797dcdf4c0aa952c8d234fa01974c4729db55c45732520ce12dd95b4"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4488120becf9b71b3ac718f4138269a6be99a42fe023ec457896ba4f80749525"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14e09e083a5796d513918a66f3d6aedbc131e39e80875afe81d98a03312889e6"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-win32.whl", hash = "sha256:0d322cc9c9b2154ba7e82f7bf25ecc7c36fbe2d82e2933b3642fc095a52cfc78"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-win_amd64.whl", hash = "sha256:7dd8583df2f98dea28b5cd53a1beac963f4f9d087888d75f22fcc93a07cf8d84"}, + {file = "SQLAlchemy-2.0.32-py3-none-any.whl", hash = "sha256:e567a8793a692451f706b363ccf3c45e056b67d90ead58c3bc9471af5d212202"}, + {file = "SQLAlchemy-2.0.32.tar.gz", hash = "sha256:c1b88cc8b02b6a5f0efb0345a03672d4c897dc7d92585176f88c67346f565ea8"}, ] [package.dependencies] @@ -2054,6 +2277,16 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "srt" +version = "3.5.3" +description = "A tiny library for parsing, modifying, and composing SRT files." +optional = false +python-versions = ">=2.7" +files = [ + {file = "srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0"}, +] + [[package]] name = "starlette" version = "0.37.2" @@ -2071,6 +2304,64 @@ anyio = ">=3.4.0,<5" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +[[package]] +name = "stevedore" +version = "5.2.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-5.2.0-py3-none-any.whl", hash = "sha256:1c15d95766ca0569cad14cb6272d4d31dae66b011a929d7c18219c176ea1b5c9"}, + {file = "stevedore-5.2.0.tar.gz", hash = "sha256:46b93ca40e1114cea93d738a6c1e365396981bb6bb78c27045b7587c9473544d"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "subliminal" +version = "2.2.1" +description = "Subtitles, faster than your thoughts" +optional = false +python-versions = ">=3.8" +files = [ + {file = "subliminal-2.2.1-py3-none-any.whl", hash = "sha256:421a71f2e3f604e5dffb551b2a51d14500c7615d7eaf16c23e713d7ad295504c"}, + {file = "subliminal-2.2.1.tar.gz", hash = "sha256:2ed6024a07bbb3c68fe3db76374244ad91adfca9d93fc24d3ddb9ef61825756e"}, +] + +[package.dependencies] +babelfish = ">=0.6.1" +beautifulsoup4 = ">=4.4.0" +chardet = ">=5.0" +click = ">=8.0" +click-option-group = ">=0.5.6" +"dogpile.cache" = ">=1.0" +enzyme = ">=0.5.0" +guessit = ">=3.0.0" +platformdirs = ">=3" +pysubs2 = ">=1.7" +rarfile = ">=2.7" +requests = ">=2.0" +srt = ">=3.5" +stevedore = ">=3.0" +tomli = ">=2" + +[package.extras] +dev = ["doc8", "mypy", "ruff", "tox", "typos", "validate-pyproject"] +docs = ["sphinx", "sphinx-rtd-theme", "sphinxcontrib-programoutput"] +test = ["importlib-metadata (>=4.6)", "lxml", "mypy", "pytest (>=6.0)", "pytest-cov", "pytest-flakes", "sympy", "vcrpy (>=1.6.1)"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + [[package]] name = "types-python-dateutil" version = "2.9.0.20240316" @@ -2497,4 +2788,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "7765d8ec6c8b13235257b128679eb65b08a8820b7207a2b9a441777393629a07" +content-hash = "756aa9675caaf95b6e9ac74cdbe1593c337df9557ad0395bb0dfcb6e77a18e0c" diff --git a/pyproject.toml b/pyproject.toml index 1f6a9992..0e075d3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ uvicorn = {extras = ["standard"], version = "^0.27.1"} apscheduler = "^3.10.4" regex = "^2023.12.25" coverage = "^7.5.4" -rank-torrent-name = "^0.2.13" +rank-torrent-name = "0.2.23" cachetools = "^5.3.3" loguru = "^0.7.2" rich = "^13.7.1" @@ -32,6 +32,7 @@ sqla-wrapper = "^6.0.0" alembic = "^1.13.2" psycopg2-binary = "^2.9.9" apprise = "^1.8.1" +subliminal = "^2.2.1" [tool.poetry.group.dev.dependencies] pyright = "^1.1.352" diff --git a/src/controllers/default.py b/src/controllers/default.py index 0ea8ccbd..e967e49f 100644 --- a/src/controllers/default.py +++ b/src/controllers/default.py @@ -134,30 +134,4 @@ async def get_stats(_: Request): payload["incomplete_retries"] = incomplete_retries payload["states"] = states - return {"success": True, "data": payload} - -@router.get("/scrape/{item_id:path}") -async def scrape_item(item_id: str, request: Request): - with db.Session() as session: - item = DB._get_item_from_db(session, MediaItem({"imdb_id":str(item_id)})) - if item is None: - raise HTTPException(status_code=404, detail="Item not found") - - scraper = request.app.program.services.get(Scraping) - if scraper is None: - raise HTTPException(status_code=404, detail="Scraping service not found") - - time_now = time.time() - scraped_results = scraper.scrape(item, log=False) - time_end = time.time() - duration = time_end - time_now - - results = {} - for hash, torrent in scraped_results.items(): - results[hash] = { - "title": torrent.data.parsed_title, - "raw_title": torrent.raw_title, - "rank": torrent.rank, - } - - return {"success": True, "total": len(results), "duration": round(duration, 3), "results": results} \ No newline at end of file + return {"success": True, "data": payload} \ No newline at end of file diff --git a/src/controllers/items.py b/src/controllers/items.py index 171d593d..4eb90a82 100644 --- a/src/controllers/items.py +++ b/src/controllers/items.py @@ -1,15 +1,16 @@ -from typing import List, Optional +from datetime import datetime +from typing import Optional import Levenshtein import program.db.db_functions as DB from fastapi import APIRouter, HTTPException, Request from program.db.db import db -from program.media.item import Episode, MediaItem, Season +from program.media.item import MediaItem from program.media.state import States -from program.symlink import Symlinker -from pydantic import BaseModel from sqlalchemy import func, select +from program.types import Event from utils.logger import logger +from sqlalchemy.orm import joinedload router = APIRouter( prefix="/items", @@ -17,10 +18,11 @@ responses={404: {"description": "Not found"}}, ) - -class IMDbIDs(BaseModel): - imdb_ids: Optional[List[str]] = None - +def handle_ids(ids: str) -> list[int]: + ids = [int(id) for id in ids.split(",")] if "," in ids else [int(ids)] + if not ids: + raise HTTPException(status_code=400, detail="No item ID provided") + return ids @router.get("/states") async def get_states(): @@ -29,7 +31,6 @@ async def get_states(): "states": [state for state in States], } - @router.get( "", summary="Retrieve Media Items", @@ -43,6 +44,7 @@ async def get_items( state: Optional[str] = None, sort: Optional[str] = "desc", search: Optional[str] = None, + extended: Optional[bool] = False, ): if page < 1: raise HTTPException(status_code=400, detail="Page number must be 1 or greater.") @@ -85,9 +87,10 @@ async def get_items( if type not in ["movie", "show", "season", "episode"]: raise HTTPException( status_code=400, - detail=f"Invalid type: {type}. Valid types are: ['movie', 'show', 'season', 'episode']", - ) - query = query.where(MediaItem.type.in_(types)) + detail=f"Invalid type: {type}. Valid types are: ['movie', 'show', 'season', 'episode']") + else: + types=[type] + query = query.where(MediaItem.type.in_(types)) if sort and not search: if sort.lower() == "asc": @@ -108,7 +111,7 @@ async def get_items( return { "success": True, - "items": [item.to_dict() for item in items], + "items": [item.to_extended_dict() if extended else item.to_dict() for item in items], "page": page, "limit": limit, "total_items": total_items, @@ -116,16 +119,11 @@ async def get_items( } -@router.get("/extended/{item_id}") -async def get_extended_item_info(_: Request, item_id: str): - with db.Session() as session: - item = session.execute(select(MediaItem).where(MediaItem.imdb_id == item_id)).unique().scalar_one_or_none() - if item is None: - raise HTTPException(status_code=404, detail="Item not found") - return {"success": True, "item": item.to_extended_dict()} - - -@router.post("/add") +@router.post( + "/add", + summary="Add Media Items", + description="Add media items with bases on imdb IDs", +) async def add_items( request: Request, imdb_ids: str = None ): @@ -146,56 +144,105 @@ async def add_items( raise HTTPException(status_code=400, detail="No valid IMDb ID(s) provided") for id in valid_ids: - item = MediaItem({"imdb_id": id, "requested_by": "riven"}) - request.app.program.add_to_queue(item) + item = MediaItem({"imdb_id": id, "requested_by": "riven", "requested_at": datetime.now()}) + request.app.program._push_event_queue(Event("Manual", item)) return {"success": True, "message": f"Added {len(valid_ids)} item(s) to the queue"} - -@router.delete("/remove") -async def remove_item( - _: Request, imdb_id: str +@router.post( + "/reset", + summary="Reset Media Items", + description="Reset media items with bases on item IDs", +) +async def reset_items( + request: Request, ids: str ): - if not imdb_id: - raise HTTPException(status_code=400, detail="No IMDb ID provided") - if DB._remove_item_from_db(imdb_id): - return {"success": True, "message": f"Removed item with imdb_id {imdb_id}"} - return {"success": False, "message": f"No item with imdb_id ({imdb_id}) found"} - - -@router.get("/imdb/{imdb_id}") -async def get_imdb_info( - _: Request, - imdb_id: str, - season: Optional[int] = None, - episode: Optional[int] = None, + ids = handle_ids(ids) + with db.Session() as session: + items = [] + for id in ids: + item = session.execute(select(MediaItem).where(MediaItem._id == id).options(joinedload("*"))).unique().scalar_one() + items.append(item) + for item in items: + if item.type == "show": + for season in item.seasons: + for episode in season.episodes: + episode.reset() + season.reset() + elif item.type == "season": + for episode in item.episodes: + episode.reset() + item.reset() + + session.commit() + return {"success": True, "message": f"Reset items with id {ids}"} + +@router.post( + "/retry", + summary="Retry Media Items", + description="Retry media items with bases on item IDs", +) +async def retry_items( + request: Request, ids: str ): - """ - Get the item with the given IMDb ID. - If the season and episode are provided, get the item with the given season and episode. - """ + ids = handle_ids(ids) with db.Session() as session: - if season is not None and episode is not None: - item = session.execute( - select(Episode).where( - (Episode.imdb_id == imdb_id) & - (Episode.season_number == season) & - (Episode.episode_number == episode) - ) - ).scalar_one_or_none() - elif season is not None: - item = session.execute( - select(Season).where( - (Season.imdb_id == imdb_id) & - (Season.season_number == season) - ) - ).scalar_one_or_none() - else: - item = session.execute( - select(MediaItem).where(MediaItem.imdb_id == imdb_id) - ).scalar_one_or_none() - - if item is None: - raise HTTPException(status_code=404, detail="Item not found") - - return {"success": True, "item": item.to_extended_dict()} + items = [] + for id in ids: + items.append(session.execute(select(MediaItem).where(MediaItem._id == id)).unique().scalar_one()) + for item in items: + request.app.program._remove_from_running_events(item) + request.app.program.add_to_queue(item) + + return {"success": True, "message": f"Retried items with id {ids}"} + +@router.delete( + "", + summary="Remove Media Items", + description="Remove media items with bases on item IDs",) +async def remove_item( + _: Request, ids: str +): + ids = handle_ids(ids) + for id in ids: + DB._remove_item_from_db(id) + return {"success": True, "message": f"Removed item with id {id}"} + +# These require downloaders to be refactored + +# @router.get("/cached") +# async def manual_scrape(request: Request, ids: str): +# scraper = request.app.program.services.get(Scraping) +# downloader = request.app.program.services.get(Downloader).service +# if downloader.__class__.__name__ not in ["RealDebridDownloader", "TorBoxDownloader"]: +# raise HTTPException(status_code=400, detail="Only Real-Debrid is supported for manual scraping currently") +# ids = [int(id) for id in ids.split(",")] if "," in ids else [int(ids)] +# if not ids: +# raise HTTPException(status_code=400, detail="No item ID provided") +# with db.Session() as session: +# items = [] +# return_dict = {} +# for id in ids: +# items.append(session.execute(select(MediaItem).where(MediaItem._id == id)).unique().scalar_one()) +# if any(item for item in items if item.type in ["Season", "Episode"]): +# raise HTTPException(status_code=400, detail="Only shows and movies can be manually scraped currently") +# for item in items: +# new_item = item.__class__({}) +# # new_item.parent = item.parent +# new_item.copy(item) +# new_item.copy_other_media_attr(item) +# scraped_results = scraper.scrape(new_item, log=False) +# cached_hashes = downloader.get_cached_hashes(new_item, scraped_results) +# for hash, stream in scraped_results.items(): +# return_dict[hash] = {"cached": hash in cached_hashes, "name": stream.raw_title} +# return {"success": True, "data": return_dict} + +# @router.post("/download") +# async def download(request: Request, id: str, hash: str): +# downloader = request.app.program.services.get(Downloader).service +# with db.Session() as session: +# item = session.execute(select(MediaItem).where(MediaItem._id == id)).unique().scalar_one() +# item.reset(True) +# downloader.download_cached(item, hash) +# request.app.program.add_to_queue(item) +# return {"success": True, "message": f"Downloading {item.title} with hash {hash}"} \ No newline at end of file diff --git a/src/controllers/settings.py b/src/controllers/settings.py index 64de995e..d698de75 100644 --- a/src/controllers/settings.py +++ b/src/controllers/settings.py @@ -1,5 +1,5 @@ from copy import copy -from typing import Any, List +from typing import Any, Dict, List from fastapi import APIRouter, HTTPException from program.settings.manager import settings_manager @@ -65,6 +65,32 @@ async def get_settings(paths: str): } +@router.post("/set/all") +async def set_all_settings(new_settings: Dict[str, Any]): + current_settings = settings_manager.settings.model_dump() + + def update_settings(current_obj, new_obj): + for key, value in new_obj.items(): + if isinstance(value, dict) and key in current_obj: + update_settings(current_obj[key], value) + else: + current_obj[key] = value + + update_settings(current_settings, new_settings) + + # Validate and save the updated settings + try: + updated_settings = settings_manager.settings.model_validate(current_settings) + settings_manager.load(settings_dict=updated_settings.model_dump()) + settings_manager.save() # Ensure the changes are persisted + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + return { + "success": True, + "message": "All settings updated successfully!", + } + @router.post("/set") async def set_settings(settings: List[SetSettings]): current_settings = settings_manager.settings.model_dump() diff --git a/src/controllers/ws.py b/src/controllers/ws.py new file mode 100644 index 00000000..e601ebfd --- /dev/null +++ b/src/controllers/ws.py @@ -0,0 +1,53 @@ +import json +from loguru import logger +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter( + prefix="/ws", + tags=["websocket"], + responses={404: {"description": "Not found"}}) + +class ConnectionManager: + def __init__(self): + self.active_connections: list[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + logger.debug("Frontend connected!") + self.active_connections.append(websocket) + await websocket.send_json({"type": "health", "status": "running"}) + + def disconnect(self, websocket: WebSocket): + logger.debug("Frontend disconnected!") + self.active_connections.remove(websocket) + + async def send_personal_message(self, message: str, websocket: WebSocket): + await websocket.send_text(message) + + async def send_log_message(self, message: str): + await self.broadcast({"type": "log", "message": message}) + + async def send_item_update(self, item: json): + await self.broadcast({"type": "item_update", "item": item}) + + async def broadcast(self, message: json): + for connection in self.active_connections: + try: + await connection.send_json(message) + except RuntimeError: + self.active_connections.remove(connection) + + +manager = ConnectionManager() + + +@router.websocket("") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(websocket) + except RuntimeError: + manager.disconnect(websocket) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 3c482602..b7c71218 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,3 @@ -import argparse import contextlib import signal import sys @@ -10,8 +9,8 @@ from controllers.actions import router as actions_router from controllers.default import router as default_router from controllers.items import router as items_router +from controllers.ws import router as ws_router -# from controllers.metrics import router as metrics_router from controllers.settings import router as settings_router from controllers.tmdb import router as tmdb_router from controllers.webhooks import router as webhooks_router @@ -20,7 +19,7 @@ from program import Program from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request -from program.db.db_functions import hard_reset_database +from utils.cli import handle_args from utils.logger import logger @@ -40,24 +39,7 @@ async def dispatch(self, request: Request, call_next): ) return response - -parser = argparse.ArgumentParser() -parser.add_argument( - "--ignore_cache", - action="store_true", - help="Ignore the cached metadata, create new data from scratch.", -) -parser.add_argument( - "--hard_reset_db", - action="store_true", - help="Hard reset the database, including deleting the Alembic directory.", -) - -args = parser.parse_args() - -if args.hard_reset_db: - hard_reset_database() - exit(0) +args = handle_args() app = FastAPI( title="Riven", @@ -86,7 +68,7 @@ async def dispatch(self, request: Request, call_next): app.include_router(webhooks_router) app.include_router(tmdb_router) app.include_router(actions_router) -# app.include_router(metrics_router) +app.include_router(ws_router) class Server(uvicorn.Server): diff --git a/src/program/content/trakt.py b/src/program/content/trakt.py index c741a8be..482d98ef 100644 --- a/src/program/content/trakt.py +++ b/src/program/content/trakt.py @@ -103,23 +103,10 @@ def run(self): self.items_already_seen.add(imdb_id) new_items_count += 1 - if source == "Popular": - media_item = MediaItem({ + yield MediaItem({ "imdb_id": imdb_id, "requested_by": self.key }) - elif item_type == "movie": - media_item = Movie({ - "imdb_id": imdb_id, - "requested_by": self.key - }) - else: - media_item = Show({ - "imdb_id": imdb_id, - "requested_by": self.key - }) - - yield media_item if new_items_count > 0: logger.log("TRAKT", f"New items fetched from {source}: {new_items_count}") diff --git a/src/program/db/db_functions.py b/src/program/db/db_functions.py index bb69dccd..c357b347 100644 --- a/src/program/db/db_functions.py +++ b/src/program/db/db_functions.py @@ -4,7 +4,7 @@ import alembic from program.media.item import Episode, MediaItem, Movie, Season, Show -from program.media.stream import Stream +from program.media.stream import Stream, StreamRelation from program.types import Event from sqlalchemy import delete, func, select, text from sqlalchemy.orm import joinedload @@ -42,29 +42,41 @@ def _get_item_from_db(session, item: MediaItem): type = _get_item_type_from_db(item) match type: case "movie": - r = session.execute(select(Movie).where(MediaItem.imdb_id==item.imdb_id).options(joinedload("*"))).unique().scalar_one() - r.streams = session.execute(select(Stream).where(Stream.parent_id==item._id).options(joinedload("*"))).unique().scalars().all() + r = session.execute( + select(Movie) + .where(MediaItem.imdb_id == item.imdb_id) + .options(joinedload("*")) + ).unique().scalar_one() return r case "show": - r = session.execute(select(Show).where(MediaItem.imdb_id==item.imdb_id).options(joinedload("*"))).unique().scalar_one() - r.streams = session.execute(select(Stream).where(Stream.parent_id==item._id).options(joinedload("*"))).unique().scalars().all() + r = session.execute( + select(Show) + .where(MediaItem.imdb_id == item.imdb_id) + .options(joinedload("*")) + ).unique().scalar_one() return r case "season": - r = session.execute(select(Season).where(Season._id==item._id).options(joinedload("*"))).unique().scalar_one() - r.streams = session.execute(select(Stream).where(Stream.parent_id==item._id).options(joinedload("*"))).unique().scalars().all() + r = session.execute( + select(Season) + .where(Season._id == item._id) + .options(joinedload("*")) + ).unique().scalar_one() return r case "episode": - r = session.execute(select(Episode).where(Episode._id==item._id).options(joinedload("*"))).unique().scalar_one() - r.streams = session.execute(select(Stream).where(Stream.parent_id==item._id).options(joinedload("*"))).unique().scalars().all() + r = session.execute( + select(Episode) + .where(Episode._id == item._id) + .options(joinedload("*")) + ).unique().scalar_one() return r case _: logger.error(f"_get_item_from_db Failed to create item from type: {type}") return None -def _remove_item_from_db(imdb_id): +def _remove_item_from_db(id): try: with db.Session() as session: - item = session.execute(select(MediaItem).where(MediaItem.imdb_id == imdb_id)).unique().scalar_one() + item = session.execute(select(MediaItem).where(MediaItem._id == id)).unique().scalar_one() item_type = None if item.type == "movie": item_type = Movie @@ -98,52 +110,34 @@ def _run_thread_with_db_item(fn, service, program, input_item: MediaItem | None) if input_item is not None: with db.Session() as session: if isinstance(input_item, (Movie, Show, Season, Episode)): - item = input_item - if not _check_for_and_run_insertion_required(session, item): + if not _check_for_and_run_insertion_required(session, input_item): pass - item = _get_item_from_db(session, item) - - # session.merge(item) - for res in fn(item): - if isinstance(res, list): - all_media_items = True - for i in res: - if not isinstance(i, MediaItem): - all_media_items = False - - program._remove_from_running_items(item, service.__name__) - if all_media_items is True: - for i in res: - program._push_event_queue(Event(emitted_by="_run_thread_with_db_item", item=i)) - session.commit() - return - elif not isinstance(res, MediaItem): - logger.log("PROGRAM", f"Service {service.__name__} emitted {res} from input item {item} of type {type(res).__name__}, backing off.") - program._remove_from_running_items(item, service.__name__) - if res is not None and isinstance(res, MediaItem): - program._push_event_queue(Event(emitted_by=service, item=res)) - # self._check_for_and_run_insertion_required(item) - - item.store_state() + input_item = _get_item_from_db(session, input_item) + + for res in fn(input_item): + if not isinstance(res, MediaItem): + logger.log("PROGRAM", f"Service {service.__name__} emitted {res} from input item {input_item} of type {type(res).__name__}, backing off.") + program._remove_from_running_events(input_item, service.__name__) + + input_item.store_state() session.commit() session.expunge_all() - return res - for i in fn(input_item): - if isinstance(i, (Show, Movie, Season, Episode)): - with db.Session() as session: - _check_for_and_run_insertion_required(session, i) - program._push_event_queue(Event(emitted_by=service, item=i)) - yield i + yield res + else: + #Content services + for i in fn(input_item): + if isinstance(i, (MediaItem)): + with db.Session() as session: + _check_for_and_run_insertion_required(session, i) + yield i return else: for i in fn(): - if isinstance(i, (Show, Movie, Season, Episode)): + if isinstance(i, (MediaItem)): with db.Session() as session: _check_for_and_run_insertion_required(session, i) - program._push_event_queue(Event(emitted_by=service, item=i)) - else: - program._push_event_queue(Event(emitted_by=service, item=i)) + yield i return def hard_reset_database(): diff --git a/src/program/downloaders/__init__.py b/src/program/downloaders/__init__.py index 67c3f809..c1a7dd9a 100644 --- a/src/program/downloaders/__init__.py +++ b/src/program/downloaders/__init__.py @@ -16,6 +16,10 @@ def __init__(self): AllDebridDownloader: AllDebridDownloader(), } self.initialized = self.validate() + + @property + def service(self): + return next(service for service in self.services.values() if service.initialized) def validate(self): initialized_services = [service for service in self.services.values() if service.initialized] @@ -25,12 +29,5 @@ def validate(self): return len(initialized_services) == 1 def run(self, item: MediaItem): - for service in self.services.values(): - if service.initialized: - downloaded = service.run(item) - if not downloaded: - if item.type == "show": - yield [season for season in item.seasons] - elif item.type == "season": - yield [episode for episode in item.episodes] + self.service.run(item) yield item \ No newline at end of file diff --git a/src/program/downloaders/realdebrid.py b/src/program/downloaders/realdebrid.py index 346b2de6..5cfd155e 100644 --- a/src/program/downloaders/realdebrid.py +++ b/src/program/downloaders/realdebrid.py @@ -145,7 +145,7 @@ def _chunked(lst: List, n: int) -> Generator[List, None, None]: filtered_streams = [ stream.infohash for stream in item.streams if stream.infohash and stream.infohash not in processed_stream_hashes - and not stream.blacklisted + and not item.is_stream_blacklisted(stream) ] if not filtered_streams: logger.log("NOT_FOUND", f"No streams found from filtering out processed and blacklisted hashes for: {item.log_string}") @@ -166,8 +166,9 @@ def _chunked(lst: List, n: int) -> Generator[List, None, None]: if item.type == "movie" or item.type == "episode": for hash in filtered_streams: stream = next((stream for stream in item.streams if stream.infohash == hash), None) - if stream and not stream.blacklisted: - stream.blacklisted = True + if stream and not item.is_stream_blacklisted(stream): + item.blacklist_stream(stream) + logger.debug(f"Blacklisted stream for {item.log_string} with hash: {hash}") logger.log("NOT_FOUND", f"No wanted cached streams found for {item.log_string} out of {len(filtered_streams)}") return False @@ -176,11 +177,12 @@ def _evaluate_stream_response(self, data: dict, processed_stream_hashes: set, it """Evaluate the response data from the stream availability check.""" for stream_hash, provider_list in data.items(): stream = next((stream for stream in item.streams if stream.infohash == stream_hash), None) - if not stream or stream.blacklisted: + if not stream or item.is_stream_blacklisted(stream): continue if not provider_list or not provider_list.get("rd"): - stream.blacklisted = True + item.blacklist_stream(stream) + logger.debug(f"Blacklisted stream for {item.log_string} with hash: {stream_hash}") continue if self._process_providers(item, provider_list, stream_hash): diff --git a/src/program/downloaders/shared.py b/src/program/downloaders/shared.py new file mode 100644 index 00000000..38dea689 --- /dev/null +++ b/src/program/downloaders/shared.py @@ -0,0 +1,144 @@ +import contextlib +from posixpath import splitext +from RTN import parse +from RTN.exceptions import GarbageTorrent + +from program.media.state import States + +WANTED_FORMATS = {".mkv", ".mp4", ".avi"} + +class FileFinder: + """ + A class that helps you find files. + + Attributes: + filename_attr (str): The name of the file attribute. + filesize_attr (str): The size of the file attribute. + min_filesize (int): The minimum file size. + max_filesize (int): The maximum file size. + """ + + def __init__(self, name, size, min, max): + self.filename_attr = name + self.filesize_attr = size + self.min_filesize = min + self.max_filesize = max + + def find_required_files(self, item, container): + """ + Find the required files based on the given item and container. + + Args: + item (Item): The item object representing the movie, show, season, or episode. + container (list): The list of files to search through. + + Returns: + list: A list of files that match the criteria based on the item type. + Returns an empty list if no files match the criteria. + + """ + files = [ + file + for file in container + if file and self.min_filesize < file[self.filesize_attr] < self.max_filesize + and file[self.filesize_attr] > 10000 + and splitext(file[self.filename_attr].lower())[1] in WANTED_FORMATS + ] + return_files = [] + + if not files: + return [] + + if item.type == "movie": + for file in files: + with contextlib.suppress(GarbageTorrent, TypeError): + parsed_file = parse(file[self.filename_attr], remove_trash=True) + if parsed_file.type == "movie": + return_files.append(file) + if item.type == "show": + needed_episodes = {} + acceptable_states = [States.Indexed, States.Scraped, States.Unknown, States.Failed, States.PartiallyCompleted] + + for season in item.seasons: + if season.state in acceptable_states and season.is_released_nolog: + needed_episode_numbers = {episode.number for episode in season.episodes if episode.state in acceptable_states and episode.is_released_nolog} + if needed_episode_numbers: + needed_episodes[season.number] = needed_episode_numbers + + if not any(needed_episodes.values()): + return return_files + + matched_files = {} + one_season = len(item.seasons) == 1 + + for file in files: + with contextlib.suppress(GarbageTorrent, TypeError): + parsed_file = parse(file[self.filename_attr], remove_trash=True) + if not parsed_file or not parsed_file.episode or 0 in parsed_file.season: + continue + + # Check each season and episode to find a match + for season_number, episodes in needed_episodes.items(): + if one_season or season_number in parsed_file.season: + for episode_number in parsed_file.episode: + if episode_number in episodes: + # Store the matched file for this episode + matched_files.setdefault((season_number, episode_number), []).append(file) + + # total_needed_episodes = sum(len(episodes) for episodes in needed_episodes.values()) + # matched_episodes = sum(len(files) for files in matched_files.values()) + + if set(needed_episodes).issubset(matched_files): + for key, files in matched_files.items(): + season_number, episode_number = key + for file in files: + if not file or "sample" in file[self.filename_attr].lower(): + continue + return_files.append(file) + + if item.type == "season": + acceptable_states = [States.Indexed, States.Scraped, States.Unknown, States.Failed, States.PartiallyCompleted] + needed_episodes = [] + for episode in item.episodes: + if episode.state in acceptable_states and episode.is_released_nolog: + needed_episodes.append(episode.number) + + if not needed_episodes: + return return_files + + matched_files = {} + one_season = len(item.parent.seasons) == 1 + + for file in files: + with contextlib.suppress(GarbageTorrent, TypeError): + parsed_file = parse(file[self.filename_attr], remove_trash=True) + if not parsed_file or not parsed_file.episode or 0 in parsed_file.season: + continue + + if one_season or item.number in parsed_file.season: + for episode_number in parsed_file.episode: + if episode_number in needed_episodes: + matched_files.setdefault(episode_number, []).append(file) + + matched_episodes = sum(len(files) for files in matched_files.values()) + + if set(needed_episodes).issubset(matched_files): + for files in matched_files.values(): + for file in files: + if not file or "sample" in file[self.filename_attr].lower(): + continue + return_files.append(file) + + if item.type == "episode": + for file in files: + if not file or not file.get(self.filename_attr): + continue + with contextlib.suppress(GarbageTorrent, TypeError): + parsed_file = parse(file[self.filename_attr], remove_trash=True) + if ( + item.number in parsed_file.episode + and item.parent.number in parsed_file.season + ): + return_files.append(file) + + return return_files diff --git a/src/program/downloaders/torbox.py b/src/program/downloaders/torbox.py index 6c739954..e742aa27 100644 --- a/src/program/downloaders/torbox.py +++ b/src/program/downloaders/torbox.py @@ -6,6 +6,7 @@ from program.media.item import MediaItem from program.media.state import States +from program.media.stream import Stream from program.settings.manager import settings_manager from requests import ConnectTimeout from RTN import parse @@ -92,10 +93,21 @@ def run(self, item: MediaItem) -> bool: logger.log("DEBRID", f"Item is not cached: {item.log_string}") for stream in item.streams: logger.log( - "DEBUG", f"Blacklisting hash ({stream.infohash}) for item: {item.log_string}" + "DEBUG", f"Blacklisting uncached hash ({stream.infohash}) for item: {item.log_string}" ) stream.blacklisted = True return return_value + + def get_cached_hashes(self, item: MediaItem, streams: list[str]) -> list[str]: + """Check if the item is cached in torbox.app""" + cached_hashes = self.get_torrent_cached(streams) + return {stream: cached_hashes[stream]["files"] for stream in streams if stream in cached_hashes} + + def download_cached(self, item: MediaItem, stream: str) -> None: + """Download the cached item from torbox.app""" + cache = self.get_torrent_cached([stream])[stream] + item.active_stream = cache + self.download(item) def find_required_files(self, item, container): diff --git a/src/program/indexers/trakt.py b/src/program/indexers/trakt.py index 2713e6f5..2166d016 100644 --- a/src/program/indexers/trakt.py +++ b/src/program/indexers/trakt.py @@ -22,22 +22,22 @@ def __init__(self): self.initialized = True self.settings = settings_manager.settings.indexer + def copy_attributes(self, source, target): + """Copy attributes from source to target.""" + attributes = ["file", "folder", "update_folder", "symlinked", "is_anime", "symlink_path", "subtitles"] + for attr in attributes: + target.set(attr, getattr(source, attr, None)) + def copy_items(self, itema: MediaItem, itemb: MediaItem): + """Copy attributes from itema to itemb recursively.""" if isinstance(itema, Show) and isinstance(itemb, Show): - for (seasona, seasonb) in zip(itema.seasons, itemb.seasons): - for (episodea, episodeb) in zip(seasona.episodes, seasonb.episodes): - episodeb.set("update_folder", episodea.update_folder) - episodeb.set("symlinked", episodea.symlinked) - episodeb.set("file", episodea.file) - episodeb.set("folder", episodea.folder) - seasonb.set("is_anime", itema.is_anime) - episodeb.set("is_anime", itema.is_anime) - elif isinstance(itema, Movie) and isinstance(itemb, Movie): - itemb.set("file", itema.file) - itemb.set("folder", itema.folder) - itemb.set("update_folder", itema.update_folder) - itemb.set("symlinked", itema.symlinked) + for seasona, seasonb in zip(itema.seasons, itemb.seasons): + for episodea, episodeb in zip(seasona.episodes, seasonb.episodes): + self.copy_attributes(episodea, episodeb) + seasonb.set("is_anime", itema.is_anime) itemb.set("is_anime", itema.is_anime) + elif isinstance(itema, Movie) and isinstance(itemb, Movie): + self.copy_attributes(itema, itemb) return itemb def run(self, in_item: MediaItem) -> Generator[Union[Movie, Show, Season, Episode], None, None]: diff --git a/src/program/libraries/symlink.py b/src/program/libraries/symlink.py index 85bcd0b8..5280b4b7 100644 --- a/src/program/libraries/symlink.py +++ b/src/program/libraries/symlink.py @@ -3,6 +3,7 @@ from pathlib import Path from program.media.item import Episode, MediaItem, Movie, Season, Show +from program.media.subtitle import Subtitle from program.settings.manager import settings_manager from utils.logger import logger @@ -54,12 +55,14 @@ def run(self): def process_items(directory: Path, item_class, item_type: str, is_anime: bool = False): """Process items in the given directory and yield MediaItem instances.""" items = [ - (Path(root), files[0]) - for root, _, files - in os.walk(directory) - if files + (Path(root), file) + for root, _, files in os.walk(directory) + for file in files + if not file.endswith('.srt') ] for path, filename in items: + if filename.endswith(".srt"): + continue imdb_id = re.search(r"(tt\d+)", filename) title = re.search(r"(.+)?( \()", filename) if not imdb_id or not title: @@ -68,6 +71,7 @@ def process_items(directory: Path, item_class, item_type: str, is_anime: bool = item = item_class({"imdb_id": imdb_id.group(), "title": title.group(1)}) resolve_symlink_and_set_attrs(item, path / filename) + find_subtitles(item, path / filename) if settings_manager.settings.force_refresh: item.set("symlinked", True) @@ -82,8 +86,17 @@ def process_items(directory: Path, item_class, item_type: str, is_anime: bool = def resolve_symlink_and_set_attrs(item, path: Path) -> Path: # Resolve the symlink path resolved_path = (path).resolve() - item.set("file", str(resolved_path.stem)) - item.set("folder", str(resolved_path.parent.stem)) + item.file = str(resolved_path.stem) + item.folder = str(resolved_path.parent.stem) + item.symlink_path = str(path) + +def find_subtitles(item, path: Path): + # Scan for subtitle files + for file in os.listdir(path.parent): + if file.startswith(Path(item.symlink_path).stem) and file.endswith(".srt"): + lang_code = file.split(".")[1] + item.subtitles.append(Subtitle({lang_code: (path.parent / file).__str__()})) + logger.debug(f"Found subtitle file {file}.") def process_shows(directory: Path, item_type: str, is_anime: bool = False) -> Show: """Process shows in the given directory and yield Show instances.""" @@ -104,7 +117,7 @@ def process_shows(directory: Path, item_type: str, is_anime: bool = False) -> Sh season_item = Season({"number": int(season_number.group())}) episodes = {} for episode in os.listdir(directory / show / season): - if not (episode_number := re.search(r"s\d+e(\d+)", episode)): + if not (episode_number := re.search(r"s\d+e(\d+)", episode, re.IGNORECASE)): logger.log("NOT_FOUND", f"Can't extract episode number at path {directory / show / season / episode}") # Delete the episode since it can't be indexed os.remove(directory / show / season / episode) @@ -112,6 +125,7 @@ def process_shows(directory: Path, item_type: str, is_anime: bool = False) -> Sh episode_item = Episode({"number": int(episode_number.group(1))}) resolve_symlink_and_set_attrs(episode_item, Path(directory) / show / season / episode) + find_subtitles(episode_item, Path(directory) / show / season / episode) if settings_manager.settings.force_refresh: episode_item.set("symlinked", True) episode_item.set("update_folder", str(Path(directory) / show / season / episode)) @@ -120,7 +134,6 @@ def process_shows(directory: Path, item_type: str, is_anime: bool = False) -> Sh episode_item.set("update_folder", "updated") if is_anime: episode_item.is_anime = True - #season_item.add_episode(episode_item) episodes[int(episode_number.group(1))] = episode_item if len(episodes) > 0: for i in range(1, max(episodes.keys())+1): diff --git a/src/program/media/item.py b/src/program/media/item.py index d8f4f737..a7e4d83b 100644 --- a/src/program/media/item.py +++ b/src/program/media/item.py @@ -1,13 +1,19 @@ """MediaItem class""" from datetime import datetime +import json +from pathlib import Path from typing import List, Optional, Self +import asyncio import sqlalchemy from program.db.db import db from program.media.state import States from RTN import parse from sqlalchemy.orm import Mapped, mapped_column, relationship -from .stream import Stream + +from program.media.subtitle import Subtitle +from .stream import Stream, StreamRelation +from controllers.ws import manager from utils.logger import logger @@ -24,12 +30,13 @@ class MediaItem(db.Model): indexed_at: Mapped[Optional[datetime]] = mapped_column(sqlalchemy.DateTime, nullable=True) scraped_at: Mapped[Optional[datetime]] = mapped_column(sqlalchemy.DateTime, nullable=True) scraped_times: Mapped[Optional[int]] = mapped_column(sqlalchemy.Integer, default=0) - active_stream: Mapped[Optional[dict[str, str]]] = mapped_column(sqlalchemy.JSON, nullable=True) - streams: Mapped[List[Stream]] = relationship("Stream", back_populates='parent', lazy="select", cascade="all, delete-orphan") - blacklisted_streams: Mapped[Optional[List[Stream]]] = mapped_column(sqlalchemy.JSON, nullable=True) + active_stream: Mapped[Optional[dict[str]]] = mapped_column(sqlalchemy.JSON, nullable=True) + streams: Mapped[list[Stream]] = relationship(secondary="StreamRelation", back_populates="parents") + blacklisted_streams: Mapped[list[Stream]] = relationship(secondary="StreamBlacklistRelation", back_populates="blacklisted_parents") symlinked: Mapped[Optional[bool]] = mapped_column(sqlalchemy.Boolean, default=False) symlinked_at: Mapped[Optional[datetime]] = mapped_column(sqlalchemy.DateTime, nullable=True) symlinked_times: Mapped[Optional[int]] = mapped_column(sqlalchemy.Integer, default=0) + symlink_path: Mapped[Optional[str]] = mapped_column(sqlalchemy.String, nullable=True) file: Mapped[Optional[str]] = mapped_column(sqlalchemy.String, nullable=True) folder: Mapped[Optional[str]] = mapped_column(sqlalchemy.String, nullable=True) alternative_folder: Mapped[Optional[str]] = mapped_column(sqlalchemy.String, nullable=True) @@ -49,6 +56,7 @@ class MediaItem(db.Model): update_folder: Mapped[Optional[str]] = mapped_column(sqlalchemy.String, nullable=True) overseerr_id: Mapped[Optional[int]] = mapped_column(sqlalchemy.Integer, nullable=True) last_state: Mapped[Optional[str]] = mapped_column(sqlalchemy.String, default="Unknown") + subtitles: Mapped[list[Subtitle]] = relationship(Subtitle, back_populates="parent") __mapper_args__ = { "polymorphic_identity": "mediaitem", @@ -65,6 +73,7 @@ def __init__(self, item: dict) -> None: self.scraped_times = 0 self.active_stream = item.get("active_stream", {}) self.streams: List[Stream] = [] + self.blacklisted_streams: List[Stream] = [] self.symlinked = False self.symlinked_at = None @@ -98,9 +107,27 @@ def __init__(self, item: dict) -> None: # Overseerr related self.overseerr_id = item.get("overseerr_id") + #Post processing + self.subtitles = item.get("subtitles", []) + def store_state(self) -> None: + if self.last_state != self._determine_state().name: + asyncio.run(manager.send_item_update(json.dumps(self.to_dict()))) self.last_state = self._determine_state().name + def is_stream_blacklisted(self, stream: Stream): + """Check if a stream is blacklisted for this item.""" + return stream in self.blacklisted_streams + + def blacklist_stream(self, stream: Stream): + """Blacklist a stream for this item.""" + if stream in self.streams: + self.streams.remove(stream) + self.blacklisted_streams.append(stream) + logger.debug(f"Stream {stream.infohash} blacklisted for {self.log_string}") + return True + return False + @property def is_released(self) -> bool: """Check if an item has been released.""" @@ -159,13 +186,12 @@ def copy_other_media_attr(self, other): def is_scraped(self): return (len(self.streams) > 0 - and - all(stream.blacklisted == False for stream in self.streams)) + and any(not stream in self.blacklisted_streams for stream in self.streams)) def to_dict(self): """Convert item to dictionary (API response)""" return { - "item_id": str(self.item_id), + "id": str(self._id), "title": self.title, "type": self.__class__.__name__, "imdb_id": self.imdb_id if hasattr(self, "imdb_id") else None, @@ -173,13 +199,13 @@ def to_dict(self): "tmdb_id": self.tmdb_id if hasattr(self, "tmdb_id") else None, "state": self.state.value, "imdb_link": self.imdb_link if hasattr(self, "imdb_link") else None, - "aired_at": self.aired_at, + "aired_at": str(self.aired_at), "genres": self.genres if hasattr(self, "genres") else None, "is_anime": self.is_anime if hasattr(self, "is_anime") else False, "guid": self.guid, "requested_at": str(self.requested_at), "requested_by": self.requested_by, - "scraped_at": self.scraped_at, + "scraped_at": str(self.scraped_at), "scraped_times": self.scraped_times, } @@ -205,6 +231,9 @@ def to_extended_dict(self, abbreviated_children=False): dict["active_stream"] = ( self.active_stream if hasattr(self, "active_stream") else None ) + dict["streams"] = getattr(self, "streams", []) + dict["blacklisted_streams"] = getattr(self, "blacklisted_streams", []) + dict["number"] = self.number if hasattr(self, "number") else None dict["symlinked"] = self.symlinked if hasattr(self, "symlinked") else None dict["symlinked_at"] = ( self.symlinked_at if hasattr(self, "symlinked_at") else None @@ -218,6 +247,8 @@ def to_extended_dict(self, abbreviated_children=False): ) dict["file"] = self.file if hasattr(self, "file") else None dict["folder"] = self.folder if hasattr(self, "folder") else None + dict["symlink_path"] = self.symlink_path if hasattr(self, "symlink_path") else None + dict["subtitles"] = getattr(self, "subtitles", []) return dict def __iter__(self): @@ -225,8 +256,8 @@ def __iter__(self): yield attr def __eq__(self, other): - if isinstance(other, type(self)): - return self.imdb_id == other.imdb_id + if type(other) == type(self): + return self._id == other._id return False def copy(self, other): @@ -258,6 +289,37 @@ def get_top_title(self) -> str: def __hash__(self): return hash(self.item_id) + + def reset(self, reset_times: bool = True): + """Reset item attributes for rescraping.""" + if self.symlink_path: + if Path(self.symlink_path).exists(): + Path(self.symlink_path).unlink() + self.set("symlink_path", None) + + for subtitle in self.subtitles: + subtitle.remove() + + self.set("file", None) + self.set("folder", None) + self.set("alternative_folder", None) + + if hasattr(self, "active_stream"): + stream: Stream = next((stream for stream in self.streams if stream.infohash == self.active_stream["hash"]), None) + if stream: + self.blacklist_stream(stream) + + self.set("active_stream", {}) + self.set("symlinked", False) + self.set("symlinked_at", None) + self.set("update_folder", None) + self.set("scraped_at", None) + + if reset_times: + self.set("symlinked_times", 0) + self.set("scraped_times", 0) + + logger.debug(f"Item {self.log_string} reset for rescraping") @property def log_string(self): @@ -322,17 +384,17 @@ def get_season_index_by_id(self, item_id): def _determine_state(self): if all(season.state == States.Completed for season in self.seasons): return States.Completed + if any( + season.state in (States.Completed, States.PartiallyCompleted) + for season in self.seasons + ): + return States.PartiallyCompleted if all(season.state == States.Symlinked for season in self.seasons): return States.Symlinked if all(season.state == States.Downloaded for season in self.seasons): return States.Downloaded if self.is_scraped(): return States.Scraped - if any( - season.state in (States.Completed, States.PartiallyCompleted) - for season in self.seasons - ): - return States.PartiallyCompleted if any(season.state == States.Indexed for season in self.seasons): return States.Indexed if any(season.state == States.Requested for season in self.seasons): @@ -342,6 +404,8 @@ def _determine_state(self): def store_state(self) -> None: for season in self.seasons: season.store_state() + if self.last_state != self._determine_state().name: + asyncio.run(manager.send_item_update(json.dumps(self.to_dict()))) self.last_state = self._determine_state().name def __repr__(self): @@ -414,6 +478,8 @@ class Season(MediaItem): def store_state(self) -> None: for episode in self.episodes: episode.store_state() + if self.last_state != self._determine_state().name: + asyncio.run(manager.send_item_update(json.dumps(self.to_dict()))) self.last_state = self._determine_state().name def __init__(self, item): @@ -430,14 +496,14 @@ def _determine_state(self): if len(self.episodes) > 0: if all(episode.state == States.Completed for episode in self.episodes): return States.Completed + if any(episode.state == States.Completed for episode in self.episodes): + return States.PartiallyCompleted if all(episode.state == States.Symlinked for episode in self.episodes): return States.Symlinked if all(episode.file and episode.folder for episode in self.episodes): return States.Downloaded if self.is_scraped(): return States.Scraped - if any(episode.state == States.Completed for episode in self.episodes): - return States.PartiallyCompleted if any(episode.state == States.Indexed for episode in self.episodes): return States.Indexed if any(episode.state == States.Requested for episode in self.episodes): @@ -448,13 +514,6 @@ def _determine_state(self): def is_released(self) -> bool: return any(episode.is_released for episode in self.episodes) - def __eq__(self, other): - if ( - type(self) == type(other) - and self.parent_id == other.parent_id - ): - return self.number == other.get("number", None) - def __repr__(self): return f"Season:{self.number}:{self.state.name}" @@ -526,14 +585,6 @@ def __init__(self, item): if self.parent and isinstance(self.parent, Season): self.is_anime = self.parent.parent.is_anime - def __eq__(self, other): - if ( - type(self) == type(other) - and self.item_id == other.item_id - and self.parent.parent.item_id == other.parent.parent.item_id - ): - return self.number == other.get("number", None) - def __repr__(self): return f"Episode:{self.number}:{self.state.name}" diff --git a/src/program/media/stream.py b/src/program/media/stream.py index a6940e59..978f904a 100644 --- a/src/program/media/stream.py +++ b/src/program/media/stream.py @@ -2,7 +2,21 @@ from program.db.db import db import sqlalchemy from sqlalchemy.orm import Mapped, mapped_column, relationship +from loguru import logger +class StreamRelation(db.Model): + __tablename__ = "StreamRelation" + + _id: Mapped[int] = mapped_column(sqlalchemy.Integer, primary_key=True) + parent_id: Mapped[int] = mapped_column(sqlalchemy.Integer, sqlalchemy.ForeignKey("MediaItem._id", ondelete="CASCADE")) + child_id: Mapped[int] = mapped_column(sqlalchemy.Integer, sqlalchemy.ForeignKey("Stream._id", ondelete="CASCADE")) + +class StreamBlacklistRelation(db.Model): + __tablename__ = "StreamBlacklistRelation" + + _id: Mapped[int] = mapped_column(sqlalchemy.Integer, primary_key=True) + media_item_id: Mapped[int] = mapped_column(sqlalchemy.Integer, sqlalchemy.ForeignKey("MediaItem._id", ondelete="CASCADE")) + stream_id: Mapped[int] = mapped_column(sqlalchemy.Integer, sqlalchemy.ForeignKey("Stream._id", ondelete="CASCADE")) class Stream(db.Model): __tablename__ = "Stream" @@ -13,10 +27,9 @@ class Stream(db.Model): parsed_title: Mapped[str] = mapped_column(sqlalchemy.String, nullable=False) rank: Mapped[int] = mapped_column(sqlalchemy.Integer, nullable=False) lev_ratio: Mapped[float] = mapped_column(sqlalchemy.Float, nullable=False) - blacklisted: Mapped[bool] = mapped_column(sqlalchemy.Boolean, nullable=False) - parent_id: Mapped[int] = mapped_column(sqlalchemy.Integer, sqlalchemy.ForeignKey("MediaItem._id")) - parent: Mapped["MediaItem"] = relationship("MediaItem", back_populates="streams") + parents: Mapped[list["MediaItem"]] = relationship(secondary="StreamRelation", back_populates="streams") + blacklisted_parents: Mapped[list["MediaItem"]] = relationship(secondary="StreamBlacklistRelation", back_populates="blacklisted_streams") def __init__(self, torrent: Torrent): self.raw_title = torrent.raw_title @@ -24,11 +37,9 @@ def __init__(self, torrent: Torrent): self.parsed_title = torrent.data.parsed_title self.rank = torrent.rank self.lev_ratio = torrent.lev_ratio - self.blacklisted = False def __hash__(self): return self.infohash def __eq__(self, other): - return isinstance(other, Stream) and self.infohash == other.infohash - \ No newline at end of file + return isinstance(other, Stream) and self.infohash == other.infohash \ No newline at end of file diff --git a/src/program/media/subtitle.py b/src/program/media/subtitle.py new file mode 100644 index 00000000..6909123b --- /dev/null +++ b/src/program/media/subtitle.py @@ -0,0 +1,26 @@ +from pathlib import Path +from program.db.db import db +from sqlalchemy import Integer, String, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + + +class Subtitle(db.Model): + __tablename__ = "Subtitle" + + _id: Mapped[int] = mapped_column(Integer, primary_key=True) + language: Mapped[str] = mapped_column(String) + file: Mapped[str] = mapped_column(String, nullable=True) + + parent_id: Mapped[int] = mapped_column(Integer, ForeignKey("MediaItem._id", ondelete="CASCADE")) + parent: Mapped["MediaItem"] = relationship("MediaItem", back_populates="subtitles") + + def __init__(self, optional={}): + for key in optional.keys(): + self.language = key + self.file = optional[key] + + def remove(self): + if self.file and Path(self.file).exists(): + Path(self.file).unlink() + self.file = None + return self \ No newline at end of file diff --git a/src/program/post_processing/__init__.py b/src/program/post_processing/__init__.py new file mode 100644 index 00000000..8a7e957f --- /dev/null +++ b/src/program/post_processing/__init__.py @@ -0,0 +1,24 @@ +from program.media.item import MediaItem +from program.post_processing.subliminal import Subliminal +from program.settings.manager import settings_manager + + +class PostProcessing: + def __init__(self): + self.key = "post_processing" + self.initialized = False + self.settings = settings_manager.settings.post_processing + self.services = { + Subliminal: Subliminal() + } + self.initialized = self.validate() + if not self.initialized: + return + + def validate(self): + return any(service.enabled for service in self.services.values()) + + def run(self, item: MediaItem): + if Subliminal.should_submit(item): + self.services[Subliminal].run(item) + yield item \ No newline at end of file diff --git a/src/program/post_processing/subliminal.py b/src/program/post_processing/subliminal.py new file mode 100644 index 00000000..9c0f4ba4 --- /dev/null +++ b/src/program/post_processing/subliminal.py @@ -0,0 +1,118 @@ +import os +import pathlib +from subliminal import download_best_subtitles, region, Video, save_subtitles +from babelfish import Language +from program.media.subtitle import Subtitle +from program.settings.manager import settings_manager +from utils import root_dir +from loguru import logger + +class Subliminal: + def __init__(self): + self.key = "subliminal" + if not region.is_configured: + region.configure('dogpile.cache.dbm', arguments={'filename': f'{root_dir}/data/subliminal.dbm'}) + self.settings = settings_manager.settings.post_processing.subliminal + self.languages = set(create_language_from_string(lang) for lang in self.settings.languages) + self.initialized = self.enabled + + @property + def enabled(self): + return self.settings.enabled + + def scan_files_and_download(self): + # Do we want this? + pass + # videos = _scan_videos(settings_manager.settings.symlink.library_path) + # subtitles = download_best_subtitles(videos, {Language("eng")}) + # for video, subtitle in subtitles.items(): + # original_name = video.name + # video.name = pathlib.Path(video.symlink) + # saved = save_subtitles(video, subtitle) + # video.name = original_name + # for subtitle in saved: + # logger.info(f"Downloaded ({subtitle.language}) subtitle for {pathlib.Path(video.symlink).stem}") + + def get_subtitles(self, item): + if item.type in ["movie", "episode"]: + real_name = pathlib.Path(item.symlink_path).resolve().name + video = Video.fromname(real_name) + video.symlink_path = item.symlink_path + video.subtitle_languages = get_existing_subtitles(pathlib.Path(item.symlink_path).stem, pathlib.Path(item.symlink_path).parent) + return download_best_subtitles([video], self.languages) + return {} + + def save_subtitles(self, subtitles, item): + for video, subtitle in subtitles.items(): + original_name = video.name + video.name = pathlib.Path(video.symlink_path) + saved = save_subtitles(video, subtitle) + for subtitle in saved: + logger.info(f"Downloaded ({subtitle.language}) subtitle for {pathlib.Path(item.symlink_path).stem}") + video.name = original_name + + + def run(self, item): + subtitles = self.get_subtitles(item) + for language in self.languages: + key = str(language) + item.subtitles.append(Subtitle({key: None})) + self.save_subtitles(subtitles, item) + self.update_item(item) + + def update_item(self, item): + folder = pathlib.Path(item.symlink_path).parent + subs = get_existing_subtitles(pathlib.Path(item.symlink_path).stem, folder) + for lang in subs: + key = str(lang) + for subtitle in item.subtitles: + if subtitle.language == key: + subtitle.file = (folder / lang.file).__str__() + break + + def should_submit(item): + return item.type in ["movie", "episode"] and not any(subtitle.file is not None for subtitle in item.subtitles) + +def _scan_videos(directory): + """ + Scan the given directory recursively for video files. + + :param directory: Path to the directory to scan + :return: List of Video objects + """ + videos = [] + for root, _, files in os.walk(directory): + for file in files: + if file.endswith(('.mp4', '.mkv', '.avi', '.mov', '.wmv')): + video_path = os.path.join(root, file) + video_name = pathlib.Path(video_path).resolve().name + video = Video.fromname(video_name) + video.symlink = pathlib.Path(video_path) + + # Scan for subtitle files + video.subtitle_languages = get_existing_subtitles(video.symlink.stem, pathlib.Path(root)) + videos.append(video) + return videos + +def create_language_from_string(lang: str) -> Language: + try: + if len(lang) == 2: + return Language.fromcode(lang, "alpha2") + if len(lang) == 3: + return Language.fromcode(lang, "alpha3b") + except ValueError: + logger.error(f"Invalid language code: {lang}") + return None + +def get_existing_subtitles(filename: str, path: pathlib.Path) -> set[Language]: + subtitle_languages = set() + for file in path.iterdir(): + if file.stem.startswith(filename) and file.suffix == '.srt': + parts = file.name.split('.') + if len(parts) > 2: + lang_code = parts[-2] + language = create_language_from_string(lang_code) + language.file = file.name + subtitle_languages.add(language) + return subtitle_languages + diff --git a/src/program/program.py b/src/program/program.py index 90f6ccba..b83d6208 100644 --- a/src/program/program.py +++ b/src/program/program.py @@ -13,8 +13,10 @@ from program.downloaders import Downloader from program.indexers.trakt import TraktIndexer from program.libraries import SymlinkLibrary -from program.media.item import Episode, MediaItem, Movie, Season, Show, copy_item +from program.media.item import Episode, MediaItem, Movie, Season, Show from program.media.state import States +from program.post_processing import PostProcessing +from program.post_processing.subliminal import Subliminal from program.scrapers import Scraping from program.settings.manager import settings_manager from program.settings.models import get_version @@ -45,8 +47,8 @@ def __init__(self, args): self.initialized = False self.event_queue = Queue() self.services = {} - self.queued_items = [] - self.running_items = [] + self.queued_events = [] + self.running_events = [] self.mutex = Lock() self.enable_trace = settings_manager.settings.tracemalloc self.sql_Session = db.Session @@ -89,6 +91,7 @@ def initialize_services(self): **self.indexing_services, **self.requesting_services, **self.processing_services, + PostProcessing: PostProcessing(), } if self.enable_trace: @@ -208,13 +211,20 @@ def _retry_library(self) -> None: ).unique().scalars().all() for item in items_to_submit: - self._push_event_queue(Event(emitted_by=self.__class__, item=item)) + self._push_event_queue(Event(emitted_by="RetryLibrary", item=item)) + + def _download_subtitles(self) -> None: + if settings_manager.settings.post_processing.subliminal.enabled: + self.services[PostProcessing].services[Subliminal].scan_files_and_download() def _schedule_functions(self) -> None: """Schedule each service based on its update interval.""" scheduled_functions = { self._retry_library: {"interval": 60 * 10}, } + if settings_manager.settings.post_processing.subliminal.enabled: + pass + # scheduled_functions[self._download_subtitles] = {"interval": 60 * 60 * 24} for func, config in scheduled_functions.items(): self.scheduler.add_job( func, @@ -252,85 +262,84 @@ def _schedule_services(self) -> None: logger.log("PROGRAM", f"Scheduled {service_cls.__name__} to run every {update_interval} seconds.") def _id_in_queue(self, id): - return any(i._id == id for i in self.queued_items) + return any(event.item._id == id for event in self.queued_events) - def _id_in_running_items(self, id): - return any(i._id == id for i in self.running_items) + def _id_in_running_events(self, id): + return any(event.item._id == id for event in self.running_events) def _push_event_queue(self, event): with self.mutex: - if event.item in self.queued_items or event.item in self.running_items: - logger.debug(f"Item {event.item.log_string} is already in the queue or running, skipping.") + if any(event.item.imdb_id and qi.item.imdb_id == event.item.imdb_id for qi in self.queued_events): + logger.debug(f"Item {event.item.log_string} is already in the queue, skipping.") + return False + elif any(event.item.imdb_id and ri.item.imdb_id == event.item.imdb_id for ri in self.running_events): + logger.debug(f"Item {event.item.log_string} is already running, skipping.") return False if isinstance(event.item, MediaItem) and hasattr(event.item, "_id"): if event.item.type == "show": for s in event.item.seasons: - if self._id_in_queue(s._id) or self._id_in_running_items(s._id): + if self._id_in_queue(s._id) or self._id_in_running_events(s._id): return False for e in s.episodes: - if self._id_in_queue(e._id) or self._id_in_running_items(e._id): + if self._id_in_queue(e._id) or self._id_in_running_events(e._id): return False elif event.item.type == "season": for e in event.item.episodes: - if self._id_in_queue(e._id) or self._id_in_running_items(e._id): + if self._id_in_queue(e._id) or self._id_in_running_events(e._id): return False elif hasattr(event.item, "parent"): parent = event.item.parent - if self._id_in_queue(parent._id) or self._id_in_running_items(parent._id): + if self._id_in_queue(parent._id) or self._id_in_running_events(parent._id): return False - elif hasattr(parent, "parent") and (self._id_in_queue(parent.parent._id) or self._id_in_running_items(parent.parent._id)): + elif hasattr(parent, "parent") and (self._id_in_queue(parent.parent._id) or self._id_in_running_events(parent.parent._id)): return False if not isinstance(event.item, (Show, Movie, Episode, Season)): logger.log("NEW", f"Added {event.item.log_string} to the queue") else: logger.log("DISCOVERY", f"Re-added {event.item.log_string} to the queue") - - event.item = copy_item(event.item) - self.queued_items.append(event.item) + self.queued_events.append(event) self.event_queue.put(event) return True def _pop_event_queue(self, event): with self.mutex: # DB._store_item(event.item) # possibly causing duplicates - self.queued_items.remove(event.item) + self.queued_events.remove(event) - def _remove_from_running_items(self, item, service_name=""): + def _remove_from_running_events(self, item, service_name=""): with self.mutex: - if item in self.running_items: - self.running_items.remove(item) + event = next((event for event in self.running_events if item._id and event.item._id == item._id or item.imdb_id and event.item.imdb_id == item.imdb_id), None) + if event: + self.running_events.remove(event) logger.log("PROGRAM", f"Item {item.log_string} finished running section {service_name}" ) - def add_to_running(self, item, service_name): - if item is None: + def add_to_running(self, e): + if e.item is None: return with self.mutex: - if item not in self.running_items: - if isinstance(item, MediaItem) and not self._id_in_running_items(item._id): - self.running_items.append(copy_item(item)) - elif not isinstance(item, MediaItem): - self.running_items.append(copy_item(item)) - logger.log("PROGRAM", f"Item {item.log_string} started running section {service_name}" ) + if all(event.item._id != e.item._id for event in self.running_events): + emitted_by = e.emitted_by.__name__ if type(e.emitted_by) != str else e.emitted_by + if isinstance(e.item, MediaItem) and not self._id_in_running_events(e.item._id) or not isinstance(e.item, MediaItem): + self.running_events.append(e) + logger.log("PROGRAM", f"Item {e.item.log_string} started running section { emitted_by }" ) def _process_future_item(self, future: Future, service: Service, orig_item: MediaItem) -> None: """Callback to add the results from a future emitted by a service to the event queue.""" try: - for _item in future.result(): - pass - if orig_item is not None: - logger.log("PROGRAM", f"Service {service.__name__} finished running on {orig_item.log_string}") - else: - logger.log("PROGRAM", f"Service {service.__name__} finished running.") + for i in future.result(): + if i is not None: + self._remove_from_running_events(i, service.__name__) + self._push_event_queue(Event(emitted_by=service, item=i)) except TimeoutError: logger.debug("Service {service.__name__} timeout waiting for result on {orig_item.log_string}") - self._remove_from_running_items(orig_item, service.__name__) + self._remove_from_running_events(orig_item, service.__name__) except Exception: logger.exception(f"Service {service.__name__} failed with exception {traceback.format_exc()}") - self._remove_from_running_items(orig_item, service.__name__) + self._remove_from_running_events(orig_item, service.__name__) def _submit_job(self, service: Service, item: MediaItem | None) -> None: if item and service: @@ -402,7 +411,7 @@ def run(self): event: Event = self.event_queue.get(timeout=10) if self.enable_trace: self.dump_tracemalloc() - self.add_to_running(event.item, "program.run") + self.add_to_running(event) self._pop_event_queue(event) except Empty: if self.enable_trace: @@ -411,25 +420,25 @@ def run(self): with db.Session() as session: existing_item: MediaItem | None = DB._get_item_from_db(session, event.item) - updated_item, next_service, items_to_submit = process_event( + processed_item, next_service, items_to_submit = process_event( existing_item, event.emitted_by, existing_item if existing_item is not None else event.item ) - if updated_item and isinstance(existing_item, MediaItem) and updated_item.state == States.Symlinked: - if updated_item.type in ["show", "movie"]: - logger.success(f"Item has been completed: {updated_item.log_string}") + if processed_item and processed_item.state == States.Completed: + if processed_item.type in ["show", "movie"]: + logger.success(f"Item has been completed: {processed_item.log_string}") if settings_manager.settings.notifications.enabled: - notify_on_complete(updated_item) + notify_on_complete(processed_item) - self._remove_from_running_items(event.item, "program.run") + self._remove_from_running_events(event.item, "program.run") if items_to_submit: for item_to_submit in items_to_submit: - self.add_to_running(item_to_submit, next_service.__name__) + self.add_to_running(Event(next_service.__name__, item_to_submit)) self._submit_job(next_service, item_to_submit) - if isinstance(existing_item, MediaItem): - existing_item.store_state() + if isinstance(processed_item, MediaItem): + processed_item.store_state() session.commit() def stop(self): @@ -446,9 +455,10 @@ def stop(self): self.scheduler.shutdown(wait=False) logger.log("PROGRAM", "Riven has been stopped.") - def add_to_queue(self, item: MediaItem) -> bool: + def add_to_queue(self, item: MediaItem, emitted_by="Manual") -> bool: """Add item to the queue for processing.""" - return self._push_event_queue(Event(emitted_by=self.__class__, item=item)) + logger.log("PROGRAM", f"Adding {item.log_string} to the queue.") + return self._push_event_queue(Event(emitted_by=emitted_by, item=item)) def clear_queue(self): """Clear the event queue.""" diff --git a/src/program/scrapers/__init__.py b/src/program/scrapers/__init__.py index af6656a0..a40dd67c 100644 --- a/src/program/scrapers/__init__.py +++ b/src/program/scrapers/__init__.py @@ -71,26 +71,16 @@ def partial_state(self, item: MediaItem) -> bool: def run(self, item: Union[Show, Season, Episode, Movie]) -> Generator[Union[Show, Season, Episode, Movie], None, None]: """Scrape an item.""" - if not item or not self.can_we_scrape(item): - yield self.yield_incomplete_children(item) - return - - partial_state = self.partial_state(item) - if partial_state is not False: - yield partial_state - return - - sorted_streams = self.scrape(item) - for stream in sorted_streams.values(): - if stream not in item.streams: - item.streams.append(stream) - item.set("scraped_at", datetime.now()) - item.set("scraped_times", item.scraped_times + 1) - - if not item.get("streams", {}): + if self.can_we_scrape(item): + sorted_streams = self.scrape(item) + for stream in sorted_streams.values(): + if stream not in item.streams: + item.streams.append(stream) + item.set("scraped_at", datetime.now()) + item.set("scraped_times", item.scraped_times + 1) + + if not item.get("streams", []): logger.log("NOT_FOUND", f"Scraping returned no good results for {item.log_string}") - yield self.yield_incomplete_children(item) - return yield item @@ -137,7 +127,10 @@ def run_service(service, item,): @classmethod def can_we_scrape(cls, item: MediaItem) -> bool: """Check if we can scrape an item.""" - return item.is_released and cls.should_submit(item) + if item.is_released and cls.should_submit(item): + return True + logger.debug(f"Conditions not met, will not scrape {item.log_string}") + return False @staticmethod def should_submit(item: MediaItem) -> bool: diff --git a/src/program/settings/models.py b/src/program/settings/models.py index 2e875187..796b7423 100644 --- a/src/program/settings/models.py +++ b/src/program/settings/models.py @@ -347,6 +347,17 @@ class NotificationsModel(Observable): on_item_type: List[str] = ["movie", "show", "season"] service_urls: List[str] = [] +class SubliminalConfig(Observable): + enabled: bool = False + languages: List[str] = ["eng"] + # providers: List[str] = ["opensubtitles"] + # min_score: int = 7 + # max_age: int = 30 + # timeout: int = 30 + # ratelimit: bool = True + +class PostProcessing(Observable): + subliminal: SubliminalConfig = SubliminalConfig() class AppModel(Observable): version: str = get_version() @@ -364,6 +375,7 @@ class AppModel(Observable): indexer: IndexerModel = IndexerModel() database: DatabaseModel = DatabaseModel() notifications: NotificationsModel = NotificationsModel() + post_processing: PostProcessing = PostProcessing() def __init__(self, **data: Any): current_version = get_version() diff --git a/src/program/state_transition.py b/src/program/state_transition.py index d8d8143a..b8ebeba2 100644 --- a/src/program/state_transition.py +++ b/src/program/state_transition.py @@ -4,11 +4,14 @@ from program.indexers.trakt import TraktIndexer from program.libraries import SymlinkLibrary from program.media import Episode, MediaItem, Movie, Season, Show, States +from program.post_processing import PostProcessing +from program.post_processing.subliminal import Subliminal from program.scrapers import Scraping from program.symlink import Symlinker from program.types import ProcessedEvent, Service from program.updaters import Updater from utils.logger import logger +from program.settings.manager import settings_manager def process_event(existing_item: MediaItem | None, emitted_by: Service, item: MediaItem) -> ProcessedEvent: @@ -39,11 +42,40 @@ def process_event(existing_item: MediaItem | None, emitted_by: Service, item: Me updated_item = item = existing_item if existing_item.state == States.Completed: return existing_item, None, [] - items_to_submit = [item] if Scraping.can_we_scrape(item) else [] + if item.type in ["movie", "episode"]: + items_to_submit = [item] if Scraping.can_we_scrape(item) else [] + elif item.type == "show": + if Scraping.can_we_scrape(item): + items_to_submit = [item] + else: + for season in item.seasons: + if season.state in [States.Indexed, States.PartiallyCompleted] and Scraping.can_we_scrape(season): + items_to_submit.append(season) + elif season.state == States.Scraped: + next_service = Downloader + items_to_submit.append(season) + elif item.type == "season": + if Scraping.can_we_scrape(item): + items_to_submit = [item] + else: + for episode in item.episodes: + if episode.state in [States.Indexed, States.PartiallyCompleted] and Scraping.can_we_scrape(episode): + items_to_submit.append(episode) + elif episode.state == States.Scraped: + next_service = Downloader + items_to_submit.append(episode) + elif episode.state == States.Downloaded: + next_service = Symlinker + items_to_submit.append(episode) elif item.state == States.Scraped: next_service = Downloader - items_to_submit = [item] + items_to_submit = [] + if item.type == "show": + items_to_submit = [s for s in item.seasons if s.state == States.Downloaded] + if item.type == "season": + items_to_submit = [e for e in item.episodes if e.state == States.Downloaded] + items_to_submit.append(item) elif item.state == States.Downloaded: next_service = Symlinker @@ -80,6 +112,17 @@ def process_event(existing_item: MediaItem | None, emitted_by: Service, item: Me items_to_submit = [item] elif item.state == States.Completed: - return no_further_processing + if settings_manager.settings.post_processing.subliminal.enabled: + next_service = PostProcessing + if item.type in ["movie", "episode"] and Subliminal.should_submit(item): + items_to_submit = [item] + elif item.type == "show": + items_to_submit = [e for s in item.seasons for e in s.episodes if e.state == States.Completed and Subliminal.should_submit(e)] + elif item.type == "season": + items_to_submit = [e for e in item.episodes if e.state == States.Completed and Subliminal.should_submit(e)] + if not items_to_submit: + return no_further_processing + else: + return no_further_processing return updated_item, next_service, items_to_submit \ No newline at end of file diff --git a/src/program/symlink.py b/src/program/symlink.py index 67b6c283..b954b4a1 100644 --- a/src/program/symlink.py +++ b/src/program/symlink.py @@ -25,7 +25,7 @@ class Symlinker: library_path (str): The absolute path of the location we will create our symlinks that point to the rclone_path. """ - def __init__(self, media_items=None): + def __init__(self): self.key = "symlink" self.settings = settings_manager.settings.symlink self.rclone_path = self.settings.rclone_path @@ -88,10 +88,6 @@ def create_initial_folders(self): def run(self, item: Union[Movie, Show, Season, Episode]): """Check if the media item exists and create a symlink if it does""" - if not item: - logger.error("Invalid item sent to Symlinker: None") - return - try: if isinstance(item, Show): self._symlink_show(item) @@ -103,8 +99,7 @@ def run(self, item: Union[Movie, Show, Season, Episode]): logger.error(f"Exception thrown when creating symlink for {item.log_string}: {e}") item.set("symlinked_times", item.symlinked_times + 1) - if self.should_submit(item): - yield item + yield item @staticmethod def should_submit(item: Union[Movie, Show, Season, Episode]) -> bool: @@ -266,6 +261,7 @@ def _symlink(self, item: Union[Movie, Episode]) -> bool: item.set("symlinked", True) item.set("symlinked_at", datetime.now()) item.set("symlinked_times", item.symlinked_times + 1) + item.set("symlink_path", destination) return True def _create_item_folders(self, item: Union[Movie, Show, Season, Episode], filename: str) -> str: @@ -379,17 +375,17 @@ def delete_item_symlinks(self, id: int) -> bool: logger.debug(f"Deleted symlink for {item.log_string}") if isinstance(item, (Movie, Episode)): - reset_symlinked(item, reset_times=True) + item.reset(True) elif isinstance(item, Show): for season in item.seasons: for episode in season.episodes: - reset_symlinked(episode, reset_times=True) - reset_symlinked(season, reset_times=True) - reset_symlinked(item, reset_times=True) + episode.reset(True) + season.reset(True) + item.reset(True) elif isinstance(item, Season): for episode in item.episodes: - reset_symlinked(episode, reset_times=True) - reset_symlinked(item, reset_times=True) + episode.reset(True) + item.reset(True) item.store_state() session.commit() @@ -450,7 +446,7 @@ def quick_file_check(item: Union[Movie, Episode]) -> bool: return True if item.symlinked_times >= 3: - reset_symlinked(item, reset_times=True) + item.reset() logger.log("SYMLINKER", f"Reset item {item.log_string} back to scrapable after 3 failed attempts") return False @@ -475,26 +471,3 @@ def search_file(rclone_path: Path, item: Union[Movie, Episode]) -> bool: except Exception as e: logger.error(f"Error occurred while searching for file {filename} in {rclone_path}: {e}") return False - -def reset_symlinked(item: MediaItem, reset_times: bool = True) -> None: - """Reset item attributes for rescraping.""" - item.set("file", None) - item.set("folder", None) - item.set("alternative_folder", None) - - if hasattr(item, "active_stream") and "hash" in item.active_stream: - hash_to_blacklist = item.active_stream["hash"] - stream: Stream = next((stream for stream in item.streams if stream.infohash == hash_to_blacklist), None) - if stream: - stream.blacklisted = True - - item.set("active_stream", {}) - item.set("symlinked", False) - item.set("symlinked_at", None) - item.set("update_folder", None) - - if reset_times: - item.set("symlinked_times", 0) - item.set("scraped_times", 0) - - logger.debug(f"Item {item.log_string} reset for rescraping") diff --git a/src/utils/cli.py b/src/utils/cli.py new file mode 100644 index 00000000..864797dc --- /dev/null +++ b/src/utils/cli.py @@ -0,0 +1,35 @@ +import argparse +from program.db.db_functions import hard_reset_database +from utils.logger import logger, scrub_logs + +def handle_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--ignore_cache", + action="store_true", + help="Ignore the cached metadata, create new data from scratch.", + ) + parser.add_argument( + "--hard_reset_db", + action="store_true", + help="Hard reset the database.", + ) + parser.add_argument( + "--clean_logs", + action="store_true", + help="Clean old logs.", + ) + + args = parser.parse_args() + + if args.hard_reset_db: + hard_reset_database() + logger.info("Hard reset the database") + exit(0) + + if args.clean_logs: + scrub_logs() + logger.info("Cleaned old logs.") + exit(0) + + return args \ No newline at end of file diff --git a/src/utils/logger.py b/src/utils/logger.py index 93c3bd3c..65ff6f5b 100644 --- a/src/utils/logger.py +++ b/src/utils/logger.py @@ -1,5 +1,6 @@ """Logging utils""" +import asyncio import os import sys from datetime import datetime @@ -8,6 +9,7 @@ from program.settings.manager import settings_manager from rich.console import Console from utils import data_dir_path +from controllers.ws import manager LOG_ENABLED: bool = settings_manager.settings.log @@ -98,7 +100,16 @@ def get_log_settings(name, default_color, default_icon): "backtrace": False, "diagnose": True, "enqueue": True, - } + }, + # maybe later + # { + # "sink": manager.send_log_message, + # "level": level.upper() or "INFO", + # "format": log_format, + # "backtrace": False, + # "diagnose": False, + # "enqueue": True, + # } ])