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:
commit
5aa37556d7
18
.dockerignore
Normal file
18
.dockerignore
Normal 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
5
.env.example
Normal 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
9
.gitignore
vendored
@ -1,4 +1,11 @@
|
||||
*.pyc
|
||||
*.mkv
|
||||
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.config.json
|
||||
.env
|
||||
src/.tvdb_cache/*
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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"]
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"tvdb_api_key": "0a8eff11-dbaf-4057-b005-4d3aef15c3bf",
|
||||
"tvdb_api_key": "",
|
||||
"default_episode_duration": 45,
|
||||
"classification_thresholds": {
|
||||
"size_threshold_ratio": 0.3,
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal 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"
|
||||
@ -23,13 +23,39 @@ import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path so we can import our modules
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
try:
|
||||
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
|
||||
from TVDBProvider.tvdb_client import MockTVDBClient
|
||||
from Matcher import FileClassifier, EpisodeRenamer
|
||||
from config import config_manager
|
||||
_spec_client = importlib.util.spec_from_file_location(
|
||||
"TVDBProvider.tvdb_client", os.path.join(_src, "TVDBProvider", "tvdb_client.py"))
|
||||
_client_mod = importlib.util.module_from_spec(_spec_client)
|
||||
_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):
|
||||
@ -119,6 +145,12 @@ def main():
|
||||
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()
|
||||
|
||||
# Validate inputs
|
||||
@ -221,8 +253,12 @@ def main():
|
||||
for disc, episodes in disc_mapping.items():
|
||||
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,
|
||||
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:
|
||||
print(f"\n=== DRY RUN - No files will be modified ===")
|
||||
|
||||
100
nginx/nginx.conf
Normal file
100
nginx/nginx.conf
Normal 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
23
pyproject.toml
Normal 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"]
|
||||
@ -8,10 +8,17 @@ import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path so we can import our modules
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
from config import config_manager
|
||||
try:
|
||||
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 = 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():
|
||||
|
||||
@ -85,8 +85,23 @@ class EpisodeRenamer:
|
||||
|
||||
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]:
|
||||
"""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 = []
|
||||
duplicates_found = []
|
||||
|
||||
@ -112,12 +127,42 @@ class EpisodeRenamer:
|
||||
if len(group_episodes) > 1:
|
||||
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
|
||||
group_episodes.sort(key=lambda x: (
|
||||
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
|
||||
))
|
||||
group_episodes.sort(key=dup_score)
|
||||
|
||||
# Keep the first file (from earliest disc, largest size), mark others as duplicates
|
||||
keeper = group_episodes[0]
|
||||
@ -144,17 +189,35 @@ class EpisodeRenamer:
|
||||
|
||||
# Handle duplicates based on auto_delete_duplicates flag
|
||||
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:
|
||||
source_path = duplicate['path']
|
||||
|
||||
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))
|
||||
print(f"Deleted duplicate: {source_path.name}")
|
||||
|
||||
except Exception as 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:
|
||||
# Move duplicates to delete me folder (original behavior)
|
||||
if not self._create_delete_folder():
|
||||
|
||||
@ -20,15 +20,28 @@ class TVDBCache:
|
||||
self.cache_dir.mkdir(exist_ok=True)
|
||||
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:
|
||||
"""Generate cache key for series/season combination."""
|
||||
# Normalize series name for consistent caching
|
||||
normalized_name = series_name.lower().replace(' ', '_').replace('-', '_')
|
||||
return f"{normalized_name}_s{season_number:02d}"
|
||||
sanitized_name = self._sanitize_name(series_name)
|
||||
return f"{sanitized_name}_s{season_number:02d}"
|
||||
|
||||
def _get_cache_file(self, cache_key: str) -> Path:
|
||||
"""Get cache file path for given key."""
|
||||
return self.cache_dir / f"{cache_key}.json"
|
||||
"""Get cache file path for given key, validated to stay within cache_dir."""
|
||||
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:
|
||||
"""Check if cache file exists and is not expired."""
|
||||
|
||||
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Episode Matcher source packages."""
|
||||
@ -55,7 +55,10 @@ class ConfigManager:
|
||||
return default_config
|
||||
|
||||
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()
|
||||
if not api_key or api_key == "YOUR_TVDB_API_KEY_HERE":
|
||||
return None
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user