Intial push
This commit is contained in:
1 parent
8f3bf42917
commit
7b1c694f82
5 files changed
+1074
No files matched your search
@@ -0,0 +1,41 @@
|
||||
# Sonarr
|
||||
SONARR_URL=http://sonarr:8989
|
||||
SONARR_API_KEY=
|
||||
SONARR_TIMEOUT=45
|
||||
SONARR_RETRIES=3
|
||||
|
||||
RADARR_URL=http://radarr:7878
|
||||
RADARR_API_KEY=
|
||||
RADARR_TIMEOUT=45
|
||||
RADARR_RETRIES=3
|
||||
|
||||
|
||||
# Gotify (optional)
|
||||
GOTIFY_URL=
|
||||
GOTIFY_TOKEN=-s
|
||||
|
||||
|
||||
#TMDB_API_KEY=
|
||||
#TMDB_PRIMARY_COUNTRY=US
|
||||
#TMDB_TIMEOUT=45
|
||||
#TMDB_RETRIES=3
|
||||
|
||||
|
||||
#OMDB_API_KEY=
|
||||
TVDB_API_KEY=
|
||||
# Batching configuration
|
||||
BATCH_DELAY=5.0 # Seconds to wait for more webhooks
|
||||
MAX_CONCURRENT_SERIES=3 # Max series proceossing simultaneously
|
||||
|
||||
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||
|
||||
# Batching (optional)
|
||||
#BATCH_DELAY=5.0
|
||||
#MAX_CONCURRENT_SERIES=3
|
||||
|
||||
# Features (optional)
|
||||
MANAGE_NFO=true
|
||||
FIX_DIR_MTIMES=true
|
||||
LOCK_METADATA=true
|
||||
DEBUG=false
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# deps (curl for healthcheck; gosu only needed when running as root)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
sqlite3 curl gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a default appuser (will be ignored if process isn’t root)
|
||||
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY media_date_cache.py .
|
||||
COPY webhook_server.py .
|
||||
|
||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||
|
||||
# Root-aware entrypoint
|
||||
RUN cat > /entrypoint.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-1000}
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
# running as root: we can adjust IDs and drop privileges
|
||||
groupmod -o -g "$PGID" appuser 2>/dev/null || groupadd -o -g "$PGID" appuser
|
||||
id appuser &>/dev/null || useradd -o -u "$PUID" -g "$PGID" -m appuser
|
||||
usermod -o -u "$PUID" -g "$PGID" appuser 2>/dev/null || true
|
||||
chown -R "$PUID:$PGID" /app || true
|
||||
exec gosu appuser "$@"
|
||||
else
|
||||
# not root (e.g., compose set user: 1000:1000) — just run
|
||||
exec "$@"
|
||||
fi
|
||||
EOF
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["python", "webhook_server.py"]
|
||||
@@ -0,0 +1,106 @@
|
||||
# NFOGuard
|
||||
|
||||
|
||||
*** Alpha in progress things are volatile use at your own risk ***
|
||||
|
||||
**NFOGuard** is a lightweight webhook service that locks in the *true import date* of your movies and TV episodes, ensuring that upgrades, renames, or reimports never bubble old media up as “new” in Emby, Jellyfin, or Plex.
|
||||
|
||||
It integrates with **Sonarr** and **Radarr**, listens for import/rename/upgrade events, and automatically manages `.nfo` files and filesystem timestamps to keep your library chronology consistent.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Import Date Locking**
|
||||
Captures the original date a movie or episode was imported and preserves it across upgrades.
|
||||
|
||||
- **NFO Management**
|
||||
Creates and updates `.nfo` files for movies and TV episodes with consistent `<dateadded>` fields.
|
||||
Optionally adds `<lockdata>` to prevent metadata drift from scrapers.
|
||||
|
||||
- **Filesystem Time Fixing**
|
||||
Updates file and directory modification times (`mtime`) to match the preserved import date.
|
||||
|
||||
- **Batching Support**
|
||||
Groups multiple webhook events (e.g. whole-season downloads) to avoid thrashing.
|
||||
|
||||
- **Database Backing**
|
||||
Uses SQLite to remember previously-seen imports, so dates persist across restarts.
|
||||
|
||||
- **Manual Scans**
|
||||
Exposes endpoints to rescan and reconcile TV or movie libraries on demand.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 How It Works
|
||||
|
||||
1. **Webhooks**
|
||||
- Add NFOGuard as a webhook in Sonarr and Radarr.
|
||||
- Supported event types: `Download`, `Upgrade`, `Rename`.
|
||||
|
||||
2. **Processing**
|
||||
- Maps Sonarr/Radarr paths to container paths.
|
||||
- Looks up IMDb IDs from folder names or payloads.
|
||||
- Writes `.nfo` files and updates file/dir mtimes.
|
||||
- Records everything in `media_dates.db`.
|
||||
|
||||
3. **Playback Apps**
|
||||
- Emby/Jellyfin/Plex see consistent `dateadded` values.
|
||||
- Upgrades don’t bubble to the top of “Recently Added.”
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start (Docker)
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name=NFOGuard \
|
||||
-p 8080:8080 \
|
||||
-v /mnt/unionfs/Media:/media:rw \
|
||||
-v /opt/NFOGuard/data:/app/data \
|
||||
-e TV_PATHS="/media/TV/tv,/media/TV/tv6" \
|
||||
-e MOVIE_PATHS="/media/Movies/movies,/media/Movies/movies6" \
|
||||
ghcr.io/your-org/NFOGuard:latest
|
||||
⚙️ Environment Variables
|
||||
Variable Default Description
|
||||
TV_PATHS /media/tv,/media/tv6 Comma-separated list of TV root paths
|
||||
MOVIE_PATHS /media/movies,/media/movies6 Comma-separated list of Movie root paths
|
||||
DB_PATH /app/data/media_dates.db Path to SQLite database
|
||||
MANAGE_NFO true Write/update NFO files
|
||||
FIX_DIR_MTIMES true Sync directory/file mtimes to import date
|
||||
LOCK_METADATA true Add <lockdata> in NFOs
|
||||
BATCH_DELAY 5.0 Delay before processing batched events
|
||||
DEBUG false Enable verbose logging
|
||||
|
||||
🔌 API Endpoints
|
||||
POST /webhook/sonarr – Sonarr events
|
||||
|
||||
POST /webhook/radarr – Radarr events
|
||||
|
||||
POST /manual/scan – Manual scan (TV + Movies)
|
||||
|
||||
POST /manual/scan/tv – Manual scan TV only
|
||||
|
||||
POST /manual/scan/movies – Manual scan Movies only
|
||||
|
||||
GET /health – Health check
|
||||
|
||||
GET /stats – Database stats
|
||||
|
||||
GET /batch/status – Current batch queue
|
||||
|
||||
📖 Example Workflow
|
||||
You add The Blacklist in Sonarr.
|
||||
|
||||
Sonarr downloads S01E01 → NFOGuard logs the import date in DB and .nfo.
|
||||
|
||||
Months later, a better-quality upgrade replaces the file.
|
||||
|
||||
NFOGuard updates the .nfo and mtime, but keeps the original import date.
|
||||
|
||||
Emby/Jellyfin/Plex see the file as unchanged in chronology.
|
||||
|
||||
Result: no more “old shows” showing up in “Recently Added.”
|
||||
|
||||
📜 License
|
||||
MIT License – do what you want, just don’t blame us if your timestamps go timey-wimey.
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
@@ -0,0 +1,877 @@
|
||||
#!/usr/bin/env python3
|
||||
# webhook_server.py - FastAPI webhook server for media date cache
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import glob
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any, List, Set
|
||||
import sqlite3
|
||||
import uvicorn
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
# Import our existing logic
|
||||
from media_date_cache import (
|
||||
db_connect, apply_series, parse_imdb_from_path,
|
||||
_log, _iso_now, SonarrClient
|
||||
)
|
||||
|
||||
# Pydantic models for webhook payloads
|
||||
class SonarrWebhook(BaseModel):
|
||||
eventType: str
|
||||
series: Optional[Dict[str, Any]] = None
|
||||
episodes: Optional[list] = []
|
||||
episodeFile: Optional[Dict[str, Any]] = None
|
||||
isUpgrade: Optional[bool] = False
|
||||
movie: Optional[Dict[str, Any]] = None
|
||||
remoteMovie: Optional[Dict[str, Any]] = None
|
||||
deletedFiles: Optional[list] = []
|
||||
|
||||
class Config:
|
||||
extra = "allow" # Allow extra fields not defined in the model
|
||||
|
||||
class RadarrWebhook(BaseModel):
|
||||
eventType: str
|
||||
movie: Optional[Dict[str, Any]] = None
|
||||
movieFile: Optional[Dict[str, Any]] = None
|
||||
isUpgrade: Optional[bool] = False
|
||||
deletedFiles: Optional[list] = []
|
||||
remoteMovie: Optional[Dict[str, Any]] = None
|
||||
renamedMovieFiles: Optional[List[Dict[str, Any]]] = [] # present on Rename
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
uptime: str
|
||||
database_status: str
|
||||
|
||||
app = FastAPI(
|
||||
title="Media Date Cache Webhook Server",
|
||||
description="Webhook server for managing TV show and movie import dates and NFO files",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Global state
|
||||
start_time = datetime.now(timezone.utc)
|
||||
db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db"))
|
||||
|
||||
class WebhookConfig:
|
||||
def __init__(self):
|
||||
# TV paths
|
||||
tv_paths_str = os.environ.get("TV_PATHS", os.environ.get("MEDIA_PATH", "/media/tv,/media/tv6"))
|
||||
self.tv_paths = [Path(p.strip()) for p in tv_paths_str.split(",")]
|
||||
|
||||
# Movie paths
|
||||
movie_paths_str = os.environ.get("MOVIE_PATHS", "/media/movies,/media/movies6")
|
||||
self.movie_paths = [Path(p.strip()) for p in movie_paths_str.split(",")]
|
||||
|
||||
self.manage_nfo = os.environ.get("MANAGE_NFO", "true").lower() == "true"
|
||||
self.fix_dir_mtimes = os.environ.get("FIX_DIR_MTIMES", "true").lower() == "true"
|
||||
self.lock_metadata = os.environ.get("LOCK_METADATA", "true").lower() == "true"
|
||||
self.debug = os.environ.get("DEBUG", "false").lower() == "true"
|
||||
# Batching configuration
|
||||
self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0")) # seconds
|
||||
self.max_concurrent_series = int(os.environ.get("MAX_CONCURRENT_SERIES", "3"))
|
||||
|
||||
config = WebhookConfig()
|
||||
|
||||
# Movie database schema additions
|
||||
MOVIE_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
imdb_id TEXT PRIMARY KEY,
|
||||
movie_path TEXT NOT NULL,
|
||||
last_applied TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS movie_dates (
|
||||
imdb_id TEXT PRIMARY KEY,
|
||||
released TEXT,
|
||||
dateadded TEXT,
|
||||
source TEXT,
|
||||
has_video_file INTEGER DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
def ensure_movie_tables(conn: sqlite3.Connection):
|
||||
"""Ensure movie tables exist in the database"""
|
||||
try:
|
||||
result = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='movies'").fetchone()
|
||||
if not result:
|
||||
_log("INFO", "Creating movie tables in database")
|
||||
conn.executescript(MOVIE_SCHEMA)
|
||||
conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
_log("ERROR", f"Failed to create movie tables: {e}")
|
||||
|
||||
# Movie processing functions
|
||||
def parse_imdb_from_movie_path(p: Path):
|
||||
"""Extract IMDb ID from movie directory name"""
|
||||
# Look for patterns like: Movie Name (2023) [imdb-tt1234567]
|
||||
m = re.search(r'\[imdb-(tt\d+)\]', p.name, re.IGNORECASE)
|
||||
return m.group(1).lower() if m else None
|
||||
|
||||
def is_movie_dir(p: Path) -> bool:
|
||||
"""Check if path is a movie directory with IMDb ID"""
|
||||
return p.is_dir() and parse_imdb_from_movie_path(p) is not None
|
||||
|
||||
def write_movie_nfo(movie_dir: Path, imdb_id: str, released: str, dateadded: str, source: str, lock_metadata: bool = True):
|
||||
"""Write movie NFO file"""
|
||||
try:
|
||||
movie_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed creating movie directory {movie_dir}: {e}")
|
||||
return
|
||||
|
||||
nfo_file = movie_dir / f"{movie_dir.name}.nfo"
|
||||
xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||
"<movie>",
|
||||
f" <id>{imdb_id}</id>",
|
||||
f" <uniqueid type=\"imdb\" default=\"true\">{imdb_id}</uniqueid>",
|
||||
]
|
||||
|
||||
if released:
|
||||
xml.append(f" <premiered>{released.split('T')[0]}</premiered>")
|
||||
xml.append(f" <year>{released.split('-')[0]}</year>")
|
||||
|
||||
if dateadded:
|
||||
xml.append(f" <dateadded>{dateadded}</dateadded>")
|
||||
|
||||
if lock_metadata:
|
||||
xml.append(" <lockdata>true</lockdata>")
|
||||
|
||||
if source:
|
||||
xml.append(f" <!-- source: {source} -->")
|
||||
xml.append(f" <!-- managed by media-date-cache -->")
|
||||
|
||||
xml.append("</movie>\n")
|
||||
content = "\n".join(xml)
|
||||
|
||||
try:
|
||||
nfo_file.write_text(content, encoding="utf-8")
|
||||
_log("DEBUG", f"Created movie NFO: {nfo_file}")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed writing {nfo_file}: {e}")
|
||||
|
||||
def set_movie_file_mtime(movie_dir: Path, dateadded_iso: str):
|
||||
"""Set movie file modification time to match dateadded"""
|
||||
if not dateadded_iso:
|
||||
return
|
||||
|
||||
try:
|
||||
ts = datetime.fromisoformat(dateadded_iso.replace("Z", "+00:00")).timestamp()
|
||||
|
||||
# Find video files
|
||||
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
||||
for f in movie_dir.iterdir():
|
||||
if f.is_file() and f.suffix.lower() in video_exts:
|
||||
try:
|
||||
os.utime(f, (ts, ts), follow_symlinks=False)
|
||||
_log("DEBUG", f"Updated movie file mtime: {f} -> {dateadded_iso}")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to update mtime on {f}: {e}")
|
||||
|
||||
# Set directory mtime too
|
||||
try:
|
||||
os.utime(movie_dir, (ts, ts), follow_symlinks=False)
|
||||
_log("DEBUG", f"Updated movie dir mtime: {movie_dir} -> {dateadded_iso}")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to update mtime on {movie_dir}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Bad timestamp for movie mtimes: {dateadded_iso}: {e}")
|
||||
|
||||
def apply_movie(movie_dir: Path, conn: sqlite3.Connection):
|
||||
"""Apply date caching to a movie directory"""
|
||||
imdb_id = parse_imdb_from_movie_path(movie_dir)
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"Movie path does not contain an IMDb id [imdb-tt...]: {movie_dir}")
|
||||
return
|
||||
|
||||
ensure_movie_tables(conn)
|
||||
|
||||
# Store/refresh movie row
|
||||
conn.execute(
|
||||
"INSERT INTO movies(imdb_id, movie_path, last_applied) VALUES(?,?,?) "
|
||||
"ON CONFLICT(imdb_id) DO UPDATE SET movie_path=excluded.movie_path, last_applied=excluded.last_applied",
|
||||
(imdb_id, str(movie_dir), _iso_now()),
|
||||
)
|
||||
|
||||
# Check if movie has video files
|
||||
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
||||
has_video_files = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_dir.iterdir())
|
||||
|
||||
if not has_video_files:
|
||||
_log("WARNING", f"No video files found in movie directory: {movie_dir}")
|
||||
conn.execute(
|
||||
"INSERT INTO movie_dates(imdb_id, released, dateadded, source, has_video_file) VALUES(?,?,?,?,?) "
|
||||
"ON CONFLICT(imdb_id) DO UPDATE SET has_video_file=excluded.has_video_file",
|
||||
(imdb_id, None, None, None, 0),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
|
||||
# For movies, we'll use simpler logic - primarily file mtime as dateadded
|
||||
# Get the newest video file mtime as our dateadded
|
||||
newest_mtime = None
|
||||
for f in movie_dir.iterdir():
|
||||
if f.is_file() and f.suffix.lower() in video_exts:
|
||||
try:
|
||||
mtime = f.stat().st_mtime
|
||||
if newest_mtime is None or mtime > newest_mtime:
|
||||
newest_mtime = mtime
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
dateadded_iso = None
|
||||
if newest_mtime:
|
||||
try:
|
||||
dateadded_iso = datetime.fromtimestamp(newest_mtime, tz=timezone.utc).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
released_iso = None
|
||||
source = "file:mtime"
|
||||
|
||||
# Store in database
|
||||
conn.execute(
|
||||
"INSERT INTO movie_dates(imdb_id, released, dateadded, source, has_video_file) VALUES(?,?,?,?,?) "
|
||||
"ON CONFLICT(imdb_id) DO UPDATE SET released=excluded.released, dateadded=excluded.dateadded, source=excluded.source, has_video_file=excluded.has_video_file",
|
||||
(imdb_id, released_iso, dateadded_iso, source, 1),
|
||||
)
|
||||
|
||||
# Create NFO if requested
|
||||
if config.manage_nfo:
|
||||
write_movie_nfo(movie_dir, imdb_id, released_iso, dateadded_iso, source, config.lock_metadata)
|
||||
|
||||
# Update file times if requested
|
||||
if config.fix_dir_mtimes and dateadded_iso:
|
||||
set_movie_file_mtime(movie_dir, dateadded_iso)
|
||||
|
||||
conn.commit()
|
||||
_log("INFO", f"Processed movie: {movie_dir.name}")
|
||||
|
||||
# Thread-safe batching mechanism (updated for both TV and movies)
|
||||
class WebhookBatcher:
|
||||
def __init__(self):
|
||||
self.pending_items: Dict[str, Dict] = {} # key -> latest webhook data
|
||||
self.timers: Dict[str, threading.Timer] = {} # key -> timer
|
||||
self.processing: Set[str] = set() # Currently processing items
|
||||
self.lock = threading.Lock()
|
||||
self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent_series, thread_name_prefix="media-processor")
|
||||
|
||||
def add_webhook(self, key: str, webhook_data: Dict, media_type: str):
|
||||
"""Add webhook to batch, resetting timer if needed"""
|
||||
with self.lock:
|
||||
# Cancel existing timer if present
|
||||
if key in self.timers:
|
||||
self.timers[key].cancel()
|
||||
_log("DEBUG", f"Cancelled existing timer for {key}")
|
||||
|
||||
# Store/update webhook data
|
||||
webhook_data['media_type'] = media_type # Add media type to data
|
||||
self.pending_items[key] = webhook_data
|
||||
_log("INFO", f"Batched {media_type} webhook for {key} (delay: {config.batch_delay}s)")
|
||||
|
||||
# Create new timer
|
||||
timer = threading.Timer(config.batch_delay, self._process_item, args=[key])
|
||||
self.timers[key] = timer
|
||||
timer.start()
|
||||
|
||||
def _process_item(self, key: str):
|
||||
"""Process an item after batch delay"""
|
||||
with self.lock:
|
||||
if key in self.processing or key not in self.pending_items:
|
||||
return
|
||||
self.processing.add(key)
|
||||
webhook_data = self.pending_items.pop(key)
|
||||
self.timers.pop(key, None)
|
||||
|
||||
try:
|
||||
media_type = webhook_data.get('media_type', 'unknown')
|
||||
_log("INFO", f"Processing batched {media_type}: {key}")
|
||||
self.executor.submit(self._process_item_sync, key, webhook_data)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error submitting {media_type} processing for {key}: {e}")
|
||||
with self.lock:
|
||||
self.processing.discard(key)
|
||||
|
||||
def _process_item_sync(self, key: str, webhook_data: Dict):
|
||||
"""Synchronous item processing with dedicated DB connection"""
|
||||
try:
|
||||
media_type = webhook_data.get('media_type', 'unknown')
|
||||
if media_type == 'tv':
|
||||
self._process_tv_sync(key, webhook_data)
|
||||
elif media_type == 'movie':
|
||||
self._process_movie_sync(key, webhook_data)
|
||||
else:
|
||||
_log("ERROR", f"Unknown media type: {media_type}")
|
||||
finally:
|
||||
with self.lock:
|
||||
self.processing.discard(key)
|
||||
|
||||
def _process_tv_sync(self, imdb_id: str, webhook_data: Dict):
|
||||
"""Process TV series"""
|
||||
try:
|
||||
series_info = webhook_data.get('series', {})
|
||||
series_title = series_info.get("title", "Unknown")
|
||||
series_path_str = webhook_data.get('series_path')
|
||||
|
||||
if not series_path_str:
|
||||
_log("ERROR", f"No series path found for {imdb_id}")
|
||||
return
|
||||
|
||||
series_path = Path(series_path_str)
|
||||
if not series_path.exists():
|
||||
_log("ERROR", f"Series path does not exist: {series_path}")
|
||||
return
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = db_connect(db_path)
|
||||
conn.execute("PRAGMA busy_timeout = 30000")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
|
||||
class MockArgs:
|
||||
def __init__(self):
|
||||
self.manage_nfo = config.manage_nfo
|
||||
self.fix_dir_mtimes = config.fix_dir_mtimes
|
||||
self.lock_metadata = config.lock_metadata
|
||||
self.use_cache = True
|
||||
self.force_refresh = False
|
||||
self.skip_if_current = False
|
||||
self.debug = config.debug
|
||||
self.update_video_mtimes = True
|
||||
|
||||
_log("INFO", f"Processing TV series: {series_title} at {series_path}")
|
||||
apply_series(series_path, MockArgs(), conn)
|
||||
_log("INFO", f"Successfully processed batched TV series: {series_title}")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing TV series {series_title}: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error in TV processing: {e}")
|
||||
|
||||
def _process_movie_sync(self, imdb_id: str, webhook_data: Dict):
|
||||
"""Process movie"""
|
||||
try:
|
||||
movie_info = webhook_data.get('movie', {})
|
||||
movie_title = movie_info.get("title", "Unknown")
|
||||
movie_path_str = webhook_data.get('movie_path')
|
||||
|
||||
if not movie_path_str:
|
||||
_log("ERROR", f"No movie path found for {imdb_id}")
|
||||
return
|
||||
|
||||
movie_path = Path(movie_path_str)
|
||||
if not movie_path.exists():
|
||||
_log("ERROR", f"Movie path does not exist: {movie_path}")
|
||||
return
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = db_connect(db_path)
|
||||
conn.execute("PRAGMA busy_timeout = 30000")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
|
||||
_log("INFO", f"Processing movie: {movie_title} at {movie_path}")
|
||||
apply_movie(movie_path, conn)
|
||||
_log("INFO", f"Successfully processed batched movie: {movie_title}")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing movie {movie_title}: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error in movie processing: {e}")
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""Get current batching status"""
|
||||
with self.lock:
|
||||
return {
|
||||
"pending_items": list(self.pending_items.keys()),
|
||||
"processing_items": list(self.processing),
|
||||
"pending_count": len(self.pending_items),
|
||||
"processing_count": len(self.processing),
|
||||
"executor_active_threads": getattr(self.executor, '_threads', 0)
|
||||
}
|
||||
|
||||
# Global batcher instance
|
||||
batcher = WebhookBatcher()
|
||||
|
||||
def find_series_path_from_sonarr_path(sonarr_path: str) -> Optional[Path]:
|
||||
"""Convert Sonarr's full path to container path"""
|
||||
if not sonarr_path:
|
||||
return None
|
||||
|
||||
if sonarr_path.startswith("/mnt/unionfs/Media/TV/"):
|
||||
container_path = sonarr_path.replace("/mnt/unionfs/Media/TV/", "/media/")
|
||||
path = Path(container_path)
|
||||
if path.exists():
|
||||
_log("INFO", f"Found series path via Sonarr path: {path}")
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def find_movie_path_from_radarr_path(radarr_path: str) -> Optional[Path]:
|
||||
"""Convert Radarr's full path (folderPath or path) to container path"""
|
||||
if not radarr_path:
|
||||
return None
|
||||
if radarr_path.startswith("/mnt/unionfs/Media/Movies/"):
|
||||
container_path = radarr_path.replace("/mnt/unionfs/Media/Movies/", "/media/")
|
||||
path = Path(container_path)
|
||||
if path.exists():
|
||||
_log("INFO", f"Found movie path via Radarr path: {path}")
|
||||
return path
|
||||
return None
|
||||
|
||||
def find_series_path(series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]:
|
||||
"""Find the series directory based on title, IMDb ID, and optional Sonarr path"""
|
||||
|
||||
if sonarr_path:
|
||||
series_path = find_series_path_from_sonarr_path(sonarr_path)
|
||||
if series_path:
|
||||
return series_path
|
||||
|
||||
if imdb_id and not imdb_id.startswith("tt"):
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
|
||||
_log("DEBUG", f"Searching for series: {series_title} with IMDb ID: {imdb_id}")
|
||||
|
||||
for media_path in config.tv_paths:
|
||||
if not media_path.exists():
|
||||
_log("WARNING", f"TV path does not exist: {media_path}")
|
||||
continue
|
||||
|
||||
if imdb_id:
|
||||
pattern = str(media_path / f"*[imdb-{imdb_id}]*")
|
||||
matches = glob.glob(pattern)
|
||||
if matches:
|
||||
found_path = Path(matches[0])
|
||||
_log("INFO", f"Found series directory by IMDb ID: {found_path}")
|
||||
return found_path
|
||||
|
||||
if series_title:
|
||||
series_title_clean = series_title.lower().replace(" ", "").replace("-", "").replace(":", "")
|
||||
for item in media_path.iterdir():
|
||||
if item.is_dir() and "[imdb-" in item.name.lower():
|
||||
item_clean = item.name.lower().replace(" ", "").replace("-", "").replace(":", "")
|
||||
if series_title_clean in item_clean:
|
||||
_log("INFO", f"Found series directory by title: {item}")
|
||||
return item
|
||||
|
||||
_log("WARNING", f"Could not find directory for series: {series_title} ({imdb_id})")
|
||||
return None
|
||||
|
||||
def find_movie_path(movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]:
|
||||
"""Find the movie directory based on title, IMDb ID, and optional Radarr path"""
|
||||
|
||||
if radarr_path:
|
||||
movie_path = find_movie_path_from_radarr_path(radarr_path)
|
||||
if movie_path:
|
||||
return movie_path
|
||||
|
||||
if imdb_id and not imdb_id.startswith("tt"):
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
|
||||
_log("DEBUG", f"Searching for movie: {movie_title} with IMDb ID: {imdb_id}")
|
||||
|
||||
for media_path in config.movie_paths:
|
||||
if not media_path.exists():
|
||||
_log("WARNING", f"Movie path does not exist: {media_path}")
|
||||
continue
|
||||
|
||||
if imdb_id:
|
||||
pattern = str(media_path / f"*[imdb-{imdb_id}]*")
|
||||
matches = glob.glob(pattern)
|
||||
if matches:
|
||||
found_path = Path(matches[0])
|
||||
_log("INFO", f"Found movie directory by IMDb ID: {found_path}")
|
||||
return found_path
|
||||
|
||||
if movie_title:
|
||||
movie_title_clean = movie_title.lower().replace(" ", "").replace("-", "").replace(":", "")
|
||||
for item in media_path.iterdir():
|
||||
if item.is_dir() and "[imdb-" in item.name.lower():
|
||||
item_clean = item.name.lower().replace(" ", "").replace("-", "").replace(":", "")
|
||||
if movie_title_clean in item_clean:
|
||||
_log("INFO", f"Found movie directory by title: {item}")
|
||||
return item
|
||||
|
||||
_log("WARNING", f"Could not find directory for movie: {movie_title} ({imdb_id})")
|
||||
return None
|
||||
|
||||
async def process_sonarr_import(webhook_data: SonarrWebhook):
|
||||
"""Process a Sonarr import webhook by adding to batch"""
|
||||
try:
|
||||
if not webhook_data.series:
|
||||
_log("WARNING", f"No series data in {webhook_data.eventType} webhook")
|
||||
return
|
||||
|
||||
series_info = webhook_data.series
|
||||
series_title = series_info.get("title", "Unknown")
|
||||
imdb_id = series_info.get("imdbId", "")
|
||||
sonarr_path = series_info.get("path", "")
|
||||
|
||||
if imdb_id:
|
||||
imdb_id = imdb_id.replace("tt", "").strip()
|
||||
if imdb_id:
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found for series: {series_title}")
|
||||
return
|
||||
|
||||
_log("INFO", f"Received webhook for series: {series_title} (IMDb: {imdb_id})")
|
||||
|
||||
series_path = find_series_path(series_title, imdb_id, sonarr_path)
|
||||
if not series_path:
|
||||
_log("ERROR", f"Could not find series directory for: {series_title} ({imdb_id})")
|
||||
return
|
||||
|
||||
webhook_dict = {
|
||||
'series': series_info,
|
||||
'series_path': str(series_path),
|
||||
'event_type': webhook_data.eventType
|
||||
}
|
||||
|
||||
batcher.add_webhook(imdb_id, webhook_dict, 'tv')
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing Sonarr webhook: {e}")
|
||||
raise
|
||||
|
||||
async def process_radarr_import(webhook_data: RadarrWebhook):
|
||||
"""Process a Radarr import webhook by adding to batch"""
|
||||
try:
|
||||
if not webhook_data.movie:
|
||||
_log("WARNING", f"No movie data in {webhook_data.eventType} webhook")
|
||||
return
|
||||
|
||||
movie_info = webhook_data.movie
|
||||
movie_title = movie_info.get("title", "Unknown")
|
||||
|
||||
# Prefer folderPath for Rename payloads; fall back to path
|
||||
radarr_path = movie_info.get("folderPath") or movie_info.get("path", "")
|
||||
|
||||
# imdbId may be missing on Rename; fall back to remoteMovie or parse from folder
|
||||
imdb_id = (movie_info.get("imdbId") or (webhook_data.remoteMovie or {}).get("imdbId") or "").strip()
|
||||
if imdb_id:
|
||||
imdb_id = f"tt{imdb_id.replace('tt','')}"
|
||||
|
||||
movie_path = find_movie_path(movie_title, imdb_id, radarr_path)
|
||||
if not imdb_id and movie_path:
|
||||
# Try to parse from the resolved folder name
|
||||
parsed = parse_imdb_from_movie_path(movie_path)
|
||||
if parsed:
|
||||
imdb_id = parsed
|
||||
|
||||
if not imdb_id:
|
||||
_log("WARNING", f"No IMDb ID available for movie '{movie_title}'. Proceeding by path only.")
|
||||
|
||||
_log("INFO", f"Received webhook for movie: {movie_title} (IMDb: {imdb_id or 'unknown'})")
|
||||
if getattr(webhook_data, "renamedMovieFiles", None):
|
||||
_log("INFO", f"Renamed {len(webhook_data.renamedMovieFiles)} file(s) for {movie_title}")
|
||||
|
||||
if not movie_path:
|
||||
_log("ERROR", f"Could not find movie directory for: {movie_title} ({imdb_id or 'unknown'})")
|
||||
return
|
||||
|
||||
webhook_dict = {
|
||||
'movie': movie_info,
|
||||
'movie_path': str(movie_path),
|
||||
'event_type': webhook_data.eventType
|
||||
}
|
||||
|
||||
batcher.add_webhook(imdb_id or str(movie_path), webhook_dict, 'movie')
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing Radarr webhook: {e}")
|
||||
raise
|
||||
|
||||
@app.post("/webhook/sonarr")
|
||||
async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Handle Sonarr webhooks"""
|
||||
try:
|
||||
webhook_data = await request.json()
|
||||
_log("DEBUG", f"Raw Sonarr webhook payload: {webhook_data}")
|
||||
|
||||
webhook = SonarrWebhook(**webhook_data)
|
||||
_log("INFO", f"Received Sonarr webhook: {webhook.eventType}")
|
||||
|
||||
if webhook.eventType not in ["Download", "Upgrade", "Rename"]:
|
||||
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
|
||||
|
||||
await process_sonarr_import(webhook)
|
||||
return {"status": "accepted", "message": "Sonarr webhook queued for batched processing"}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to parse Sonarr webhook: {e}")
|
||||
raise HTTPException(status_code=422, detail=f"Invalid Sonarr webhook format: {e}")
|
||||
|
||||
@app.post("/webhook/radarr")
|
||||
async def radarr_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Handle Radarr webhooks"""
|
||||
try:
|
||||
webhook_data = await request.json()
|
||||
_log("DEBUG", f"Raw Radarr webhook payload: {webhook_data}")
|
||||
|
||||
webhook = RadarrWebhook(**webhook_data)
|
||||
_log("INFO", f"Received Radarr webhook: {webhook.eventType}")
|
||||
|
||||
# Allow common movie-file events; Rename payload has folderPath/renamedMovieFiles
|
||||
if webhook.eventType not in ["Download", "Upgrade", "Rename", "MovieFileDelete", "MovieDelete"]:
|
||||
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
|
||||
|
||||
await process_radarr_import(webhook)
|
||||
return {"status": "accepted", "message": "Radarr webhook queued for batched processing"}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to parse Radarr webhook: {e}")
|
||||
raise HTTPException(status_code=422, detail=f"Invalid Radarr webhook format: {e}")
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> HealthResponse:
|
||||
"""Health check endpoint"""
|
||||
uptime = datetime.now(timezone.utc) - start_time
|
||||
|
||||
# Check database connectivity
|
||||
try:
|
||||
conn = db_connect(db_path)
|
||||
conn.execute("PRAGMA busy_timeout = 5000")
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
conn.close()
|
||||
db_status = "healthy"
|
||||
except Exception as e:
|
||||
db_status = f"error: {e}"
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy" if db_status == "healthy" else "degraded",
|
||||
version="1.0.0",
|
||||
uptime=str(uptime),
|
||||
database_status=db_status
|
||||
)
|
||||
|
||||
@app.get("/stats")
|
||||
async def get_stats():
|
||||
"""Get database statistics"""
|
||||
try:
|
||||
conn = db_connect(db_path)
|
||||
conn.execute("PRAGMA busy_timeout = 5000")
|
||||
|
||||
# Ensure movie tables exist
|
||||
ensure_movie_tables(conn)
|
||||
|
||||
# Get TV series count
|
||||
tv_series_count = conn.execute("SELECT COUNT(*) FROM series").fetchone()[0]
|
||||
|
||||
# Get TV episode count
|
||||
tv_episode_count = conn.execute("SELECT COUNT(*) FROM episode_dates").fetchone()[0]
|
||||
|
||||
# Get TV episodes with video files
|
||||
tv_with_files = conn.execute("SELECT COUNT(*) FROM episode_dates WHERE has_video_file = 1").fetchone()[0]
|
||||
|
||||
# Get movie count
|
||||
movie_count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0]
|
||||
|
||||
# Get movies with video files
|
||||
movies_with_files = conn.execute("SELECT COUNT(*) FROM movie_dates WHERE has_video_file = 1").fetchone()[0]
|
||||
|
||||
# Get TV source breakdown
|
||||
tv_sources = conn.execute("""
|
||||
SELECT source, COUNT(*) as count
|
||||
FROM episode_dates
|
||||
WHERE has_video_file = 1
|
||||
GROUP BY source
|
||||
ORDER BY count DESC
|
||||
""").fetchall()
|
||||
|
||||
# Get movie source breakdown
|
||||
movie_sources = conn.execute("""
|
||||
SELECT source, COUNT(*) as count
|
||||
FROM movie_dates
|
||||
WHERE has_video_file = 1
|
||||
GROUP BY source
|
||||
ORDER BY count DESC
|
||||
""").fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"tv_series_count": tv_series_count,
|
||||
"tv_episode_count": tv_episode_count,
|
||||
"tv_episodes_with_files": tv_with_files,
|
||||
"movie_count": movie_count,
|
||||
"movies_with_files": movies_with_files,
|
||||
"tv_sources": dict(tv_sources),
|
||||
"movie_sources": dict(movie_sources)
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/batch/status")
|
||||
async def get_batch_status():
|
||||
"""Get current batching status"""
|
||||
return batcher.get_status()
|
||||
|
||||
def iterate_movie_paths():
|
||||
"""Find all movie directories in configured paths"""
|
||||
movies = []
|
||||
for movie_path in config.movie_paths:
|
||||
if not movie_path.exists():
|
||||
continue
|
||||
for item in movie_path.iterdir():
|
||||
if is_movie_dir(item):
|
||||
movies.append(item)
|
||||
return sorted(movies)
|
||||
|
||||
def run_manual_scan_sync(scan_paths: List[Path], scan_type: str = "both"):
|
||||
"""Synchronous function for manual scan - runs in executor"""
|
||||
from media_date_cache import iterate_target_path
|
||||
|
||||
class MockArgs:
|
||||
def __init__(self):
|
||||
self.manage_nfo = config.manage_nfo
|
||||
self.fix_dir_mtimes = config.fix_dir_mtimes
|
||||
self.lock_metadata = config.lock_metadata
|
||||
self.use_cache = True
|
||||
self.force_refresh = False
|
||||
self.skip_if_current = True # Skip current files in manual scan
|
||||
self.debug = config.debug
|
||||
self.update_video_mtimes = True
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = db_connect(db_path)
|
||||
conn.execute("PRAGMA busy_timeout = 30000")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
ensure_movie_tables(conn)
|
||||
|
||||
for scan_path in scan_paths:
|
||||
_log("INFO", f"Starting manual scan of: {scan_path}")
|
||||
|
||||
if scan_type in ["both", "tv"]:
|
||||
# Scan TV series
|
||||
tv_targets = iterate_target_path(scan_path)
|
||||
for series_dir in tv_targets:
|
||||
try:
|
||||
apply_series(series_dir, MockArgs(), conn)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed processing TV series {series_dir}: {e}")
|
||||
|
||||
if scan_type in ["both", "movies"]:
|
||||
# Scan movies
|
||||
if scan_path in config.movie_paths:
|
||||
for item in scan_path.iterdir():
|
||||
if is_movie_dir(item):
|
||||
try:
|
||||
apply_movie(item, conn)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed processing movie {item}: {e}")
|
||||
|
||||
_log("INFO", f"Completed manual scan of: {scan_path}")
|
||||
finally:
|
||||
if conn:
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
@app.post("/manual/scan")
|
||||
async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"):
|
||||
"""Trigger a manual library scan"""
|
||||
if path:
|
||||
scan_paths = [Path(path)]
|
||||
else:
|
||||
# Scan both TV and movie paths
|
||||
scan_paths = list(config.tv_paths) + list(config.movie_paths)
|
||||
|
||||
# Validate scan_type
|
||||
if scan_type not in ["both", "tv", "movies"]:
|
||||
raise HTTPException(status_code=400, detail="scan_type must be 'both', 'tv', or 'movies'")
|
||||
|
||||
# Run in executor
|
||||
async def run_scan():
|
||||
await asyncio.get_event_loop().run_in_executor(None, run_manual_scan_sync, scan_paths, scan_type)
|
||||
|
||||
background_tasks.add_task(run_scan)
|
||||
|
||||
return {"status": "started", "message": f"Manual {scan_type} scan started for {len(scan_paths)} paths"}
|
||||
|
||||
@app.post("/manual/scan/tv")
|
||||
async def manual_scan_tv(background_tasks: BackgroundTasks, path: Optional[str] = None):
|
||||
"""Trigger a manual TV library scan"""
|
||||
return await manual_scan(background_tasks, path, "tv")
|
||||
|
||||
@app.post("/manual/scan/movies")
|
||||
async def manual_scan_movies(background_tasks: BackgroundTasks, path: Optional[str] = None):
|
||||
"""Trigger a manual movie library scan"""
|
||||
return await manual_scan(background_tasks, path, "movies")
|
||||
|
||||
@app.get("/test/path/{media_title}")
|
||||
async def test_path_matching(media_title: str, imdb_id: Optional[str] = None,
|
||||
media_type: str = "tv", app_path: Optional[str] = None):
|
||||
"""Test endpoint to debug path matching"""
|
||||
if media_type == "tv":
|
||||
result = find_series_path(media_title, imdb_id or "", app_path or "")
|
||||
paths = config.tv_paths
|
||||
elif media_type == "movie":
|
||||
result = find_movie_path(media_title, imdb_id or "", app_path or "")
|
||||
paths = config.movie_paths
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="media_type must be 'tv' or 'movie'")
|
||||
|
||||
return {
|
||||
"media_title": media_title,
|
||||
"media_type": media_type,
|
||||
"imdb_id": imdb_id,
|
||||
"app_path": app_path,
|
||||
"search_paths": [str(p) for p in paths],
|
||||
"found_path": str(result) if result else None,
|
||||
"exists": result.exists() if result else False
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Enable debug logging if requested
|
||||
if config.debug:
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
os.environ["DEBUG"] = "true"
|
||||
|
||||
_log("INFO", "Starting Media Date Cache Webhook Server with TV and Movie support")
|
||||
_log("INFO", f"TV paths: {[str(p) for p in config.tv_paths]}")
|
||||
_log("INFO", f"Movie paths: {[str(p) for p in config.movie_paths]}")
|
||||
_log("INFO", f"Database path: {db_path}")
|
||||
_log("INFO", f"Manage NFO: {config.manage_nfo}")
|
||||
_log("INFO", f"Fix dir mtimes: {config.fix_dir_mtimes}")
|
||||
_log("INFO", f"Lock metadata: {config.lock_metadata}")
|
||||
_log("INFO", f"Batch delay: {config.batch_delay}s")
|
||||
_log("INFO", f"Max concurrent items: {config.max_concurrent_series}")
|
||||
|
||||
uvicorn.run(
|
||||
"webhook_server:app",
|
||||
host="0.0.0.0",
|
||||
port=int(os.environ.get("PORT", "8080")),
|
||||
reload=False
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user