diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2a158a1 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4eafb14 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..38c6aae --- /dev/null +++ b/config.json.example @@ -0,0 +1,8 @@ +{ + "tvdb_api_key": "", + "default_episode_duration": 45, + "classification_thresholds": { + "size_threshold_ratio": 0.3, + "duration_threshold_ratio": 0.4 + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ac62948 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/episode_matcher.py b/episode_matcher.py index 3a458e1..caf68dc 100755 --- a/episode_matcher.py +++ b/episode_matcher.py @@ -24,17 +24,38 @@ import os from pathlib import Path try: - from TVDBProvider import TVDBClient - from TVDBProvider.tvdb_client import MockTVDBClient - from Matcher import FileClassifier, EpisodeRenamer - from config import config_manager + 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: - # Fallback for running without pip install -e . - sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - from TVDBProvider import TVDBClient - from TVDBProvider.tvdb_client import MockTVDBClient - from Matcher import FileClassifier, EpisodeRenamer - from config import config_manager + # 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 + + _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): diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..f777e78 --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } + } +} diff --git a/setup_config.py b/setup_config.py index 59671e7..423b7e8 100755 --- a/setup_config.py +++ b/setup_config.py @@ -9,10 +9,16 @@ import os from pathlib import Path try: - from config import config_manager + from src.config import config_manager except ImportError: - sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - 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():