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()