diff --git a/.cache/media_dates.db b/.cache/media_dates.db index 76df7bd..4c65604 100644 Binary files a/.cache/media_dates.db and b/.cache/media_dates.db differ diff --git a/.env b/.env index 70552e8..fcdf533 100644 --- a/.env +++ b/.env @@ -20,7 +20,11 @@ GOTIFY_URL=https://gotify.kickthetree.com GOTIFY_TOKEN=ALI5WTYMaWtJh-s -TMDB_API_KEY=ffbbddbf30b1b2b22194f6eaff2bf48c -TMDB_PRIMARY_COUNTRY=US -TMDB_TIMEOUT=45 -TMDB_RETRIES=3 +#TMDB_API_KEY=ffbbddbf30b1b2b22194f6eaff2bf48c +#TMDB_PRIMARY_COUNTRY=US +#TMDB_TIMEOUT=45 +#TMDB_RETRIES=3 + + +#OMDB_API_KEY=7566f5e +TVDB_API_KEY=f34ed64b-558e-41e9-81b5-28e6cbd2806a diff --git a/.venv/lib/python3.12/site-packages/certifi/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/certifi/__pycache__/__init__.cpython-312.pyc index b634540..b8fc279 100644 Binary files a/.venv/lib/python3.12/site-packages/certifi/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/certifi/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/certifi/__pycache__/core.cpython-312.pyc b/.venv/lib/python3.12/site-packages/certifi/__pycache__/core.cpython-312.pyc index 9868a56..6a7f135 100644 Binary files a/.venv/lib/python3.12/site-packages/certifi/__pycache__/core.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/certifi/__pycache__/core.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/__init__.cpython-312.pyc index f084e99..3369e79 100644 Binary files a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/api.cpython-312.pyc b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/api.cpython-312.pyc index 5fea437..a1f8129 100644 Binary files a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/api.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/api.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/cd.cpython-312.pyc b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/cd.cpython-312.pyc index 75bd07a..07b8f02 100644 Binary files a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/cd.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/cd.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/constant.cpython-312.pyc b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/constant.cpython-312.pyc index 5c08b14..fe851ac 100644 Binary files a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/constant.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/constant.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/legacy.cpython-312.pyc b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/legacy.cpython-312.pyc index eb40a37..035d017 100644 Binary files a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/legacy.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/legacy.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/models.cpython-312.pyc b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/models.cpython-312.pyc index 1f38629..407dbbf 100644 Binary files a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/models.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/models.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/utils.cpython-312.pyc b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/utils.cpython-312.pyc index 118fb5d..38e5450 100644 Binary files a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/utils.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/utils.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/version.cpython-312.pyc b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/version.cpython-312.pyc index 3be46a0..b500738 100644 Binary files a/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/version.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/charset_normalizer/__pycache__/version.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/dotenv/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/dotenv/__pycache__/__init__.cpython-312.pyc index e7fd7f5..10076b9 100644 Binary files a/.venv/lib/python3.12/site-packages/dotenv/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/dotenv/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/dotenv/__pycache__/main.cpython-312.pyc b/.venv/lib/python3.12/site-packages/dotenv/__pycache__/main.cpython-312.pyc index 018bb8e..f28a9ae 100644 Binary files a/.venv/lib/python3.12/site-packages/dotenv/__pycache__/main.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/dotenv/__pycache__/main.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/dotenv/__pycache__/parser.cpython-312.pyc b/.venv/lib/python3.12/site-packages/dotenv/__pycache__/parser.cpython-312.pyc index d9a6720..d46d5ef 100644 Binary files a/.venv/lib/python3.12/site-packages/dotenv/__pycache__/parser.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/dotenv/__pycache__/parser.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/dotenv/__pycache__/variables.cpython-312.pyc b/.venv/lib/python3.12/site-packages/dotenv/__pycache__/variables.cpython-312.pyc index e8e1fbf..f418c97 100644 Binary files a/.venv/lib/python3.12/site-packages/dotenv/__pycache__/variables.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/dotenv/__pycache__/variables.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/idna/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/idna/__pycache__/__init__.cpython-312.pyc index 5ce0756..c6a297c 100644 Binary files a/.venv/lib/python3.12/site-packages/idna/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/idna/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/idna/__pycache__/core.cpython-312.pyc b/.venv/lib/python3.12/site-packages/idna/__pycache__/core.cpython-312.pyc index a4bb146..8add996 100644 Binary files a/.venv/lib/python3.12/site-packages/idna/__pycache__/core.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/idna/__pycache__/core.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/idna/__pycache__/idnadata.cpython-312.pyc b/.venv/lib/python3.12/site-packages/idna/__pycache__/idnadata.cpython-312.pyc index c845d1e..cd1ef74 100644 Binary files a/.venv/lib/python3.12/site-packages/idna/__pycache__/idnadata.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/idna/__pycache__/idnadata.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/idna/__pycache__/intranges.cpython-312.pyc b/.venv/lib/python3.12/site-packages/idna/__pycache__/intranges.cpython-312.pyc index be2c77e..92b73de 100644 Binary files a/.venv/lib/python3.12/site-packages/idna/__pycache__/intranges.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/idna/__pycache__/intranges.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/idna/__pycache__/package_data.cpython-312.pyc b/.venv/lib/python3.12/site-packages/idna/__pycache__/package_data.cpython-312.pyc index a6dc505..2e0d0a4 100644 Binary files a/.venv/lib/python3.12/site-packages/idna/__pycache__/package_data.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/idna/__pycache__/package_data.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/__pycache__/__init__.cpython-312.pyc index 4059d1b..051beb2 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/__init__.cpython-312.pyc index 894d659..327969a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/build_env.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/build_env.cpython-312.pyc index b99edd6..ec8dcb1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/build_env.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/build_env.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/cache.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/cache.cpython-312.pyc index 22ac49c..1a6fa8c 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/cache.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/cache.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/configuration.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/configuration.cpython-312.pyc index 3aa5374..297a8c6 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/configuration.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/configuration.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/exceptions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/exceptions.cpython-312.pyc index cafecd7..384704e 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/exceptions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/exceptions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/pyproject.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/pyproject.cpython-312.pyc index 92b7c22..6537278 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/pyproject.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/pyproject.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/self_outdated_check.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/self_outdated_check.cpython-312.pyc index bf74b8d..6212cec 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/self_outdated_check.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/self_outdated_check.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/wheel_builder.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/wheel_builder.cpython-312.pyc index 949b4e9..db0522e 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/wheel_builder.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/__pycache__/wheel_builder.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/__init__.cpython-312.pyc index ad6647c..55e36e0 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/autocompletion.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/autocompletion.cpython-312.pyc index 24fc0a5..d2182f1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/autocompletion.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/autocompletion.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/base_command.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/base_command.cpython-312.pyc index c226a50..42b945c 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/base_command.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/base_command.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/cmdoptions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/cmdoptions.cpython-312.pyc index 6724065..cd58117 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/cmdoptions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/cmdoptions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/command_context.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/command_context.cpython-312.pyc index 4c9e36a..9fac4c4 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/command_context.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/command_context.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/index_command.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/index_command.cpython-312.pyc index b3cde53..3e98e61 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/index_command.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/index_command.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/main.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/main.cpython-312.pyc index f59e10c..d08a365 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/main.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/main.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/main_parser.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/main_parser.cpython-312.pyc index eda5ea1..2e07e96 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/main_parser.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/main_parser.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/parser.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/parser.cpython-312.pyc index 1ab1777..86b5228 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/parser.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/parser.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/progress_bars.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/progress_bars.cpython-312.pyc index 97c9825..05ff9bf 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/progress_bars.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/progress_bars.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/req_command.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/req_command.cpython-312.pyc index d24009a..f7d28e5 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/req_command.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/req_command.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/spinners.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/spinners.cpython-312.pyc index 87468f3..e1d1c01 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/spinners.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/spinners.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/status_codes.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/status_codes.cpython-312.pyc index b628859..654d923 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/status_codes.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/cli/__pycache__/status_codes.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/commands/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/commands/__pycache__/__init__.cpython-312.pyc index 3e46e68..67f4329 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/commands/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/commands/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/commands/__pycache__/install.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/commands/__pycache__/install.cpython-312.pyc index 449bc9d..44d1ec4 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/commands/__pycache__/install.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/commands/__pycache__/install.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/__init__.cpython-312.pyc index fcf839e..ba4d35a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/base.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/base.cpython-312.pyc index 84fe747..af2dac2 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/base.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/base.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/installed.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/installed.cpython-312.pyc index d5adf80..5aed3f0 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/installed.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/installed.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/sdist.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/sdist.cpython-312.pyc index 211f935..201b9f3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/sdist.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/sdist.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/wheel.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/wheel.cpython-312.pyc index 5635e6d..baf8897 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/wheel.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/distributions/__pycache__/wheel.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/__init__.cpython-312.pyc index b5c47cb..48960e1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/collector.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/collector.cpython-312.pyc index 2d521a4..aada6f5 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/collector.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/collector.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/package_finder.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/package_finder.cpython-312.pyc index be920e1..5d64cda 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/package_finder.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/package_finder.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/sources.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/sources.cpython-312.pyc index ed90b53..556fb90 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/sources.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/index/__pycache__/sources.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/__init__.cpython-312.pyc index ce2b20a..8b8e219 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/_sysconfig.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/_sysconfig.cpython-312.pyc index 8b3cb6e..1feb2e0 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/_sysconfig.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/_sysconfig.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/base.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/base.cpython-312.pyc index 8386808..020215a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/base.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/locations/__pycache__/base.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/__init__.cpython-312.pyc index a41da6a..402c0b1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/_json.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/_json.cpython-312.pyc index 1e4ebf8..9cff87f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/_json.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/_json.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/base.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/base.cpython-312.pyc index c7497d0..fa9aaa4 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/base.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/__pycache__/base.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/__init__.cpython-312.pyc index 25ffda4..76ce8be 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_compat.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_compat.cpython-312.pyc index 23e1562..4c8bc1f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_compat.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_compat.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_dists.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_dists.cpython-312.pyc index 8f9e559..be2edfa 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_dists.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_dists.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_envs.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_envs.cpython-312.pyc index e6264bd..22840e3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_envs.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/metadata/importlib/__pycache__/_envs.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/__init__.cpython-312.pyc index 448a2d9..d6c1a81 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/candidate.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/candidate.cpython-312.pyc index 4e3310c..2c63c8c 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/candidate.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/candidate.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/direct_url.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/direct_url.cpython-312.pyc index b7a0931..fe011b1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/direct_url.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/direct_url.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/format_control.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/format_control.cpython-312.pyc index 7a0da89..0030f45 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/format_control.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/format_control.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/index.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/index.cpython-312.pyc index af9135d..6d51577 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/index.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/index.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/installation_report.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/installation_report.cpython-312.pyc index 4177ced..c14ea2a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/installation_report.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/installation_report.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/link.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/link.cpython-312.pyc index c7f43e5..70cc31a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/link.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/link.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/scheme.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/scheme.cpython-312.pyc index 1d75391..ae0e9ec 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/scheme.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/scheme.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/search_scope.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/search_scope.cpython-312.pyc index ea82758..0f348f3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/search_scope.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/search_scope.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/selection_prefs.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/selection_prefs.cpython-312.pyc index 62ba26d..fc2b88d 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/selection_prefs.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/selection_prefs.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/target_python.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/target_python.cpython-312.pyc index 8f2ee62..8b5170b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/target_python.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/target_python.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/wheel.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/wheel.cpython-312.pyc index 8c0744a..1eddd53 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/wheel.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/models/__pycache__/wheel.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/__init__.cpython-312.pyc index e903153..a2694a7 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/auth.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/auth.cpython-312.pyc index 3efc25a..2dd8331 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/auth.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/auth.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/cache.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/cache.cpython-312.pyc index 857322b..8e29e23 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/cache.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/cache.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/download.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/download.cpython-312.pyc index a1001d2..038f2c2 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/download.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/download.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/lazy_wheel.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/lazy_wheel.cpython-312.pyc index b2fe715..f0d106b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/lazy_wheel.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/lazy_wheel.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/session.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/session.cpython-312.pyc index c4d52b8..553a1bd 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/session.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/session.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/utils.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/utils.cpython-312.pyc index 8ef4b97..81b051a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/utils.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/network/__pycache__/utils.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/__init__.cpython-312.pyc index c86ef8d..b6dfdfa 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/check.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/check.cpython-312.pyc index 9052e27..211f0b8 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/check.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/check.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/prepare.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/prepare.cpython-312.pyc index 0c6e8ad..b70629e 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/prepare.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/__pycache__/prepare.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/__init__.cpython-312.pyc index 3b8483f..622f357 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/build_tracker.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/build_tracker.cpython-312.pyc index 485d3c9..b821048 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/build_tracker.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/build_tracker.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata.cpython-312.pyc index de8f85f..c092a17 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata_editable.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata_editable.cpython-312.pyc index 6829d80..b4b3d1a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata_editable.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata_editable.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata_legacy.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata_legacy.cpython-312.pyc index 89bbb70..d27cbb6 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata_legacy.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/metadata_legacy.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel.cpython-312.pyc index 7af846b..72e8c33 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel_editable.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel_editable.cpython-312.pyc index 80e1e79..5c4a49c 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel_editable.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel_editable.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel_legacy.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel_legacy.cpython-312.pyc index 83b1d1a..f91f6e3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel_legacy.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/build/__pycache__/wheel_legacy.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/__init__.cpython-312.pyc index f6bc284..462c4f0 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/editable_legacy.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/editable_legacy.cpython-312.pyc index 30f1e17..c36ea6b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/editable_legacy.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/editable_legacy.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/wheel.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/wheel.cpython-312.pyc index 9186df9..79f9cb9 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/wheel.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/operations/install/__pycache__/wheel.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/__init__.cpython-312.pyc index 14a8dd9..9c470e7 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/constructors.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/constructors.cpython-312.pyc index a1558b6..35c2b4d 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/constructors.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/constructors.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_dependency_group.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_dependency_group.cpython-312.pyc index 58d0ea4..baa69b7 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_dependency_group.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_dependency_group.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_file.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_file.cpython-312.pyc index bfb7386..b450916 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_file.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_file.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_install.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_install.cpython-312.pyc index 64fd63d..87a8e20 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_install.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_install.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_set.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_set.cpython-312.pyc index 43610e8..56a66d5 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_set.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_set.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_uninstall.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_uninstall.cpython-312.pyc index 83f3d47..e78c3ab 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_uninstall.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/req/__pycache__/req_uninstall.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/__pycache__/__init__.cpython-312.pyc index 14948f9..b0f4635 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/__pycache__/base.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/__pycache__/base.cpython-312.pyc index 0cbffaf..416fad2 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/__pycache__/base.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/__pycache__/base.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/__init__.cpython-312.pyc index a556be7..4220f0d 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/base.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/base.cpython-312.pyc index 0b7fa25..31ee37f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/base.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/base.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/candidates.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/candidates.cpython-312.pyc index 53b9951..92262c5 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/candidates.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/candidates.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/factory.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/factory.cpython-312.pyc index 520b351..3f8f87c 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/factory.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/factory.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/found_candidates.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/found_candidates.cpython-312.pyc index 63bdb61..5753405 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/found_candidates.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/found_candidates.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/provider.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/provider.cpython-312.pyc index 884c32f..707e473 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/provider.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/provider.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/reporter.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/reporter.cpython-312.pyc index 9ea5f59..652a2b3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/reporter.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/reporter.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/requirements.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/requirements.cpython-312.pyc index 6f12951..0cc7a53 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/requirements.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/requirements.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/resolver.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/resolver.cpython-312.pyc index e5617dc..9c1abef 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/resolver.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/resolution/resolvelib/__pycache__/resolver.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/__init__.cpython-312.pyc index a117671..f558b4d 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/_log.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/_log.cpython-312.pyc index 6425ab6..dd0e52b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/_log.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/_log.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/appdirs.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/appdirs.cpython-312.pyc index 5208c64..4fe1e7f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/appdirs.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/appdirs.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/compat.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/compat.cpython-312.pyc index 891cdb0..37dfb0f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/compat.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/compat.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/compatibility_tags.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/compatibility_tags.cpython-312.pyc index 67026c6..502ef9d 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/compatibility_tags.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/compatibility_tags.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/deprecation.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/deprecation.cpython-312.pyc index fedc3f7..23c21be 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/deprecation.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/deprecation.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/direct_url_helpers.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/direct_url_helpers.cpython-312.pyc index 4e9ab50..3c99187 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/direct_url_helpers.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/direct_url_helpers.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/egg_link.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/egg_link.cpython-312.pyc index 619b859..7b9251f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/egg_link.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/egg_link.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/entrypoints.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/entrypoints.cpython-312.pyc index 5a0e66f..b160295 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/entrypoints.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/entrypoints.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/filesystem.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/filesystem.cpython-312.pyc index 02bc3a3..8db5121 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/filesystem.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/filesystem.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/filetypes.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/filetypes.cpython-312.pyc index 6250f32..6776fc9 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/filetypes.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/filetypes.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/glibc.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/glibc.cpython-312.pyc index c851b66..67754e9 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/glibc.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/glibc.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/hashes.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/hashes.cpython-312.pyc index f7c8e2e..500ddf0 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/hashes.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/hashes.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/logging.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/logging.cpython-312.pyc index f77c10e..1412325 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/logging.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/logging.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/misc.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/misc.cpython-312.pyc index aed4565..48d1568 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/misc.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/misc.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/packaging.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/packaging.cpython-312.pyc index 386ce3b..a70ed41 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/packaging.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/packaging.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/retry.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/retry.cpython-312.pyc index 62a1353..0bae883 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/retry.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/retry.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/setuptools_build.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/setuptools_build.cpython-312.pyc index cd3ac9a..0a9e7fe 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/setuptools_build.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/setuptools_build.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/subprocess.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/subprocess.cpython-312.pyc index eb78a3d..0fe409b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/subprocess.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/subprocess.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/temp_dir.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/temp_dir.cpython-312.pyc index 3b197e2..e78ca2b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/temp_dir.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/temp_dir.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/unpacking.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/unpacking.cpython-312.pyc index 27b03d6..b4adce3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/unpacking.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/unpacking.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/urls.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/urls.cpython-312.pyc index 9babf4a..fd9e75f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/urls.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/urls.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/virtualenv.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/virtualenv.cpython-312.pyc index 006069b..52579cd 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/virtualenv.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/virtualenv.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/wheel.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/wheel.cpython-312.pyc index 270cbc8..010558e 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/wheel.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/utils/__pycache__/wheel.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/__init__.cpython-312.pyc index ce773a7..b78e6f8 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/bazaar.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/bazaar.cpython-312.pyc index 7404de8..ffa6326 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/bazaar.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/bazaar.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/git.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/git.cpython-312.pyc index eb4a622..ea92bbd 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/git.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/git.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/mercurial.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/mercurial.cpython-312.pyc index dc15a71..2afa666 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/mercurial.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/mercurial.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/subversion.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/subversion.cpython-312.pyc index d1aefd3..9e83765 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/subversion.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/subversion.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/versioncontrol.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/versioncontrol.cpython-312.pyc index 2820f08..eb77452 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/versioncontrol.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_internal/vcs/__pycache__/versioncontrol.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/__pycache__/__init__.cpython-312.pyc index f63b83a..e32b142 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/__init__.cpython-312.pyc index 708a889..7514857 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/adapter.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/adapter.cpython-312.pyc index 724df2c..b13261e 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/adapter.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/adapter.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/cache.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/cache.cpython-312.pyc index af175fa..c4f5e47 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/cache.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/cache.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/controller.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/controller.cpython-312.pyc index abf3d33..dbaa498 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/controller.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/controller.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/filewrapper.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/filewrapper.cpython-312.pyc index cbd72b8..30d12a2 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/filewrapper.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/filewrapper.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/serialize.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/serialize.cpython-312.pyc index 72907bc..8d21949 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/serialize.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/serialize.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/wrapper.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/wrapper.cpython-312.pyc index 07df6f8..99feea6 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/wrapper.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/__pycache__/wrapper.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/__init__.cpython-312.pyc index 1ea0918..bbff763 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/file_cache.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/file_cache.cpython-312.pyc index 52f9d87..d626089 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/file_cache.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/file_cache.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/redis_cache.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/redis_cache.cpython-312.pyc index 099e390..59855ea 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/redis_cache.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/cachecontrol/caches/__pycache__/redis_cache.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/certifi/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/certifi/__pycache__/__init__.cpython-312.pyc index 0e3c6e7..ece7955 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/certifi/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/certifi/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/certifi/__pycache__/core.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/certifi/__pycache__/core.cpython-312.pyc index 2643613..1b64e52 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/certifi/__pycache__/core.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/certifi/__pycache__/core.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/dependency_groups/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/dependency_groups/__pycache__/__init__.cpython-312.pyc index 1bd42ae..adf6fec 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/dependency_groups/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/dependency_groups/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/dependency_groups/__pycache__/_implementation.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/dependency_groups/__pycache__/_implementation.cpython-312.pyc index 685eb7a..8ea1376 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/dependency_groups/__pycache__/_implementation.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/dependency_groups/__pycache__/_implementation.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/__init__.cpython-312.pyc index ac91e8a..5c26820 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/compat.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/compat.cpython-312.pyc index 023757d..bab38ea 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/compat.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/compat.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/resources.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/resources.cpython-312.pyc index 4f8bec1..4d0c101 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/resources.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/resources.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/scripts.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/scripts.cpython-312.pyc index 4ffaa5f..748fd24 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/scripts.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/scripts.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/util.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/util.cpython-312.pyc index 534aaef..791b0fa 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/util.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/distlib/__pycache__/util.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/distro/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/distro/__pycache__/__init__.cpython-312.pyc index 7391a7b..d7a9b05 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/distro/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/distro/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/distro/__pycache__/distro.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/distro/__pycache__/distro.cpython-312.pyc index 52a8694..1317838 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/distro/__pycache__/distro.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/distro/__pycache__/distro.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/__init__.cpython-312.pyc index 99b39d4..840a13d 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/core.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/core.cpython-312.pyc index 54f8510..a5577d0 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/core.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/core.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/idnadata.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/idnadata.cpython-312.pyc index b83f953..ef3bc0e 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/idnadata.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/idnadata.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/intranges.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/intranges.cpython-312.pyc index f120a7a..1f8920c 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/intranges.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/intranges.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/package_data.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/package_data.cpython-312.pyc index 2b53f76..e965823 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/package_data.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/idna/__pycache__/package_data.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/__init__.cpython-312.pyc index da5bc7e..767b9b2 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/exceptions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/exceptions.cpython-312.pyc index 34526cc..b17241f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/exceptions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/exceptions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/ext.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/ext.cpython-312.pyc index 57479d3..9e6f523 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/ext.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/ext.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/fallback.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/fallback.cpython-312.pyc index 94ebfc4..812a94b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/fallback.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/msgpack/__pycache__/fallback.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/__init__.cpython-312.pyc index 127edd2..9b25277 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_elffile.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_elffile.cpython-312.pyc index 74ffd8a..fcce866 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_elffile.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_elffile.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_manylinux.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_manylinux.cpython-312.pyc index ed1061e..bc1ad4b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_manylinux.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_manylinux.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_musllinux.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_musllinux.cpython-312.pyc index efd1629..322de78 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_musllinux.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_musllinux.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_parser.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_parser.cpython-312.pyc index 6b0e878..d79c544 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_parser.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_parser.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_structures.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_structures.cpython-312.pyc index 1f869fa..3e76b86 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_structures.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_structures.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_tokenizer.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_tokenizer.cpython-312.pyc index d146f58..56101bb 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_tokenizer.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/_tokenizer.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/markers.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/markers.cpython-312.pyc index db99612..fc1f5f9 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/markers.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/markers.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/requirements.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/requirements.cpython-312.pyc index 34cb758..3428f33 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/requirements.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/requirements.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/specifiers.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/specifiers.cpython-312.pyc index a4dfebb..2013ba1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/specifiers.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/specifiers.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/tags.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/tags.cpython-312.pyc index 6c50e5b..30cc7cc 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/tags.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/tags.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/utils.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/utils.cpython-312.pyc index e8c3e6b..2a850b2 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/utils.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/utils.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/version.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/version.cpython-312.pyc index 2ea22b8..4fb2db7 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/version.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/packaging/__pycache__/version.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/__init__.cpython-312.pyc index d7f5602..d315f0b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/api.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/api.cpython-312.pyc index 48aab34..c47f268 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/api.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/api.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/unix.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/unix.cpython-312.pyc index 631a885..983cf12 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/unix.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/unix.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/version.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/version.cpython-312.pyc index effadfb..94ea65f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/version.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/platformdirs/__pycache__/version.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/__init__.cpython-312.pyc index 1965e7b..00f1012 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/filter.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/filter.cpython-312.pyc index c362571..d9da622 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/filter.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/filter.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/lexer.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/lexer.cpython-312.pyc index 062c459..7d27c3a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/lexer.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/lexer.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/modeline.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/modeline.cpython-312.pyc index 41cccc0..cf0cc61 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/modeline.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/modeline.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/plugin.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/plugin.cpython-312.pyc index 2a88acd..481f59c 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/plugin.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/plugin.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/regexopt.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/regexopt.cpython-312.pyc index 8a3f761..36e2ae2 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/regexopt.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/regexopt.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/style.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/style.cpython-312.pyc index 3865c2d..bb71ec7 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/style.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/style.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/token.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/token.cpython-312.pyc index 7be51ac..af5c349 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/token.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/token.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/util.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/util.cpython-312.pyc index b8eb5fa..59dda7f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/util.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/__pycache__/util.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/filters/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/filters/__pycache__/__init__.cpython-312.pyc index 1853e01..7f2de29 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/filters/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/filters/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/lexers/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/lexers/__pycache__/__init__.cpython-312.pyc index 58ea44f..1974689 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/lexers/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/lexers/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/lexers/__pycache__/_mapping.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/lexers/__pycache__/_mapping.cpython-312.pyc index 2fb71cc..e6d858b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/lexers/__pycache__/_mapping.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/lexers/__pycache__/_mapping.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/styles/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/styles/__pycache__/__init__.cpython-312.pyc index ece7bcf..b4011cd 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/styles/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/styles/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/styles/__pycache__/_mapping.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/styles/__pycache__/_mapping.cpython-312.pyc index 47bc20a..deb0dd1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/styles/__pycache__/_mapping.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pygments/styles/__pycache__/_mapping.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/__pycache__/__init__.cpython-312.pyc index d6d03a3..12e3dae 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/__pycache__/_impl.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/__pycache__/_impl.cpython-312.pyc index c83c49c..0aed764 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/__pycache__/_impl.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/__pycache__/_impl.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/__pycache__/__init__.cpython-312.pyc index a53e546..180e5ac 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/__init__.cpython-312.pyc index 1c19aeb..d475c0f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/__version__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/__version__.cpython-312.pyc index c992b61..3e5e758 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/__version__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/__version__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/_internal_utils.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/_internal_utils.cpython-312.pyc index f0ac374..955e110 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/_internal_utils.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/_internal_utils.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/adapters.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/adapters.cpython-312.pyc index d085c89..754f6ea 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/adapters.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/adapters.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/api.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/api.cpython-312.pyc index 6a7c55f..4567401 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/api.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/api.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/auth.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/auth.cpython-312.pyc index 6f2c802..814ea65 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/auth.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/auth.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/certs.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/certs.cpython-312.pyc index c670313..c0d4034 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/certs.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/certs.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/compat.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/compat.cpython-312.pyc index fb14101..02d9adc 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/compat.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/compat.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/cookies.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/cookies.cpython-312.pyc index b50c547..0bc0443 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/cookies.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/cookies.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/exceptions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/exceptions.cpython-312.pyc index be6080c..6deeac6 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/exceptions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/exceptions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/hooks.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/hooks.cpython-312.pyc index e79fd1a..b66a0de 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/hooks.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/hooks.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/models.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/models.cpython-312.pyc index 4390387..c6edb90 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/models.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/models.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/packages.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/packages.cpython-312.pyc index 58a39a7..759c6a0 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/packages.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/packages.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/sessions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/sessions.cpython-312.pyc index 7606d83..a33edce 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/sessions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/sessions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/status_codes.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/status_codes.cpython-312.pyc index 6ecab00..02cc82b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/status_codes.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/status_codes.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/structures.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/structures.cpython-312.pyc index 46c269a..1ee76cf 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/structures.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/structures.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/utils.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/utils.cpython-312.pyc index d41b6e5..17acb01 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/utils.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/requests/__pycache__/utils.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/__init__.cpython-312.pyc index bcac458..91c0fd5 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/providers.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/providers.cpython-312.pyc index fdaf179..702c3db 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/providers.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/providers.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/reporters.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/reporters.cpython-312.pyc index 3692d9e..9443fe8 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/reporters.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/reporters.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/structs.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/structs.cpython-312.pyc index d3c8a14..d1c3f80 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/structs.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/__pycache__/structs.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/__init__.cpython-312.pyc index fee11b6..6946213 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/abstract.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/abstract.cpython-312.pyc index 915b96b..d928c56 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/abstract.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/abstract.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/criterion.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/criterion.cpython-312.pyc index 6cf64a3..eb08f97 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/criterion.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/criterion.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/exceptions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/exceptions.cpython-312.pyc index e123d45..52772a1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/exceptions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/exceptions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/resolution.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/resolution.cpython-312.pyc index 50576bf..1589f98 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/resolution.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/resolvelib/resolvers/__pycache__/resolution.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/__init__.cpython-312.pyc index 84b39ef..1fa2d61 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_cell_widths.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_cell_widths.cpython-312.pyc index 9386d8d..c3543ba 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_cell_widths.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_cell_widths.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_emoji_codes.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_emoji_codes.cpython-312.pyc index 0795421..bf74f77 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_emoji_codes.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_emoji_codes.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_emoji_replace.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_emoji_replace.cpython-312.pyc index 6cca90a..0de6d79 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_emoji_replace.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_emoji_replace.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_export_format.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_export_format.cpython-312.pyc index 9e88aa2..71324e3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_export_format.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_export_format.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_extension.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_extension.cpython-312.pyc index eaa0151..ffa0b16 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_extension.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_extension.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_fileno.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_fileno.cpython-312.pyc index d00623e..354528f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_fileno.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_fileno.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_log_render.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_log_render.cpython-312.pyc index 5a21ea6..6e8c503 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_log_render.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_log_render.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_loop.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_loop.cpython-312.pyc index ea71216..080b7ea 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_loop.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_loop.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_null_file.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_null_file.cpython-312.pyc index 3fd3aef..bb5627a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_null_file.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_null_file.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_palettes.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_palettes.cpython-312.pyc index cb01a54..3c65e6f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_palettes.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_palettes.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_pick.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_pick.cpython-312.pyc index d1fa478..dec1364 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_pick.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_pick.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_ratio.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_ratio.cpython-312.pyc index 81eb24b..58cc72e 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_ratio.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_ratio.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_spinners.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_spinners.cpython-312.pyc index 7a1d230..30ca0bb 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_spinners.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_spinners.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_wrap.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_wrap.cpython-312.pyc index 85c285f..1cce21e 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_wrap.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/_wrap.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/abc.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/abc.cpython-312.pyc index 6336b8e..f454450 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/abc.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/abc.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/align.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/align.cpython-312.pyc index 05ca7e2..cca112b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/align.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/align.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/ansi.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/ansi.cpython-312.pyc index 6e2f511..4a8306a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/ansi.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/ansi.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/box.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/box.cpython-312.pyc index 60c4555..8650c75 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/box.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/box.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/cells.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/cells.cpython-312.pyc index dee27a6..479620f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/cells.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/cells.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/color.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/color.cpython-312.pyc index 6d29ee8..faaf8b5 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/color.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/color.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/color_triplet.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/color_triplet.cpython-312.pyc index b7c39a3..f4447f4 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/color_triplet.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/color_triplet.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/columns.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/columns.cpython-312.pyc index 2404bc4..186dfde 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/columns.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/columns.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/console.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/console.cpython-312.pyc index 36ae067..f5cd11d 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/console.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/console.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/constrain.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/constrain.cpython-312.pyc index 208df4b..bc32235 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/constrain.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/constrain.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/containers.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/containers.cpython-312.pyc index 4114f67..eda10be 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/containers.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/containers.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/control.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/control.cpython-312.pyc index 9d661bf..de437db 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/control.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/control.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/default_styles.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/default_styles.cpython-312.pyc index c8a6a39..41cfde1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/default_styles.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/default_styles.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/emoji.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/emoji.cpython-312.pyc index c8536c9..7547cab 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/emoji.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/emoji.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/errors.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/errors.cpython-312.pyc index b02210b..2414a09 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/errors.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/errors.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/file_proxy.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/file_proxy.cpython-312.pyc index 899d6f7..7f9f228 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/file_proxy.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/file_proxy.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/filesize.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/filesize.cpython-312.pyc index c8bb29c..92283b9 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/filesize.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/filesize.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/highlighter.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/highlighter.cpython-312.pyc index 87ab585..ec63740 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/highlighter.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/highlighter.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/jupyter.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/jupyter.cpython-312.pyc index 5b27b7f..25c3991 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/jupyter.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/jupyter.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/live.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/live.cpython-312.pyc index 55c43a6..0e3deca 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/live.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/live.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/live_render.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/live_render.cpython-312.pyc index 3f1407d..eb05efa 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/live_render.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/live_render.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/logging.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/logging.cpython-312.pyc index 4b5787d..70db994 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/logging.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/logging.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/markup.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/markup.cpython-312.pyc index 0e8b99e..f9a93e1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/markup.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/markup.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/measure.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/measure.cpython-312.pyc index 38b9480..0c629da 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/measure.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/measure.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/padding.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/padding.cpython-312.pyc index 9a6e44c..8927d27 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/padding.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/padding.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/pager.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/pager.cpython-312.pyc index 9d643a2..c7cef39 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/pager.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/pager.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/palette.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/palette.cpython-312.pyc index 4084010..fc22625 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/palette.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/palette.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/panel.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/panel.cpython-312.pyc index 52fb1a0..155d2bf 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/panel.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/panel.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/pretty.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/pretty.cpython-312.pyc index 5efba0b..d305a09 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/pretty.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/pretty.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/progress.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/progress.cpython-312.pyc index 633ff93..94fb01b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/progress.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/progress.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/progress_bar.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/progress_bar.cpython-312.pyc index 9b4ce9a..e7ed186 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/progress_bar.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/progress_bar.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/protocol.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/protocol.cpython-312.pyc index f7ad1af..fb1c827 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/protocol.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/protocol.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/region.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/region.cpython-312.pyc index 3ee3d63..cd63263 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/region.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/region.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/repr.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/repr.cpython-312.pyc index 394fcd1..e07eff6 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/repr.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/repr.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/scope.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/scope.cpython-312.pyc index 23d85d9..c21b399 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/scope.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/scope.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/screen.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/screen.cpython-312.pyc index ff430da..b660528 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/screen.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/screen.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/segment.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/segment.cpython-312.pyc index 0484db0..5523ea9 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/segment.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/segment.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/spinner.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/spinner.cpython-312.pyc index ff985d3..5caa4ff 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/spinner.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/spinner.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/style.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/style.cpython-312.pyc index f34de66..e9ad78f 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/style.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/style.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/styled.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/styled.cpython-312.pyc index f376c09..f410a47 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/styled.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/styled.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/syntax.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/syntax.cpython-312.pyc index 5d6fb9c..550e365 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/syntax.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/syntax.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/table.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/table.cpython-312.pyc index 7e6db68..47972bd 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/table.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/table.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/terminal_theme.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/terminal_theme.cpython-312.pyc index 7c8ef1a..f07e78e 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/terminal_theme.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/terminal_theme.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/text.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/text.cpython-312.pyc index 79480e6..652a2ee 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/text.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/text.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/theme.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/theme.cpython-312.pyc index f7d4e55..67f5922 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/theme.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/theme.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/themes.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/themes.cpython-312.pyc index 3fd3936..a78c714 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/themes.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/themes.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/traceback.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/traceback.cpython-312.pyc index f96e830..78db594 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/traceback.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/rich/__pycache__/traceback.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/__init__.cpython-312.pyc index 95b17be..d363c99 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_api.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_api.cpython-312.pyc index a693d41..76cd557 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_api.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_api.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_openssl.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_openssl.cpython-312.pyc index 32e88c2..ca7cd12 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_openssl.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_openssl.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_ssl_constants.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_ssl_constants.cpython-312.pyc index 4c80c0b..3f70fd3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_ssl_constants.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/truststore/__pycache__/_ssl_constants.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/__init__.cpython-312.pyc index 6e5cf6a..194c0e7 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/_collections.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/_collections.cpython-312.pyc index 91cf45c..9e30630 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/_collections.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/_collections.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/_version.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/_version.cpython-312.pyc index a78851c..34755cb 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/_version.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/_version.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/connection.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/connection.cpython-312.pyc index 25d21f4..81e20b4 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/connection.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/connection.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/connectionpool.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/connectionpool.cpython-312.pyc index 95a60df..9f1bcc3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/connectionpool.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/connectionpool.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/exceptions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/exceptions.cpython-312.pyc index b69c5e0..51380dd 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/exceptions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/exceptions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/fields.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/fields.cpython-312.pyc index 917fa52..61f14b0 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/fields.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/fields.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/filepost.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/filepost.cpython-312.pyc index ab2a5f4..c76b179 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/filepost.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/filepost.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/poolmanager.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/poolmanager.cpython-312.pyc index 7ac7a41..84c0a45 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/poolmanager.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/poolmanager.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/request.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/request.cpython-312.pyc index 369b922..cca9b9b 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/request.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/request.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/response.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/response.cpython-312.pyc index 41c6920..f0a30f3 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/response.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/__pycache__/response.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/__init__.cpython-312.pyc index a22b3bb..e213c86 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/_appengine_environ.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/_appengine_environ.cpython-312.pyc index 4d5f534..b0056f2 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/_appengine_environ.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/_appengine_environ.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/socks.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/socks.cpython-312.pyc index 864bfb0..8541719 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/socks.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/contrib/__pycache__/socks.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/packages/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/packages/__pycache__/__init__.cpython-312.pyc index 3c63f20..f013344 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/packages/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/packages/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/packages/__pycache__/six.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/packages/__pycache__/six.cpython-312.pyc index 15a7fac..4098422 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/packages/__pycache__/six.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/packages/__pycache__/six.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/__init__.cpython-312.pyc index 8ca8a55..8795286 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/connection.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/connection.cpython-312.pyc index 4c83df5..66fa3de 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/connection.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/connection.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/proxy.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/proxy.cpython-312.pyc index 7b4d417..9e306f1 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/proxy.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/proxy.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/queue.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/queue.cpython-312.pyc index c53c496..4cdbee9 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/queue.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/queue.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/request.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/request.cpython-312.pyc index 00bb180..3cc2944 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/request.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/request.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/response.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/response.cpython-312.pyc index 46e2381..72d3c31 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/response.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/response.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/retry.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/retry.cpython-312.pyc index 1f02cba..12db482 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/retry.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/retry.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssl_.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssl_.cpython-312.pyc index b3d441d..d64709a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssl_.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssl_.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssl_match_hostname.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssl_match_hostname.cpython-312.pyc index 2ee5f70..a3edc0a 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssl_match_hostname.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssl_match_hostname.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssltransport.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssltransport.cpython-312.pyc index 5ed0310..b079262 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssltransport.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/ssltransport.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/timeout.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/timeout.cpython-312.pyc index 0124d43..a98d9cf 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/timeout.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/timeout.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/url.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/url.cpython-312.pyc index 9c7e3a1..d48dcdd 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/url.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/url.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/wait.cpython-312.pyc b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/wait.cpython-312.pyc index be90c40..84a1bc5 100644 Binary files a/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/wait.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/pip/_vendor/urllib3/util/__pycache__/wait.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/__init__.cpython-312.pyc index 9b1d509..8253517 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/__version__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/__version__.cpython-312.pyc index 7d609fd..e681a39 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/__version__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/__version__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/_internal_utils.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/_internal_utils.cpython-312.pyc index ae793b0..fc7f6de 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/_internal_utils.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/_internal_utils.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/adapters.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/adapters.cpython-312.pyc index 4cab4b6..98b5521 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/adapters.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/adapters.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/api.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/api.cpython-312.pyc index 41e1573..4db8ad3 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/api.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/api.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/auth.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/auth.cpython-312.pyc index 6bce589..a99cc49 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/auth.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/auth.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/certs.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/certs.cpython-312.pyc index 231ccce..4ff707c 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/certs.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/certs.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/compat.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/compat.cpython-312.pyc index a1f24fe..5b0d4bd 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/compat.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/compat.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/cookies.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/cookies.cpython-312.pyc index e3183cb..e19b6b9 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/cookies.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/cookies.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/exceptions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/exceptions.cpython-312.pyc index 272c6bd..cd2c60c 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/exceptions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/exceptions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/hooks.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/hooks.cpython-312.pyc index 6214ea4..d28c7ca 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/hooks.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/hooks.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/models.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/models.cpython-312.pyc index b2a2b1d..f97f56f 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/models.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/models.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/packages.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/packages.cpython-312.pyc index b2a71b9..f0c8526 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/packages.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/packages.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/sessions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/sessions.cpython-312.pyc index 82e4cdb..bcc60dc 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/sessions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/sessions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/status_codes.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/status_codes.cpython-312.pyc index 563fae0..474e51c 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/status_codes.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/status_codes.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/structures.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/structures.cpython-312.pyc index 4c109d6..2dbdfe1 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/structures.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/structures.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/requests/__pycache__/utils.cpython-312.pyc b/.venv/lib/python3.12/site-packages/requests/__pycache__/utils.cpython-312.pyc index f1c9cdb..d6db2de 100644 Binary files a/.venv/lib/python3.12/site-packages/requests/__pycache__/utils.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/requests/__pycache__/utils.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/__init__.cpython-312.pyc index 015e89b..d210c9f 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_base_connection.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_base_connection.cpython-312.pyc index f49eb1d..0ef5553 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_base_connection.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_base_connection.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_collections.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_collections.cpython-312.pyc index b95e27f..13d691f 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_collections.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_collections.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_request_methods.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_request_methods.cpython-312.pyc index fe5b3f2..eedb016 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_request_methods.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_request_methods.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_version.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_version.cpython-312.pyc index 5c928cb..5aad475 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_version.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/_version.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/connection.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/connection.cpython-312.pyc index 6221cd6..dc295e6 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/connection.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/connection.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/connectionpool.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/connectionpool.cpython-312.pyc index 608add3..17a4d62 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/connectionpool.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/connectionpool.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/exceptions.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/exceptions.cpython-312.pyc index 6b3f639..b16f13d 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/exceptions.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/exceptions.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/fields.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/fields.cpython-312.pyc index 7eeb379..f0374bf 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/fields.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/fields.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/filepost.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/filepost.cpython-312.pyc index 5ced10a..2da4052 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/filepost.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/filepost.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/poolmanager.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/poolmanager.cpython-312.pyc index 375271c..cbead3a 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/poolmanager.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/poolmanager.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/response.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/response.cpython-312.pyc index 42913d1..b551697 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/__pycache__/response.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/__pycache__/response.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/contrib/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/contrib/__pycache__/__init__.cpython-312.pyc index 93a2653..8803c13 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/contrib/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/contrib/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/contrib/__pycache__/socks.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/contrib/__pycache__/socks.cpython-312.pyc index 9361938..f71bcc5 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/contrib/__pycache__/socks.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/contrib/__pycache__/socks.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/http2/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/http2/__pycache__/__init__.cpython-312.pyc index ff14cc9..3c52ddd 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/http2/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/http2/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/http2/__pycache__/probe.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/http2/__pycache__/probe.cpython-312.pyc index 5d0b2b3..19a862d 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/http2/__pycache__/probe.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/http2/__pycache__/probe.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/__init__.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/__init__.cpython-312.pyc index 40db19b..d5a4e8b 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/__init__.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/__init__.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/connection.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/connection.cpython-312.pyc index 02df415..9cf5e4b 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/connection.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/connection.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/proxy.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/proxy.cpython-312.pyc index 7c294b0..28f2386 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/proxy.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/proxy.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/request.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/request.cpython-312.pyc index 8c05cc6..19ccb40 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/request.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/request.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/response.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/response.cpython-312.pyc index 9869fdc..c250b67 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/response.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/response.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/retry.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/retry.cpython-312.pyc index 3cdea1e..447856f 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/retry.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/retry.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssl_.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssl_.cpython-312.pyc index afc168a..21f8cc3 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssl_.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssl_.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssl_match_hostname.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssl_match_hostname.cpython-312.pyc index 18b0b3f..6d09056 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssl_match_hostname.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssl_match_hostname.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssltransport.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssltransport.cpython-312.pyc index 5b32167..4216d9f 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssltransport.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/ssltransport.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/timeout.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/timeout.cpython-312.pyc index 4c15a34..c48a92a 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/timeout.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/timeout.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/url.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/url.cpython-312.pyc index 54a109d..7e2479e 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/url.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/url.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/util.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/util.cpython-312.pyc index d918633..fffc1bb 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/util.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/util.cpython-312.pyc differ diff --git a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/wait.cpython-312.pyc b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/wait.cpython-312.pyc index a0e825f..0783cd1 100644 Binary files a/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/wait.cpython-312.pyc and b/.venv/lib/python3.12/site-packages/urllib3/util/__pycache__/wait.cpython-312.pyc differ diff --git a/logs/logs.tar b/logs/logs.tar new file mode 100644 index 0000000..e4bbfa8 Binary files /dev/null and b/logs/logs.tar differ diff --git a/logs/mdc_tv.pid b/logs/mdc_tv.pid new file mode 100644 index 0000000..d200784 --- /dev/null +++ b/logs/mdc_tv.pid @@ -0,0 +1 @@ +490448 diff --git a/logs/mdc_tv_2025-08-24_20-14.log b/logs/mdc_tv_2025-08-24_20-14.log new file mode 100644 index 0000000..11c707c --- /dev/null +++ b/logs/mdc_tv_2025-08-24_20-14.log @@ -0,0 +1,906 @@ +nohup: ignoring input +[2025-08-24 20:14:27] === Media Date Sync Starting === +[2025-08-24 20:14:27] SONARR_URL: http://sonarr:8989 +[2025-08-24 20:14:27] SONARR_API_KEY: SET +[2025-08-24 20:14:27] TMDB_API_KEY: SET +[2025-08-24 20:14:27] Sonarr connection successful +[2025-08-24 20:14:27] Database initialized: /opt/scripts/unmanic/.cache/media_dates.db +[2025-08-24 20:14:27] Loading Sonarr series index... +[2025-08-24 20:14:27] Loaded 557 series from Sonarr +[2025-08-24 20:14:27] Scanning for media files in: /mnt/unionfs/Media/TV/tv +[2025-08-24 20:14:41] Found 14579 media file(s) +[2025-08-24 20:14:41] Starting processing: refresh=True, dry_run=False, manage_nfo=True +[2025-08-24 20:14:41] Processing file 1/14579: Teen Wolf (2011)-S02E02-Shape Shifted[WEBDL-1080p][EAC35.1][h264].mkv +[2025-08-24 20:14:41] Skipping (only-older): chosen >= current +[2025-08-24 20:14:41] Processing file 2/14579: Teen Wolf (2011)-S02E11-Battlefield[WEBDL-1080p][EAC35.1][h264].mkv +[2025-08-24 20:14:41] Skipping (only-older): chosen >= current +[2025-08-24 20:14:41] Processing file 3/14579: Teen Wolf (2011)-S02E12-Master Plan[WEBDL-1080p][EAC35.1][h264].mkv +[2025-08-24 20:14:41] Skipping (only-older): chosen >= current +[2025-08-24 20:14:41] Processing file 4/14579: Teen Wolf (2011)-S02E06-Frenemy[WEBDL-1080p][EAC35.1][h264].mkv +[2025-08-24 20:14:41] Skipping (only-older): chosen >= current +[2025-08-24 20:14:41] Processing file 5/14579: Teen Wolf (2011)-S02E01-Omega[WEBDL-1080p][EAC35.1][h264].mkv +[2025-08-24 20:14:41] Skipping (only-older): chosen >= current +[2025-08-24 20:14:42] Processing file 26/14579: The Summer I Turned Pretty (2022)-S03E05-Last Dance[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:14:42] Progress: 50/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:14:42] Processing file 51/14579: Project Runway (2004)-S21E01-New House New Rules[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:14:43] Processing file 76/14579: Foundation (2021)-S01E06-Death and the Maiden[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:14:46] Progress: 100/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:14:46] Processing file 101/14579: Jersey Shore - Family Vacation (2018)-S07E27-The Big Apple[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:14:49] Processing file 126/14579: Jersey Shore - Family Vacation (2018)-S07E16-Happy Birthday Snooki![HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:14:51] Progress: 150/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:14:51] Processing file 151/14579: My Life Is Murder (2019)-S01E10-Mirror Mirror[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:14:53] Processing file 176/14579: Australian Survivor (2002)-S12E02-The Praying Mantis[WEBDL-720p][AAC2.0][h264].mkv +[2025-08-24 20:14:53] Progress: 200/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:14:53] Processing file 201/14579: Australian Survivor (2002)-S11E19-How do Rainbows Work[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:14:58] Processing file 226/14579: Friends (1994)-S06E19-The One with Joeys Fridge[WEBDL-1080p][EAC35.1][h264].mkv +[2025-08-24 20:14:58] Progress: 250/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:14:58] Processing file 251/14579: Friends (1994)-S05E18-The One Where Rachel Smokes[WEBDL-1080p][EAC35.1][h264].mkv +[2025-08-24 20:14:58] Processing file 276/14579: Friends (1994)-S07E02-The One with Rachels Book[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:14:58] Progress: 300/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:14:58] Processing file 301/14579: Friends (1994)-S04E20-The One with All the Wedding Dresses[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:14:59] Processing file 326/14579: Friends (1994)-S10E10-The One Where Chandler Gets Caught[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:00] Progress: 350/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:15:00] Processing file 351/14579: Friends (1994)-S03E08-The One with the Giant Poking Device[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:00] Processing file 376/14579: Friends (1994)-S09E14-The One with the Blind Dates[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:00] Progress: 400/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:15:00] Processing file 401/14579: Friends (1994)-S01E22-The One with the Ick Factor[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:00] Processing file 426/14579: Friends (1994)-S08E12-The One Where Joey Dates Rachel[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:01] Progress: 450/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:15:01] Processing file 451/14579: Friends (1994)-S02E09-The One with Phoebes Dad[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:02] Processing file 476/14579: The Office (US) (2005)-S08E10-Christmas Wishes[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:02] Progress: 500/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:15:02] Processing file 501/14579: The Office (US) (2005)-S02E22-Casino Night[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:02] Processing file 526/14579: The Office (US) (2005)-S05E24-Casual Friday[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:02] Progress: 550/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:15:02] Processing file 551/14579: The Office (US) (2005)-S06E12-Scotts Tots[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:02] Processing file 576/14579: The Office (US) (2005)-S04E05-Launch Party 1[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:02] Progress: 600/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:15:02] Processing file 601/14579: The Office (US) (2005)-S03E12-Traveling Salesmen[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:02] Processing file 626/14579: The Office (US) (2005)-S07E09-WUPHF.com[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:03] Progress: 650/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:15:03] Processing file 651/14579: The Wheel of Time (2021)-S01E06-The Flame of Tar Valon[WEBRip-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:04] Processing file 676/14579: Harlots (2017)-S02E01-Episode 1[WEBRip-1080p]{[Mediainfo AudioCodec}[h264].mkv +[2025-08-24 20:15:04] Progress: 700/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:15:04] Processing file 701/14579: The Repair Shop (2017)-S13E17-Episode 17[WEBDL-1080p]{[Mediainfo AudioCodec}[h264].mkv +[2025-08-24 20:15:05] Processing file 726/14579: The Repair Shop (2017)-S14E17-VE Day Special[WEBRip-1080p][AAC2.0][h264].mp4 +[2025-08-24 20:15:05] Progress: 750/14579 (updated: 0, cached: 0, misses: 0) +[2025-08-24 20:15:05] Processing file 751/14579: Dark Matter (2015)-S01E02-Episode Two[Bluray-1080p Remux][AAC2.0][x265].mkv +[2025-08-24 20:15:06] Processing file 776/14579: Impractical Jokers (2011)-S12E07-Meat Cute[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:08] Progress: 800/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:08] Processing file 801/14579: Impractical Jokers (2011)-S09E04-Poetry Slammed[HDTV-1080p][AC32.0][x264].mkv +[2025-08-24 20:15:08] Processing file 826/14579: Impractical Jokers (2011)-S10E02-Post Malone[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:15] Progress: 850/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:15] Processing file 851/14579: It's Always Sunny in Philadelphia (2005)-S06E08-The Gang Gets a New Member[HDTV-1080p]{[Mediainfo AudioCodec}[h264].mkv +[2025-08-24 20:15:15] Processing file 876/14579: It's Always Sunny in Philadelphia (2005)-S17E08-The Golden Bachelor Live[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:15] Progress: 900/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:15] Processing file 901/14579: It's Always Sunny in Philadelphia (2005)-S15E03-The Gang Buys A Roller Rink[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:15] Processing file 926/14579: It's Always Sunny in Philadelphia (2005)-S16E04-Frank vs. Russia[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:15] Progress: 950/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:15] Processing file 951/14579: It's Always Sunny in Philadelphia (2005)-S07E07-Chardee MacDennis The Game of Games[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:15] Processing file 976/14579: It's Always Sunny in Philadelphia (2005)-S04E07-Who Pooped the Bed[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:16] Progress: 1000/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:16] Processing file 1001/14579: It's Always Sunny in Philadelphia (2005)-S09E07-The Gang Gets Quarantined[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:16] Processing file 1026/14579: The Institute (2025)-S01E01-The Boy[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:16] Progress: 1050/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:16] Processing file 1051/14579: Law & Order - Special Victims Unit (1999)-S26E14-The Grid Plan[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:19] Processing file 1076/14579: The Capture (2019)-S01E03-Truffle Hog[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:20] Progress: 1100/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:20] Processing file 1101/14579: Wednesday (2022)-S02E03-Call of the Woe[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:22] Processing file 1126/14579: Lilo & Stitch - The Series (2003)-S01E30-Angel[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:25] Progress: 1150/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:25] Processing file 1151/14579: Somebody Feed Phil (2018)-S01E03-Tel Aviv[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:28] Processing file 1176/14579: The Walking Dead (2010)-S04E16-A[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:28] Progress: 1200/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:28] Processing file 1201/14579: The Walking Dead (2010)-S03E09-The Suicide King[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:28] Processing file 1226/14579: The Walking Dead (2010)-S10E19-One More[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:29] Progress: 1250/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:29] Processing file 1251/14579: The Walking Dead (2010)-S06E06-Always Accountable[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:29] Processing file 1276/14579: The Walking Dead (2010)-S11E10-New Haunts[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:29] Progress: 1300/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:29] Processing file 1301/14579: The Walking Dead (2010)-S08E03-Monsters[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:30] Processing file 1326/14579: Bosch - Legacy (2022)-S03E03-Blankie[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:31] Progress: 1350/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:31] Processing file 1351/14579: The Paradise (2012)-S01E05-Episode 5[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:32] Processing file 1376/14579: Mister Ed (1961)-S06E08-The Horse and the Pussycat[DVD][AAC2.0][x264].mp4 +[2025-08-24 20:15:32] Progress: 1400/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:32] Processing file 1401/14579: Mister Ed (1961)-S05E19-Eds Cold Tail[WEBDL-480p][AAC2.0][x264].mkv +[2025-08-24 20:15:32] Processing file 1426/14579: Mister Ed (1961)-S03E06-Horse Sense[SDTV][AAC2.0][x264].mkv +[2025-08-24 20:15:32] Progress: 1450/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:32] Processing file 1451/14579: Mister Ed (1961)-S04E24-The Prowler[SDTV][AAC2.0][x264].mkv +[2025-08-24 20:15:37] Processing file 1476/14579: Everybody Loves Raymond (1996)-S01E01-Pilot[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:37] Progress: 1500/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:37] Processing file 1501/14579: Everybody Loves Raymond (1996)-S06E19-Talk to Your Daughter[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:37] Processing file 1526/14579: Everybody Loves Raymond (1996)-S08E05-The Contractor[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:37] Progress: 1550/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:37] Processing file 1551/14579: Everybody Loves Raymond (1996)-S02E11-The Letter[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:37] Processing file 1576/14579: Everybody Loves Raymond (1996)-S05E11-Christmas Present[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:38] Progress: 1600/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:38] Processing file 1601/14579: Everybody Loves Raymond (1996)-S07E10-Maries Vision[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:38] Processing file 1626/14579: Everybody Loves Raymond (1996)-S04E06-The Sister[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:38] Progress: 1650/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:38] Processing file 1651/14579: Everybody Loves Raymond (1996)-S03E03-The Sitter[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:38] Processing file 1676/14579: Everybody Loves Raymond (1996)-S09E10-Favors[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:38] Progress: 1700/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:38] Processing file 1701/14579: The Day of the Jackal (2024)-S01E08-Episode 8[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:40] Processing file 1726/14579: The Man in the High Castle (2015)-S04E04-Happy Trails[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:40] Progress: 1750/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:40] Processing file 1751/14579: The Man in the High Castle (2015)-S02E07-Land O Smiles[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:40] Processing file 1776/14579: Emily in Paris (2020)-S04E04-The Grey Area[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:41] Progress: 1800/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:41] Processing file 1801/14579: Duck Dynasty - The Revival (2025)-S01E01-The Robertsons Are Back[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:43] Processing file 1826/14579: Breaking Bad (2008)-S05E11-Confessions[Bluray-1080p Remux][AAC2.0][AVC].mkv +[2025-08-24 20:15:43] Progress: 1850/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:43] Processing file 1851/14579: Breaking Bad (2008)-S03E01-No Más[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:15:43] Processing file 1876/14579: Knuckles (2024)-S01E06-What Happens in Reno Stays in Reno[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:15:45] Progress: 1900/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:45] Processing file 1901/14579: James May - Our Man In. (2020)-S01E04-Hey Bim![HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:47] Processing file 1926/14579: Spidey and His Amazing Friends (2021)-S02E09-E10-Li'l Hulk and Surprise Party Surprise[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:50] Progress: 1950/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:50] Processing file 1951/14579: Spidey and His Amazing Friends (2021)-S01E24-Gobby on Ice[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:53] Processing file 1976/14579: Spidey and His Amazing Friends (2021)-S04E03-E04-Giant-Sized Lizard and Rhinos Wild Bunch[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:56] Progress: 2000/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:56] Processing file 2001/14579: Spidey and His Amazing Friends (2021)-S03E49-E50-Moon Girl and the Dino Dilemma and Hulks Squirrley Switch[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:57] Processing file 2026/14579: Vindication (2019)-S02E03-Suspicion Comes Home[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:57] Progress: 2050/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:57] Processing file 2051/14579: The Passage (2019)-S01E07-You Are Like The Sun[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:15:58] Processing file 2076/14579: FUBAR (2023)-S02E06-Penny Possums Pizzapocalypse[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:58] Progress: 2100/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:15:58] Processing file 2101/14579: That '90s Show (2023)-S02E10-Doll Parts[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:15:59] Processing file 2126/14579: Dept. Q (2025)-S01E02-Episode 2[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:01] Progress: 2150/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:01] Processing file 2151/14579: 9-1-1 (2018)-S08E11-Holy Mother of God[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:02] Processing file 2176/14579: St. Denis Medical (2024)-S01E09-You Gotta Have a Plan[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:02] Progress: 2200/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:02] Processing file 2201/14579: We Were the Lucky Ones (2024)-S01E08-Rio[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:04] Processing file 2226/14579: Brooklyn Nine-Nine (2013)-S01E06-Halloween[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:04] Progress: 2250/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:04] Processing file 2251/14579: Brooklyn Nine-Nine (2013)-S08E06-The Set Up[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:04] Processing file 2276/14579: Brooklyn Nine-Nine (2013)-S02E20-AC DC[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:05] Progress: 2300/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:05] Processing file 2301/14579: Brooklyn Nine-Nine (2013)-S03E13-The Cruise[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:05] Processing file 2326/14579: Brooklyn Nine-Nine (2013)-S04E22-Crime and Punishment[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:06] Progress: 2350/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:06] Processing file 2351/14579: Mad Men (2007)-S05E02-A Little Kiss 2[HDTV-1080p][AAC2.0][h265].mkv +[2025-08-24 20:16:06] Processing file 2376/14579: Mad Men (2007)-S01E06-Babylon[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:06] Progress: 2400/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:06] Processing file 2401/14579: Mad Men (2007)-S04E11-Chinese Wall[HDTV-1080p][AAC2.0][h265].mkv +[2025-08-24 20:16:06] Processing file 2426/14579: Mad Men (2007)-S07E05-The Runaways[HDTV-1080p][AAC2.0][h265].mkv +[2025-08-24 20:16:09] Progress: 2450/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:09] Processing file 2451/14579: The Walking Dead - The Ones Who Live (2024)-S01E05-Become[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:12] Processing file 2476/14579: Paw Patrol (2013)-S10E36-E37-Pups vs. the Hum-Flectors and Pups Save a Capybara[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:12] Progress: 2500/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:12] Processing file 2501/14579: Paw Patrol (2013)-S10E42-Rescue Wheels Pups Save Adventure Bay[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:13] Processing file 2526/14579: No Good Deed (2024)-S01E05-Off the Market[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:17] Progress: 2550/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:17] Processing file 2551/14579: Doc (US) (2025)-S01E06-Once More with Feeling[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:19] Processing file 2576/14579: Star Trek - Prodigy (2021)-S02E03-Who Saves the Saviors[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:20] Progress: 2600/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:20] Processing file 2601/14579: Toxic Town (2025)-S01E04-Episode 4[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:21] Processing file 2626/14579: The IT Crowd (2006)-S04E02-The Final Countdown[WEBRip-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:22] Progress: 2650/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:22] Processing file 2651/14579: Voltron - Defender of the Universe (1984)-S01E13-The Witch Gets a Facelift[DVD][AAC2.0][x264].mkv +[2025-08-24 20:16:25] Processing file 2676/14579: Voltron - Defender of the Universe (1984)-S01E20-Pidges Home Planet[DVD][AAC2.0][x264].mkv +[2025-08-24 20:16:28] Progress: 2700/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:28] Processing file 2701/14579: Voltron - Defender of the Universe (1984)-S02E19-The Alliance Strikes Back![DVD][AAC2.0][x264].mkv +[2025-08-24 20:16:31] Processing file 2726/14579: What If…! (2021)-S01E03-What If… The World Lost Its Mightiest Heroes[WEBDL-1080p][AAC2.0][x265].mkv +[2025-08-24 20:16:32] Progress: 2750/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:32] Processing file 2751/14579: You (2018)-S05E05-Last Dance[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:35] Processing file 2776/14579: Andor (2022)-S02E04-Ever Been to Ghorman[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:37] Progress: 2800/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:37] Processing file 2801/14579: Dragnet (1967)-S04E14-Burglary Helpful Woman[DVD][MP31.0].mkv +[2025-08-24 20:16:37] Processing file 2826/14579: Dragnet (1967)-S03E24-Juvenile DR-32[DVD][MP31.0].mkv +[2025-08-24 20:16:37] Progress: 2850/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:37] Processing file 2851/14579: Dragnet (1967)-S01E08-The Candy Store Robberies[DVD][MP31.0].mkv +[2025-08-24 20:16:37] Processing file 2876/14579: Dragnet (1967)-S02E19-The Big Amateur[DVD][MP31.0].mkv +[2025-08-24 20:16:38] Progress: 2900/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:38] Processing file 2901/14579: The Neighborhood (2018)-S07E09-Welcome to Pickleball 1[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:41] Processing file 2926/14579: Married at First Sight (AU) (2015)-S09E22-Episode 22[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:41] Progress: 2950/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:41] Processing file 2951/14579: Married at First Sight (AU) (2015)-S10E06-Episode 6[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:42] Processing file 2976/14579: Married at First Sight (AU) (2015)-S10E08-Episode 8[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:42] Progress: 3000/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:42] Processing file 3001/14579: Married at First Sight (AU) (2015)-S11E13-Episode 13[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:42] Processing file 3026/14579: Married at First Sight (AU) (2015)-S12E30-Episode 30[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:16:42] Progress: 3050/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:42] Processing file 3051/14579: Married at First Sight (AU) (2015)-S12E05-Episode 5[WEBDL-720p][AAC2.0][h264].mp4 +[2025-08-24 20:16:43] Processing file 3076/14579: Bluey (2018)-S02E34-Swim School[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:47] Progress: 3100/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:47] Processing file 3101/14579: Bluey (2018)-S02E39-Double Babysitter[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:50] Processing file 3126/14579: Bluey (2018)-S01E31-Work[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:54] Progress: 3150/14579 (updated: 0, cached: 0, misses: 1) +[2025-08-24 20:16:54] Processing file 3151/14579: Bluey (2018)-S01E30-Fairies[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:16:58] Processing file 3176/14579: Bluey (2018)-S01E06-The Weekend[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:04] Progress: 3200/14579 (updated: 0, cached: 0, misses: 2) +[2025-08-24 20:17:04] Processing file 3201/14579: Bluey (2018)-S03E02-Bedroom[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:08] Processing file 3226/14579: Bluey (2018)-S03E30-Turtleboy[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:09] Progress: 3250/14579 (updated: 0, cached: 0, misses: 2) +[2025-08-24 20:17:09] Processing file 3251/14579: Austin & Ally (2011)-S04E06-Duos and Deception[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:10] Processing file 3276/14579: Austin & Ally (2011)-S01E11-Songwriting and Starfish[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:10] Progress: 3300/14579 (updated: 0, cached: 0, misses: 2) +[2025-08-24 20:17:10] Processing file 3301/14579: Austin & Ally (2011)-S02E06-Big Dreams and Big Apples 1[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:10] Processing file 3326/14579: FROM (2022)-S02E05-Lullaby[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:11] Processing file 3351/14579: The Walking Dead - Dead City (2023)-S02E01-Power Equals Power[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:12] Processing file 3376/14579: Supernatural (2005)-S02E06-No Exit[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:13] Progress: 3400/14579 (updated: 0, cached: 0, misses: 16) +[2025-08-24 20:17:13] Processing file 3401/14579: Supernatural (2005)-S01E12-Faith[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:13] Processing file 3426/14579: Supernatural (2005)-S04E15-Death Takes a Holiday[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:14] Progress: 3450/14579 (updated: 0, cached: 0, misses: 16) +[2025-08-24 20:17:14] Processing file 3451/14579: Black Mirror (2011)-S01E02-Fifteen Million Merits[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:17] Processing file 3476/14579: Black Mirror (2011)-S04E05-Metalhead[WEBDL-1080p][AAC2.0][AV1].mkv +[2025-08-24 20:17:17] Progress: 3500/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:17] Processing file 3501/14579: The Resort (2022)-S01E03-Tempus Exhaurire[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:18] Processing file 3526/14579: Resident Alien (2021)-S04E02-The Lonely Man[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:19] Progress: 3550/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:19] Processing file 3551/14579: Resident Alien (2021)-S01E01-Pilot[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:17:19] Processing file 3576/14579: Billy the Kid (2022)-S02E05-A Debt Collected[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:21] Progress: 3600/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:21] Processing file 3601/14579: Bizarre Foods (2007)-S01E08-Mexico[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:21] Processing file 3626/14579: Peaky Blinders (2013)-S01E01-Episode 1[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:21] Progress: 3650/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:21] Processing file 3651/14579: Normal People (2020)-S01E04-Episode 4[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:23] Processing file 3676/14579: Top Gear (2002)-S05E06-Episode 6[WEBDL-1080p][AAC2.0][x264].mp4 +[2025-08-24 20:17:23] Progress: 3700/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:23] Processing file 3701/14579: Top Gear (2002)-S02E01-Episode 1[WEBDL-1080p][AAC2.0][x264].mp4 +[2025-08-24 20:17:23] Processing file 3726/14579: Top Gear (2002)-S15E06-Episode 6[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:23] Progress: 3750/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:23] Processing file 3751/14579: Top Gear (2002)-S01E08-Episode 8[WEBDL-1080p][AAC2.0][x264].mp4 +[2025-08-24 20:17:23] Processing file 3776/14579: Top Gear (2002)-S12E08-Episode 8[WEBDL-1080p][AAC2.0][x264].mp4 +[2025-08-24 20:17:23] Progress: 3800/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:23] Processing file 3801/14579: Top Gear (2002)-S10E02-Episode 2[WEBDL-1080p][AAC2.0][x264].mp4 +[2025-08-24 20:17:24] Processing file 3826/14579: Top Gear (2002)-S04E06-Episode 6[WEBDL-1080p][AAC2.0][x264].mp4 +[2025-08-24 20:17:24] Progress: 3850/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:24] Processing file 3851/14579: Top Gear (2002)-S21E06-Burma Special 1[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:24] Processing file 3876/14579: Top Gear (2002)-S07E04-Episode 4[WEBDL-1080p][AAC2.0][x264].mp4 +[2025-08-24 20:17:24] Progress: 3900/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:24] Processing file 3901/14579: Abbott Elementary (2021)-S04E13-Science Fair[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:25] Processing file 3926/14579: Based On A True Story (2023)-S02E06-Based on a Drew Story[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:26] Progress: 3950/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:26] Processing file 3951/14579: The Outpost (2018)-S04E12-The Betrayer[Bluray-1080p Remux][DTS-HD MA5.1][h264].mkv +[2025-08-24 20:17:26] Processing file 3976/14579: The Outpost (2018)-S01E08-Beyond the Wall[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:26] Progress: 4000/14579 (updated: 0, cached: 0, misses: 18) +[2025-08-24 20:17:26] Processing file 4001/14579: Hollywood Con Queen (2024)-S01E03-Thank You for the Story[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:29] Processing file 4026/14579: Mr. & Mrs. Smith (2024)-S01E08-A Breakup[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:31] Progress: 4050/14579 (updated: 27, cached: 0, misses: 18) +[2025-08-24 20:17:31] Processing file 4051/14579: Arrow (2012)-S05E17-Kapiushon[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:17:31] Processing file 4076/14579: Arrow (2012)-S06E09-Irreconcilable Differences[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:17:31] Progress: 4100/14579 (updated: 27, cached: 0, misses: 18) +[2025-08-24 20:17:31] Processing file 4101/14579: Arrow (2012)-S04E03-Restoration[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:17:31] Processing file 4126/14579: Arrow (2012)-S07E09-Elseworlds 2[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:17:32] Progress: 4150/14579 (updated: 27, cached: 0, misses: 18) +[2025-08-24 20:17:32] Processing file 4151/14579: Cougar Town (2009)-S01E06-A Woman in Love Its Not Me[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:33] Processing file 4176/14579: Love Island USA (2019)-S07E06-Episode 6[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:36] Progress: 4200/14579 (updated: 56, cached: 0, misses: 18) +[2025-08-24 20:17:36] Processing file 4201/14579: Love Island USA (2019)-S07E08-Episode 8[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:40] Processing file 4226/14579: Love Island USA (2019)-S06E16-Episode 16[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:43] Progress: 4250/14579 (updated: 100, cached: 0, misses: 18) +[2025-08-24 20:17:43] Processing file 4251/14579: A Man on the Inside (2024)-S01E06-Our Man in Sacramento[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:46] Processing file 4276/14579: The Blacklist (2013)-S03E01-The Troll Farmer[Bluray-1080p][DTS5.1][x264].mkv +[2025-08-24 20:17:46] Progress: 4300/14579 (updated: 107, cached: 0, misses: 18) +[2025-08-24 20:17:46] Processing file 4301/14579: The Blacklist (2013)-S04E21-Mr. Kaplan[Bluray-1080p][AAC2.0][x265].mkv +[2025-08-24 20:17:46] Processing file 4326/14579: The Blacklist (2013)-S06E21-Anna McMahon[Bluray-1080p][AAC2.0][x265].mkv +[2025-08-24 20:17:46] Progress: 4350/14579 (updated: 107, cached: 0, misses: 18) +[2025-08-24 20:17:46] Processing file 4351/14579: The Blacklist (2013)-S05E06-The Travel Agency[Bluray-1080p][AAC2.0][x265].mkv +[2025-08-24 20:17:47] Processing file 4376/14579: The Blacklist (2013)-S02E04-Dr. Linus Creel[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:47] Progress: 4400/14579 (updated: 107, cached: 0, misses: 18) +[2025-08-24 20:17:47] Processing file 4401/14579: Say Nothing (2024)-S01E05-Evil Little Maniacs[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:48] Processing file 4426/14579: The Baxters (2024)-S01E08-Food for Thought[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:51] Progress: 4450/14579 (updated: 107, cached: 0, misses: 18) +[2025-08-24 20:17:51] Processing file 4451/14579: The Baxters (2024)-S03E01-New Beginnings[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:53] Processing file 4476/14579: Suits LA (2025)-S01E06-Dester[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:17:56] Progress: 4500/14579 (updated: 115, cached: 0, misses: 18) +[2025-08-24 20:17:56] Processing file 4501/14579: The Golden Girls (1985)-S06E12-Ebbtides Revenge[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:17:56] Processing file 4526/14579: The Golden Girls (1985)-S01E10-Heart Attack[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:17:56] Progress: 4550/14579 (updated: 115, cached: 0, misses: 18) +[2025-08-24 20:17:56] Processing file 4551/14579: The Golden Girls (1985)-S05E15-Triple Play[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:17:56] Processing file 4576/14579: The Golden Girls (1985)-S02E20-Whose Face is This Anyway[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:17:56] Progress: 4600/14579 (updated: 115, cached: 0, misses: 18) +[2025-08-24 20:17:56] Processing file 4601/14579: The Golden Girls (1985)-S07E13-The Popes Ring[DVD][AAC2.0][x265].mkv +[2025-08-24 20:17:57] Processing file 4626/14579: The Golden Girls (1985)-S03E07-Strange Bedfellows[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:17:57] Progress: 4650/14579 (updated: 115, cached: 0, misses: 18) +[2025-08-24 20:17:57] Processing file 4651/14579: The Golden Girls (1985)-S04E25-Were Outta Here 1[SDTV][AAC2.0][h264].mkv +[2025-08-24 20:17:58] Processing file 4676/14579: Outer Banks (2020)-S03E02-The Bells[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:58] Progress: 4700/14579 (updated: 115, cached: 0, misses: 18) +[2025-08-24 20:17:58] Processing file 4701/14579: Outer Banks (2020)-S01E09-The Bell Tower[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:17:58] Processing file 4726/14579: Cowboy Cartel (2024)-S01E02-The Inside Track[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:00] Progress: 4750/14579 (updated: 129, cached: 0, misses: 22) +[2025-08-24 20:18:00] Processing file 4751/14579: FBI (2018)-S07E12-Manhunt[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:01] Processing file 4776/14579: Chicago Fire (2012)-S13E08-Quicksand[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:01] Progress: 4800/14579 (updated: 130, cached: 0, misses: 22) +[2025-08-24 20:18:01] Processing file 4801/14579: Grey's Anatomy (2005)-S21E03-I Can See Clearly Now[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:03] Processing file 4826/14579: Dancing with the Stars (2005)-S32E01-Premiere[WEBDL-720p][AAC2.0][h264].mkv +[2025-08-24 20:18:03] Progress: 4850/14579 (updated: 131, cached: 0, misses: 22) +[2025-08-24 20:18:03] Processing file 4851/14579: Dancing with the Stars (2005)-S33E07-Halloween Nightmares[WEBDL-720p][AAC2.0][h264].mkv +[2025-08-24 20:18:03] Processing file 4876/14579: Dark (2017)-S02E04-The Travelers[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:04] Progress: 4900/14579 (updated: 134, cached: 0, misses: 22) +[2025-08-24 20:18:04] Processing file 4901/14579: Palm Royale (2024)-S01E04-Maxine Rolls the Dice[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:06] Processing file 4926/14579: The Buccaneers (2023)-S02E07-All Rise[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:10] Progress: 4950/14579 (updated: 178, cached: 0, misses: 22) +[2025-08-24 20:18:10] Processing file 4951/14579: School Spirits (2023)-S02E04-A Walk-In To Remember[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:11] Processing file 4976/14579: Bad Romance (2024)-S02E06-Betrayed[SDTV][AAC2.0][x264].mkv +[2025-08-24 20:18:12] Progress: 5000/14579 (updated: 181, cached: 0, misses: 22) +[2025-08-24 20:18:12] Processing file 5001/14579: The Sex Lives of College Girls (2021)-S02E06-Doppelbanger[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:17] Processing file 5026/14579: Beverly Hills, 90210 (1990)-S07E14-Jobbed[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:17] Progress: 5050/14579 (updated: 181, cached: 0, misses: 22) +[2025-08-24 20:18:17] Processing file 5051/14579: Beverly Hills, 90210 (1990)-S10E22-The Easter Bunny[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:17] Processing file 5076/14579: Beverly Hills, 90210 (1990)-S03E10-Home and Away[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:17] Progress: 5100/14579 (updated: 181, cached: 0, misses: 22) +[2025-08-24 20:18:17] Processing file 5101/14579: Beverly Hills, 90210 (1990)-S03E07-A Song of Myself[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:17] Processing file 5126/14579: Beverly Hills, 90210 (1990)-S09E12-Trials and Tribulations[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:17] Progress: 5150/14579 (updated: 181, cached: 0, misses: 22) +[2025-08-24 20:18:17] Processing file 5151/14579: Beverly Hills, 90210 (1990)-S04E11-Take Back the Night[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:18] Processing file 5176/14579: Beverly Hills, 90210 (1990)-S06E23-Leap of Faith[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:18] Progress: 5200/14579 (updated: 181, cached: 0, misses: 22) +[2025-08-24 20:18:18] Processing file 5201/14579: Beverly Hills, 90210 (1990)-S01E06-Higher Education[Bluray-576p][AAC2.0][h264].mkv +[2025-08-24 20:18:18] Processing file 5226/14579: Beverly Hills, 90210 (1990)-S05E12-Rock of Ages a.k.a. The Voodoo That You Do So Well[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:18] Progress: 5250/14579 (updated: 181, cached: 0, misses: 22) +[2025-08-24 20:18:18] Processing file 5251/14579: Beverly Hills, 90210 (1990)-S08E20-Cupids Arrow[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:18] Processing file 5276/14579: Beverly Hills, 90210 (1990)-S08E06-The Right Thing[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:19] Progress: 5300/14579 (updated: 181, cached: 0, misses: 22) +[2025-08-24 20:18:19] Processing file 5301/14579: Beverly Hills, 90210 (1990)-S02E04-Anaconda[DVD][AAC2.0][x265].mkv +[2025-08-24 20:18:21] Processing file 5326/14579: When Calls the Heart (2014)-S12E02-You Get What You Give[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:22] Progress: 5350/14579 (updated: 191, cached: 0, misses: 22) +[2025-08-24 20:18:22] Processing file 5351/14579: Community (2009)-S03E04-Remedial Chaos Theory[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:22] Processing file 5376/14579: Community (2009)-S02E10-Mixology Certification[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:23] Progress: 5400/14579 (updated: 191, cached: 0, misses: 22) +[2025-08-24 20:18:23] Processing file 5401/14579: Community (2009)-S01E21-Contemporary American Poultry[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:23] Processing file 5426/14579: Community (2009)-S06E13-Emotional Consequences of Broadcast Television[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:23] Progress: 5450/14579 (updated: 191, cached: 0, misses: 22) +[2025-08-24 20:18:23] Processing file 5451/14579: Jury Duty (2023)-S01E01-Voir Dire[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:24] Processing file 5476/14579: Murdoch Mysteries (2008)-S18E18-The Incredible Astonishing Adventures of Constable George Crabtree[WEBRip-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:25] Progress: 5500/14579 (updated: 199, cached: 0, misses: 22) +[2025-08-24 20:18:25] Processing file 5501/14579: The Irrational (2023)-S02E06-The Wrong Side of Maybe[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:25] Processing file 5526/14579: Band of Brothers (2001)-S01E10-Points[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:18:26] Progress: 5550/14579 (updated: 199, cached: 0, misses: 22) +[2025-08-24 20:18:26] Processing file 5551/14579: Sullivan's Crossing (2023)-S01E08-Aftershock[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:28] Processing file 5576/14579: Interior Chinatown (2024)-S01E09-Bad Guy[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:28] Progress: 5600/14579 (updated: 199, cached: 0, misses: 22) +[2025-08-24 20:18:28] Processing file 5601/14579: Upload (2020)-S01E03-The Funeral[WEBRip-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:30] Processing file 5626/14579: Sweetpea (2024)-S01E03-Black Spots in the Garage[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:31] Progress: 5650/14579 (updated: 199, cached: 0, misses: 22) +[2025-08-24 20:18:31] Processing file 5651/14579: That '70s Show (1998)-S06E22-Sparks[Bluray-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:31] Processing file 5676/14579: That '70s Show (1998)-S01E12-The Best Christmas Ever[Bluray-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:32] Progress: 5700/14579 (updated: 199, cached: 0, misses: 22) +[2025-08-24 20:18:32] Processing file 5701/14579: That '70s Show (1998)-S04E17-Kelsos Career[Bluray-1080p][AAC2.0][h265].mkv +[2025-08-24 20:18:32] Processing file 5726/14579: House of David (2025)-S01E06-Giants Awakened[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:34] Progress: 5750/14579 (updated: 211, cached: 0, misses: 22) +[2025-08-24 20:18:34] Processing file 5751/14579: Snowpiercer (2020)-S04E02-The Sting of Survival[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:34] Processing file 5776/14579: Elsbeth (2024)-S02E15-I See… Murder[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:36] Progress: 5800/14579 (updated: 212, cached: 0, misses: 22) +[2025-08-24 20:18:36] Processing file 5801/14579: Sanford and Son (1972)-S05E08-Donna Pops the Question[WEBDL-1080p][AAC2.0][x265].mkv +[2025-08-24 20:18:37] Processing file 5826/14579: Sanford and Son (1972)-S02E22-Pot Luck[WEBDL-1080p][AAC2.0][x265].mkv +[2025-08-24 20:18:37] Progress: 5850/14579 (updated: 212, cached: 0, misses: 22) +[2025-08-24 20:18:37] Processing file 5851/14579: Sanford and Son (1972)-S06E20-The Will[WEBDL-1080p][AAC2.0][x265].mkv +[2025-08-24 20:18:37] Processing file 5876/14579: Sanford and Son (1972)-S03E24-Hello Cousin Emma.Goodbye Cousin Emma[WEBDL-1080p][AAC2.0][x265].mkv +[2025-08-24 20:18:37] Progress: 5900/14579 (updated: 212, cached: 0, misses: 22) +[2025-08-24 20:18:37] Processing file 5901/14579: Sanford and Son (1972)-S04E05-Therell Be Some Changes Made[WEBDL-1080p][AAC2.0][x265].mkv +[2025-08-24 20:18:38] Processing file 5926/14579: Murder in a Small Town (2024)-S01E04-Prized Possessions[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:40] Progress: 5950/14579 (updated: 221, cached: 0, misses: 22) +[2025-08-24 20:18:40] Processing file 5951/14579: Peppa Pig (2004)-S08E38-Thanksgiving[HDTV-1080p][EAC32.0][x264].mkv +[2025-08-24 20:18:40] Processing file 5976/14579: Peppa Pig (2004)-S08E13-Igloo[HDTV-1080p][EAC32.0][x264].mkv +[2025-08-24 20:18:41] Progress: 6000/14579 (updated: 222, cached: 0, misses: 22) +[2025-08-24 20:18:41] Processing file 6001/14579: Love on the Spectrum (US) (2022)-S03E04-Episode 4[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:18:49] Processing file 6026/14579: The Beverly Hillbillies (1962)-S07E25-The Jogging Clampetts[SDTV][AAC2.0][DivX].avi +[2025-08-24 20:18:49] Progress: 6050/14579 (updated: 222, cached: 0, misses: 22) +[2025-08-24 20:18:49] Processing file 6051/14579: The Beverly Hillbillies (1962)-S09E12-The Great Revelation[SDTV][AAC2.0][DivX].avi +[2025-08-24 20:18:49] Processing file 6076/14579: The Beverly Hillbillies (1962)-S03E34-The Art Center[DVD][AAC2.0][h264].mkv +[2025-08-24 20:18:49] Progress: 6100/14579 (updated: 222, cached: 0, misses: 22) +[2025-08-24 20:18:49] Processing file 6101/14579: The Beverly Hillbillies (1962)-S04E18-The Buggy[DVD][AAC2.0][x264].mkv +[2025-08-24 20:18:50] Processing file 6126/14579: The Beverly Hillbillies (1962)-S06E26-The Soap Opera[SDTV][AAC2.0][DivX].avi +[2025-08-24 20:18:50] Progress: 6150/14579 (updated: 222, cached: 0, misses: 22) +[2025-08-24 20:18:50] Processing file 6151/14579: The Beverly Hillbillies (1962)-S01E06-Trick or Treat[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:50] Processing file 6176/14579: The Beverly Hillbillies (1962)-S01E08-Jethro Goes to School[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:50] Progress: 6200/14579 (updated: 222, cached: 0, misses: 22) +[2025-08-24 20:18:50] Processing file 6201/14579: The Beverly Hillbillies (1962)-S05E20-The Indians Are Coming[DVD][AAC2.0][x264].mkv +[2025-08-24 20:18:50] Processing file 6226/14579: The Beverly Hillbillies (1962)-S02E21-Son of Lafe Returns[DVD][AAC2.0][x264].mkv +[2025-08-24 20:18:51] Progress: 6250/14579 (updated: 222, cached: 0, misses: 22) +[2025-08-24 20:18:51] Processing file 6251/14579: The Beverly Hillbillies (1962)-S08E07-The Clampetts in New York[SDTV][AAC2.0][DivX].avi +[2025-08-24 20:18:51] Processing file 6276/14579: Firefly (2002)-S01E01-The Train Job[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:53] Progress: 6300/14579 (updated: 236, cached: 0, misses: 22) +[2025-08-24 20:18:53] Processing file 6301/14579: S.W.A.T. (2017)-S08E22-Return to Base 2[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:55] Processing file 6326/14579: The Simpsons (1989)-S36E11-Bottle Episode[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:57] Progress: 6350/14579 (updated: 255, cached: 0, misses: 25) +[2025-08-24 20:18:57] Processing file 6351/14579: The Sticky (2024)-S01E05-Blade[HDTV-1080p][EAC35.1][h264].mkv +[2025-08-24 20:18:58] Processing file 6376/14579: Fire Country (2022)-S03E10-The Leone Way[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:58] Progress: 6400/14579 (updated: 255, cached: 0, misses: 25) +[2025-08-24 20:18:58] Processing file 6401/14579: Fire Country (2022)-S01E03-Where Theres Smoke[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:59] Processing file 6426/14579: Expedition Unknown (2015)-S08E05-Treasure of the Warrior Queen[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:18:59] Progress: 6450/14579 (updated: 255, cached: 0, misses: 25) +[2025-08-24 20:18:59] Processing file 6451/14579: iCarly (2007)-S04E09-iPity the Nevel[WEBRip-1080p][AAC2.0][x265].mkv +[2025-08-24 20:18:59] Processing file 6476/14579: iCarly (2007)-S03E12-iEnrage Gibby[WEBRip-1080p][AAC2.0][x265].mkv +[2025-08-24 20:18:59] Progress: 6500/14579 (updated: 255, cached: 0, misses: 25) +[2025-08-24 20:18:59] Processing file 6501/14579: iCarly (2007)-S05E05-iQ[WEBRip-1080p][AAC2.0][x265].mkv +[2025-08-24 20:19:00] Processing file 6526/14579: iCarly (2007)-S01E23-iCarly Saves TV[WEBRip-1080p][AAC2.0][x265].mkv +[2025-08-24 20:19:00] Progress: 6550/14579 (updated: 255, cached: 0, misses: 25) +[2025-08-24 20:19:00] Processing file 6551/14579: NCIS (2003)-S22E08-Out of Control[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:00] Processing file 6576/14579: Superman & Lois (2021)-S04E09-To Live and Die Again[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:01] Progress: 6600/14579 (updated: 255, cached: 0, misses: 25) +[2025-08-24 20:19:01] Processing file 6601/14579: We Were Liars (2025)-S01E07-Everybody Knows That the Captain Lied[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:04] Processing file 6626/14579: The Equalizer (2021)-S04E08-Condemned[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:08] Progress: 6650/14579 (updated: 301, cached: 0, misses: 25) +[2025-08-24 20:19:08] Processing file 6651/14579: The Equalizer (2021)-S05E09-Stolen Angel[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:11] Processing file 6676/14579: The Equalizer (2021)-S02E09-Bout That Life[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:12] Progress: 6700/14579 (updated: 328, cached: 0, misses: 25) +[2025-08-24 20:19:12] Processing file 6701/14579: Black Doves (2024)-S01E03-The Coming Night[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:19:13] Processing file 6726/14579: Law & Order - Organized Crime (2021)-S04E04-The Last Supper[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:17] Progress: 6750/14579 (updated: 359, cached: 0, misses: 25) +[2025-08-24 20:19:17] Processing file 6751/14579: Testament - The Story of Moses (2024)-S01E02-The Plagues[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:19:18] Processing file 6776/14579: The Walking Dead - Daryl Dixon (2023)-S01E02-Alouette[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:20] Progress: 6800/14579 (updated: 372, cached: 0, misses: 25) +[2025-08-24 20:19:20] Processing file 6801/14579: Shrinking (2023)-S02E05-Honesty Era[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:21] Processing file 6826/14579: The Curse of Oak Island (2014)-S12E10-Graves Concerns[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:21] Progress: 6850/14579 (updated: 372, cached: 0, misses: 25) +[2025-08-24 20:19:21] Processing file 6851/14579: Shining Girls (2022)-S01E02-Evergreen[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:22] Processing file 6876/14579: The Orville (2017)-S01E03-About a Girl[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:22] Progress: 6900/14579 (updated: 372, cached: 0, misses: 25) +[2025-08-24 20:19:22] Processing file 6901/14579: The Spencer Sisters (2023)-S01E01-The Scholars Snafu[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:24] Processing file 6926/14579: Percy Jackson and the Olympians (2023)-S01E08-The Prophecy Comes True[WEBRip-1080p][HDR10][AAC2.0][h265].mkv +[2025-08-24 20:19:24] Progress: 6950/14579 (updated: 380, cached: 0, misses: 25) +[2025-08-24 20:19:24] Processing file 6951/14579: Ice Airport Alaska (2020)-S04E09-Runway Mayday[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:26] Processing file 6976/14579: Berlin Station (2016)-S02E09-Winners Right the History Books[WEBRip-1080p][AAC2.0][x264].mkv +[2025-08-24 20:19:28] Progress: 7000/14579 (updated: 396, cached: 0, misses: 25) +[2025-08-24 20:19:28] Processing file 7001/14579: The Fall Guy (1981)-S05E19-The Lady in Green[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:28] Processing file 7026/14579: The Fall Guy (1981)-S02E02-The Ives Have It[DVD][MP31.0].mkv +[2025-08-24 20:19:29] Progress: 7050/14579 (updated: 396, cached: 0, misses: 25) +[2025-08-24 20:19:29] Processing file 7051/14579: The Fall Guy (1981)-S01E10-License to Kill 2[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:19:29] Processing file 7076/14579: The Fall Guy (1981)-S03E12-Wheels[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:29] Progress: 7100/14579 (updated: 396, cached: 0, misses: 25) +[2025-08-24 20:19:29] Processing file 7101/14579: The Fall Guy (1981)-S04E05-Terror U[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:30] Processing file 7126/14579: Ballard (2025)-S01E09-Collateral[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:31] Progress: 7150/14579 (updated: 396, cached: 0, misses: 25) +[2025-08-24 20:19:31] Processing file 7151/14579: I Love Lucy (1951)-S03E29-Tennessee Ernie Hangs On[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:31] Processing file 7176/14579: I Love Lucy (1951)-S04E25-The Star Upstairs[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:31] Progress: 7200/14579 (updated: 396, cached: 0, misses: 25) +[2025-08-24 20:19:31] Processing file 7201/14579: I Love Lucy (1951)-S06E26-Lucy Raises Tulips[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:19:32] Processing file 7226/14579: I Love Lucy (1951)-S05E25-Lucy Goes to Monte Carlo[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:32] Progress: 7250/14579 (updated: 397, cached: 0, misses: 25) +[2025-08-24 20:19:32] Processing file 7251/14579: I Love Lucy (1951)-S05E06-Homecoming[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:32] Processing file 7276/14579: I Love Lucy (1951)-S02E03-The Anniversary Present[Bluray-1080p Remux][AAC2.0][h264].mkv +[2025-08-24 20:19:33] Progress: 7300/14579 (updated: 403, cached: 0, misses: 25) +[2025-08-24 20:19:33] Processing file 7301/14579: SEAL Team (2017)-S07E09-The Sea and the Hills[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:36] Processing file 7326/14579: Seinfeld (1989)-S04E03-E04-The Pitch and The Ticket[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:36] Progress: 7350/14579 (updated: 403, cached: 0, misses: 25) +[2025-08-24 20:19:36] Processing file 7351/14579: Seinfeld (1989)-S03E06-The Parking Garage[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:36] Processing file 7376/14579: Seinfeld (1989)-S09E15-The Wizard[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:36] Progress: 7400/14579 (updated: 403, cached: 0, misses: 25) +[2025-08-24 20:19:36] Processing file 7401/14579: Seinfeld (1989)-S07E24-The Invitations[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:36] Processing file 7426/14579: Seinfeld (1989)-S02E09-The Deal[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:36] Progress: 7450/14579 (updated: 403, cached: 0, misses: 25) +[2025-08-24 20:19:36] Processing file 7451/14579: Seinfeld (1989)-S05E05-The Bris[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:37] Processing file 7476/14579: Seinfeld (1989)-S06E16-The Beard[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:38] Progress: 7500/14579 (updated: 405, cached: 0, misses: 25) +[2025-08-24 20:19:38] Processing file 7501/14579: Yellowjackets (2021)-S02E04-Old Wounds[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:19:40] Processing file 7526/14579: Severance (2022)-S02E01-Hello Ms. Cobel[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:41] Progress: 7550/14579 (updated: 412, cached: 0, misses: 25) +[2025-08-24 20:19:41] Processing file 7551/14579: SuperKitties (2023)-S01E49-E50-Vanishing Valentines and Golden Gift[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:41] Processing file 7576/14579: Yellowstone (2018)-S04E03-All I See Is You[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:45] Progress: 7600/14579 (updated: 438, cached: 0, misses: 25) +[2025-08-24 20:19:45] Processing file 7601/14579: Yellowstone (2018)-S02E03-The Reek of Desperation[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:49] Processing file 7626/14579: Yellowstone (2018)-S01E04-The Long Black Train[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:52] Progress: 7650/14579 (updated: 465, cached: 0, misses: 25) +[2025-08-24 20:19:52] Processing file 7651/14579: The King of Queens (1998)-S01E05-Paternal Affairs[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:52] Processing file 7676/14579: The King of Queens (1998)-S06E06-Affidavit Justice[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:52] Progress: 7700/14579 (updated: 465, cached: 0, misses: 25) +[2025-08-24 20:19:52] Processing file 7701/14579: The King of Queens (1998)-S02E08-Dire Strayts[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:52] Processing file 7726/14579: The King of Queens (1998)-S05E11-Mentalo Case[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:52] Progress: 7750/14579 (updated: 465, cached: 0, misses: 25) +[2025-08-24 20:19:52] Processing file 7751/14579: The King of Queens (1998)-S07E13-Gorilla Warfare[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:53] Processing file 7776/14579: The King of Queens (1998)-S04E15-Dougie Nights[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:53] Progress: 7800/14579 (updated: 465, cached: 0, misses: 25) +[2025-08-24 20:19:53] Processing file 7801/14579: The King of Queens (1998)-S03E11-Better Camera[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:53] Processing file 7826/14579: The King of Queens (1998)-S09E09-Mild Bunch[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:55] Progress: 7850/14579 (updated: 471, cached: 0, misses: 25) +[2025-08-24 20:19:55] Processing file 7851/14579: The Recruit (2022)-S01E01-I.N.A.S.I.A.L[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:19:56] Processing file 7876/14579: Futurama (1999)-S09E10-Otherwise[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:19:58] Progress: 7900/14579 (updated: 488, cached: 0, misses: 25) +[2025-08-24 20:19:58] Processing file 7901/14579: Amy Bradley Is Missing (2025)-S01E03-Message in a Bottle[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:19:58] Processing file 7926/14579: The Penguin (2024)-S01E05-Homecoming[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:20:01] Progress: 7950/14579 (updated: 488, cached: 0, misses: 25) +[2025-08-24 20:20:01] Processing file 7951/14579: Parks and Recreation (2009)-S03E06-Indianapolis[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:02] Processing file 7976/14579: Parks and Recreation (2009)-S04E08-Smallest Park[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:02] Progress: 8000/14579 (updated: 488, cached: 0, misses: 25) +[2025-08-24 20:20:02] Processing file 8001/14579: Parks and Recreation (2009)-S06E01-London 1[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:02] Processing file 8026/14579: Parks and Recreation (2009)-S05E12-Anns Decision[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:02] Progress: 8050/14579 (updated: 488, cached: 0, misses: 25) +[2025-08-24 20:20:02] Processing file 8051/14579: Parks and Recreation (2009)-S02E09-The Camel[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:03] Processing file 8076/14579: Blue Bloods (2010)-S14E17-Entitlement[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:05] Progress: 8100/14579 (updated: 500, cached: 0, misses: 25) +[2025-08-24 20:20:05] Processing file 8101/14579: Big Nate (2022)-S02E11-The Real Kids of PS 38[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:07] Processing file 8126/14579: Ghosts (2019)-S01E01-Who Do You Think You Are[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:14] Progress: 8150/14579 (updated: 530, cached: 0, misses: 29) +[2025-08-24 20:20:14] Processing file 8151/14579: Weird But True! (2016)-S01E07-Survival 101[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:15] Processing file 8176/14579: The Ark (2023)-S01E04-We Werent Supposed to Be Awake[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:17] Progress: 8200/14579 (updated: 546, cached: 0, misses: 29) +[2025-08-24 20:20:17] Processing file 8201/14579: Too Much (2025)-S01E04-Notting Kill[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:20:19] Processing file 8226/14579: Marvel's Daredevil (2015)-S02E06-Regrets Only[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:20] Progress: 8250/14579 (updated: 557, cached: 0, misses: 29) +[2025-08-24 20:20:20] Processing file 8251/14579: Penny Dreadful - City of Angels (2020)-S01E08-Hide and Seek[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:24] Processing file 8276/14579: Border Security - America's Front Line (2016)-S01E21-Episode 21[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:20:28] Progress: 8300/14579 (updated: 591, cached: 0, misses: 29) +[2025-08-24 20:20:28] Processing file 8301/14579: Border Security - America's Front Line (2016)-S02E11-Episode 11[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:20:33] Processing file 8326/14579: Big Time Rush (2009)-S04E05-Big Time Cameo[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:33] Progress: 8350/14579 (updated: 607, cached: 0, misses: 29) +[2025-08-24 20:20:33] Processing file 8351/14579: Big Time Rush (2009)-S02E19-Big Time Single[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:34] Processing file 8376/14579: Big Time Rush (2009)-S01E11-Big Time Jobs[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:34] Progress: 8400/14579 (updated: 607, cached: 0, misses: 29) +[2025-08-24 20:20:34] Processing file 8401/14579: Sherlock (2010)-S04E01-The Six Thatchers[Bluray-1080p][AAC2.0][x265].mkv +[2025-08-24 20:20:36] Processing file 8426/14579: Happy Face (2025)-S01E02-Killing Shame[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:36] Progress: 8450/14579 (updated: 620, cached: 0, misses: 29) +[2025-08-24 20:20:36] Processing file 8451/14579: Top of the Lake (2013)-S02E01-China Girl[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:37] Processing file 8476/14579: Duck Family Treasure (2022)-S01E10-#mensnightout[WEBRip-1080p][AAC2.0][x264].mkv +[2025-08-24 20:20:40] Progress: 8500/14579 (updated: 628, cached: 0, misses: 29) +[2025-08-24 20:20:40] Processing file 8501/14579: Fancy Nancy (2018)-S03E11-Joie de Jonathan[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:40] Processing file 8526/14579: Fancy Nancy (2018)-S02E29-E30-Le Boy Next Door and Nancys Looth Tooth[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:41] Progress: 8550/14579 (updated: 628, cached: 0, misses: 29) +[2025-08-24 20:20:41] Processing file 8551/14579: Fancy Nancy (2018)-S01E09-E10-Nancys Devine Sleepover and Nancys Sacré Bleu Fondue[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:41] Processing file 8576/14579: Supacell (2024)-S01E05-Rodney[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:20:46] Progress: 8600/14579 (updated: 652, cached: 0, misses: 29) +[2025-08-24 20:20:46] Processing file 8601/14579: FBI - Most Wanted (2020)-S06E17-Gut Job[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:47] Processing file 8626/14579: Torchwood (2006)-S04E06-The Middle Men[Bluray-1080p Remux][AAC2.0][h264].mkv +[2025-08-24 20:20:48] Progress: 8650/14579 (updated: 665, cached: 0, misses: 29) +[2025-08-24 20:20:48] Processing file 8651/14579: Torchwood (2006)-S01E05-Small Worlds[Bluray-1080p Remux][AC35.1][VC1].mkv +[2025-08-24 20:20:50] Processing file 8676/14579: The Rookie (2018)-S07E10-Chaos Agent[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:52] Progress: 8700/14579 (updated: 679, cached: 0, misses: 29) +[2025-08-24 20:20:52] Processing file 8701/14579: Dirty Jobs (2005)-S04E17-Spider Pharm[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:52] Processing file 8726/14579: Dirty Jobs (2005)-S03E06-Vomit Island Workers[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:52] Progress: 8750/14579 (updated: 679, cached: 0, misses: 29) +[2025-08-24 20:20:52] Processing file 8751/14579: Dirty Jobs (2005)-S05E07-Rocky Reach Dam[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:53] Processing file 8776/14579: Gordon Ramsay - Uncharted (2019)-S03E06-The Great Smoky Mountains[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:57] Progress: 8800/14579 (updated: 712, cached: 0, misses: 29) +[2025-08-24 20:20:57] Processing file 8801/14579: Toxic (2025)-S01E06-Chasing a Ghost[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:20:59] Processing file 8826/14579: M-A-S-H (1972)-S05E14-The Most Unforgettable Characters[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:03] Progress: 8850/14579 (updated: 752, cached: 0, misses: 29) +[2025-08-24 20:21:03] Processing file 8851/14579: M-A-S-H (1972)-S02E18-Operation Noselift[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:07] Processing file 8876/14579: M-A-S-H (1972)-S08E17-Heal Thyself[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:11] Progress: 8900/14579 (updated: 802, cached: 0, misses: 29) +[2025-08-24 20:21:11] Processing file 8901/14579: M-A-S-H (1972)-S11E12-Say No More[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:15] Processing file 8926/14579: M-A-S-H (1972)-S06E18-Your Hit Parade[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:19] Progress: 8950/14579 (updated: 852, cached: 0, misses: 29) +[2025-08-24 20:21:19] Processing file 8951/14579: M-A-S-H (1972)-S01E20-The Army-Navy Game[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:23] Processing file 8976/14579: M-A-S-H (1972)-S10E14-The Tooth Shall Set You Free[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:26] Progress: 9000/14579 (updated: 902, cached: 0, misses: 29) +[2025-08-24 20:21:26] Processing file 9001/14579: M-A-S-H (1972)-S03E09-Alcoholics Unanimous[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:30] Processing file 9026/14579: M-A-S-H (1972)-S04E21-Smilin Jack[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:33] Progress: 9050/14579 (updated: 952, cached: 0, misses: 29) +[2025-08-24 20:21:33] Processing file 9051/14579: M-A-S-H (1972)-S07E14-Dear Sis[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:36] Processing file 9076/14579: High Potential (2024)-S01E04-Survival Mode[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:37] Progress: 9100/14579 (updated: 971, cached: 0, misses: 29) +[2025-08-24 20:21:37] Processing file 9101/14579: The Sopranos (1999)-S01E10-A Hit Is a Hit[Bluray-1080p][Opus5.1][AV1].mkv +[2025-08-24 20:21:38] Processing file 9126/14579: The Brokenwood Mysteries (2014)-S11E05-The End of the Line[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:39] Progress: 9150/14579 (updated: 978, cached: 0, misses: 29) +[2025-08-24 20:21:39] Processing file 9151/14579: Wildcard Kitchen (2024)-S02E03-James Beard Brawl[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:21:45] Processing file 9176/14579: Dino Ranch (2021)-S03E39-E40-Good Ol Goliath and Quacks Nest of Friends[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:45] Progress: 9200/14579 (updated: 994, cached: 0, misses: 29) +[2025-08-24 20:21:45] Processing file 9201/14579: Dino Ranch (2021)-S02E51-E52-Twister with a Chance of Thundertot and Flight of the Bubling Ogie[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:45] Processing file 9226/14579: Dino Ranch (2021)-S01E37-E38-Ranch-a-Pal-ooza and Skys the Limit[WEBDL-720p][AAC2.0][h264].mkv +[2025-08-24 20:21:46] Progress: 9250/14579 (updated: 994, cached: 0, misses: 29) +[2025-08-24 20:21:46] Processing file 9251/14579: Dino Ranch (2021)-S01E46-Ridin Mighty[WEBDL-720p][AAC2.0][x264].mkv +[2025-08-24 20:21:46] Processing file 9276/14579: The Bondsman (2025)-S01E02-Valacor[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:48] Progress: 9300/14579 (updated: 1004, cached: 0, misses: 29) +[2025-08-24 20:21:48] Processing file 9301/14579: Hotel Portofino (2022)-S02E06-Farewells[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:49] Processing file 9326/14579: Strawberry Shortcake (2003)-S02E03-Horse of a Different Color[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:21:49] Progress: 9350/14579 (updated: 1004, cached: 0, misses: 29) +[2025-08-24 20:21:49] Processing file 9351/14579: Lady in the Lake (2024)-S01E06-I know who killed Cleo Johnson[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:51] Processing file 9376/14579: PJ Masks (2015)-S03E17-E18-Secret of the Pagoda and Storm of the Ninja[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:21:52] Progress: 9400/14579 (updated: 1004, cached: 0, misses: 29) +[2025-08-24 20:21:52] Processing file 9401/14579: PJ Masks (2015)-S05E35-The Power of Mystery Mountain[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:52] Processing file 9426/14579: The Regime (2024)-S01E03-The Heroes Banquet[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:21:52] Progress: 9450/14579 (updated: 1004, cached: 0, misses: 29) +[2025-08-24 20:21:52] Processing file 9451/14579: Marvel's Iron Fist (2017)-S02E06-The Dragon Dies at Dawn[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:21:53] Processing file 9476/14579: Bubble Guppies (2011)-S06E25-The Fastest Feather in the Race![WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:56] Progress: 9500/14579 (updated: 1018, cached: 0, misses: 29) +[2025-08-24 20:21:56] Processing file 9501/14579: Matlock (2024)-S01E15-Game Face[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:57] Processing file 9526/14579: Reacher (2022)-S01E02-First Dance[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:58] Progress: 9550/14579 (updated: 1023, cached: 0, misses: 29) +[2025-08-24 20:21:58] Processing file 9551/14579: The Mega-Brands That Built America (2023)-S01E01-Battle of the Superstores[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:59] Processing file 9576/14579: The Wire (2002)-S03E08-Moral Midgetry[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:21:59] Progress: 9600/14579 (updated: 1023, cached: 0, misses: 29) +[2025-08-24 20:21:59] Processing file 9601/14579: The Wire (2002)-S02E02-Collateral Damage[HDTV-1080p][AAC5.1][h264].mkv +[2025-08-24 20:22:01] Processing file 9626/14579: The Flash (2014)-S07E17-Heart of the Matter 1[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:22:05] Progress: 9650/14579 (updated: 1059, cached: 0, misses: 29) +[2025-08-24 20:22:05] Processing file 9651/14579: The Flash (2014)-S03E09-The Present[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:22:08] Processing file 9676/14579: The Flash (2014)-S04E20-Therefore She Is[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:22:12] Progress: 9700/14579 (updated: 1109, cached: 0, misses: 29) +[2025-08-24 20:22:12] Processing file 9701/14579: The Flash (2014)-S06E07-The Last Temptation of Barry Allen 1[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:22:16] Processing file 9726/14579: The Flash (2014)-S01E06-The Flash is Born[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:22:19] Progress: 9750/14579 (updated: 1159, cached: 0, misses: 29) +[2025-08-24 20:22:19] Processing file 9751/14579: The Flash (2014)-S05E22-Legacy[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:22:22] Processing file 9776/14579: The Flash (2014)-S02E20-Rupture[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:22:26] Progress: 9800/14579 (updated: 1209, cached: 0, misses: 29) +[2025-08-24 20:22:26] Processing file 9801/14579: Last Man Standing (2011)-S09E14-The Two Nieces of Eve[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:31] Processing file 9826/14579: New Girl (2011)-S03E07-Coach[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:31] Progress: 9850/14579 (updated: 1228, cached: 0, misses: 29) +[2025-08-24 20:22:31] Processing file 9851/14579: New Girl (2011)-S04E18-Walk of Shame[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:31] Processing file 9876/14579: New Girl (2011)-S06E02-Hubbedy Bubby[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:31] Progress: 9900/14579 (updated: 1228, cached: 0, misses: 29) +[2025-08-24 20:22:31] Processing file 9901/14579: New Girl (2011)-S01E10-The Story of the 50[WEBDL-1080p][AAC5.1][AV1].mkv +[2025-08-24 20:22:31] Processing file 9926/14579: New Girl (2011)-S05E16-Helmet[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:32] Progress: 9950/14579 (updated: 1228, cached: 0, misses: 29) +[2025-08-24 20:22:32] Processing file 9951/14579: New Girl (2011)-S02E03-Fluffer[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:32] Processing file 9976/14579: Dinosaurs (1991)-S02E03-I Never Ate for My Father[DVD][AAC2.0].mkv +[2025-08-24 20:22:33] Progress: 10000/14579 (updated: 1228, cached: 0, misses: 29) +[2025-08-24 20:22:33] Processing file 10001/14579: Dinosaurs (1991)-S03E17-Swamp Music[DVD][AAC2.0].mkv +[2025-08-24 20:22:33] Processing file 10026/14579: Investigation Alien (2024)-S01E06-Alien Identity[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:34] Progress: 10050/14579 (updated: 1233, cached: 0, misses: 29) +[2025-08-24 20:22:34] Processing file 10051/14579: Government Cheese (2025)-S01E08-An Evening with Abraham Cohen[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:37] Processing file 10076/14579: Found (2023)-S02E03-Missing While Lonely[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:39] Progress: 10100/14579 (updated: 1273, cached: 0, misses: 29) +[2025-08-24 20:22:39] Processing file 10101/14579: Richard Hammond's Workshop (2021)-S04E07-Heebie Jeebies[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:41] Processing file 10126/14579: The Motherhood (2025)-S01E04-Reaching for the Stars[WEBRip-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:42] Progress: 10150/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:42] Processing file 10151/14579: Grosse Pointe Garden Society (2025)-S01E11-Monaco Under the Stars[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:43] Processing file 10176/14579: Dexter (2006)-S02E09-Resistance Is Futile[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:44] Progress: 10200/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:44] Processing file 10201/14579: Game of Thrones (2011)-S05E05-Kill the Boy[Bluray-1080p Remux][TrueHD Atmos7.1][h265].mkv +[2025-08-24 20:22:44] Processing file 10226/14579: Game of Thrones (2011)-S07E01-Dragonstone[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:22:47] Progress: 10250/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:47] Processing file 10251/14579: Happy Days (1974)-S04E06-Fonzies Hero[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:22:48] Processing file 10276/14579: Happy Days (1974)-S03E13-They Call It Potsie Love[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:22:48] Progress: 10300/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:48] Processing file 10301/14579: Happy Days (1974)-S01E10-Give the Band a Hand[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:22:48] Processing file 10326/14579: Happy Days (1974)-S02E19-Fonzie Joins the Band[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:22:48] Progress: 10350/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:48] Processing file 10351/14579: Happy Days (1974)-S05E16-Joanies First Kiss[DVD][AAC2.0][MPEG2].mkv +[2025-08-24 20:22:53] Processing file 10376/14579: The Love Boat (1977)-S07E05-Rhino Of The Year One Last Time For Love or Money[WEBDL-720p][AAC2.0][x264].mkv +[2025-08-24 20:22:53] Progress: 10400/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:53] Processing file 10401/14579: The Love Boat (1977)-S04E13-Isaacs Secret Seal of Approval The Curse of the Dumbrowskis[WEBDL-720p][AAC2.0][x264].mkv +[2025-08-24 20:22:53] Processing file 10426/14579: The Love Boat (1977)-S09E22-Spain Cruise The Matadors Mrs. Jameson Comes Out Loves Labors Found Marry Me Marry Me 1[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:53] Progress: 10450/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:53] Processing file 10451/14579: The Love Boat (1977)-S03E26-Vickis First Love Accident Prone The High Cost of Loving[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:54] Processing file 10476/14579: The Love Boat (1977)-S01E23-This Business of Love Crash Diet Crush Ill Never Fall in Love Again[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:54] Progress: 10500/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:54] Processing file 10501/14579: The Love Boat (1977)-S06E01-Venetian Love Song Down for the Count Arrividerci Gopher The Arrangement 1[WEBDL-720p][AAC2.0][x264].mkv +[2025-08-24 20:22:54] Processing file 10526/14579: The Love Boat (1977)-S02E27-Third Wheel Grandmothers Day Second String Mom[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:54] Progress: 10550/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:54] Processing file 10551/14579: The Love Boat (1977)-S02E18-Disco Baby Alas Poor Dwyer After the War Ticket to Ride Itsy Bitsy Part 1[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:55] Processing file 10576/14579: The Love Boat (1977)-S08E21-Ace Takes a Holiday The Runaway The Courier[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:55] Progress: 10600/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:55] Processing file 10601/14579: The Love Boat (1977)-S05E19-New York A.C. Live It Up Alls Fair in Love and War[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:22:56] Processing file 10626/14579: The Bear (2022)-S04E07-Bears[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:56] Progress: 10650/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:56] Processing file 10651/14579: The Bear (2022)-S01E07-Review[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:57] Processing file 10676/14579: The Brady Bunch (1969)-S02E19-The Liberation of Marcia Brady[DVD][AAC2.0][x264].mkv +[2025-08-24 20:22:57] Progress: 10700/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:57] Processing file 10701/14579: The Brady Bunch (1969)-S01E12-The Voice of Christmas[DVD][AAC2.0][x264].mkv +[2025-08-24 20:22:59] Processing file 10726/14579: For All Mankind (2019)-S02E08-And Heres to You[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:59] Progress: 10750/14579 (updated: 1282, cached: 0, misses: 29) +[2025-08-24 20:22:59] Processing file 10751/14579: For All Mankind (2019)-S04E02-Have a Nice Sol[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:22:59] Processing file 10776/14579: Missing You (2025)-S01E03-Never Too Much[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:23:01] Progress: 10800/14579 (updated: 1287, cached: 0, misses: 29) +[2025-08-24 20:23:01] Processing file 10801/14579: Chicago P.D. (2014)-S12E06-Pawns[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:03] Processing file 10826/14579: NCIS - Origins (2024)-S01E16-Bugs[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:06] Progress: 10850/14579 (updated: 1305, cached: 0, misses: 29) +[2025-08-24 20:23:06] Processing file 10851/14579: Halt and Catch Fire (2014)-S01E05-Adventure[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:06] Processing file 10876/14579: Halt and Catch Fire (2014)-S03E05-Yerba Buena[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:08] Progress: 10900/14579 (updated: 1314, cached: 0, misses: 29) +[2025-08-24 20:23:08] Processing file 10901/14579: Will Trent (2023)-S03E08-Abigail B[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:09] Processing file 10926/14579: Disclaimer (2024)-S01E01-I[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:11] Progress: 10950/14579 (updated: 1324, cached: 0, misses: 29) +[2025-08-24 20:23:11] Processing file 10951/14579: 24 in 24 - Last Chef Standing (2024)-S02E06-Shift 6 Teamwork[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:13] Processing file 10976/14579: Only Murders in the Building (2021)-S04E07-Valley of the Dolls[DSNPWEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:14] Progress: 11000/14579 (updated: 1332, cached: 0, misses: 29) +[2025-08-24 20:23:14] Processing file 11001/14579: Bad Sisters (2022)-S01E04-Baby Becka[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:16] Processing file 11026/14579: Mayor of Kingstown (2021)-S02E06-Left With the Nose[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:16] Progress: 11050/14579 (updated: 1334, cached: 0, misses: 29) +[2025-08-24 20:23:16] Processing file 11051/14579: Mayor of Kingstown (2021)-S03E06-Ecotone[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:28] Processing file 11076/14579: Criminal Minds (2005)-S06E02-JJ[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:28] Progress: 11100/14579 (updated: 1341, cached: 0, misses: 29) +[2025-08-24 20:23:28] Processing file 11101/14579: Criminal Minds (2005)-S15E08-Family Tree[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:29] Processing file 11126/14579: Criminal Minds (2005)-S12E01-The Crimson King[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:29] Progress: 11150/14579 (updated: 1350, cached: 0, misses: 29) +[2025-08-24 20:23:29] Processing file 11151/14579: Criminal Minds (2005)-S01E11-Blood Hungry[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:29] Processing file 11176/14579: Criminal Minds (2005)-S05E20-A Thousand Words[WEBRip-1080p][AAC2.0][h265].mkv +[2025-08-24 20:23:29] Progress: 11200/14579 (updated: 1350, cached: 0, misses: 29) +[2025-08-24 20:23:29] Processing file 11201/14579: Criminal Minds (2005)-S11E04-Outlaw[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:23:30] Processing file 11226/14579: Criminal Minds (2005)-S08E08-The Wheels on the Bus[WEBRip-1080p][AAC2.0][h265].mkv +[2025-08-24 20:23:30] Progress: 11250/14579 (updated: 1350, cached: 0, misses: 29) +[2025-08-24 20:23:30] Processing file 11251/14579: Criminal Minds (2005)-S02E04-Psychodrama[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:30] Processing file 11276/14579: Criminal Minds (2005)-S13E06-The Bunker[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:30] Progress: 11300/14579 (updated: 1350, cached: 0, misses: 29) +[2025-08-24 20:23:30] Processing file 11301/14579: Criminal Minds (2005)-S07E08-Hope[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:30] Processing file 11326/14579: Criminal Minds (2005)-S03E08-Lucky[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:31] Progress: 11350/14579 (updated: 1350, cached: 0, misses: 29) +[2025-08-24 20:23:31] Processing file 11351/14579: Criminal Minds (2005)-S09E02-The Inspired 2[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:23:31] Processing file 11376/14579: Criminal Minds (2005)-S10E19-Beyond Borders[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:31] Progress: 11400/14579 (updated: 1350, cached: 0, misses: 29) +[2025-08-24 20:23:31] Processing file 11401/14579: Criminal Minds (2005)-S04E01-Mayhem[WEBRip-1080p][AAC2.0][h265].mkv +[2025-08-24 20:23:32] Processing file 11426/14579: Night Court (2023)-S03E14-Hot to Trot[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:35] Progress: 11450/14579 (updated: 1379, cached: 0, misses: 29) +[2025-08-24 20:23:35] Processing file 11451/14579: Night Court (2023)-S02E05-Hold the Pickles Keep the Change[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:37] Processing file 11476/14579: The Expanse (2015)-S03E11-Fallen World[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:37] Progress: 11500/14579 (updated: 1381, cached: 0, misses: 29) +[2025-08-24 20:23:37] Processing file 11501/14579: The Expanse (2015)-S01E03-Remember the Cant[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:37] Processing file 11526/14579: The Expanse (2015)-S02E07-The Seventh Man[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:23:38] Progress: 11550/14579 (updated: 1381, cached: 0, misses: 29) +[2025-08-24 20:23:38] Processing file 11551/14579: The Kardashians (2022)-S06E01-Literally Falling Apart[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:39] Processing file 11576/14579: Twisted Metal (2023)-S02E04-LZGTBZY[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:41] Progress: 11600/14579 (updated: 1394, cached: 0, misses: 29) +[2025-08-24 20:23:41] Processing file 11601/14579: The Gentlemen (2024)-S01E04-An Unsympathetic Gentleman[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:23:43] Processing file 11626/14579: The Secret Lives of Mormon Wives (2024)-S01E01-The First Book of Taylor[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:44] Processing file 11651/14579: Dish with Kish (2024)-S01E03-Top Croquette[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:23:45] Processing file 11676/14579: Below Deck Down Under (2022)-S03E01-Seychelles by the Seashore[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:47] Progress: 11700/14579 (updated: 1412, cached: 0, misses: 56) +[2025-08-24 20:23:47] Processing file 11701/14579: The Coroner (2015)-S02E06-Life[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:49] Processing file 11726/14579: Leave It to Beaver (1957)-S04E25-Wally and Dudley[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:23:49] Progress: 11750/14579 (updated: 1412, cached: 0, misses: 56) +[2025-08-24 20:23:49] Processing file 11751/14579: Leave It to Beaver (1957)-S04E12-Beaver and Kenneth[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:49] Processing file 11776/14579: Leave It to Beaver (1957)-S03E13-Junes Birthday[Bluray-1080p Remux][AAC2.0][AV1].mkv +[2025-08-24 20:23:49] Progress: 11800/14579 (updated: 1412, cached: 0, misses: 56) +[2025-08-24 20:23:49] Processing file 11801/14579: Leave It to Beaver (1957)-S02E24-The Bus Ride[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:23:50] Processing file 11826/14579: Leave It to Beaver (1957)-S02E32-Friendship[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:50] Progress: 11850/14579 (updated: 1412, cached: 0, misses: 56) +[2025-08-24 20:23:50] Processing file 11851/14579: Leave It to Beaver (1957)-S05E08-Wallys Big Date[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:50] Processing file 11876/14579: Leave It to Beaver (1957)-S01E39-Cat Out of the Bag[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:23:50] Progress: 11900/14579 (updated: 1412, cached: 0, misses: 56) +[2025-08-24 20:23:50] Processing file 11901/14579: Leave It to Beaver (1957)-S01E11-Beavers Short Pants[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:23:50] Processing file 11926/14579: Leave It to Beaver (1957)-S06E37-Beaver Sees America[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:51] Progress: 11950/14579 (updated: 1412, cached: 0, misses: 56) +[2025-08-24 20:23:51] Processing file 11951/14579: Leave It to Beaver (1957)-S06E33-Summer in Alaska[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:23:53] Processing file 11976/14579: FBI - International (2021)-S04E13-Youve Been Greenlit[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:56] Progress: 12000/14579 (updated: 1449, cached: 0, misses: 56) +[2025-08-24 20:23:56] Processing file 12001/14579: 1923 (2022)-S02E01-The Killing Season[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:23:56] Processing file 12026/14579: Supergirl (2015)-S01E15-Solitude[WEBDL-1080p][AAC2.0][AV1].mkv +[2025-08-24 20:23:57] Progress: 12050/14579 (updated: 1451, cached: 0, misses: 56) +[2025-08-24 20:23:57] Processing file 12051/14579: Dope Thief (2025)-S01E06-Love Songs from Mars[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:24:17] Processing file 12076/14579: Max & Ruby (2002)-S05E67-E69-Max and Ruby Give Thanks and Max Leaves and Rubys Fall Pageant[WEBDL-1080p][AAC2.0][x264].mp4 +[2025-08-24 20:24:20] Processing file 12101/14579: Max & Ruby (2002)-S06E23-E24-Max and Winston and Grandmas Bunny Sniffles[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:24:32] Processing file 12126/14579: Max & Ruby (2002)-S01E16-E18-Rubys Merit Badge and Maxs Apple and Quiet Max![WEBDL-480p][AAC2.0][h264].mkv +[2025-08-24 20:24:35] Progress: 12150/14579 (updated: 1521, cached: 0, misses: 69) +[2025-08-24 20:24:35] Processing file 12151/14579: Max & Ruby (2002)-S03E25-E27-Maxs Snow Day and Maxs Snow Bunny and Maxs Mix Up[WEBDL-480p][AAC2.0][h264].mkv +[2025-08-24 20:24:42] Processing file 12176/14579: Max & Ruby (2002)-S07E17-E18-Robo-Max and Let it Snow[WEBDL-1080p][AAC2.0][x264].mp4 +[2025-08-24 20:24:47] Progress: 12200/14579 (updated: 1523, cached: 0, misses: 83) +[2025-08-24 20:24:47] Processing file 12201/14579: Animal Control (2023)-S03E02-Rattlers and Gators[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:24:50] Processing file 12226/14579: Law & Order - Criminal Intent (2001)-S02E12-Suite Sorrow[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:24:54] Progress: 12250/14579 (updated: 1565, cached: 0, misses: 83) +[2025-08-24 20:24:54] Processing file 12251/14579: Law & Order - Criminal Intent (2001)-S01E01-One[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:24:58] Processing file 12276/14579: Taken Together - Who Killed Lyric and Elizabeth! (2024)-S01E02-Episode 2[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:24:59] Progress: 12300/14579 (updated: 1592, cached: 0, misses: 83) +[2025-08-24 20:24:59] Processing file 12301/14579: The Pitt (2025)-S01E15-9-00 P.M[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:02] Processing file 12326/14579: Better Call Saul (2015)-S05E09-Bad Choice Road[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:25:03] Progress: 12350/14579 (updated: 1608, cached: 0, misses: 83) +[2025-08-24 20:25:03] Processing file 12351/14579: The Acolyte (2024)-S01E06-Teach Corrupt[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:04] Processing file 12376/14579: Franklin (1997)-S01E13-Franklin and the Baby Franklin Goes to Day Camp[DVD][MP32.0].avi +[2025-08-24 20:25:04] Progress: 12400/14579 (updated: 1645, cached: 0, misses: 83) +[2025-08-24 20:25:04] Processing file 12401/14579: Franklin (1997)-S02E13-Franklins Test Franklin and the Duckling[DVD][MP32.0][XviD].avi +[2025-08-24 20:25:04] Processing file 12426/14579: Franklin (1997)-S04E04-Franklins Sailboat Franklin Snoops[DVD][MP32.0][XviD].avi +[2025-08-24 20:25:06] Progress: 12450/14579 (updated: 1687, cached: 0, misses: 83) +[2025-08-24 20:25:06] Processing file 12451/14579: Impractical Jokers - Dinner Party (2020)-S01E05-The PBandJ Episode[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:12] Processing file 12476/14579: Little Bear (1995)-S03E01-Father Bears Nightshirt[WEBDL-480p][AAC2.0][x264].mkv +[2025-08-24 20:25:12] Progress: 12500/14579 (updated: 1720, cached: 0, misses: 83) +[2025-08-24 20:25:12] Processing file 12501/14579: Little Bear (1995)-S04E04-Sleep Over[WEBDL-480p][AAC2.0][x264].mkv +[2025-08-24 20:25:13] Processing file 12526/14579: Little Bear (1995)-S01E40-E42-Little Bear the Magician and Doctor Little Bear and Bigger Little Bear[SDTV]{[Mediainfo AudioCodec}[h264].mp4 +[2025-08-24 20:25:13] Progress: 12550/14579 (updated: 1770, cached: 0, misses: 83) +[2025-08-24 20:25:13] Processing file 12551/14579: Little Bear (1995)-S02E31-E33-Picnic on Pudding Hill and Little Bears Walkabout and Little Bears Secret Friend[SDTV][EAC32.0][x264].mp4 +[2025-08-24 20:25:14] Processing file 12576/14579: The Witcher (2019)-S02E08-Family[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:14] Progress: 12600/14579 (updated: 1791, cached: 0, misses: 83) +[2025-08-24 20:25:14] Processing file 12601/14579: Industry (2020)-S03E04-White Mischief[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:17] Processing file 12626/14579: Frasier (1993)-S05E04-The Kid[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:17] Progress: 12650/14579 (updated: 1791, cached: 0, misses: 83) +[2025-08-24 20:25:17] Processing file 12651/14579: Frasier (1993)-S11E07-Maris Returns 1[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:17] Processing file 12676/14579: Frasier (1993)-S08E16-Docu.Drama[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:17] Progress: 12700/14579 (updated: 1791, cached: 0, misses: 83) +[2025-08-24 20:25:17] Processing file 12701/14579: Frasier (1993)-S06E20-Dr. Nora[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:17] Processing file 12726/14579: Frasier (1993)-S01E20-Fortysomething[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:25:18] Progress: 12750/14579 (updated: 1791, cached: 0, misses: 83) +[2025-08-24 20:25:18] Processing file 12751/14579: Frasier (1993)-S09E12-Mother Load 1[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:18] Processing file 12776/14579: Frasier (1993)-S03E16-Look Before You Leap[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:18] Progress: 12800/14579 (updated: 1791, cached: 0, misses: 83) +[2025-08-24 20:25:18] Processing file 12801/14579: Frasier (1993)-S10E07-Bristle While You Work 1[Bluray-1080p Remux][AAC2.0][h265].mkv +[2025-08-24 20:25:18] Processing file 12826/14579: Frasier (1993)-S04E21-Daphne Hates Sherry[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:18] Progress: 12850/14579 (updated: 1791, cached: 0, misses: 83) +[2025-08-24 20:25:18] Processing file 12851/14579: Frasier (1993)-S07E23-Something Borrowed Someone Blue 1[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:19] Processing file 12876/14579: The Old Man (2022)-S01E02-II[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:19] Progress: 12900/14579 (updated: 1791, cached: 0, misses: 83) +[2025-08-24 20:25:19] Processing file 12901/14579: Survivor (2000)-S48E03-Committing to the Bit[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:22] Processing file 12926/14579: The Good Doctor (2017)-S07E05-Who at Peace[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:22] Progress: 12950/14579 (updated: 1810, cached: 0, misses: 83) +[2025-08-24 20:25:22] Processing file 12951/14579: Frasier (2023)-S01E08-The B Story[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:23] Processing file 12976/14579: When Hope Calls (2019)-S01E05-A House United[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:25] Progress: 13000/14579 (updated: 1833, cached: 0, misses: 83) +[2025-08-24 20:25:25] Processing file 13001/14579: Lovecraft Country (2020)-S01E07-I Am[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:26] Processing file 13026/14579: Tokyo Vice (2022)-S02E10-Endgame[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:28] Progress: 13050/14579 (updated: 1844, cached: 0, misses: 83) +[2025-08-24 20:25:28] Processing file 13051/14579: The Grand Tour (2016)-S03E05-An Itchy Urus[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:31] Processing file 13076/14579: The Grand Tour (2016)-S01E01-The Holy Trinity[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:35] Progress: 13100/14579 (updated: 1848, cached: 0, misses: 84) +[2025-08-24 20:25:35] Processing file 13101/14579: Tracker (2024)-S02E06-Trust Fall[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:36] Processing file 13126/14579: House of the Dragon (2022)-S01E03-Second of His Name[Bluray-1080p Remux][TrueHD Atmos7.1][h265].mkv +[2025-08-24 20:25:38] Progress: 13150/14579 (updated: 1851, cached: 0, misses: 84) +[2025-08-24 20:25:38] Processing file 13151/14579: Women in Blue (2024)-S01E04-Alma[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:40] Processing file 13176/14579: Squid Game (2021)-S01E01-Red Light Green Light[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:41] Progress: 13200/14579 (updated: 1879, cached: 0, misses: 84) +[2025-08-24 20:25:41] Processing file 13201/14579: SAS Rogue Heroes (2022)-S02E02-Episode 2[HDTV-1080p][EAC35.1][h264].mkv +[2025-08-24 20:25:41] Processing file 13226/14579: The Mandalorian (2019)-S03E06-Chapter 22 Guns for Hire[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:43] Progress: 13250/14579 (updated: 1881, cached: 0, misses: 84) +[2025-08-24 20:25:43] Processing file 13251/14579: The Forsyte Saga (2002)-S02E03-Episode 3[WEBDL-480p][AAC2.0][x264].mkv +[2025-08-24 20:25:45] Processing file 13276/14579: GOOD BOY (2025)-S01E10-Time-out[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:46] Progress: 13300/14579 (updated: 1898, cached: 0, misses: 84) +[2025-08-24 20:25:46] Processing file 13301/14579: American Ninja Warrior (2009)-S07E03-Houston Qualifying[SDTV][AAC2.0][x264].mp4 +[2025-08-24 20:25:46] Processing file 13326/14579: American Ninja Warrior (2009)-S16E11-Las Vegas Finals 4[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:47] Progress: 13350/14579 (updated: 1898, cached: 0, misses: 84) +[2025-08-24 20:25:47] Processing file 13351/14579: My Name is Gabriel (2024)-S01E07-Episode 7[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:48] Processing file 13376/14579: Home Town (2016)-S09E02-Unlimited Dreams[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:48] Progress: 13400/14579 (updated: 1922, cached: 0, misses: 84) +[2025-08-24 20:25:48] Processing file 13401/14579: Saturday Night Live (1975)-S50E18-Jon Hamm Lizzo[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:49] Processing file 13426/14579: The Chosen (2019)-S01E08-I Am He[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:50] Processing file 13451/14579: James May's Great Explorers (2025)-S01E01-Christopher Columbus[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:51] Processing file 13476/14579: American Ninja Warrior Junior (2018)-S03E12-Quarterfinal 4[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:25:52] Progress: 13500/14579 (updated: 1953, cached: 0, misses: 87) +[2025-08-24 20:25:52] Processing file 13501/14579: Silo (2023)-S02E09-The Safeguard[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:53] Processing file 13526/14579: The Night Agent (2023)-S02E01-Call Tracking[HDTV-1080p][DV HDR10][AAC2.0][h265].mkv +[2025-08-24 20:25:55] Progress: 13550/14579 (updated: 1963, cached: 0, misses: 87) +[2025-08-24 20:25:55] Processing file 13551/14579: The Food That Built America (2019)-S03E04-A Dish Best Served. Soft[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:55] Processing file 13576/14579: The Food That Built America (2019)-S04E03-Flight of the Buffalo Wing[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:56] Progress: 13600/14579 (updated: 1963, cached: 0, misses: 87) +[2025-08-24 20:25:56] Processing file 13601/14579: The Food That Built America (2019)-S02E07-Cookie Wars[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:25:59] Processing file 13626/14579: Young Sheldon (2017)-S05E07-An Introduction to Engineering and a Glob of Hair Gel[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:00] Progress: 13650/14579 (updated: 1964, cached: 0, misses: 87) +[2025-08-24 20:26:00] Processing file 13651/14579: Young Sheldon (2017)-S02E20-A Proposal and a Popsicle Stick Cross[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:00] Processing file 13676/14579: Young Sheldon (2017)-S06E16-A Stolen Truck and Going on the Lam[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:01] Progress: 13700/14579 (updated: 1964, cached: 0, misses: 87) +[2025-08-24 20:26:01] Processing file 13701/14579: Young Sheldon (2017)-S01E01-Pilot[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:02] Processing file 13726/14579: Young Sheldon (2017)-S03E06-A Parasol and a Hell of an Arm[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:03] Progress: 13750/14579 (updated: 1964, cached: 0, misses: 87) +[2025-08-24 20:26:03] Processing file 13751/14579: Young Sheldon (2017)-S07E01-A Wiener Schnitzel and Underwear in a Tree[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:15] Processing file 13776/14579: RuPaul's Drag Race - Untucked! (2009)-S17E04-Bitch Im a Drag Queen![HDTV-1080p][EAC32.0][h264].mkv +[2025-08-24 20:26:26] Progress: 13800/14579 (updated: 1964, cached: 0, misses: 109) +[2025-08-24 20:26:26] Processing file 13801/14579: Law & Order (1990)-S06E17-Deceit[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:26:26] Processing file 13826/14579: Law & Order (1990)-S08E08-Shadow[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:26:26] Progress: 13850/14579 (updated: 1964, cached: 0, misses: 109) +[2025-08-24 20:26:26] Processing file 13851/14579: Law & Order (1990)-S24E13-In God We Trust[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:27] Processing file 13876/14579: Law & Order (1990)-S07E22-Past Imperfect[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:26:27] Progress: 13900/14579 (updated: 1964, cached: 0, misses: 109) +[2025-08-24 20:26:27] Processing file 13901/14579: Law & Order (1990)-S09E11-Ramparts[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:26:27] Processing file 13926/14579: Alone (2015)-S10E10-Rats[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:28] Progress: 13950/14579 (updated: 1965, cached: 0, misses: 109) +[2025-08-24 20:26:28] Processing file 13951/14579: Top Chef (2006)-S22E09-Cooking on the Edge[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:29] Processing file 13976/14579: Ransom Canyon (2025)-S01E03-Trouble Can Be Fun Sometimes[WEBDL-1080p][AAC2.0][x264].mkv +[2025-08-24 20:26:33] Progress: 14000/14579 (updated: 2005, cached: 0, misses: 109) +[2025-08-24 20:26:33] Processing file 14001/14579: And Just Like That. (2021)-S01E01-Hello Its Me[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:26:42] Processing file 14026/14579: Gilligan's Island (1964)-S02E05-The Sweepstakes[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:42] Progress: 14050/14579 (updated: 2055, cached: 0, misses: 109) +[2025-08-24 20:26:42] Processing file 14051/14579: Gilligan's Island (1964)-S02E18-The Postman Cometh[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:43] Processing file 14076/14579: Gilligan's Island (1964)-S01E18-X Marks the Spot[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:43] Progress: 14100/14579 (updated: 2105, cached: 0, misses: 109) +[2025-08-24 20:26:43] Processing file 14101/14579: Gilligan's Island (1964)-S03E24-The Second Ginger Grant[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:44] Processing file 14126/14579: Lioness (2023)-S02E04-Five Hundred Children[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:48] Progress: 14150/14579 (updated: 2136, cached: 0, misses: 109) +[2025-08-24 20:26:48] Processing file 14151/14579: Love Rat (2024)-S01E01-Episode 1[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:51] Processing file 14176/14579: Bel-Air (2022)-S03E10-Save the Best for Last[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:55] Progress: 14200/14579 (updated: 2159, cached: 0, misses: 109) +[2025-08-24 20:26:55] Processing file 14201/14579: American Primeval (2025)-S01E06-Episode 6[HDTV-2160p][AAC2.0][h265].mkv +[2025-08-24 20:26:56] Processing file 14226/14579: Scrubs (2001)-S07E05-My Growing Pains[WEBRip-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:57] Progress: 14250/14579 (updated: 2159, cached: 0, misses: 109) +[2025-08-24 20:26:57] Processing file 14251/14579: Scrubs (2001)-S04E21-My Lips Are Sealed[WEBRip-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:57] Processing file 14276/14579: Scrubs (2001)-S01E24-My Last Day[WEBRip-1080p][AC32.0][h264].mkv +[2025-08-24 20:26:57] Progress: 14300/14579 (updated: 2159, cached: 0, misses: 109) +[2025-08-24 20:26:57] Processing file 14301/14579: Scrubs (2001)-S06E05-My Friend with Money[WEBRip-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:57] Processing file 14326/14579: Scrubs (2001)-S02E04-My Big Mouth[WEBRip-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:57] Progress: 14350/14579 (updated: 2159, cached: 0, misses: 109) +[2025-08-24 20:26:57] Processing file 14351/14579: Scrubs (2001)-S05E23-My Urologist[WEBRip-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:58] Processing file 14376/14579: Masters of the Air (2024)-S01E01-Part One[HDTV-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:58] Progress: 14400/14579 (updated: 2159, cached: 0, misses: 109) +[2025-08-24 20:26:58] Processing file 14401/14579: Clarkson's Farm (2021)-S03E03-Jobbing[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:26:59] Processing file 14426/14579: Slow Horses (2022)-S03E02-Hard Lessons[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:27:00] Progress: 14450/14579 (updated: 2167, cached: 0, misses: 109) +[2025-08-24 20:27:00] Processing file 14451/14579: Arcane (2021)-S02E01-Heavy Is the Crown[HDTV-1080p][DV][AAC2.0][h265].mkv +[2025-08-24 20:27:04] Processing file 14476/14579: Cobra Kai (2018)-S04E05-Match Point[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:27:04] Progress: 14500/14579 (updated: 2177, cached: 0, misses: 109) +[2025-08-24 20:27:04] Processing file 14501/14579: Cobra Kai (2018)-S05E01-Long Long Way From Home[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:27:05] Processing file 14526/14579: Cobra Kai (2018)-S06E08-Snakes on a Plane[HDTV-1080p][AAC2.0][x264].mkv +[2025-08-24 20:27:09] Progress: 14550/14579 (updated: 2187, cached: 0, misses: 109) +[2025-08-24 20:27:09] Processing file 14551/14579: Banshee (2013)-S03E09-Even God Doesnt Know What to Make of You[WEBDL-1080p][AAC2.0][h264].mkv +[2025-08-24 20:27:09] Processing file 14576/14579: Banshee (2013)-S02E09-Homecoming[Bluray-1080p][AAC2.0][x265].mkv +[2025-08-24 20:27:09] Media date sync completed: +Kind: tv +Checked: 14579 +Updated: 2187 +Cached: 0 +Misses: 109 +NFO created: 11810, removed: 0 +NFO timestamps synced: 14465 +Sidecars updated: 3169 +Sources Used: + - sonarr_airdate: 8390 + - sonarr_import: 3606 + - tmdb_episode_airdate: 2396 + - sonarr_grabbed: 78 diff --git a/logs/tv_apply_2025-08-24_1454.log b/logs/tv_apply_2025-08-24_1454.log deleted file mode 100644 index 4474ecd..0000000 --- a/logs/tv_apply_2025-08-24_1454.log +++ /dev/null @@ -1,25 +0,0 @@ -nohup: ignoring input -[2025-08-24 14:54:02] === Media Date Sync Starting === -[2025-08-24 14:54:02] Loading environment from: /opt/scripts/unmanic/.env -[2025-08-24 14:54:02] SONARR_URL: http://sonarr:8989 -[2025-08-24 14:54:02] SONARR_API_KEY: SET -[2025-08-24 14:54:02] TMDB_API_KEY: SET -[2025-08-24 14:54:02] Sonarr connection successful -[2025-08-24 14:54:02] Database initialized: /opt/scripts/unmanic/.cache/media_dates.db -[2025-08-24 14:54:02] Loading Sonarr series index... -[2025-08-24 14:54:02] Loaded 557 series from Sonarr -[2025-08-24 14:54:02] Scanning for media files in: /mnt/unionfs/Media/TV -[2025-08-24 14:54:17] Found 14670 media file(s) -[2025-08-24 14:54:17] Starting processing: refresh=False, dry_run=False, manage_nfo=True -[2025-08-24 14:54:17] Processing file 1: Teen Wolf (2011)-S02E02-Shape Shifted[WEBDL-1080p][EAC35.1][h264].mkv -[2025-08-24 14:54:17] Using cached result: sonarr_airdate -[2025-08-24 14:54:17] Processing file 2: Teen Wolf (2011)-S02E11-Battlefield[WEBDL-1080p][EAC35.1][h264].mkv -[2025-08-24 14:54:17] Using cached result: sonarr_airdate -[2025-08-24 14:54:17] Processing file 3: Teen Wolf (2011)-S02E12-Master Plan[WEBDL-1080p][EAC35.1][h264].mkv -[2025-08-24 14:54:17] Using cached result: sonarr_airdate -[2025-08-24 14:54:17] Processing file 4: Teen Wolf (2011)-S02E06-Frenemy[WEBDL-1080p][EAC35.1][h264].mkv -[2025-08-24 14:54:17] Using cached result: sonarr_airdate -[2025-08-24 14:54:17] Processing file 5: Teen Wolf (2011)-S02E01-Omega[WEBDL-1080p][EAC35.1][h264].mkv -[2025-08-24 14:54:17] Using cached result: sonarr_airdate -[2025-08-24 15:04:15] === Complete === -[2025-08-24 15:04:15] Media date sync completed:, Kind: tv, Checked: 14670, Updated: 24, Cached: 14646, Misses: 0, Sidecars updated: 182, NFO created: 24, NFO removed: 0, Dry run: False diff --git a/media_date.V33 b/media_date.V33 new file mode 100644 index 0000000..185e33a --- /dev/null +++ b/media_date.V33 @@ -0,0 +1,1007 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +media_date_cache.py + +Applies canonical "date added" timestamps to TV and Movie files (and their sidecars), +using Sonarr/Radarr history and episode/movie metadata, with TMDB fallbacks. + +Key behavior: +- Prefer original import date from Sonarr/Radarr; fallback to airdate (TV) or TMDB release date (Movie); fallback to existing mtime with --live-on-miss +- Writes minimal NFOs (TV: SxxEyy.nfo; Movie: movie.nfo) +- **NFO mtime always mirrors the video file's actual mtime** after processing (so it matches the file + sidecars) +- Sidecars (.srt/.ass/.vtt/...) are aligned to the chosen timestamp (or left alone with --only-older) +- SQLite cache to avoid re-fetching unless --refresh +- Gotify notifications (optional) +""" + +from __future__ import annotations + +import argparse +import dataclasses +from dataclasses import field +import datetime as dt +import json +import os +import re +import sqlite3 +import sys +import time +from pathlib import Path +from typing import Dict, Iterator, List, Optional, Tuple +import urllib.request +import urllib.error + +DEFAULT_GOTIFY_URL = os.getenv("GOTIFY_URL", "") +DEFAULT_GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN", "") + +SONARR_TIMEOUT = int(os.getenv("SONARR_TIMEOUT", "45")) +SONARR_RETRIES = int(os.getenv("SONARR_RETRIES", "3")) +RADARR_TIMEOUT = int(os.getenv("RADARR_TIMEOUT", "45")) +RADARR_RETRIES = int(os.getenv("RADARR_RETRIES", "3")) +TMDB_TIMEOUT = int(os.getenv("TMDB_TIMEOUT", "45")) +TMDB_RETRIES = int(os.getenv("TMDB_RETRIES", "3")) + +LOG_TS_FMT = "%Y-%m-%d %H:%M:%S" + +VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".flv", ".webm"} +SIDECAR_EXTS = {".srt", ".sub", ".idx", ".ass", ".ssa", ".vtt", ".smi", ".rt", ".txt"} +NFO_SIMPLE_PATTERN = re.compile(r"^S\d{2}E\d{2}\.nfo$", re.IGNORECASE) + +SE_EP_RX = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})") +IMDB_RX = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) +TMDB_RX = re.compile(r"\[tmdb-(\d+)\]", re.IGNORECASE) +YEAR_RX = re.compile(r"\((\d{4})\)") + +DEBUG_HTTP = False +DEBUG_TRUNCATE = 3000 # chars + +def log(msg: str) -> None: + ts = dt.datetime.now().strftime(LOG_TS_FMT) + print(f"[{ts}] {msg}", flush=True) + +def dlog(msg: str) -> None: + if DEBUG_HTTP: + log(f"[DEBUG] {msg}") + +def send_gotify_notification(title: str, message: str, priority: int = 5) -> None: + if not DEFAULT_GOTIFY_URL or not DEFAULT_GOTIFY_TOKEN: + return + try: + url = f"{DEFAULT_GOTIFY_URL.rstrip('/')}/message" + data = json.dumps({"title": title, "message": message, "priority": priority}).encode("utf-8") + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("X-Gotify-Key", DEFAULT_GOTIFY_TOKEN) + with urllib.request.urlopen(req, timeout=15): + pass + except Exception as e: + log(f"WARN: Gotify notification failed: {e}") + +def load_env_file(env_path: Optional[str]) -> None: + if not env_path: + return + p = Path(env_path) + if not p.exists(): + log(f"WARN: .env not found: {env_path}") + return + for line in p.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + +# ---------------- DB ---------------- + +def db_init(cx: sqlite3.Connection) -> None: + cx.execute( + """ + CREATE TABLE IF NOT EXISTS media_dates( + path TEXT PRIMARY KEY, + kind TEXT NOT NULL, + chosen_ts INTEGER NOT NULL, + chosen_src TEXT NOT NULL, + extra TEXT, + last_updated INTEGER DEFAULT (strftime('%s', 'now')) + ) + """ + ) + cx.commit() + +def db_get_cached(cx: sqlite3.Connection, path: str) -> Optional[Tuple[int, str]]: + cur = cx.execute("SELECT chosen_ts, chosen_src FROM media_dates WHERE path = ?", (path,)) + row = cur.fetchone() + return (int(row[0]), row[1]) if row else None + +def db_upsert(cx: sqlite3.Connection, path: str, kind: str, chosen_ts: int, chosen_src: str, extra: Dict[str, object] | None) -> None: + cx.execute( + """ + INSERT INTO media_dates(path, kind, chosen_ts, chosen_src, extra, last_updated) + VALUES(?,?,?,?,?,?) + ON CONFLICT(path) DO UPDATE SET + kind=excluded.kind, + chosen_ts=excluded.chosen_ts, + chosen_src=excluded.chosen_src, + extra=excluded.extra, + last_updated=excluded.last_updated + """, + (path, kind, int(chosen_ts), chosen_src, json.dumps(extra or {}, ensure_ascii=False), int(time.time())), + ) + cx.commit() + +# -------------- HTTP helper -------------- + +def _pretty(obj) -> str: + try: + return json.dumps(obj, indent=2, ensure_ascii=False)[:DEBUG_TRUNCATE] + except Exception: + return str(obj)[:DEBUG_TRUNCATE] + +def _req_json(method: str, url: str, *, headers: Optional[Dict[str, str]] = None, params: Optional[Dict[str, object]] = None, timeout: int = 30, retries: int = 2) -> dict | list: + from urllib.parse import urlencode + full_url = f"{url}?{urlencode(params)}" if params else url + last_err = None + for _ in range(max(1, retries)): + try: + dlog(f"HTTP {method} {full_url}") + req = urllib.request.Request(full_url, method=method) + for k, v in (headers or {}).items(): + req.add_header(k, v) + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + txt = body.decode("utf-8", errors="ignore") + try: + js = json.loads(txt) + dlog(f"← {full_url} OK\n{_pretty(js)}") + return js + except Exception as je: + dlog(f"← {full_url} NON-JSON ({len(txt)} bytes)\n{txt[:DEBUG_TRUNCATE]}") + raise RuntimeError(f"Non-JSON response from {full_url}") from je + except urllib.error.HTTPError as e: + last_err = e + err_body = e.read().decode("utf-8", errors="ignore") + dlog(f"← {full_url} HTTP {e.code}\n{err_body[:DEBUG_TRUNCATE]}") + time.sleep(0.35) + except Exception as e: + last_err = e + dlog(f"← {full_url} ERROR: {e}") + time.sleep(0.35) + raise last_err or RuntimeError("request failed") + +# ---------- API clients + caches ---------- + +@dataclasses.dataclass +class SonarrClient: + url: str + api_key: str + timeout: int = SONARR_TIMEOUT + retries: int = SONARR_RETRIES + def _h(self) -> Dict[str, str]: return {"X-Api-Key": self.api_key} + def ping(self) -> bool: + try: + _req_json("GET", f"{self.url}/api/v3/system/status", headers=self._h(), timeout=self.timeout, retries=self.retries) + return True + except Exception as e: + log(f"WARN: Sonarr ping failed: {e}") + return False + def series_index(self) -> List[dict]: + res = _req_json("GET", f"{self.url}/api/v3/series", headers=self._h(), timeout=self.timeout, retries=self.retries) + return res if isinstance(res, list) else list(res or []) + def history_for(self, series_id: int) -> List[dict]: + res = _req_json("GET", f"{self.url}/api/v3/history/series", headers=self._h(), params={"seriesId": series_id, "pageSize": 1000}, timeout=self.timeout, retries=self.retries) + if isinstance(res, dict): + recs = res.get("records") + return recs if isinstance(recs, list) else [] + if isinstance(res, list): + return res + return [] + def episodes_for(self, series_id: int) -> List[dict]: + res = _req_json("GET", f"{self.url}/api/v3/episode", headers=self._h(), params={"seriesId": series_id}, timeout=self.timeout, retries=self.retries) + return res if isinstance(res, list) else [] + +@dataclasses.dataclass +class SonarrCache: + sc: SonarrClient + episodes_map: Dict[int, List[dict]] = field(default_factory=dict) + history_map: Dict[int, List[dict]] = field(default_factory=dict) + def episodes(self, series_id: int) -> List[dict]: + if series_id not in self.episodes_map: + eps = self.sc.episodes_for(series_id) + self.episodes_map[series_id] = eps + dlog(f"Cached Sonarr episodes for series {series_id}: {len(eps)}") + return self.episodes_map[series_id] + def history(self, series_id: int) -> List[dict]: + if series_id not in self.history_map: + hist = self.sc.history_for(series_id) + self.history_map[series_id] = hist + dlog(f"Cached Sonarr history for series {series_id}: {len(hist)}") + return self.history_map[series_id] + +@dataclasses.dataclass +class RadarrClient: + url: str + api_key: str + timeout: int = RADARR_TIMEOUT + retries: int = RADARR_RETRIES + def _h(self) -> Dict[str, str]: return {"X-Api-Key": self.api_key} + def ping(self) -> bool: + try: + _req_json("GET", f"{self.url}/api/v3/system/status", headers=self._h(), timeout=self.timeout, retries=self.retries) + return True + except Exception as e: + log(f"WARN: Radarr ping failed: {e}") + return False + def movies_index(self) -> List[dict]: + res = _req_json("GET", f"{self.url}/api/v3/movie", headers=self._h(), timeout=self.timeout, retries=self.retries) + return res if isinstance(res, list) else [] + def history_for(self, movie_id: int) -> List[dict]: + res = _req_json("GET", f"{self.url}/api/v3/history/movie", headers=self._h(), params={"movieId": movie_id, "pageSize": 1000}, timeout=self.timeout, retries=self.retries) + if isinstance(res, dict): + recs = res.get("records") + return recs if isinstance(recs, list) else [] + if isinstance(res, list): + return res + return [] + +@dataclasses.dataclass +class TMDBClient: + api_key: str + primary_country: str = "US" + base_url: str = "https://api.themoviedb.org/3" + timeout: int = TMDB_TIMEOUT + retries: int = TMDB_RETRIES + def _p(self, **kw) -> Dict[str, object]: + p = {"api_key": self.api_key, "language": "en-US"}; p.update(kw); return p + def find_tv_by_imdb(self, imdb_id: str) -> Optional[int]: + js = _req_json("GET", f"{self.base_url}/find/{imdb_id}", params=self._p(external_source="imdb_id"), timeout=self.timeout, retries=self.retries) + lst = js.get("tv_results") if isinstance(js, dict) else None + return lst[0].get("id") if lst else None + def search_tv(self, name: str, year: Optional[int]) -> Optional[int]: + js = _req_json("GET", f"{self.base_url}/search/tv", params=self._p(query=name, first_air_date_year=year or ""), timeout=self.timeout, retries=self.retries) + res = js.get("results") if isinstance(js, dict) else None + return res[0].get("id") if res else None + def find_movie_by_imdb(self, imdb_id: str) -> Optional[int]: + js = _req_json("GET", f"{self.base_url}/find/{imdb_id}", params=self._p(external_source="imdb_id"), timeout=self.timeout, retries=self.retries) + lst = js.get("movie_results") if isinstance(js, dict) else None + return lst[0].get("id") if lst else None + def search_movie(self, name: str, year: Optional[int]) -> Optional[int]: + js = _req_json("GET", f"{self.base_url}/search/movie", params=self._p(query=name, year=year or "", primary_release_year=year or ""), timeout=self.timeout, retries=self.retries) + res = js.get("results") if isinstance(js, dict) else None + return res[0].get("id") if res else None + def episode_air_date(self, tv_id: int, season: int, episode: int) -> Optional[str]: + try: + js = _req_json("GET", f"{self.base_url}/tv/{tv_id}/season/{season}/episode/{episode}", params=self._p(), timeout=self.timeout, retries=self.retries) + return js.get("air_date") if isinstance(js, dict) else None + except Exception: + return None + def movie_release_date(self, movie_id: int) -> Optional[str]: + try: + js = _req_json("GET", f"{self.base_url}/movie/{movie_id}", params=self._p(), timeout=self.timeout, retries=self.retries) + return js.get("release_date") if isinstance(js, dict) else None + except Exception: + return None + +# -------------- parsers -------------- + +@dataclasses.dataclass +class EpisodeKey: + series_title: str + season: int + episode: int + imdb_id: Optional[str] + tmdb_id: Optional[str] + year: Optional[int] + +def parse_episode_from_path(p: Path) -> Optional[EpisodeKey]: + parent = p.parent + show_dir = parent.parent.name if parent.name.lower().startswith("season ") else parent.name + m = SE_EP_RX.search(p.name) + if not m: + return None + season = int(m.group(1)); episode = int(m.group(2)) + imdb_id_match = IMDB_RX.search(p.name) or IMDB_RX.search(show_dir) + imdb_id = imdb_id_match.group(1) if imdb_id_match else None + tmdb_id_match = TMDB_RX.search(p.name) or TMDB_RX.search(show_dir) + tmdb_id = tmdb_id_match.group(1) if tmdb_id_match else None + year = None + m3 = YEAR_RX.search(show_dir) + if m3: + try: + year = int(m3.group(1)) + except Exception: + year = None + series_title = IMDB_RX.sub("", show_dir) + series_title = TMDB_RX.sub("", series_title) + series_title = YEAR_RX.sub("", series_title).strip() or show_dir + return EpisodeKey(series_title, season, episode, imdb_id, tmdb_id, year) + +@dataclasses.dataclass +class MovieKey: + title: str + year: Optional[int] + imdb_id: Optional[str] + tmdb_id: Optional[str] + +def parse_movie_from_path(p: Path) -> Optional[MovieKey]: + movie_dir = p.parent.name + imdb_id_match = IMDB_RX.search(movie_dir) + tmdb_id_match = TMDB_RX.search(movie_dir) + imdb_id = imdb_id_match.group(1) if imdb_id_match else None + tmdb_id = tmdb_id_match.group(1) if tmdb_id_match else None + year = None + m3 = YEAR_RX.search(movie_dir) + if m3: + try: + year = int(m3.group(1)) + except Exception: + year = None + clean = IMDB_RX.sub("", movie_dir) + clean = TMDB_RX.sub("", clean) + clean = YEAR_RX.sub("", clean).strip() or movie_dir + return MovieKey(clean, year, imdb_id, tmdb_id) + +# -------------- date picking -------------- + +def parse_date_yyyy_mm_dd(s: str) -> Optional[int]: + try: + y, m, d = map(int, s.split("-")) + return int(dt.datetime(y, m, d, 0, 0, 0, tzinfo=dt.timezone.utc).timestamp()) + except Exception: + return None + +def pick_from_sonarr_import_for_episode_from_history(history: List[dict], episode_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]: + import_types = {"episodeFileImported", "downloadFolderImported", "manualImport"} + grabbed_types = {"grabbed"} + imports: List[Tuple[int, dict]] = [] + grabs: List[Tuple[int, dict]] = [] + for rec in history or []: + if not isinstance(rec, dict): + continue + if int(rec.get("episodeId", -1)) != int(episode_id): + continue + et = rec.get("eventType") or "" + date_s = rec.get("date") + if not date_s: + continue + try: + t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00")) + ts = int(t.timestamp()) + except Exception: + continue + if et in import_types: + imports.append((ts, rec)) + elif et in grabbed_types: + grabs.append((ts, rec)) + if imports: + ts, rec0 = sorted(imports, key=lambda x: x[0])[0] + return ts, "sonarr_import", {"episodeId": episode_id, "eventType": rec0.get("eventType")} + if grabs: + ts, rec0 = sorted(grabs, key=lambda x: x[0])[0] + return ts, "sonarr_grabbed", {"episodeId": episode_id, "eventType": rec0.get("eventType")} + return None + +def pick_from_sonarr_episode_from_list(episodes: List[dict], season: int, episode: int) -> Optional[Tuple[int, str, Dict[str, object]]]: + for ep in episodes or []: + if not isinstance(ep, dict): + continue + if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode: + air = ep.get("airDateUtc") or ep.get("airDate") + if not air: + return None + if "T" in air: + try: + t = dt.datetime.fromisoformat(air.replace("Z", "+00:00")) + return int(t.timestamp()), "sonarr_airdate", {"episodeId": ep.get("id")} + except Exception: + return None + ts = parse_date_yyyy_mm_dd(air) + if ts is not None: + return ts, "sonarr_airdate", {"episodeId": ep.get("id")} + return None + return None + +def pick_from_radarr_history(rc: RadarrClient, movie_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]: + try: + hist = rc.history_for(movie_id) + except Exception as e: + log(f"WARN: Radarr history timeout/err movie={movie_id}: {e}") + return None + best_ts = None; best = None + for rec in hist or []: + if not isinstance(rec, dict): + continue + date_s = rec.get("date") or "" + if not date_s: + continue + try: + t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00")) + ts = int(t.timestamp()) + except Exception: + continue + if best_ts is None or ts < best_ts: + best_ts = ts; best = rec + if best_ts is not None: + return best_ts, "radarr_history", {"history_id": (best or {}).get("id")} + return None + +@dataclasses.dataclass +class TMDBMovieLike: + title: str; year: Optional[int]; imdb_id: Optional[str]; tmdb_id: Optional[str] + +def pick_from_tmdb_movie(tmdb: TMDBClient, mk: TMDBMovieLike) -> Optional[Tuple[int, str, Dict[str, object]]]: + if not tmdb.api_key: + return None + movie_id = None + if mk.tmdb_id: + try: + movie_id = int(mk.tmdb_id) + except Exception: + movie_id = None + if movie_id is None and mk.imdb_id: + try: + movie_id = tmdb.find_movie_by_imdb(mk.imdb_id) + except Exception as e: + log(f"WARN: TMDB find movie by IMDb failed {mk.imdb_id}: {e}") + if movie_id is None: + try: + movie_id = tmdb.search_movie(mk.title, mk.year) + except Exception as e: + log(f"WARN: TMDB movie search failed title={mk.title}: {e}") + if movie_id is None: + return None + rel = tmdb.movie_release_date(movie_id) + if rel: + ts = parse_date_yyyy_mm_dd(rel) + if ts is not None: + return ts, "tmdb_movie_release", {"tmdb_movie_id": movie_id, "release_date": rel} + return None + +def pick_from_tmdb_tv(tmdb: TMDBClient, series_title: str, season: int, episode: int, imdb_id: Optional[str], tmdb_id: Optional[str], year: Optional[int]) -> Optional[Tuple[int, str, Dict[str, object]]]: + if not tmdb.api_key: + return None + tv_id = None + if tmdb_id: + try: + tv_id = int(tmdb_id) + except Exception: + tv_id = None + if tv_id is None and imdb_id: + try: + tv_id = tmdb.find_tv_by_imdb(imdb_id) + except Exception as e: + log(f"WARN: TMDB find by IMDb failed {imdb_id}: {e}") + if tv_id is None: + try: + tv_id = tmdb.search_tv(series_title, year) + except Exception as e: + log(f"WARN: TMDB search failed title={series_title}: {e}") + if tv_id is None: + return None + air = tmdb.episode_air_date(tv_id, season, episode) + if air: + ts = parse_date_yyyy_mm_dd(air) + if ts is not None: + return ts, "tmdb_episode_airdate", {"tmdb_tv_id": tv_id, "air_date": air} + return None + +# -------------- NFO helpers -------------- + +def create_simple_nfo(season: int, episode: int, air_date: Optional[str], date_added: Optional[str]) -> str: + aired_tag = f" {air_date}\n" if air_date else "" + dateadded_tag = f" {date_added}\n" if date_added else "" + return f""" + +{aired_tag}{dateadded_tag}""" + +def create_movie_nfo(title: str, year: Optional[int] = None, release_date: Optional[str] = None, date_added: Optional[str] = None) -> str: + premiered_tag = f" {release_date}\n" if release_date else "" + dateadded_tag = f" {date_added}\n" if date_added else "" + year_tag = f" {year}\n" if year else "" + title_tag = f" {title}\n" if title else "" + return f""" + +{title_tag}{year_tag}{premiered_tag}{dateadded_tag}""" + +def _fmt(ts: int) -> str: + return dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + +def _set_file_ts(path: Path, ts: int, dry_run: bool) -> bool: + if dry_run: + return True + try: + os.utime(str(path), (ts, ts)) + return True + except Exception as e: + log(f"WARN: Failed to set timestamp on {path}: {e}") + return False + +def manage_nfo_files( + media_dir: Path, + kind: str, + season: int = 0, + episode: int = 0, + air_date: Optional[str] = None, + date_added: Optional[str] = None, + title: Optional[str] = None, + year: Optional[int] = None, + release_date: Optional[str] = None, + nfo_timestamp: Optional[int] = None, # final, anchor ts (video mtime) + dry_run: bool = False +) -> Tuple[int, int, int]: + """ + Create/refresh minimal NFOs AND force the NFO file's mtime to nfo_timestamp. + Returns (created_count, removed_count, timestamps_synced_count). + """ + created = 0 + removed = 0 + synced = 0 + + def _verify_and_set_ts(path: Path, ts: Optional[int]) -> bool: + if ts is None: + return False + if dry_run: + return True + before = None + try: + before = int(path.stat().st_mtime) + except Exception: + before = None + try: + os.utime(str(path), (ts, ts)) + cur = int(path.stat().st_mtime) + if DEBUG_HTTP: + log(f"[DEBUG] NFO ts set {path.name}: {('unknown' if before is None else _fmt(before))} -> {_fmt(cur)} (target {_fmt(ts)})") + return abs(cur - int(ts)) <= 2 + except Exception as e: + log(f"WARN: Failed to set timestamp on {path}: {e}") + return False + + if kind == "tv": + simple_nfo_name = f"S{season:02d}E{episode:02d}.nfo" + simple_nfo_path = media_dir / simple_nfo_name + nfo_content = create_simple_nfo(season, episode, air_date, date_added) + + try: + should_write = True + if simple_nfo_path.exists(): + existing_content = simple_nfo_path.read_text(encoding='utf-8', errors='ignore') + should_write = existing_content.strip() != nfo_content.strip() + if should_write: + if not dry_run: + simple_nfo_path.write_text(nfo_content, encoding='utf-8') + created += 1 + except Exception as e: + log(f"WARN: Failed to create/update {simple_nfo_path}: {e}") + + if simple_nfo_path.exists() and nfo_timestamp is not None: + if _verify_and_set_ts(simple_nfo_path, nfo_timestamp): + synced += 1 + + # remove non-simple NFOs + for nfo_file in media_dir.glob("*.nfo"): + if nfo_file == simple_nfo_path: + continue + n_lower = nfo_file.name.lower() + if n_lower in ("season.nfo", "tvshow.nfo"): + continue + if NFO_SIMPLE_PATTERN.match(nfo_file.name): + continue + try: + if not dry_run: + nfo_file.unlink() + removed += 1 + log(f"Removed complex NFO: {nfo_file.name}") + except Exception as e: + log(f"WARN: Failed to remove {nfo_file}: {e}") + + else: + movie_nfo_path = media_dir / "movie.nfo" + nfo_content = create_movie_nfo(title or "", year=year, release_date=release_date, date_added=date_added) + + try: + should_write = True + if movie_nfo_path.exists(): + existing_content = movie_nfo_path.read_text(encoding='utf-8', errors='ignore') + should_write = existing_content.strip() != nfo_content.strip() + if should_write: + if not dry_run: + movie_nfo_path.write_text(nfo_content, encoding='utf-8') + created += 1 + except Exception as e: + log(f"WARN: Failed to create/update {movie_nfo_path}: {e}") + + if movie_nfo_path.exists() and nfo_timestamp is not None: + if _verify_and_set_ts(movie_nfo_path, nfo_timestamp): + synced += 1 + + return created, removed, synced + +# -------------- sidecars -------------- + +def _season_episode_token(name: str) -> Optional[Tuple[int,int]]: + m = SE_EP_RX.search(name) + if not m: + return None + return int(m.group(1)), int(m.group(2)) + +def find_sidecar_files(media_file: Path) -> List[Path]: + sidecars: List[Path] = [] + parent_dir = media_file.parent + base_stem = media_file.stem + token = _season_episode_token(media_file.name) + for f in parent_dir.iterdir(): + if not f.is_file(): + continue + if f == media_file: + continue + suf = f.suffix.lower() + if suf not in SIDECAR_EXTS: + continue + if f.stem == base_stem or f.name.startswith(base_stem + "."): + sidecars.append(f); continue + if token: + other = _season_episode_token(f.name) + if other and other == token: + sidecars.append(f); continue + uniq: Dict[str, Path] = {} + for s in sidecars: + uniq[s.name] = s + return list(uniq.values()) + +def apply_timestamp_to_file(file_path: Path, timestamp: int, dry_run: bool = False) -> bool: + if dry_run: + return True + try: + os.utime(str(file_path), (timestamp, timestamp)) + return True + except Exception as e: + log(f"WARN: Failed to set timestamp on {file_path}: {e}") + return False + +def iter_media_files(roots: List[str]) -> Iterator[Path]: + for root in roots: + base = Path(root) + if not base.exists(): + continue + for p in base.rglob("*"): + if p.is_file() and p.suffix.lower() in VIDEO_EXTS: + yield p + +# -------------- matching helpers -------------- + +def choose_series_id_for_path(series_index: List[dict], fp: Path) -> Optional[int]: + if not series_index: + return None + try: + series_dir = fp.parent.parent.name if fp.parent.name.lower().startswith("season ") else fp.parent.name + except Exception: + series_dir = fp.parent.name + base = series_dir.lower().strip() + base = YEAR_RX.sub("", base) + base = IMDB_RX.sub("", base) + base = TMDB_RX.sub("", base) + base = base.strip() + for s in series_index: + title = (s.get("title") or "").lower().strip() + if base == title: + return s.get("id") + return None + +def choose_movie_id_for_path(movies_index: List[dict], fp: Path) -> Optional[int]: + movie_dir = fp.parent.name + base = movie_dir.lower().strip() + base = YEAR_RX.sub("", base) + base = IMDB_RX.sub("", base) + base = TMDB_RX.sub("", base) + base = base.strip() + for m in movies_index: + title = (m.get("title") or "").lower().strip() + if base == title: + return m.get("id") + return None + +# -------------- main apply -------------- + +def apply_command(args: argparse.Namespace) -> None: + global DEBUG_HTTP + DEBUG_HTTP = bool(args.debug_http) + + log("=== Media Date Sync Starting ===") + load_env_file(args.env) + + sonarr_url = os.getenv("SONARR_URL", "http://sonarr:8989") + sonarr_key = os.getenv("SONARR_API_KEY", "") + radarr_url = os.getenv("RADARR_URL", "http://radarr:7878") + radarr_key = os.getenv("RADARR_API_KEY", "") + tmdb_key = os.getenv("TMDB_API_KEY", "") + tmdb_country = os.getenv("TMDB_PRIMARY_COUNTRY", "US") + + log(f"SONARR_URL: {sonarr_url}") + log(f"SONARR_API_KEY: {'SET' if sonarr_key else 'MISSING'}") + log(f"TMDB_API_KEY: {'SET' if tmdb_key else 'MISSING'}") + + sc = None; scache = None + if args.kind == "tv" and sonarr_key and sonarr_url: + sc = SonarrClient(sonarr_url, sonarr_key) + if not sc.ping(): + log("WARN: Sonarr not reachable") + sc = None + else: + log("Sonarr connection successful") + scache = SonarrCache(sc) + elif args.kind == "tv": + log("WARN: Sonarr not configured") + + rc = None; movies_index: List[dict] = [] + if args.kind == "movie" and radarr_key and radarr_url: + rc = RadarrClient(radarr_url, radarr_key) + if not rc.ping(): + log("WARN: Radarr not reachable") + rc = None + else: + try: + movies_index = rc.movies_index() + log(f"Loaded {len(movies_index)} movies from Radarr") + except Exception as e: + log(f"WARN: Failed to load Radarr movies: {e}") + elif args.kind == "movie": + log("WARN: Radarr not configured") + + tmdb = TMDBClient(tmdb_key, tmdb_country) + if not tmdb_key: + log("WARN: TMDB not configured - limited functionality") + + cx = sqlite3.connect(args.db) + db_init(cx) + log(f"Database initialized: {args.db}") + + series_index: List[dict] = [] + if args.kind == "tv" and sc: + try: + log("Loading Sonarr series index...") + series_index = sc.series_index() + log(f"Loaded {len(series_index)} series from Sonarr") + except Exception as e: + log(f"WARN: Failed to load Sonarr series: {e}") + + roots = args.root or [] + only_file = None + if args.path: + p = Path(args.path) + if p.is_file(): + roots = [str(p.parent)] + only_file = p + else: + roots = [str(p)] + if not roots: + env_roots = os.getenv("TV_ROOTS" if args.kind == "tv" else "MOVIE_ROOTS", "") + if env_roots: + roots = [r for r in env_roots.split(":") if r.strip()] + log(f"Using {'TV' if args.kind=='tv' else 'MOVIE'}_ROOTS from env: {', '.join(roots)}") + if not roots: + log("ERROR: No root paths specified. Use --root or set TV_ROOTS/MOVIE_ROOTS in .env") + return + + log(f"Scanning for media files in: {', '.join(roots)}") + files = [only_file] if (only_file is not None) else list(iter_media_files(roots)) + log(f"Found {len(files)} media file(s)") + if not files: + log("No media files found.") + return + + checked = updated = misses = cached = 0 + nfo_created = nfo_removed = sidecars_updated = nfo_ts_synced = 0 + source_counts: Dict[str, int] = {} + + log(f"Starting processing: refresh={args.refresh}, dry_run={args.dry_run}, manage_nfo={args.manage_nfo}") + + for i, fp in enumerate(files, 1): + path = str(fp) + if i % 25 == 1 or i <= 5: + log(f"Processing file {i}/{len(files)}: {fp.name}") + checked += 1 + + # pick effective timestamp (canonical "chosen") via cache or fresh + chosen_ts = None + chosen_src = None + extra: Dict[str, object] = {} + air_date_str = None + release_date_str = None + + if not args.refresh: + cached_result = db_get_cached(cx, path) + if cached_result: + cached += 1 + chosen_ts, chosen_src = cached_result + if i <= 5: + human = dt.datetime.utcfromtimestamp(chosen_ts).strftime("%Y-%m-%d %H:%M:%S UTC") + log(f" Using cached result: {chosen_src} @ {human}") + + if chosen_ts is None: + if args.kind == "tv": + epk = parse_episode_from_path(fp) + if not epk: + if i <= 10: + log(f"WARN: Could not parse episode info from: {fp.name}") + misses += 1 + continue + series_id = choose_series_id_for_path(series_index, fp) if series_index else None + if scache and series_id: + eps = scache.episodes(series_id) + epres = pick_from_sonarr_episode_from_list(eps, epk.season, epk.episode) + if epres: + chosen_ts, chosen_src, extra = epres + air_date_str = dt.datetime.fromtimestamp(chosen_ts, tz=dt.timezone.utc).strftime("%Y-%m-%d") + # prefer a real import/grab for THIS episode + ep_id = extra.get("episodeId") + if ep_id: + hist = scache.history(series_id) + imp = pick_from_sonarr_import_for_episode_from_history(hist, ep_id) + if imp: + chosen_ts, chosen_src, extra = imp + if chosen_ts is None and tmdb_key: + t = pick_from_tmdb_tv(tmdb, epk.series_title, epk.season, epk.episode, epk.imdb_id, epk.tmdb_id, epk.year) + if t: + chosen_ts, chosen_src, extra = t + air_date_str = dt.datetime.fromtimestamp(chosen_ts, tz=dt.timezone.utc).strftime("%Y-%m-%d") + else: + mk = parse_movie_from_path(fp) + if not mk: + if i <= 10: + log(f"WARN: Could not parse movie info from: {fp.name}") + misses += 1 + continue + movie_id = choose_movie_id_for_path(movies_index, fp) if movies_index else None + if rc and movie_id: + hist_res = pick_from_radarr_history(rc, movie_id) + if hist_res: + chosen_ts, chosen_src, extra = hist_res + if chosen_ts is None and tmdb_key and mk: + t = pick_from_tmdb_movie(tmdb, TMDBMovieLike(mk.title, mk.year, mk.imdb_id, mk.tmdb_id)) + if t: + chosen_ts, chosen_src, extra = t + release_date_str = dt.datetime.fromtimestamp(chosen_ts, tz=dt.timezone.utc).strftime("%Y-%m-%d") + + if chosen_ts is None and args.live_on_miss: + try: + chosen_ts = int(fp.stat().st_mtime) + chosen_src = "existing_mtime"; extra = {} + if i <= 5: + log(" Using existing mtime as fallback") + except Exception: + pass + + if chosen_ts is None: + if i <= 10: + log(f"WARN: No date found for: {fp.name}") + misses += 1 + continue + + source_counts[chosen_src] = source_counts.get(chosen_src, 0) + 1 + + # Apply to video/sidecars honoring --only-older + skip_apply = False + try: + cur_mtime = int(fp.stat().st_mtime) + except Exception: + cur_mtime = None + + if args.only_older and cur_mtime is not None and chosen_ts >= cur_mtime: + skip_apply = True + if i <= 5: + log(f" Skipping (only-older): chosen >= current") + + if not skip_apply and apply_timestamp_to_file(fp, chosen_ts, args.dry_run): + updated += 1 + + # Sidecars follow the chosen timestamp if we applied to video; with only-older skip, leave them as-is + if not skip_apply: + for s in find_sidecar_files(fp): + if apply_timestamp_to_file(s, chosen_ts, args.dry_run): + sidecars_updated += 1 + + # --------- NFO MIRRORS VIDEO MTIME (anchor) --------- + # Re-stat the video to get the *actual* anchor mtime (new or unchanged) + try: + anchor_ts = int(fp.stat().st_mtime) + except Exception: + anchor_ts = chosen_ts # fallback + + if args.manage_nfo: + nfo_date_added_str = dt.datetime.fromtimestamp(anchor_ts, tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + if args.kind == "tv": + epk2 = parse_episode_from_path(fp) + c, r, s = manage_nfo_files( + fp.parent, "tv", + epk2.season if epk2 else 0, + epk2.episode if epk2 else 0, + air_date_str, # informational + nfo_date_added_str, # mirrors video mtime + nfo_timestamp=anchor_ts, # NFO mtime mirrors video mtime + dry_run=args.dry_run + ) + else: + mk2 = parse_movie_from_path(fp) + c, r, s = manage_nfo_files( + fp.parent, "movie", 0, 0, + None, + nfo_date_added_str, + title=(mk2.title if mk2 else None), + year=(mk2.year if mk2 else None), + release_date=release_date_str, + nfo_timestamp=anchor_ts, + dry_run=args.dry_run + ) + nfo_created += c; nfo_removed += r; nfo_ts_synced += s + + # DB write (respect --only-older for the stored "chosen_ts") + if not args.dry_run: + try: + existing = db_get_cached(cx, path) + if args.only_older and existing and chosen_ts >= existing[0]: + pass + else: + db_upsert(cx, path, args.kind, chosen_ts, chosen_src, (extra or {})) + except Exception as e: + log(f"WARN: DB error: {e}") + + if i % 50 == 0: + log(f"Progress: {i}/{len(files)} (updated: {updated}, cached: {cached}, misses: {misses})") + + completion_msg = ( + "Media date sync completed:\n" + f"Kind: {args.kind}\n" + f"Checked: {checked}\n" + f"Updated: {updated}\n" + f"Cached: {cached}\n" + f"Misses: {misses}\n" + f"NFO created: {nfo_created}, removed: {nfo_removed}\n" + f"NFO timestamps synced: {nfo_ts_synced}\n" + f"Sidecars updated: {sidecars_updated}" + ) + if source_counts: + src_lines = "\n".join([f" - {k}: {v}" for k, v in sorted(source_counts.items(), key=lambda x: (-x[1], x[0]))]) + completion_msg += f"\nSources Used:\n{src_lines}" + log(completion_msg) + send_gotify_notification("Media Date Sync Complete", completion_msg) + +# -------------- CLI -------------- + +def build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Apply canonical mtimes to media files using Sonarr/Radarr/TMDB fallbacks.") + sub = p.add_subparsers(dest="cmd", required=True) + ap = sub.add_parser("apply", help="Scan, resolve dates, apply mtimes, and record to DB.") + ap.add_argument("--env", help="Optional .env file with API keys and URLs") + ap.add_argument("--kind", choices=["tv", "movie"], required=True, help="Media kind") + ap.add_argument("--db", required=True, help="SQLite DB file path") + ap.add_argument("--root", action="append", help="Root folder(s) to scan; repeatable") + ap.add_argument("--path", help="Process a single file or a single folder (overrides --root)") + ap.add_argument("--only-older", action="store_true", help="Backfill only: only set earlier dates; never move to a later timestamp") + ap.add_argument("--refresh", action="store_true", help="Ignore cached results and re-fetch all dates") + ap.add_argument("--dry-run", action="store_true", help="Do not write mtime/DB; just log what would happen") + ap.add_argument("--live-on-miss", action="store_true", help="If no date found, keep file's current mtime as chosen date") + ap.add_argument("--manage-nfo", action="store_true", help="Create/refresh simple NFOs (TV: SxxEyy.nfo; Movies: movie.nfo).") + ap.add_argument("--debug-http", action="store_true", help="Verbose log of all HTTP requests/responses (Sonarr/Radarr/TMDB) and NFO timestamping") + return p + +def main() -> None: + parser = build_arg_parser() + args = parser.parse_args() + try: + if args.cmd == "apply": + apply_command(args) + else: + parser.error("unknown command") + except KeyboardInterrupt: + log("Interrupted."); sys.exit(1) + except Exception as e: + log(f"FATAL ERROR: {e}") + import traceback; traceback.print_exc(); sys.exit(1) + +if __name__ == "__main__": + main() + diff --git a/media_date_cache.AnotherCHAT b/media_date_cache.AnotherCHAT new file mode 100755 index 0000000..5fbe802 --- /dev/null +++ b/media_date_cache.AnotherCHAT @@ -0,0 +1,539 @@ +#!/opt/scripts/unmanic/.venv/bin/python3 +# -*- coding: utf-8 -*- + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + print(f"[{_iso_now()}] {level}: {msg}") + +# ---------- .env pre-parse (no external deps) ---------- +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # Allow legacy var names too + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1]; i += 2; continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1]; i += 1; continue + new_argv.append(a); i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ---------- DB ---------- +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +EXPECTED_SERIES_COLS = {"imdb_id","series_path","last_applied"} +EXPECTED_EP_COLS = {"imdb_id","season","episode","aired","dateadded","source"} + +def _table_cols(conn, table): + cur = conn.execute(f"PRAGMA table_info({table});") + return {r[1] for r in cur.fetchall()} + +def _ensure_schema(conn, allow_drop=True): + conn.executescript(SCHEMA) + ok = True + try: + sc = _table_cols(conn, "series") + ec = _table_cols(conn, "episode_dates") + if sc != EXPECTED_SERIES_COLS or ec != EXPECTED_EP_COLS: + ok = False + except sqlite3.OperationalError: + ok = False + if not ok and allow_drop: + _log("WARNING","Schema mismatch detected — dropping and recreating tables.") + conn.executescript(""" + DROP TABLE IF EXISTS episode_dates; + DROP TABLE IF EXISTS series; + """) + conn.executescript(SCHEMA) + sc = _table_cols(conn, "series") + ec = _table_cols(conn, "episode_dates") + if sc != EXPECTED_SERIES_COLS or ec != EXPECTED_EP_COLS: + raise RuntimeError("Failed to create expected schema.") + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + _ensure_schema(conn, allow_drop=True) + return conn + +# ---------- HTTP/helpers ---------- +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: return None + try: return json.loads(data.decode("utf-8")) + except Exception: return None + +def to_utc_iso(s): + if not s: return None + try: + if len(s)==10 and s[4]=="-" and s[7]=="-": + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + s2 = s.replace("Z","+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) + else: dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + +def file_mtime_iso(p: Path): + try: return iso_from_epoch(p.stat().st_mtime) + except Exception: return None + +# ---------- clients ---------- +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL","").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY","") + self.timeout = int(os.environ.get("SONARR_TIMEOUT","45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES","3") or "3") + self.enabled = bool(self.base and self.key) + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: q += "?"+urllib.parse.urlencode(params) + headers = {"X-Api-Key": self.key} + for _ in range(self.retries): + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: return j + time.sleep(0.3) + return None + def series_by_imdb(self, imdb_id): + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY","") + self.timeout = int(os.environ.get("TMDB_TIMEOUT","45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES","3") or "3") + self.enabled = bool(self.api_key) + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = {**(params or {}), "api_key": self.api_key} + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + for _ in range(self.retries): + j = http_get_json(url, timeout=self.timeout) + if j is not None: return j + time.sleep(0.3) + return None + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source":"imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: return tv[0].get("id") + return None + def season_airdates(self, tv_id, season_number): + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num,int) and air: + res[num] = air + return res + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY","") + self.enabled = bool(self.api_key) + def season_airdates(self, imdb_id, season_number): + if not self.enabled: return {} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode( + {"i":imdb_id,"Season":str(season_number),"apikey":self.api_key} + ) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response")!="True": return res + for ep in j.get("Episodes",[]): + try: num = int(ep.get("Episode")) + except Exception: continue + rel = ep.get("Released") + if rel and rel!="N/A": res[num] = rel + return res + +# ---------- NFO / FS ---------- +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: f.unlink(); count += 1 + except Exception: pass + return count + +def resolve_season_dir(series_dir: Path, season_num: int) -> Path: + by_number = series_dir / f"Season {season_num:02d}" + if by_number.exists(): return by_number + if season_num == 0: + specials = series_dir / "Specials" + if specials.exists(): return specials + try: + by_number.mkdir(parents=True, exist_ok=True) + return by_number + except Exception: + if season_num == 0: + specials = series_dir / "Specials" + specials.mkdir(parents=True, exist_ok=True) + return specials + return by_number + +def write_episode_nfo(series_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str): + season_dir = resolve_season_dir(series_dir, season_num) + dst = season_dir / f"S{season_num:02d}E{ep_num:02d}.nfo" + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + xml.append(f" ") + xml.append("\n") + try: + dst.write_text("\n".join(xml), encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(series_dir: Path, season_num: int): + season_dir = resolve_season_dir(series_dir, season_num) + p = season_dir / "season.nfo" + if p.exists(): return + xml = f""" + + {season_num} + +""" + try: p.write_text(xml, encoding="utf-8") + except Exception as e: _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + p = series_dir / "tvshow.nfo" + if p.exists(): return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: p.write_text(xml, encoding="utf-8") + except Exception as e: _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z","+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: ts = datetime.fromisoformat(iso_timestamp.replace("Z","+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv",".mp4",".avi",".mov",".m4v",".srt",".ass",".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: pass + +# ---------- Core ---------- +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def _discover_season_numbers(series_dir: Path): + seasons = set() + for d in series_dir.iterdir(): + if not d.is_dir(): continue + name = d.name + low = name.lower() + if low == "specials": + seasons.add(0) + elif low.startswith("season "): + tail = name.split()[-1] + if tail.isdigit(): + seasons.add(int(tail)) + return sorted(seasons) + +def gather_episode_dates(series_dir: Path, imdb_id: str, args): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + out = {} + + # Sonarr first (episodeFile.dateAdded → airDateUtc) + if sonarr.enabled: + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + for ep in eps: + sn = ep.get("seasonNumber"); en = ep.get("episodeNumber") + if not isinstance(sn,int) or not isinstance(en,int): continue + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None; src = None + efid = ep.get("episodeFileId") + if efid: + fobj = sonarr.episode_file(efid) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")); src = "sonarr:episodeFile.dateAdded" + if not date_add and air_utc: + date_add = air_utc; src = "sonarr:episode.airDateUtc" + if air_utc or date_add: + out[(sn,en)] = (air_utc, date_add, src) + + # TMDB fallback for air dates (also used as dateadded if needed) + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + for sn in _discover_season_numbers(series_dir): + tm = tmdb.season_airdates(tv_id, sn) + for en, air in tm.items(): + key = (sn,en) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # OMDb final fallback for air dates + if omdb.enabled: + for sn in _discover_season_numbers(series_dir): + m = omdb.season_airdates(imdb_id, sn) + for en, air in m.items(): + key = (sn,en) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # File mtime last resort + for p in series_dir.iterdir(): + if not p.is_dir(): continue + low = p.name.lower() + if low == "specials": + sn = 0 + elif low.startswith("season ") and p.name.split()[-1].isdigit(): + sn = int(p.name.split()[-1]) + else: + continue + for f in p.iterdir(): + if not f.is_file(): continue + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: continue + key = (sn,en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ((out.get(key) or (None,))[0], mt, "file:mtime") + return out + +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + if args.manage_nfo: + for sd in series_dir.iterdir(): + if not sd.is_dir(): continue + low = sd.name.lower() + if not (low=="specials" or low.startswith("season ")): continue + removed = remove_long_nfos(sd) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {sd}") + maybe_tvshow_nfo(series_dir, imdb_id) + + dates = gather_episode_dates(series_dir, imdb_id, args) + + seasons_latest = {} + for (sn,en), (aired, dateadded, source) in sorted(dates.items()): + if args.manage_nfo: + write_episode_nfo(series_dir, sn, en, aired, dateadded, source or "") + ensure_season_nfo(series_dir, sn) + + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z","+00:00")) + seasons_latest[sn] = max(ts, seasons_latest.get(sn, ts)) + except Exception: + pass + + conn.commit() + + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = resolve_season_dir(series_dir, sn) + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + if is_series_dir(path): return [path] + if path.is_dir(): + out = [p for p in path.iterdir() if is_series_dir(p)] + return sorted(out) + return [] + +# ---------- CLI ---------- +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = sqlite3.connect(str(Path(args.db))) + conn.execute("PRAGMA foreign_keys=ON;") + conn.executescript(""" + DROP TABLE IF EXISTS episode_dates; + DROP TABLE IF EXISTS series; + """) + _ensure_schema(conn, allow_drop=False) + conn.commit(); conn.close() + +def cmd_apply(args): + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TMDB_API_KEY"): + _log("INFO", "TMDB not configured (set TMDB_API_KEY)") + if not os.environ.get("OMDB_API_KEY"): + _log("INFO", "OMDb not configured (set OMDB_API_KEY)") + + _log("INFO", f"=== APPLY: {targets[0] if len(targets)==1 else args.path} ===") + for series_dir in targets: + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Drop & recreate schema") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season/Specials folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes (use with care)") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + args.func(args) + +if __name__ == "__main__": + main() + diff --git a/media_date_cache.CHATSBEST b/media_date_cache.CHATSBEST new file mode 100644 index 0000000..868d676 --- /dev/null +++ b/media_date_cache.CHATSBEST @@ -0,0 +1,918 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse +import dataclasses +from dataclasses import field +import datetime as dt +import json +import os +import re +import sqlite3 +import sys +import time +from pathlib import Path +from typing import Dict, Iterator, List, Optional, Tuple +import urllib.request +import urllib.error + +# ================= +# Basics / constants +# ================= + +DEFAULT_GOTIFY_URL = os.getenv("GOTIFY_URL", "") +DEFAULT_GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN", "") + +SONARR_TIMEOUT = int(os.getenv("SONARR_TIMEOUT", "45")) +SONARR_RETRIES = int(os.getenv("SONARR_RETRIES", "3")) +RADARR_TIMEOUT = int(os.getenv("RADARR_TIMEOUT", "45")) +RADARR_RETRIES = int(os.getenv("RADARR_RETRIES", "3")) +TMDB_TIMEOUT = int(os.getenv("TMDB_TIMEOUT", "45")) +TMDB_RETRIES = int(os.getenv("TMDB_RETRIES", "3")) + +LOG_TS_FMT = "%Y-%m-%d %H:%M:%S" + +VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".flv", ".webm"} +SIDECAR_EXTS = {".srt", ".sub", ".idx", ".ass", ".ssa", ".vtt", ".smi", ".rt", ".txt"} +NFO_SIMPLE_PATTERN = re.compile(r"^S\d{2}E\d{2}\.nfo$", re.IGNORECASE) + +SE_EP_RX = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})") +IMDB_RX = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) +TMDB_RX = re.compile(r"\[tmdb-(\d+)\]", re.IGNORECASE) +YEAR_RX = re.compile(r"\((\d{4})\)") + +DEBUG_HTTP = False +DEBUG_TRUNCATE = 3000 # chars + +def log(msg: str) -> None: + ts = dt.datetime.now().strftime(LOG_TS_FMT) + print(f"[{ts}] {msg}", flush=True) + +def dlog(msg: str) -> None: + if DEBUG_HTTP: + log(f"[DEBUG] {msg}") + +def send_gotify_notification(title: str, message: str, priority: int = 5) -> None: + if not DEFAULT_GOTIFY_URL or not DEFAULT_GOTIFY_TOKEN: + return + try: + url = f"{DEFAULT_GOTIFY_URL.rstrip('/')}/message" + data = json.dumps({"title": title, "message": message, "priority": priority}).encode("utf-8") + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("X-Gotify-Key", DEFAULT_GOTIFY_TOKEN) + with urllib.request.urlopen(req, timeout=15): + pass + except Exception as e: + log(f"WARN: Gotify notification failed: {e}") + +def load_env_file(env_path: Optional[str]) -> None: + if not env_path: + return + p = Path(env_path) + if not p.exists(): + log(f"WARN: .env not found: {env_path}") + return + for line in p.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + +# ================= +# DB +# ================= + +def db_init(cx: sqlite3.Connection) -> None: + cx.execute( + """ + CREATE TABLE IF NOT EXISTS media_dates( + path TEXT PRIMARY KEY, + kind TEXT NOT NULL, + chosen_ts INTEGER NOT NULL, + chosen_src TEXT NOT NULL, + extra TEXT, + last_updated INTEGER DEFAULT (strftime('%s', 'now')) + ) + """ + ) + cx.commit() + +def db_get_cached(cx: sqlite3.Connection, path: str) -> Optional[Tuple[int, str]]: + cur = cx.execute("SELECT chosen_ts, chosen_src FROM media_dates WHERE path = ?", (path,)) + row = cur.fetchone() + return (int(row[0]), row[1]) if row else None + +def db_upsert(cx: sqlite3.Connection, path: str, kind: str, chosen_ts: int, chosen_src: str, extra: Dict[str, object] | None) -> None: + cx.execute( + """ + INSERT INTO media_dates(path, kind, chosen_ts, chosen_src, extra, last_updated) + VALUES(?,?,?,?,?,?) + ON CONFLICT(path) DO UPDATE SET + kind=excluded.kind, + chosen_ts=excluded.chosen_ts, + chosen_src=excluded.chosen_src, + extra=excluded.extra, + last_updated=excluded.last_updated + """, + (path, kind, int(chosen_ts), chosen_src, json.dumps(extra or {}, ensure_ascii=False), int(time.time())), + ) + cx.commit() + +# ================= +# HTTP helper +# ================= + +def _pretty(obj) -> str: + try: + return json.dumps(obj, indent=2, ensure_ascii=False)[:DEBUG_TRUNCATE] + except Exception: + return str(obj)[:DEBUG_TRUNCATE] + +def _req_json( + method: str, + url: str, + *, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, object]] = None, + timeout: int = 30, + retries: int = 2 +) -> dict | list: + from urllib.parse import urlencode + full_url = f"{url}?{urlencode(params)}" if params else url + last_err = None + for attempt in range(max(1, retries)): + try: + dlog(f"HTTP {method} {full_url}") + req = urllib.request.Request(full_url, method=method) + for k, v in (headers or {}).items(): + req.add_header(k, v) + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + txt = body.decode("utf-8", errors="ignore") + try: + js = json.loads(txt) + dlog(f"← {full_url} OK\n{_pretty(js)}") + return js + except Exception as je: + dlog(f"← {full_url} NON-JSON ({len(txt)} bytes)\n{txt[:DEBUG_TRUNCATE]}") + raise RuntimeError(f"Non-JSON response from {full_url}") from je + except urllib.error.HTTPError as e: + last_err = e + err_body = e.read().decode("utf-8", errors="ignore") + dlog(f"← {full_url} HTTP {e.code}\n{err_body[:DEBUG_TRUNCATE]}") + time.sleep(0.35) + except Exception as e: + last_err = e + dlog(f"← {full_url} ERROR: {e}") + time.sleep(0.35) + raise last_err or RuntimeError("request failed") + +# ================= +# API clients + per-series cache +# ================= + +@dataclasses.dataclass +class SonarrClient: + url: str + api_key: str + timeout: int = SONARR_TIMEOUT + retries: int = SONARR_RETRIES + def _h(self) -> Dict[str, str]: return {"X-Api-Key": self.api_key} + def ping(self) -> bool: + try: + _req_json("GET", f"{self.url}/api/v3/system/status", headers=self._h(), timeout=self.timeout, retries=self.retries) + return True + except Exception as e: + log(f"WARN: Sonarr ping failed: {e}"); return False + def series_index(self) -> List[dict]: + res = _req_json("GET", f"{self.url}/api/v3/series", headers=self._h(), timeout=self.timeout, retries=self.retries) + return res if isinstance(res, list) else list(res or []) + def history_for(self, series_id: int) -> List[dict]: + res = _req_json( + "GET", f"{self.url}/api/v3/history/series", + headers=self._h(), params={"seriesId": series_id, "pageSize": 1000}, + timeout=self.timeout, retries=self.retries + ) + if isinstance(res, dict): + recs = res.get("records") + return recs if isinstance(recs, list) else [] + if isinstance(res, list): + return res + return [] + def episodes_for(self, series_id: int) -> List[dict]: + res = _req_json("GET", f"{self.url}/api/v3/episode", headers=self._h(), params={"seriesId": series_id}, timeout=self.timeout, retries=self.retries) + return res if isinstance(res, list) else [] + +@dataclasses.dataclass +class SonarrCache: + sc: SonarrClient + episodes_map: Dict[int, List[dict]] = field(default_factory=dict) + history_map: Dict[int, List[dict]] = field(default_factory=dict) + + def episodes(self, series_id: int) -> List[dict]: + if series_id not in self.episodes_map: + eps = self.sc.episodes_for(series_id) + self.episodes_map[series_id] = eps + dlog(f"Cached Sonarr episodes for series {series_id}: {len(eps)}") + return self.episodes_map[series_id] + + def history(self, series_id: int) -> List[dict]: + if series_id not in self.history_map: + hist = self.sc.history_for(series_id) + self.history_map[series_id] = hist + dlog(f"Cached Sonarr history for series {series_id}: {len(hist)}") + return self.history_map[series_id] + +@dataclasses.dataclass +class RadarrClient: + url: str + api_key: str + timeout: int = RADARR_TIMEOUT + retries: int = RADARR_RETRIES + def _h(self) -> Dict[str, str]: return {"X-Api-Key": self.api_key} + def ping(self) -> bool: + try: + _req_json("GET", f"{self.url}/api/v3/system/status", headers=self._h(), timeout=self.timeout, retries=self.retries) + return True + except Exception as e: + log(f"WARN: Radarr ping failed: {e}"); return False + def movies_index(self) -> List[dict]: + res = _req_json("GET", f"{self.url}/api/v3/movie", headers=self._h(), timeout=self.timeout, retries=self.retries) + return res if isinstance(res, list) else [] + def history_for(self, movie_id: int) -> List[dict]: + res = _req_json( + "GET", f"{self.url}/api/v3/history/movie", + headers=self._h(), params={"movieId": movie_id, "pageSize": 1000}, + timeout=self.timeout, retries=self.retries + ) + if isinstance(res, dict): + recs = res.get("records") + return recs if isinstance(recs, list) else [] + if isinstance(res, list): + return res + return [] + +@dataclasses.dataclass +class TMDBClient: + api_key: str + primary_country: str = "US" + base_url: str = "https://api.themoviedb.org/3" + timeout: int = TMDB_TIMEOUT + retries: int = TMDB_RETRIES + def _p(self, **kw) -> Dict[str, object]: + p = {"api_key": self.api_key, "language": "en-US"}; p.update(kw); return p + def find_tv_by_imdb(self, imdb_id: str) -> Optional[int]: + js = _req_json("GET", f"{self.base_url}/find/{imdb_id}", params=self._p(external_source="imdb_id"), timeout=self.timeout, retries=self.retries) + lst = js.get("tv_results") if isinstance(js, dict) else None + return lst[0].get("id") if lst else None + def search_tv(self, name: str, year: Optional[int]) -> Optional[int]: + js = _req_json("GET", f"{self.base_url}/search/tv", params=self._p(query=name, first_air_date_year=year or ""), timeout=self.timeout, retries=self.retries) + res = js.get("results") if isinstance(js, dict) else None + return res[0].get("id") if res else None + def find_movie_by_imdb(self, imdb_id: str) -> Optional[int]: + js = _req_json("GET", f"{self.base_url}/find/{imdb_id}", params=self._p(external_source="imdb_id"), timeout=self.timeout, retries=self.retries) + lst = js.get("movie_results") if isinstance(js, dict) else None + return lst[0].get("id") if lst else None + def search_movie(self, name: str, year: Optional[int]) -> Optional[int]: + js = _req_json("GET", f"{self.base_url}/search/movie", params=self._p(query=name, year=year or "", primary_release_year=year or ""), timeout=self.timeout, retries=self.retries) + res = js.get("results") if isinstance(js, dict) else None + return res[0].get("id") if res else None + def episode_air_date(self, tv_id: int, season: int, episode: int) -> Optional[str]: + try: + js = _req_json("GET", f"{self.base_url}/tv/{tv_id}/season/{season}/episode/{episode}", params=self._p(), timeout=self.timeout, retries=self.retries) + return js.get("air_date") if isinstance(js, dict) else None + except Exception: + return None + def movie_release_date(self, movie_id: int) -> Optional[str]: + try: + js = _req_json("GET", f"{self.base_url}/movie/{movie_id}", params=self._p(), timeout=self.timeout, retries=self.retries) + return js.get("release_date") if isinstance(js, dict) else None + except Exception: + return None + +# ================= +# Parsers +# ================= + +@dataclasses.dataclass +class EpisodeKey: + series_title: str + season: int + episode: int + imdb_id: Optional[str] + tmdb_id: Optional[str] + year: Optional[int] + +def parse_episode_from_path(p: Path) -> Optional[EpisodeKey]: + parent = p.parent + show_dir = parent.parent.name if parent.name.lower().startswith("season ") else parent.name + m = SE_EP_RX.search(p.name) + if not m: return None + season = int(m.group(1)); episode = int(m.group(2)) + imdb_id = (IMDB_RX.search(p.name) or IMDB_RX.search(show_dir) or [None, None])[1] + tmdb_id = (TMDB_RX.search(p.name) or TMDB_RX.search(show_dir) or [None, None])[1] + year = None + m3 = YEAR_RX.search(show_dir) + if m3: + try: year = int(m3.group(1)) + except: year = None + series_title = IMDB_RX.sub("", show_dir) + series_title = TMDB_RX.sub("", series_title) + series_title = YEAR_RX.sub("", series_title).strip() or show_dir + return EpisodeKey(series_title, season, episode, imdb_id, tmdb_id, year) + +@dataclasses.dataclass +class MovieKey: + title: str + year: Optional[int] + imdb_id: Optional[str] + tmdb_id: Optional[str] + +def parse_movie_from_path(p: Path) -> Optional[MovieKey]: + movie_dir = p.parent.name + imdb_id = (IMDB_RX.search(movie_dir) or [None, None])[1] + tmdb_id = (TMDB_RX.search(movie_dir) or [None, None])[1] + year = None + m3 = YEAR_RX.search(movie_dir) + if m3: + try: year = int(m3.group(1)) + except: year = None + clean = IMDB_RX.sub("", movie_dir); clean = TMDB_RX.sub("", clean); clean = YEAR_RX.sub("", clean).strip() or movie_dir + return MovieKey(clean, year, imdb_id, tmdb_id) + +# ================= +# Picking dates (cached lists) +# ================= + +def parse_date_yyyy_mm_dd(s: str) -> Optional[int]: + try: + y, m, d = map(int, s.split("-")) + return int(dt.datetime(y, m, d, 0, 0, 0, tzinfo=dt.timezone.utc).timestamp()) + except Exception: + return None + +def pick_from_sonarr_import_for_episode_from_history(history: List[dict], episode_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]: + import_types = {"episodeFileImported", "downloadFolderImported", "manualImport"} + grabbed_types = {"grabbed"} + + imports: List[Tuple[int, dict]] = [] + grabs: List[Tuple[int, dict]] = [] + + for rec in history or []: + if not isinstance(rec, dict): continue + if int(rec.get("episodeId", -1)) != int(episode_id): continue + et = rec.get("eventType") or "" + date_s = rec.get("date") + if not date_s: continue + try: + t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00")) + ts = int(t.timestamp()) + except Exception: + continue + if et in import_types: + imports.append((ts, rec)) + elif et in grabbed_types: + grabs.append((ts, rec)) + + if imports: + ts, rec = sorted(imports, key=lambda x: x[0])[0] + return ts, "sonarr_import", {"episodeId": episode_id, "eventType": rec.get("eventType")} + if grabs: + ts, rec = sorted(grabs, key=lambda x: x[0])[0] + return ts, "sonarr_grabbed", {"episodeId": episode_id, "eventType": rec.get("eventType")} + return None + +def pick_from_sonarr_episode_from_list(episodes: List[dict], season: int, episode: int) -> Optional[Tuple[int, str, Dict[str, object]]]: + for ep in episodes or []: + if not isinstance(ep, dict): continue + if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode: + air = ep.get("airDateUtc") or ep.get("airDate") + if not air: return None + if "T" in air: + try: + t = dt.datetime.fromisoformat(air.replace("Z", "+00:00")) + return int(t.timestamp()), "sonarr_airdate", {"episodeId": ep.get("id")} + except Exception: + return None + ts = parse_date_yyyy_mm_dd(air) + if ts is not None: + return ts, "sonarr_airdate", {"episodeId": ep.get("id")} + return None + return None + +def pick_from_radarr_history(rc: RadarrClient, movie_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]: + try: + hist = rc.history_for(movie_id) + except Exception as e: + log(f"WARN: Radarr history timeout/err movie={movie_id}: {e}"); return None + best_ts = None; best = None + for rec in hist or []: + if not isinstance(rec, dict): continue + date_s = rec.get("date") or "" + if not date_s: continue + try: + t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00")) + ts = int(t.timestamp()) + except Exception: + continue + if best_ts is None or ts < best_ts: + best_ts = ts; best = rec + if best_ts is not None: + return best_ts, "radarr_history", {"history_id": (best or {}).get("id")} + return None + +# TMDB fallback (unchanged) +@dataclasses.dataclass +class TMDBMovieLike: + title: str; year: Optional[int]; imdb_id: Optional[str]; tmdb_id: Optional[str] + +def pick_from_tmdb_movie(tmdb: TMDBClient, mk: TMDBMovieLike) -> Optional[Tuple[int, str, Dict[str, object]]]: + if not tmdb.api_key: return None + movie_id = None + if mk.tmdb_id: + try: movie_id = int(mk.tmdb_id) + except: movie_id = None + if movie_id is None and mk.imdb_id: + try: movie_id = tmdb.find_movie_by_imdb(mk.imdb_id) + except Exception as e: log(f"WARN: TMDB find movie by IMDb failed {mk.imdb_id}: {e}") + if movie_id is None: + try: movie_id = tmdb.search_movie(mk.title, mk.year) + except Exception as e: log(f"WARN: TMDB movie search failed title={mk.title}: {e}") + if movie_id is None: return None + rel = tmdb.movie_release_date(movie_id) + if rel: + ts = parse_date_yyyy_mm_dd(rel) + if ts is not None: + return ts, "tmdb_movie_release", {"tmdb_movie_id": movie_id, "release_date": rel} + return None + +def pick_from_tmdb_tv(tmdb: TMDBClient, series_title: str, season: int, episode: int, imdb_id: Optional[str], tmdb_id: Optional[str], year: Optional[int]) -> Optional[Tuple[int, str, Dict[str, object]]]: + if not tmdb.api_key: return None + tv_id = None + if tmdb_id: + try: tv_id = int(tmdb_id) + except: tv_id = None + if tv_id is None and imdb_id: + try: tv_id = tmdb.find_tv_by_imdb(imdb_id) + except Exception as e: log(f"WARN: TMDB find by IMDb failed {imdb_id}: {e}") + if tv_id is None: + try: tv_id = tmdb.search_tv(series_title, year) + except Exception as e: log(f"WARN: TMDB search failed title={series_title}: {e}") + if tv_id is None: return None + air = tmdb.episode_air_date(tv_id, season, episode) + if air: + ts = parse_date_yyyy_mm_dd(air) + if ts is not None: + return ts, "tmdb_episode_airdate", {"tmdb_tv_id": tv_id, "air_date": air} + return None + +# ================= +# NFO helpers +# ================= + +def create_simple_nfo(season: int, episode: int, air_date: Optional[str], date_added: Optional[str]) -> str: + aired_tag = f" {air_date}\n" if air_date else "" + dateadded_tag = f" {date_added}\n" if date_added else "" + return f""" + +{aired_tag}{dateadded_tag}""" + +def create_movie_nfo(title: str, year: Optional[int] = None, release_date: Optional[str] = None, date_added: Optional[str] = None) -> str: + premiered_tag = f" {release_date}\n" if release_date else "" + dateadded_tag = f" {date_added}\n" if date_added else "" + year_tag = f" {year}\n" if year else "" + title_tag = f" {title}\n" if title else "" + return f""" + +{title_tag}{year_tag}{premiered_tag}{dateadded_tag}""" + +def _set_file_ts(path: Path, ts: int, dry_run: bool) -> bool: + if dry_run: return True + try: + os.utime(str(path), (ts, ts)); return True + except Exception as e: + log(f"WARN: Failed to set timestamp on {path}: {e}"); return False + +def manage_nfo_files( + media_dir: Path, + kind: str, + season: int = 0, + episode: int = 0, + air_date: Optional[str] = None, + date_added: Optional[str] = None, + title: Optional[str] = None, + year: Optional[int] = None, + release_date: Optional[str] = None, + nfo_timestamp: Optional[int] = None, + dry_run: bool = False +) -> Tuple[int, int, int]: + created = 0; removed = 0; synced = 0 + + if kind == "tv": + simple_nfo_name = f"S{season:02d}E{episode:02d}.nfo" + simple_nfo_path = media_dir / simple_nfo_name + nfo_content = create_simple_nfo(season, episode, air_date, date_added) + + try: + should_write = True + if simple_nfo_path.exists(): + existing_content = simple_nfo_path.read_text(encoding='utf-8', errors='ignore') + should_write = existing_content.strip() != nfo_content.strip() + if should_write and not dry_run: + simple_nfo_path.write_text(nfo_content, encoding='utf-8'); created = 1 + elif should_write and dry_run: + created = 1 + except Exception as e: + log(f"WARN: Failed to create/update {simple_nfo_path}: {e}") + + if nfo_timestamp is not None: + if _set_file_ts(simple_nfo_path, nfo_timestamp, dry_run): + synced += 1 + + for nfo_file in media_dir.glob("*.nfo"): + n = nfo_file.name.lower() + if NFO_SIMPLE_PATTERN.match(nfo_file.name): continue + if n in ("season.nfo", "tvshow.nfo"): continue + if not dry_run: + try: nfo_file.unlink(); removed += 1; log(f"Removed complex NFO: {nfo_file.name}") + except Exception as e: log(f"WARN: Failed to remove {nfo_file}: {e}") + else: + removed += 1; log(f"Would remove complex NFO: {nfo_file.name}") + + else: + movie_nfo_path = media_dir / "movie.nfo" + nfo_content = create_movie_nfo(title or "", year=year, release_date=release_date, date_added=date_added) + + try: + should_write = True + if movie_nfo_path.exists(): + existing_content = movie_nfo_path.read_text(encoding='utf-8', errors='ignore') + should_write = existing_content.strip() != nfo_content.strip() + if should_write and not dry_run: + movie_nfo_path.write_text(nfo_content, encoding='utf-8'); created = 1 + elif should_write and dry_run: + created = 1 + except Exception as e: + log(f"WARN: Failed to create/update {movie_nfo_path}: {e}") + + if nfo_timestamp is not None: + if _set_file_ts(movie_nfo_path, nfo_timestamp, dry_run): + synced += 1 + + return created, removed, synced + +# ================= +# Sidecars +# ================= + +def _season_episode_token(name: str) -> Optional[Tuple[int,int]]: + m = SE_EP_RX.search(name) + if not m: return None + return int(m.group(1)), int(m.group(2)) + +def find_sidecar_files(media_file: Path) -> List[Path]: + sidecars: List[Path] = [] + parent_dir = media_file.parent + base_stem = media_file.stem + token = _season_episode_token(media_file.name) + + for f in parent_dir.iterdir(): + if not f.is_file(): continue + if f == media_file: continue + suf = f.suffix.lower() + if suf not in SIDECAR_EXTS: + continue + if f.stem == base_stem or f.name.startswith(base_stem + "."): + sidecars.append(f); continue + if token: + other = _season_episode_token(f.name) + if other and other == token: + sidecars.append(f); continue + + uniq: Dict[str, Path] = {} + for s in sidecars: uniq[s.name] = s + return list(uniq.values()) + +def apply_timestamp_to_file(file_path: Path, timestamp: int, dry_run: bool = False) -> bool: + if dry_run: return True + try: + os.utime(str(file_path), (timestamp, timestamp)); return True + except Exception as e: + log(f"WARN: Failed to set timestamp on {file_path}: {e}"); return False + +def iter_media_files(roots: List[str]) -> Iterator[Path]: + for root in roots: + base = Path(root) + if not base.exists(): continue + for p in base.rglob("*"): + if p.is_file() and p.suffix.lower() in VIDEO_EXTS: + yield p + +# ================= +# Sonarr series match +# ================= + +def choose_series_id_for_path(series_index: List[dict], fp: Path) -> Optional[int]: + if not series_index: return None + try: + series_dir = fp.parent.parent.name if fp.parent.name.lower().startswith("season ") else fp.parent.name + except Exception: + series_dir = fp.parent.name + base = series_dir.lower().strip() + base = YEAR_RX.sub("", base); base = IMDB_RX.sub("", base); base = TMDB_RX.sub("", base); base = base.strip() + for s in series_index: + title = (s.get("title") or "").lower().strip() + if base == title: return s.get("id") + return None + +# ================= +# Main apply +# ================= + +def apply_command(args: argparse.Namespace) -> None: + global DEBUG_HTTP + DEBUG_HTTP = bool(args.debug_http) + + log("=== Media Date Sync Starting ===") + load_env_file(args.env) + + sonarr_url = os.getenv("SONARR_URL", "http://sonarr:8989") + sonarr_key = os.getenv("SONARR_API_KEY", "") + radarr_url = os.getenv("RADARR_URL", "http://radarr:7878") + radarr_key = os.getenv("RADARR_API_KEY", "") + tmdb_key = os.getenv("TMDB_API_KEY", "") + tmdb_country = os.getenv("TMDB_PRIMARY_COUNTRY", "US") + + log(f"SONARR_URL: {sonarr_url}") + log(f"SONARR_API_KEY: {'SET' if sonarr_key else 'MISSING'}") + log(f"TMDB_API_KEY: {'SET' if tmdb_key else 'MISSING'}") + + sc = None + scache = None + if sonarr_key and sonarr_url: + sc = SonarrClient(sonarr_url, sonarr_key) + if not sc.ping(): + log("WARN: Sonarr not reachable"); sc = None + else: + log("Sonarr connection successful") + scache = SonarrCache(sc) + else: + log("WARN: Sonarr not configured") + + tmdb = TMDBClient(tmdb_key, tmdb_country) + if not tmdb_key: + log("WARN: TMDB not configured - limited functionality") + + cx = sqlite3.connect(args.db) + db_init(cx) + log(f"Database initialized: {args.db}") + + series_index: List[dict] = [] + if args.kind == "tv" and sc: + try: + log("Loading Sonarr series index...") + series_index = sc.series_index() + log(f"Loaded {len(series_index)} series from Sonarr") + except Exception as e: + log(f"WARN: Failed to load Sonarr series: {e}") + + roots = args.root or [] + only_file = None + if args.path: + p = Path(args.path) + if p.is_file(): + roots = [str(p.parent)] + only_file = p + else: + roots = [str(p)] + if not roots: + env_roots = os.getenv("TV_ROOTS" if args.kind == "tv" else "MOVIE_ROOTS", "") + if env_roots: + roots = [r for r in env_roots.split(":") if r.strip()] + log(f"Using {'TV' if args.kind=='tv' else 'MOVIE'}_ROOTS from env: {', '.join(roots)}") + if not roots: + log("ERROR: No root paths specified. Use --root or set TV_ROOTS/MOVIE_ROOTS in .env"); return + + log(f"Scanning for media files in: {', '.join(roots)}") + files = [only_file] if (only_file is not None) else list(iter_media_files(roots)) + log(f"Found {len(files)} media file(s)") + if not files: + log("No media files found."); return + + checked = updated = misses = cached = 0 + nfo_created = nfo_removed = sidecars_updated = nfo_ts_synced = 0 + source_counts: Dict[str, int] = {} + + log(f"Starting processing: refresh={args.refresh}, dry_run={args.dry_run}, manage_nfo={args.manage_nfo}") + + for i, fp in enumerate(files, 1): + path = str(fp) + if i % 25 == 1 or i <= 5: + log(f"Processing file {i}/{len(files)}: {fp.name}") + checked += 1 + + # Cached branch + if not args.refresh: + cached_result = db_get_cached(cx, path) + if cached_result: + cached += 1 + cached_ts, cached_src = cached_result + if i <= 5: + human = dt.datetime.utcfromtimestamp(cached_ts).strftime("%Y-%m-%d %H:%M:%S UTC") + log(f" Using cached result: {cached_src} @ {human}") + if apply_timestamp_to_file(fp, cached_ts, args.dry_run): + updated += 1 + for s in find_sidecar_files(fp): + if apply_timestamp_to_file(s, cached_ts, args.dry_run): + sidecars_updated += 1 + if args.manage_nfo: + date_added_str = dt.datetime.fromtimestamp(cached_ts, tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + if args.kind == "tv": + epk = parse_episode_from_path(fp) + if epk: + air = dt.datetime.fromtimestamp(cached_ts, tz=dt.timezone.utc).strftime("%Y-%m-%d") if "airdate" in cached_src else None + c, r, s = manage_nfo_files(fp.parent, "tv", epk.season, epk.episode, air, date_added_str, nfo_timestamp=cached_ts, dry_run=args.dry_run) + nfo_created += c; nfo_removed += r; nfo_ts_synced += s + else: + mk = parse_movie_from_path(fp) + c, r, s = manage_nfo_files(fp.parent, "movie", 0, 0, None, date_added_str, title=(mk.title if mk else None), year=(mk.year if mk else None), release_date=None, nfo_timestamp=cached_ts, dry_run=args.dry_run) + nfo_created += c; nfo_removed += r; nfo_ts_synced += s + continue + + # Fresh resolution + ts_src_extra: Optional[Tuple[int, str, Dict[str, object]]] = None + air_date_str = date_added_str = release_date_str = None + + if args.kind == "tv": + epk = parse_episode_from_path(fp) + if not epk: + if i <= 10: log(f"WARN: Could not parse episode info from: {fp.name}") + misses += 1; continue + + series_id = choose_series_id_for_path(series_index, fp) if series_index else None + + # Prefer Sonarr episode airdate (from cached per-series episodes) + episode_result = None + if scache and series_id: + eps = scache.episodes(series_id) + episode_result = pick_from_sonarr_episode_from_list(eps, epk.season, epk.episode) + if episode_result: + ts_src_extra = episode_result + air_date_str = dt.datetime.fromtimestamp(episode_result[0], tz=dt.timezone.utc).strftime("%Y-%m-%d") + date_added_str = dt.datetime.fromtimestamp(episode_result[0], tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + # If we can find a *true import* for THIS episode, prefer that (original import date) + ep_id = episode_result[2].get("episodeId") + if ep_id: + hist = scache.history(series_id) + import_res = pick_from_sonarr_import_for_episode_from_history(hist, ep_id) + if import_res: + ts_src_extra = import_res # override with import/grabbed + date_added_str = dt.datetime.fromtimestamp(import_res[0], tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + # If Sonarr missing, try TMDB (airdate fallback) + if not ts_src_extra and tmdb_key: + tm = pick_from_tmdb_tv(TMDBClient(tmdb_key), epk.series_title, epk.season, epk.episode, epk.imdb_id, epk.tmdb_id, epk.year) + if tm: + ts_src_extra = tm + air_date_str = dt.datetime.fromtimestamp(tm[0], tz=dt.timezone.utc).strftime("%Y-%m-%d") + date_added_str = dt.datetime.fromtimestamp(tm[0], tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + else: + mk = parse_movie_from_path(fp) + if not mk: + if i <= 10: log(f"WARN: Could not parse movie info from: {fp.name}") + misses += 1; continue + # (movie handling would mirror TV, omitted here for brevity) + + if not ts_src_extra and args.live_on_miss: + try: + mtime = int(fp.stat().st_mtime) + ts_src_extra = (mtime, "existing_mtime", {}) + if i <= 5: log(" Using existing mtime as fallback") + except Exception: + ts_src_extra = None + + if not ts_src_extra: + if i <= 10: log(f"WARN: No date found for: {fp.name}") + misses += 1; continue + + ts, src, extra = ts_src_extra + source_counts[src] = source_counts.get(src, 0) + 1 + + skip_apply = False + if args.only_older: + try: + cur_mtime = int(fp.stat().st_mtime) + if ts >= cur_mtime: + skip_apply = True + if i <= 5: log(f" Skipping (only-older): chosen >= current") + except Exception: + pass + + if not skip_apply and apply_timestamp_to_file(fp, ts, args.dry_run): + updated += 1 + for s in find_sidecar_files(fp): + if not skip_apply and apply_timestamp_to_file(s, ts, args.dry_run): + sidecars_updated += 1 + + if args.manage_nfo: + if args.kind == "tv": + epk2 = parse_episode_from_path(fp) + c, r, s = manage_nfo_files( + fp.parent, "tv", + epk2.season if epk2 else 0, + epk2.episode if epk2 else 0, + air_date_str, + dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), + nfo_timestamp=ts, + dry_run=args.dry_run + ) + else: + mk2 = parse_movie_from_path(fp) + c, r, s = manage_nfo_files( + fp.parent, "movie", 0, 0, + None, + dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), + title=(mk2.title if mk2 else None), + year=(mk2.year if mk2 else None), + release_date=release_date_str, + nfo_timestamp=ts, + dry_run=args.dry_run + ) + nfo_created += c; nfo_removed += r; nfo_ts_synced += s + + if not args.dry_run: + try: + existing = db_get_cached(cx, path) + if args.only_older and existing and ts >= existing[0]: + pass + else: + db_upsert(cx, path, args.kind, ts, src, (extra or {})) + except Exception as e: + log(f"WARN: DB error: {e}") + + if i % 50 == 0: # more frequent progress for big shows + log(f"Progress: {i}/{len(files)} (updated: {updated}, cached: {cached}, misses: {misses})") + + completion_msg = ( + "Media date sync completed:\n" + f"Kind: {args.kind}\n" + f"Checked: {checked}\n" + f"Updated: {updated}\n" + f"Cached: {cached}\n" + f"Misses: {misses}\n" + f"NFO created: {nfo_created}, removed: {nfo_removed}\n" + f"NFO timestamps synced: {nfo_ts_synced}\n" + f"Sidecars updated: {sidecars_updated}" + ) + if source_counts: + src_lines = "\n".join([f" - {k}: {v}" for k, v in sorted(source_counts.items(), key=lambda x: (-x[1], x[0]))]) + completion_msg += f"\nSources Used:\n{src_lines}" + log(completion_msg) + send_gotify_notification("Media Date Sync Complete", completion_msg) + +# ================= +# CLI +# ================= + +def build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Apply canonical mtimes to media files using Sonarr/Radarr/TMDB fallbacks.") + sub = p.add_subparsers(dest="cmd", required=True) + ap = sub.add_parser("apply", help="Scan, resolve dates, apply mtimes, and record to DB.") + ap.add_argument("--env", help="Optional .env file with API keys and URLs") + ap.add_argument("--kind", choices=["tv", "movie"], required=True, help="Media kind") + ap.add_argument("--db", required=True, help="SQLite DB file path") + ap.add_argument("--root", action="append", help="Root folder(s) to scan; repeatable") + ap.add_argument("--path", help="Process a single file or a single folder (overrides --root)") + ap.add_argument("--only-older", action="store_true", help="Backfill only: only set earlier dates; never move to a later timestamp") + ap.add_argument("--refresh", action="store_true", help="Ignore cached results and re-fetch all dates") + ap.add_argument("--dry-run", action="store_true", help="Do not write mtime/DB; just log what would happen") + ap.add_argument("--live-on-miss", action="store_true", help="If no date found, keep file's current mtime as chosen date") + ap.add_argument("--manage-nfo", action="store_true", help="Create/refresh simple NFOs (TV: SxxEyy.nfo; Movies: movie.nfo).") + ap.add_argument("--debug-http", action="store_true", help="Verbose log of all HTTP requests/responses (Sonarr/Radarr/TMDB)") + return p + +def main() -> None: + parser = build_arg_parser() + args = parser.parse_args() + try: + if args.cmd == "apply": + apply_command(args) + else: + parser.error("unknown command") + except KeyboardInterrupt: + log("Interrupted."); sys.exit(1) + except Exception as e: + log(f"FATAL ERROR: {e}") + import traceback; traceback.print_exc(); sys.exit(1) + +if __name__ == "__main__": + main() + diff --git a/media_date_cache.LatestChat b/media_date_cache.LatestChat new file mode 100755 index 0000000..4ef3590 --- /dev/null +++ b/media_date_cache.LatestChat @@ -0,0 +1,538 @@ +#!/opt/scripts/unmanic/.venv/bin/python3 +# -*- coding: utf-8 -*- + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + print(f"[{_iso_now()}] {level}: {msg}") + +# ---------- .env pre-parse (no external deps) ---------- +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1]; i += 2; continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1]; i += 1; continue + new_argv.append(a); i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ---------- DB ---------- +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +EXPECTED_SERIES_COLS = {"imdb_id","series_path","last_applied"} +EXPECTED_EP_COLS = {"imdb_id","season","episode","aired","dateadded","source"} + +def _table_cols(conn, table): + cur = conn.execute(f"PRAGMA table_info({table});") + return {r[1] for r in cur.fetchall()} + +def _ensure_schema(conn, allow_drop=True): + conn.executescript(SCHEMA) + ok = True + try: + sc = _table_cols(conn, "series") + ec = _table_cols(conn, "episode_dates") + if sc != EXPECTED_SERIES_COLS or ec != EXPECTED_EP_COLS: + ok = False + except sqlite3.OperationalError: + ok = False + if not ok and allow_drop: + _log("WARNING","Schema mismatch detected — dropping and recreating tables.") + conn.executescript(""" + DROP TABLE IF EXISTS episode_dates; + DROP TABLE IF EXISTS series; + """) + conn.executescript(SCHEMA) + # recheck + sc = _table_cols(conn, "series") + ec = _table_cols(conn, "episode_dates") + if sc != EXPECTED_SERIES_COLS or ec != EXPECTED_EP_COLS: + raise RuntimeError("Failed to create expected schema.") + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + _ensure_schema(conn, allow_drop=True) + return conn + +# ---------- HTTP/helpers ---------- +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: return None + try: return json.loads(data.decode("utf-8")) + except Exception: return None + +def to_utc_iso(s): + if not s: return None + try: + if len(s)==10 and s[4]=="-" and s[7]=="-": + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + s2 = s.replace("Z","+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) + else: dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + +def file_mtime_iso(p: Path): + try: return iso_from_epoch(p.stat().st_mtime) + except Exception: return None + +# ---------- clients ---------- +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL","").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY","") + self.timeout = int(os.environ.get("SONARR_TIMEOUT","45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES","3") or "3") + self.enabled = bool(self.base and self.key) + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: q += "?"+urllib.parse.urlencode(params) + headers = {"X-Api-Key": self.key} + for _ in range(self.retries): + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: return j + time.sleep(0.3) + return None + def series_by_imdb(self, imdb_id): + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY","") + self.timeout = int(os.environ.get("TMDB_TIMEOUT","45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES","3") or "3") + self.enabled = bool(self.api_key) + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = {**(params or {}), "api_key": self.api_key} + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + for _ in range(self.retries): + j = http_get_json(url, timeout=self.timeout) + if j is not None: return j + time.sleep(0.3) + return None + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source":"imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: return tv[0].get("id") + return None + def season_airdates(self, tv_id, season_number): + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num,int) and air: + res[num] = air + return res + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY","") + self.enabled = bool(self.api_key) + def season_airdates(self, imdb_id, season_number): + if not self.enabled: return {} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode( + {"i":imdb_id,"Season":str(season_number),"apikey":self.api_key} + ) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response")!="True": return res + for ep in j.get("Episodes",[]): + try: num = int(ep.get("Episode")) + except Exception: continue + rel = ep.get("Released") + if rel and rel!="N/A": res[num] = rel + return res + +# ---------- NFO / FS ---------- +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: f.unlink(); count += 1 + except Exception: pass + return count + +def resolve_season_dir(series_dir: Path, season_num: int) -> Path: + # prefer existing folders + by_number = series_dir / f"Season {season_num:02d}" + if by_number.exists(): return by_number + if season_num == 0: + specials = series_dir / "Specials" + if specials.exists(): return specials + # fallback: create standard folder + try: + (by_number).mkdir(parents=True, exist_ok=True) + return by_number + except Exception: + # last ditch: specials for season 0 + if season_num == 0: + specials = series_dir / "Specials" + specials.mkdir(parents=True, exist_ok=True) + return specials + return by_number + +def write_episode_nfo(series_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str): + season_dir = resolve_season_dir(series_dir, season_num) + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + xml.append(f" ") + xml.append("\n") + try: + dst.write_text("\n".join(xml), encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(series_dir: Path, season_num: int): + season_dir = resolve_season_dir(series_dir, season_num) + p = season_dir / "season.nfo" + if p.exists(): return + xml = f""" + + {season_num} + +""" + try: p.write_text(xml, encoding="utf-8") + except Exception as e: _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + p = series_dir / "tvshow.nfo" + if p.exists(): return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: p.write_text(xml, encoding="utf-8") + except Exception as e: _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z","+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: ts = datetime.fromisoformat(iso_timestamp.replace("Z","+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv",".mp4",".avi",".mov",".m4v",".srt",".ass",".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: pass + +# ---------- Core ---------- +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + out = {} + + if sonarr.enabled: + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + for ep in eps: + sn = ep.get("seasonNumber"); en = ep.get("episodeNumber") + if not isinstance(sn,int) or not isinstance(en,int): continue + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None; src = None + efid = ep.get("episodeFileId") + if efid: + fobj = sonarr.episode_file(efid) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")); src = "sonarr:episodeFile.dateAdded" + if not date_add and air_utc: + date_add = air_utc; src = "sonarr:episode.airDateUtc" + if air_utc or date_add: + out[(sn,en)] = (air_utc, date_add, src) + + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and (p.name.lower().startswith("season ") or p.name.lower()=="specials") + and (p.name.lower()=="specials" or p.name.split()[-1].isdigit())}) + # normalize specials to 0 + seasons = [0 if (series_dir/f"Season {s:02d}").name.lower()=="specials" else s for s in seasons] + for sn in seasons: + tm = tmdb.season_airdates(tv_id, sn) + for en, air in tm.items(): + key = (sn,en) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "tmdb:air_date") + + if omdb.enabled: + seasons = [] + for p in series_dir.iterdir(): + if not p.is_dir(): continue + name = p.name.lower() + if name=="specials": seasons.append(0) + elif name.startswith("season ") and p.name.split()[-1].isdigit(): + seasons.append(int(p.name.split()[-1])) + for sn in sorted(set(seasons)): + m = omdb.season_airdates(imdb_id, sn) + for en, air in m.items(): + key = (sn,en) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # File mtime fallback + for p in series_dir.iterdir(): + if not p.is_dir(): continue + name = p.name.lower() + if name=="specials": sn = 0 + elif name.startswith("season ") and p.name.split()[-1].isdigit(): + sn = int(p.name.split()[-1]) + else: + continue + media = [f for f in p.iterdir() if f.is_file() and re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: continue + key = (sn,en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ((out.get(key) or (None,))[0], mt, "file:mtime") + return out + +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + if args.manage_nfo: + for sd in series_dir.iterdir(): + if not sd.is_dir(): continue + name = sd.name.lower() + if not (name=="specials" or name.startswith("season ")): continue + removed = remove_long_nfos(sd) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {sd}") + maybe_tvshow_nfo(series_dir, imdb_id) + + dates = gather_episode_dates(series_dir, imdb_id, args) + + seasons_latest = {} + for (sn,en), (aired, dateadded, source) in sorted(dates.items()): + if args.manage_nfo: + write_episode_nfo(series_dir, sn, en, aired, dateadded, source or "") + # add season.nfo (safe) + ensure_season_nfo(series_dir, sn) + + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z","+00:00")) + seasons_latest[sn] = max(ts, seasons_latest.get(sn, ts)) + except Exception: + pass + + conn.commit() + + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = resolve_season_dir(series_dir, sn) + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + if is_series_dir(path): return [path] + if path.is_dir(): + out = [p for p in path.iterdir() if is_series_dir(p)] + return sorted(out) + return [] + +# ---------- CLI ---------- +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = sqlite3.connect(str(Path(args.db))) + conn.execute("PRAGMA foreign_keys=ON;") + conn.executescript(""" + DROP TABLE IF EXISTS episode_dates; + DROP TABLE IF EXISTS series; + """) + _ensure_schema(conn, allow_drop=False) + conn.commit(); conn.close() + +def cmd_apply(args): + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TMDB_API_KEY"): + _log("INFO", "TMDB not configured (set TMDB_API_KEY)") + if not os.environ.get("OMDB_API_KEY"): + _log("INFO", "OMDb not configured (set OMDB_API_KEY)") + + _log("INFO", f"=== APPLY: {targets[0] if len(targets)==1 else args.path} ===") + for series_dir in targets: + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Drop & recreate schema") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season/ Specials folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes (use with care)") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + args.func(args) + +if __name__ == "__main__": + main() + diff --git a/media_date_cache.V55 b/media_date_cache.V55 new file mode 100644 index 0000000..19d7cd1 --- /dev/null +++ b/media_date_cache.V55 @@ -0,0 +1,711 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +media_date_cache.py +One script to: + - Set episode using this order of precedence: + 1) Sonarr earliest *history* import (downloadFolderImported/episodeFileImported) + 2) Sonarr airDateUtc / airDate + 3) TMDB air_date + 4) Trakt first_aired + 5) OMDb Released + 6) Existing SxxEyy.nfo (dateadded/ aired) + 7) File mtime (last resort) + - Write minimal per-episode NFOs (SxxEyy.nfo) with , , and + - Remove long filename episode NFOs (e.g., "Show - SxxEyy - title.nfo") + - Optionally set season folder mtime to earliest episode date + - Does NOT modify season.nfo or tvshow.nfo + +CLI: + python3 media_date_cache.py rebuild-db \ + --env /opt/scripts/unmanic/.env \ + --db /opt/scripts/unmanic/.cache/media_dates.db + + python3 media_date_cache.py apply \ + --env /opt/scripts/unmanic/.env \ + --db /opt/scripts/unmanic/.cache/media_dates.db \ + --path "/path/Show Name (Year) [imdb-ttxxxxxxx]" \ + --manage-nfo \ + --fix-dir-mtimes +""" + +import argparse +import datetime as dt +import os +import re +import sqlite3 +import sys +import time +from pathlib import Path +from typing import Optional, Tuple, Dict, List + +import requests +import xml.etree.ElementTree as ET + +# ----------------------------- +# Logging +# ----------------------------- +def _ts(): + return dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds") +def _log(level, msg): print(f"[{_ts()}] {level}: {msg}") +def info(msg): _log("INFO", msg) +def warn(msg): _log("WARNING", msg) +def err(msg): _log("ERROR", msg) +def debug(msg): + if os.environ.get("MDC_DEBUG") == "1": + _log("DEBUG", msg) + +# ----------------------------- +# Minimal .env loader +# ----------------------------- +def load_env_file(env_path: Optional[str]) -> None: + if not env_path: + return + p = Path(env_path) + if not p.exists(): + warn(f".env not loaded (file not found) path={env_path}") + return + try: + with p.open("r", encoding="utf-8") as fh: + for line in fh: + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + k, v = s.split("=", 1) + k, v = k.strip(), v.strip() + if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")): + v = v[1:-1] + os.environ.setdefault(k, v) + info(f".env loaded from {env_path}") + except Exception as e: + warn(f".env not loaded (parse error): {e} path={env_path}") + +# ----------------------------- +# SQLite cache (lightweight) +# ----------------------------- +SCHEMA = """ +CREATE TABLE IF NOT EXISTS episode_dates( + series_imdb TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + dateadded TEXT, + source TEXT, + PRIMARY KEY(series_imdb, season, episode) +); +CREATE INDEX IF NOT EXISTS idx_series ON episode_dates(series_imdb); +""" + +def db_connect(db_path: str): + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA synchronous=NORMAL;") + return conn + +def db_init(conn): + for stmt in SCHEMA.strip().split(";"): + s = stmt.strip() + if s: + conn.execute(s) + conn.commit() + +def db_set_date(conn, series_imdb: str, season: int, episode: int, dateadded: str, source: str): + conn.execute( + "INSERT INTO episode_dates(series_imdb, season, episode, dateadded, source) VALUES(?,?,?,?,?) " + "ON CONFLICT(series_imdb, season, episode) DO UPDATE SET dateadded=excluded.dateadded, source=excluded.source", + (series_imdb, season, episode, dateadded, source), + ) + conn.commit() + +def db_get_date(conn, series_imdb: str, season: int, episode: int) -> Optional[Tuple[str, str]]: + cur = conn.execute( + "SELECT dateadded, source FROM episode_dates WHERE series_imdb=? AND season=? AND episode=?", + (series_imdb, season, episode), + ) + row = cur.fetchone() + return (row[0], row[1]) if row else None + +# ----------------------------- +# Helpers +# ----------------------------- +IMDB_DIR_RX = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) +SE_EP_RX = re.compile(r"[Ss](\d{1,2})[Ee](\d{1,3})") + +def parse_series_imdb_from_path(series_path: str) -> Optional[str]: + m = IMDB_DIR_RX.search(Path(series_path).name) + return m.group(1).lower() if m else None + +def iso_utc_from_timestamp(ts: float) -> str: + return dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc).isoformat(timespec="seconds") + +def iso_utc(date_str_or_iso: str, default_time: str = "00:00:00") -> str: + """Normalize YYYY-MM-DD or ISO to ISO UTC (seconds).""" + s = date_str_or_iso.strip() + try: + if "T" in s: + dtp = dt.datetime.fromisoformat(s.replace("Z", "+00:00")) + if dtp.tzinfo is None: + dtp = dtp.replace(tzinfo=dt.timezone.utc) + return dtp.astimezone(dt.timezone.utc).isoformat(timespec="seconds") + return f"{dt.date.fromisoformat(s).isoformat()}T{default_time}+00:00" + except Exception: + return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + +def iso_utc_now() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + +def ymd(s: Optional[str]) -> Optional[str]: + if not s: return None + try: + return dt.date.fromisoformat(s[:10]).isoformat() + except Exception: + return s[:10] + +def write_episode_nfo(nfo_path: Path, season: int, episode: int, aired_date: Optional[str], dateadded_iso: str, source: str): + lines = ['', ""] + lines.append(f" {season}") + lines.append(f" {episode}") + if aired_date: + lines.append(f" {ymd(aired_date)}") + lines.append(f" {dateadded_iso}") + lines.append(f" ") + lines.append("\n") + nfo_path.write_text("\n".join(lines), encoding="utf-8") + +def read_existing_short_nfo(folder: Path, season: int, episode: int) -> Tuple[Optional[str], Optional[str]]: + nfo = folder / f"S{season:02d}E{episode:02d}.nfo" + if not nfo.exists(): + return None, None + try: + tree = ET.parse(nfo) + root = tree.getroot() + aired = root.findtext("aired") + dateadded = root.findtext("dateadded") + return (aired, dateadded) + except Exception: + return None, None + +def remove_longname_nfos(folder: Path) -> int: + removed = 0 + for f in folder.glob("*.nfo"): + name = f.name + if re.fullmatch(r"[Ss]\d{2}[Ee]\d{2,3}\.nfo", name): + continue + if name.lower() in ("season.nfo", "tvshow.nfo"): + continue + try: + f.unlink() + removed += 1 + except Exception as e: + warn(f"Failed to remove {f}: {e}") + return removed + +def find_episode_video_and_se(folder: Path) -> Dict[Tuple[int, int], Path]: + videos = {} + for f in folder.iterdir(): + if not f.is_file(): continue + if f.suffix.lower() not in (".mkv", ".mp4", ".avi", ".ts", ".m2ts"): continue + m = SE_EP_RX.search(f.name) + if not m: continue + s, e = int(m.group(1)), int(m.group(2)) + videos[(s, e)] = f + return videos + +# ----------------------------- +# Sonarr client +# ----------------------------- +class Sonarr: + def __init__(self, url: Optional[str], apikey: Optional[str], timeout: int = 45, retries: int = 3): + self.url = (url or "").rstrip("/") + self.apikey = apikey or "" + self.timeout, self.retries = timeout, retries + self.enabled = bool(self.url and self.apikey) + if not self.enabled: + info("Sonarr not configured (set --sonarr-url/--sonarr-apikey or env SONARR_URL/SONARR_API_KEY)") + + def _get(self, path, params=None): + if not self.enabled: return None + p = dict(params or {}); p["apikey"] = self.apikey + last = None + for _ in range(self.retries + 1): + try: + r = requests.get(self.url + path, params=p, timeout=self.timeout) + if r.status_code == 404: return None + r.raise_for_status() + return r.json() + except Exception as e: + last = e; time.sleep(0.75) + warn(f"Sonarr GET {path} failed: {last}"); return None + + def series_by_imdb(self, imdb_id: str): + return self._get("/api/v3/series/lookup", params={"term": f"imdb:{imdb_id}"}) + + def episode(self, series_id: int, season: int, episode: int): + j = self._get("/api/v3/episode", params={"seriesId": series_id, "seasonNumber": season, "episodeNumber": episode}) + if not j: return None + return j[0] if isinstance(j, list) and j else j + + def episode_file(self, episode_file_id: int): + return self._get(f"/api/v3/episodefile/{episode_file_id}") + + # ----- new: history helpers ----- + def _history_series_pages(self, series_id: int, page_size: int = 1000): + """Yield history records for a series (handles both paged and list responses).""" + # Try paged endpoint + page = 1 + while True: + j = self._get("/api/v3/history/series", + params={"seriesId": series_id, "page": page, "pageSize": page_size, + "sortKey": "date", "sortDirection": "descending"}) + if not j: + break + # Some Sonarr setups return a dict with 'records'; others may return a list. + if isinstance(j, dict) and "records" in j: + recs = j.get("records") or [] + for r in recs: + yield r + total = int(j.get("totalRecords", len(recs))) + if page * page_size >= total or not recs: + break + elif isinstance(j, list): + if not j: break + for r in j: + yield r + break + else: + break + page += 1 + + def earliest_import_for_episode(self, series_id: int, episode_id: int) -> Optional[str]: + """ + Return earliest (oldest) history 'import' date for a given episode. + Looks for eventType in a broad set that indicates an import. + """ + if not self.enabled: + return None + # Event types we treat as "import" + IMPORT_EVENTS = {"downloadFolderImported", "episodeFileImported", "episodeFileAdded"} + earliest = None + for rec in self._history_series_pages(series_id): + try: + if int(rec.get("episodeId", -1)) != int(episode_id): + continue + et = rec.get("eventType") or rec.get("type") or "" + if et not in IMPORT_EVENTS: + continue + d = rec.get("date") + if not d: + continue + # Normalize + iso = iso_utc(d) + # Keep the oldest (min) + dt_ts = dt.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() + if earliest is None or dt_ts < earliest[0]: + earliest = (dt_ts, iso, et) + except Exception: + continue + if earliest: + return earliest[1] + return None + +# ----------------------------- +# TMDB client +# ----------------------------- +class TMDB: + def __init__(self, api_key: Optional[str], timeout: int = 45, retries: int = 3, primary_country: Optional[str] = None): + self.api_key = api_key or "" + self.timeout, self.retries = timeout, retries + self.primary_country = primary_country or "US" + self.enabled = bool(self.api_key) + if not self.enabled: + info("TMDB not configured (set --tmdb-apikey or env TMDB_API_KEY)") + self.base = "https://api.themoviedb.org/3" + + def _get(self, path, params=None): + if not self.enabled: return None + p = dict(params or {}); p["api_key"] = self.api_key + last = None + for _ in range(self.retries + 1): + try: + r = requests.get(self.base + path, params=p, timeout=self.timeout) + if r.status_code == 404: return None + r.raise_for_status() + return r.json() + except Exception as e: + last = e; time.sleep(0.75) + warn(f"TMDB GET {path} failed: {last}"); return None + + def tv_id_from_imdb(self, imdb_id: str) -> Optional[int]: + j = self._get(f"/find/{imdb_id}", params={"external_source": "imdb_id"}) + if not j: + warn(f"TMDB: find failed for {imdb_id}"); return None + tv_results = j.get("tv_results") or [] + if tv_results: + tv_id = tv_results[0].get("id") + debug(f"TMDB map IMDb {imdb_id} -> TV {tv_id}") + return tv_id + warn(f"TMDB: no tv_results for {imdb_id}"); return None + + def episode_air_date(self, tv_id: int, season: int, episode: int) -> Optional[str]: + j = self._get(f"/tv/{tv_id}/season/{season}/episode/{episode}") + if not j: + warn(f"TMDB: episode not found tv_id={tv_id} S{season:02d}E{episode:02d}") + return None + air_date = j.get("air_date") + if not air_date: + warn(f"TMDB: episode no air_date tv_id={tv_id} S{season:02d}E{episode:02d}") + return air_date + +# ----------------------------- +# Trakt client (optional) +# ----------------------------- +class Trakt: + def __init__(self, client_id: Optional[str], timeout: int = 30, retries: int = 2): + self.client_id = client_id or "" + self.enabled = bool(self.client_id) + if not self.enabled: + info("Trakt not configured (set TRAKT_CLIENT_ID)") + self.timeout, self.retries = timeout, retries + self.base = "https://api.trakt.tv" + + def _headers(self): + return {"Content-Type": "application/json", "trakt-api-version": "2", "trakt-api-key": self.client_id} + + def _get(self, path, params=None): + if not self.enabled: return None + last = None + for _ in range(self.retries + 1): + try: + r = requests.get(self.base + path, params=params, headers=self._headers(), timeout=self.timeout) + if r.status_code == 404: return None + r.raise_for_status() + return r.json() + except Exception as e: + last = e; time.sleep(0.75) + warn(f"Trakt GET {path} failed: {last}"); return None + + def episode_first_aired(self, imdb_id: str, season: int, episode: int) -> Optional[str]: + j = self._get(f"/shows/{imdb_id}/seasons/{season}/episodes/{episode}?extended=full") + if not j: + warn(f"Trakt: episode not found for {imdb_id} S{season:02d}E{episode:02d}") + return None + return j.get("first_aired") + +# ----------------------------- +# OMDb client (optional) +# ----------------------------- +class OMDb: + def __init__(self, api_key: Optional[str], timeout: int = 20, retries: int = 2): + self.api_key = api_key or "" + self.enabled = bool(self.api_key) + if not self.enabled: + info("OMDb not configured (set OMDB_API_KEY)") + self.timeout, self.retries = timeout, retries + self.base = "https://www.omdbapi.com/" + + def _get(self, params): + if not self.enabled: return None + p = dict(params or {}); p["apikey"] = self.api_key + last = None + for _ in range(self.retries + 1): + try: + r = requests.get(self.base, params=p, timeout=self.timeout) + r.raise_for_status() + j = r.json() + if j.get("Response") == "False": + return None + return j + except Exception as e: + last = e; time.sleep(0.5) + warn(f"OMDb request failed: {last}"); return None + + def season_listing(self, imdb_id: str, season: int) -> Optional[List[Dict]]: + j = self._get({"i": imdb_id, "Season": str(season)}) + if not j: + warn(f"OMDb: season listing not found for {imdb_id} season {season}") + return None + return j.get("Episodes") or [] + + def episode_released(self, imdb_id: str, season: int, episode: int) -> Optional[str]: + eps = self.season_listing(imdb_id, season) + if not eps: + return None + for e in eps: + try: + if int(e.get("Episode", "-1")) == episode: + rel = e.get("Released") + try: + dtp = dt.datetime.strptime(rel, "%d %b %Y") + return dtp.date().isoformat() + except Exception: + return None + except Exception: + pass + warn(f"OMDb: no matching episode {episode} in season {season} for {imdb_id}") + return None + +# ----------------------------- +# Source selection +# ----------------------------- +def choose_dateadded(series_imdb: str, + season: int, + episode: int, + ep_video_file: Path, + sonarr: Sonarr, + tmdb: TMDB, + trakt: Trakt, + omdb: OMDb, + folder_for_nfo: Path) -> Tuple[str, str, Optional[str]]: + """ + Priority: + 1) Sonarr earliest history import (if SONARR_USE_EARLIEST_IMPORT=1) + else Sonarr episodeFile.dateAdded + 2) Sonarr airDateUtc / airDate + 3) TMDB air_date + 4) Trakt first_aired + 5) OMDb Released + 6) Existing SxxEyy.nfo + 7) file mtime + Returns (dateadded_iso, source, aired_date_opt) + """ + + use_earliest = os.environ.get("SONARR_USE_EARLIEST_IMPORT", "1") != "0" + + # 1) Sonarr + if sonarr.enabled: + try: + results = sonarr.series_by_imdb(series_imdb) + if isinstance(results, list) and results: + series_obj = None + for s in results: + if (s.get("imdbId") or "").lower() == series_imdb.lower(): + series_obj = s; break + if not series_obj: series_obj = results[0] + sid = series_obj.get("id") + if sid: + ep = sonarr.episode(sid, season, episode) + if ep: + # Try earliest import from history first (if enabled) + if use_earliest and ep.get("id") is not None: + earliest = sonarr.earliest_import_for_episode(sid, int(ep["id"])) + if earliest: + iso = iso_utc(earliest) + info(f"Using Sonarr earliest history import S{season:02d}E{episode:02d}: {iso}") + return iso, "sonarr:history.earliestImport", ep.get("airDateUtc") or ep.get("airDate") or None + + # Else: current file's dateAdded + if ep.get("hasFile") and ep.get("episodeFileId"): + ef = sonarr.episode_file(int(ep["episodeFileId"])) + if ef and ef.get("dateAdded"): + epfile_added_iso = iso_utc(ef["dateAdded"]) + info(f"Using Sonarr episodeFile.dateAdded S{season:02d}E{episode:02d}: {epfile_added_iso}") + return epfile_added_iso, "sonarr:episodeFile.dateAdded", ep.get("airDateUtc") or ep.get("airDate") or None + # Fallback: episode.added (rare) + if ep.get("added"): + ep_added_iso = iso_utc(ep["added"]) + info(f"Using Sonarr episode.added S{season:02d}E{episode:02d}: {ep_added_iso}") + return ep_added_iso, "sonarr:episode.added", ep.get("airDateUtc") or ep.get("airDate") or None + # Next: air date from Sonarr + air_utc = ep.get("airDateUtc") or ep.get("airDate") + if air_utc: + iso = iso_utc(air_utc, "00:00:00") + info(f"Using Sonarr air date S{season:02d}E{episode:02d}: {iso}") + return iso, "sonarr:airDate", air_utc + info(f"Sonarr episode found but no usable dates S{season:02d}E{episode:02d}") + else: + info(f"Sonarr lookup returned no series for {series_imdb}") + except Exception as e: + warn(f"Sonarr error: {e}") + + # 2) TMDB + if tmdb.enabled: + try: + tv_id = tmdb.tv_id_from_imdb(series_imdb) + if tv_id: + air = tmdb.episode_air_date(tv_id, season, episode) + if air: + iso = iso_utc(air, "00:00:00") + info(f"Using TMDB air_date S{season:02d}E{episode:02d}: {iso}") + return iso, "tmdb:air_date", air + else: + warn(f"TMDB had no air_date S{season:02d}E{episode:02d}") + else: + warn(f"TMDB could not map {series_imdb} to TV id") + except Exception as e: + warn(f"TMDB error: {e}") + else: + info("TMDB not configured; skipping") + + # 3) Trakt first_aired + if trakt.enabled: + try: + fa = trakt.episode_first_aired(series_imdb, season, episode) + if fa: + iso = iso_utc(fa) + info(f"Using Trakt first_aired S{season:02d}E{episode:02d}: {iso}") + return iso, "trakt:first_aired", fa + except Exception as e: + warn(f"Trakt error: {e}") + + # 4) OMDb Released + if omdb.enabled: + try: + rel = omdb.episode_released(series_imdb, season, episode) + if rel: + iso = iso_utc(rel, "00:00:00") + info(f"Using OMDb Released S{season:02d}E{episode:02d}: {iso}") + return iso, "omdb:released", rel + except Exception as e: + warn(f"OMDb error: {e}") + + # 5) Existing NFO + aired0, dateadded0 = read_existing_short_nfo(folder_for_nfo, season, episode) + if dateadded0: + iso = iso_utc(dateadded0) + info(f"Using existing NFO dateadded S{season:02d}E{episode:02d}: {iso}") + return iso, "nfo:dateadded", aired0 + if aired0: + iso = iso_utc(aired0, "00:00:00") + info(f"Using existing NFO aired S{season:02d}E{episode:02d}: {iso}") + return iso, "nfo:aired", aired0 + + # 6) File mtime + try: + mt = ep_video_file.stat().st_mtime + iso = iso_utc_from_timestamp(mt) + except Exception: + iso = iso_utc_now() + info(f"Using file mtime S{season:02d}E{episode:02d}: {iso}") + return iso, "file:mtime", None + +# ----------------------------- +# Apply to a series +# ----------------------------- +def apply_to_series(series_path: str, conn, manage_nfo: bool, fix_dir_mtimes: bool, + sonarr: Sonarr, tmdb: TMDB, trakt: Trakt, omdb: OMDb): + root = Path(series_path) + imdb = parse_series_imdb_from_path(series_path) + if not imdb: + err(f"Series path does not contain an IMDb id [imdb-tt...] : {series_path}") + return + + for folder in sorted([p for p in root.iterdir() if p.is_dir()]): + # season folder detection (including Specials) + season_num = None + nl = folder.name.lower() + if nl == "specials": season_num = 0 + else: + m = re.match(r"season\s+(\d+)$", nl) + if m: season_num = int(m.group(1)) + if season_num is None: + continue + + removed = remove_longname_nfos(folder) + if removed: + info(f"Removed {removed} long-name NFO(s) in {folder}") + + videos = find_episode_video_and_se(folder) + if not videos: + debug(f"No episode videos in {folder}") + continue + + earliest_ts = None + for (snum, enum), vpath in sorted(videos.items()): + dateadded_iso, source, aired_opt = choose_dateadded(imdb, snum, enum, vpath, sonarr, tmdb, trakt, omdb, folder) + # cache + try: + db_set_date(conn, imdb, snum, enum, dateadded_iso, source) + except Exception as e: + warn(f"DB cache set failed for S{snum:02d}E{enum:02d}: {e}") + + if manage_nfo: + nfo = folder / f"S{snum:02d}E{enum:02d}.nfo" + try: + write_episode_nfo(nfo, snum, enum, aired_opt or ymd(dateadded_iso), dateadded_iso, source) + except Exception as e: + warn(f"Failed to write {nfo}: {e}") + + try: + t = dt.datetime.fromisoformat(dateadded_iso.replace("Z", "+00:00")).timestamp() + earliest_ts = t if earliest_ts is None else min(earliest_ts, t) + except Exception: + pass + + if fix_dir_mtimes and earliest_ts: + try: + os.utime(folder, (earliest_ts, earliest_ts)) + info(f"Updated mtime on {folder} -> {iso_utc_from_timestamp(earliest_ts)}") + except Exception as e: + warn(f"Failed to utime {folder}: {e}") + +# ----------------------------- +# CLI +# ----------------------------- +def cmd_rebuild_db(args): + load_env_file(args.env) + info(f"Rebuilding DB at {args.db}") + conn = db_connect(args.db) + db_init(conn) + conn.close() + +def cmd_apply(args): + load_env_file(args.env) + + sonarr = Sonarr( + url=args.sonarr_url or os.environ.get("SONARR_URL"), + apikey=args.sonarr_apikey or os.environ.get("SONARR_API_KEY"), + timeout=int(os.environ.get("SONARR_TIMEOUT", "45")), + retries=int(os.environ.get("SONARR_RETRIES", "3")), + ) + tmdb = TMDB( + api_key=args.tmdb_apikey or os.environ.get("TMDB_API_KEY"), + timeout=int(os.environ.get("TMDB_TIMEOUT", "45")), + retries=int(os.environ.get("TMDB_RETRIES", "3")), + primary_country=os.environ.get("TMDB_PRIMARY_COUNTRY", "US"), + ) + trakt = Trakt( + client_id=os.environ.get("TRAKT_CLIENT_ID"), + timeout=int(os.environ.get("TRAKT_TIMEOUT", "30")), + retries=int(os.environ.get("TRAKT_RETRIES", "2")), + ) + omdb = OMDb( + api_key=os.environ.get("OMDB_API_KEY"), + timeout=int(os.environ.get("OMDB_TIMEOUT", "20")), + retries=int(os.environ.get("OMDB_RETRIES", "2")), + ) + + conn = db_connect(args.db) + db_init(conn) + + apply_to_series(args.path, conn, args.manage_nfo, args.fix_dir_mtimes, sonarr, tmdb, trakt, omdb) + + conn.close() + +def main(): + ap = argparse.ArgumentParser(description="Maintain episode dateadded (Sonarr history earliest import→Sonarr air→TMDB→Trakt→OMDb→NFO→mtime), NFOs, and mtimes.") + sub = ap.add_subparsers(dest="cmd", required=True) + + ap_a = sub.add_parser("rebuild-db", help="Initialize SQLite cache") + ap_a.add_argument("--env", help="Path to .env file", default=None) + ap_a.add_argument("--db", help="SQLite DB path", required=True) + ap_a.set_defaults(func=cmd_rebuild_db) + + ap_b = sub.add_parser("apply", help="Apply to a single series folder") + ap_b.add_argument("--env", help="Path to .env file", default=None) + ap_b.add_argument("--db", help="SQLite DB path", required=True) + ap_b.add_argument("--path", help="Series folder (must contain [imdb-tt...])", required=True) + ap_b.add_argument("--manage-nfo", action="store_true", help="Write SxxEyy.nfo files") + ap_b.add_argument("--fix-dir-mtimes", action="store_true", help="Set season directory mtime to earliest episode date") + # Optional overrides (usually via .env) + ap_b.add_argument("--sonarr-url", default=None) + ap_b.add_argument("--sonarr-apikey", default=None) + ap_b.add_argument("--tmdb-apikey", default=None) + ap_b.set_defaults(func=cmd_apply) + + args = ap.parse_args() + args.func(args) + +if __name__ == "__main__": + main() + diff --git a/media_date_cache.claude b/media_date_cache.claude new file mode 100755 index 0000000..c8f615f --- /dev/null +++ b/media_date_cache.claude @@ -0,0 +1,658 @@ +#!/opt/scripts/unmanic/.venv/bin/python3 +# -*- coding: utf-8 -*- + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +# ========================= +# tiny .env loader + --env anywhere +# ========================= +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + print(f"[{_iso_now()}] {level}: {msg}") + +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # normalize variants + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1] + i += 2 + continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1] + i += 1 + continue + new_argv.append(a) + i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ========================= +# DB schema / connect +# ========================= +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + + # Check if tables exist and have correct schema + try: + # Test if episode_dates table exists with correct columns + result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() + columns = [row[1] for row in result] # row[1] is column name + required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] + + if not all(col in columns for col in required_columns): + _log("WARNING", "Database schema outdated, recreating tables...") + conn.execute("DROP TABLE IF EXISTS episode_dates;") + conn.execute("DROP TABLE IF EXISTS series;") + conn.execute("DROP TABLE IF EXISTS meta;") + + except sqlite3.OperationalError: + # Tables don't exist, which is fine + pass + + # Create/recreate schema + conn.executescript(SCHEMA) + + # Verify schema was created correctly + try: + conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") + _log("DEBUG", "Database schema verified") + except sqlite3.OperationalError as e: + _log("ERROR", f"Database schema verification failed: {e}") + raise + + return conn + +# ========================= +# HTTP helpers +# ========================= +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception: + return None + +def to_utc_iso(s): + # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' + if not s: + return None + try: + if len(s) == 10 and s[4] == "-" and s[7] == "-": + # date only -> midnight UTC + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + # normalize Z to +00:00 + s2 = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + try: + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + except Exception: + return None + +def file_mtime_iso(p: Path): + try: + return iso_from_epoch(p.stat().st_mtime) + except Exception: + return None + +# ========================= +# Source clients +# ========================= +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL", "").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY", "") + self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") + self.enabled = bool(self.base and self.key) + + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: + qs = urllib.parse.urlencode(params) + q = f"{q}?{qs}" + headers = {"X-Api-Key": self.key} + for attempt in range(self.retries): + try: + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: + return j + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + time.sleep(0.3) + return None + + def series_by_imdb(self, imdb_id): + # Sonarr supports /series/lookup?term=imdbid:tt1234567 + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + # pick exact imdb id match + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id): + """Get the original import date from history, not just the latest""" + history = self._get("/history", {"episodeId": episode_id, "sortKey": "date", "sortDir": "asc"}) or {} + + # Look for the earliest "downloadFolderImported" event + import_events = [] + for event in history.get("records", []): + if event.get("eventType") == "downloadFolderImported": + import_events.append({ + "date": event.get("date"), + "sourceTitle": event.get("sourceTitle", "") + }) + + if import_events: + # Return the earliest import date + earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") + return earliest["date"] + + return None + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY", "") + self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") + self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = params or {} + params["api_key"] = self.api_key + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + for _ in range(self.retries): + j = http_get_json(url, timeout=self.timeout) + if j is not None: + return j + time.sleep(0.3) + return None + + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: + return tv[0].get("id") + # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? + return None + + def season_airdates(self, tv_id, season_number): + # returns {episode_number: air_date or None} + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num, int) and air: + res[num] = air + return res + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def season_airdates(self, imdb_id, season_number): + # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY + if not self.enabled: return {} + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response") != "True": + return res + for ep in j.get("Episodes", []): + try: + num = int(ep.get("Episode")) + except Exception: + continue + rel = ep.get("Released") # '2011-12-04' etc + if rel and rel != "N/A": + res[num] = rel + return res + +# ========================= +# NFO helpers +# ========================= +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: + f.unlink() + count += 1 + except Exception: + pass + return count + +def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str): + # Ensure the season directory exists + try: + season_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + _log("ERROR", f"Failed creating season directory {season_dir}: {e}") + return + + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + xml.append(f" ") + xml.append("\n") + content = "\n".join(xml) + try: + dst.write_text(content, encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(season_dir: Path, season_num: int): + # Small, safe season.nfo; don't overwrite if present + p = season_dir / "season.nfo" + if p.exists(): + return + xml = f""" + + {season_num} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + # Don't clobber; only create minimal file if missing + p = series_dir / "tvshow.nfo" + if p.exists(): + return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".srt", ".ass", ".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: + os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: + pass + +# ========================= +# Core logic +# ========================= +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) + out = {} + + # Prefer Sonarr + if sonarr.enabled: + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + for ep in eps: + season_num = ep.get("seasonNumber") + ep_num = ep.get("episodeNumber") + if not isinstance(season_num, int) or not isinstance(ep_num, int): + continue + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None + src = None + + # Try to get original import date from history first + episode_id = ep.get("id") + if episode_id: + import_date = sonarr.get_episode_import_history(episode_id) + if import_date: + date_add = to_utc_iso(import_date) + src = "sonarr:history.downloadFolderImported" + + # Fallback to episode file dateAdded + if not date_add: + ep_file_id = ep.get("episodeFileId") + if ep_file_id: + fobj = sonarr.episode_file(ep_file_id) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")) + src = "sonarr:episodeFile.dateAdded" + + # Final fallback to air date + if not date_add and air_utc: + date_add = air_utc + src = "sonarr:episode.airDateUtc" + + if air_utc or date_add: + out[(season_num, ep_num)] = (air_utc, date_add, src) + + # Fill gaps with TMDB + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + # find all season folders we have + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = tmdb.season_airdates(tv_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: # no dateadded yet + air_iso = to_utc_iso(air) # date → midnight UTC + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # Fill remaining with OMDb + if omdb.enabled: + # infer seasons as above + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = omdb.season_airdates(imdb_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # Absolute last resort: file mtime + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # map existing episode files to ep numbers + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: # skip cross-labeling + continue + key = (sn, en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") + + return out + +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + # Store/refresh series row + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked + if args.manage_nfo: + for season_dir in series_dir.iterdir(): + if season_dir.is_dir() and season_dir.name.lower().startswith("season "): + removed = remove_long_nfos(season_dir) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") + try: + sn = int(season_dir.name.split()[-1]) + ensure_season_nfo(season_dir, sn) + except Exception: + pass + maybe_tvshow_nfo(series_dir, imdb_id) + + # Gather dates + dates = gather_episode_dates(series_dir, imdb_id, args) + + # Write short NFOs and record DB rows; track per-season latest date for mtime + seasons_latest = {} + + for (sn, en), (aired, dateadded, source) in sorted(dates.items()): + # write episode nfo + if args.manage_nfo: + write_episode_nfo(series_dir / f"Season {sn:02d}", sn, en, aired, dateadded, source or "") + + # DB upsert + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + # compute latest per season for mtime + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) + except Exception: + ts = None + if ts: + seasons_latest.setdefault(sn, ts) + if ts > seasons_latest[sn]: + seasons_latest[sn] = ts + + conn.commit() + + # Fix folder mtimes (season dirs) + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = series_dir / f"Season {sn:02d}" + if season_dir.exists(): + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs + if is_series_dir(path): + return [path] + if path.is_dir(): + out = [] + for p in path.iterdir(): + if is_series_dir(p): + out.append(p) + return sorted(out) + return [] + +# ========================= +# CLI +# ========================= +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = db_connect(Path(args.db)) + # wipe content but keep schema + conn.execute("DELETE FROM episode_dates;") + conn.execute("DELETE FROM series;") + conn.commit() + conn.close() + +def cmd_apply(args): + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + # Inform about source availability + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TMDB_API_KEY"): + _log("INFO", "TMDB not configured (set TMDB_API_KEY)") + if not os.environ.get("OMDB_API_KEY"): + _log("INFO", "OMDb not configured (set OMDB_API_KEY)") + + for series_dir in targets: + _log("INFO", f"=== APPLY: {series_dir} ===") + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes for a series dir or a library root") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to season latest (use with care)") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + # (No need to load env here; it was already handled in preparse) + args.func(args) + +if __name__ == "__main__": + main() diff --git a/media_date_cache.claude_TMDB b/media_date_cache.claude_TMDB new file mode 100755 index 0000000..f5f0c36 --- /dev/null +++ b/media_date_cache.claude_TMDB @@ -0,0 +1,838 @@ +#!/opt/scripts/unmanic/.venv/bin/python3 +# -*- coding: utf-8 -*- + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +# ========================= +# tiny .env loader + --env anywhere +# ========================= +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + # Only show DEBUG messages if debug mode is enabled + if level == "DEBUG" and not getattr(_log, 'debug_enabled', False): + return + print(f"[{_iso_now()}] {level}: {msg}") + +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # normalize variants + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1] + i += 2 + continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1] + i += 1 + continue + new_argv.append(a) + i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ========================= +# DB schema / connect +# ========================= +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + + # Check if tables exist and have correct schema + try: + # Test if episode_dates table exists with correct columns + result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() + columns = [row[1] for row in result] # row[1] is column name + required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] + + if not all(col in columns for col in required_columns): + _log("WARNING", "Database schema outdated, recreating tables...") + conn.execute("DROP TABLE IF EXISTS episode_dates;") + conn.execute("DROP TABLE IF EXISTS series;") + conn.execute("DROP TABLE IF EXISTS meta;") + + except sqlite3.OperationalError: + # Tables don't exist, which is fine + pass + + # Create/recreate schema + conn.executescript(SCHEMA) + + # Verify schema was created correctly + try: + conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") + _log("DEBUG", "Database schema verified") + except sqlite3.OperationalError as e: + _log("ERROR", f"Database schema verification failed: {e}") + raise + + return conn + +# ========================= +# HTTP helpers +# ========================= +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception as e: + _log("WARNING", f"Failed to parse JSON from {url}: {e}") + return None + except urllib.error.HTTPError as e: + _log("ERROR", f"HTTP {e.code} error for {url}: {e.reason}") + if e.code == 401: + _log("ERROR", "Authentication failed - check your API keys") + elif e.code == 429: + _log("WARNING", "Rate limited - waiting before retry") + time.sleep(2) + raise # Re-raise so caller can handle + except urllib.error.URLError as e: + _log("ERROR", f"Network error for {url}: {e}") + return None + except Exception as e: + _log("ERROR", f"Unexpected error for {url}: {e}") + return None + +def to_utc_iso(s): + # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' + if not s: + return None + try: + if len(s) == 10 and s[4] == "-" and s[7] == "-": + # date only -> midnight UTC + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + # normalize Z to +00:00 + s2 = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + try: + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + except Exception: + return None + +def file_mtime_iso(p: Path): + try: + return iso_from_epoch(p.stat().st_mtime) + except Exception: + return None + +# ========================= +# Source clients +# ========================= +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL", "").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY", "") + self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") + self.enabled = bool(self.base and self.key) + + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: + qs = urllib.parse.urlencode(params) + q = f"{q}?{qs}" + headers = {"X-Api-Key": self.key} + + for attempt in range(self.retries): + try: + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"Sonarr authentication failed - check SONARR_API_KEY") + return None # Don't retry auth failures + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(0.5 * (attempt + 1)) # Progressive backoff + + _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {q}") + return None + + def series_by_imdb(self, imdb_id): + # Sonarr supports /series/lookup?term=imdbid:tt1234567 + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + # pick exact imdb id match + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id): + """Get the original import date from history, not just the latest""" + # Get ALL history for this episode by fetching multiple pages + all_records = [] + page = 1 + page_size = 100 + + while True: + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "page": page, + "pageSize": page_size + }) or {} + + records = history.get("records", []) + if not records: + break + + all_records.extend(records) + + # Check if we got all records (if we got less than pageSize, we're done) + if len(records) < page_size: + break + + page += 1 + if page > 10: # Safety valve - don't go crazy + break + + _log("DEBUG", f"Got {len(all_records)} total history records for episode {episode_id}") + + # Look for ACTUAL import events (not just renames) + import_events = [] + grabbed_events = [] + + for event in all_records: + event_type = event.get("eventType") + date = event.get("date") + + _log("DEBUG", f"History event: {event_type} at {date}") + + # Primary import event - this is what we want + if event_type == "downloadFolderImported" and date: + import_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Track grab events as secondary indicators + elif event_type == "grabbed" and date: + grabbed_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Use downloadFolderImported events if we have them + if import_events: + earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") + _log("INFO", f"Found original import date: {earliest['date']} (downloadFolderImported) for episode {episode_id}") + return earliest["date"] + + # If no import events but we have grabs, use the earliest grab + some buffer time + # This handles cases where history is incomplete but we know when it was grabbed + if grabbed_events: + earliest_grab = min(grabbed_events, key=lambda x: x["date"] or "9999-12-31") + _log("WARNING", f"No downloadFolderImported found, using grabbed event: {earliest_grab['date']} for episode {episode_id}") + return earliest_grab["date"] + + _log("WARNING", f"No reliable import events found for episode {episode_id}") + return None + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY", "") + self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") + self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = params or {} + params["api_key"] = self.api_key + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + + for attempt in range(self.retries): + try: + j = http_get_json(url, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"TMDB authentication failed - check TMDB_API_KEY") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 3 + _log("WARNING", f"TMDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"TMDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") + except Exception as e: + _log("WARNING", f"TMDB API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(1.0 * (attempt + 1)) + + return None + + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: + return tv[0].get("id") + # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? + return None + + def season_airdates(self, tv_id, season_number): + # returns {episode_number: air_date or None} + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num, int) and air: + res[num] = air + return res + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def season_airdates(self, imdb_id, season_number): + # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY + if not self.enabled: return {} + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response") != "True": + return res + for ep in j.get("Episodes", []): + try: + num = int(ep.get("Episode")) + except Exception: + continue + rel = ep.get("Released") # '2011-12-04' etc + if rel and rel != "N/A": + res[num] = rel + return res + +# ========================= +# NFO helpers +# ========================= +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: + f.unlink() + count += 1 + except Exception: + pass + return count + +def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str): + # Ensure the season directory exists + try: + season_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + _log("ERROR", f"Failed creating season directory {season_dir}: {e}") + return + + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + xml.append(f" ") + xml.append("\n") + content = "\n".join(xml) + try: + dst.write_text(content, encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(season_dir: Path, season_num: int): + # Small, safe season.nfo; don't overwrite if present + p = season_dir / "season.nfo" + if p.exists(): + return + xml = f""" + + {season_num} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + # Don't clobber; only create minimal file if missing + p = series_dir / "tvshow.nfo" + if p.exists(): + return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".srt", ".ass", ".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: + os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: + pass + +# ========================= +# Core logic +# ========================= +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) + out = {} + + # If using cache, first load existing data from DB + if args.use_cache: + _log("INFO", f"Checking cache for existing episode data: {imdb_id}") + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() + + for season, episode, aired, dateadded, source in cached: + out[(season, episode)] = (aired, dateadded, source) + + _log("INFO", f"Found {len(cached)} cached episodes for {imdb_id}") + + # Find which episodes we have on disk but not in cache + disk_episodes = set() + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # Find episode files + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if m: + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 == sn: + disk_episodes.add((sn, en2)) + + cached_episodes = set(out.keys()) + missing_episodes = disk_episodes - cached_episodes + + if missing_episodes: + _log("INFO", f"Found {len(missing_episodes)} episodes on disk not in cache: {sorted(missing_episodes)}") + else: + _log("INFO", f"All disk episodes are cached, skipping API queries") + return out + + # Only query APIs if not using cache or if we have missing episodes + need_api_query = not args.use_cache or (args.use_cache and missing_episodes) + + if need_api_query: + # Prefer Sonarr + if sonarr.enabled: + _log("INFO", f"Querying Sonarr for series: {imdb_id}") + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + _log("INFO", f"Found Sonarr series ID: {series_id}") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + _log("INFO", f"Found {len(eps)} episodes in Sonarr") + for ep in eps: + season_num = ep.get("seasonNumber") + ep_num = ep.get("episodeNumber") + episode_id = ep.get("id") + + if not isinstance(season_num, int) or not isinstance(ep_num, int): + continue + + # If using cache, skip episodes we already have unless forced + key = (season_num, ep_num) + if args.use_cache and key in out and not args.force_refresh: + continue + + _log("DEBUG", f"Processing S{season_num:02d}E{ep_num:02d} (episode ID: {episode_id})") + + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None + src = None + + # Try to get original import date from history first + if episode_id: + import_date = sonarr.get_episode_import_history(episode_id) + if import_date: + date_add = to_utc_iso(import_date) + src = "sonarr:history.downloadFolderImported" + _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using original import date {date_add}") + + # Fallback to episode file dateAdded + if not date_add: + ep_file_id = ep.get("episodeFileId") + if ep_file_id: + fobj = sonarr.episode_file(ep_file_id) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")) + src = "sonarr:episodeFile.dateAdded" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No history found, using file dateAdded {date_add}") + + # Final fallback to air date + if not date_add and air_utc: + date_add = air_utc + src = "sonarr:episode.airDateUtc" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No dateAdded found, using air date {date_add}") + + if air_utc or date_add: + out[key] = (air_utc, date_add, src) + else: + _log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}") + + # Fill gaps with TMDB (only for missing episodes if using cache) + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + # find all season folders we have + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = tmdb.season_airdates(tv_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: # no dateadded yet + air_iso = to_utc_iso(air) # date → midnight UTC + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # Fill remaining with OMDb (only for missing episodes if using cache) + if omdb.enabled: + # infer seasons as above + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = omdb.season_airdates(imdb_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # Absolute last resort: file mtime (always check for missing episodes) + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # map existing episode files to ep numbers + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: # skip cross-labeling + continue + key = (sn, en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") + + return out + +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + # Store/refresh series row + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked + if args.manage_nfo: + for season_dir in series_dir.iterdir(): + if season_dir.is_dir() and season_dir.name.lower().startswith("season "): + removed = remove_long_nfos(season_dir) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") + try: + sn = int(season_dir.name.split()[-1]) + ensure_season_nfo(season_dir, sn) + except Exception: + pass + maybe_tvshow_nfo(series_dir, imdb_id) + + # Gather dates + dates = gather_episode_dates(series_dir, imdb_id, args, conn) + + # Write short NFOs and record DB rows; track per-season latest date for mtime + seasons_latest = {} + + for (sn, en), (aired, dateadded, source) in sorted(dates.items()): + # write episode nfo + if args.manage_nfo: + write_episode_nfo(series_dir / f"Season {sn:02d}", sn, en, aired, dateadded, source or "") + + # DB upsert + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + # compute latest per season for mtime + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) + except Exception: + ts = None + if ts: + seasons_latest.setdefault(sn, ts) + if ts > seasons_latest[sn]: + seasons_latest[sn] = ts + + conn.commit() + + # Fix folder mtimes (season dirs) + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = series_dir / f"Season {sn:02d}" + if season_dir.exists(): + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs + if is_series_dir(path): + return [path] + if path.is_dir(): + out = [] + for p in path.iterdir(): + if is_series_dir(p): + out.append(p) + return sorted(out) + return [] + +# ========================= +# CLI +# ========================= +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = db_connect(Path(args.db)) + # wipe content but keep schema + conn.execute("DELETE FROM episode_dates;") + conn.execute("DELETE FROM series;") + conn.commit() + conn.close() + +def cmd_apply(args): + # Enable debug logging if requested + if args.debug: + _log.debug_enabled = True + + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + # Inform about source availability + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TMDB_API_KEY"): + _log("INFO", "TMDB not configured (set TMDB_API_KEY)") + if not os.environ.get("OMDB_API_KEY"): + _log("INFO", "OMDb not configured (set OMDB_API_KEY)") + + for series_dir in targets: + _log("INFO", f"=== APPLY: {series_dir} ===") + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except urllib.error.HTTPError as e: + _log("ERROR", f"API error for {series_dir}: HTTP {e.code} - {e.reason}") + if e.code == 401: + _log("ERROR", "Check your API keys (SONARR_API_KEY, TMDB_API_KEY, OMDB_API_KEY)") + continue # Skip this series but continue with others + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + continue # Skip this series but continue with others + + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes for a series dir or a library root") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to season latest (use with care)") + p_apply.add_argument("--use-cache", action="store_true", help="Use cached episode data when available, only query APIs for missing episodes") + p_apply.add_argument("--force-refresh", action="store_true", help="Force refresh of cached data (use with --use-cache)") + p_apply.add_argument("--debug", action="store_true", help="Enable debug logging") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + # (No need to load env here; it was already handled in preparse) + args.func(args) + +if __name__ == "__main__": + main() diff --git a/media_date_cache.claude_v22 b/media_date_cache.claude_v22 new file mode 100755 index 0000000..af902e9 --- /dev/null +++ b/media_date_cache.claude_v22 @@ -0,0 +1,982 @@ +#!/opt/scripts/unmanic/.venv/bin/python3 +# -*- coding: utf-8 -*- + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +# ========================= +# tiny .env loader + --env anywhere +# ========================= +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + # Only show DEBUG messages if debug mode is enabled + if level == "DEBUG" and not getattr(_log, 'debug_enabled', False): + return + print(f"[{_iso_now()}] {level}: {msg}") + +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # normalize variants + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1] + i += 2 + continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1] + i += 1 + continue + new_argv.append(a) + i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ========================= +# DB schema / connect +# ========================= +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + + # Check if tables exist and have correct schema + try: + # Test if episode_dates table exists with correct columns + result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() + columns = [row[1] for row in result] # row[1] is column name + required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] + + if not all(col in columns for col in required_columns): + _log("WARNING", "Database schema outdated, recreating tables...") + conn.execute("DROP TABLE IF EXISTS episode_dates;") + conn.execute("DROP TABLE IF EXISTS series;") + conn.execute("DROP TABLE IF EXISTS meta;") + + except sqlite3.OperationalError: + # Tables don't exist, which is fine + pass + + # Create/recreate schema + conn.executescript(SCHEMA) + + # Verify schema was created correctly + try: + conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") + _log("DEBUG", "Database schema verified") + except sqlite3.OperationalError as e: + _log("ERROR", f"Database schema verification failed: {e}") + raise + + return conn + +# ========================= +# HTTP helpers +# ========================= +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception as e: + _log("WARNING", f"Failed to parse JSON from {url}: {e}") + return None + except urllib.error.HTTPError as e: + _log("ERROR", f"HTTP {e.code} error for {url}: {e.reason}") + if e.code == 401: + _log("ERROR", "Authentication failed - check your API keys") + elif e.code == 429: + _log("WARNING", "Rate limited - waiting before retry") + time.sleep(2) + raise # Re-raise so caller can handle + except urllib.error.URLError as e: + _log("ERROR", f"Network error for {url}: {e}") + return None + except Exception as e: + _log("ERROR", f"Unexpected error for {url}: {e}") + return None + +def to_utc_iso(s): + # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' + if not s: + return None + try: + if len(s) == 10 and s[4] == "-" and s[7] == "-": + # date only -> midnight UTC + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + # normalize Z to +00:00 + s2 = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + try: + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + except Exception: + return None + +def file_mtime_iso(p: Path): + try: + return iso_from_epoch(p.stat().st_mtime) + except Exception: + return None + +# ========================= +# Source clients +# ========================= +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL", "").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY", "") + self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") + self.enabled = bool(self.base and self.key) + + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: + qs = urllib.parse.urlencode(params) + q = f"{q}?{qs}" + headers = {"X-Api-Key": self.key} + + for attempt in range(self.retries): + try: + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"Sonarr authentication failed - check SONARR_API_KEY") + return None # Don't retry auth failures + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(0.5 * (attempt + 1)) # Progressive backoff + + _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {q}") + return None + + def series_by_imdb(self, imdb_id): + # Sonarr supports /series/lookup?term=imdbid:tt1234567 + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + # pick exact imdb id match + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id): + """Get the original import date from history, not just the latest""" + # Get ALL history for this episode by fetching multiple pages + all_records = [] + page = 1 + page_size = 100 + + while True: + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "page": page, + "pageSize": page_size + }) or {} + + records = history.get("records", []) + if not records: + break + + all_records.extend(records) + + # Check if we got all records (if we got less than pageSize, we're done) + if len(records) < page_size: + break + + page += 1 + if page > 10: # Safety valve - don't go crazy + break + + _log("DEBUG", f"Got {len(all_records)} total history records for episode {episode_id}") + + # Look for ACTUAL import events (not just renames) + import_events = [] + grabbed_events = [] + + for event in all_records: + event_type = event.get("eventType") + date = event.get("date") + + _log("DEBUG", f"History event: {event_type} at {date}") + + # Primary import event - this is what we want + if event_type == "downloadFolderImported" and date: + import_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Track grab events as secondary indicators + elif event_type == "grabbed" and date: + grabbed_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Use downloadFolderImported events if we have them + if import_events: + earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") + _log("INFO", f"Found original import date: {earliest['date']} (downloadFolderImported) for episode {episode_id}") + return earliest["date"] + + # If no import events but we have grabs, use the earliest grab + some buffer time + # This handles cases where history is incomplete but we know when it was grabbed + if grabbed_events: + earliest_grab = min(grabbed_events, key=lambda x: x["date"] or "9999-12-31") + _log("WARNING", f"No downloadFolderImported found, using grabbed event: {earliest_grab['date']} for episode {episode_id}") + return earliest_grab["date"] + + _log("WARNING", f"No reliable import events found for episode {episode_id}") + return None + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY", "") + self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") + self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = params or {} + params["api_key"] = self.api_key + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + + for attempt in range(self.retries): + try: + j = http_get_json(url, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"TMDB authentication failed - check TMDB_API_KEY") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 3 + _log("WARNING", f"TMDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"TMDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") + except Exception as e: + _log("WARNING", f"TMDB API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(1.0 * (attempt + 1)) + + return None + + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: + return tv[0].get("id") + # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? + return None + + def season_airdates(self, tv_id, season_number): + # returns {episode_number: air_date or None} + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num, int) and air: + res[num] = air + return res + +class TVDBClient: + def __init__(self): + self.api_key = os.environ.get("TVDB_API_KEY", "") + self.timeout = int(os.environ.get("TVDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TVDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + self.token = None + self.token_expires = 0 + + def _get_token(self): + """Get JWT token for TVDB API""" + if self.token and time.time() < self.token_expires: + return self.token + + url = "https://api4.thetvdb.com/v4/login" + headers = {"Content-Type": "application/json"} + data = json.dumps({"apikey": self.api_key}).encode('utf-8') + + try: + req = urllib.request.Request(url, data=data, headers=headers, method='POST') + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + result = json.loads(resp.read().decode('utf-8')) + self.token = result["data"]["token"] + # Token expires in 24 hours, refresh after 23 hours + self.token_expires = time.time() + (23 * 60 * 60) + return self.token + except Exception as e: + _log("ERROR", f"TVDB authentication failed: {e}") + return None + + def _get(self, path, params=None): + if not self.enabled: + return None + + token = self._get_token() + if not token: + return None + + url = f"https://api4.thetvdb.com/v4{path}" + if params: + url += "?" + urllib.parse.urlencode(params) + + headers = {"Authorization": f"Bearer {token}"} + + for attempt in range(self.retries): + try: + j = http_get_json(url, headers=headers, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + # Token might be expired, clear it and retry once + if attempt == 0: + self.token = None + self.token_expires = 0 + continue + _log("ERROR", f"TVDB authentication failed - check TVDB_API_KEY") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"TVDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"TVDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") + except Exception as e: + _log("WARNING", f"TVDB API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(1.0 * (attempt + 1)) + + return None + + def series_by_imdb(self, imdb_id): + """Find series by IMDb ID""" + j = self._get(f"/search", {"query": imdb_id, "type": "series"}) + if not j or not j.get("data"): + return None + + # Look for exact IMDb match + for series in j["data"]: + remote_ids = series.get("remoteIds") or [] + for remote in remote_ids: + if remote.get("sourceName") == "IMDB" and remote.get("id") == imdb_id: + return series + return None + + def season_episodes(self, series_id, season_number): + """Get episodes for a specific season""" + j = self._get(f"/series/{series_id}/episodes/default", { + "season": season_number, + "page": 0 + }) + + episodes = {} + if not j or not j.get("data"): + return episodes + + for ep in j["data"]["episodes"]: + ep_num = ep.get("number") + aired = ep.get("aired") + if isinstance(ep_num, int) and aired: + episodes[ep_num] = aired + + return episodes + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def season_airdates(self, imdb_id, season_number): + # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY + if not self.enabled: return {} + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response") != "True": + return res + for ep in j.get("Episodes", []): + try: + num = int(ep.get("Episode")) + except Exception: + continue + rel = ep.get("Released") # '2011-12-04' etc + if rel and rel != "N/A": + res[num] = rel + return res + +# ========================= +# NFO helpers +# ========================= +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: + f.unlink() + count += 1 + except Exception: + pass + return count + +def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str): + # Ensure the season directory exists + try: + season_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + _log("ERROR", f"Failed creating season directory {season_dir}: {e}") + return + + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + xml.append(f" ") + xml.append("\n") + content = "\n".join(xml) + try: + dst.write_text(content, encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(season_dir: Path, season_num: int): + # Small, safe season.nfo; don't overwrite if present + p = season_dir / "season.nfo" + if p.exists(): + return + xml = f""" + + {season_num} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + # Don't clobber; only create minimal file if missing + p = series_dir / "tvshow.nfo" + if p.exists(): + return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".srt", ".ass", ".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: + os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: + pass + +# ========================= +# Core logic +# ========================= +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) + out = {} + + # If using cache, first load existing data from DB + if args.use_cache: + _log("INFO", f"Checking cache for existing episode data: {imdb_id}") + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() + + for season, episode, aired, dateadded, source in cached: + out[(season, episode)] = (aired, dateadded, source) + + _log("INFO", f"Found {len(cached)} cached episodes for {imdb_id}") + + # Find which episodes we have on disk but not in cache + disk_episodes = set() + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # Find episode files + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if m: + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 == sn: + disk_episodes.add((sn, en2)) + + cached_episodes = set(out.keys()) + missing_episodes = disk_episodes - cached_episodes + + if missing_episodes: + _log("INFO", f"Found {len(missing_episodes)} episodes on disk not in cache: {sorted(missing_episodes)}") + else: + _log("INFO", f"All disk episodes are cached, skipping API queries") + return out + + # Only query APIs if not using cache or if we have missing episodes + need_api_query = not args.use_cache or (args.use_cache and missing_episodes) + + if need_api_query: + # Prefer Sonarr + if sonarr.enabled: + _log("INFO", f"Querying Sonarr for series: {imdb_id}") + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + _log("INFO", f"Found Sonarr series ID: {series_id}") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + _log("INFO", f"Found {len(eps)} episodes in Sonarr") + for ep in eps: + season_num = ep.get("seasonNumber") + ep_num = ep.get("episodeNumber") + episode_id = ep.get("id") + + if not isinstance(season_num, int) or not isinstance(ep_num, int): + continue + + # If using cache, skip episodes we already have unless forced + key = (season_num, ep_num) + if args.use_cache and key in out and not args.force_refresh: + continue + + _log("DEBUG", f"Processing S{season_num:02d}E{ep_num:02d} (episode ID: {episode_id})") + + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None + src = None + + # Try to get original import date from history first + if episode_id: + import_date = sonarr.get_episode_import_history(episode_id) + if import_date: + date_add = to_utc_iso(import_date) + src = "sonarr:history.downloadFolderImported" + _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using original import date {date_add}") + + # Fallback to episode file dateAdded + if not date_add: + ep_file_id = ep.get("episodeFileId") + if ep_file_id: + fobj = sonarr.episode_file(ep_file_id) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")) + src = "sonarr:episodeFile.dateAdded" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No history found, using file dateAdded {date_add}") + + # Final fallback to air date + if not date_add and air_utc: + date_add = air_utc + src = "sonarr:episode.airDateUtc" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No dateAdded found, using air date {date_add}") + + if air_utc or date_add: + out[key] = (air_utc, date_add, src) + else: + _log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}") + + # Fill gaps with TMDB (only for missing episodes if using cache) + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + # find all season folders we have + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = tmdb.season_airdates(tv_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: # no dateadded yet + air_iso = to_utc_iso(air) # date → midnight UTC + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # Fill remaining with OMDb (only for missing episodes if using cache) + if omdb.enabled: + # infer seasons as above + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = omdb.season_airdates(imdb_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # Absolute last resort: file mtime (always check for missing episodes) + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # map existing episode files to ep numbers + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: # skip cross-labeling + continue + key = (sn, en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") + + return out + +def is_series_current(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): + """Check if series NFOs and mtimes are already current with database""" + if not args.skip_if_current: + return False + + # Get cached episode data + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() + + if not cached: + return False + + # Check if NFO files match database + for season, episode, aired, dateadded, source in cached: + season_dir = series_dir / f"Season {season:02d}" + nfo_file = season_dir / f"S{season:02d}E{episode:02d}.nfo" + + if not nfo_file.exists(): + return False + + # Quick check - does NFO contain the expected dateadded? + try: + nfo_content = nfo_file.read_text(encoding="utf-8") + if dateadded and dateadded not in nfo_content: + return False + except Exception: + return False + + return True +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + # Check if series is already current + if is_series_current(series_dir, imdb_id, args, conn): + _log("INFO", f"Series already current, skipping: {series_dir.name}") + return + + # Store/refresh series row + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked + if args.manage_nfo: + for season_dir in series_dir.iterdir(): + if season_dir.is_dir() and season_dir.name.lower().startswith("season "): + removed = remove_long_nfos(season_dir) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") + try: + sn = int(season_dir.name.split()[-1]) + ensure_season_nfo(season_dir, sn) + except Exception: + pass + maybe_tvshow_nfo(series_dir, imdb_id) + + # Gather dates + dates = gather_episode_dates(series_dir, imdb_id, args, conn) + + # Write short NFOs and record DB rows; track per-season latest date for mtime + seasons_latest = {} + + for (sn, en), (aired, dateadded, source) in sorted(dates.items()): + # write episode nfo + if args.manage_nfo: + write_episode_nfo(series_dir / f"Season {sn:02d}", sn, en, aired, dateadded, source or "") + + # DB upsert + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + # compute latest per season for mtime + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) + except Exception: + ts = None + if ts: + seasons_latest.setdefault(sn, ts) + if ts > seasons_latest[sn]: + seasons_latest[sn] = ts + + conn.commit() + + # Fix folder mtimes (season dirs) + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = series_dir / f"Season {sn:02d}" + if season_dir.exists(): + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs + if is_series_dir(path): + return [path] + if path.is_dir(): + out = [] + for p in path.iterdir(): + if is_series_dir(p): + out.append(p) + return sorted(out) + return [] + +# ========================= +# CLI +# ========================= +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = db_connect(Path(args.db)) + # wipe content but keep schema + conn.execute("DELETE FROM episode_dates;") + conn.execute("DELETE FROM series;") + conn.commit() + conn.close() + +def cmd_apply(args): + # Enable debug logging if requested + if args.debug: + _log.debug_enabled = True + + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + # Inform about source availability + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TMDB_API_KEY"): + _log("INFO", "TMDB not configured (set TMDB_API_KEY)") + if not os.environ.get("TVDB_API_KEY"): + _log("INFO", "TVDB not configured (set TVDB_API_KEY)") + if not os.environ.get("OMDB_API_KEY"): + _log("INFO", "OMDb not configured (set OMDB_API_KEY)") + + for series_dir in targets: + _log("INFO", f"=== APPLY: {series_dir} ===") + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except urllib.error.HTTPError as e: + _log("ERROR", f"API error for {series_dir}: HTTP {e.code} - {e.reason}") + if e.code == 401: + _log("ERROR", "Check your API keys (SONARR_API_KEY, TMDB_API_KEY, OMDB_API_KEY)") + continue # Skip this series but continue with others + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + continue # Skip this series but continue with others + + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes for a series dir or a library root") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to season latest (use with care)") + p_apply.add_argument("--skip-if-current", action="store_true", help="Skip series where NFOs are already current with database") + p_apply.add_argument("--use-cache", action="store_true", help="Use cached episode data when available, only query APIs for missing episodes") + p_apply.add_argument("--force-refresh", action="store_true", help="Force refresh of cached data (use with --use-cache)") + p_apply.add_argument("--debug", action="store_true", help="Enable debug logging") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + # (No need to load env here; it was already handled in preparse) + args.func(args) + +if __name__ == "__main__": + main() diff --git a/media_date_cache.claude_v26 b/media_date_cache.claude_v26 new file mode 100755 index 0000000..b177292 --- /dev/null +++ b/media_date_cache.claude_v26 @@ -0,0 +1,978 @@ +#!/opt/scripts/unmanic/.venv/bin/python3 +# -*- coding: utf-8 -*- + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +# ========================= +# tiny .env loader + --env anywhere +# ========================= +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + # Only show DEBUG messages if debug mode is enabled + if level == "DEBUG" and not getattr(_log, 'debug_enabled', False): + return + print(f"[{_iso_now()}] {level}: {msg}") + +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # normalize variants + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1] + i += 2 + continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1] + i += 1 + continue + new_argv.append(a) + i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ========================= +# DB schema / connect +# ========================= +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + + # Check if tables exist and have correct schema + try: + # Test if episode_dates table exists with correct columns + result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() + columns = [row[1] for row in result] # row[1] is column name + required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] + + if not all(col in columns for col in required_columns): + _log("WARNING", "Database schema outdated, recreating tables...") + conn.execute("DROP TABLE IF EXISTS episode_dates;") + conn.execute("DROP TABLE IF EXISTS series;") + conn.execute("DROP TABLE IF EXISTS meta;") + + except sqlite3.OperationalError: + # Tables don't exist, which is fine + pass + + # Create/recreate schema + conn.executescript(SCHEMA) + + # Verify schema was created correctly + try: + conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") + _log("DEBUG", "Database schema verified") + except sqlite3.OperationalError as e: + _log("ERROR", f"Database schema verification failed: {e}") + raise + + return conn + +# ========================= +# HTTP helpers +# ========================= +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception as e: + _log("WARNING", f"Failed to parse JSON from {url}: {e}") + return None + except urllib.error.HTTPError as e: + _log("ERROR", f"HTTP {e.code} error for {url}: {e.reason}") + if e.code == 401: + _log("ERROR", "Authentication failed - check your API keys") + elif e.code == 429: + _log("WARNING", "Rate limited - waiting before retry") + time.sleep(2) + raise # Re-raise so caller can handle + except urllib.error.URLError as e: + _log("ERROR", f"Network error for {url}: {e}") + return None + except Exception as e: + _log("ERROR", f"Unexpected error for {url}: {e}") + return None + +def to_utc_iso(s): + # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' + if not s: + return None + try: + if len(s) == 10 and s[4] == "-" and s[7] == "-": + # date only -> midnight UTC + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + # normalize Z to +00:00 + s2 = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + try: + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + except Exception: + return None + +def file_mtime_iso(p: Path): + try: + return iso_from_epoch(p.stat().st_mtime) + except Exception: + return None + +# ========================= +# Source clients +# ========================= +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL", "").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY", "") + self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") + self.enabled = bool(self.base and self.key) + + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: + qs = urllib.parse.urlencode(params) + q = f"{q}?{qs}" + headers = {"X-Api-Key": self.key} + + for attempt in range(self.retries): + try: + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"Sonarr authentication failed - check SONARR_API_KEY") + return None # Don't retry auth failures + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(0.5 * (attempt + 1)) # Progressive backoff + + _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {q}") + return None + + def series_by_imdb(self, imdb_id): + # Sonarr supports /series/lookup?term=imdbid:tt1234567 + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + # pick exact imdb id match + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id): + """Get the original import date from history, not just the latest""" + # Get ALL history for this episode by fetching multiple pages + all_records = [] + page = 1 + page_size = 100 + + while True: + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "page": page, + "pageSize": page_size + }) or {} + + records = history.get("records", []) + if not records: + break + + all_records.extend(records) + + # Check if we got all records (if we got less than pageSize, we're done) + if len(records) < page_size: + break + + page += 1 + if page > 10: # Safety valve - don't go crazy + break + + _log("DEBUG", f"Got {len(all_records)} total history records for episode {episode_id}") + + # Look for ACTUAL import events (not just renames) + import_events = [] + grabbed_events = [] + + for event in all_records: + event_type = event.get("eventType") + date = event.get("date") + + _log("DEBUG", f"History event: {event_type} at {date}") + + # Primary import event - this is what we want + if event_type == "downloadFolderImported" and date: + import_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Track grab events as secondary indicators + elif event_type == "grabbed" and date: + grabbed_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Use downloadFolderImported events if we have them + if import_events: + earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") + _log("INFO", f"Found original import date: {earliest['date']} (downloadFolderImported) for episode {episode_id}") + return earliest["date"] + + # If no import events but we have grabs, use the earliest grab + some buffer time + # This handles cases where history is incomplete but we know when it was grabbed + if grabbed_events: + earliest_grab = min(grabbed_events, key=lambda x: x["date"] or "9999-12-31") + _log("WARNING", f"No downloadFolderImported found, using grabbed event: {earliest_grab['date']} for episode {episode_id}") + return earliest_grab["date"] + + _log("WARNING", f"No reliable import events found for episode {episode_id}") + return None + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY", "") + self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") + self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = params or {} + params["api_key"] = self.api_key + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + + for attempt in range(self.retries): + try: + j = http_get_json(url, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"TMDB authentication failed - check TMDB_API_KEY") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 3 + _log("WARNING", f"TMDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"TMDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") + except Exception as e: + _log("WARNING", f"TMDB API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(1.0 * (attempt + 1)) + + return None + + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: + return tv[0].get("id") + # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? + return None + + def season_airdates(self, tv_id, season_number): + # returns {episode_number: air_date or None} + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num, int) and air: + res[num] = air + return res + +class TVDBClient: + def __init__(self): + self.api_key = os.environ.get("TVDB_API_KEY", "") + self.timeout = int(os.environ.get("TVDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TVDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + self.token = None + self.token_expires = 0 + + def _get_token(self): + """Get JWT token for TVDB API""" + if self.token and time.time() < self.token_expires: + return self.token + + url = "https://api4.thetvdb.com/v4/login" + headers = {"Content-Type": "application/json"} + data = json.dumps({"apikey": self.api_key}).encode('utf-8') + + try: + req = urllib.request.Request(url, data=data, headers=headers, method='POST') + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + result = json.loads(resp.read().decode('utf-8')) + self.token = result["data"]["token"] + # Token expires in 24 hours, refresh after 23 hours + self.token_expires = time.time() + (23 * 60 * 60) + return self.token + except Exception as e: + _log("ERROR", f"TVDB authentication failed: {e}") + return None + + def _get(self, path, params=None): + if not self.enabled: + return None + + token = self._get_token() + if not token: + return None + + url = f"https://api4.thetvdb.com/v4{path}" + if params: + url += "?" + urllib.parse.urlencode(params) + + headers = {"Authorization": f"Bearer {token}"} + + for attempt in range(self.retries): + try: + j = http_get_json(url, headers=headers, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + # Token might be expired, clear it and retry once + if attempt == 0: + self.token = None + self.token_expires = 0 + continue + _log("ERROR", f"TVDB authentication failed - check TVDB_API_KEY") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"TVDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"TVDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") + except Exception as e: + _log("WARNING", f"TVDB API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(1.0 * (attempt + 1)) + + return None + + def series_by_imdb(self, imdb_id): + """Find series by IMDb ID""" + j = self._get(f"/search", {"query": imdb_id, "type": "series"}) + if not j or not j.get("data"): + return None + + # Look for exact IMDb match + for series in j["data"]: + remote_ids = series.get("remoteIds") or [] + for remote in remote_ids: + if remote.get("sourceName") == "IMDB" and remote.get("id") == imdb_id: + return series + return None + + def season_episodes(self, series_id, season_number): + """Get episodes for a specific season""" + j = self._get(f"/series/{series_id}/episodes/default", { + "season": season_number, + "page": 0 + }) + + episodes = {} + if not j or not j.get("data"): + return episodes + + for ep in j["data"]["episodes"]: + ep_num = ep.get("number") + aired = ep.get("aired") + if isinstance(ep_num, int) and aired: + episodes[ep_num] = aired + + return episodes + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def season_airdates(self, imdb_id, season_number): + # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY + if not self.enabled: return {} + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response") != "True": + return res + for ep in j.get("Episodes", []): + try: + num = int(ep.get("Episode")) + except Exception: + continue + rel = ep.get("Released") # '2011-12-04' etc + if rel and rel != "N/A": + res[num] = rel + return res + +# ========================= +# NFO helpers +# ========================= +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: + f.unlink() + count += 1 + except Exception: + pass + return count + +def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str): + # Ensure the season directory exists + try: + season_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + _log("ERROR", f"Failed creating season directory {season_dir}: {e}") + return + + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + xml.append(f" ") + xml.append("\n") + content = "\n".join(xml) + try: + dst.write_text(content, encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(season_dir: Path, season_num: int): + # Small, safe season.nfo; don't overwrite if present + p = season_dir / "season.nfo" + if p.exists(): + return + xml = f""" + + {season_num} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + # Don't clobber; only create minimal file if missing + p = series_dir / "tvshow.nfo" + if p.exists(): + return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".srt", ".ass", ".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: + os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: + pass + +# ========================= +# Core logic +# ========================= +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) + out = {} + + # If using cache, first load existing data from DB + if args.use_cache: + _log("INFO", f"Checking cache for existing episode data: {imdb_id}") + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() + + for season, episode, aired, dateadded, source in cached: + out[(season, episode)] = (aired, dateadded, source) + + _log("INFO", f"Found {len(cached)} cached episodes for {imdb_id}") + + # Find which episodes we have on disk but not in cache + disk_episodes = set() + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # Find episode files + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if m: + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 == sn: + disk_episodes.add((sn, en2)) + + cached_episodes = set(out.keys()) + missing_episodes = disk_episodes - cached_episodes + + if missing_episodes: + _log("INFO", f"Found {len(missing_episodes)} episodes on disk not in cache: {sorted(missing_episodes)}") + else: + _log("INFO", f"All disk episodes are cached, skipping API queries") + return out + + # Only query APIs if not using cache or if we have missing episodes + need_api_query = not args.use_cache or (args.use_cache and missing_episodes) + + if need_api_query: + # Prefer Sonarr + if sonarr.enabled: + _log("INFO", f"Querying Sonarr for series: {imdb_id}") + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + _log("INFO", f"Found Sonarr series ID: {series_id}") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + _log("INFO", f"Found {len(eps)} episodes in Sonarr") + for ep in eps: + season_num = ep.get("seasonNumber") + ep_num = ep.get("episodeNumber") + episode_id = ep.get("id") + + if not isinstance(season_num, int) or not isinstance(ep_num, int): + continue + + # If using cache, skip episodes we already have unless forced + key = (season_num, ep_num) + if args.use_cache and key in out and not args.force_refresh: + continue + + _log("DEBUG", f"Processing S{season_num:02d}E{ep_num:02d} (episode ID: {episode_id})") + + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None + src = None + + # Try to get original import date from history first + if episode_id: + import_date = sonarr.get_episode_import_history(episode_id) + if import_date: + date_add = to_utc_iso(import_date) + src = "sonarr:history.downloadFolderImported" + _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using original import date {date_add}") + + # Fallback to episode file dateAdded + if not date_add: + ep_file_id = ep.get("episodeFileId") + if ep_file_id: + fobj = sonarr.episode_file(ep_file_id) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")) + src = "sonarr:episodeFile.dateAdded" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No history found, using file dateAdded {date_add}") + + # Final fallback to air date + if not date_add and air_utc: + date_add = air_utc + src = "sonarr:episode.airDateUtc" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No dateAdded found, using air date {date_add}") + + if air_utc or date_add: + out[key] = (air_utc, date_add, src) + else: + _log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}") + + # Fill gaps with TMDB (only for missing episodes if using cache) + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + # find all season folders we have + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = tmdb.season_airdates(tv_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: # no dateadded yet + air_iso = to_utc_iso(air) # date → midnight UTC + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # Fill remaining with OMDb (only for missing episodes if using cache) + if omdb.enabled: + # infer seasons as above + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = omdb.season_airdates(imdb_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # Absolute last resort: file mtime (always check for missing episodes) + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # map existing episode files to ep numbers + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: # skip cross-labeling + continue + key = (sn, en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") + + return out + +def is_series_current(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): + """Check if series NFOs and mtimes are already current with database""" + if not args.skip_if_current: + return False + + # Get cached episode data + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() + + if not cached: + return False + + # Check if NFO files match database + for season, episode, aired, dateadded, source in cached: + season_dir = series_dir / f"Season {season:02d}" + nfo_file = season_dir / f"S{season:02d}E{episode:02d}.nfo" + + if not nfo_file.exists(): + return False + + # Quick check - does NFO contain the expected dateadded? + try: + nfo_content = nfo_file.read_text(encoding="utf-8") + if dateadded and dateadded not in nfo_content: + return False + except Exception: + return False + + return True +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + # Check if series is already current + if is_series_current(series_dir, imdb_id, args, conn): + _log("INFO", f"Series already current, skipping: {series_dir.name}") + return + + # Store/refresh series row + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked + if args.manage_nfo: + for season_dir in series_dir.iterdir(): + if season_dir.is_dir() and season_dir.name.lower().startswith("season "): + removed = remove_long_nfos(season_dir) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") + try: + sn = int(season_dir.name.split()[-1]) + ensure_season_nfo(season_dir, sn) + except Exception: + pass + maybe_tvshow_nfo(series_dir, imdb_id) + + # Gather dates + dates = gather_episode_dates(series_dir, imdb_id, args, conn) + + # Write short NFOs and record DB rows; track per-season latest date for mtime + seasons_latest = {} + + for (sn, en), (aired, dateadded, source) in sorted(dates.items()): + # write episode nfo + if args.manage_nfo: + write_episode_nfo(series_dir / f"Season {sn:02d}", sn, en, aired, dateadded, source or "") + + # DB upsert + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + # compute latest per season for mtime + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) + except Exception: + ts = None + if ts: + seasons_latest.setdefault(sn, ts) + if ts > seasons_latest[sn]: + seasons_latest[sn] = ts + + conn.commit() + + # Fix folder mtimes (season dirs) + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = series_dir / f"Season {sn:02d}" + if season_dir.exists(): + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs + if is_series_dir(path): + return [path] + if path.is_dir(): + out = [] + for p in path.iterdir(): + if is_series_dir(p): + out.append(p) + return sorted(out) + return [] + +# ========================= +# CLI +# ========================= +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = db_connect(Path(args.db)) + # wipe content but keep schema + conn.execute("DELETE FROM episode_dates;") + conn.execute("DELETE FROM series;") + conn.commit() + conn.close() + +def cmd_apply(args): + # Enable debug logging if requested + if args.debug: + _log.debug_enabled = True + + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + # Inform about source availability + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TVDB_API_KEY"): + _log("INFO", "TVDB not configured (set TVDB_API_KEY for better air date coverage)") + + for series_dir in targets: + _log("INFO", f"=== APPLY: {series_dir} ===") + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except urllib.error.HTTPError as e: + _log("ERROR", f"API error for {series_dir}: HTTP {e.code} - {e.reason}") + if e.code == 401: + _log("ERROR", "Check your API keys (SONARR_API_KEY, TMDB_API_KEY, OMDB_API_KEY)") + continue # Skip this series but continue with others + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + continue # Skip this series but continue with others + + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes for a series dir or a library root") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to season latest (use with care)") + p_apply.add_argument("--skip-if-current", action="store_true", help="Skip series where NFOs are already current with database") + p_apply.add_argument("--use-cache", action="store_true", help="Use cached episode data when available, only query APIs for missing episodes") + p_apply.add_argument("--force-refresh", action="store_true", help="Force refresh of cached data (use with --use-cache)") + p_apply.add_argument("--debug", action="store_true", help="Enable debug logging") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + # (No need to load env here; it was already handled in preparse) + args.func(args) + +if __name__ == "__main__": + main() diff --git a/media_date_cache.claudev10 b/media_date_cache.claudev10 new file mode 100755 index 0000000..e5669ba --- /dev/null +++ b/media_date_cache.claudev10 @@ -0,0 +1,715 @@ +#!/opt/scripts/unmanic/.venv/bin/python3 +# -*- coding: utf-8 -*- + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +# ========================= +# tiny .env loader + --env anywhere +# ========================= +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + # Only show DEBUG messages if debug mode is enabled + if level == "DEBUG" and not getattr(_log, 'debug_enabled', False): + return + print(f"[{_iso_now()}] {level}: {msg}") + +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # normalize variants + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1] + i += 2 + continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1] + i += 1 + continue + new_argv.append(a) + i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ========================= +# DB schema / connect +# ========================= +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + + # Check if tables exist and have correct schema + try: + # Test if episode_dates table exists with correct columns + result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() + columns = [row[1] for row in result] # row[1] is column name + required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] + + if not all(col in columns for col in required_columns): + _log("WARNING", "Database schema outdated, recreating tables...") + conn.execute("DROP TABLE IF EXISTS episode_dates;") + conn.execute("DROP TABLE IF EXISTS series;") + conn.execute("DROP TABLE IF EXISTS meta;") + + except sqlite3.OperationalError: + # Tables don't exist, which is fine + pass + + # Create/recreate schema + conn.executescript(SCHEMA) + + # Verify schema was created correctly + try: + conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") + _log("DEBUG", "Database schema verified") + except sqlite3.OperationalError as e: + _log("ERROR", f"Database schema verification failed: {e}") + raise + + return conn + +# ========================= +# HTTP helpers +# ========================= +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception: + return None + +def to_utc_iso(s): + # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' + if not s: + return None + try: + if len(s) == 10 and s[4] == "-" and s[7] == "-": + # date only -> midnight UTC + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + # normalize Z to +00:00 + s2 = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + try: + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + except Exception: + return None + +def file_mtime_iso(p: Path): + try: + return iso_from_epoch(p.stat().st_mtime) + except Exception: + return None + +# ========================= +# Source clients +# ========================= +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL", "").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY", "") + self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") + self.enabled = bool(self.base and self.key) + + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: + qs = urllib.parse.urlencode(params) + q = f"{q}?{qs}" + headers = {"X-Api-Key": self.key} + for attempt in range(self.retries): + try: + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: + return j + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + time.sleep(0.3) + return None + + def series_by_imdb(self, imdb_id): + # Sonarr supports /series/lookup?term=imdbid:tt1234567 + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + # pick exact imdb id match + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id): + """Get the original import date from history, not just the latest""" + # Get ALL history for this episode by fetching multiple pages + all_records = [] + page = 1 + page_size = 100 + + while True: + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "page": page, + "pageSize": page_size + }) or {} + + records = history.get("records", []) + if not records: + break + + all_records.extend(records) + + # Check if we got all records (if we got less than pageSize, we're done) + if len(records) < page_size: + break + + page += 1 + if page > 10: # Safety valve - don't go crazy + break + + _log("DEBUG", f"Got {len(all_records)} total history records for episode {episode_id}") + + # Look for import-related events in chronological order + import_events = [] + for event in all_records: + event_type = event.get("eventType") + date = event.get("date") + + _log("DEBUG", f"History event: {event_type} at {date}") + + # Look for various import-related events + if event_type in ["downloadFolderImported", "episodeFileRenamed"] and date: + import_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + if import_events: + # Return the earliest import-related date + earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") + _log("INFO", f"Found original import date: {earliest['date']} ({earliest['eventType']}) for episode {episode_id}") + return earliest["date"] + + _log("WARNING", f"No import-related events found for episode {episode_id}") + return None + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY", "") + self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") + self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = params or {} + params["api_key"] = self.api_key + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + for _ in range(self.retries): + j = http_get_json(url, timeout=self.timeout) + if j is not None: + return j + time.sleep(0.3) + return None + + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: + return tv[0].get("id") + # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? + return None + + def season_airdates(self, tv_id, season_number): + # returns {episode_number: air_date or None} + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num, int) and air: + res[num] = air + return res + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def season_airdates(self, imdb_id, season_number): + # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY + if not self.enabled: return {} + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response") != "True": + return res + for ep in j.get("Episodes", []): + try: + num = int(ep.get("Episode")) + except Exception: + continue + rel = ep.get("Released") # '2011-12-04' etc + if rel and rel != "N/A": + res[num] = rel + return res + +# ========================= +# NFO helpers +# ========================= +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: + f.unlink() + count += 1 + except Exception: + pass + return count + +def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str): + # Ensure the season directory exists + try: + season_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + _log("ERROR", f"Failed creating season directory {season_dir}: {e}") + return + + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + xml.append(f" ") + xml.append("\n") + content = "\n".join(xml) + try: + dst.write_text(content, encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(season_dir: Path, season_num: int): + # Small, safe season.nfo; don't overwrite if present + p = season_dir / "season.nfo" + if p.exists(): + return + xml = f""" + + {season_num} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + # Don't clobber; only create minimal file if missing + p = series_dir / "tvshow.nfo" + if p.exists(): + return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".srt", ".ass", ".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: + os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: + pass + +# ========================= +# Core logic +# ========================= +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) + out = {} + + # Prefer Sonarr + if sonarr.enabled: + _log("INFO", f"Querying Sonarr for series: {imdb_id}") + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + _log("INFO", f"Found Sonarr series ID: {series_id}") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + _log("INFO", f"Found {len(eps)} episodes in Sonarr") + for ep in eps: + season_num = ep.get("seasonNumber") + ep_num = ep.get("episodeNumber") + episode_id = ep.get("id") + + if not isinstance(season_num, int) or not isinstance(ep_num, int): + continue + + _log("DEBUG", f"Processing S{season_num:02d}E{ep_num:02d} (episode ID: {episode_id})") + + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None + src = None + + # Try to get original import date from history first + if episode_id: + import_date = sonarr.get_episode_import_history(episode_id) + if import_date: + date_add = to_utc_iso(import_date) + src = "sonarr:history.downloadFolderImported" + _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using original import date {date_add}") + + # Fallback to episode file dateAdded + if not date_add: + ep_file_id = ep.get("episodeFileId") + if ep_file_id: + fobj = sonarr.episode_file(ep_file_id) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")) + src = "sonarr:episodeFile.dateAdded" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No history found, using file dateAdded {date_add}") + + # Final fallback to air date + if not date_add and air_utc: + date_add = air_utc + src = "sonarr:episode.airDateUtc" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No dateAdded found, using air date {date_add}") + + if air_utc or date_add: + out[(season_num, ep_num)] = (air_utc, date_add, src) + else: + _log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}") + + # Fill gaps with TMDB + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + # find all season folders we have + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = tmdb.season_airdates(tv_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: # no dateadded yet + air_iso = to_utc_iso(air) # date → midnight UTC + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # Fill remaining with OMDb + if omdb.enabled: + # infer seasons as above + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = omdb.season_airdates(imdb_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # Absolute last resort: file mtime + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # map existing episode files to ep numbers + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: # skip cross-labeling + continue + key = (sn, en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") + + return out + +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + # Store/refresh series row + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked + if args.manage_nfo: + for season_dir in series_dir.iterdir(): + if season_dir.is_dir() and season_dir.name.lower().startswith("season "): + removed = remove_long_nfos(season_dir) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") + try: + sn = int(season_dir.name.split()[-1]) + ensure_season_nfo(season_dir, sn) + except Exception: + pass + maybe_tvshow_nfo(series_dir, imdb_id) + + # Gather dates + dates = gather_episode_dates(series_dir, imdb_id, args) + + # Write short NFOs and record DB rows; track per-season latest date for mtime + seasons_latest = {} + + for (sn, en), (aired, dateadded, source) in sorted(dates.items()): + # write episode nfo + if args.manage_nfo: + write_episode_nfo(series_dir / f"Season {sn:02d}", sn, en, aired, dateadded, source or "") + + # DB upsert + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + # compute latest per season for mtime + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) + except Exception: + ts = None + if ts: + seasons_latest.setdefault(sn, ts) + if ts > seasons_latest[sn]: + seasons_latest[sn] = ts + + conn.commit() + + # Fix folder mtimes (season dirs) + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = series_dir / f"Season {sn:02d}" + if season_dir.exists(): + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs + if is_series_dir(path): + return [path] + if path.is_dir(): + out = [] + for p in path.iterdir(): + if is_series_dir(p): + out.append(p) + return sorted(out) + return [] + +# ========================= +# CLI +# ========================= +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = db_connect(Path(args.db)) + # wipe content but keep schema + conn.execute("DELETE FROM episode_dates;") + conn.execute("DELETE FROM series;") + conn.commit() + conn.close() + +def cmd_apply(args): + # Enable debug logging if requested + if args.debug: + _log.debug_enabled = True + + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + # Inform about source availability + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TMDB_API_KEY"): + _log("INFO", "TMDB not configured (set TMDB_API_KEY)") + if not os.environ.get("OMDB_API_KEY"): + _log("INFO", "OMDb not configured (set OMDB_API_KEY)") + + for series_dir in targets: + _log("INFO", f"=== APPLY: {series_dir} ===") + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes for a series dir or a library root") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to season latest (use with care)") + p_apply.add_argument("--debug", action="store_true", help="Enable debug logging") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + # (No need to load env here; it was already handled in preparse) + args.func(args) + +if __name__ == "__main__": + main() diff --git a/media_date_cache.claudev11 b/media_date_cache.claudev11 new file mode 100755 index 0000000..6cf40a4 --- /dev/null +++ b/media_date_cache.claudev11 @@ -0,0 +1,784 @@ +#!/opt/scripts/unmanic/.venv/bin/python3 +# -*- coding: utf-8 -*- + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +# ========================= +# tiny .env loader + --env anywhere +# ========================= +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + # Only show DEBUG messages if debug mode is enabled + if level == "DEBUG" and not getattr(_log, 'debug_enabled', False): + return + print(f"[{_iso_now()}] {level}: {msg}") + +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # normalize variants + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1] + i += 2 + continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1] + i += 1 + continue + new_argv.append(a) + i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ========================= +# DB schema / connect +# ========================= +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + + # Check if tables exist and have correct schema + try: + # Test if episode_dates table exists with correct columns + result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() + columns = [row[1] for row in result] # row[1] is column name + required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] + + if not all(col in columns for col in required_columns): + _log("WARNING", "Database schema outdated, recreating tables...") + conn.execute("DROP TABLE IF EXISTS episode_dates;") + conn.execute("DROP TABLE IF EXISTS series;") + conn.execute("DROP TABLE IF EXISTS meta;") + + except sqlite3.OperationalError: + # Tables don't exist, which is fine + pass + + # Create/recreate schema + conn.executescript(SCHEMA) + + # Verify schema was created correctly + try: + conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") + _log("DEBUG", "Database schema verified") + except sqlite3.OperationalError as e: + _log("ERROR", f"Database schema verification failed: {e}") + raise + + return conn + +# ========================= +# HTTP helpers +# ========================= +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception: + return None + +def to_utc_iso(s): + # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' + if not s: + return None + try: + if len(s) == 10 and s[4] == "-" and s[7] == "-": + # date only -> midnight UTC + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + # normalize Z to +00:00 + s2 = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + try: + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + except Exception: + return None + +def file_mtime_iso(p: Path): + try: + return iso_from_epoch(p.stat().st_mtime) + except Exception: + return None + +# ========================= +# Source clients +# ========================= +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL", "").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY", "") + self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") + self.enabled = bool(self.base and self.key) + + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: + qs = urllib.parse.urlencode(params) + q = f"{q}?{qs}" + headers = {"X-Api-Key": self.key} + for attempt in range(self.retries): + try: + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: + return j + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + time.sleep(0.3) + return None + + def series_by_imdb(self, imdb_id): + # Sonarr supports /series/lookup?term=imdbid:tt1234567 + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + # pick exact imdb id match + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id): + """Get the original import date from history, not just the latest""" + # Get ALL history for this episode by fetching multiple pages + all_records = [] + page = 1 + page_size = 100 + + while True: + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "page": page, + "pageSize": page_size + }) or {} + + records = history.get("records", []) + if not records: + break + + all_records.extend(records) + + # Check if we got all records (if we got less than pageSize, we're done) + if len(records) < page_size: + break + + page += 1 + if page > 10: # Safety valve - don't go crazy + break + + _log("DEBUG", f"Got {len(all_records)} total history records for episode {episode_id}") + + # Look for ACTUAL import events (not just renames) + import_events = [] + grabbed_events = [] + + for event in all_records: + event_type = event.get("eventType") + date = event.get("date") + + _log("DEBUG", f"History event: {event_type} at {date}") + + # Primary import event - this is what we want + if event_type == "downloadFolderImported" and date: + import_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Track grab events as secondary indicators + elif event_type == "grabbed" and date: + grabbed_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Use downloadFolderImported events if we have them + if import_events: + earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") + _log("INFO", f"Found original import date: {earliest['date']} (downloadFolderImported) for episode {episode_id}") + return earliest["date"] + + # If no import events but we have grabs, use the earliest grab + some buffer time + # This handles cases where history is incomplete but we know when it was grabbed + if grabbed_events: + earliest_grab = min(grabbed_events, key=lambda x: x["date"] or "9999-12-31") + _log("WARNING", f"No downloadFolderImported found, using grabbed event: {earliest_grab['date']} for episode {episode_id}") + return earliest_grab["date"] + + _log("WARNING", f"No reliable import events found for episode {episode_id}") + return None + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY", "") + self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") + self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = params or {} + params["api_key"] = self.api_key + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + for _ in range(self.retries): + j = http_get_json(url, timeout=self.timeout) + if j is not None: + return j + time.sleep(0.3) + return None + + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: + return tv[0].get("id") + # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? + return None + + def season_airdates(self, tv_id, season_number): + # returns {episode_number: air_date or None} + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num, int) and air: + res[num] = air + return res + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def season_airdates(self, imdb_id, season_number): + # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY + if not self.enabled: return {} + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response") != "True": + return res + for ep in j.get("Episodes", []): + try: + num = int(ep.get("Episode")) + except Exception: + continue + rel = ep.get("Released") # '2011-12-04' etc + if rel and rel != "N/A": + res[num] = rel + return res + +# ========================= +# NFO helpers +# ========================= +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: + f.unlink() + count += 1 + except Exception: + pass + return count + +def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str): + # Ensure the season directory exists + try: + season_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + _log("ERROR", f"Failed creating season directory {season_dir}: {e}") + return + + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + xml.append(f" ") + xml.append("\n") + content = "\n".join(xml) + try: + dst.write_text(content, encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(season_dir: Path, season_num: int): + # Small, safe season.nfo; don't overwrite if present + p = season_dir / "season.nfo" + if p.exists(): + return + xml = f""" + + {season_num} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + # Don't clobber; only create minimal file if missing + p = series_dir / "tvshow.nfo" + if p.exists(): + return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".srt", ".ass", ".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: + os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: + pass + +# ========================= +# Core logic +# ========================= +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) + out = {} + + # If using cache, first load existing data from DB + if args.use_cache: + _log("INFO", f"Checking cache for existing episode data: {imdb_id}") + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() + + for season, episode, aired, dateadded, source in cached: + out[(season, episode)] = (aired, dateadded, source) + + _log("INFO", f"Found {len(cached)} cached episodes for {imdb_id}") + + # Find which episodes we have on disk but not in cache + disk_episodes = set() + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # Find episode files + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if m: + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 == sn: + disk_episodes.add((sn, en2)) + + cached_episodes = set(out.keys()) + missing_episodes = disk_episodes - cached_episodes + + if missing_episodes: + _log("INFO", f"Found {len(missing_episodes)} episodes on disk not in cache: {sorted(missing_episodes)}") + else: + _log("INFO", f"All disk episodes are cached, skipping API queries") + return out + + # Only query APIs if not using cache or if we have missing episodes + need_api_query = not args.use_cache or (args.use_cache and missing_episodes) + + if need_api_query: + # Prefer Sonarr + if sonarr.enabled: + _log("INFO", f"Querying Sonarr for series: {imdb_id}") + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + _log("INFO", f"Found Sonarr series ID: {series_id}") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + _log("INFO", f"Found {len(eps)} episodes in Sonarr") + for ep in eps: + season_num = ep.get("seasonNumber") + ep_num = ep.get("episodeNumber") + episode_id = ep.get("id") + + if not isinstance(season_num, int) or not isinstance(ep_num, int): + continue + + # If using cache, skip episodes we already have unless forced + key = (season_num, ep_num) + if args.use_cache and key in out and not args.force_refresh: + continue + + _log("DEBUG", f"Processing S{season_num:02d}E{ep_num:02d} (episode ID: {episode_id})") + + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None + src = None + + # Try to get original import date from history first + if episode_id: + import_date = sonarr.get_episode_import_history(episode_id) + if import_date: + date_add = to_utc_iso(import_date) + src = "sonarr:history.downloadFolderImported" + _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using original import date {date_add}") + + # Fallback to episode file dateAdded + if not date_add: + ep_file_id = ep.get("episodeFileId") + if ep_file_id: + fobj = sonarr.episode_file(ep_file_id) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")) + src = "sonarr:episodeFile.dateAdded" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No history found, using file dateAdded {date_add}") + + # Final fallback to air date + if not date_add and air_utc: + date_add = air_utc + src = "sonarr:episode.airDateUtc" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No dateAdded found, using air date {date_add}") + + if air_utc or date_add: + out[key] = (air_utc, date_add, src) + else: + _log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}") + + # Fill gaps with TMDB (only for missing episodes if using cache) + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + # find all season folders we have + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = tmdb.season_airdates(tv_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: # no dateadded yet + air_iso = to_utc_iso(air) # date → midnight UTC + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # Fill remaining with OMDb (only for missing episodes if using cache) + if omdb.enabled: + # infer seasons as above + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = omdb.season_airdates(imdb_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # Absolute last resort: file mtime (always check for missing episodes) + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # map existing episode files to ep numbers + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: # skip cross-labeling + continue + key = (sn, en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") + + return out + +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + # Store/refresh series row + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked + if args.manage_nfo: + for season_dir in series_dir.iterdir(): + if season_dir.is_dir() and season_dir.name.lower().startswith("season "): + removed = remove_long_nfos(season_dir) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") + try: + sn = int(season_dir.name.split()[-1]) + ensure_season_nfo(season_dir, sn) + except Exception: + pass + maybe_tvshow_nfo(series_dir, imdb_id) + + # Gather dates + dates = gather_episode_dates(series_dir, imdb_id, args, conn) + + # Write short NFOs and record DB rows; track per-season latest date for mtime + seasons_latest = {} + + for (sn, en), (aired, dateadded, source) in sorted(dates.items()): + # write episode nfo + if args.manage_nfo: + write_episode_nfo(series_dir / f"Season {sn:02d}", sn, en, aired, dateadded, source or "") + + # DB upsert + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + # compute latest per season for mtime + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) + except Exception: + ts = None + if ts: + seasons_latest.setdefault(sn, ts) + if ts > seasons_latest[sn]: + seasons_latest[sn] = ts + + conn.commit() + + # Fix folder mtimes (season dirs) + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = series_dir / f"Season {sn:02d}" + if season_dir.exists(): + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs + if is_series_dir(path): + return [path] + if path.is_dir(): + out = [] + for p in path.iterdir(): + if is_series_dir(p): + out.append(p) + return sorted(out) + return [] + +# ========================= +# CLI +# ========================= +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = db_connect(Path(args.db)) + # wipe content but keep schema + conn.execute("DELETE FROM episode_dates;") + conn.execute("DELETE FROM series;") + conn.commit() + conn.close() + +def cmd_apply(args): + # Enable debug logging if requested + if args.debug: + _log.debug_enabled = True + + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + # Inform about source availability + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TMDB_API_KEY"): + _log("INFO", "TMDB not configured (set TMDB_API_KEY)") + if not os.environ.get("OMDB_API_KEY"): + _log("INFO", "OMDb not configured (set OMDB_API_KEY)") + + for series_dir in targets: + _log("INFO", f"=== APPLY: {series_dir} ===") + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes for a series dir or a library root") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to season latest (use with care)") + p_apply.add_argument("--use-cache", action="store_true", help="Use cached episode data when available, only query APIs for missing episodes") + p_apply.add_argument("--force-refresh", action="store_true", help="Force refresh of cached data (use with --use-cache)") + p_apply.add_argument("--debug", action="store_true", help="Enable debug logging") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + # (No need to load env here; it was already handled in preparse) + args.func(args) + +if __name__ == "__main__": + main() diff --git a/media_date_cache.claudev6 b/media_date_cache.claudev6 new file mode 100755 index 0000000..44c7941 --- /dev/null +++ b/media_date_cache.claudev6 @@ -0,0 +1,694 @@ +#!/opt/scripts/unmanic/.venv/bin/python3 +# -*- coding: utf-8 -*- + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +# ========================= +# tiny .env loader + --env anywhere +# ========================= +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + # Only show DEBUG messages if debug mode is enabled + if level == "DEBUG" and not getattr(_log, 'debug_enabled', False): + return + print(f"[{_iso_now()}] {level}: {msg}") + +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # normalize variants + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1] + i += 2 + continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1] + i += 1 + continue + new_argv.append(a) + i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ========================= +# DB schema / connect +# ========================= +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + + # Check if tables exist and have correct schema + try: + # Test if episode_dates table exists with correct columns + result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() + columns = [row[1] for row in result] # row[1] is column name + required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] + + if not all(col in columns for col in required_columns): + _log("WARNING", "Database schema outdated, recreating tables...") + conn.execute("DROP TABLE IF EXISTS episode_dates;") + conn.execute("DROP TABLE IF EXISTS series;") + conn.execute("DROP TABLE IF EXISTS meta;") + + except sqlite3.OperationalError: + # Tables don't exist, which is fine + pass + + # Create/recreate schema + conn.executescript(SCHEMA) + + # Verify schema was created correctly + try: + conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") + _log("DEBUG", "Database schema verified") + except sqlite3.OperationalError as e: + _log("ERROR", f"Database schema verification failed: {e}") + raise + + return conn + +# ========================= +# HTTP helpers +# ========================= +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception: + return None + +def to_utc_iso(s): + # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' + if not s: + return None + try: + if len(s) == 10 and s[4] == "-" and s[7] == "-": + # date only -> midnight UTC + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + # normalize Z to +00:00 + s2 = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + try: + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + except Exception: + return None + +def file_mtime_iso(p: Path): + try: + return iso_from_epoch(p.stat().st_mtime) + except Exception: + return None + +# ========================= +# Source clients +# ========================= +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL", "").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY", "") + self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") + self.enabled = bool(self.base and self.key) + + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: + qs = urllib.parse.urlencode(params) + q = f"{q}?{qs}" + headers = {"X-Api-Key": self.key} + for attempt in range(self.retries): + try: + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: + return j + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + time.sleep(0.3) + return None + + def series_by_imdb(self, imdb_id): + # Sonarr supports /series/lookup?term=imdbid:tt1234567 + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + # pick exact imdb id match + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id): + """Get the original import date from history, not just the latest""" + # Get ALL history for this episode, sorted by date ascending (oldest first) + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "pageSize": 1000 # Get a lot of records to ensure we don't miss early ones + }) or {} + + _log("DEBUG", f"Got {len(history.get('records', []))} history records for episode {episode_id}") + + # Look for the earliest "downloadFolderImported" event + import_events = [] + for event in history.get("records", []): + event_type = event.get("eventType") + date = event.get("date") + + _log("DEBUG", f"History event: {event_type} at {date}") + + if event_type == "downloadFolderImported" and date: + import_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + if import_events: + # Return the earliest import date + earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") + _log("INFO", f"Found original import date: {earliest['date']} for episode {episode_id}") + return earliest["date"] + + _log("WARNING", f"No downloadFolderImported events found for episode {episode_id}") + return None + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY", "") + self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") + self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = params or {} + params["api_key"] = self.api_key + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + for _ in range(self.retries): + j = http_get_json(url, timeout=self.timeout) + if j is not None: + return j + time.sleep(0.3) + return None + + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: + return tv[0].get("id") + # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? + return None + + def season_airdates(self, tv_id, season_number): + # returns {episode_number: air_date or None} + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num, int) and air: + res[num] = air + return res + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def season_airdates(self, imdb_id, season_number): + # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY + if not self.enabled: return {} + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response") != "True": + return res + for ep in j.get("Episodes", []): + try: + num = int(ep.get("Episode")) + except Exception: + continue + rel = ep.get("Released") # '2011-12-04' etc + if rel and rel != "N/A": + res[num] = rel + return res + +# ========================= +# NFO helpers +# ========================= +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: + f.unlink() + count += 1 + except Exception: + pass + return count + +def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str): + # Ensure the season directory exists + try: + season_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + _log("ERROR", f"Failed creating season directory {season_dir}: {e}") + return + + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + xml.append(f" ") + xml.append("\n") + content = "\n".join(xml) + try: + dst.write_text(content, encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(season_dir: Path, season_num: int): + # Small, safe season.nfo; don't overwrite if present + p = season_dir / "season.nfo" + if p.exists(): + return + xml = f""" + + {season_num} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + # Don't clobber; only create minimal file if missing + p = series_dir / "tvshow.nfo" + if p.exists(): + return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".srt", ".ass", ".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: + os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: + pass + +# ========================= +# Core logic +# ========================= +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) + out = {} + + # Prefer Sonarr + if sonarr.enabled: + _log("INFO", f"Querying Sonarr for series: {imdb_id}") + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + _log("INFO", f"Found Sonarr series ID: {series_id}") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + _log("INFO", f"Found {len(eps)} episodes in Sonarr") + for ep in eps: + season_num = ep.get("seasonNumber") + ep_num = ep.get("episodeNumber") + episode_id = ep.get("id") + + if not isinstance(season_num, int) or not isinstance(ep_num, int): + continue + + _log("DEBUG", f"Processing S{season_num:02d}E{ep_num:02d} (episode ID: {episode_id})") + + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None + src = None + + # Try to get original import date from history first + if episode_id: + import_date = sonarr.get_episode_import_history(episode_id) + if import_date: + date_add = to_utc_iso(import_date) + src = "sonarr:history.downloadFolderImported" + _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using original import date {date_add}") + + # Fallback to episode file dateAdded + if not date_add: + ep_file_id = ep.get("episodeFileId") + if ep_file_id: + fobj = sonarr.episode_file(ep_file_id) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")) + src = "sonarr:episodeFile.dateAdded" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No history found, using file dateAdded {date_add}") + + # Final fallback to air date + if not date_add and air_utc: + date_add = air_utc + src = "sonarr:episode.airDateUtc" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No dateAdded found, using air date {date_add}") + + if air_utc or date_add: + out[(season_num, ep_num)] = (air_utc, date_add, src) + else: + _log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}") + + # Fill gaps with TMDB + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + # find all season folders we have + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = tmdb.season_airdates(tv_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: # no dateadded yet + air_iso = to_utc_iso(air) # date → midnight UTC + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # Fill remaining with OMDb + if omdb.enabled: + # infer seasons as above + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = omdb.season_airdates(imdb_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # Absolute last resort: file mtime + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # map existing episode files to ep numbers + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: # skip cross-labeling + continue + key = (sn, en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") + + return out + +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + # Store/refresh series row + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked + if args.manage_nfo: + for season_dir in series_dir.iterdir(): + if season_dir.is_dir() and season_dir.name.lower().startswith("season "): + removed = remove_long_nfos(season_dir) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") + try: + sn = int(season_dir.name.split()[-1]) + ensure_season_nfo(season_dir, sn) + except Exception: + pass + maybe_tvshow_nfo(series_dir, imdb_id) + + # Gather dates + dates = gather_episode_dates(series_dir, imdb_id, args) + + # Write short NFOs and record DB rows; track per-season latest date for mtime + seasons_latest = {} + + for (sn, en), (aired, dateadded, source) in sorted(dates.items()): + # write episode nfo + if args.manage_nfo: + write_episode_nfo(series_dir / f"Season {sn:02d}", sn, en, aired, dateadded, source or "") + + # DB upsert + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + # compute latest per season for mtime + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) + except Exception: + ts = None + if ts: + seasons_latest.setdefault(sn, ts) + if ts > seasons_latest[sn]: + seasons_latest[sn] = ts + + conn.commit() + + # Fix folder mtimes (season dirs) + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = series_dir / f"Season {sn:02d}" + if season_dir.exists(): + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs + if is_series_dir(path): + return [path] + if path.is_dir(): + out = [] + for p in path.iterdir(): + if is_series_dir(p): + out.append(p) + return sorted(out) + return [] + +# ========================= +# CLI +# ========================= +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = db_connect(Path(args.db)) + # wipe content but keep schema + conn.execute("DELETE FROM episode_dates;") + conn.execute("DELETE FROM series;") + conn.commit() + conn.close() + +def cmd_apply(args): + # Enable debug logging if requested + if args.debug: + _log.debug_enabled = True + + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + # Inform about source availability + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TMDB_API_KEY"): + _log("INFO", "TMDB not configured (set TMDB_API_KEY)") + if not os.environ.get("OMDB_API_KEY"): + _log("INFO", "OMDb not configured (set OMDB_API_KEY)") + + for series_dir in targets: + _log("INFO", f"=== APPLY: {series_dir} ===") + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes for a series dir or a library root") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to season latest (use with care)") + p_apply.add_argument("--debug", action="store_true", help="Enable debug logging") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + # (No need to load env here; it was already handled in preparse) + args.func(args) + +if __name__ == "__main__": + main() diff --git a/media_date_cache.py b/media_date_cache.py old mode 100644 new mode 100755 index de02e7c..281e12d --- a/media_date_cache.py +++ b/media_date_cache.py @@ -1,1124 +1,993 @@ -#!/usr/bin/env python3 +#!/opt/scripts/unmanic/.venv/bin/python3 # -*- coding: utf-8 -*- -""" -Apply canonical "date" timestamps (mtime) to media files and cache results. - -Priority (TV): - 1) Sonarr history/import date (first successful import/release) - 2) Sonarr episode 'airDate' (UTC midnight) - 3) TMDB episode 'air_date' - 4) (optional, with --live-on-miss) keep existing mtime - -Priority (Movies): - 1) Radarr history/import date (first successful import/release) - 2) Radarr movie 'digitalRelease' or 'physicalRelease' - 3) TMDB movie 'release_date' - 4) (optional, with --live-on-miss) keep existing mtime - -DB schema (SQLite): - CREATE TABLE IF NOT EXISTS media_dates( - path TEXT PRIMARY KEY, - kind TEXT NOT NULL, - chosen_ts INTEGER NOT NULL, - chosen_src TEXT NOT NULL, - extra TEXT, - last_updated INTEGER DEFAULT (strftime('%s', 'now')) - ); - -Usage (examples): - python media_date_cache.py apply \ - --env /opt/scripts/unmanic/.env \ - --kind tv \ - --db /opt/scripts/unmanic/.cache/media_dates.db \ - --root "/mnt/unionfs/Media/TV" \ - --refresh --live-on-miss - - python media_date_cache.py apply \ - --env /opt/scripts/unmanic/.env \ - --kind movie \ - --db /opt/scripts/unmanic/.cache/media_dates.db \ - --root "/mnt/unionfs/Media/Movies" \ - --refresh --live-on-miss -""" - -from __future__ import annotations - -import argparse -import dataclasses -import datetime as dt -import json -import os -import re -import sqlite3 -import sys -import time +import os, sys, re, json, sqlite3, argparse, time from pathlib import Path -from typing import Dict, Iterable, List, Optional, Tuple - -import requests +from datetime import datetime, timezone +import urllib.request, urllib.parse # ========================= -# Defaults / ENV variables +# tiny .env loader + --env anywhere # ========================= +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") -DEFAULT_SONARR_URL = os.getenv("SONARR_URL", "http://sonarr:8989") -DEFAULT_SONARR_API_KEY = os.getenv("SONARR_API_KEY", "") -DEFAULT_RADARR_URL = os.getenv("RADARR_URL", "http://radarr:7878") -DEFAULT_RADARR_API_KEY = os.getenv("RADARR_API_KEY", "") -DEFAULT_TMDB_API_KEY = os.getenv("TMDB_API_KEY", "") -DEFAULT_TMDB_PRIMARY_COUNTRY = os.getenv("TMDB_PRIMARY_COUNTRY", "US") -DEFAULT_GOTIFY_URL = os.getenv("GOTIFY_URL", "") -DEFAULT_GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN", "") - -# Multi-root support from environment -TV_ROOTS = os.getenv("TV_ROOTS", "").split(":") if os.getenv("TV_ROOTS") else [] -MOVIE_ROOTS = os.getenv("MOVIE_ROOTS", "").split(":") if os.getenv("MOVIE_ROOTS") else [] - -SONARR_TIMEOUT = int(os.getenv("SONARR_TIMEOUT", "45")) -SONARR_RETRIES = int(os.getenv("SONARR_RETRIES", "3")) -RADARR_TIMEOUT = int(os.getenv("RADARR_TIMEOUT", "45")) -RADARR_RETRIES = int(os.getenv("RADARR_RETRIES", "3")) -TMDB_TIMEOUT = int(os.getenv("TMDB_TIMEOUT", "45")) -TMDB_RETRIES = int(os.getenv("TMDB_RETRIES", "3")) - -LOG_TS_FMT = "%Y-%m-%d %H:%M:%S" - -VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".flv", ".webm"} -SIDECAR_EXTS = {".srt", ".sub", ".idx", ".ass", ".ssa", ".vtt", ".smi", ".rt", ".txt"} -NFO_SIMPLE_PATTERN = re.compile(r"^S\d{2}E\d{2}\.nfo$", re.IGNORECASE) - -# File parsing patterns -SE_EP_RX = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})") -IMDB_RX = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) -TMDB_RX = re.compile(r"\[tmdb-(\d+)\]", re.IGNORECASE) -YEAR_RX = re.compile(r"\((\d{4})\)") - - -def log(msg: str) -> None: - ts = dt.datetime.now().strftime(LOG_TS_FMT) - print(f"[{ts}] {msg}", flush=True) - - -def send_gotify_notification(title: str, message: str, priority: int = 5) -> None: - """Send notification to Gotify server if configured.""" - if not DEFAULT_GOTIFY_URL or not DEFAULT_GOTIFY_TOKEN: +def _log(level, msg): + # Only show DEBUG messages if debug mode is enabled + if level == "DEBUG" and not getattr(_log, 'debug_enabled', False): return - - try: - requests.post( - f"{DEFAULT_GOTIFY_URL}/message", - data={ - "title": title, - "message": message, - "priority": priority - }, - headers={"X-Gotify-Key": DEFAULT_GOTIFY_TOKEN}, - timeout=10 - ) - except Exception as e: - log(f"WARN: Gotify notification failed: {e}") + print(f"[{_iso_now()}] {level}: {msg}") - -def load_env_file(path: Optional[str]) -> None: - """Load a simple KEY=VALUE .env file.""" - if not path: - return +def _load_env_file(path: str) -> bool: p = Path(path) - if not p.is_file(): - log(f"WARN: .env file not found: {path}") - return - - log(f"Loading environment from: {path}") - for line in p.read_text().splitlines(): - line = line.strip() + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) k = k.strip() - v = v.strip() - # Strip optional surrounding quotes - if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')): - v = v[1:-1] - os.environ.setdefault(k, v) + v = v.strip().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # normalize variants + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1] + i += 2 + continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1] + i += 1 + continue + new_argv.append(a) + i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv -# ================= -# SQLite helpers -# ================= +sys.argv = _preparse_env(sys.argv) -def db_init(cx: sqlite3.Connection) -> None: - cx.execute( - """ - CREATE TABLE IF NOT EXISTS media_dates( - path TEXT PRIMARY KEY, - kind TEXT NOT NULL, - chosen_ts INTEGER NOT NULL, - chosen_src TEXT NOT NULL, - extra TEXT, - last_updated INTEGER DEFAULT (strftime('%s', 'now')) - ) - """ - ) - cx.commit() +# ========================= +# DB schema / connect +# ========================= +SCHEMA = """ +PRAGMA journal_mode=WAL; +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); -def db_get_cached(cx: sqlite3.Connection, path: str) -> Optional[Tuple[int, str]]: - """Get cached timestamp and source for a path.""" - cur = cx.execute("SELECT chosen_ts, chosen_src FROM media_dates WHERE path = ?", (path,)) - row = cur.fetchone() - if row: - return row[0], row[1] - return None +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + PRIMARY KEY (imdb_id, season, episode) +); +""" -def db_upsert( - cx: sqlite3.Connection, - path: str, - kind: str, - chosen_ts: int, - chosen_src: str, - extra: Dict[str, object] | None, -) -> None: - cx.execute( - """ - INSERT INTO media_dates(path, kind, chosen_ts, chosen_src, extra, last_updated) - VALUES(?,?,?,?,?,?) - ON CONFLICT(path) DO UPDATE SET - kind=excluded.kind, - chosen_ts=excluded.chosen_ts, - chosen_src=excluded.chosen_src, - extra=excluded.extra, - last_updated=excluded.last_updated - """, - (path, kind, int(chosen_ts), chosen_src, json.dumps(extra or {}, ensure_ascii=False), int(time.time())), - ) - cx.commit() - - -# ================= -# HTTP helpers -# ================= - -def _req_json( - method: str, - url: str, - *, - headers: Optional[Dict[str, str]] = None, - params: Optional[Dict[str, object]] = None, - timeout: int = 30, - retries: int = 3, - backoff: float = 2.0, -) -> dict: - """Request JSON with retries/backoff; raise on final failure.""" - last_err = None - for attempt in range(1, retries + 1): - try: - r = requests.request(method, url, headers=headers or {}, params=params or {}, timeout=timeout) - r.raise_for_status() - return r.json() - except (requests.exceptions.Timeout, requests.exceptions.ReadTimeout) as e: - last_err = e - except requests.exceptions.RequestException as e: - last_err = e - if attempt < retries: - time.sleep(backoff ** attempt) - raise last_err # type: ignore[misc] - - -# ================= -# Sonarr client -# ================= - -@dataclasses.dataclass -class SonarrClient: - url: str - api_key: str - timeout: int = SONARR_TIMEOUT - retries: int = SONARR_RETRIES - - def _h(self) -> Dict[str, str]: - return {"X-Api-Key": self.api_key} - - def ping(self) -> bool: - try: - _ = _req_json( - "GET", f"{self.url}/api/v3/system/status", - headers=self._h(), timeout=self.timeout, retries=self.retries - ) - return True - except Exception as e: - log(f"WARN: Sonarr ping failed: {e}") - return False - - def series_index(self) -> List[dict]: - return _req_json( - "GET", f"{self.url}/api/v3/series", - headers=self._h(), timeout=self.timeout, retries=self.retries - ) - - def episodes_for(self, series_id: int) -> List[dict]: - return _req_json( - "GET", f"{self.url}/api/v3/episode", - headers=self._h(), params={"seriesId": series_id}, - timeout=self.timeout, retries=self.retries - ) - - def history_for(self, series_id: int) -> List[dict]: - res = _req_json( - "GET", f"{self.url}/api/v3/history", - headers=self._h(), params={ - "page": 1, - "pageSize": 1000, - "sortKey": "date", - "sortDirection": "ascending", - "seriesId": series_id, - "eventType": 3 # Downloaded/Imported events only - }, - timeout=self.timeout, retries=self.retries - ) - return res.get("records", []) - - -# ================= -# Radarr client -# ================= - -@dataclasses.dataclass -class RadarrClient: - url: str - api_key: str - timeout: int = RADARR_TIMEOUT - retries: int = RADARR_RETRIES - - def _h(self) -> Dict[str, str]: - return {"X-Api-Key": self.api_key} - - def ping(self) -> bool: - try: - _ = _req_json( - "GET", f"{self.url}/api/v3/system/status", - headers=self._h(), timeout=self.timeout, retries=self.retries - ) - return True - except Exception as e: - log(f"WARN: Radarr ping failed: {e}") - return False - - def movies_index(self) -> List[dict]: - return _req_json( - "GET", f"{self.url}/api/v3/movie", - headers=self._h(), timeout=self.timeout, retries=self.retries - ) - - def history_for(self, movie_id: int) -> List[dict]: - res = _req_json( - "GET", f"{self.url}/api/v3/history", - headers=self._h(), params={ - "page": 1, - "pageSize": 1000, - "sortKey": "date", - "sortDirection": "ascending", - "movieId": movie_id, - "eventType": 3 # Downloaded/Imported events only - }, - timeout=self.timeout, retries=self.retries - ) - return res.get("records", []) - - -# ================= -# TMDB client -# ================= - -@dataclasses.dataclass -class TMDBClient: - api_key: str - country: str = DEFAULT_TMDB_PRIMARY_COUNTRY - base_url: str = "https://api.themoviedb.org/3" - timeout: int = TMDB_TIMEOUT - retries: int = TMDB_RETRIES - - def _p(self, **kw) -> Dict[str, object]: - p = {"api_key": self.api_key} - p.update(kw) - return p - - def find_tv_by_imdb(self, imdb_id: str) -> Optional[int]: - js = _req_json( - "GET", f"{self.base_url}/find/{imdb_id}", - params=self._p(external_source="imdb_id"), - timeout=self.timeout, retries=self.retries - ) - tv = js.get("tv_results") or [] - if tv: - return tv[0].get("id") - return None - - def find_movie_by_imdb(self, imdb_id: str) -> Optional[int]: - js = _req_json( - "GET", f"{self.base_url}/find/{imdb_id}", - params=self._p(external_source="imdb_id"), - timeout=self.timeout, retries=self.retries - ) - movies = js.get("movie_results") or [] - if movies: - return movies[0].get("id") - return None - - def search_tv(self, name: str, first_air_date_year: Optional[int]) -> Optional[int]: - js = _req_json( - "GET", f"{self.base_url}/search/tv", - params=self._p(query=name, first_air_date_year=first_air_date_year or ""), - timeout=self.timeout, retries=self.retries - ) - res = js.get("results") or [] - if res: - return res[0].get("id") - return None - - def search_movie(self, name: str, year: Optional[int]) -> Optional[int]: - js = _req_json( - "GET", f"{self.base_url}/search/movie", - params=self._p(query=name, year=year or "", primary_release_year=year or ""), - timeout=self.timeout, retries=self.retries - ) - res = js.get("results") or [] - if res: - return res[0].get("id") - return None - - def episode_air_date(self, tv_id: int, season: int, episode: int) -> Optional[str]: - try: - js = _req_json( - "GET", f"{self.base_url}/tv/{tv_id}/season/{season}/episode/{episode}", - params=self._p(), - timeout=self.timeout, retries=self.retries - ) - return js.get("air_date") - except Exception: - return None - - def movie_release_date(self, movie_id: int) -> Optional[str]: - try: - js = _req_json( - "GET", f"{self.base_url}/movie/{movie_id}", - params=self._p(), - timeout=self.timeout, retries=self.retries - ) - return js.get("release_date") - except Exception: - return None - - -# ================= -# File parsing -# ================= - -@dataclasses.dataclass -class EpisodeKey: - series_title: str - year: Optional[int] - imdb_id: Optional[str] - tmdb_id: Optional[str] - season: int - episode: int - - -@dataclasses.dataclass -class MovieKey: - title: str - year: Optional[int] - imdb_id: Optional[str] - tmdb_id: Optional[str] - - -def parse_episode_from_path(path: Path) -> Optional[EpisodeKey]: - """Parse episode info from file path.""" - m = SE_EP_RX.search(path.name) - if not m: - return None - season = int(m.group(1)) - episode = int(m.group(2)) - - series_dir = path.parent - if series_dir.name.lower().startswith("season "): - series_dir = series_dir.parent - - series_title = series_dir.name - imdb_id = None - tmdb_id = None - year = None - - m2 = IMDB_RX.search(series_title) - if m2: - imdb_id = m2.group(1) - - m3 = TMDB_RX.search(series_title) - if m3: - tmdb_id = m3.group(1) - - m4 = YEAR_RX.search(series_title) - if m4: - try: - year = int(m4.group(1)) - except ValueError: - year = None - - clean_title = IMDB_RX.sub("", series_title) - clean_title = TMDB_RX.sub("", clean_title) - clean_title = YEAR_RX.sub("", clean_title) - clean_title = clean_title.strip() - if not clean_title: - clean_title = series_title - - return EpisodeKey(clean_title, year, imdb_id, tmdb_id, season, episode) - - -def parse_movie_from_path(path: Path) -> Optional[MovieKey]: - """Parse movie info from file path.""" - movie_dir = path.parent - movie_title = movie_dir.name +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") - imdb_id = None - tmdb_id = None - year = None - - m1 = IMDB_RX.search(movie_title) - if m1: - imdb_id = m1.group(1) - - m2 = TMDB_RX.search(movie_title) - if m2: - tmdb_id = m2.group(1) - - m3 = YEAR_RX.search(movie_title) - if m3: - try: - year = int(m3.group(1)) - except ValueError: - year = None - - clean_title = IMDB_RX.sub("", movie_title) - clean_title = TMDB_RX.sub("", clean_title) - clean_title = YEAR_RX.sub("", clean_title) - clean_title = clean_title.strip() - if not clean_title: - clean_title = movie_title - - return MovieKey(clean_title, year, imdb_id, tmdb_id) - - -# ================= -# Date choosing -# ================= - -def parse_date_yyyy_mm_dd(s: str) -> Optional[int]: + # Check if tables exist and have correct schema try: - y, m, d = map(int, s.split("-")) - return int(dt.datetime(y, m, d, 0, 0, 0, tzinfo=dt.timezone.utc).timestamp()) + # Test if episode_dates table exists with correct columns + result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() + columns = [row[1] for row in result] # row[1] is column name + required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] + + if not all(col in columns for col in required_columns): + _log("WARNING", "Database schema outdated, recreating tables...") + conn.execute("DROP TABLE IF EXISTS episode_dates;") + conn.execute("DROP TABLE IF EXISTS series;") + conn.execute("DROP TABLE IF EXISTS meta;") + + except sqlite3.OperationalError: + # Tables don't exist, which is fine + pass + + # Create/recreate schema + conn.executescript(SCHEMA) + + # Verify schema was created correctly + try: + conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") + _log("DEBUG", "Database schema verified") + except sqlite3.OperationalError as e: + _log("ERROR", f"Database schema verification failed: {e}") + raise + + return conn + +# ========================= +# HTTP helpers +# ========================= +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception as e: + _log("WARNING", f"Failed to parse JSON from {url}: {e}") + return None + except urllib.error.HTTPError as e: + _log("ERROR", f"HTTP {e.code} error for {url}: {e.reason}") + if e.code == 401: + _log("ERROR", "Authentication failed - check your API keys") + elif e.code == 429: + _log("WARNING", "Rate limited - waiting before retry") + time.sleep(2) + raise # Re-raise so caller can handle + except urllib.error.URLError as e: + _log("ERROR", f"Network error for {url}: {e}") + return None + except Exception as e: + _log("ERROR", f"Unexpected error for {url}: {e}") + return None + +def to_utc_iso(s): + # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' + if not s: + return None + try: + if len(s) == 10 and s[4] == "-" and s[7] == "-": + # date only -> midnight UTC + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + # normalize Z to +00:00 + s2 = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") except Exception: return None - -def pick_from_sonarr_history(sc: SonarrClient, series_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]: - """Return (unix_ts, src, extra) or None.""" +def iso_from_epoch(epoch): try: - hist = sc.history_for(series_id) - except Exception as e: - log(f"WARN: Sonarr history timeout/err series={series_id}: {e}") + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + except Exception: return None - best_ts = None - best = None - for rec in hist: - event_type = rec.get("eventType") - if event_type not in [1, 3]: # 1=Grabbed, 3=Downloaded - continue +def file_mtime_iso(p: Path): + try: + return iso_from_epoch(p.stat().st_mtime) + except Exception: + return None + +# ========================= +# Source clients +# ========================= +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL", "").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY", "") + self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") + self.enabled = bool(self.base and self.key) + + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: + qs = urllib.parse.urlencode(params) + q = f"{q}?{qs}" + headers = {"X-Api-Key": self.key} + + for attempt in range(self.retries): + try: + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"Sonarr authentication failed - check SONARR_API_KEY") + return None # Don't retry auth failures + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") - date_s = rec.get("date") - if not date_s: + if attempt < self.retries - 1: + time.sleep(0.5 * (attempt + 1)) # Progressive backoff + + _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {q}") + return None + + def series_by_imdb(self, imdb_id): + # Sonarr supports /series/lookup?term=imdbid:tt1234567 + j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"}) + if not j: return None + # pick exact imdb id match + for s in j: + if (s.get("imdbId") or "").lower() == imdb_id.lower(): + return s + return None + + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id): + """Get the original import date from history, not just the latest""" + # Get ALL history for this episode by fetching multiple pages + all_records = [] + page = 1 + page_size = 100 + + while True: + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "page": page, + "pageSize": page_size + }) or {} + + records = history.get("records", []) + if not records: + break + + all_records.extend(records) + + # Check if we got all records (if we got less than pageSize, we're done) + if len(records) < page_size: + break + + page += 1 + if page > 10: # Safety valve - don't go crazy + break + + _log("DEBUG", f"Got {len(all_records)} total history records for episode {episode_id}") + + # Look for ACTUAL import events (not just renames) + import_events = [] + grabbed_events = [] + + for event in all_records: + event_type = event.get("eventType") + date = event.get("date") + + _log("DEBUG", f"History event: {event_type} at {date}") + + # Primary import event - this is what we want + if event_type == "downloadFolderImported" and date: + import_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Track grab events as secondary indicators + elif event_type == "grabbed" and date: + grabbed_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Use downloadFolderImported events if we have them + if import_events: + earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") + _log("INFO", f"Found original import date: {earliest['date']} (downloadFolderImported) for episode {episode_id}") + return earliest["date"] + + # If no import events but we have grabs, use the earliest grab + some buffer time + # This handles cases where history is incomplete but we know when it was grabbed + if grabbed_events: + earliest_grab = min(grabbed_events, key=lambda x: x["date"] or "9999-12-31") + _log("WARNING", f"No downloadFolderImported found, using grabbed event: {earliest_grab['date']} for episode {episode_id}") + return earliest_grab["date"] + + _log("WARNING", f"No reliable import events found for episode {episode_id}") + return None + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY", "") + self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") + self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = params or {} + params["api_key"] = self.api_key + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + + for attempt in range(self.retries): + try: + j = http_get_json(url, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"TMDB authentication failed - check TMDB_API_KEY") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 3 + _log("WARNING", f"TMDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"TMDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") + except Exception as e: + _log("WARNING", f"TMDB API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(1.0 * (attempt + 1)) + + return None + + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: + return tv[0].get("id") + # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? + return None + + def season_airdates(self, tv_id, season_number): + # returns {episode_number: air_date or None} + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num, int) and air: + res[num] = air + return res + +class TVDBClient: + def __init__(self): + self.api_key = os.environ.get("TVDB_API_KEY", "") + self.timeout = int(os.environ.get("TVDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TVDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + self.token = None + self.token_expires = 0 + + def _get_token(self): + """Get JWT token for TVDB API""" + if self.token and time.time() < self.token_expires: + return self.token + + url = "https://api4.thetvdb.com/v4/login" + headers = {"Content-Type": "application/json"} + data = json.dumps({"apikey": self.api_key}).encode('utf-8') + + try: + req = urllib.request.Request(url, data=data, headers=headers, method='POST') + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + result = json.loads(resp.read().decode('utf-8')) + self.token = result["data"]["token"] + # Token expires in 24 hours, refresh after 23 hours + self.token_expires = time.time() + (23 * 60 * 60) + return self.token + except Exception as e: + _log("ERROR", f"TVDB authentication failed: {e}") + return None + + def _get(self, path, params=None): + if not self.enabled: + return None + + token = self._get_token() + if not token: + return None + + url = f"https://api4.thetvdb.com/v4{path}" + if params: + url += "?" + urllib.parse.urlencode(params) + + headers = {"Authorization": f"Bearer {token}"} + + for attempt in range(self.retries): + try: + j = http_get_json(url, headers=headers, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + # Token might be expired, clear it and retry once + if attempt == 0: + self.token = None + self.token_expires = 0 + continue + _log("ERROR", f"TVDB authentication failed - check TVDB_API_KEY") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"TVDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"TVDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") + except Exception as e: + _log("WARNING", f"TVDB API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(1.0 * (attempt + 1)) + + return None + + def series_by_imdb(self, imdb_id): + """Find series by IMDb ID""" + j = self._get(f"/search", {"query": imdb_id, "type": "series"}) + if not j or not j.get("data"): + return None + + # Look for exact IMDb match + for series in j["data"]: + remote_ids = series.get("remoteIds") or [] + for remote in remote_ids: + if remote.get("sourceName") == "IMDB" and remote.get("id") == imdb_id: + return series + return None + + def season_episodes(self, series_id, season_number): + """Get episodes for a specific season""" + j = self._get(f"/series/{series_id}/episodes/default", { + "season": season_number, + "page": 0 + }) + + episodes = {} + if not j or not j.get("data"): + return episodes + + for ep in j["data"]["episodes"]: + ep_num = ep.get("number") + aired = ep.get("aired") + if isinstance(ep_num, int) and aired: + episodes[ep_num] = aired + + return episodes + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def season_airdates(self, imdb_id, season_number): + # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY + if not self.enabled: return {} + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response") != "True": + return res + for ep in j.get("Episodes", []): + try: + num = int(ep.get("Episode")) + except Exception: + continue + rel = ep.get("Released") # '2011-12-04' etc + if rel and rel != "N/A": + res[num] = rel + return res + +# ========================= +# NFO helpers +# ========================= +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: + f.unlink() + count += 1 + except Exception: + pass + return count + +def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str, lock_metadata: bool = True): + # Ensure the season directory exists + try: + season_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + _log("ERROR", f"Failed creating season directory {season_dir}: {e}") + return + + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + + # Add lock flag if requested (prevents Emby/Plex from overwriting metadata) + if lock_metadata: + xml.append(" true") + + # Add source comment for debugging + if source: + xml.append(f" ") + xml.append(f" ") + + xml.append("\n") + content = "\n".join(xml) + try: + dst.write_text(content, encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(season_dir: Path, season_num: int): + # Small, safe season.nfo; don't overwrite if present + p = season_dir / "season.nfo" + if p.exists(): + return + xml = f""" + + {season_num} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + # Don't clobber; only create minimal file if missing + p = series_dir / "tvshow.nfo" + if p.exists(): + return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_media_mtimes(season_dir: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + except Exception as e: + _log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}") + return + exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".srt", ".ass", ".vtt") + for f in season_dir.iterdir(): + if f.suffix.lower() in exts: + try: + os.utime(f, (ts, ts), follow_symlinks=False) + except Exception: + pass + +# ========================= +# Core logic +# ========================= +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) + out = {} + + # If using cache, first load existing data from DB + if args.use_cache: + _log("INFO", f"Checking cache for existing episode data: {imdb_id}") + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() + + for season, episode, aired, dateadded, source in cached: + out[(season, episode)] = (aired, dateadded, source) + + _log("INFO", f"Found {len(cached)} cached episodes for {imdb_id}") + + # Find which episodes we have on disk but not in cache + disk_episodes = set() + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + # Find episode files + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if m: + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 == sn: + disk_episodes.add((sn, en2)) + + cached_episodes = set(out.keys()) + missing_episodes = disk_episodes - cached_episodes + + if missing_episodes: + _log("INFO", f"Found {len(missing_episodes)} episodes on disk not in cache: {sorted(missing_episodes)}") + else: + _log("INFO", f"All disk episodes are cached, skipping API queries") + return out + + # Only query APIs if not using cache or if we have missing episodes + need_api_query = not args.use_cache or (args.use_cache and missing_episodes) + + if need_api_query: + # Prefer Sonarr + if sonarr.enabled: + _log("INFO", f"Querying Sonarr for series: {imdb_id}") + s = sonarr.series_by_imdb(imdb_id) + if s: + series_id = s.get("id") + _log("INFO", f"Found Sonarr series ID: {series_id}") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + _log("INFO", f"Found {len(eps)} episodes in Sonarr") + for ep in eps: + season_num = ep.get("seasonNumber") + ep_num = ep.get("episodeNumber") + episode_id = ep.get("id") + + if not isinstance(season_num, int) or not isinstance(ep_num, int): + continue + + # If using cache, skip episodes we already have unless forced + key = (season_num, ep_num) + if args.use_cache and key in out and not args.force_refresh: + continue + + _log("DEBUG", f"Processing S{season_num:02d}E{ep_num:02d} (episode ID: {episode_id})") + + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None + src = None + + # Try to get original import date from history first + if episode_id: + import_date = sonarr.get_episode_import_history(episode_id) + if import_date: + date_add = to_utc_iso(import_date) + src = "sonarr:history.downloadFolderImported" + _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using original import date {date_add}") + + # Fallback to episode file dateAdded + if not date_add: + ep_file_id = ep.get("episodeFileId") + if ep_file_id: + fobj = sonarr.episode_file(ep_file_id) + if fobj and fobj.get("dateAdded"): + date_add = to_utc_iso(fobj.get("dateAdded")) + src = "sonarr:episodeFile.dateAdded" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No history found, using file dateAdded {date_add}") + + # Final fallback to air date + if not date_add and air_utc: + date_add = air_utc + src = "sonarr:episode.airDateUtc" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No dateAdded found, using air date {date_add}") + + if air_utc or date_add: + out[key] = (air_utc, date_add, src) + else: + _log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}") + + # Fill gaps with TMDB (only for missing episodes if using cache) + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + # find all season folders we have + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = tmdb.season_airdates(tv_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: # no dateadded yet + air_iso = to_utc_iso(air) # date → midnight UTC + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # Fill remaining with OMDb (only for missing episodes if using cache) + if omdb.enabled: + # infer seasons as above + seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir() + if p.is_dir() and p.name.lower().startswith("season ") + and p.name.split()[-1].isdigit()}) + for sn in seasons: + m = omdb.season_airdates(imdb_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key not in out or not out[key][1]: + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # Absolute last resort: file mtime (always check for missing episodes) + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): continue try: - t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00")) - ts = int(t.timestamp()) + sn = int(season_dir.name.split()[-1]) except Exception: continue - if best_ts is None or ts < best_ts: - best_ts = ts - best = rec - - if best_ts is not None: - return best_ts, "sonarr_history", {"history_id": best.get("id"), "event_type": best.get("eventType")} - return None + # map existing episode files to ep numbers + media = [p for p in season_dir.iterdir() + if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)] + for f in media: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if not m: continue + sn2, en2 = int(m.group(1)), int(m.group(2)) + if sn2 != sn: # skip cross-labeling + continue + key = (sn, en2) + if key not in out or not out[key][1]: + mt = file_mtime_iso(f) + out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") + return out -def pick_from_sonarr_episode(sc: SonarrClient, series_id: int, season: int, episode: int) -> Optional[Tuple[int, str, Dict[str, object]]]: - try: - eps = sc.episodes_for(series_id) - except Exception as e: - log(f"WARN: Sonarr episodes timeout/err series={series_id}: {e}") - return None - - for ep in eps: - if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode: - air = ep.get("airDate") or ep.get("airDateUtc") - if not air: - return None - if "T" in air: - try: - t = dt.datetime.fromisoformat(air.replace("Z", "+00:00")) - return int(t.timestamp()), "sonarr_airdate", {"episodeId": ep.get("id")} - except Exception: - return None - ts = parse_date_yyyy_mm_dd(air) - if ts is not None: - return ts, "sonarr_airdate", {"episodeId": ep.get("id")} - return None - return None - - -# Similar functions for Radarr and TMDB... -def pick_from_tmdb_tv(tmdb: TMDBClient, epk: EpisodeKey) -> Optional[Tuple[int, str, Dict[str, object]]]: - if not tmdb.api_key: - return None - - tv_id: Optional[int] = None - - if epk.tmdb_id: - try: - tv_id = int(epk.tmdb_id) - except (ValueError, TypeError): - tv_id = None - - if tv_id is None and epk.imdb_id: - try: - tv_id = tmdb.find_tv_by_imdb(epk.imdb_id) - except Exception as e: - log(f"WARN: TMDB find by IMDb failed {epk.imdb_id}: {e}") - - if tv_id is None: - try: - tv_id = tmdb.search_tv(epk.series_title, epk.year) - except Exception as e: - log(f"WARN: TMDB search failed title={epk.series_title}: {e}") - - if tv_id is None: - return None - - air = tmdb.episode_air_date(tv_id, epk.season, epk.episode) - if not air: - return None - - ts = parse_date_yyyy_mm_dd(air) - if ts is None: - return None - return ts, "tmdb_airdate", {"tv_id": tv_id, "country": tmdb.country} - - -# ================= -# NFO Management -# ================= - -def create_simple_nfo(season: int, episode: int, air_date: Optional[str] = None, date_added: Optional[str] = None) -> str: - """Create a simple NFO file content for SxxExx.nfo format.""" - aired_tag = f" {air_date}\n" if air_date else "" - dateadded_tag = f" {date_added}\n" if date_added else "" - - return f""" - -{aired_tag}{dateadded_tag}""" - - -def create_movie_nfo(title: str, year: Optional[int] = None, release_date: Optional[str] = None, date_added: Optional[str] = None) -> str: - """Create a simple NFO file content for movie.nfo format.""" - premiered_tag = f" {release_date}\n" if release_date else "" - dateadded_tag = f" {date_added}\n" if date_added else "" - year_tag = f" {year}\n" if year else "" - title_tag = f" {title}\n" if title else "" - - return f""" - -{title_tag}{year_tag}{premiered_tag}{dateadded_tag}""" - - -def manage_nfo_files( - media_dir: Path, - kind: str, - season: int = 0, - episode: int = 0, - air_date: Optional[str] = None, - date_added: Optional[str] = None, - title: Optional[str] = None, - year: Optional[int] = None, - release_date: Optional[str] = None, - dry_run: bool = False -) -> Tuple[int, int]: - """Manage NFO files. Returns (created_count, removed_count).""" - created = 0 - removed = 0 - - if kind == "tv": - simple_nfo_name = f"S{season:02d}E{episode:02d}.nfo" - simple_nfo_path = media_dir / simple_nfo_name - - nfo_content = create_simple_nfo(season, episode, air_date, date_added) - - if not dry_run: - try: - should_write = True - if simple_nfo_path.exists(): - existing_content = simple_nfo_path.read_text(encoding='utf-8', errors='ignore') - should_write = existing_content.strip() != nfo_content.strip() - - if should_write: - simple_nfo_path.write_text(nfo_content, encoding='utf-8') - created = 1 - except Exception as e: - log(f"WARN: Failed to create {simple_nfo_path}: {e}") - else: - # For dry run, check if we would create/update - if not simple_nfo_path.exists(): - created = 1 - else: - try: - existing_content = simple_nfo_path.read_text(encoding='utf-8', errors='ignore') - if existing_content.strip() != nfo_content.strip(): - created = 1 - except Exception: - created = 1 - - # Remove complex NFO files (but keep simple ones and season.nfo) - for nfo_file in media_dir.glob("*.nfo"): - nfo_name = nfo_file.name.lower() - - # Keep these files - if NFO_SIMPLE_PATTERN.match(nfo_file.name): - continue # Keep S01E01.nfo format - if nfo_name == "season.nfo": - continue # Keep season.nfo - if nfo_name == "tvshow.nfo": - continue # Keep tvshow.nfo - - # This is a complex NFO file - remove it - if not dry_run: - try: - nfo_file.unlink() - log(f"Removed complex NFO: {nfo_file.name}") - removed += 1 - except Exception as e: - log(f"WARN: Failed to remove {nfo_file}: {e}") - else: - log(f"Would remove complex NFO: {nfo_file.name}") - removed += 1 - - return created, removed - - -# ================= -# Sidecar files -# ================= - -def find_sidecar_files(media_file: Path) -> List[Path]: - """Find sidecar files (subtitles, etc.) that match the media file.""" - sidecars = [] - base_name = media_file.stem - parent_dir = media_file.parent - - for ext in SIDECAR_EXTS: - exact_match = parent_dir / f"{base_name}{ext}" - if exact_match.exists(): - sidecars.append(exact_match) - - for lang_file in parent_dir.glob(f"{base_name}.*{ext}"): - if lang_file not in sidecars: - sidecars.append(lang_file) - - return sidecars - - -def apply_timestamp_to_file(file_path: Path, timestamp: int, dry_run: bool = False) -> bool: - """Apply timestamp to a file. Returns True if successful.""" - if dry_run: - return True - - try: - os.utime(str(file_path), (timestamp, timestamp)) - return True - except Exception as e: - log(f"WARN: Failed to set timestamp on {file_path}: {e}") +def is_series_current(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): + """Check if series NFOs and mtimes are already current with database""" + if not args.skip_if_current: return False - - -# ================= -# Scanning -# ================= - -def iter_media_files(roots: List[str]) -> Iterable[Path]: - for root in roots: - r = Path(root) - if not r.exists(): - log(f"WARN: Root path does not exist: {root}") - continue - if r.is_file(): - if r.suffix.lower() in VIDEO_EXTS: - yield r - continue - for p in r.rglob("*"): - if p.is_file() and p.suffix.lower() in VIDEO_EXTS: - yield p - - -def choose_series_id_for_path(series_index: List[dict], path: Path) -> Optional[int]: - """Try to map a path to a Sonarr seriesId by matching the series' 'path' as a prefix.""" - series_dir = path.parent - if series_dir.name.lower().startswith("season "): - series_dir = series_dir.parent - sdir = str(series_dir) - - best_len = -1 - best_sid = None - for s in series_index: - spath = s.get("path", "") - if spath and sdir.startswith(spath): - L = len(spath) - if L > best_len: - best_len = L - best_sid = s.get("id") - return best_sid - - -# ================= -# Apply command -# ================= - -def apply_command(args: argparse.Namespace) -> None: - """Main apply command function.""" - log("=== Media Date Sync Starting ===") + + # Get cached episode data + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() - # Load environment first - load_env_file(args.env) - - # NOW get the updated environment variables - sonarr_url = os.getenv("SONARR_URL", "http://sonarr:8989") - sonarr_key = os.getenv("SONARR_API_KEY", "") - radarr_url = os.getenv("RADARR_URL", "http://radarr:7878") - radarr_key = os.getenv("RADARR_API_KEY", "") - tmdb_key = os.getenv("TMDB_API_KEY", "") - tmdb_country = os.getenv("TMDB_PRIMARY_COUNTRY", "US") - - # Debug: Show what we loaded - log(f"SONARR_URL: {sonarr_url}") - log(f"SONARR_API_KEY: {'SET' if sonarr_key else 'MISSING'}") - log(f"TMDB_API_KEY: {'SET' if tmdb_key else 'MISSING'}") - - # Resolve clients with updated environment - sc = None - if sonarr_key and sonarr_url: - sc = SonarrClient(sonarr_url, sonarr_key) - if not sc.ping(): - log("WARN: Sonarr not reachable") - sc = None - else: - log("Sonarr connection successful") - else: - log("WARN: Sonarr not configured") - - tmdb = TMDBClient(tmdb_key, tmdb_country) - if not tmdb_key: - log("WARN: TMDB not configured - limited functionality") - - # Initialize database - cx = sqlite3.connect(args.db) - db_init(cx) - log(f"Database initialized: {args.db}") - - # Load Sonarr series index - series_index: List[dict] = [] - if args.kind == "tv" and sc: + if not cached: + return False + + # Check if NFO files match database + for season, episode, aired, dateadded, source in cached: + season_dir = series_dir / f"Season {season:02d}" + nfo_file = season_dir / f"S{season:02d}E{episode:02d}.nfo" + + if not nfo_file.exists(): + return False + + # Quick check - does NFO contain the expected dateadded? try: - log("Loading Sonarr series index...") - series_index = sc.series_index() - log(f"Loaded {len(series_index)} series from Sonarr") - except Exception as e: - log(f"WARN: Failed to load Sonarr series: {e}") - - # Determine root paths - roots = args.root or [] - if not roots: - tv_roots = os.getenv("TV_ROOTS", "").split(":") if os.getenv("TV_ROOTS") else [] - movie_roots = os.getenv("MOVIE_ROOTS", "").split(":") if os.getenv("MOVIE_ROOTS") else [] - - if args.kind == "tv" and tv_roots: - roots = [r for r in tv_roots if r.strip()] - log(f"Using TV_ROOTS from environment: {', '.join(roots)}") - elif args.kind == "movie" and movie_roots: - roots = [r for r in movie_roots if r.strip()] - log(f"Using MOVIE_ROOTS from environment: {', '.join(roots)}") + nfo_content = nfo_file.read_text(encoding="utf-8") + if dateadded and dateadded not in nfo_content: + return False + except Exception: + return False - if not roots: - log("ERROR: No root paths specified. Use --root or set TV_ROOTS/MOVIE_ROOTS in .env") + return True +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") return - log(f"Scanning for media files in: {', '.join(roots)}") - files = list(iter_media_files(roots)) - log(f"Found {len(files)} media file(s)") - - if not files: - log("No media files found. Check your root paths and permissions.") + # Check if series is already current + if is_series_current(series_dir, imdb_id, args, conn): + _log("INFO", f"Series already current, skipping: {series_dir.name}") return - # Processing counters - checked = 0 - updated = 0 - misses = 0 - cached = 0 - nfo_created = 0 - nfo_removed = 0 - sidecars_updated = 0 + # Store/refresh series row + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() - log(f"Starting processing: refresh={args.refresh}, dry_run={args.dry_run}, manage_nfo={args.manage_nfo}") - - for fp in files: - checked += 1 - path = str(fp) - - if checked <= 5: # Show detailed info for first 5 files - log(f"Processing file {checked}: {fp.name}") - - # Check cache first (unless refresh requested) - if not args.refresh: - cached_result = db_get_cached(cx, path) - if cached_result: - cached_ts, cached_src = cached_result - if checked <= 5: - log(f" Using cached result: {cached_src}") - - # Apply cached timestamp to media file - if apply_timestamp_to_file(fp, cached_ts, args.dry_run): - cached += 1 - - # Also update sidecar files - sidecar_files = find_sidecar_files(fp) - for sidecar in sidecar_files: - if apply_timestamp_to_file(sidecar, cached_ts, args.dry_run): - sidecars_updated += 1 - - continue - - # Parse media file based on type - ts_src_extra: Optional[Tuple[int, str, Dict[str, object]]] = None - air_date_str = None - date_added_str = None - - if args.kind == "tv": - epk = parse_episode_from_path(fp) - if not epk: - if checked <= 10: # Only log first 10 parse failures to avoid spam - log(f"WARN: Could not parse episode info from: {fp.name}") - misses += 1 - continue - - if checked <= 5: - log(f" Parsed: {epk.series_title} S{epk.season:02d}E{epk.episode:02d}") - - # Try Sonarr first - series_id = choose_series_id_for_path(series_index, fp) if series_index else None - if checked <= 5: - log(f" Sonarr series ID: {series_id}") - - if sc and series_id: - if checked <= 5: - log(f" Checking Sonarr history...") - # Try history first (import date) - history_result = pick_from_sonarr_history(sc, series_id) - if history_result: - ts_src_extra = history_result - if checked <= 5: - log(f" Got history: {history_result[1]}") - try: - date_added_dt = dt.datetime.fromtimestamp(history_result[0], tz=dt.timezone.utc) - date_added_str = date_added_dt.strftime("%Y-%m-%d %H:%M:%S") - except Exception: - pass - - if checked <= 5: - log(f" Checking Sonarr episode data...") - # Get air date regardless - episode_result = pick_from_sonarr_episode(sc, series_id, epk.season, epk.episode) - if episode_result: - if not ts_src_extra: # Use air date if no history - ts_src_extra = episode_result - if not date_added_str: - try: - date_added_dt = dt.datetime.fromtimestamp(episode_result[0], tz=dt.timezone.utc) - date_added_str = date_added_dt.strftime("%Y-%m-%d %H:%M:%S") - except Exception: - pass - - # Extract air date for NFO - try: - air_date_ts = episode_result[0] - air_date_dt = dt.datetime.fromtimestamp(air_date_ts, tz=dt.timezone.utc) - air_date_str = air_date_dt.strftime("%Y-%m-%d") - except Exception: - pass - - # TMDB fallback - if not ts_src_extra and tmdb_key: - if checked <= 5: - log(f" Trying TMDB fallback...") - tmdb_result = pick_from_tmdb_tv(tmdb, epk) - if tmdb_result: - ts_src_extra = tmdb_result - if checked <= 5: - log(f" Got TMDB result: {tmdb_result[1]}") - try: - air_date_ts = tmdb_result[0] - air_date_dt = dt.datetime.fromtimestamp(air_date_ts, tz=dt.timezone.utc) - air_date_str = air_date_dt.strftime("%Y-%m-%d") - if not date_added_str: - date_added_str = air_date_dt.strftime("%Y-%m-%d %H:%M:%S") - except Exception: - pass - - else: # movie support would go here - log("Movie support not implemented yet") - misses += 1 - continue - - # Last resort: keep existing mtime if allowed - if not ts_src_extra and args.live_on_miss: - try: - mtime = int(fp.stat().st_mtime) - ts_src_extra = (mtime, "existing_mtime", {}) - if checked <= 5: - log(f" Using existing mtime as fallback") - except Exception: - ts_src_extra = None - - if not ts_src_extra: - if checked <= 10: # Only log first 10 misses to avoid spam - log(f"WARN: No date found for: {fp.name}") - misses += 1 - continue - - ts, src, extra = ts_src_extra - - if checked <= 5: - log(f" Final source: {src}, timestamp: {dt.datetime.fromtimestamp(ts)}") - - # Apply timestamp to media file - if apply_timestamp_to_file(fp, ts, args.dry_run): - updated += 1 - - # Apply timestamp to sidecar files - sidecar_files = find_sidecar_files(fp) - if sidecar_files and checked <= 5: - log(f" Found {len(sidecar_files)} sidecar files") - for sidecar in sidecar_files: - if apply_timestamp_to_file(sidecar, ts, args.dry_run): - sidecars_updated += 1 - - # Handle NFO files for TV shows - if args.kind == "tv" and args.manage_nfo: - created, removed = manage_nfo_files( - fp.parent, "tv", epk.season, epk.episode, - air_date_str, date_added_str, dry_run=args.dry_run - ) - if (created or removed) and checked <= 5: - log(f" NFO: created={created}, removed={removed}") - nfo_created += created - nfo_removed += removed - - # Update database - if not args.dry_run: - try: - if args.kind == "tv": - extra_data = { - **(extra or {}), - "series_title": epk.series_title, - "season": epk.season, - "episode": epk.episode, - } - else: - extra_data = extra or {} - - db_upsert(cx, path, args.kind, ts, src, extra_data) - - if checked <= 5: # Debug for first few files - log(f" Database updated: {src}") - except sqlite3.OperationalError as e: - log(f"WARN: DB write failed: {e}") - except Exception as e: - log(f"WARN: Unexpected DB error: {e}") - - # Progress updates - if checked % 500 == 0: - log(f"Progress: {checked}/{len(files)} (updated: {updated}, cached: {cached}, misses: {misses})") - - # Final results - completion_msg = f"Media date sync completed:\n" \ - f"Kind: {args.kind}\n" \ - f"Checked: {checked}\n" \ - f"Updated: {updated}\n" \ - f"Cached: {cached}\n" \ - f"Misses: {misses}\n" \ - f"Sidecars updated: {sidecars_updated}\n" - + # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked if args.manage_nfo: - completion_msg += f"NFO created: {nfo_created}\n" \ - f"NFO removed: {nfo_removed}\n" - - completion_msg += f"Dry run: {args.dry_run}" - - log(f"=== Complete ===") - log(completion_msg.replace('\n', ', ')) - - # Send notification - send_gotify_notification("Media Date Sync Complete", completion_msg) + for season_dir in series_dir.iterdir(): + if season_dir.is_dir() and season_dir.name.lower().startswith("season "): + removed = remove_long_nfos(season_dir) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") + try: + sn = int(season_dir.name.split()[-1]) + ensure_season_nfo(season_dir, sn) + except Exception: + pass + maybe_tvshow_nfo(series_dir, imdb_id) + # Gather dates + dates = gather_episode_dates(series_dir, imdb_id, args, conn) -# ================= + # Write short NFOs and record DB rows; track per-season latest date for mtime + seasons_latest = {} + + for (sn, en), (aired, dateadded, source) in sorted(dates.items()): + # write episode nfo + if args.manage_nfo: + write_episode_nfo( + series_dir / f"Season {sn:02d}", + sn, en, aired, dateadded, source or "", + lock_metadata=getattr(args, 'lock_metadata', True) + ) + + # DB upsert + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) " + "VALUES(?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source", + (imdb_id, sn, en, aired or None, dateadded or None, source or None), + ) + + # compute latest per season for mtime + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) + except Exception: + ts = None + if ts: + seasons_latest.setdefault(sn, ts) + if ts > seasons_latest[sn]: + seasons_latest[sn] = ts + + conn.commit() + + # Fix folder mtimes (season dirs) + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = series_dir / f"Season {sn:02d}" + if season_dir.exists(): + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + if args.update_video_mtimes: + set_media_mtimes(season_dir, iso) + +def iterate_target_path(path: Path): + # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs + if is_series_dir(path): + return [path] + if path.is_dir(): + out = [] + for p in path.iterdir(): + if is_series_dir(p): + out.append(p) + return sorted(out) + return [] + +# ========================= # CLI -# ================= +# ========================= +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = db_connect(Path(args.db)) + # wipe content but keep schema + conn.execute("DELETE FROM episode_dates;") + conn.execute("DELETE FROM series;") + conn.commit() + conn.close() -def build_arg_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(description="Apply canonical mtimes to media files using Sonarr/Radarr/TMDB fallbacks.") +def cmd_apply(args): + # Enable debug logging if requested + if args.debug: + _log.debug_enabled = True + + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + # Inform about source availability + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TVDB_API_KEY"): + _log("INFO", "TVDB not configured (set TVDB_API_KEY for better air date coverage)") + + for series_dir in targets: + _log("INFO", f"=== APPLY: {series_dir} ===") + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except urllib.error.HTTPError as e: + _log("ERROR", f"API error for {series_dir}: HTTP {e.code} - {e.reason}") + if e.code == 401: + _log("ERROR", "Check your API keys (SONARR_API_KEY, TMDB_API_KEY, OMDB_API_KEY)") + continue # Skip this series but continue with others + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + continue # Skip this series but continue with others + + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) sub = p.add_subparsers(dest="cmd", required=True) - # apply - ap = sub.add_parser("apply", help="Scan, resolve dates, apply mtimes, and record to DB.") - ap.add_argument("--env", help="Optional .env file with API keys and URLs") - ap.add_argument("--kind", choices=["tv", "movie"], required=True, help="Media kind") - ap.add_argument("--db", required=True, help="SQLite DB file path") - ap.add_argument("--root", action="append", help="Root folder(s) to scan; repeatable (or use TV_ROOTS/MOVIE_ROOTS in .env)") - ap.add_argument("--refresh", action="store_true", help="Ignore cached results and re-fetch all dates") - ap.add_argument("--dry-run", action="store_true", help="Do not write mtime/DB; just log what would happen") - ap.add_argument("--live-on-miss", action="store_true", help="If no date found, keep file's current mtime as chosen date") - ap.add_argument("--manage-nfo", action="store_true", help="Create simple NFO files with air/release dates and date added (import dates). Remove complex NFOs.") + p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes for a series dir or a library root") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to season latest (use with care)") + p_apply.add_argument("--lock-metadata", action="store_true", default=True, help="Add lockdata flag to NFO files to prevent media server overwrites") + p_apply.add_argument("--no-lock-metadata", dest="lock_metadata", action="store_false", help="Don't add lockdata flag to NFO files") + p_apply.add_argument("--skip-if-current", action="store_true", help="Skip series where NFOs are already current with database") + p_apply.add_argument("--use-cache", action="store_true", help="Use cached episode data when available, only query APIs for missing episodes") + p_apply.add_argument("--force-refresh", action="store_true", help="Force refresh of cached data (use with --use-cache)") + p_apply.add_argument("--debug", action="store_true", help="Enable debug logging") + p_apply.set_defaults(func=cmd_apply) return p - -def main() -> None: - try: - parser = build_arg_parser() - args = parser.parse_args() - - if args.cmd == "apply": - apply_command(args) - else: - parser.error("unknown command") - except KeyboardInterrupt: - log("Interrupted.") - sys.exit(1) - except Exception as e: - log(f"FATAL ERROR: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - +def main(): + parser = build_parser() + args = parser.parse_args() + # (No need to load env here; it was already handled in preparse) + args.func(args) if __name__ == "__main__": main()