Merge pull request 'Fix security and ops issues (#2-#8)' (#24) from fix/issue-security-ops into main

Reviewed-on: https://git.home.ms/jarianc/EpisodeMatcher/pulls/24
This commit is contained in:
jarianc 2026-07-05 06:52:29 -05:00
commit 5aa37556d7
14 changed files with 360 additions and 34 deletions

18
.dockerignore Normal file
View File

@ -0,0 +1,18 @@
.git
__pycache__
*.pyc
*.py[cod]
*.so
*.egg-info/
dist/
build/
.venv/
config.json
.env
src/.tvdb_cache/
*.mkv
*.mp4
*.avi
*.webm
delete me/
extras/

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# Episode Matcher Environment Variables
# Copy this file to .env and fill in your values
# TVDB API key (get one at https://thetvdb.com/api-information)
TVDB_API_KEY=

9
.gitignore vendored
View File

@ -1,4 +1,11 @@
*.pyc *.pyc
*.mkv *.mkv
__pycache__/
*.py[cod]
*.so
*.egg-info/
dist/
build/
.config.json
.env
src/.tvdb_cache/* src/.tvdb_cache/*

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN pip install --no-cache-dir -e .
RUN useradd -m -u 1000 appuser
RUN chown -R appuser:appuser /app
USER appuser
ENTRYPOINT ["python", "episode_matcher.py"]

View File

@ -1,8 +1,8 @@
{ {
"tvdb_api_key": "0a8eff11-dbaf-4057-b005-4d3aef15c3bf", "tvdb_api_key": "",
"default_episode_duration": 45, "default_episode_duration": 45,
"classification_thresholds": { "classification_thresholds": {
"size_threshold_ratio": 0.3, "size_threshold_ratio": 0.3,
"duration_threshold_ratio": 0.4 "duration_threshold_ratio": 0.4
} }
} }

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
version: "3.8"
services:
nginx:
image: nginx:alpine
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- jellyfin
restart: unless-stopped
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
volumes:
- /path/to/media:/media
- /path/to/config:/config
environment:
- TZ=America/New_York
restart: unless-stopped
user: 1000:1000
episode-matcher:
build: .
container_name: episode-matcher
volumes:
- /path/to/media:/media
- ./config.json:/app/config.json:ro
environment:
- TVDB_API_KEY=${TVDB_API_KEY}
restart: "no"

View File

@ -23,13 +23,39 @@ import sys
import os import os
from pathlib import Path from pathlib import Path
# Add src to path so we can import our modules try:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) from src.TVDBProvider import TVDBClient
from src.TVDBProvider.tvdb_client import MockTVDBClient
from src.Matcher import FileClassifier, EpisodeRenamer
from src.config import config_manager
except ImportError:
# Development fallback: running without pip install -e .
import importlib
_src = os.path.join(os.path.dirname(__file__), 'src')
_spec_tvdb = importlib.util.spec_from_file_location(
"TVDBProvider", os.path.join(_src, "TVDBProvider", "__init__.py"))
_tvdb_mod = importlib.util.module_from_spec(_spec_tvdb)
_spec_tvdb.loader.exec_module(_tvdb_mod)
TVDBClient = _tvdb_mod.TVDBClient
from TVDBProvider import TVDBClient _spec_client = importlib.util.spec_from_file_location(
from TVDBProvider.tvdb_client import MockTVDBClient "TVDBProvider.tvdb_client", os.path.join(_src, "TVDBProvider", "tvdb_client.py"))
from Matcher import FileClassifier, EpisodeRenamer _client_mod = importlib.util.module_from_spec(_spec_client)
from config import config_manager _spec_client.loader.exec_module(_client_mod)
MockTVDBClient = _client_mod.MockTVDBClient
_spec_matcher = importlib.util.spec_from_file_location(
"Matcher", os.path.join(_src, "Matcher", "__init__.py"))
_matcher_mod = importlib.util.module_from_spec(_spec_matcher)
_spec_matcher.loader.exec_module(_matcher_mod)
FileClassifier = _matcher_mod.FileClassifier
EpisodeRenamer = _matcher_mod.EpisodeRenamer
_spec_config = importlib.util.spec_from_file_location(
"config", os.path.join(_src, "config.py"))
_config_mod = importlib.util.module_from_spec(_spec_config)
_spec_config.loader.exec_module(_config_mod)
config_manager = _config_mod.config_manager
def parse_disc_mapping(mapping_str): def parse_disc_mapping(mapping_str):
@ -119,6 +145,12 @@ def main():
help="Automatically delete duplicate files instead of moving them to 'delete me' folder" help="Automatically delete duplicate files instead of moving them to 'delete me' folder"
) )
parser.add_argument(
"--force",
action="store_true",
help="Confirm destructive operations (required with --auto-delete-duplicates to actually delete files)"
)
args = parser.parse_args() args = parser.parse_args()
# Validate inputs # Validate inputs
@ -221,8 +253,12 @@ def main():
for disc, episodes in disc_mapping.items(): for disc, episodes in disc_mapping.items():
print(f" Disc {disc}: Episodes {episodes}") print(f" Disc {disc}: Episodes {episodes}")
if args.auto_delete_duplicates and not args.force:
print("\n⚠ WARNING: --auto-delete-duplicates requires --force to actually delete files.")
print(" Without --force, duplicates will be moved to 'delete me' folder.\n")
renamer = EpisodeRenamer(str(folder_path), args.show_name, args.season_number, renamer = EpisodeRenamer(str(folder_path), args.show_name, args.season_number,
disc_mapping=disc_mapping, auto_delete_duplicates=args.auto_delete_duplicates) disc_mapping=disc_mapping, auto_delete_duplicates=args.auto_delete_duplicates and args.force)
if args.dry_run: if args.dry_run:
print(f"\n=== DRY RUN - No files will be modified ===") print(f"\n=== DRY RUN - No files will be modified ===")

100
nginx/nginx.conf Normal file
View File

@ -0,0 +1,100 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Issue #21: Hide server version
server_tokens off;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
upstream jellyfin {
server jellyfin:8096;
}
server {
listen 80;
server_name tv.home.ms;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name tv.home.ms;
ssl_certificate /etc/nginx/ssl/tv.home.ms.crt;
ssl_certificate_key /etc/nginx/ssl/tv.home.ms.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Issue #14: Security headers
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; img-src 'self' data: blob:; media-src 'self' data: blob:; frame-src 'self'; connect-src 'self' wss: ws:;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Issue #21: Hide server version (also via more_headers if available)
# Issue #22: Remove response time header
more_clear_headers Server X-Response-Time-Ms X-Powered-By;
location / {
proxy_pass http://jellyfin;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Proxy buffer settings for large video files
proxy_buffering off;
proxy_request_buffering off;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
# WebSockets for Jellyfin
location /socket {
proxy_pass http://jellyfin;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
}

23
pyproject.toml Normal file
View File

@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "episode-matcher"
version = "1.0.0"
description = "Classify, organize, and rename TV show episode files for media servers"
requires-python = ">=3.8"
dependencies = [
"requests>=2.25.1",
"pymediainfo>=5.1.0",
]
[project.scripts]
episode-matcher = "episode_matcher:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["src.*"]
[tool.setuptools.package-data]
"*" = ["*.json"]

View File

@ -8,10 +8,17 @@ import sys
import os import os
from pathlib import Path from pathlib import Path
# Add src to path so we can import our modules try:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) from src.config import config_manager
except ImportError:
from config import config_manager # Development fallback: running without pip install -e .
import importlib
_src = os.path.join(os.path.dirname(__file__), 'src')
_spec = importlib.util.spec_from_file_location(
"config", os.path.join(_src, "config.py"))
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
config_manager = _mod.config_manager
def main(): def main():

View File

@ -85,40 +85,85 @@ class EpisodeRenamer:
return moved_files return moved_files
def _compute_file_hash(self, file_path: Path, num_bytes: int = 65536) -> str:
"""Compute a hash of the first N bytes of a file for duplicate detection."""
import hashlib
h = hashlib.sha256()
try:
with open(file_path, 'rb') as f:
h.update(f.read(num_bytes))
return h.hexdigest()
except Exception:
return ""
def detect_and_move_duplicates(self, episodes: List[Dict]) -> List[str]: def detect_and_move_duplicates(self, episodes: List[Dict]) -> List[str]:
"""Detect duplicate episodes and either delete them or move them to delete me folder.""" """Detect duplicate episodes and either delete them or move them to delete me folder.
Deletion only occurs when auto_delete_duplicates=True (which requires --force flag).
Without --force, duplicates are moved to 'delete me' folder for review.
"""
processed_files = [] processed_files = []
duplicates_found = [] duplicates_found = []
# Group episodes by duration (within 1 minute tolerance) # Group episodes by duration (within 1 minute tolerance)
duration_groups = {} duration_groups = {}
for episode in episodes: for episode in episodes:
duration = episode['duration_minutes'] duration = episode['duration_minutes']
# Find existing group within 1 minute tolerance # Find existing group within 1 minute tolerance
matching_group = None matching_group = None
for group_duration in duration_groups.keys(): for group_duration in duration_groups.keys():
if abs(duration - group_duration) <= 0.5: # Tighter tolerance for exact duplicates if abs(duration - group_duration) <= 0.5: # Tighter tolerance for exact duplicates
matching_group = group_duration matching_group = group_duration
break break
if matching_group: if matching_group:
duration_groups[matching_group].append(episode) duration_groups[matching_group].append(episode)
else: else:
duration_groups[duration] = [episode] duration_groups[duration] = [episode]
# Identify duplicates (groups with more than one episode) # Identify duplicates (groups with more than one episode)
for duration, group_episodes in duration_groups.items(): for duration, group_episodes in duration_groups.items():
if len(group_episodes) > 1: if len(group_episodes) > 1:
print(f"\nFound {len(group_episodes)} potential duplicates with ~{duration:.1f}min duration:") print(f"\nFound {len(group_episodes)} potential duplicates with ~{duration:.1f}min duration:")
# Enhanced duplicate scoring: factor in file size and content hash
def dup_score(ep):
return (
ep.get('disc_number', 999) if 'disc_number' in ep else 999,
-ep['size_gb'],
ep['path'].name
)
# Hash-based disambiguation when duration and size are very close
has_hashes = False
for ep in group_episodes:
ep['_hash'] = self._compute_file_hash(ep['path'])
if ep['_hash']:
has_hashes = True
if has_hashes:
# Group by hash — identical hash = near-certain duplicate
hash_groups = {}
for ep in group_episodes:
hash_groups.setdefault(ep['_hash'], []).append(ep)
for hash_val, hash_eps in hash_groups.items():
if len(hash_eps) > 1:
hash_eps.sort(key=dup_score)
print(f" Keeping: {hash_eps[0]['path'].name} ({hash_eps[0]['size_gb']:.2f}GB) [hash match]")
for dup in hash_eps[1:]:
print(f" Duplicate (hash): {dup['path'].name} ({dup['size_gb']:.2f}GB)")
duplicates_found.append(dup)
elif len(hash_eps) == 1:
# Unique hash — still check against duration group
group_episodes_copy = [e for e in group_episodes if e['_hash'] == hash_val]
if len(group_episodes_copy) == 1:
continue
# Sort by disc number (keep lower disc numbers) then file size (largest first) for consistent ordering # Sort by disc number (keep lower disc numbers) then file size (largest first) for consistent ordering
group_episodes.sort(key=lambda x: ( group_episodes.sort(key=dup_score)
x.get('disc_number', 999) if 'disc_number' in x else 999, # Lower disc numbers first
-x['size_gb'], # Then largest files
x['path'].name # Finally by filename for consistency
))
# Keep the first file (from earliest disc, largest size), mark others as duplicates # Keep the first file (from earliest disc, largest size), mark others as duplicates
keeper = group_episodes[0] keeper = group_episodes[0]
duplicates = group_episodes[1:] duplicates = group_episodes[1:]
@ -144,17 +189,35 @@ class EpisodeRenamer:
# Handle duplicates based on auto_delete_duplicates flag # Handle duplicates based on auto_delete_duplicates flag
if self.auto_delete_duplicates: if self.auto_delete_duplicates:
# Delete duplicates directly import datetime
manifest_path = self.folder_path / "deletion_manifest.json"
manifest_entries = []
for duplicate in duplicates_found: for duplicate in duplicates_found:
source_path = duplicate['path'] source_path = duplicate['path']
try: try:
source_path.unlink() # Delete the file manifest_entries.append({
'filename': source_path.name,
'path': str(source_path),
'size_gb': duplicate['size_gb'],
'duration_minutes': duplicate['duration_minutes'],
'deleted_at': datetime.datetime.now().isoformat()
})
source_path.unlink()
processed_files.append(str(source_path)) processed_files.append(str(source_path))
print(f"Deleted duplicate: {source_path.name}") print(f"Deleted duplicate: {source_path.name}")
except Exception as e: except Exception as e:
print(f"Error deleting {source_path.name}: {e}") print(f"Error deleting {source_path.name}: {e}")
try:
import json as _json
with open(manifest_path, 'w') as f:
_json.dump(manifest_entries, f, indent=2)
print(f"\nDeletion manifest written to: {manifest_path}")
except Exception as e:
print(f"Warning: Could not write deletion manifest: {e}")
else: else:
# Move duplicates to delete me folder (original behavior) # Move duplicates to delete me folder (original behavior)
if not self._create_delete_folder(): if not self._create_delete_folder():

View File

@ -20,15 +20,28 @@ class TVDBCache:
self.cache_dir.mkdir(exist_ok=True) self.cache_dir.mkdir(exist_ok=True)
self.cache_duration = timedelta(days=7) # Cache for 7 days self.cache_duration = timedelta(days=7) # Cache for 7 days
def _sanitize_name(self, name: str) -> str:
"""Sanitize series name to prevent path traversal attacks."""
import re
sanitized = name.lower()
sanitized = re.sub(r'[^a-z0-9_\-\s]', '', sanitized)
sanitized = sanitized.replace(' ', '_').replace('-', '_')
if '..' in sanitized or '/' in sanitized or '\\' in sanitized:
raise ValueError(f"Invalid series name containing path traversal: {name!r}")
return sanitized
def _get_cache_key(self, series_name: str, season_number: int) -> str: def _get_cache_key(self, series_name: str, season_number: int) -> str:
"""Generate cache key for series/season combination.""" """Generate cache key for series/season combination."""
# Normalize series name for consistent caching sanitized_name = self._sanitize_name(series_name)
normalized_name = series_name.lower().replace(' ', '_').replace('-', '_') return f"{sanitized_name}_s{season_number:02d}"
return f"{normalized_name}_s{season_number:02d}"
def _get_cache_file(self, cache_key: str) -> Path: def _get_cache_file(self, cache_key: str) -> Path:
"""Get cache file path for given key.""" """Get cache file path for given key, validated to stay within cache_dir."""
return self.cache_dir / f"{cache_key}.json" cache_file = self.cache_dir / f"{cache_key}.json"
resolved = cache_file.resolve()
if not str(resolved).startswith(str(self.cache_dir.resolve())):
raise ValueError(f"Cache path escape detected: {cache_key!r}")
return cache_file
def _is_cache_valid(self, cache_file: Path) -> bool: def _is_cache_valid(self, cache_file: Path) -> bool:
"""Check if cache file exists and is not expired.""" """Check if cache file exists and is not expired."""

1
src/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Episode Matcher source packages."""

View File

@ -55,7 +55,10 @@ class ConfigManager:
return default_config return default_config
def get_tvdb_api_key(self) -> Optional[str]: def get_tvdb_api_key(self) -> Optional[str]:
"""Get TVDB API key from config.""" """Get TVDB API key from environment variable or config file."""
env_key = os.environ.get("TVDB_API_KEY", "").strip()
if env_key and env_key != "YOUR_TVDB_API_KEY_HERE":
return env_key
api_key = self.config.get("tvdb_api_key", "").strip() api_key = self.config.get("tvdb_api_key", "").strip()
if not api_key or api_key == "YOUR_TVDB_API_KEY_HERE": if not api_key or api_key == "YOUR_TVDB_API_KEY_HERE":
return None return None