mirror of
https://github.com/Xevion/indexer-analyze.git
synced 2025-12-06 01:15:20 -06:00
initial commit
This commit is contained in:
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Poetry
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# dotenv
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.log
|
||||||
29
auth.py
Normal file
29
auth.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
from config import AUTHELIA_URL, get_async_logger
|
||||||
|
from hooks import add_sonarr_api_key
|
||||||
|
|
||||||
|
logger: structlog.stdlib.AsyncBoundLogger = get_async_logger()
|
||||||
|
|
||||||
|
|
||||||
|
async def authelia_login(username: str, password: str) -> httpx.AsyncClient:
|
||||||
|
"""
|
||||||
|
Perform Authelia login and return an authenticated httpx.AsyncClient with Sonarr API key added.
|
||||||
|
"""
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
event_hooks={"request": [add_sonarr_api_key]},
|
||||||
|
http2=True,
|
||||||
|
limits=httpx.Limits(
|
||||||
|
keepalive_expiry=60, max_connections=200, max_keepalive_connections=30
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
login_url = f"{AUTHELIA_URL}/api/firstfactor"
|
||||||
|
payload = {"username": username, "password": password}
|
||||||
|
resp = await client.post(login_url, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
# If login is successful, cookies are set in the client
|
||||||
|
await logger.info("Authelia login successful", username=username)
|
||||||
|
|
||||||
|
return client
|
||||||
35
config.py
Normal file
35
config.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(logging.INFO))
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_async(logger) -> structlog.stdlib.AsyncBoundLogger:
|
||||||
|
return structlog.wrap_logger(
|
||||||
|
logger, wrapper_class=structlog.stdlib.AsyncBoundLogger
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_async_logger() -> structlog.stdlib.AsyncBoundLogger:
|
||||||
|
return wrap_async(
|
||||||
|
structlog.get_logger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logger: structlog.stdlib.AsyncBoundLogger = structlog.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
def getenv(key: str) -> str:
|
||||||
|
value = os.getenv(key)
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"{key} must be set in the .env file.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
SONARR_URL: str = getenv("SONARR_URL")
|
||||||
|
SONARR_API_KEY: str = getenv("SONARR_API_KEY")
|
||||||
|
AUTHELIA_URL: str = getenv("AUTHELIA_URL")
|
||||||
58
format.py
Normal file
58
format.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from config import get_async_logger
|
||||||
|
|
||||||
|
logger: structlog.stdlib.AsyncBoundLogger = get_async_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def relative_diff(dt_str1, dt_str2):
|
||||||
|
if not dt_str1 or not dt_str2:
|
||||||
|
return ""
|
||||||
|
dt1 = datetime.fromisoformat(dt_str1.replace("Z", "+00:00"))
|
||||||
|
dt2 = datetime.fromisoformat(dt_str2.replace("Z", "+00:00"))
|
||||||
|
diff = abs(dt1 - dt2)
|
||||||
|
seconds = int(diff.total_seconds())
|
||||||
|
minutes = seconds // 60
|
||||||
|
hours = minutes // 60
|
||||||
|
days = hours // 24
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if days > 0:
|
||||||
|
parts.append(f"{days}d")
|
||||||
|
hours = hours % 24
|
||||||
|
if hours > 0 or days > 0:
|
||||||
|
parts.append(f"{hours}h")
|
||||||
|
minutes = minutes % 60
|
||||||
|
if minutes > 0 and days == 0:
|
||||||
|
parts.append(f"{minutes}m")
|
||||||
|
return " ".join(parts) if parts else "0m"
|
||||||
|
|
||||||
|
|
||||||
|
def relative_time(dt_str):
|
||||||
|
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
diff = now - dt
|
||||||
|
seconds = int(diff.total_seconds())
|
||||||
|
minutes = seconds // 60
|
||||||
|
hours = minutes // 60
|
||||||
|
days = hours // 24
|
||||||
|
months = days // 30
|
||||||
|
|
||||||
|
if months > 0:
|
||||||
|
return f"{months} month{'s' if months != 1 else ''} ago"
|
||||||
|
elif days > 0:
|
||||||
|
return f"{days} day{'s' if days != 1 else ''} ago"
|
||||||
|
elif hours > 0:
|
||||||
|
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
||||||
|
elif minutes > 0:
|
||||||
|
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
|
||||||
|
else:
|
||||||
|
return "just now"
|
||||||
|
|
||||||
|
|
||||||
|
def ellipsis(s, max_length):
|
||||||
|
if not isinstance(s, str) or max_length < 4 or len(s) <= max_length:
|
||||||
|
return s
|
||||||
|
return s[: max_length - 1] + "…"
|
||||||
18
hooks.py
Normal file
18
hooks.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from config import SONARR_API_KEY, SONARR_URL, get_async_logger
|
||||||
|
|
||||||
|
logger: structlog.stdlib.AsyncBoundLogger = get_async_logger()
|
||||||
|
|
||||||
|
|
||||||
|
async def add_sonarr_api_key(request: httpx.Request) -> None:
|
||||||
|
"""
|
||||||
|
Event hook to inject the Sonarr API key into requests to the Sonarr domain and /api path.
|
||||||
|
"""
|
||||||
|
if SONARR_URL and request.url.host in SONARR_URL and "/api" in request.url.path:
|
||||||
|
await logger.debug(
|
||||||
|
"applying sonarr api key",
|
||||||
|
sonarr_url=SONARR_URL,
|
||||||
|
)
|
||||||
|
request.headers["X-Api-Key"] = SONARR_API_KEY
|
||||||
230
main.py
Normal file
230
main.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
from anyio import run
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from auth import authelia_login
|
||||||
|
from config import get_async_logger
|
||||||
|
from format import ellipsis
|
||||||
|
import anyio
|
||||||
|
from collections import Counter
|
||||||
|
from sonarr import (
|
||||||
|
get_all_series,
|
||||||
|
get_episodes_for_series,
|
||||||
|
get_history_for_episode,
|
||||||
|
get_series,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger: structlog.stdlib.AsyncBoundLogger = get_async_logger()
|
||||||
|
|
||||||
|
|
||||||
|
async def process_series(client, series_id: int) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
For a given series, count the number of files per indexer by analyzing episode history.
|
||||||
|
Returns a dictionary mapping indexer names to file counts.
|
||||||
|
"""
|
||||||
|
series = await get_series(client, series_id)
|
||||||
|
episodes = await get_episodes_for_series(client, series_id)
|
||||||
|
indexer_counts: Dict[str, int] = defaultdict(lambda: 0)
|
||||||
|
|
||||||
|
for ep in episodes:
|
||||||
|
# Skip episodes without files
|
||||||
|
if not ep.get("hasFile", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
indexer = "unknown"
|
||||||
|
episode_detail = f"{ellipsis(series['title'], 12)} S{ep['seasonNumber']:02}E{ep['episodeNumber']:02} - {ellipsis(ep.get('title', 'Unknown'), 18)}"
|
||||||
|
|
||||||
|
# Retrieves all history events for the episode
|
||||||
|
try:
|
||||||
|
history = await get_history_for_episode(client, ep["id"])
|
||||||
|
except Exception as e:
|
||||||
|
if (
|
||||||
|
hasattr(e, "response")
|
||||||
|
and getattr(e.response, "status_code", None) == 404
|
||||||
|
):
|
||||||
|
await logger.warning(
|
||||||
|
"History not found for episode (404)",
|
||||||
|
episode_id=ep["id"],
|
||||||
|
episode=episode_detail,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
await logger.error(
|
||||||
|
"Error fetching history for episode",
|
||||||
|
episode_id=ep["id"],
|
||||||
|
episode=episode_detail,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get the episode's episodeFileId
|
||||||
|
target_file_id = ep.get("episodeFileId")
|
||||||
|
if not target_file_id:
|
||||||
|
await logger.error(
|
||||||
|
"No episode file for episode",
|
||||||
|
episode_id=ep["id"],
|
||||||
|
episode=episode_detail,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find the 'downloadFolderImported' event with the matching data.fileId
|
||||||
|
def is_import_event(event: Dict, file_id: int) -> bool:
|
||||||
|
return event.get("eventType") == "downloadFolderImported" and event.get(
|
||||||
|
"data", {}
|
||||||
|
).get("fileId") == str(file_id)
|
||||||
|
|
||||||
|
import_event = next(
|
||||||
|
(
|
||||||
|
event
|
||||||
|
for event in history["records"]
|
||||||
|
if is_import_event(event, target_file_id)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not import_event:
|
||||||
|
await logger.debug(
|
||||||
|
"No import event found for episode file",
|
||||||
|
episode_id=ep["id"],
|
||||||
|
episode=episode_detail,
|
||||||
|
target_file_id=target_file_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Acquire the event's downloadId
|
||||||
|
download_id = import_event.get("downloadId")
|
||||||
|
if not download_id:
|
||||||
|
await logger.error(
|
||||||
|
"No downloadId found in import event",
|
||||||
|
episode_id=ep["id"],
|
||||||
|
episode=episode_detail,
|
||||||
|
target_file_id=target_file_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find the 'grabbed' event with the matching downloadId
|
||||||
|
def is_grabbed_event(event: Dict, download_id: str) -> bool:
|
||||||
|
return (
|
||||||
|
event.get("eventType") == "grabbed"
|
||||||
|
and event.get("downloadId") == download_id
|
||||||
|
)
|
||||||
|
|
||||||
|
grabbed_event = next(
|
||||||
|
(
|
||||||
|
event
|
||||||
|
for event in history["records"]
|
||||||
|
if is_grabbed_event(event, download_id)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not grabbed_event:
|
||||||
|
await logger.debug(
|
||||||
|
"No 'grabbed' event found",
|
||||||
|
episode_id=ep["id"],
|
||||||
|
download_id=ellipsis(download_id, 20),
|
||||||
|
episode=episode_detail,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract the indexer from the 'grabbed' event
|
||||||
|
indexer = grabbed_event.get("data", {}).get("indexer")
|
||||||
|
if not indexer:
|
||||||
|
await logger.warning(
|
||||||
|
"No indexer provided in 'grabbed' event",
|
||||||
|
episode_id=ep["id"],
|
||||||
|
episode=episode_detail,
|
||||||
|
download_id=ellipsis(download_id, 20),
|
||||||
|
)
|
||||||
|
|
||||||
|
indexer = "unknown"
|
||||||
|
|
||||||
|
# Normalize indexer names by removing the "(Prowlarr)" suffix
|
||||||
|
indexer = indexer[:-11] if indexer.endswith("(Prowlarr)") else indexer
|
||||||
|
|
||||||
|
indexer_counts[indexer] += 1
|
||||||
|
|
||||||
|
return indexer_counts
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""
|
||||||
|
Entrypoint: Authenticates with Authelia, fetches all Sonarr series, and logs per-series indexer statistics.
|
||||||
|
"""
|
||||||
|
username = os.getenv("AUTHELIA_USERNAME")
|
||||||
|
password = os.getenv("AUTHELIA_PASSWORD")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
await logger.critical(
|
||||||
|
"Missing Authelia credentials",
|
||||||
|
AUTHELIA_USERNAME=username,
|
||||||
|
AUTHELIA_PASSWORD=bool(password),
|
||||||
|
)
|
||||||
|
raise Exception(
|
||||||
|
"AUTHELIA_USERNAME and AUTHELIA_PASSWORD must be set in the .env file."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Request counter setup
|
||||||
|
request_counter = {"count": 0, "active": 0}
|
||||||
|
counter_lock = anyio.Lock()
|
||||||
|
|
||||||
|
async def count_requests(request):
|
||||||
|
async with counter_lock:
|
||||||
|
request_counter["count"] += 1
|
||||||
|
request_counter["active"] += 1
|
||||||
|
|
||||||
|
async def count_responses(response):
|
||||||
|
async with counter_lock:
|
||||||
|
request_counter["active"] -= 1
|
||||||
|
|
||||||
|
# Attach the event hooks to the client
|
||||||
|
client = await authelia_login(username, password)
|
||||||
|
if hasattr(client, "event_hooks"):
|
||||||
|
client.event_hooks.setdefault("request", []).append(count_requests)
|
||||||
|
client.event_hooks.setdefault("response", []).append(count_responses)
|
||||||
|
|
||||||
|
series_list = await get_all_series(client)
|
||||||
|
|
||||||
|
total_indexer_counts = Counter()
|
||||||
|
|
||||||
|
async def process_and_log(series):
|
||||||
|
indexer_counts = await process_series(client, series["id"])
|
||||||
|
if any(indexer_counts.keys()):
|
||||||
|
await logger.debug(
|
||||||
|
"Processed series",
|
||||||
|
series_title=series["title"],
|
||||||
|
series_id=series["id"],
|
||||||
|
indexers=dict(indexer_counts),
|
||||||
|
)
|
||||||
|
total_indexer_counts.update(indexer_counts)
|
||||||
|
|
||||||
|
async def print_rps(interval=3):
|
||||||
|
while True:
|
||||||
|
await anyio.sleep(interval)
|
||||||
|
async with counter_lock:
|
||||||
|
rps = request_counter["count"] / interval
|
||||||
|
active = request_counter["active"]
|
||||||
|
request_counter["count"] = 0
|
||||||
|
await logger.info(
|
||||||
|
"Requests per second and active requests",
|
||||||
|
rps=rps,
|
||||||
|
active_requests=active,
|
||||||
|
interval=interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
tg.start_soon(print_rps, 3)
|
||||||
|
for series in series_list:
|
||||||
|
tg.start_soon(process_and_log, series)
|
||||||
|
|
||||||
|
await logger.info(
|
||||||
|
"Total indexer counts across all series",
|
||||||
|
indexers=dict(total_indexer_counts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run(main)
|
||||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "indexer-analyze"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = ["Xevion <xevion@xevion.dev>"]
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.10"
|
||||||
|
python-dotenv = "^1.1.0"
|
||||||
|
structlog = "^25.3.0"
|
||||||
|
httpx = {extras = ["http2"], version = "^0.28.1"}
|
||||||
|
anyio = "^4.9.0"
|
||||||
|
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
44
sonarr.py
Normal file
44
sonarr.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import httpx
|
||||||
|
from config import SONARR_URL, SONARR_API_KEY, logger
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_series(client: httpx.AsyncClient):
|
||||||
|
"""
|
||||||
|
Fetch all series from Sonarr.
|
||||||
|
"""
|
||||||
|
url = f"{SONARR_URL}/api/v3/series"
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_series(client: httpx.AsyncClient, series_id: int):
|
||||||
|
"""
|
||||||
|
Fetch a single series by ID from Sonarr.
|
||||||
|
"""
|
||||||
|
url = f"{SONARR_URL}/api/v3/series/{series_id}"
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_episodes_for_series(client: httpx.AsyncClient, series_id: int):
|
||||||
|
"""
|
||||||
|
Fetch all episodes for a given series from Sonarr.
|
||||||
|
"""
|
||||||
|
url = f"{SONARR_URL}/api/v3/episode?seriesId={series_id}"
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history_for_episode(client: httpx.AsyncClient, episode_id: int):
|
||||||
|
"""
|
||||||
|
Fetch history for a given episode from Sonarr.
|
||||||
|
"""
|
||||||
|
resp = await client.get(
|
||||||
|
SONARR_URL + "/api/v3/history",
|
||||||
|
params={"episodeId": episode_id, "pageSize": 100},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
Reference in New Issue
Block a user