Compare commits
162 Commits
ca796bdb58
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c85df54d5a | |||
| 3ccf062267 | |||
| 76fd839fa8 | |||
| a5dd635cc4 | |||
| 79f292ff8e | |||
| 2f0537ef1d | |||
| d501c4364c | |||
| a326825347 | |||
| c0ed1d9630 | |||
| 1f20b4397d | |||
| b0b2e87447 | |||
| 7932b9cfba | |||
| c9b099a218 | |||
| d83ba054b3 | |||
| 3e3957234d | |||
| 1e292434c0 | |||
| b48f1220e2 | |||
| dc28508bd2 | |||
| db203b5f26 | |||
| c7d12dadaf | |||
| 2d5688d313 | |||
| d078decbb4 | |||
| 788b7e79f6 | |||
| 04b2afdb19 | |||
| 65896f1974 | |||
| 01887f082a | |||
| 73967da3e6 | |||
| e397f0639b | |||
| 2feac00b4c | |||
| 099ec12560 | |||
| c91c33e9b2 | |||
| de2203a53d | |||
| 5305e327b3 | |||
| 942b9f7f63 | |||
| c2c720cd8b | |||
| d414da5bf3 | |||
| 97a9f3431a | |||
| 041caf0d0d | |||
| 7fffaac247 | |||
| 6e491fb091 | |||
| 4b71b0e7ee | |||
| 7bab45a9aa | |||
| 7f0ad27009 | |||
| 614dab632a | |||
| 43449ca7a3 | |||
| db0a43dd18 | |||
| 6711e926a6 | |||
| f11839e005 | |||
| 6d2435a037 | |||
| b1598114d9 | |||
| b709a377c8 | |||
| babe5ac0df | |||
| a4c07eaa19 | |||
| a1a04b2711 | |||
| 1dbad2f50b | |||
| afdbe1f755 | |||
| 1916dc2646 | |||
| 36ab22ee3d | |||
| c6a8cce509 | |||
| 2eda166328 | |||
| f31430256e | |||
| ed3d7e903c | |||
| 0b9f450b1d | |||
| 10a9677804 | |||
| d6488a99bd | |||
| d1480e1b96 | |||
| c6b6b0ef64 | |||
| 9fbb9e37ab | |||
| d933ac7585 | |||
| d50ac88237 | |||
| 9c8da4d63b | |||
| 14ae2a205c | |||
| a68f7ee4bb | |||
| 63ab35b24c | |||
| 6b9e0b43f8 | |||
| d4f4bcf722 | |||
| 4cb9250c3a | |||
| 24f05079f3 | |||
| 483bcc9d25 | |||
| 0b27fc1c65 | |||
| 07cdbf1a29 | |||
| eba70ec045 | |||
| f38ca539d6 | |||
| 254c9c9c5f | |||
| 319838d305 | |||
| 4d7d9752fc | |||
| 5d3754b401 | |||
| ee403f9ac7 | |||
| 4516a80208 | |||
| eb66af837f | |||
| 1d837102e9 | |||
| cb46b56243 | |||
| 2a972f0e14 | |||
| 18d1b45609 | |||
| f46f1df7cb | |||
| cf6ee5d6d8 | |||
| ef899a05c7 | |||
| 8c850ab38c | |||
| 059d3bae3f | |||
| 98da1a07f0 | |||
| cb62ddb187 | |||
| 4713bf5ed1 | |||
| 228053a573 | |||
| 6d64812c94 | |||
| b3adf45f7b | |||
| 0666a4d9a9 | |||
| 2f4d4638ef | |||
| ff24e0c431 | |||
| def8d293b9 | |||
| 952319b061 | |||
| 4c4a805c5d | |||
| 2811d30352 | |||
| 47311864cf | |||
| ab48d485b8 | |||
| 1314ee3e70 | |||
| 7b62d73753 | |||
| e054c945ff | |||
| f6c5ccd2b6 | |||
| 7fb07fcddf | |||
| 6946ce2082 | |||
| 017681cd55 | |||
| a37d552303 | |||
| 864a82cbc5 | |||
| 68baa1766c | |||
| 413c8ad6f6 | |||
| 1ceccfe31d | |||
| ec36ce7b0e | |||
| 2583a668bc | |||
| 6d9e057a91 | |||
| 18ae174276 | |||
| 4752b424f5 | |||
| cebc31e24c | |||
| 5c0ba04832 | |||
| 4ba8db48f7 | |||
| f67663a338 | |||
| 543c5f44e7 | |||
| fc950007cc | |||
| 59dbf93f70 | |||
| 337bb4e049 | |||
| 4a2d410921 | |||
| 43f4851f40 | |||
| df15ce141f | |||
| ac30a91879 | |||
| 835d313b6c | |||
| 24dce6c970 | |||
| e4ef60847a | |||
| 3ac5aa3121 | |||
| 66a80992b5 | |||
| 78813b9189 | |||
| c61c35ebad | |||
| 80a09cf66e | |||
| df17305160 | |||
| b55b786787 | |||
| 470d77c28f | |||
| 916f237e68 | |||
| c1ab3cd874 | |||
| 379f7a6e44 | |||
| 50fe5b272f | |||
| 46465d1749 | |||
| ea69ad9202 | |||
| decad07a0d | |||
| c84d08b266 |
+50
-14
@@ -20,6 +20,23 @@ SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6
|
||||
# Download detection paths (for identifying downloads vs existing files)
|
||||
DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
|
||||
|
||||
# ===========================================
|
||||
# SERVER CONFIGURATION
|
||||
# ===========================================
|
||||
# Timezone for container logging and timestamps
|
||||
TZ=America/New_York
|
||||
|
||||
# NFOGuard Core API (webhooks, processing, database management)
|
||||
CORE_API_HOST=0.0.0.0
|
||||
CORE_API_PORT=8080
|
||||
|
||||
# NFOGuard Web Interface (dashboard, series/movie management)
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8081
|
||||
|
||||
# External port where web interface is accessible (for dynamic port reference)
|
||||
WEB_EXTERNAL_PORT=8081
|
||||
|
||||
# ===========================================
|
||||
# DATABASE CONFIGURATION
|
||||
# ===========================================
|
||||
@@ -34,6 +51,7 @@ DB_TYPE=postgresql
|
||||
DB_HOST=nfoguard-db # Container name from docker-compose
|
||||
DB_PORT=5432
|
||||
DB_NAME=nfoguard
|
||||
DB_USER=nfoguard
|
||||
|
||||
# Legacy SQLite Configuration (Deprecated in v2.6+)
|
||||
# Only use for existing SQLite installations
|
||||
@@ -52,6 +70,21 @@ RADARR_DB_PORT=5432
|
||||
RADARR_DB_NAME=radarr-main
|
||||
RADARR_DB_USER=postgres
|
||||
|
||||
# ===========================================
|
||||
# SONARR DATABASE CONNECTION (RECOMMENDED)
|
||||
# ===========================================
|
||||
# Direct database access for better performance
|
||||
# Phase 7: High-performance TV episode import date detection
|
||||
SONARR_DB_TYPE=postgresql
|
||||
SONARR_DB_HOST=192.168.255.50
|
||||
SONARR_DB_PORT=5432
|
||||
SONARR_DB_NAME=sonarr-main
|
||||
SONARR_DB_USER=postgres
|
||||
|
||||
# Alternative: SQLite (if Sonarr uses SQLite)
|
||||
# SONARR_DB_TYPE=sqlite
|
||||
# SONARR_DB_PATH=/path/to/sonarr.db
|
||||
|
||||
# ===========================================
|
||||
# API CONNECTIONS (OPTIONAL)
|
||||
# ===========================================
|
||||
@@ -79,18 +112,22 @@ ALLOW_FILE_DATE_FALLBACK=false
|
||||
TMDB_COUNTRY=US
|
||||
|
||||
# ===========================================
|
||||
# NFO FILE MANAGEMENT
|
||||
# NFO FILE MANAGEMENT (DEPRECATED - Phase 1 Migration)
|
||||
# ===========================================
|
||||
# Create/update .nfo files
|
||||
MANAGE_NFO=true
|
||||
# NFO file operations have been removed in favor of database-only architecture
|
||||
# These settings are preserved for backward compatibility but no longer have effect
|
||||
# The PostgreSQL database is now the single source of truth for all metadata
|
||||
|
||||
# Update file modification times to match import dates
|
||||
FIX_DIR_MTIMES=true
|
||||
# Create/update .nfo files (DEPRECATED - no longer used)
|
||||
MANAGE_NFO=false
|
||||
|
||||
# Add lockdata tags to prevent metadata overwrites
|
||||
LOCK_METADATA=true
|
||||
# Update file modification times to match import dates (DEPRECATED - no longer used)
|
||||
FIX_DIR_MTIMES=false
|
||||
|
||||
# Brand name in NFO comments
|
||||
# Add lockdata tags to prevent metadata overwrites (DEPRECATED - no longer used)
|
||||
LOCK_METADATA=false
|
||||
|
||||
# Brand name in NFO comments (DEPRECATED - no longer used)
|
||||
MANAGER_BRAND=NFOGuard
|
||||
|
||||
# ===========================================
|
||||
@@ -120,6 +157,11 @@ BATCH_DELAY=5.0
|
||||
# Maximum concurrent series processing
|
||||
MAX_CONCURRENT_SERIES=3
|
||||
|
||||
# Sequential processing delay for bulk downloads (seconds)
|
||||
# When multiple episodes are downloaded at once, process them one by one with this delay
|
||||
# Set to 0 to disable sequential processing (parallel mode)
|
||||
SEQUENTIAL_DELAY=20.0
|
||||
|
||||
# API timeout in seconds
|
||||
TIMEOUT_SECONDS=45
|
||||
|
||||
@@ -143,9 +185,3 @@ WEB_AUTH_ENABLED=false
|
||||
|
||||
# Session timeout in seconds (default: 3600 = 1 hour)
|
||||
WEB_AUTH_SESSION_TIMEOUT=3600
|
||||
|
||||
# ===========================================
|
||||
# SERVER CONFIGURATION
|
||||
# ===========================================
|
||||
# Port for webhook server
|
||||
PORT=8080
|
||||
@@ -11,6 +11,10 @@ jobs:
|
||||
runs-on: host
|
||||
|
||||
steps:
|
||||
- name: Pre-cleanup
|
||||
run: |
|
||||
docker system prune -f
|
||||
docker builder prune -f --filter until=24h
|
||||
- name: Checkout code (DEV)
|
||||
run: |
|
||||
echo "Current workspace: $(pwd)"
|
||||
|
||||
@@ -11,6 +11,15 @@ jobs:
|
||||
runs-on: host
|
||||
|
||||
steps:
|
||||
- name: Pre-cleanup Docker space
|
||||
run: |
|
||||
echo "🧹 Cleaning up Docker space before build..."
|
||||
docker system prune -f
|
||||
docker builder prune -f --filter until=24h
|
||||
docker image prune -af --filter until=48h
|
||||
echo "📊 Docker space after cleanup:"
|
||||
docker system df
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "Current workspace: $(pwd)"
|
||||
@@ -203,6 +212,15 @@ jobs:
|
||||
echo ""
|
||||
echo "🐳 Pull with: docker pull 192.168.253.221:3000/sbcrumb/nfoguard:${VERSION}-gitea"
|
||||
|
||||
- name: Post-cleanup Docker space
|
||||
if: always() # Run even if build fails
|
||||
run: |
|
||||
echo "🧹 Final cleanup to prevent disk space issues..."
|
||||
docker system prune -f
|
||||
docker builder prune -af --filter until=24h
|
||||
echo "📊 Final Docker space usage:"
|
||||
docker system df
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: host
|
||||
|
||||
@@ -32,6 +32,9 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Copy web starter file to root directory
|
||||
COPY start_web.py .
|
||||
|
||||
# Create git metadata for version detection based on build arg
|
||||
RUN mkdir -p .git && \
|
||||
echo "ref: refs/heads/${GIT_BRANCH}" > .git/HEAD
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# Maintainarr Webhook Integration
|
||||
|
||||
This document describes how to configure NFOGuard to receive webhooks from Maintainarr for automatic database cleanup when media is removed.
|
||||
|
||||
## Overview
|
||||
|
||||
When Maintainarr removes media from your Plex/Radarr/Sonarr collections, NFOGuard can automatically remove the corresponding entries from its database to keep it clean and up-to-date.
|
||||
|
||||
## Webhook Configuration
|
||||
|
||||
### 1. NFOGuard Endpoint
|
||||
|
||||
The webhook endpoint is available at:
|
||||
```
|
||||
http://YOUR_NFOGUARD_HOST:8080/webhook/maintainarr
|
||||
```
|
||||
|
||||
**Important**: Use the core NFOGuard container port (typically 8080), not the web interface port (8081).
|
||||
|
||||
### 2. Maintainarr Configuration
|
||||
|
||||
In Maintainarr, create a new webhook notification agent with these settings:
|
||||
|
||||
#### Basic Settings
|
||||
- **Name**: `NFOGuard Cleanup`
|
||||
- **Enabled**: ✅ Checked
|
||||
- **Agent**: `Webhook`
|
||||
|
||||
#### Webhook Configuration
|
||||
- **Webhook URL**: `http://YOUR_NFOGUARD_HOST:8080/webhook/maintainarr`
|
||||
- **JSON Payload**: Use the template below
|
||||
- **Auth Header**: *(Leave empty - no authentication required)*
|
||||
|
||||
#### JSON Payload Template
|
||||
Copy and paste this JSON template into the "Json Payload" field:
|
||||
|
||||
```json
|
||||
{
|
||||
"notification_type": "{{notification_type}}",
|
||||
"subject": "{{subject}}",
|
||||
"message": "{{message}}",
|
||||
"extra": "{{extra}}"
|
||||
}
|
||||
```
|
||||
|
||||
**Important Note**: Maintainarr's template variables may not include IMDb IDs directly. The webhook will attempt to extract IMDb IDs from the notification content, but this may require manual configuration or rule setup in Maintainarr to include IMDb IDs in the notification text.
|
||||
|
||||
**Alternative Approach**: If Maintainarr doesn't provide IMDb IDs in notifications, you may need to use NFOGuard's manual cleanup tools or configure Maintainarr rules to include IMDb information in the message content.
|
||||
|
||||
#### Event Types
|
||||
Select these notification types:
|
||||
- ✅ **Media Removed From Collection** - Removes media from NFOGuard database
|
||||
- ✅ **Media About To Be Handled** - Optional: Log when media is about to be processed
|
||||
|
||||
Optional event types (will be logged but not processed):
|
||||
- ☐ Media Added To Collection
|
||||
- ☐ Media Handled
|
||||
- ☐ Rule Handling Failed
|
||||
- ☐ Collection Handling Failed
|
||||
|
||||
## Supported Operations
|
||||
|
||||
### Movies
|
||||
When a movie is removed from Maintainarr:
|
||||
- NFOGuard checks if the movie exists in its database (by IMDb ID)
|
||||
- If found, removes the movie record from the `movies` table
|
||||
- Logs the deletion operation
|
||||
|
||||
### TV Series
|
||||
When a TV series is removed from Maintainarr:
|
||||
- NFOGuard checks if the series exists in its database (by IMDb ID)
|
||||
- If found, removes all episode records from the `episodes` table
|
||||
- Removes the series record from the `series` table
|
||||
- Logs the deletion operation with episode count
|
||||
|
||||
## Webhook Payload
|
||||
|
||||
Maintainarr sends webhook payloads using template variables that you configure:
|
||||
|
||||
```json
|
||||
{
|
||||
"notification_type": "Media Removed",
|
||||
"subject": "Example Movie (2023)",
|
||||
"message": "Removed movie Example Movie from collection Action Movies - IMDb: tt1234567",
|
||||
"extra": "tt1234567"
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
1. **Maintainarr** populates the template variables ({{notification_type}}, {{subject}}, {{message}}, {{extra}})
|
||||
2. **NFOGuard** receives the webhook and parses the content to extract:
|
||||
- **IMDb ID**: Extracted from `message`, `subject`, or `extra` fields using pattern matching
|
||||
- **Media Type**: Determined from message content keywords or database lookup
|
||||
- **Title**: Extracted from `subject` or `message` fields
|
||||
|
||||
### Media Identification
|
||||
NFOGuard looks for IMDb IDs in this format:
|
||||
- `tt1234567` (preferred)
|
||||
- `1234567` (will be converted to tt1234567)
|
||||
|
||||
The webhook handler uses intelligent parsing to:
|
||||
- Extract IMDb IDs from any field using regex patterns
|
||||
- Determine if media is a Movie or Series based on keywords or database lookup
|
||||
- Extract the media title from subject or message content
|
||||
|
||||
## Response Format
|
||||
|
||||
NFOGuard responds with JSON indicating the result:
|
||||
|
||||
### Success Response
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Processed Media Removed for Example Movie",
|
||||
"media_type": "Movie",
|
||||
"imdb_id": "tt1234567",
|
||||
"removed_count": 1,
|
||||
"removed_items": ["Movie: Example Movie (tt1234567)"]
|
||||
}
|
||||
```
|
||||
|
||||
### Ignored Response
|
||||
```json
|
||||
{
|
||||
"status": "ignored",
|
||||
"reason": "Media tt1234567 not found in database"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "No IMDb ID found in webhook payload"
|
||||
}
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
All webhook activities are logged with details:
|
||||
|
||||
```
|
||||
INFO: Received Maintainarr webhook: Media Removed
|
||||
INFO: Processing movie deletion for Example Movie (tt1234567)
|
||||
SUCCESS: Removed movie Example Movie (tt1234567) from database
|
||||
INFO: Maintainarr cleanup: Media Removed - Movie 'Example Movie' (tt1234567). Removed from database: Movie: Example Movie (tt1234567)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **No IMDb ID Found**:
|
||||
- Maintainarr template variables may not include IMDb IDs
|
||||
- Check if the notification message contains IMDb information
|
||||
- You may need to manually include IMDb IDs in Maintainarr rule configurations
|
||||
|
||||
2. **Media Not Found**:
|
||||
- Check if the media exists in NFOGuard's database
|
||||
- Verify the IMDb ID matches between Maintainarr and NFOGuard
|
||||
|
||||
3. **Connection Issues**:
|
||||
- Ensure NFOGuard core container is accessible on port 8080
|
||||
- Check firewall settings and network connectivity
|
||||
|
||||
4. **Authentication Errors**:
|
||||
- No authentication is required for the webhook endpoint
|
||||
- Ensure you're using the core container port, not web interface port
|
||||
|
||||
5. **Test Notifications**:
|
||||
- Test notifications (like the one you just sent) will be acknowledged but not processed
|
||||
- Real media removal events will trigger the cleanup process
|
||||
|
||||
### Testing the Webhook
|
||||
|
||||
You can test the webhook manually using curl:
|
||||
|
||||
```bash
|
||||
curl -X POST http://YOUR_NFOGUARD_HOST:8080/webhook/maintainarr \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"notification_type": "Media Removed",
|
||||
"subject": "Test Movie (2023)",
|
||||
"message": "Removed movie Test Movie from collection - IMDb: tt1234567",
|
||||
"extra": "tt1234567"
|
||||
}'
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- The webhook endpoint does not require authentication
|
||||
- Consider using firewalls or network restrictions to limit access
|
||||
- The endpoint only processes deletion requests, not additions
|
||||
- All operations are logged for audit purposes
|
||||
|
||||
## Integration Benefits
|
||||
|
||||
- **Automatic Cleanup**: Keeps NFOGuard database synchronized with your media collection
|
||||
- **Accurate Statistics**: Dashboard stats reflect only currently available media
|
||||
- **Reduced Manual Maintenance**: No need to manually clean up orphaned entries
|
||||
- **Audit Trail**: All deletions are logged with full details
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
- NFOGuard: v2.8.0+
|
||||
- Maintainarr: All versions with webhook support
|
||||
- Requires NFOGuard core container (processing container), not web-only container
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
- ./.env:/app/.env:ro # Main configuration
|
||||
- ./.env.secrets:/app/.env.secrets:ro # Secrets
|
||||
environment:
|
||||
- PORT=8080
|
||||
- CORE_API_PORT=8080
|
||||
depends_on:
|
||||
- radarr-postgres
|
||||
```
|
||||
|
||||
+4
-2
@@ -72,10 +72,10 @@ class AuthSession:
|
||||
class SimpleAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Simple authentication middleware for web interface routes"""
|
||||
|
||||
def __init__(self, app, config):
|
||||
def __init__(self, app, config, session_manager=None):
|
||||
super().__init__(app)
|
||||
self.config = config
|
||||
self.session_manager = AuthSession(config.web_auth_session_timeout)
|
||||
self.session_manager = session_manager or AuthSession(config.web_auth_session_timeout)
|
||||
self.security = HTTPBasic()
|
||||
|
||||
# Routes that require authentication (web interface)
|
||||
@@ -92,6 +92,8 @@ class SimpleAuthMiddleware(BaseHTTPMiddleware):
|
||||
self.public_routes = [
|
||||
"/webhook/",
|
||||
"/health",
|
||||
"/logo/", # Logo files should always be accessible
|
||||
"/favicon.ico", # Favicon should always be accessible
|
||||
"/ping",
|
||||
"/api/v1/health",
|
||||
"/api/v1/metrics",
|
||||
|
||||
@@ -31,6 +31,18 @@ class RadarrWebhook(BaseModel):
|
||||
extra = "allow"
|
||||
|
||||
|
||||
class MaintainarrWebhook(BaseModel):
|
||||
"""Maintainarr webhook payload model - uses template variables"""
|
||||
notification_type: Optional[str] = None # e.g., "Media Removed"
|
||||
subject: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
extra: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response model"""
|
||||
status: str
|
||||
@@ -108,3 +120,65 @@ class EpisodeResponse(BaseModel):
|
||||
series_path: str
|
||||
season_name: str
|
||||
episode_name: str
|
||||
|
||||
|
||||
# Scheduled Scans Models
|
||||
|
||||
class CreateScheduledScanRequest(BaseModel):
|
||||
"""Request model for creating a scheduled scan"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
cron_expression: str
|
||||
media_type: str # 'tv', 'movies', 'both'
|
||||
scan_mode: str # 'smart', 'full', 'incomplete'
|
||||
specific_paths: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class UpdateScheduledScanRequest(BaseModel):
|
||||
"""Request model for updating a scheduled scan"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
cron_expression: Optional[str] = None
|
||||
media_type: Optional[str] = None
|
||||
scan_mode: Optional[str] = None
|
||||
specific_paths: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class ScheduledScanResponse(BaseModel):
|
||||
"""Response model for scheduled scan data"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
cron_expression: str
|
||||
media_type: str
|
||||
scan_mode: str
|
||||
specific_paths: Optional[str]
|
||||
enabled: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_run_at: Optional[str]
|
||||
next_run_at: Optional[str]
|
||||
run_count: int
|
||||
created_by: Optional[str]
|
||||
updated_by: Optional[str]
|
||||
|
||||
|
||||
class ScheduleExecutionResponse(BaseModel):
|
||||
"""Response model for schedule execution data"""
|
||||
id: int
|
||||
schedule_id: int
|
||||
schedule_name: str
|
||||
started_at: str
|
||||
completed_at: Optional[str]
|
||||
status: str
|
||||
media_type: str
|
||||
scan_mode: str
|
||||
items_processed: int
|
||||
items_skipped: int
|
||||
items_failed: int
|
||||
execution_time_seconds: Optional[int]
|
||||
error_message: Optional[str]
|
||||
logs: Optional[str]
|
||||
triggered_by: Optional[str]
|
||||
+1831
-161
File diff suppressed because it is too large
Load Diff
+1488
-47
File diff suppressed because it is too large
Load Diff
@@ -165,6 +165,53 @@ class RadarrDbClient:
|
||||
|
||||
return None
|
||||
|
||||
def get_all_movies(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all movies from Radarr database (only movies with files)
|
||||
|
||||
Returns:
|
||||
List of dictionaries with movie info
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
m."Id" as id,
|
||||
m."Path" as path,
|
||||
m."Added" as added,
|
||||
mm."ImdbId" as imdb_id,
|
||||
mm."Title" as title,
|
||||
mm."Year" as year,
|
||||
mm."DigitalRelease" as digital_release,
|
||||
mm."PhysicalRelease" as physical_release,
|
||||
mm."InCinemas" as in_cinemas,
|
||||
(SELECT COUNT(*) FROM "MovieFiles" mf WHERE mf."MovieId" = m."Id") as file_count
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
WHERE (SELECT COUNT(*) FROM "MovieFiles" mf WHERE mf."MovieId" = m."Id") > 0
|
||||
ORDER BY mm."Title"
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
# SQLite uses ? placeholders but this query has none
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if rows:
|
||||
return [dict(row) if self.db_type == "sqlite" else row for row in rows]
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error getting all movies: {e}")
|
||||
return []
|
||||
|
||||
def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get earliest import date from History table, accounting for upgrade scenarios
|
||||
|
||||
@@ -150,6 +150,10 @@ class SonarrClient:
|
||||
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
|
||||
return None
|
||||
|
||||
def get_series_by_id(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get series information by Sonarr series ID"""
|
||||
return self._get(f"/series/{series_id}")
|
||||
|
||||
def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all episodes for a series"""
|
||||
return self._get("/episode", {"seriesId": series_id}) or []
|
||||
@@ -222,7 +226,7 @@ class SonarrClient:
|
||||
import_date = earliest_import["date"]
|
||||
_log("INFO", f"Found import date: {import_date} for episode {episode_id}")
|
||||
|
||||
# Check if this looks like an upgrade by comparing to renames
|
||||
# Check chronological order of events
|
||||
if rename_events:
|
||||
earliest_rename = min(rename_events, key=lambda x: x["date"])
|
||||
rename_date = earliest_rename["date"]
|
||||
@@ -230,12 +234,16 @@ class SonarrClient:
|
||||
try:
|
||||
import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00"))
|
||||
rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00"))
|
||||
days_diff = (import_dt - rename_dt).days
|
||||
|
||||
# If import is significantly after rename, prefer rename date
|
||||
if days_diff > 30:
|
||||
_log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date")
|
||||
return rename_date
|
||||
# If import happened BEFORE rename, it's valid original import
|
||||
if import_dt <= rename_dt:
|
||||
_log("INFO", f"Import {import_date} happened before/during rename {rename_date} - using import date")
|
||||
return import_date
|
||||
|
||||
# If rename happened BEFORE import - always use aired date fallback
|
||||
else:
|
||||
_log("WARNING", f"Rename {rename_date} happened before import {import_date} - using aired date fallback")
|
||||
return None # Trigger aired date fallback
|
||||
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Error comparing dates: {e}")
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Direct Sonarr Database Client for NFOGuard
|
||||
Provides high-performance access to Sonarr's SQLite/PostgreSQL database
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
class SonarrDbClient:
|
||||
"""Direct database client for Sonarr's SQLite or PostgreSQL database"""
|
||||
|
||||
def __init__(self,
|
||||
db_type: str = "sqlite",
|
||||
db_path: Optional[str] = None,
|
||||
db_host: Optional[str] = None,
|
||||
db_port: Optional[int] = None,
|
||||
db_name: Optional[str] = None,
|
||||
db_user: Optional[str] = None,
|
||||
db_password: Optional[str] = None):
|
||||
"""
|
||||
Initialize Sonarr database client
|
||||
|
||||
Args:
|
||||
db_type: "sqlite" or "postgresql"
|
||||
db_path: Path to SQLite database file
|
||||
db_host: PostgreSQL host
|
||||
db_port: PostgreSQL port
|
||||
db_name: PostgreSQL database name
|
||||
db_user: PostgreSQL username
|
||||
db_password: PostgreSQL password
|
||||
"""
|
||||
self.db_type = db_type.lower()
|
||||
self.db_path = db_path
|
||||
self.db_host = db_host
|
||||
self.db_port = db_port or 5432
|
||||
self.db_name = db_name
|
||||
self.db_user = db_user
|
||||
self.db_password = db_password
|
||||
|
||||
self._test_connection()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Optional['SonarrDbClient']:
|
||||
"""Create client from environment variables"""
|
||||
db_type = os.environ.get("SONARR_DB_TYPE", "").lower()
|
||||
|
||||
if not db_type:
|
||||
return None
|
||||
|
||||
if db_type == "sqlite":
|
||||
db_path = os.environ.get("SONARR_DB_PATH")
|
||||
if not db_path or not Path(db_path).exists():
|
||||
_log("WARNING", f"SONARR_DB_PATH not found or invalid: {db_path}")
|
||||
return None
|
||||
return cls(db_type="sqlite", db_path=db_path)
|
||||
|
||||
elif db_type == "postgresql":
|
||||
# Support both individual vars and connection string
|
||||
db_url = os.environ.get("SONARR_DB_URL")
|
||||
if db_url:
|
||||
parsed = urlparse(db_url)
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=parsed.hostname,
|
||||
db_port=parsed.port or 5432,
|
||||
db_name=parsed.path.lstrip('/'),
|
||||
db_user=parsed.username,
|
||||
db_password=parsed.password
|
||||
)
|
||||
else:
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=os.environ.get("SONARR_DB_HOST"),
|
||||
db_port=int(os.environ.get("SONARR_DB_PORT", "5432")),
|
||||
db_name=os.environ.get("SONARR_DB_NAME"),
|
||||
db_user=os.environ.get("SONARR_DB_USER"),
|
||||
db_password=os.environ.get("SONARR_DB_PASSWORD")
|
||||
)
|
||||
else:
|
||||
_log("ERROR", f"Unsupported database type: {db_type}")
|
||||
return None
|
||||
|
||||
def _test_connection(self) -> None:
|
||||
"""Test database connection on initialization"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
if conn:
|
||||
conn.close()
|
||||
_log("INFO", f"Connected to Sonarr {self.db_type} database successfully")
|
||||
else:
|
||||
raise Exception("Failed to create connection")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to connect to Sonarr database: {e}")
|
||||
raise
|
||||
|
||||
def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
|
||||
"""Get database connection"""
|
||||
if self.db_type == "sqlite":
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
elif self.db_type == "postgresql":
|
||||
conn = psycopg2.connect(
|
||||
host=self.db_host,
|
||||
port=self.db_port,
|
||||
database=self.db_name,
|
||||
user=self.db_user,
|
||||
password=self.db_password
|
||||
)
|
||||
return conn
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {self.db_type}")
|
||||
|
||||
def get_series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Find series by IMDb ID using database query
|
||||
|
||||
Returns:
|
||||
Dictionary with series info including id, imdb_id, title, path
|
||||
"""
|
||||
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
"Id" as id,
|
||||
"ImdbId" as imdb_id,
|
||||
"TvdbId" as tvdb_id,
|
||||
"Title" as title,
|
||||
"Path" as path,
|
||||
"Added" as added
|
||||
FROM "Series"
|
||||
WHERE "ImdbId" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (imdb_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return dict(row) if self.db_type == "sqlite" else row
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_all_episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all episodes for a series
|
||||
|
||||
Args:
|
||||
series_id: Sonarr series ID
|
||||
|
||||
Returns:
|
||||
List of episode dictionaries with season, episode, air_date
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
"Id" as id,
|
||||
"SeasonNumber" as season,
|
||||
"EpisodeNumber" as episode,
|
||||
"Title" as title,
|
||||
"AirDate" as air_date,
|
||||
"EpisodeFileId" as episode_file_id
|
||||
FROM "Episodes"
|
||||
WHERE "SeriesId" = %s
|
||||
ORDER BY "SeasonNumber", "EpisodeNumber"
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (series_id,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
return [dict(row) for row in rows]
|
||||
else:
|
||||
return rows
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for series {series_id}: {e}")
|
||||
return []
|
||||
|
||||
def get_episode_import_date(self, episode_id: int) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get earliest import date for an episode from History table
|
||||
|
||||
Args:
|
||||
episode_id: Sonarr episode ID
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# Query for earliest import event (EventType 3)
|
||||
import_query = """
|
||||
SELECT
|
||||
"Date" as event_date,
|
||||
"EventType" as event_type
|
||||
FROM "History"
|
||||
WHERE "EpisodeId" = %s
|
||||
AND "EventType" = 3
|
||||
ORDER BY "Date" ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
import_query = import_query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Try import events first
|
||||
cursor.execute(import_query, (episode_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||
if isinstance(event_date, str):
|
||||
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = event_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
return date_iso, "sonarr:db.history.import"
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for episode {episode_id}: {e}")
|
||||
|
||||
return None, "sonarr:db.no_import_found"
|
||||
|
||||
def get_episode_file_date(self, series_id: int, season: int, episode: int) -> Optional[str]:
|
||||
"""
|
||||
Get episode file DateAdded as fallback
|
||||
|
||||
Args:
|
||||
series_id: Sonarr series ID
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
|
||||
Returns:
|
||||
ISO date string or None
|
||||
"""
|
||||
# First get the episode ID to find the file
|
||||
episode_query = """
|
||||
SELECT "EpisodeFileId"
|
||||
FROM "Episodes"
|
||||
WHERE "SeriesId" = %s
|
||||
AND "SeasonNumber" = %s
|
||||
AND "EpisodeNumber" = %s
|
||||
"""
|
||||
|
||||
file_query = """
|
||||
SELECT "DateAdded"
|
||||
FROM "EpisodeFiles"
|
||||
WHERE "Id" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
episode_query = episode_query.replace("%s", "?")
|
||||
file_query = file_query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get episode file ID
|
||||
cursor.execute(episode_query, (series_id, season, episode))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
file_id = row['EpisodeFileId'] if self.db_type == "postgresql" else row[0]
|
||||
|
||||
if file_id:
|
||||
# Get file date
|
||||
cursor.execute(file_query, (file_id,))
|
||||
file_row = cursor.fetchone()
|
||||
|
||||
if file_row:
|
||||
date_value = file_row['DateAdded'] if self.db_type == "postgresql" else file_row[0]
|
||||
|
||||
if isinstance(date_value, str):
|
||||
dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = date_value.replace(tzinfo=timezone.utc)
|
||||
|
||||
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for episode file: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def bulk_import_dates_for_series(self, series_id: int) -> Dict[Tuple[int, int], Tuple[Optional[str], str]]:
|
||||
"""
|
||||
Get import dates for all episodes in a series in a single query
|
||||
|
||||
Args:
|
||||
series_id: Sonarr series ID
|
||||
|
||||
Returns:
|
||||
Dictionary mapping (season, episode) -> (date_iso, source)
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
e."SeasonNumber" as season,
|
||||
e."EpisodeNumber" as episode,
|
||||
e."Id" as episode_id,
|
||||
MIN(h."Date") as earliest_import
|
||||
FROM "Episodes" e
|
||||
LEFT JOIN "History" h ON e."Id" = h."EpisodeId" AND h."EventType" = 3
|
||||
WHERE e."SeriesId" = %s
|
||||
GROUP BY e."SeasonNumber", e."EpisodeNumber", e."Id"
|
||||
ORDER BY e."SeasonNumber", e."EpisodeNumber"
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
results = {}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (series_id,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
if self.db_type == "postgresql":
|
||||
season, episode, episode_id, earliest_import = row['season'], row['episode'], row['episode_id'], row['earliest_import']
|
||||
else:
|
||||
season, episode, episode_id, earliest_import = row[0], row[1], row[2], row[3]
|
||||
|
||||
if earliest_import:
|
||||
if isinstance(earliest_import, str):
|
||||
dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = earliest_import.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
results[(season, episode)] = (date_iso, "sonarr:db.bulk.import")
|
||||
else:
|
||||
results[(season, episode)] = (None, "sonarr:db.bulk.no_import")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Bulk query error for series {series_id}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def get_database_stats(self) -> Dict[str, Any]:
|
||||
"""Get basic statistics about the Sonarr database"""
|
||||
stats = {}
|
||||
|
||||
queries = {
|
||||
"total_series": 'SELECT COUNT(*) FROM "Series"',
|
||||
"total_episodes": 'SELECT COUNT(*) FROM "Episodes"',
|
||||
"total_episode_files": 'SELECT COUNT(*) FROM "EpisodeFiles"',
|
||||
"total_history_events": 'SELECT COUNT(*) FROM "History"',
|
||||
"import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
|
||||
"grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
|
||||
}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
for stat_name, query in queries.items():
|
||||
cursor.execute(query)
|
||||
result = cursor.fetchone()
|
||||
stats[stat_name] = result[0] if result else 0
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Stats query error: {e}")
|
||||
stats["error"] = str(e)
|
||||
|
||||
return stats
|
||||
|
||||
def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Comprehensive health check for the Sonarr database connection
|
||||
|
||||
Returns:
|
||||
Dictionary with health status, connection info, and basic functionality tests
|
||||
"""
|
||||
health = {
|
||||
"status": "healthy",
|
||||
"database_type": self.db_type,
|
||||
"connection": "ok",
|
||||
"readable": False,
|
||||
"tables_exist": False,
|
||||
"sample_data": False,
|
||||
"issues": [],
|
||||
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Test 1: Basic read
|
||||
try:
|
||||
cursor.execute('SELECT 1')
|
||||
result = cursor.fetchone()
|
||||
if result and result[0] == 1:
|
||||
health["readable"] = True
|
||||
health["connection"] = "readable"
|
||||
else:
|
||||
health["issues"].append("Basic SELECT query failed")
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Read test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 2: Check required tables
|
||||
required_tables = ["Series", "Episodes", "EpisodeFiles", "History"]
|
||||
existing_tables = []
|
||||
|
||||
try:
|
||||
if self.db_type == "postgresql":
|
||||
cursor.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('Series', 'Episodes', 'EpisodeFiles', 'History')
|
||||
""")
|
||||
else: # SQLite
|
||||
cursor.execute("""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name IN ('Series', 'Episodes', 'EpisodeFiles', 'History')
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
existing_tables = [row[0] for row in rows]
|
||||
|
||||
if len(existing_tables) == len(required_tables):
|
||||
health["tables_exist"] = True
|
||||
else:
|
||||
missing = set(required_tables) - set(existing_tables)
|
||||
health["issues"].append(f"Missing tables: {list(missing)}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
health["existing_tables"] = existing_tables
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Table check failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 3: Check for sample data
|
||||
if health["tables_exist"]:
|
||||
try:
|
||||
cursor.execute('SELECT COUNT(*) FROM "Series"')
|
||||
series_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM "Episodes"')
|
||||
episode_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM "History"')
|
||||
history_count = cursor.fetchone()[0]
|
||||
|
||||
if series_count > 0 and episode_count > 0:
|
||||
health["sample_data"] = True
|
||||
health["series_count"] = series_count
|
||||
health["episode_count"] = episode_count
|
||||
health["history_count"] = history_count
|
||||
else:
|
||||
health["issues"].append(f"Low data counts - Series: {series_count}, Episodes: {episode_count}")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Sample data check failed: {e}")
|
||||
|
||||
# Test 4: Test a real query
|
||||
if health["sample_data"]:
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM "Series"
|
||||
WHERE "ImdbId" IS NOT NULL
|
||||
""")
|
||||
imdb_series = cursor.fetchone()[0]
|
||||
health["series_with_imdb"] = imdb_series
|
||||
|
||||
if imdb_series > 0:
|
||||
health["functional"] = True
|
||||
else:
|
||||
health["issues"].append("No series with IMDb IDs found")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Functional test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
except Exception as e:
|
||||
health["status"] = "error"
|
||||
health["connection"] = "failed"
|
||||
health["issues"].append(f"Connection failed: {e}")
|
||||
_log("ERROR", f"Database health check failed: {e}")
|
||||
|
||||
# Overall status
|
||||
if health["issues"]:
|
||||
if health["status"] == "healthy":
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Add connection details (safe info only)
|
||||
health["connection_info"] = {
|
||||
"type": self.db_type,
|
||||
"host": self.db_host if self.db_type == "postgresql" else None,
|
||||
"port": self.db_port if self.db_type == "postgresql" else None,
|
||||
"database": self.db_name if self.db_type == "postgresql" else None,
|
||||
"path": self.db_path if self.db_type == "sqlite" else None
|
||||
}
|
||||
|
||||
return health
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the database client
|
||||
print("Testing SonarrDbClient...")
|
||||
|
||||
# Test with environment variables
|
||||
client = SonarrDbClient.from_env()
|
||||
if client:
|
||||
print("✅ Connected to Sonarr database")
|
||||
|
||||
# Test stats
|
||||
stats = client.get_database_stats()
|
||||
print(f"Database stats: {stats}")
|
||||
|
||||
# Test series lookup
|
||||
test_series = client.get_series_by_imdb("tt1628033") # Top Gear from your data
|
||||
if test_series:
|
||||
print(f"Found test series: {test_series}")
|
||||
|
||||
# Test episodes
|
||||
series_id = test_series['id']
|
||||
episodes = client.get_all_episodes_for_series(series_id)
|
||||
print(f"Found {len(episodes)} episodes")
|
||||
|
||||
# Test bulk import dates
|
||||
if episodes:
|
||||
import_dates = client.bulk_import_dates_for_series(series_id)
|
||||
print(f"Import dates for {len(import_dates)} episodes")
|
||||
else:
|
||||
print("Test series not found")
|
||||
else:
|
||||
print("❌ Could not connect to database - check environment variables")
|
||||
@@ -45,6 +45,9 @@ class NFOGuardConfig:
|
||||
|
||||
def _load_configuration(self) -> None:
|
||||
"""Load all configuration from environment variables"""
|
||||
# Server configuration
|
||||
self._load_server_settings()
|
||||
|
||||
# Core paths - Required
|
||||
self._load_paths()
|
||||
|
||||
@@ -58,6 +61,7 @@ class NFOGuardConfig:
|
||||
# Batching and performance
|
||||
self.batch_delay = self._get_float_env("BATCH_DELAY", 5.0, 0.1, 300.0)
|
||||
self.max_concurrent = self._get_int_env("MAX_CONCURRENT_SERIES", 3, 1, 10)
|
||||
self.sequential_delay = self._get_float_env("SEQUENTIAL_DELAY", 20.0, 0.0, 60.0) # Delay between sequential episodes (default 20s)
|
||||
|
||||
# Database
|
||||
self.db_type = os.environ.get("DB_TYPE", "sqlite").lower()
|
||||
@@ -118,6 +122,16 @@ class NFOGuardConfig:
|
||||
current_value=movie_paths_env
|
||||
)
|
||||
|
||||
def _load_server_settings(self) -> None:
|
||||
"""Load server configuration"""
|
||||
# Core API settings (webhooks, processing, database management)
|
||||
self.core_api_host = os.environ.get("CORE_API_HOST", "0.0.0.0")
|
||||
self.core_api_port = self._get_int_env("CORE_API_PORT", 8080, 1024, 65535)
|
||||
|
||||
# Web API settings (dashboard, web interface) - for reference/connection
|
||||
self.web_api_host = os.environ.get("WEB_API_HOST", "0.0.0.0")
|
||||
self.web_api_port = self._get_int_env("WEB_API_PORT", 8081, 1024, 65535)
|
||||
|
||||
def _load_external_connections(self) -> None:
|
||||
"""Load external API and database connection settings"""
|
||||
# API URLs
|
||||
@@ -159,6 +173,12 @@ class NFOGuardConfig:
|
||||
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
|
||||
self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
|
||||
|
||||
def get_season_dir_name(self, season: int) -> str:
|
||||
"""Get the directory name for a specific season, handling Season 0 as 'Specials'"""
|
||||
if season == 0:
|
||||
return "Specials"
|
||||
return self.tv_season_dir_format.format(season=season)
|
||||
|
||||
def _load_auth_settings(self) -> None:
|
||||
"""Load web interface authentication settings"""
|
||||
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
"""
|
||||
Async NFO Manager for NFOGuard
|
||||
High-performance async NFO file operations with concurrent processing
|
||||
"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from utils.logging import _log
|
||||
from utils.async_file_utils import (
|
||||
async_read_nfo_file,
|
||||
async_write_nfo_file,
|
||||
async_file_exists,
|
||||
async_set_file_mtime,
|
||||
async_batch_nfo_operations,
|
||||
async_concurrent_episode_processing
|
||||
)
|
||||
from utils.nfo_patterns import (
|
||||
create_basic_nfo_structure,
|
||||
extract_imdb_from_nfo_content,
|
||||
extract_dates_from_nfo,
|
||||
extract_imdb_id_from_text
|
||||
)
|
||||
from utils.validation import validate_date_string
|
||||
|
||||
|
||||
class AsyncNFOManager:
|
||||
"""Async NFO file manager with concurrent processing capabilities"""
|
||||
|
||||
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
|
||||
self.manager_brand = manager_brand
|
||||
self.debug = debug
|
||||
|
||||
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||
"""
|
||||
Async extract IMDb ID from directory path or filename
|
||||
|
||||
Args:
|
||||
path: Path to examine
|
||||
|
||||
Returns:
|
||||
IMDb ID if found, None otherwise
|
||||
"""
|
||||
# Use the sync version since it's just string processing
|
||||
return extract_imdb_id_from_text(str(path))
|
||||
|
||||
async def async_parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Async extract IMDb ID from NFO file content
|
||||
|
||||
Args:
|
||||
nfo_path: Path to NFO file
|
||||
|
||||
Returns:
|
||||
IMDb ID if found, None otherwise
|
||||
"""
|
||||
root = await async_read_nfo_file(nfo_path)
|
||||
if root is None:
|
||||
return None
|
||||
|
||||
return extract_imdb_from_nfo_content(root)
|
||||
|
||||
async def async_find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
|
||||
"""
|
||||
Async find IMDb ID from directory name, filenames, or NFO file
|
||||
|
||||
Args:
|
||||
movie_dir: Path to movie directory
|
||||
|
||||
Returns:
|
||||
IMDb ID if found, None otherwise
|
||||
"""
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Async searching for IMDb ID in: {movie_dir.name}")
|
||||
|
||||
# First try directory name
|
||||
imdb_id = await self.async_parse_imdb_from_path(movie_dir)
|
||||
if imdb_id:
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Found IMDb ID in directory name: {imdb_id}")
|
||||
return imdb_id
|
||||
|
||||
# Try all files in the directory concurrently
|
||||
if await async_file_exists(movie_dir):
|
||||
try:
|
||||
from utils.async_file_utils import aiofiles
|
||||
entries = await aiofiles.os.listdir(movie_dir)
|
||||
|
||||
# Create tasks to check all files
|
||||
async def check_file(filename: str) -> Optional[str]:
|
||||
file_path = movie_dir / filename
|
||||
if await aiofiles.os.path.isfile(file_path):
|
||||
return await self.async_parse_imdb_from_path(file_path)
|
||||
return None
|
||||
|
||||
# Check all files concurrently
|
||||
results = await asyncio.gather(
|
||||
*[check_file(filename) for filename in entries],
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Find first valid IMDb ID
|
||||
for result in results:
|
||||
if isinstance(result, str) and result:
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Found IMDb ID in filename: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to scan directory {movie_dir}: {e}")
|
||||
|
||||
# Finally, try NFO file content
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
imdb_id = await self.async_parse_imdb_from_nfo(nfo_path)
|
||||
if imdb_id:
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Found IMDb ID in NFO file: {imdb_id}")
|
||||
return imdb_id
|
||||
|
||||
if self.debug:
|
||||
_log("DEBUG", f"No IMDb ID found for: {movie_dir.name}")
|
||||
return None
|
||||
|
||||
async def async_create_movie_nfo(
|
||||
self,
|
||||
movie_dir: Path,
|
||||
imdb_id: str,
|
||||
dateadded: str,
|
||||
premiered: Optional[str] = None,
|
||||
lock_metadata: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Async create movie NFO file
|
||||
|
||||
Args:
|
||||
movie_dir: Path to movie directory
|
||||
imdb_id: IMDb ID
|
||||
dateadded: Date added
|
||||
premiered: Optional premiere date
|
||||
lock_metadata: Whether to lock metadata
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
|
||||
# Prepare dates
|
||||
dates = {"dateadded": dateadded}
|
||||
if premiered and validate_date_string(premiered):
|
||||
dates["premiered"] = premiered
|
||||
|
||||
# Create NFO structure
|
||||
root = create_basic_nfo_structure(
|
||||
media_type="movie",
|
||||
title=movie_dir.name,
|
||||
imdb_id=imdb_id,
|
||||
dates=dates,
|
||||
additional_fields={"source": self.manager_brand}
|
||||
)
|
||||
|
||||
# Write NFO file asynchronously
|
||||
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
|
||||
|
||||
if success and self.debug:
|
||||
_log("DEBUG", f"Created movie NFO: {nfo_path}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to create movie NFO for {movie_dir}: {e}")
|
||||
return False
|
||||
|
||||
async def async_create_episode_nfo(
|
||||
self,
|
||||
season_dir: Path,
|
||||
season: int,
|
||||
episode: int,
|
||||
aired: Optional[str] = None,
|
||||
dateadded: Optional[str] = None,
|
||||
source: str = "unknown",
|
||||
lock_metadata: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Async create episode NFO file
|
||||
|
||||
Args:
|
||||
season_dir: Path to season directory
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
aired: Optional air date
|
||||
dateadded: Optional date added
|
||||
source: Source of the data
|
||||
lock_metadata: Whether to lock metadata
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
|
||||
nfo_path = season_dir / nfo_filename
|
||||
|
||||
# Prepare dates
|
||||
dates = {}
|
||||
if aired and validate_date_string(aired):
|
||||
dates["aired"] = aired
|
||||
if dateadded and validate_date_string(dateadded):
|
||||
dates["dateadded"] = dateadded
|
||||
|
||||
# Create NFO structure
|
||||
root = create_basic_nfo_structure(
|
||||
media_type="episodedetails",
|
||||
title=f"S{season:02d}E{episode:02d}",
|
||||
dates=dates,
|
||||
additional_fields={
|
||||
"season": str(season),
|
||||
"episode": str(episode),
|
||||
"source": source
|
||||
}
|
||||
)
|
||||
|
||||
# Write NFO file asynchronously
|
||||
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
|
||||
|
||||
if success and self.debug:
|
||||
_log("DEBUG", f"Created episode NFO: {nfo_path}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to create episode NFO S{season:02d}E{episode:02d}: {e}")
|
||||
return False
|
||||
|
||||
async def async_batch_create_episode_nfos(
|
||||
self,
|
||||
episode_data_list: List[Dict[str, Any]],
|
||||
max_concurrent: int = 5
|
||||
) -> List[bool]:
|
||||
"""
|
||||
Batch create multiple episode NFOs concurrently
|
||||
|
||||
Args:
|
||||
episode_data_list: List of episode data dictionaries
|
||||
max_concurrent: Maximum concurrent NFO operations
|
||||
|
||||
Returns:
|
||||
List of success/failure results
|
||||
"""
|
||||
async def _create_episode_nfo(episode_data: Dict[str, Any]) -> bool:
|
||||
return await self.async_create_episode_nfo(
|
||||
season_dir=episode_data.get('season_dir'),
|
||||
season=episode_data.get('season'),
|
||||
episode=episode_data.get('episode'),
|
||||
aired=episode_data.get('aired'),
|
||||
dateadded=episode_data.get('dateadded'),
|
||||
source=episode_data.get('source', 'unknown'),
|
||||
lock_metadata=episode_data.get('lock_metadata', True)
|
||||
)
|
||||
|
||||
return await async_concurrent_episode_processing(
|
||||
episode_data_list,
|
||||
_create_episode_nfo,
|
||||
max_concurrent
|
||||
)
|
||||
|
||||
async def async_set_file_mtime(self, file_path: Path, date_str: str) -> bool:
|
||||
"""
|
||||
Async set file modification time from date string
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
date_str: Date string in ISO format
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not validate_date_string(date_str):
|
||||
_log("WARNING", f"Invalid date format for mtime: {date_str}")
|
||||
return False
|
||||
|
||||
# Parse date string to timestamp
|
||||
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
timestamp = dt.timestamp()
|
||||
|
||||
# Set file mtime asynchronously
|
||||
success = await async_set_file_mtime(file_path, timestamp)
|
||||
|
||||
if success and self.debug:
|
||||
_log("DEBUG", f"Set mtime for {file_path}: {date_str}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to set mtime for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
async def async_batch_set_file_mtimes(
|
||||
self,
|
||||
file_mtime_pairs: List[Tuple[Path, str]],
|
||||
max_concurrent: int = 10
|
||||
) -> List[bool]:
|
||||
"""
|
||||
Batch set file modification times concurrently
|
||||
|
||||
Args:
|
||||
file_mtime_pairs: List of (file_path, date_str) tuples
|
||||
max_concurrent: Maximum concurrent operations
|
||||
|
||||
Returns:
|
||||
List of success/failure results
|
||||
"""
|
||||
async def _set_single_mtime(file_path: Path, date_str: str) -> bool:
|
||||
return await self.async_set_file_mtime(file_path, date_str)
|
||||
|
||||
# Create tasks for all mtime operations
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def _set_mtime_with_semaphore(file_path: Path, date_str: str) -> bool:
|
||||
async with semaphore:
|
||||
return await _set_single_mtime(file_path, date_str)
|
||||
|
||||
tasks = [
|
||||
_set_mtime_with_semaphore(file_path, date_str)
|
||||
for file_path, date_str in file_mtime_pairs
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
return [result if isinstance(result, bool) else False for result in results]
|
||||
|
||||
async def async_validate_nfo_integrity(
|
||||
self,
|
||||
nfo_paths: List[Path],
|
||||
max_concurrent: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Async validate integrity of multiple NFO files
|
||||
|
||||
Args:
|
||||
nfo_paths: List of NFO file paths to validate
|
||||
max_concurrent: Maximum concurrent validations
|
||||
|
||||
Returns:
|
||||
Dictionary with validation results and statistics
|
||||
"""
|
||||
results = {
|
||||
'total_files': len(nfo_paths),
|
||||
'valid_files': 0,
|
||||
'invalid_files': 0,
|
||||
'missing_files': 0,
|
||||
'validation_errors': [],
|
||||
'file_results': {}
|
||||
}
|
||||
|
||||
async def _validate_single_nfo(nfo_path: Path) -> Dict[str, Any]:
|
||||
file_result = {
|
||||
'path': str(nfo_path),
|
||||
'exists': False,
|
||||
'valid_xml': False,
|
||||
'has_imdb_id': False,
|
||||
'has_dates': False,
|
||||
'error': None
|
||||
}
|
||||
|
||||
try:
|
||||
# Check if file exists
|
||||
if not await async_file_exists(nfo_path):
|
||||
file_result['error'] = 'File does not exist'
|
||||
return file_result
|
||||
|
||||
file_result['exists'] = True
|
||||
|
||||
# Try to parse NFO
|
||||
root = await async_read_nfo_file(nfo_path)
|
||||
if root is None:
|
||||
file_result['error'] = 'Failed to parse XML'
|
||||
return file_result
|
||||
|
||||
file_result['valid_xml'] = True
|
||||
|
||||
# Check for IMDb ID
|
||||
imdb_id = extract_imdb_from_nfo_content(root)
|
||||
file_result['has_imdb_id'] = bool(imdb_id)
|
||||
|
||||
# Check for dates
|
||||
dates = extract_dates_from_nfo(root)
|
||||
file_result['has_dates'] = any(dates.values())
|
||||
|
||||
except Exception as e:
|
||||
file_result['error'] = str(e)
|
||||
|
||||
return file_result
|
||||
|
||||
# Validate all files concurrently
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def _validate_with_semaphore(nfo_path: Path) -> Dict[str, Any]:
|
||||
async with semaphore:
|
||||
return await _validate_single_nfo(nfo_path)
|
||||
|
||||
tasks = [_validate_with_semaphore(nfo_path) for nfo_path in nfo_paths]
|
||||
file_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Process results
|
||||
for i, result in enumerate(file_results):
|
||||
if isinstance(result, dict):
|
||||
path_str = str(nfo_paths[i])
|
||||
results['file_results'][path_str] = result
|
||||
|
||||
if not result['exists']:
|
||||
results['missing_files'] += 1
|
||||
elif result['valid_xml']:
|
||||
results['valid_files'] += 1
|
||||
else:
|
||||
results['invalid_files'] += 1
|
||||
|
||||
if result.get('error'):
|
||||
results['validation_errors'].append(f"{path_str}: {result['error']}")
|
||||
|
||||
return results
|
||||
+643
-6
@@ -108,6 +108,8 @@ class NFOGuardDatabase:
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
imdb_id VARCHAR(20) PRIMARY KEY,
|
||||
title TEXT,
|
||||
year INTEGER,
|
||||
path TEXT NOT NULL,
|
||||
released DATE,
|
||||
dateadded TIMESTAMP,
|
||||
@@ -117,6 +119,44 @@ class NFOGuardDatabase:
|
||||
)
|
||||
""")
|
||||
|
||||
# Add title and year columns if they don't exist (migration for existing databases)
|
||||
cursor.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='movies' AND column_name='title') THEN
|
||||
ALTER TABLE movies ADD COLUMN title TEXT;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='movies' AND column_name='year') THEN
|
||||
ALTER TABLE movies ADD COLUMN year INTEGER;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# Add skipped and skip_reason columns if they don't exist (migration for skip tracking)
|
||||
cursor.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='movies' AND column_name='skipped') THEN
|
||||
ALTER TABLE movies ADD COLUMN skipped BOOLEAN DEFAULT FALSE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='movies' AND column_name='skip_reason') THEN
|
||||
ALTER TABLE movies ADD COLUMN skip_reason TEXT;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='episodes' AND column_name='skipped') THEN
|
||||
ALTER TABLE episodes ADD COLUMN skipped BOOLEAN DEFAULT FALSE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='episodes' AND column_name='skip_reason') THEN
|
||||
ALTER TABLE episodes ADD COLUMN skip_reason TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# Processing history table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS processing_history (
|
||||
@@ -129,11 +169,79 @@ class NFOGuardDatabase:
|
||||
)
|
||||
""")
|
||||
|
||||
# Missing IMDb tracking table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS missing_imdb (
|
||||
id SERIAL PRIMARY KEY,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
media_type VARCHAR(20) NOT NULL,
|
||||
folder_name TEXT,
|
||||
filename TEXT,
|
||||
discovered_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_checked TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
check_count INTEGER DEFAULT 1,
|
||||
notes TEXT,
|
||||
resolved BOOLEAN DEFAULT FALSE,
|
||||
resolved_at TIMESTAMP,
|
||||
resolved_imdb_id VARCHAR(20)
|
||||
)
|
||||
""")
|
||||
|
||||
# Scheduled scans table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scheduled_scans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
cron_expression VARCHAR(100) NOT NULL,
|
||||
media_type VARCHAR(20) NOT NULL CHECK (media_type IN ('tv', 'movies', 'both')),
|
||||
scan_mode VARCHAR(20) NOT NULL CHECK (scan_mode IN ('smart', 'full', 'incomplete')),
|
||||
specific_paths TEXT,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_run_at TIMESTAMP,
|
||||
next_run_at TIMESTAMP,
|
||||
run_count INTEGER DEFAULT 0,
|
||||
created_by VARCHAR(100),
|
||||
updated_by VARCHAR(100)
|
||||
)
|
||||
""")
|
||||
|
||||
# Schedule execution history table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schedule_executions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
status VARCHAR(50) NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
|
||||
media_type VARCHAR(20) NOT NULL,
|
||||
scan_mode VARCHAR(20) NOT NULL,
|
||||
items_processed INTEGER DEFAULT 0,
|
||||
items_skipped INTEGER DEFAULT 0,
|
||||
items_failed INTEGER DEFAULT 0,
|
||||
execution_time_seconds INTEGER,
|
||||
error_message TEXT,
|
||||
logs TEXT,
|
||||
triggered_by VARCHAR(100),
|
||||
FOREIGN KEY (schedule_id) REFERENCES scheduled_scans(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for PostgreSQL
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_type ON missing_imdb(media_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_resolved ON missing_imdb(resolved)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_path ON missing_imdb(file_path)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_scans_enabled ON scheduled_scans(enabled)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_scans_next_run ON scheduled_scans(next_run_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_schedule ON schedule_executions(schedule_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_status ON schedule_executions(status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_started ON schedule_executions(started_at)")
|
||||
def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
|
||||
"""Insert or update series record"""
|
||||
with self.get_connection() as conn:
|
||||
@@ -187,25 +295,28 @@ class NFOGuardDatabase:
|
||||
""", (imdb_id, path, timestamp))
|
||||
|
||||
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
|
||||
dateadded: Optional[str], source: str, has_video_file: bool = False):
|
||||
dateadded: Optional[str], source: str, has_video_file: bool = False,
|
||||
title: Optional[str] = None, year: Optional[int] = None):
|
||||
"""Insert or update movie date record"""
|
||||
import os
|
||||
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
|
||||
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, title={title}, dateadded={dateadded}, source={source}")
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.utcnow()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (%s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s)
|
||||
INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (imdb_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
year = EXCLUDED.year,
|
||||
released = EXCLUDED.released,
|
||||
dateadded = EXCLUDED.dateadded,
|
||||
source = EXCLUDED.source,
|
||||
has_video_file = EXCLUDED.has_video_file,
|
||||
last_updated = EXCLUDED.last_updated
|
||||
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, timestamp))
|
||||
""", (imdb_id, title, year, imdb_id, released, dateadded, source, has_video_file, timestamp))
|
||||
|
||||
# Debug: Check what was actually saved
|
||||
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,))
|
||||
@@ -214,6 +325,74 @@ class NFOGuardDatabase:
|
||||
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] if result else 'NOT_FOUND'}")
|
||||
|
||||
def mark_movie_skipped(self, imdb_id: str, title: str, year: int, path: str, reason: str):
|
||||
"""Mark a movie as skipped with reason"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.utcnow()
|
||||
cursor.execute("""
|
||||
INSERT INTO movies (imdb_id, title, year, path, skipped, skip_reason, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, %s, TRUE, %s, FALSE, %s)
|
||||
ON CONFLICT (imdb_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
year = EXCLUDED.year,
|
||||
path = EXCLUDED.path,
|
||||
skipped = TRUE,
|
||||
skip_reason = EXCLUDED.skip_reason,
|
||||
last_updated = EXCLUDED.last_updated
|
||||
""", (imdb_id, title, year, path, reason, timestamp))
|
||||
|
||||
def mark_episode_skipped(self, imdb_id: str, season: int, episode: int, reason: str):
|
||||
"""Mark an episode as skipped with reason"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.utcnow()
|
||||
cursor.execute("""
|
||||
INSERT INTO episodes (imdb_id, season, episode, skipped, skip_reason, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, TRUE, %s, FALSE, %s)
|
||||
ON CONFLICT (imdb_id, season, episode) DO UPDATE SET
|
||||
skipped = TRUE,
|
||||
skip_reason = EXCLUDED.skip_reason,
|
||||
last_updated = EXCLUDED.last_updated
|
||||
""", (imdb_id, season, episode, reason, timestamp))
|
||||
|
||||
def clear_movie_skipped(self, imdb_id: str):
|
||||
"""Clear skipped flag for a movie"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE movies SET skipped = FALSE, skip_reason = NULL
|
||||
WHERE imdb_id = %s
|
||||
""", (imdb_id,))
|
||||
|
||||
def clear_episode_skipped(self, imdb_id: str, season: int, episode: int):
|
||||
"""Clear skipped flag for an episode"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE episodes SET skipped = FALSE, skip_reason = NULL
|
||||
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||
""", (imdb_id, season, episode))
|
||||
|
||||
def get_skipped_counts(self) -> Dict:
|
||||
"""Get counts of skipped movies and episodes"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Count skipped movies
|
||||
cursor.execute("SELECT COUNT(*) as count FROM movies WHERE skipped = TRUE")
|
||||
skipped_movies = cursor.fetchone()['count']
|
||||
|
||||
# Count skipped episodes
|
||||
cursor.execute("SELECT COUNT(*) as count FROM episodes WHERE skipped = TRUE")
|
||||
skipped_episodes = cursor.fetchone()['count']
|
||||
|
||||
return {
|
||||
'movies': skipped_movies,
|
||||
'episodes': skipped_episodes,
|
||||
'total': skipped_movies + skipped_episodes
|
||||
}
|
||||
|
||||
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
|
||||
"""Get all episodes for a series"""
|
||||
with self.get_connection() as conn:
|
||||
@@ -258,6 +437,81 @@ class NFOGuardDatabase:
|
||||
""", (imdb_id, media_type, event_type, datetime.utcnow().isoformat(),
|
||||
json.dumps(details) if details else None))
|
||||
|
||||
def migrate_movie_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool:
|
||||
"""Migrate a movie from placeholder IMDb ID to real IMDb ID"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if old record exists
|
||||
cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (old_imdb_id,))
|
||||
old_record = cursor.fetchone()
|
||||
if not old_record:
|
||||
return False
|
||||
|
||||
old_data = dict(old_record)
|
||||
|
||||
# Check if new IMDb ID already exists
|
||||
cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (new_imdb_id,))
|
||||
if cursor.fetchone():
|
||||
# New IMDb ID already exists, just delete the old one
|
||||
cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,))
|
||||
return True
|
||||
|
||||
# Create new record with correct IMDb ID
|
||||
cursor.execute("""
|
||||
INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source,
|
||||
has_video_file, last_updated, skipped, skip_reason)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (new_imdb_id, old_data.get('title'), old_data.get('year'),
|
||||
old_data.get('path'), old_data.get('released'), old_data.get('dateadded'),
|
||||
old_data.get('source'), old_data.get('has_video_file'),
|
||||
datetime.utcnow(), False, None)) # Clear skipped status
|
||||
|
||||
# Delete old placeholder record
|
||||
cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,))
|
||||
return True
|
||||
|
||||
def migrate_series_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool:
|
||||
"""Migrate a series and all its episodes from placeholder IMDb ID to real IMDb ID"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if old series exists
|
||||
cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (old_imdb_id,))
|
||||
old_series = cursor.fetchone()
|
||||
if not old_series:
|
||||
return False
|
||||
|
||||
old_series_data = dict(old_series)
|
||||
|
||||
# Check if new series IMDb ID already exists
|
||||
cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (new_imdb_id,))
|
||||
if cursor.fetchone():
|
||||
# New series already exists, migrate episodes and delete old series
|
||||
cursor.execute("""
|
||||
UPDATE episodes SET imdb_id = %s WHERE imdb_id = %s
|
||||
""", (new_imdb_id, old_imdb_id))
|
||||
cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,))
|
||||
return True
|
||||
|
||||
# Create new series record
|
||||
cursor.execute("""
|
||||
INSERT INTO series (imdb_id, path, last_updated, metadata)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""", (new_imdb_id, old_series_data.get('path'),
|
||||
datetime.utcnow(), old_series_data.get('metadata')))
|
||||
|
||||
# Migrate all episodes to new series IMDb ID and clear skipped status
|
||||
cursor.execute("""
|
||||
UPDATE episodes
|
||||
SET imdb_id = %s, skipped = FALSE, skip_reason = NULL
|
||||
WHERE imdb_id = %s
|
||||
""", (new_imdb_id, old_imdb_id))
|
||||
|
||||
# Delete old placeholder series
|
||||
cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,))
|
||||
return True
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get database statistics"""
|
||||
with self.get_connection() as conn:
|
||||
@@ -441,6 +695,90 @@ class NFOGuardDatabase:
|
||||
|
||||
return deleted_count > 0
|
||||
|
||||
def delete_series(self, imdb_id: str) -> bool:
|
||||
"""
|
||||
Delete a specific series from the database
|
||||
|
||||
Args:
|
||||
imdb_id: Series IMDb ID
|
||||
|
||||
Returns:
|
||||
True if series was deleted, False if not found
|
||||
"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
DELETE FROM series
|
||||
WHERE imdb_id = %s
|
||||
""", (imdb_id,))
|
||||
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
return deleted_count > 0
|
||||
|
||||
def update_movie_file_info(self, imdb_id: str, path: str, has_video_file: bool = True) -> bool:
|
||||
"""
|
||||
Update path and video file status for an existing movie
|
||||
Used when population finds a video file for a manually-added movie
|
||||
|
||||
Args:
|
||||
imdb_id: Movie IMDb ID
|
||||
path: File path to the video file
|
||||
has_video_file: Whether a video file exists (default True)
|
||||
|
||||
Returns:
|
||||
True if movie was updated, False if not found
|
||||
"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE movies
|
||||
SET path = %s,
|
||||
has_video_file = %s,
|
||||
last_updated = %s
|
||||
WHERE imdb_id = %s
|
||||
""", (path, has_video_file, datetime.utcnow(), imdb_id))
|
||||
|
||||
updated_count = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
return updated_count > 0
|
||||
|
||||
def update_episode_file_info(self, imdb_id: str, season: int, episode: int,
|
||||
path: str, has_video_file: bool = True) -> bool:
|
||||
"""
|
||||
Update path and video file status for an existing episode
|
||||
Used when population finds a video file for a manually-added episode
|
||||
|
||||
Args:
|
||||
imdb_id: Series IMDb ID
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
path: File path to the video file
|
||||
has_video_file: Whether a video file exists (default True)
|
||||
|
||||
Returns:
|
||||
True if episode was updated, False if not found
|
||||
"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE episodes
|
||||
SET path = %s,
|
||||
has_video_file = %s,
|
||||
last_updated = %s
|
||||
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||
""", (path, has_video_file, datetime.utcnow(), imdb_id, season, episode))
|
||||
|
||||
updated_count = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
return updated_count > 0
|
||||
|
||||
def delete_orphaned_movies(self) -> List[Dict]:
|
||||
"""
|
||||
Find and delete movies that don't have corresponding video files on disk
|
||||
@@ -560,11 +898,310 @@ class NFOGuardDatabase:
|
||||
|
||||
return deleted_series
|
||||
|
||||
def add_missing_imdb(self, file_path: str, media_type: str, folder_name: str = None, filename: str = None, notes: str = None):
|
||||
"""Add or update a missing IMDb entry"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO missing_imdb (file_path, media_type, folder_name, filename, notes)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (file_path) DO UPDATE SET
|
||||
last_checked = CURRENT_TIMESTAMP,
|
||||
check_count = missing_imdb.check_count + 1,
|
||||
media_type = EXCLUDED.media_type,
|
||||
folder_name = EXCLUDED.folder_name,
|
||||
filename = EXCLUDED.filename,
|
||||
notes = EXCLUDED.notes
|
||||
""", (file_path, media_type, folder_name, filename, notes))
|
||||
|
||||
conn.commit()
|
||||
|
||||
def get_missing_imdb_items(self, media_type: str = None, resolved: bool = False) -> List[Dict]:
|
||||
"""Get missing IMDb items, optionally filtered by type and resolution status"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT id, file_path, media_type, folder_name, filename,
|
||||
discovered_at, last_checked, check_count, notes,
|
||||
resolved, resolved_at, resolved_imdb_id
|
||||
FROM missing_imdb
|
||||
WHERE resolved = %s
|
||||
"""
|
||||
params = [resolved]
|
||||
|
||||
if media_type:
|
||||
query += " AND media_type = %s"
|
||||
params.append(media_type)
|
||||
|
||||
query += " ORDER BY last_checked DESC"
|
||||
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
def resolve_missing_imdb(self, file_path: str, imdb_id: str):
|
||||
"""Mark a missing IMDb item as resolved"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE missing_imdb
|
||||
SET resolved = TRUE,
|
||||
resolved_at = CURRENT_TIMESTAMP,
|
||||
resolved_imdb_id = %s
|
||||
WHERE file_path = %s
|
||||
""", (imdb_id, file_path))
|
||||
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_missing_imdb(self, file_path: str) -> bool:
|
||||
"""Delete a missing IMDb entry"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM missing_imdb WHERE file_path = %s", (file_path,))
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
return deleted_count > 0
|
||||
|
||||
# Scheduled Scans Methods
|
||||
|
||||
def create_scheduled_scan(self, name: str, description: str, cron_expression: str,
|
||||
media_type: str, scan_mode: str, specific_paths: str = None,
|
||||
enabled: bool = True, created_by: str = None) -> int:
|
||||
"""Create a new scheduled scan"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO scheduled_scans
|
||||
(name, description, cron_expression, media_type, scan_mode, specific_paths, enabled, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (name, description, cron_expression, media_type, scan_mode, specific_paths, enabled, created_by))
|
||||
|
||||
return cursor.fetchone()['id']
|
||||
|
||||
def get_scheduled_scans(self, enabled_only: bool = False) -> List[Dict]:
|
||||
"""Get all scheduled scans"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM scheduled_scans"
|
||||
if enabled_only:
|
||||
query += " WHERE enabled = TRUE"
|
||||
query += " ORDER BY name"
|
||||
|
||||
cursor.execute(query)
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_scheduled_scan(self, scan_id: int) -> Optional[Dict]:
|
||||
"""Get a specific scheduled scan by ID"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM scheduled_scans WHERE id = %s", (scan_id,))
|
||||
return cursor.fetchone()
|
||||
|
||||
def update_scheduled_scan(self, scan_id: int, name: str = None, description: str = None,
|
||||
cron_expression: str = None, media_type: str = None,
|
||||
scan_mode: str = None, specific_paths: str = None,
|
||||
enabled: bool = None, updated_by: str = None) -> bool:
|
||||
"""Update a scheduled scan"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if name is not None:
|
||||
updates.append("name = %s")
|
||||
params.append(name)
|
||||
if description is not None:
|
||||
updates.append("description = %s")
|
||||
params.append(description)
|
||||
if cron_expression is not None:
|
||||
updates.append("cron_expression = %s")
|
||||
params.append(cron_expression)
|
||||
if media_type is not None:
|
||||
updates.append("media_type = %s")
|
||||
params.append(media_type)
|
||||
if scan_mode is not None:
|
||||
updates.append("scan_mode = %s")
|
||||
params.append(scan_mode)
|
||||
if specific_paths is not None:
|
||||
updates.append("specific_paths = %s")
|
||||
params.append(specific_paths)
|
||||
if enabled is not None:
|
||||
updates.append("enabled = %s")
|
||||
params.append(enabled)
|
||||
if updated_by is not None:
|
||||
updates.append("updated_by = %s")
|
||||
params.append(updated_by)
|
||||
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.append(scan_id)
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
query = f"UPDATE scheduled_scans SET {', '.join(updates)} WHERE id = %s"
|
||||
cursor.execute(query, params)
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_scheduled_scan(self, scan_id: int) -> bool:
|
||||
"""Delete a scheduled scan and its execution history"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM scheduled_scans WHERE id = %s", (scan_id,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def update_scan_next_run(self, scan_id: int, next_run_at: datetime) -> bool:
|
||||
"""Update the next run time for a scheduled scan"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE scheduled_scans
|
||||
SET next_run_at = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (next_run_at, scan_id))
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def update_scan_last_run(self, scan_id: int, last_run_at: datetime = None) -> bool:
|
||||
"""Update the last run time and increment run count for a scheduled scan"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if last_run_at is None:
|
||||
last_run_at = datetime.utcnow()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE scheduled_scans
|
||||
SET last_run_at = %s, run_count = run_count + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (last_run_at, scan_id))
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# Schedule Execution Methods
|
||||
|
||||
def create_schedule_execution(self, schedule_id: int, media_type: str, scan_mode: str,
|
||||
triggered_by: str = None) -> int:
|
||||
"""Create a new schedule execution record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO schedule_executions
|
||||
(schedule_id, status, media_type, scan_mode, triggered_by)
|
||||
VALUES (%s, 'running', %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (schedule_id, media_type, scan_mode, triggered_by))
|
||||
|
||||
return cursor.fetchone()['id']
|
||||
|
||||
def update_schedule_execution(self, execution_id: int, status: str = None,
|
||||
items_processed: int = None, items_skipped: int = None,
|
||||
items_failed: int = None, error_message: str = None,
|
||||
logs: str = None) -> bool:
|
||||
"""Update a schedule execution record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if status is not None:
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
if status in ['completed', 'failed', 'cancelled']:
|
||||
updates.append("completed_at = CURRENT_TIMESTAMP")
|
||||
updates.append("execution_time_seconds = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at))")
|
||||
|
||||
if items_processed is not None:
|
||||
updates.append("items_processed = %s")
|
||||
params.append(items_processed)
|
||||
if items_skipped is not None:
|
||||
updates.append("items_skipped = %s")
|
||||
params.append(items_skipped)
|
||||
if items_failed is not None:
|
||||
updates.append("items_failed = %s")
|
||||
params.append(items_failed)
|
||||
if error_message is not None:
|
||||
updates.append("error_message = %s")
|
||||
params.append(error_message)
|
||||
if logs is not None:
|
||||
updates.append("logs = %s")
|
||||
params.append(logs)
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(execution_id)
|
||||
query = f"UPDATE schedule_executions SET {', '.join(updates)} WHERE id = %s"
|
||||
cursor.execute(query, params)
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_schedule_executions(self, schedule_id: int = None, limit: int = 50) -> List[Dict]:
|
||||
"""Get schedule execution history"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT se.*, ss.name as schedule_name
|
||||
FROM schedule_executions se
|
||||
JOIN scheduled_scans ss ON se.schedule_id = ss.id
|
||||
"""
|
||||
params = []
|
||||
|
||||
if schedule_id is not None:
|
||||
query += " WHERE se.schedule_id = %s"
|
||||
params.append(schedule_id)
|
||||
|
||||
query += " ORDER BY se.started_at DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_running_executions(self) -> List[Dict]:
|
||||
"""Get currently running schedule executions"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT se.*, ss.name as schedule_name
|
||||
FROM schedule_executions se
|
||||
JOIN scheduled_scans ss ON se.schedule_id = ss.id
|
||||
WHERE se.status = 'running'
|
||||
ORDER BY se.started_at DESC
|
||||
""")
|
||||
|
||||
return cursor.fetchall()
|
||||
|
||||
def close(self):
|
||||
"""Close all database connections"""
|
||||
if hasattr(self._local, 'connection'):
|
||||
try:
|
||||
# For PostgreSQL, ensure all transactions are committed/rolled back
|
||||
try:
|
||||
# Force rollback any open transactions
|
||||
self._local.connection.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Close the connection
|
||||
self._local.connection.close()
|
||||
delattr(self._local, 'connection')
|
||||
except Exception:
|
||||
print("✅ Database connection closed successfully")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error closing database connection: {e}")
|
||||
pass # Connection may already be closed
|
||||
@@ -0,0 +1,476 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database Populator for NFOGuard
|
||||
Bulk populates the NFOGuard database from Radarr/Sonarr
|
||||
Phase 4: Replace NFO-based initial population with direct DB/API queries
|
||||
"""
|
||||
import time
|
||||
import hashlib
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from core.database import NFOGuardDatabase
|
||||
from clients.radarr_client import RadarrClient
|
||||
from clients.sonarr_client import SonarrClient
|
||||
from utils.logging import _log
|
||||
from utils.imdb_utils import parse_imdb_from_path
|
||||
|
||||
|
||||
class DatabasePopulator:
|
||||
"""Populates NFOGuard database from Radarr/Sonarr sources"""
|
||||
|
||||
def __init__(self, db: NFOGuardDatabase, radarr_client: RadarrClient, sonarr_client: SonarrClient):
|
||||
self.db = db
|
||||
self.radarr = radarr_client
|
||||
self.sonarr = sonarr_client
|
||||
|
||||
def populate_movies(self) -> Dict[str, any]:
|
||||
"""
|
||||
Populate movies from Radarr database/API
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics: {
|
||||
'total': int,
|
||||
'added': int,
|
||||
'updated': int,
|
||||
'skipped': int,
|
||||
'errors': int,
|
||||
'duration': float
|
||||
}
|
||||
"""
|
||||
_log("INFO", "Starting movie population from Radarr")
|
||||
start_time = time.time()
|
||||
|
||||
stats = {
|
||||
'total': 0,
|
||||
'added': 0,
|
||||
'updated': 0,
|
||||
'skipped': 0,
|
||||
'errors': 0,
|
||||
'duration': 0.0,
|
||||
'skipped_items': [] # Track what was skipped and why
|
||||
}
|
||||
|
||||
try:
|
||||
# Get all movies from Radarr database
|
||||
if not hasattr(self.radarr, 'db_client') or not self.radarr.db_client:
|
||||
_log("ERROR", "Radarr database client not available - cannot populate movies")
|
||||
stats['errors'] += 1
|
||||
return stats
|
||||
|
||||
movies = self.radarr.db_client.get_all_movies()
|
||||
if not movies:
|
||||
_log("WARNING", "No movies found in Radarr database")
|
||||
return stats
|
||||
|
||||
stats['total'] = len(movies)
|
||||
_log("INFO", f"Found {stats['total']} movies in Radarr")
|
||||
|
||||
# Process each movie
|
||||
for movie in movies:
|
||||
try:
|
||||
# Get movie path first (we'll need it for IMDb extraction)
|
||||
path = movie.get('path', '')
|
||||
|
||||
# Try to get IMDb ID from Radarr database
|
||||
imdb_id = movie.get('imdb_id')
|
||||
|
||||
# If not in database, try extracting from directory/filename
|
||||
if not imdb_id and path:
|
||||
imdb_id = parse_imdb_from_path(Path(path))
|
||||
if imdb_id:
|
||||
_log("DEBUG", f"Extracted IMDb ID {imdb_id} from path for: {movie.get('title')}")
|
||||
|
||||
if not imdb_id:
|
||||
# Generate placeholder IMDb ID using hash of path
|
||||
path_hash = hashlib.md5(path.encode()).hexdigest()[:12]
|
||||
imdb_id = f"missing-{path_hash}"
|
||||
skip_reason = 'No IMDb ID found'
|
||||
skip_info = {
|
||||
'title': movie.get('title', 'Unknown'),
|
||||
'year': movie.get('year'),
|
||||
'imdb_id': imdb_id,
|
||||
'path': path,
|
||||
'reason': skip_reason
|
||||
}
|
||||
stats['skipped_items'].append(skip_info)
|
||||
_log("DEBUG", f"Movie without IMDb ID: {movie.get('title')} (path: {path}), using placeholder {imdb_id}")
|
||||
|
||||
# Mark as skipped in database with placeholder IMDb ID
|
||||
self.db.mark_movie_skipped(
|
||||
imdb_id=imdb_id,
|
||||
title=movie.get('title', 'Unknown'),
|
||||
year=movie.get('year', 0),
|
||||
path=path,
|
||||
reason=skip_reason
|
||||
)
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
|
||||
# Check if movie already exists in database
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
if existing and existing.get('dateadded'):
|
||||
# Already in database - update file path and video status if needed
|
||||
existing_path = existing.get('path')
|
||||
if not existing_path or existing_path == 'unknown' or existing_path != path:
|
||||
_log("INFO", f"Movie {imdb_id} exists but updating file info: {path}")
|
||||
self.db.update_movie_file_info(imdb_id, path, has_video_file=True)
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
self.db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type='movie',
|
||||
event_type='file_info_update',
|
||||
details={'path': path}
|
||||
)
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to add processing history for {imdb_id}: {e}")
|
||||
|
||||
stats['updated'] += 1
|
||||
else:
|
||||
_log("DEBUG", f"Movie {imdb_id} already in database with correct path, skipping")
|
||||
continue
|
||||
|
||||
# Get release date
|
||||
released = None
|
||||
if movie.get('digital_release'):
|
||||
released = movie.get('digital_release')
|
||||
source_type = 'radarr:digital'
|
||||
elif movie.get('physical_release'):
|
||||
released = movie.get('physical_release')
|
||||
source_type = 'radarr:physical'
|
||||
elif movie.get('in_cinemas'):
|
||||
released = movie.get('in_cinemas')
|
||||
source_type = 'radarr:theatrical'
|
||||
else:
|
||||
source_type = 'radarr:unknown'
|
||||
|
||||
# Get import date from Radarr history using Radarr's internal movie ID
|
||||
radarr_movie_id = movie.get('id')
|
||||
if radarr_movie_id:
|
||||
# get_movie_import_date returns tuple (date, source)
|
||||
import_date, import_source = self.radarr.get_movie_import_date(radarr_movie_id)
|
||||
if import_date:
|
||||
dateadded = import_date
|
||||
source = import_source
|
||||
elif released:
|
||||
# Use release date as fallback
|
||||
dateadded = released
|
||||
source = f'{source_type}_fallback'
|
||||
else:
|
||||
skip_reason = 'No import date in Radarr history and no release dates available'
|
||||
skip_info = {
|
||||
'title': movie.get('title', 'Unknown'),
|
||||
'year': movie.get('year'),
|
||||
'imdb_id': imdb_id,
|
||||
'reason': skip_reason
|
||||
}
|
||||
stats['skipped_items'].append(skip_info)
|
||||
_log("DEBUG", f"No date available for movie {imdb_id}, skipping")
|
||||
|
||||
# Mark as skipped in database for troubleshooting
|
||||
self.db.mark_movie_skipped(
|
||||
imdb_id=imdb_id,
|
||||
title=movie.get('title', 'Unknown'),
|
||||
year=movie.get('year', 0),
|
||||
path=path or 'unknown',
|
||||
reason=skip_reason
|
||||
)
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
elif released:
|
||||
# No Radarr ID, use release date
|
||||
dateadded = released
|
||||
source = f'{source_type}_fallback'
|
||||
else:
|
||||
skip_reason = 'No Radarr movie ID and no release dates available'
|
||||
skip_info = {
|
||||
'title': movie.get('title', 'Unknown'),
|
||||
'year': movie.get('year'),
|
||||
'imdb_id': imdb_id,
|
||||
'reason': skip_reason
|
||||
}
|
||||
stats['skipped_items'].append(skip_info)
|
||||
_log("DEBUG", f"No date available for movie {imdb_id}, skipping")
|
||||
|
||||
# Mark as skipped in database for troubleshooting
|
||||
self.db.mark_movie_skipped(
|
||||
imdb_id=imdb_id,
|
||||
title=movie.get('title', 'Unknown'),
|
||||
year=movie.get('year', 0),
|
||||
path=path or 'unknown',
|
||||
reason=skip_reason
|
||||
)
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
|
||||
# Insert into database with title and year
|
||||
title = movie.get('title')
|
||||
year = movie.get('year')
|
||||
self.db.upsert_movie_dates(
|
||||
imdb_id, released, dateadded, source,
|
||||
has_video_file=True, title=title, year=year
|
||||
)
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
self.db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type='movie',
|
||||
event_type='database_population',
|
||||
details={'source': source, 'title': title}
|
||||
)
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to add processing history for {imdb_id}: {e}")
|
||||
|
||||
stats['added'] += 1
|
||||
_log("DEBUG", f"Added movie {imdb_id}: {title} ({year}) (source: {source})")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing movie {movie.get('title', 'unknown')}: {e}")
|
||||
stats['errors'] += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error during movie population: {e}")
|
||||
stats['errors'] += 1
|
||||
|
||||
stats['duration'] = time.time() - start_time
|
||||
_log("INFO", f"Movie population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s")
|
||||
|
||||
# Log details of skipped items
|
||||
if stats['skipped_items']:
|
||||
_log("INFO", f"Skipped items details ({len(stats['skipped_items'])} total):")
|
||||
for item in stats['skipped_items']:
|
||||
_log("INFO", f" - {item['title']} ({item.get('year', 'N/A')}) [{item.get('imdb_id', 'No IMDb')}]: {item['reason']}")
|
||||
|
||||
return stats
|
||||
|
||||
def populate_tv_episodes(self) -> Dict[str, any]:
|
||||
"""
|
||||
Populate TV episodes from Sonarr API
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics: {
|
||||
'total_series': int,
|
||||
'total_episodes': int,
|
||||
'added': int,
|
||||
'updated': int,
|
||||
'skipped': int,
|
||||
'errors': int,
|
||||
'duration': float
|
||||
}
|
||||
"""
|
||||
_log("INFO", "Starting TV episode population from Sonarr")
|
||||
start_time = time.time()
|
||||
|
||||
stats = {
|
||||
'total_series': 0,
|
||||
'total_episodes': 0,
|
||||
'added': 0,
|
||||
'updated': 0,
|
||||
'skipped': 0,
|
||||
'errors': 0,
|
||||
'duration': 0.0,
|
||||
'skipped_items': [] # Track what was skipped and why
|
||||
}
|
||||
|
||||
try:
|
||||
# Get all series from Sonarr
|
||||
all_series = self.sonarr.get_all_series()
|
||||
if not all_series:
|
||||
_log("WARNING", "No series found in Sonarr")
|
||||
return stats
|
||||
|
||||
stats['total_series'] = len(all_series)
|
||||
_log("INFO", f"Found {stats['total_series']} series in Sonarr")
|
||||
|
||||
# Process each series
|
||||
for series in all_series:
|
||||
try:
|
||||
imdb_id = series.get('imdbId')
|
||||
series_id = series.get('id')
|
||||
series_path = series.get('path', '')
|
||||
series_title = series.get('title', 'Unknown')
|
||||
|
||||
if not imdb_id:
|
||||
# Generate placeholder IMDb ID using hash of path
|
||||
path_hash = hashlib.md5(series_path.encode()).hexdigest()[:12]
|
||||
imdb_id = f"missing-{path_hash}"
|
||||
_log("DEBUG", f"Series without IMDb ID: {series_title} (path: {series_path}), using placeholder {imdb_id}")
|
||||
|
||||
# Update series record
|
||||
self.db.upsert_series(imdb_id, series_path)
|
||||
|
||||
# Try high-performance database bulk query first
|
||||
sonarr_db = getattr(self.sonarr, 'db_client', None)
|
||||
bulk_import_dates = {}
|
||||
|
||||
if sonarr_db:
|
||||
try:
|
||||
_log("DEBUG", f"Using DB bulk query for {series_title}")
|
||||
bulk_import_dates = sonarr_db.bulk_import_dates_for_series(series_id)
|
||||
_log("DEBUG", f"✅ Got {len(bulk_import_dates)} import dates from DB for {series_title}")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"DB bulk query failed for {series_title}, falling back to API: {e}")
|
||||
|
||||
# Get all episodes for this series
|
||||
episodes = self.sonarr.episodes_for_series(series_id)
|
||||
if not episodes:
|
||||
continue
|
||||
|
||||
_log("DEBUG", f"Processing {len(episodes)} episodes for {series_title}")
|
||||
|
||||
# Process each episode
|
||||
for episode in episodes:
|
||||
try:
|
||||
season_num = episode.get('seasonNumber', 0)
|
||||
episode_num = episode.get('episodeNumber', 0)
|
||||
episode_title = episode.get('title', 'Unknown')
|
||||
|
||||
if season_num < 0 or episode_num <= 0:
|
||||
continue
|
||||
|
||||
stats['total_episodes'] += 1
|
||||
|
||||
# Check if episode already exists
|
||||
existing = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||
if existing and existing.get('dateadded'):
|
||||
# Already in database - update file path and video status if needed
|
||||
existing_path = existing.get('path')
|
||||
episode_path = episode.get('path', 'unknown')
|
||||
if not existing_path or existing_path == 'unknown' or existing_path != episode_path:
|
||||
_log("INFO", f"Episode {imdb_id} S{season_num:02d}E{episode_num:02d} exists but updating file info: {episode_path}")
|
||||
self.db.update_episode_file_info(imdb_id, season_num, episode_num, episode_path, has_video_file=True)
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
self.db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type='episode',
|
||||
event_type='file_info_update',
|
||||
details={'season': season_num, 'episode': episode_num, 'path': episode_path}
|
||||
)
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
stats['updated'] += 1
|
||||
continue
|
||||
|
||||
# Only process episodes that have video files
|
||||
has_file = episode.get('hasFile', False)
|
||||
if not has_file:
|
||||
# No video file - skip silently (intentionally filtered)
|
||||
continue
|
||||
|
||||
# Get air date
|
||||
aired = episode.get('airDate')
|
||||
|
||||
# Get import date
|
||||
dateadded = None
|
||||
source = None
|
||||
|
||||
# Try bulk DB result first
|
||||
if (season_num, episode_num) in bulk_import_dates:
|
||||
dateadded, source = bulk_import_dates[(season_num, episode_num)]
|
||||
# Fall back to API query
|
||||
else:
|
||||
episode_id = episode.get('id')
|
||||
if episode_id:
|
||||
import_date = self.sonarr.get_episode_import_history(episode_id)
|
||||
if import_date:
|
||||
dateadded = import_date
|
||||
source = 'sonarr:api.import_history'
|
||||
|
||||
# Fallback to air date if no import date
|
||||
if not dateadded and aired:
|
||||
dateadded = aired
|
||||
source = 'sonarr:aired_fallback'
|
||||
elif not dateadded:
|
||||
# No date available
|
||||
skip_reason = 'No import date from Sonarr history and no air date available'
|
||||
skip_info = {
|
||||
'title': series_title,
|
||||
'episode_title': episode_title,
|
||||
'season': season_num,
|
||||
'episode': episode_num,
|
||||
'reason': skip_reason
|
||||
}
|
||||
stats['skipped_items'].append(skip_info)
|
||||
|
||||
# Mark as skipped in database for troubleshooting
|
||||
self.db.mark_episode_skipped(
|
||||
imdb_id=imdb_id,
|
||||
season=season_num,
|
||||
episode=episode_num,
|
||||
reason=skip_reason
|
||||
)
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
|
||||
# Insert into database
|
||||
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, has_file)
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
self.db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type='episode',
|
||||
event_type='database_population',
|
||||
details={'season': season_num, 'episode': episode_num, 'source': source, 'title': episode_title}
|
||||
)
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
stats['added'] += 1
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing episode S{season_num:02d}E{episode_num:02d} of {series_title}: {e}")
|
||||
stats['errors'] += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing series {series.get('title', 'unknown')}: {e}")
|
||||
stats['errors'] += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error during TV episode population: {e}")
|
||||
stats['errors'] += 1
|
||||
|
||||
stats['duration'] = time.time() - start_time
|
||||
_log("INFO", f"TV episode population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s")
|
||||
|
||||
# Log details of skipped items
|
||||
if stats['skipped_items']:
|
||||
_log("INFO", f"Skipped episodes details ({len(stats['skipped_items'])} total):")
|
||||
for item in stats['skipped_items'][:20]: # Only log first 20 to avoid spam
|
||||
_log("INFO", f" - {item['title']} S{str(item['season']).zfill(2)}E{str(item['episode']).zfill(2)} ({item.get('episode_title', 'Unknown')}): {item['reason']}")
|
||||
if len(stats['skipped_items']) > 20:
|
||||
_log("INFO", f" ... and {len(stats['skipped_items']) - 20} more (see web interface for full list)")
|
||||
|
||||
return stats
|
||||
|
||||
def populate_all(self) -> Dict[str, any]:
|
||||
"""
|
||||
Populate both movies and TV episodes
|
||||
|
||||
Returns:
|
||||
Combined statistics dictionary
|
||||
"""
|
||||
_log("INFO", "Starting full database population")
|
||||
start_time = time.time()
|
||||
|
||||
movie_stats = self.populate_movies()
|
||||
tv_stats = self.populate_tv_episodes()
|
||||
|
||||
combined_stats = {
|
||||
'movies': movie_stats,
|
||||
'tv': tv_stats,
|
||||
'total_duration': time.time() - start_time
|
||||
}
|
||||
|
||||
_log("INFO", f"Full database population complete in {combined_stats['total_duration']:.2f}s")
|
||||
return combined_stats
|
||||
@@ -1,276 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Episode NFO Manager - Handles TV episode NFO creation with video filename matching
|
||||
Core principle: NFO filenames should match video filenames
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
import re
|
||||
from .logging import _log
|
||||
|
||||
|
||||
class EpisodeNFOManager:
|
||||
"""Manages episode NFO files with video filename matching"""
|
||||
|
||||
def __init__(self, manager_brand: str = "NFOGuard"):
|
||||
self.manager_brand = manager_brand
|
||||
|
||||
def find_video_files_for_season(self, season_dir: Path) -> Dict[Tuple[int, int], List[Path]]:
|
||||
"""Find all video files in season directory, grouped by (season, episode)"""
|
||||
if not season_dir.exists():
|
||||
_log("DEBUG", f"Season directory does not exist: {season_dir}")
|
||||
return {}
|
||||
|
||||
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
|
||||
episodes = {}
|
||||
|
||||
_log("DEBUG", f"Scanning video files in: {season_dir}")
|
||||
for video_file in season_dir.iterdir():
|
||||
_log("DEBUG", f"Checking file: {video_file.name} (is_file: {video_file.is_file()}, suffix: {video_file.suffix.lower()})")
|
||||
if (video_file.is_file() and
|
||||
video_file.suffix.lower() in video_extensions):
|
||||
|
||||
episode_info = self._parse_episode_from_filename(video_file.name)
|
||||
_log("DEBUG", f"Episode parsing for '{video_file.name}': {episode_info}")
|
||||
if episode_info:
|
||||
season_num, episode_num = episode_info
|
||||
key = (season_num, episode_num)
|
||||
if key not in episodes:
|
||||
episodes[key] = []
|
||||
episodes[key].append(video_file)
|
||||
_log("DEBUG", f"Added video file: S{season_num:02d}E{episode_num:02d} → {video_file.name}")
|
||||
|
||||
_log("DEBUG", f"Total video files found: {len(episodes)} episodes")
|
||||
return episodes
|
||||
|
||||
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
|
||||
"""Extract season and episode numbers from filename"""
|
||||
# Try S##E## format first (most common)
|
||||
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
# Try ##x## format
|
||||
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
return None
|
||||
|
||||
def find_nfo_for_episode(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
||||
"""Find existing NFO file for episode (prefer video-matching filename)"""
|
||||
if not season_dir.exists():
|
||||
return None
|
||||
|
||||
# First, look for NFO files that match video filenames
|
||||
video_files = self.find_video_files_for_season(season_dir)
|
||||
key = (season_num, episode_num)
|
||||
|
||||
if key in video_files:
|
||||
for video_file in video_files[key]:
|
||||
potential_nfo = season_dir / f"{video_file.stem}.nfo"
|
||||
if potential_nfo.exists():
|
||||
_log("DEBUG", f"Found video-matching NFO: {potential_nfo.name}")
|
||||
return potential_nfo
|
||||
|
||||
# Fallback: look for short name NFO
|
||||
short_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
if short_nfo.exists():
|
||||
_log("DEBUG", f"Found short-name NFO: {short_nfo.name}")
|
||||
return short_nfo
|
||||
|
||||
# Last resort: search all NFO files for matching season/episode data
|
||||
for nfo_file in season_dir.glob("*.nfo"):
|
||||
if self._nfo_matches_episode(nfo_file, season_num, episode_num):
|
||||
_log("DEBUG", f"Found matching NFO by content: {nfo_file.name}")
|
||||
return nfo_file
|
||||
|
||||
return None
|
||||
|
||||
def _nfo_matches_episode(self, nfo_path: Path, season_num: int, episode_num: int) -> bool:
|
||||
"""Check if NFO file contains the specified season/episode"""
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
if root.tag == "episodedetails":
|
||||
season_elem = root.find("season")
|
||||
episode_elem = root.find("episode")
|
||||
|
||||
if (season_elem is not None and episode_elem is not None and
|
||||
season_elem.text and episode_elem.text):
|
||||
try:
|
||||
file_season = int(season_elem.text)
|
||||
file_episode = int(episode_elem.text)
|
||||
return file_season == season_num and file_episode == episode_num
|
||||
except ValueError:
|
||||
pass
|
||||
except (ET.ParseError, Exception):
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def get_target_nfo_path(self, season_dir: Path, season_num: int, episode_num: int) -> Path:
|
||||
"""Get the target NFO path (prefer video filename, fallback to short name)"""
|
||||
video_files = self.find_video_files_for_season(season_dir)
|
||||
key = (season_num, episode_num)
|
||||
|
||||
if key in video_files and video_files[key]:
|
||||
# Use the first video file found (handle multiple files gracefully)
|
||||
video_file = video_files[key][0]
|
||||
target_nfo = season_dir / f"{video_file.stem}.nfo"
|
||||
_log("DEBUG", f"Target NFO will match video: {target_nfo.name}")
|
||||
return target_nfo
|
||||
else:
|
||||
# Fallback to short name if no video file found
|
||||
target_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
_log("WARNING", f"No video file found for S{season_num:02d}E{episode_num:02d}, using short name: {target_nfo.name}")
|
||||
return target_nfo
|
||||
|
||||
def migrate_nfo_to_video_filename(self, season_dir: Path, season_num: int, episode_num: int) -> bool:
|
||||
"""If short-name NFO exists, rename it to match video filename"""
|
||||
existing_nfo = self.find_nfo_for_episode(season_dir, season_num, episode_num)
|
||||
target_nfo = self.get_target_nfo_path(season_dir, season_num, episode_num)
|
||||
|
||||
# If we already have the right filename, nothing to do
|
||||
if existing_nfo and existing_nfo == target_nfo:
|
||||
return True
|
||||
|
||||
# If we have an NFO but it doesn't match target, rename it
|
||||
if existing_nfo and existing_nfo != target_nfo:
|
||||
try:
|
||||
_log("INFO", f"Migrating NFO filename: {existing_nfo.name} -> {target_nfo.name}")
|
||||
existing_nfo.rename(target_nfo)
|
||||
return True
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to rename NFO: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
||||
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||
title: Optional[str] = None, plot: Optional[str] = None) -> bool:
|
||||
"""Create or update episode NFO with video filename matching"""
|
||||
|
||||
# Get the target NFO path (matching video filename)
|
||||
nfo_path = self.get_target_nfo_path(season_dir, season_num, episode_num)
|
||||
|
||||
# Migrate existing NFO if needed
|
||||
self.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
|
||||
|
||||
try:
|
||||
# Load existing NFO if it exists
|
||||
episode_elem = None
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
episode_elem = tree.getroot()
|
||||
|
||||
if episode_elem.tag != "episodedetails":
|
||||
raise ValueError("Root element is not <episodedetails>")
|
||||
|
||||
# Remove NFOGuard-managed fields (we'll re-add them)
|
||||
for tag in ["season", "episode", "aired", "premiered", "dateadded", "lockdata"]:
|
||||
existing = episode_elem.find(tag)
|
||||
if existing is not None:
|
||||
episode_elem.remove(existing)
|
||||
|
||||
_log("DEBUG", f"Loaded existing NFO content for {nfo_path.name}")
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
_log("WARNING", f"Corrupted NFO file {nfo_path.name}: {e}. Creating new one.")
|
||||
episode_elem = None
|
||||
|
||||
# Create new structure if needed
|
||||
if episode_elem is None:
|
||||
episode_elem = ET.Element("episodedetails")
|
||||
|
||||
# Add title if provided and not already present
|
||||
if title and not episode_elem.find("title"):
|
||||
title_elem = ET.SubElement(episode_elem, "title")
|
||||
title_elem.text = title
|
||||
|
||||
# Add plot if provided and not already present
|
||||
if plot and not episode_elem.find("plot"):
|
||||
plot_elem = ET.SubElement(episode_elem, "plot")
|
||||
plot_elem.text = plot
|
||||
|
||||
# Add NFOGuard fields at the end
|
||||
season_elem = ET.SubElement(episode_elem, "season")
|
||||
season_elem.text = str(season_num)
|
||||
|
||||
episode_num_elem = ET.SubElement(episode_elem, "episode")
|
||||
episode_num_elem.text = str(episode_num)
|
||||
|
||||
if aired:
|
||||
aired_elem = ET.SubElement(episode_elem, "aired")
|
||||
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||
|
||||
# Also add premiered for compatibility
|
||||
premiered_elem = ET.SubElement(episode_elem, "premiered")
|
||||
premiered_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(episode_elem, "dateadded")
|
||||
dateadded_elem.text = dateadded
|
||||
|
||||
# Add lockdata
|
||||
lockdata_elem = ET.SubElement(episode_elem, "lockdata")
|
||||
lockdata_elem.text = "true"
|
||||
|
||||
# Add comment with source
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
|
||||
episode_elem.append(comment)
|
||||
|
||||
# Write the NFO file
|
||||
tree = ET.ElementTree(episode_elem)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
_log("INFO", f"✅ Created/updated episode NFO: {nfo_path.name}")
|
||||
_log("INFO", f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, DateAdded: {dateadded}, Source: {source}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to create episode NFO {nfo_path}: {e}")
|
||||
return False
|
||||
|
||||
def extract_nfoguard_data(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract NFOGuard-managed data from existing NFO"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
if root.tag != "episodedetails":
|
||||
return None
|
||||
|
||||
# Look for NFOGuard fields
|
||||
dateadded_elem = root.find("dateadded")
|
||||
aired_elem = root.find("aired")
|
||||
lockdata_elem = root.find("lockdata")
|
||||
|
||||
# Only consider it NFOGuard-managed if it has dateadded and lockdata
|
||||
if (dateadded_elem is not None and dateadded_elem.text and
|
||||
lockdata_elem is not None and lockdata_elem.text == "true"):
|
||||
|
||||
result = {
|
||||
"dateadded": dateadded_elem.text.strip(),
|
||||
"source": "existing_nfo"
|
||||
}
|
||||
|
||||
if aired_elem is not None and aired_elem.text:
|
||||
result["aired"] = aired_elem.text.strip()
|
||||
|
||||
_log("DEBUG", f"Found NFOGuard data in {nfo_path.name}: {result}")
|
||||
return result
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
_log("WARNING", f"Error parsing NFO {nfo_path.name}: {e}")
|
||||
|
||||
return None
|
||||
@@ -1,789 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NFO Manager for creating and managing metadata files
|
||||
Handles NFO creation for movies, TV shows, seasons, and episodes
|
||||
"""
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
import re
|
||||
|
||||
|
||||
class NFOManager:
|
||||
"""Manages NFO file creation and updates"""
|
||||
|
||||
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
|
||||
self.manager_brand = manager_brand
|
||||
self.debug = debug
|
||||
|
||||
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||
"""Extract IMDb ID from directory path or filename"""
|
||||
# Look for various IMDb patterns in both directory and file names
|
||||
path_str = str(path).lower()
|
||||
|
||||
# Try [imdb-ttXXXXXXX] format first (most explicit)
|
||||
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try standalone [ttXXXXXXX] format in brackets
|
||||
match = re.search(r'\[(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try {imdb-ttXXXXXXX} format with curly braces
|
||||
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try (imdb-ttXXXXXXX) format with parentheses
|
||||
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try ttXXXXXXX at end of filename/dirname (common pattern)
|
||||
match = re.search(r'[-_\s](tt\d+)$', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
|
||||
"""Extract IMDb ID from NFO file content"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
root = self._parse_nfo_with_tolerance(nfo_path)
|
||||
|
||||
# Check for <uniqueid type="imdb">ttXXXXXX</uniqueid>
|
||||
imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]')
|
||||
if imdb_uniqueid is not None and imdb_uniqueid.text:
|
||||
imdb_id = imdb_uniqueid.text.strip()
|
||||
if imdb_id.startswith('tt'):
|
||||
return imdb_id
|
||||
|
||||
# Check for legacy <imdbid>ttXXXXXX</imdbid>
|
||||
imdbid_elem = root.find('.//imdbid')
|
||||
if imdbid_elem is not None and imdbid_elem.text:
|
||||
imdb_id = imdbid_elem.text.strip()
|
||||
if imdb_id.startswith('tt'):
|
||||
return imdb_id
|
||||
|
||||
# Check for legacy <imdb>ttXXXXXX</imdb>
|
||||
imdb_elem = root.find('.//imdb')
|
||||
if imdb_elem is not None and imdb_elem.text:
|
||||
imdb_id = imdb_elem.text.strip()
|
||||
if imdb_id.startswith('tt'):
|
||||
return imdb_id
|
||||
|
||||
# Last resort: Check for TMDB ID as fallback identifier
|
||||
# This handles movies that only have TMDB IDs in NFO files
|
||||
tmdb_uniqueid = root.find('.//uniqueid[@type="tmdb"]')
|
||||
if tmdb_uniqueid is not None and tmdb_uniqueid.text:
|
||||
tmdb_id = tmdb_uniqueid.text.strip()
|
||||
if tmdb_id.isdigit():
|
||||
print(f"⚠️ Found TMDB ID {tmdb_id} but no IMDb ID - using TMDB ID as fallback")
|
||||
# Return TMDB ID with prefix to distinguish from IMDb IDs
|
||||
return f"tmdb-{tmdb_id}"
|
||||
|
||||
except (ET.ParseError, Exception):
|
||||
# Skip corrupted or non-XML files
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
|
||||
"""Find IMDb ID from directory name, filenames, or NFO file"""
|
||||
# First try directory name
|
||||
imdb_id = self.parse_imdb_from_path(movie_dir)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Try all files in the directory for IMDb ID patterns
|
||||
for file_path in movie_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
imdb_id = self.parse_imdb_from_path(file_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Finally, try NFO file content (including TMDB fallback)
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
imdb_id = self.parse_imdb_from_nfo(nfo_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
return None
|
||||
|
||||
def find_series_imdb_id(self, series_dir: Path) -> Optional[str]:
|
||||
"""Find IMDb ID from TV series directory name, filenames, or tvshow.nfo file"""
|
||||
# First try directory name
|
||||
imdb_id = self.parse_imdb_from_path(series_dir)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Try all files in the directory for IMDb ID patterns
|
||||
for file_path in series_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
imdb_id = self.parse_imdb_from_path(file_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Finally, try tvshow.nfo file content
|
||||
nfo_path = series_dir / "tvshow.nfo"
|
||||
imdb_id = self.parse_imdb_from_nfo(nfo_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
return None
|
||||
|
||||
def extract_nfoguard_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract NFOGuard-managed dates from existing NFO file"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Look for NFOGuard fields
|
||||
dateadded_elem = root.find('.//dateadded')
|
||||
premiered_elem = root.find('.//premiered')
|
||||
aired_elem = root.find('.//aired') # For TV episodes
|
||||
lockdata_elem = root.find('.//lockdata')
|
||||
|
||||
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
|
||||
if lockdata_elem is not None and lockdata_elem.text == "true":
|
||||
# Extract original source from NFOGuard comment, default to nfo_file_existing
|
||||
source = "nfo_file_existing"
|
||||
|
||||
# Parse XML content to find NFOGuard comment with source
|
||||
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||
import re
|
||||
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
|
||||
if source_match:
|
||||
source = source_match.group(1).strip()
|
||||
print(f"🔍 Extracted original source from NFO comment: {source}")
|
||||
|
||||
result = {
|
||||
"source": source
|
||||
}
|
||||
|
||||
if dateadded_elem is not None and dateadded_elem.text:
|
||||
result["dateadded"] = dateadded_elem.text.strip()
|
||||
|
||||
if premiered_elem is not None and premiered_elem.text:
|
||||
result["released"] = premiered_elem.text.strip()
|
||||
|
||||
if aired_elem is not None and aired_elem.text:
|
||||
result["aired"] = aired_elem.text.strip()
|
||||
|
||||
print(f"✅ Found NFOGuard data in NFO: dateadded={result.get('dateadded', 'None')}, source={source}, released={result.get('released', 'None')}, aired={result.get('aired', 'None')}")
|
||||
return result
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
print(f"⚠️ Error parsing NFO for NFOGuard data: {e}")
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]:
|
||||
"""Extract NFOGuard-managed dates from existing episode NFO file"""
|
||||
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
|
||||
nfo_path = season_path / nfo_filename
|
||||
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Look for NFOGuard fields in episode NFO
|
||||
dateadded_elem = root.find('.//dateadded')
|
||||
aired_elem = root.find('.//aired')
|
||||
lockdata_elem = root.find('.//lockdata')
|
||||
|
||||
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
|
||||
if lockdata_elem is not None and lockdata_elem.text == "true":
|
||||
# Extract original source from NFOGuard comment, default to episode_nfo_existing
|
||||
source = "episode_nfo_existing"
|
||||
|
||||
# Parse XML content to find NFOGuard comment with source
|
||||
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||
import re
|
||||
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
|
||||
if source_match:
|
||||
source = source_match.group(1).strip()
|
||||
print(f"🔍 Extracted original source from episode NFO comment: {source}")
|
||||
|
||||
result = {
|
||||
"source": source
|
||||
}
|
||||
|
||||
if dateadded_elem is not None and dateadded_elem.text:
|
||||
result["dateadded"] = dateadded_elem.text.strip()
|
||||
|
||||
if aired_elem is not None and aired_elem.text:
|
||||
result["aired"] = aired_elem.text.strip()
|
||||
|
||||
print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result.get('dateadded', 'None')}, source={source}, aired={result.get('aired', 'None')}")
|
||||
return result
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
print(f"⚠️ Error parsing episode NFO for NFOGuard data: {e}")
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _parse_nfo_with_tolerance(self, nfo_path: Path):
|
||||
"""Parse NFO file with tolerance for URLs appended after XML"""
|
||||
try:
|
||||
# First try normal parsing
|
||||
tree = ET.parse(nfo_path)
|
||||
return tree.getroot()
|
||||
except ET.ParseError as e:
|
||||
# If parsing fails, try to extract just the XML part
|
||||
try:
|
||||
with open(nfo_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find the last </movie> tag and truncate after it
|
||||
last_movie_end = content.rfind('</movie>')
|
||||
if last_movie_end != -1:
|
||||
xml_content = content[:last_movie_end + 8] # +8 for </movie>
|
||||
|
||||
# Try parsing the truncated content
|
||||
root = ET.fromstring(xml_content)
|
||||
print(f"✅ Successfully parsed NFO after removing trailing content: {nfo_path.name}")
|
||||
return root
|
||||
else:
|
||||
# Re-raise original error if we can't find </movie>
|
||||
raise e
|
||||
except Exception:
|
||||
# Re-raise original error if our fix attempt fails
|
||||
raise e
|
||||
|
||||
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
|
||||
released: Optional[str] = None, source: str = "unknown",
|
||||
lock_metadata: bool = True) -> None:
|
||||
"""Create or update movie.nfo file preserving existing content"""
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
|
||||
# Debug output only if DEBUG=true in environment
|
||||
import os
|
||||
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||
print(f"🔍 create_movie_nfo called: imdb_id={imdb_id}, dateadded={dateadded}, released={released}, source={source}")
|
||||
print(f"🔍 NFO path: {nfo_path}")
|
||||
print(f"🔍 NFO exists: {nfo_path.exists()}")
|
||||
|
||||
try:
|
||||
# Try to load existing NFO file
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
# Try to parse the XML, handling URLs appended after </movie>
|
||||
movie = self._parse_nfo_with_tolerance(nfo_path)
|
||||
|
||||
# Ensure root element is <movie>
|
||||
if movie.tag != "movie":
|
||||
raise ValueError("Root element is not <movie>")
|
||||
|
||||
# Only remove elements that are clearly NFOGuard-managed
|
||||
# Look for elements that have NFOGuard characteristics
|
||||
elements_to_remove = []
|
||||
|
||||
# Remove lockdata=true (this is definitely NFOGuard)
|
||||
for lockdata in movie.findall("lockdata"):
|
||||
if lockdata.text == "true":
|
||||
elements_to_remove.append(lockdata)
|
||||
|
||||
# Remove IMDb uniqueids that we manage
|
||||
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
|
||||
elements_to_remove.append(uniqueid)
|
||||
|
||||
# For dateadded/premiered/year, only remove if they appear to be NFOGuard-managed
|
||||
# (i.e., if lockdata=true exists, these are likely ours)
|
||||
has_nfoguard_lockdata = any(ld.text == "true" for ld in movie.findall("lockdata"))
|
||||
|
||||
if has_nfoguard_lockdata:
|
||||
# This NFO was managed by NFOGuard, safe to remove our fields
|
||||
for tag in ["dateadded", "premiered", "year"]:
|
||||
existing = movie.find(tag)
|
||||
if existing is not None:
|
||||
# Store the value before removing (for premiered/year)
|
||||
if tag == "premiered" and not released:
|
||||
print(f"🔍 Preserving existing premiered date: {existing.text}")
|
||||
released = existing.text
|
||||
elements_to_remove.append(existing)
|
||||
else:
|
||||
# No NFOGuard lockdata found, be more conservative
|
||||
# Only remove dateadded if it looks like NFOGuard format (ISO timestamp)
|
||||
dateadded_elem = movie.find("dateadded")
|
||||
if dateadded_elem is not None and dateadded_elem.text:
|
||||
# NFOGuard uses ISO format like "2025-10-12 16:26:02"
|
||||
if re.match(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', dateadded_elem.text.strip()):
|
||||
elements_to_remove.append(dateadded_elem)
|
||||
|
||||
# Remove all identified elements
|
||||
for elem in elements_to_remove:
|
||||
movie.remove(elem)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean NFO file to replace corrupted one")
|
||||
movie = ET.Element("movie")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
movie = ET.Element("movie")
|
||||
|
||||
# Create all NFOGuard elements first, then append them in correct order
|
||||
# This ensures they appear as a group at the bottom of the file
|
||||
nfoguard_elements = []
|
||||
|
||||
# Add NFOGuard comment marker as the first of our additions
|
||||
nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ")
|
||||
nfoguard_elements.append(nfoguard_comment)
|
||||
|
||||
# Add IMDb uniqueid
|
||||
uniqueid = ET.Element("uniqueid", type="imdb", default="true")
|
||||
uniqueid.text = imdb_id
|
||||
nfoguard_elements.append(uniqueid)
|
||||
|
||||
# Add premiered date if we have it
|
||||
if released:
|
||||
premiered_elem = ET.Element("premiered")
|
||||
premiered_elem.text = released[:10] if len(released) >= 10 else released
|
||||
nfoguard_elements.append(premiered_elem)
|
||||
|
||||
# Extract year from premiered date for consistency
|
||||
try:
|
||||
year_value = released[:4] if len(released) >= 4 else None
|
||||
if year_value and year_value.isdigit():
|
||||
year_elem = ET.Element("year")
|
||||
year_elem.text = year_value
|
||||
nfoguard_elements.append(year_elem)
|
||||
except:
|
||||
pass # Skip year if we can't extract it
|
||||
|
||||
# Add dateadded - THIS IS CRITICAL FOR EMBY PLUGIN
|
||||
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||
print(f"🔍 About to add dateadded: {dateadded} (type: {type(dateadded)})")
|
||||
if dateadded:
|
||||
dateadded_elem = ET.Element("dateadded")
|
||||
dateadded_elem.text = dateadded
|
||||
nfoguard_elements.append(dateadded_elem)
|
||||
print(f"✅ Adding dateadded to NFO: {dateadded}")
|
||||
else:
|
||||
print(f"❌ dateadded is empty/None, not adding to NFO")
|
||||
|
||||
# Add lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.Element("lockdata")
|
||||
lockdata.text = "true"
|
||||
nfoguard_elements.append(lockdata)
|
||||
|
||||
# Now append all NFOGuard elements to the movie in one batch
|
||||
# This ensures they appear as a contiguous block at the bottom
|
||||
for elem in nfoguard_elements:
|
||||
movie.append(elem)
|
||||
|
||||
print(f"✅ Added {len(nfoguard_elements)} NFOGuard elements to bottom of NFO")
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(movie)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write directly to file (comment is already embedded in XML)
|
||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||
f.write('<?xml version="1.0" encoding="utf-8"?>\n')
|
||||
tree.write(f, encoding='unicode', xml_declaration=False)
|
||||
|
||||
print(f"✅ Successfully created/updated movie NFO: {nfo_path}")
|
||||
print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}")
|
||||
|
||||
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
|
||||
"""Create or update tvshow.nfo file preserving existing content"""
|
||||
nfo_path = series_dir / "tvshow.nfo"
|
||||
|
||||
try:
|
||||
# Try to load existing NFO file
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
tvshow = tree.getroot()
|
||||
|
||||
# Ensure root element is <tvshow>
|
||||
if tvshow.tag != "tvshow":
|
||||
raise ValueError("Root element is not <tvshow>")
|
||||
|
||||
# Remove existing NFOGuard-managed elements to avoid duplicates
|
||||
# These will be re-added at the bottom
|
||||
for tag in ["lockdata"]:
|
||||
existing = tvshow.find(tag)
|
||||
if existing is not None:
|
||||
tvshow.remove(existing)
|
||||
|
||||
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
|
||||
for uniqueid in tvshow.findall("uniqueid[@type='imdb']"):
|
||||
tvshow.remove(uniqueid)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean tvshow.nfo file to replace corrupted one")
|
||||
tvshow = ET.Element("tvshow")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
tvshow = ET.Element("tvshow")
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
|
||||
# Add IMDb uniqueid at the end
|
||||
imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true")
|
||||
imdb_uniqueid.text = imdb_id
|
||||
|
||||
# Add TVDB ID if available (preserve existing or add new)
|
||||
if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"):
|
||||
tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
|
||||
tvdb_uniqueid.text = tvdb_id
|
||||
|
||||
# Add lockdata at the very end
|
||||
lockdata = ET.SubElement(tvshow, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} "
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(tvshow)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write to string first to add comment
|
||||
xml_str = ET.tostring(tvshow, encoding='unicode')
|
||||
|
||||
# Add XML declaration and comment
|
||||
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||
|
||||
# Write to file
|
||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_xml)
|
||||
|
||||
print(f"✅ Successfully created/updated TV show NFO: {nfo_path}")
|
||||
print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else ""))
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}")
|
||||
|
||||
def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
|
||||
"""Create or update season.nfo file preserving existing content"""
|
||||
nfo_path = season_dir / "season.nfo"
|
||||
|
||||
try:
|
||||
season_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Try to load existing NFO file
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
season = tree.getroot()
|
||||
|
||||
# Ensure root element is <season>
|
||||
if season.tag != "season":
|
||||
raise ValueError("Root element is not <season>")
|
||||
|
||||
# Remove existing NFOGuard-managed elements
|
||||
for tag in ["seasonnumber", "lockdata"]:
|
||||
existing = season.find(tag)
|
||||
if existing is not None:
|
||||
season.remove(existing)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean season.nfo file to replace corrupted one")
|
||||
season = ET.Element("season")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
season = ET.Element("season")
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
seasonnumber = ET.SubElement(season, "seasonnumber")
|
||||
seasonnumber.text = str(season_number)
|
||||
|
||||
# Add lockdata at the end
|
||||
lockdata = ET.SubElement(season, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} "
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(season)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write to string first to add comment
|
||||
xml_str = ET.tostring(season, encoding='unicode')
|
||||
|
||||
# Add XML declaration and comment
|
||||
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||
|
||||
# Write to file
|
||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_xml)
|
||||
|
||||
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
|
||||
print(f" Season: {season_number}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
|
||||
|
||||
def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
||||
"""Find any existing episode NFO file that matches season/episode"""
|
||||
if not season_dir.exists():
|
||||
return None
|
||||
|
||||
# Look for NFO files in the season directory
|
||||
for nfo_file in season_dir.glob("*.nfo"):
|
||||
|
||||
# Check if this NFO contains the right season/episode
|
||||
try:
|
||||
tree = ET.parse(nfo_file)
|
||||
root = tree.getroot()
|
||||
|
||||
if root.tag == "episodedetails":
|
||||
# Check for season/episode elements
|
||||
season_elem = root.find("season")
|
||||
episode_elem = root.find("episode")
|
||||
|
||||
if (season_elem is not None and episode_elem is not None and
|
||||
season_elem.text and episode_elem.text):
|
||||
try:
|
||||
file_season = int(season_elem.text)
|
||||
file_episode = int(episode_elem.text)
|
||||
|
||||
if file_season == season_num and file_episode == episode_num:
|
||||
print(f"🔍 Found existing episode NFO: {nfo_file.name}")
|
||||
return nfo_file
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
except (ET.ParseError, Exception):
|
||||
# Skip corrupted or non-XML files
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _get_target_episode_nfo_name(self, season_dir: Path, season_num: int, episode_num: int) -> str:
|
||||
"""Get target NFO filename - prefer existing NFO, then matching video file, fallback to short name"""
|
||||
if not season_dir.exists():
|
||||
return f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
|
||||
# First check if an NFO already exists for this episode
|
||||
existing_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
|
||||
if existing_nfo:
|
||||
print(f"📂 Existing NFO found, will preserve filename: {existing_nfo.name}")
|
||||
return existing_nfo.name
|
||||
|
||||
# Look for video files with matching season/episode
|
||||
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".flv", ".webm"]
|
||||
|
||||
for video_file in season_dir.iterdir():
|
||||
if (video_file.is_file() and
|
||||
video_file.suffix.lower() in video_extensions):
|
||||
|
||||
# Parse episode info from video filename
|
||||
episode_info = self._parse_episode_from_filename(video_file.name)
|
||||
if episode_info and episode_info == (season_num, episode_num):
|
||||
# Found matching video file - use its name for NFO
|
||||
target_nfo_name = f"{video_file.stem}.nfo"
|
||||
print(f"🎯 Target NFO will match video: {target_nfo_name}")
|
||||
return target_nfo_name
|
||||
|
||||
# Fallback to short name if no matching video found
|
||||
short_name = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
print(f"⚠️ No matching video file found, using short name: {short_name}")
|
||||
return short_name
|
||||
|
||||
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
|
||||
"""Parse season and episode numbers from filename"""
|
||||
# Try S##E## format first
|
||||
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
# Try ##x## format
|
||||
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
return None
|
||||
|
||||
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
||||
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Create or update episode NFO file preserving existing content"""
|
||||
# Get target NFO filename (prefer long name matching video file)
|
||||
target_nfo_name = self._get_target_episode_nfo_name(season_dir, season_num, episode_num)
|
||||
nfo_path = season_dir / target_nfo_name
|
||||
|
||||
try:
|
||||
# Check for existing NFO file at target location
|
||||
source_nfo_path = nfo_path if nfo_path.exists() else None
|
||||
|
||||
if source_nfo_path:
|
||||
try:
|
||||
tree = ET.parse(source_nfo_path)
|
||||
episode = tree.getroot()
|
||||
|
||||
# Ensure root element is <episodedetails>
|
||||
if episode.tag != "episodedetails":
|
||||
raise ValueError("Root element is not <episodedetails>")
|
||||
|
||||
print(f"📝 Updating existing NFO: {nfo_path.name}")
|
||||
|
||||
# Preserve existing content fields and store NFOGuard-managed fields for re-adding
|
||||
preserved_values = {}
|
||||
|
||||
# Extract and preserve NFOGuard-managed fields (we'll re-add these at the bottom)
|
||||
nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
|
||||
for tag in nfoguard_fields:
|
||||
existing = episode.find(tag)
|
||||
if existing is not None:
|
||||
# Store the value before removing
|
||||
if tag == "aired" and not aired:
|
||||
aired = existing.text # Preserve existing aired date
|
||||
preserved_values[tag] = existing.text
|
||||
episode.remove(existing)
|
||||
|
||||
# Important: DO NOT remove content fields like title, plot, runtime, premiered, etc.
|
||||
# These should be preserved from the long-named NFO files
|
||||
|
||||
# Debug: Show what fields are preserved after removing NFOGuard fields (if DEBUG enabled)
|
||||
if self.debug:
|
||||
preserved_fields = [elem.tag for elem in episode]
|
||||
if preserved_fields:
|
||||
print(f" 🔍 Content preserved after cleanup: {', '.join(preserved_fields)}")
|
||||
else:
|
||||
print(f" ℹ️ NFO contains only NFOGuard metadata (no additional content fields)")
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean episode NFO file to replace corrupted one")
|
||||
episode = ET.Element("episodedetails")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
episode = ET.Element("episodedetails")
|
||||
|
||||
# Add enhanced metadata only if not already present (preserve existing from long-named NFO)
|
||||
if enhanced_metadata:
|
||||
if enhanced_metadata.get("title") and not episode.find("title"):
|
||||
title_elem = ET.SubElement(episode, "title")
|
||||
title_elem.text = enhanced_metadata["title"]
|
||||
|
||||
if enhanced_metadata.get("overview") and not episode.find("plot"):
|
||||
plot_elem = ET.SubElement(episode, "plot")
|
||||
plot_elem.text = enhanced_metadata["overview"]
|
||||
|
||||
if enhanced_metadata.get("runtime") and not episode.find("runtime"):
|
||||
runtime_elem = ET.SubElement(episode, "runtime")
|
||||
runtime_elem.text = str(enhanced_metadata["runtime"])
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
|
||||
# Basic episode info at the end
|
||||
season_elem = ET.SubElement(episode, "season")
|
||||
season_elem.text = str(season_num)
|
||||
|
||||
episode_elem = ET.SubElement(episode, "episode")
|
||||
episode_elem.text = str(episode_num)
|
||||
|
||||
# Dates at the end
|
||||
if aired:
|
||||
aired_elem = ET.SubElement(episode, "aired")
|
||||
# Convert datetime objects to strings
|
||||
aired_str = str(aired)
|
||||
aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str
|
||||
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(episode, "dateadded")
|
||||
# Convert datetime objects to strings
|
||||
dateadded_elem.text = str(dateadded)
|
||||
|
||||
# Add lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.SubElement(episode, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} - Source: {source} "
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(episode)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write to string first to add comment
|
||||
xml_str = ET.tostring(episode, encoding='unicode')
|
||||
|
||||
# Add XML declaration and comment
|
||||
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||
|
||||
# Write to file
|
||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_xml)
|
||||
|
||||
print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
|
||||
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
|
||||
|
||||
# NFO file created/updated successfully
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
|
||||
|
||||
def set_file_mtime(self, file_path: Path, iso_timestamp) -> None:
|
||||
"""Set file modification time to match import date"""
|
||||
try:
|
||||
# Convert datetime objects to strings first
|
||||
if hasattr(iso_timestamp, 'isoformat'):
|
||||
iso_timestamp = iso_timestamp.isoformat()
|
||||
elif not isinstance(iso_timestamp, str):
|
||||
iso_timestamp = str(iso_timestamp)
|
||||
|
||||
# Parse ISO timestamp
|
||||
if iso_timestamp.endswith('Z'):
|
||||
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
|
||||
elif '+' in iso_timestamp or 'T' in iso_timestamp:
|
||||
dt = datetime.fromisoformat(iso_timestamp)
|
||||
elif ' ' in iso_timestamp:
|
||||
# Handle space-separated datetime format (e.g., "2025-10-16 20:31:22")
|
||||
dt = datetime.fromisoformat(iso_timestamp.replace(' ', 'T'))
|
||||
else:
|
||||
# Assume it's a simple date (e.g., "2025-10-16")
|
||||
dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00')
|
||||
|
||||
# Convert to timestamp
|
||||
timestamp = dt.timestamp()
|
||||
|
||||
# Set both access and modification times
|
||||
os.utime(file_path, (timestamp, timestamp))
|
||||
print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error setting mtime for {file_path}: {e}")
|
||||
|
||||
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
|
||||
"""Update modification times for all video files in movie directory"""
|
||||
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
||||
updated_files = []
|
||||
|
||||
for file_path in movie_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in video_exts:
|
||||
self.set_file_mtime(file_path, iso_timestamp)
|
||||
updated_files.append(file_path.name)
|
||||
|
||||
if updated_files:
|
||||
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
|
||||
else:
|
||||
print(f"⚠️ No video files found to update in {movie_dir.name}")
|
||||
@@ -0,0 +1,144 @@
|
||||
# NFOGuard Production Docker Compose - 3-Container Architecture
|
||||
#
|
||||
# RECOMMENDED SETUP: Separated core processing and web interface for optimal performance
|
||||
#
|
||||
# This is the default configuration providing:
|
||||
# - Performance isolation between web and processing
|
||||
# - Webhook responsiveness during scans
|
||||
# - Independent scaling and updates
|
||||
# - Professional web interface with branding
|
||||
#
|
||||
# For legacy single-container setup, see: docker-compose.legacy-single.yml
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
nfoguard-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: nfoguard-db
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_EXTERNAL_PORT:-5432}:5432" # Optional external access
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-nfoguard} -d ${DB_NAME:-nfoguard}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- nfoguard-network
|
||||
|
||||
# NFOGuard Core (Processing Engine)
|
||||
nfoguard:
|
||||
image: sbcrumb/nfoguard:latest
|
||||
container_name: nfoguard-core
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
- WEB_EXTERNAL_PORT=${WEB_EXTERNAL_PORT}
|
||||
volumes:
|
||||
# Media paths (adjust to your setup)
|
||||
- /mnt/unionfs/Media/TV:/media/TV:ro
|
||||
- /mnt/unionfs/Media/Movies:/media/Movies:ro
|
||||
# Data persistence
|
||||
- nfoguard_data:/app/data
|
||||
# Logs
|
||||
- nfoguard_logs:/app/data/logs
|
||||
ports:
|
||||
- "${CORE_API_PORT:-8080}:8080" # Core API (webhooks, processing)
|
||||
depends_on:
|
||||
nfoguard-db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- nfoguard-network
|
||||
|
||||
# NFOGuard Web Interface
|
||||
nfoguard-web:
|
||||
image: sbcrumb/nfoguard:latest # Same image as core!
|
||||
container_name: nfoguard-web
|
||||
restart: unless-stopped
|
||||
command: ["python", "start_web.py"] # Different entry point
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
environment:
|
||||
- TZ=${TZ:-America/New_York}
|
||||
ports:
|
||||
- "${WEB_API_PORT:-8081}:8081" # Web Interface
|
||||
depends_on:
|
||||
nfoguard-db:
|
||||
condition: service_healthy
|
||||
nfoguard:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- nfoguard-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
nfoguard_data:
|
||||
driver: local
|
||||
nfoguard_logs:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
nfoguard-network:
|
||||
driver: bridge
|
||||
|
||||
# Configuration Notes:
|
||||
# 1. Core Processing (nfoguard): Handles webhooks, scanning, NFO management
|
||||
# 2. Web Interface (nfoguard-web): Lightweight dashboard and management
|
||||
# 3. Database (nfoguard-db): Shared PostgreSQL database
|
||||
#
|
||||
# Port Configuration:
|
||||
# - Core API: ${CORE_API_PORT:-8080} (webhooks, processing)
|
||||
# - Web Interface: ${WEB_API_PORT:-8081} (dashboard)
|
||||
# - Database: ${DB_EXTERNAL_PORT:-5432} (optional external access)
|
||||
#
|
||||
# Performance Benefits:
|
||||
# - Web interface operations don't impact core processing
|
||||
# - Webhooks remain responsive during long scans
|
||||
# - Independent scaling and resource allocation
|
||||
# - Separated concerns for maintenance and updates
|
||||
# NFOGuard Core (Processing Engine)
|
||||
nfoguard:
|
||||
# ... other settings ...
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/simple"]
|
||||
interval: 30s
|
||||
timeout: 15s # Increased from 10s
|
||||
retries: 3
|
||||
start_period: 60s # Increased from 40s
|
||||
|
||||
# NFOGuard Web Interface
|
||||
nfoguard-web:
|
||||
# ... other settings ...
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
interval: 30s
|
||||
timeout: 15s # Increased from 10s
|
||||
retries: 3
|
||||
start_period: 30s # Increased from 10s
|
||||
@@ -1,3 +1,14 @@
|
||||
# NFOGuard Legacy Single-Container Configuration
|
||||
#
|
||||
# DEPRECATED: This is the legacy single-container setup where web interface
|
||||
# and core processing run in the same container, which can cause performance
|
||||
# issues during intensive scans.
|
||||
#
|
||||
# RECOMMENDED: Use docker-compose.example.yml for the new 3-container
|
||||
# architecture with better performance isolation.
|
||||
#
|
||||
# This file is maintained for backward compatibility and migration purposes.
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NFOGuard - Automated NFO file management for Radarr and Sonarr
|
||||
Modular architecture with webhook processing and intelligent date handling
|
||||
NFOGuard Core - Automated NFO file management and processing engine
|
||||
Core processing container with webhooks, scanning, and database management
|
||||
Web interface separated to nfoguard-web container
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -16,13 +17,12 @@ from fastapi import FastAPI
|
||||
# Import configuration first
|
||||
from config.settings import config
|
||||
|
||||
# Import authentication
|
||||
from api.auth import SimpleAuthMiddleware, create_auth_dependencies
|
||||
# Authentication removed - handled by separate web container
|
||||
from utils.logging import _log
|
||||
|
||||
# Import core components
|
||||
from core.database import NFOGuardDatabase
|
||||
from core.nfo_manager import NFOManager
|
||||
# from core.nfo_manager import NFOManager # Phase 3: Removed - no longer needed
|
||||
from core.path_mapper import PathMapper
|
||||
|
||||
# Import clients
|
||||
@@ -93,20 +93,20 @@ def initialize_components():
|
||||
|
||||
# Initialize core components
|
||||
db = NFOGuardDatabase(config=config)
|
||||
nfo_manager = NFOManager(config.manager_brand, config.debug)
|
||||
# nfo_manager = NFOManager(config.manager_brand, config.debug) # Phase 3: Removed
|
||||
path_mapper = PathMapper(config)
|
||||
|
||||
# Initialize processors
|
||||
tv_processor = TVProcessor(db, nfo_manager, path_mapper)
|
||||
movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
|
||||
# Initialize processors (nfo_manager=None for backward compatibility)
|
||||
tv_processor = TVProcessor(db, None, path_mapper)
|
||||
movie_processor = MovieProcessor(db, None, path_mapper)
|
||||
|
||||
# Initialize webhook batcher with nfo_manager for comprehensive IMDb detection
|
||||
batcher = WebhookBatcher(nfo_manager)
|
||||
# Initialize webhook batcher (no longer needs nfo_manager - Phase 3)
|
||||
batcher = WebhookBatcher(nfo_manager=None)
|
||||
batcher.set_processors(tv_processor, movie_processor)
|
||||
|
||||
return {
|
||||
"db": db,
|
||||
"nfo_manager": nfo_manager,
|
||||
# "nfo_manager": nfo_manager, # Phase 3: Removed
|
||||
"path_mapper": path_mapper,
|
||||
"tv_processor": tv_processor,
|
||||
"movie_processor": movie_processor,
|
||||
@@ -146,6 +146,18 @@ def signal_handler(signum, frame):
|
||||
_log("WARNING", f"Error closing database: {e}")
|
||||
|
||||
_log("INFO", "Graceful shutdown complete")
|
||||
|
||||
# Force exit after 2 seconds if graceful shutdown doesn't work
|
||||
import threading
|
||||
def force_exit():
|
||||
import time
|
||||
time.sleep(2)
|
||||
_log("WARNING", "Force exiting after timeout")
|
||||
os._exit(0)
|
||||
|
||||
force_thread = threading.Thread(target=force_exit, daemon=True)
|
||||
force_thread.start()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@@ -175,16 +187,8 @@ def main():
|
||||
# Initialize components
|
||||
dependencies = initialize_components()
|
||||
|
||||
# Add authentication dependencies
|
||||
auth_deps = create_auth_dependencies(config)
|
||||
dependencies.update(auth_deps)
|
||||
|
||||
# Add authentication middleware if enabled
|
||||
if config.web_auth_enabled:
|
||||
app.add_middleware(SimpleAuthMiddleware, config=config)
|
||||
_log("INFO", f"Web authentication enabled for user: {config.web_auth_username}")
|
||||
else:
|
||||
_log("INFO", "Web authentication disabled - web interface is public")
|
||||
# Note: Authentication and web interface handled by separate nfoguard-web container
|
||||
_log("INFO", "Core API: Authentication handled by separate web container")
|
||||
|
||||
# Store dependencies globally for signal handler access
|
||||
signal_handler.dependencies = dependencies
|
||||
@@ -193,10 +197,16 @@ def main():
|
||||
register_routes(app, dependencies)
|
||||
|
||||
try:
|
||||
# Core API configuration (webhooks, processing, database management)
|
||||
core_host = config.core_api_host if hasattr(config, 'core_api_host') else "0.0.0.0"
|
||||
core_port = config.core_api_port if hasattr(config, 'core_api_port') else 8080
|
||||
|
||||
_log("INFO", f"🚀 Starting NFOGuard Core API on {core_host}:{core_port}")
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=int(os.environ.get("PORT", "8080")),
|
||||
host=core_host,
|
||||
port=core_port,
|
||||
reload=False,
|
||||
access_log=False, # Reduce logging overhead
|
||||
server_header=False, # Reduce response overhead
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# NFOGuard Web Interface Package
|
||||
@@ -0,0 +1 @@
|
||||
# NFOGuard Web API Package
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Simple authentication middleware for NFOGuard web interface
|
||||
Provides basic HTTP auth and session management for web interface protection
|
||||
"""
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import HTTPException, status, Request, Response
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
class AuthSession:
|
||||
"""Simple session management for web interface"""
|
||||
|
||||
def __init__(self, timeout_seconds: int = 3600):
|
||||
self.sessions: Dict[str, Dict[str, Any]] = {}
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
def create_session(self, username: str) -> str:
|
||||
"""Create a new session and return session token"""
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
self.sessions[session_token] = {
|
||||
"username": username,
|
||||
"created_at": datetime.utcnow(),
|
||||
"last_activity": datetime.utcnow()
|
||||
}
|
||||
return session_token
|
||||
|
||||
def validate_session(self, session_token: str) -> bool:
|
||||
"""Validate session token and update last activity"""
|
||||
if not session_token or session_token not in self.sessions:
|
||||
return False
|
||||
|
||||
session = self.sessions[session_token]
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Check if session expired
|
||||
if (now - session["last_activity"]).seconds > self.timeout_seconds:
|
||||
del self.sessions[session_token]
|
||||
return False
|
||||
|
||||
# Update last activity
|
||||
session["last_activity"] = now
|
||||
return True
|
||||
|
||||
def get_session_user(self, session_token: str) -> Optional[str]:
|
||||
"""Get username from valid session"""
|
||||
if self.validate_session(session_token):
|
||||
return self.sessions[session_token]["username"]
|
||||
return None
|
||||
|
||||
def delete_session(self, session_token: str) -> None:
|
||||
"""Delete a session (logout)"""
|
||||
if session_token in self.sessions:
|
||||
del self.sessions[session_token]
|
||||
|
||||
def cleanup_expired_sessions(self) -> None:
|
||||
"""Remove expired sessions"""
|
||||
now = datetime.utcnow()
|
||||
expired_tokens = []
|
||||
|
||||
for token, session in self.sessions.items():
|
||||
if (now - session["last_activity"]).seconds > self.timeout_seconds:
|
||||
expired_tokens.append(token)
|
||||
|
||||
for token in expired_tokens:
|
||||
del self.sessions[token]
|
||||
|
||||
|
||||
class SimpleAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Simple authentication middleware for web interface routes"""
|
||||
|
||||
def __init__(self, app, config):
|
||||
super().__init__(app)
|
||||
self.config = config
|
||||
self.session_manager = AuthSession(config.web_auth_session_timeout)
|
||||
self.security = HTTPBasic()
|
||||
|
||||
# Routes that require authentication (web interface)
|
||||
self.protected_routes = [
|
||||
"/", # Main web interface
|
||||
"/static/", # Static files (CSS, JS)
|
||||
"/api/movies", # Web API endpoints
|
||||
"/api/series",
|
||||
"/api/episodes",
|
||||
"/api/dashboard"
|
||||
]
|
||||
|
||||
# Routes that are always public (webhooks, health checks, API endpoints)
|
||||
self.public_routes = [
|
||||
"/webhook/",
|
||||
"/health",
|
||||
"/ping",
|
||||
"/api/v1/health",
|
||||
"/api/v1/metrics",
|
||||
"/database/", # Database management endpoints (API access)
|
||||
"/manual/", # Manual scan endpoints (API access)
|
||||
"/debug/", # Debug endpoints (API access)
|
||||
"/test/", # Test endpoints (API access)
|
||||
"/bulk/" # Bulk operation endpoints (API access)
|
||||
]
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Process request through authentication middleware"""
|
||||
|
||||
# Skip authentication if disabled
|
||||
if not self.config.web_auth_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Check if route requires authentication
|
||||
path = request.url.path
|
||||
needs_auth = any(path.startswith(route) for route in self.protected_routes)
|
||||
is_public = any(path.startswith(route) for route in self.public_routes)
|
||||
|
||||
if is_public or not needs_auth:
|
||||
return await call_next(request)
|
||||
|
||||
# Check for existing session
|
||||
session_token = request.cookies.get("nfoguard_session")
|
||||
if session_token and self.session_manager.validate_session(session_token):
|
||||
# Valid session, proceed
|
||||
return await call_next(request)
|
||||
|
||||
# Check for HTTP Basic Auth
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.startswith("Basic "):
|
||||
credentials = self._parse_basic_auth(auth_header)
|
||||
if credentials and self._validate_credentials(credentials.username, credentials.password):
|
||||
# Create session for successful login
|
||||
session_token = self.session_manager.create_session(credentials.username)
|
||||
response = await call_next(request)
|
||||
response.set_cookie(
|
||||
key="nfoguard_session",
|
||||
value=session_token,
|
||||
max_age=self.config.web_auth_session_timeout,
|
||||
httponly=True,
|
||||
secure=False # Set to True if using HTTPS
|
||||
)
|
||||
return response
|
||||
|
||||
# Authentication required
|
||||
return self._auth_required_response()
|
||||
|
||||
def _parse_basic_auth(self, auth_header: str) -> Optional[HTTPBasicCredentials]:
|
||||
"""Parse HTTP Basic Auth header"""
|
||||
try:
|
||||
import base64
|
||||
encoded_credentials = auth_header.split(" ")[1]
|
||||
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
|
||||
username, password = decoded_credentials.split(":", 1)
|
||||
return HTTPBasicCredentials(username=username, password=password)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _validate_credentials(self, username: str, password: str) -> bool:
|
||||
"""Validate username and password"""
|
||||
return (username == self.config.web_auth_username and
|
||||
password == self.config.web_auth_password)
|
||||
|
||||
def _auth_required_response(self) -> Response:
|
||||
"""Return 401 response with WWW-Authenticate header"""
|
||||
return Response(
|
||||
content="Authentication required",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
headers={"WWW-Authenticate": "Basic realm=\"NFOGuard Web Interface\""}
|
||||
)
|
||||
|
||||
|
||||
def create_auth_dependencies(config) -> Dict[str, Any]:
|
||||
"""Create authentication-related dependencies for dependency injection"""
|
||||
session_manager = AuthSession(config.web_auth_session_timeout)
|
||||
|
||||
return {
|
||||
"session_manager": session_manager,
|
||||
"auth_enabled": config.web_auth_enabled,
|
||||
"auth_config": {
|
||||
"username": config.web_auth_username,
|
||||
"timeout": config.web_auth_session_timeout
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
NFOGuard Web Interface Configuration
|
||||
Lightweight configuration for web-only container
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
def _bool_env(name: str, default: bool = False) -> bool:
|
||||
"""Convert environment variable to boolean"""
|
||||
value = os.environ.get(name, "").lower()
|
||||
return value in ("true", "1", "yes", "on")
|
||||
|
||||
|
||||
class WebConfig:
|
||||
"""Configuration for NFOGuard Web Interface"""
|
||||
|
||||
def __init__(self):
|
||||
self._load_server_settings()
|
||||
self._load_database_settings()
|
||||
self._load_auth_settings()
|
||||
self._load_ui_settings()
|
||||
|
||||
def _load_server_settings(self) -> None:
|
||||
"""Load web server configuration"""
|
||||
self.web_host = os.environ.get("WEB_HOST", "0.0.0.0")
|
||||
self.web_port = int(os.environ.get("WEB_PORT", "8081"))
|
||||
self.web_workers = int(os.environ.get("WEB_WORKERS", "1"))
|
||||
self.web_debug = _bool_env("WEB_DEBUG", False)
|
||||
|
||||
# Core NFOGuard API connection (for some operations)
|
||||
self.core_api_host = os.environ.get("CORE_API_HOST", "nfoguard")
|
||||
self.core_api_port = int(os.environ.get("CORE_API_PORT", "8080"))
|
||||
self.core_api_url = f"http://{self.core_api_host}:{self.core_api_port}"
|
||||
|
||||
def _load_database_settings(self) -> None:
|
||||
"""Load database configuration (read-only access)"""
|
||||
self.db_type = os.environ.get("DB_TYPE", "postgresql").lower()
|
||||
self.db_host = os.environ.get("DB_HOST", "nfoguard-db")
|
||||
self.db_port = int(os.environ.get("DB_PORT", "5432"))
|
||||
self.db_name = os.environ.get("DB_NAME", "nfoguard")
|
||||
self.db_user = os.environ.get("DB_USER", "nfoguard")
|
||||
self.db_password = os.environ.get("DB_PASSWORD", "")
|
||||
|
||||
if not self.db_password:
|
||||
raise ValueError("DB_PASSWORD must be set for web interface database access")
|
||||
|
||||
def _load_auth_settings(self) -> None:
|
||||
"""Load web interface authentication settings"""
|
||||
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
|
||||
self.web_auth_username = os.environ.get("WEB_AUTH_USERNAME", "admin")
|
||||
self.web_auth_password = os.environ.get("WEB_AUTH_PASSWORD", "")
|
||||
self.web_auth_session_timeout = int(os.environ.get("WEB_AUTH_SESSION_TIMEOUT", "3600"))
|
||||
|
||||
if self.web_auth_enabled and not self.web_auth_password:
|
||||
raise ValueError("WEB_AUTH_PASSWORD must be set when authentication is enabled")
|
||||
|
||||
def _load_ui_settings(self) -> None:
|
||||
"""Load UI-specific settings"""
|
||||
self.app_title = os.environ.get("APP_TITLE", "NFOGuard")
|
||||
self.app_subtitle = os.environ.get("APP_SUBTITLE", "Database Management & Reporting")
|
||||
self.pagination_limit = int(os.environ.get("PAGINATION_LIMIT", "50"))
|
||||
self.refresh_interval = int(os.environ.get("REFRESH_INTERVAL", "30")) # seconds
|
||||
|
||||
# Logo configuration
|
||||
self.logo_enabled = _bool_env("LOGO_ENABLED", True)
|
||||
self.logo_path = "/static/logo/NFOguardLogoPlain.png"
|
||||
|
||||
|
||||
# Global config instance
|
||||
web_config = WebConfig()
|
||||
@@ -0,0 +1 @@
|
||||
# NFOGuard Web Core Components
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
NFOGuard Web Database - Lightweight Read-Only Database Access
|
||||
Optimized for web interface queries with minimal dependencies
|
||||
"""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDatabase:
|
||||
"""Lightweight database access for web interface"""
|
||||
|
||||
def __init__(self, db_type: str, host: str, port: int, database: str, user: str, password: str):
|
||||
self.db_type = db_type.lower()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.database = database
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.connection = None
|
||||
|
||||
# Connect to database
|
||||
self._connect()
|
||||
|
||||
def _connect(self):
|
||||
"""Connect to PostgreSQL database"""
|
||||
if self.db_type != "postgresql":
|
||||
raise ValueError("Web interface only supports PostgreSQL")
|
||||
|
||||
try:
|
||||
self.connection = psycopg2.connect(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
database=self.database,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
cursor_factory=psycopg2.extras.RealDictCursor
|
||||
)
|
||||
# Set to autocommit for read operations
|
||||
self.connection.autocommit = True
|
||||
logger.info(f"Connected to PostgreSQL: {self.host}:{self.port}/{self.database}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to database: {e}")
|
||||
raise
|
||||
|
||||
def execute_query(self, query: str, params: Optional[Tuple] = None) -> List[Dict[str, Any]]:
|
||||
"""Execute a SELECT query and return results"""
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Query failed: {query[:100]}... Error: {e}")
|
||||
raise
|
||||
|
||||
def execute_single(self, query: str, params: Optional[Tuple] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Execute a query and return single result"""
|
||||
results = self.execute_query(query, params)
|
||||
return results[0] if results else None
|
||||
|
||||
def execute_scalar(self, query: str, params: Optional[Tuple] = None) -> Any:
|
||||
"""Execute a query and return single value"""
|
||||
result = self.execute_single(query, params)
|
||||
return list(result.values())[0] if result else None
|
||||
|
||||
# Dashboard Statistics
|
||||
def get_dashboard_stats(self) -> Dict[str, Any]:
|
||||
"""Get dashboard statistics"""
|
||||
stats = {}
|
||||
|
||||
# Movie statistics
|
||||
movie_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_movies,
|
||||
COUNT(CASE WHEN dateadded IS NOT NULL AND source != 'unknown' THEN 1 END) as movies_with_dates,
|
||||
COUNT(CASE WHEN dateadded IS NULL OR source = 'unknown' THEN 1 END) as movies_without_dates
|
||||
FROM movies
|
||||
"""
|
||||
movie_stats = self.execute_single(movie_query)
|
||||
stats.update(movie_stats)
|
||||
|
||||
# TV statistics
|
||||
tv_query = """
|
||||
SELECT
|
||||
COUNT(DISTINCT imdb_id) as total_series,
|
||||
COUNT(*) as total_episodes,
|
||||
COUNT(CASE WHEN dateadded IS NOT NULL AND source != 'unknown' THEN 1 END) as episodes_with_dates,
|
||||
COUNT(CASE WHEN dateadded IS NULL OR source = 'unknown' THEN 1 END) as episodes_without_dates
|
||||
FROM episodes
|
||||
"""
|
||||
tv_stats = self.execute_single(tv_query)
|
||||
stats.update(tv_stats)
|
||||
|
||||
return stats
|
||||
|
||||
# Movie queries
|
||||
def get_movies(self, skip: int = 0, limit: int = 50, has_date: Optional[bool] = None) -> List[Dict[str, Any]]:
|
||||
"""Get movies with pagination"""
|
||||
where_clause = ""
|
||||
params = []
|
||||
|
||||
if has_date is not None:
|
||||
if has_date:
|
||||
where_clause = "WHERE dateadded IS NOT NULL AND source != 'unknown'"
|
||||
else:
|
||||
where_clause = "WHERE dateadded IS NULL OR source = 'unknown'"
|
||||
|
||||
query = f"""
|
||||
SELECT imdb_id, title, year, dateadded, released, source, last_updated
|
||||
FROM movies
|
||||
{where_clause}
|
||||
ORDER BY title, year
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
params.extend([limit, skip])
|
||||
|
||||
return self.execute_query(query, params)
|
||||
|
||||
def get_movie_count(self, has_date: Optional[bool] = None) -> int:
|
||||
"""Get total movie count"""
|
||||
where_clause = ""
|
||||
params = []
|
||||
|
||||
if has_date is not None:
|
||||
if has_date:
|
||||
where_clause = "WHERE dateadded IS NOT NULL AND source != 'unknown'"
|
||||
else:
|
||||
where_clause = "WHERE dateadded IS NULL OR source = 'unknown'"
|
||||
|
||||
query = f"SELECT COUNT(*) FROM movies {where_clause}"
|
||||
return self.execute_scalar(query, params)
|
||||
|
||||
# TV Series queries
|
||||
def get_series(self, skip: int = 0, limit: int = 50, date_filter: str = "none") -> List[Dict[str, Any]]:
|
||||
"""Get TV series with episode statistics"""
|
||||
where_clause = ""
|
||||
if date_filter == "complete":
|
||||
where_clause = """
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM episodes e2
|
||||
WHERE e2.imdb_id = e.imdb_id
|
||||
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
|
||||
)
|
||||
"""
|
||||
elif date_filter == "incomplete":
|
||||
where_clause = """
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM episodes e2
|
||||
WHERE e2.imdb_id = e.imdb_id
|
||||
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
|
||||
)
|
||||
"""
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
e.imdb_id,
|
||||
e.series_title,
|
||||
COUNT(*) as total_episodes,
|
||||
COUNT(CASE WHEN e.dateadded IS NOT NULL AND e.source != 'unknown' THEN 1 END) as episodes_with_dates,
|
||||
COUNT(CASE WHEN e.dateadded IS NULL OR e.source = 'unknown' THEN 1 END) as episodes_without_dates,
|
||||
MAX(e.last_updated) as last_updated
|
||||
FROM episodes e
|
||||
{where_clause}
|
||||
GROUP BY e.imdb_id, e.series_title
|
||||
ORDER BY e.series_title
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
|
||||
return self.execute_query(query, [limit, skip])
|
||||
|
||||
def get_series_count(self, date_filter: str = "none") -> int:
|
||||
"""Get total series count"""
|
||||
where_clause = ""
|
||||
if date_filter == "complete":
|
||||
where_clause = """
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM episodes e2
|
||||
WHERE e2.imdb_id = e.imdb_id
|
||||
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
|
||||
)
|
||||
"""
|
||||
elif date_filter == "incomplete":
|
||||
where_clause = """
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM episodes e2
|
||||
WHERE e2.imdb_id = e.imdb_id
|
||||
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
|
||||
)
|
||||
"""
|
||||
|
||||
query = f"""
|
||||
SELECT COUNT(DISTINCT imdb_id)
|
||||
FROM episodes e
|
||||
{where_clause}
|
||||
"""
|
||||
|
||||
return self.execute_scalar(query)
|
||||
|
||||
def get_episodes_for_series(self, imdb_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all episodes for a series"""
|
||||
query = """
|
||||
SELECT imdb_id, series_title, season, episode, episode_title,
|
||||
dateadded, source, last_updated
|
||||
FROM episodes
|
||||
WHERE imdb_id = %s
|
||||
ORDER BY season, episode
|
||||
"""
|
||||
return self.execute_query(query, [imdb_id])
|
||||
|
||||
# Source statistics
|
||||
def get_series_sources(self) -> List[Dict[str, Any]]:
|
||||
"""Get source statistics for series"""
|
||||
query = """
|
||||
SELECT
|
||||
source,
|
||||
COUNT(DISTINCT imdb_id) as series_count,
|
||||
COUNT(*) as episode_count
|
||||
FROM episodes
|
||||
WHERE source != 'unknown'
|
||||
GROUP BY source
|
||||
ORDER BY series_count DESC, episode_count DESC
|
||||
"""
|
||||
return self.execute_query(query)
|
||||
|
||||
# Episode-specific methods for web interface
|
||||
def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]:
|
||||
"""Get episode data including dates"""
|
||||
query = """
|
||||
SELECT imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated
|
||||
FROM episodes
|
||||
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||
"""
|
||||
return self.execute_single(query, (imdb_id, season, episode))
|
||||
|
||||
def upsert_episode_date(self, imdb_id: str, season: int, episode: int,
|
||||
aired: Optional[str], dateadded: Optional[str],
|
||||
source: str, has_video_file: bool = False) -> None:
|
||||
"""Update or insert episode date information"""
|
||||
# First check if episode exists
|
||||
existing = self.get_episode_date(imdb_id, season, episode)
|
||||
|
||||
# Temporarily disable autocommit for the transaction
|
||||
original_autocommit = self.connection.autocommit
|
||||
self.connection.autocommit = False
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
if existing:
|
||||
# Update existing episode
|
||||
query = """
|
||||
UPDATE episodes
|
||||
SET aired = %s, dateadded = %s, source = %s, has_video_file = %s, last_updated = NOW()
|
||||
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||
"""
|
||||
cursor.execute(query, (aired, dateadded, source, has_video_file, imdb_id, season, episode))
|
||||
else:
|
||||
# Insert new episode
|
||||
query = """
|
||||
INSERT INTO episodes (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
"""
|
||||
cursor.execute(query, (imdb_id, season, episode, aired, dateadded, source, has_video_file))
|
||||
|
||||
self.connection.commit()
|
||||
except Exception as e:
|
||||
self.connection.rollback()
|
||||
logger.error(f"Failed to upsert episode date: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Restore original autocommit setting
|
||||
self.connection.autocommit = original_autocommit
|
||||
|
||||
def get_movie_dates(self, imdb_id: str) -> Optional[Dict]:
|
||||
"""Get movie data including dates"""
|
||||
query = """
|
||||
SELECT imdb_id, path, released, dateadded, source, has_video_file, last_updated
|
||||
FROM movies
|
||||
WHERE imdb_id = %s
|
||||
"""
|
||||
return self.execute_single(query, (imdb_id,))
|
||||
|
||||
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
|
||||
dateadded: Optional[str], source: str,
|
||||
has_video_file: bool = False, path: str = "") -> None:
|
||||
"""Update or insert movie date information"""
|
||||
# First check if movie exists
|
||||
existing = self.get_movie_dates(imdb_id)
|
||||
|
||||
# Temporarily disable autocommit for the transaction
|
||||
original_autocommit = self.connection.autocommit
|
||||
self.connection.autocommit = False
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
if existing:
|
||||
# Update existing movie
|
||||
query = """
|
||||
UPDATE movies
|
||||
SET released = %s, dateadded = %s, source = %s, has_video_file = %s, last_updated = NOW()
|
||||
WHERE imdb_id = %s
|
||||
"""
|
||||
cursor.execute(query, (released, dateadded, source, has_video_file, imdb_id))
|
||||
else:
|
||||
# Insert new movie
|
||||
query = """
|
||||
INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
||||
"""
|
||||
cursor.execute(query, (imdb_id, path, released, dateadded, source, has_video_file))
|
||||
|
||||
self.connection.commit()
|
||||
except Exception as e:
|
||||
self.connection.rollback()
|
||||
logger.error(f"Failed to upsert movie dates: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Restore original autocommit setting
|
||||
self.connection.autocommit = original_autocommit
|
||||
|
||||
def get_connection(self):
|
||||
"""Get database connection for advanced operations"""
|
||||
return self.connection
|
||||
|
||||
def _get_first_value(self, row):
|
||||
"""Extract first value from a database row (compatibility method)"""
|
||||
if row is None:
|
||||
return None
|
||||
if isinstance(row, dict):
|
||||
return list(row.values())[0] if row else None
|
||||
return row[0] if row else None
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get basic database statistics (compatibility method)"""
|
||||
return self.get_dashboard_stats()
|
||||
|
||||
def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Dict) -> None:
|
||||
"""Add processing history entry (simplified for web interface)"""
|
||||
# Temporarily disable autocommit for the transaction
|
||||
original_autocommit = self.connection.autocommit
|
||||
self.connection.autocommit = False
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
# Check if processing_history table exists
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'processing_history'
|
||||
)
|
||||
""")
|
||||
table_exists = cursor.fetchone()[0]
|
||||
|
||||
if table_exists:
|
||||
query = """
|
||||
INSERT INTO processing_history (imdb_id, media_type, event_type, details, processed_at)
|
||||
VALUES (%s, %s, %s, %s, NOW())
|
||||
"""
|
||||
import json
|
||||
cursor.execute(query, (imdb_id, media_type, event_type, json.dumps(details)))
|
||||
self.connection.commit()
|
||||
else:
|
||||
# Table doesn't exist, skip logging
|
||||
logger.debug("Processing history table not found, skipping log entry")
|
||||
except Exception as e:
|
||||
self.connection.rollback()
|
||||
logger.error(f"Failed to add processing history: {e}")
|
||||
# Don't raise, this is non-critical
|
||||
finally:
|
||||
# Restore original autocommit setting
|
||||
self.connection.autocommit = original_autocommit
|
||||
|
||||
def close(self):
|
||||
"""Close database connection"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
logger.info("Database connection closed")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
NFOGuard Web Interface - Separated Web Application
|
||||
Lightweight FastAPI application for web interface only
|
||||
"""
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Add current directory and parent directory to path for imports
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
# Import web-specific configuration
|
||||
from config.web_settings import web_config
|
||||
|
||||
# Import database (lightweight, read-only access)
|
||||
from core.web_database import WebDatabase
|
||||
|
||||
# Import web routes and authentication
|
||||
from api.web_routes import register_web_routes
|
||||
from api.auth import SimpleAuthMiddleware, create_auth_dependencies
|
||||
|
||||
|
||||
def create_web_app() -> FastAPI:
|
||||
"""Create FastAPI web application"""
|
||||
app = FastAPI(
|
||||
title="NFOGuard Web Interface",
|
||||
description="Web interface for NFOGuard media database management",
|
||||
version="2.9.0-fixes-only-files",
|
||||
docs_url="/docs" if web_config.web_debug else None,
|
||||
redoc_url="/redoc" if web_config.web_debug else None
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def initialize_web_database() -> WebDatabase:
|
||||
"""Initialize web database connection (read-only optimized)"""
|
||||
return WebDatabase(
|
||||
db_type=web_config.db_type,
|
||||
host=web_config.db_host,
|
||||
port=web_config.db_port,
|
||||
database=web_config.db_name,
|
||||
user=web_config.db_user,
|
||||
password=web_config.db_password
|
||||
)
|
||||
|
||||
|
||||
def setup_static_files(app: FastAPI) -> None:
|
||||
"""Mount static file directories"""
|
||||
# Mount main static files
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# Mount logo separately for easy access
|
||||
app.mount("/logo", StaticFiles(directory="logo"), name="logo")
|
||||
|
||||
# Serve index.html at root
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
def setup_signal_handlers():
|
||||
"""Setup graceful shutdown signal handlers"""
|
||||
def signal_handler(signum, frame):
|
||||
print(f"\n🛑 Received signal {signum}, shutting down web interface...")
|
||||
# Web interface can shutdown immediately (no background processing)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for NFOGuard Web Interface"""
|
||||
print("🌐 Starting NFOGuard Web Interface...")
|
||||
print(f"📊 Configuration: Port {web_config.web_port}, Auth: {'Enabled' if web_config.web_auth_enabled else 'Disabled'}")
|
||||
|
||||
# Setup signal handlers
|
||||
setup_signal_handlers()
|
||||
|
||||
# Create FastAPI app
|
||||
app = create_web_app()
|
||||
|
||||
# Initialize database
|
||||
try:
|
||||
db = initialize_web_database()
|
||||
print(f"✅ Connected to database: {web_config.db_host}:{web_config.db_port}/{web_config.db_name}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect to database: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create dependencies for dependency injection
|
||||
dependencies = {
|
||||
"db": db,
|
||||
"config": web_config
|
||||
}
|
||||
|
||||
# Add authentication dependencies if enabled
|
||||
if web_config.web_auth_enabled:
|
||||
auth_deps = create_auth_dependencies(web_config)
|
||||
dependencies.update(auth_deps)
|
||||
|
||||
# Add authentication middleware
|
||||
app.add_middleware(SimpleAuthMiddleware, config=web_config)
|
||||
print(f"🔐 Web authentication enabled for user: {web_config.web_auth_username}")
|
||||
else:
|
||||
print("🔓 Web authentication disabled - interface is public")
|
||||
|
||||
# Setup static files and routes
|
||||
setup_static_files(app)
|
||||
|
||||
# Register web routes
|
||||
register_web_routes(app, dependencies)
|
||||
|
||||
print(f"🚀 Starting web server on {web_config.web_host}:{web_config.web_port}")
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=web_config.web_host,
|
||||
port=web_config.web_port,
|
||||
workers=web_config.web_workers,
|
||||
log_level="debug" if web_config.web_debug else "info",
|
||||
access_log=web_config.web_debug
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Web interface shutdown by user")
|
||||
except Exception as e:
|
||||
print(f"❌ Web interface failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,902 @@
|
||||
/* NFOGuard Web Interface Styles */
|
||||
:root {
|
||||
--primary-color: #007bff;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #28a745;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--dark-color: #343a40;
|
||||
--light-color: #f8f9fa;
|
||||
--border-color: #dee2e6;
|
||||
--text-muted: #6c757d;
|
||||
--shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--dark-color);
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header-content h1 i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.header-content p {
|
||||
opacity: 0.9;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Authentication Status */
|
||||
.auth-status {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auth-logout {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-logout:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
max-width: 1200px;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-icon.movies { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||
.stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); }
|
||||
.stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); }
|
||||
.stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); }
|
||||
|
||||
.stat-info h3 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-info p {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-info small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.chart-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--light-color);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Content Header */
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
color: var(--dark-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box i {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--warning-color);
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--light-color);
|
||||
font-weight: 600;
|
||||
color: var(--dark-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background-color: rgba(0, 123, 255, 0.05);
|
||||
}
|
||||
|
||||
.data-table .loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination .btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.pagination .page-info {
|
||||
margin: 0 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Higher z-index for edit modals that appear on top of other modals */
|
||||
#edit-modal, #smart-fix-modal {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
/* Reports */
|
||||
.report-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.summary-card p {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.summary-card span {
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.report-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.report-section h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
/* Tools */
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.tool-card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.tool-card p {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-display {
|
||||
background: var(--light-color);
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: white;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
min-width: 300px;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left-color: var(--success-color);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left-color: var(--danger-color);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smart Fix Modal */
|
||||
.smart-fix-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.option-card {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.option-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option-label input[type="radio"] {
|
||||
margin-right: 0.75rem;
|
||||
margin-top: 0.1rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.option-content h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--dark-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.option-content p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.option-content small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.manual-date-input {
|
||||
width: 100% !important;
|
||||
margin-top: 0.5rem !important;
|
||||
}
|
||||
|
||||
.option-card input[type="radio"]:checked + .option-content {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.option-card:has(input[type="radio"]:checked) {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(0, 123, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Additional badge styles */
|
||||
.badge-info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
/* Enhanced Edit Modal Date Options */
|
||||
.date-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.date-option-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.date-option-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.date-option-label {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.date-option-label input[type="radio"] {
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.1rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.date-option-content h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--dark-color);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date-option-content p {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.date-option-content small {
|
||||
color: var(--primary-color);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date-option-card input[type="radio"]:checked + .date-option-content h4 {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.date-option-card:has(input[type="radio"]:checked) {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(0, 123, 255, 0.03);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.content-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.content-controls {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-center { text-align: center; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.d-none { display: none; }
|
||||
.d-block { display: block; }
|
||||
.d-flex { display: flex; }
|
||||
.justify-content-between { justify-content: space-between; }
|
||||
.align-items-center { align-items: center; }
|
||||
|
||||
/* Manual Scan Styles */
|
||||
.scan-status {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--light-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.scan-progress {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--success-color));
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.scan-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.scan-info span:first-child {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.scan-info span:last-child {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NFOGuard - Database Management</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=2.10.0-skipped-imdb-edit-v2">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<h1><i class="fas fa-shield-alt"></i> NFOGuard <span style="font-size: 0.5em; color: #888;">v2.10.0-skipped-imdb-edit-v2</span></h1>
|
||||
<p>Database Management & Reporting</p>
|
||||
</div>
|
||||
<div class="auth-status" id="auth-status" style="display: none;">
|
||||
<span class="auth-user">
|
||||
<i class="fas fa-user"></i> <span id="auth-username">Loading...</span>
|
||||
</span>
|
||||
<button class="auth-logout" id="logout-btn" onclick="logout()">
|
||||
<i class="fas fa-sign-out-alt"></i> Logout
|
||||
</button>
|
||||
</div>
|
||||
<nav class="nav-tabs">
|
||||
<button class="nav-tab active" data-tab="dashboard">
|
||||
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="movies">
|
||||
<i class="fas fa-film"></i> Movies
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="tv">
|
||||
<i class="fas fa-tv"></i> TV Series
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="reports">
|
||||
<i class="fas fa-chart-bar"></i> Reports
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="scheduled-scans">
|
||||
<i class="fas fa-clock"></i> Scheduled Scans
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="tools">
|
||||
<i class="fas fa-tools"></i> Tools
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Dashboard Tab -->
|
||||
<div class="tab-content active" id="dashboard">
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon movies">
|
||||
<i class="fas fa-film"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 id="movies-total">-</h3>
|
||||
<p>Total Movies</p>
|
||||
<small id="movies-with-dates">- with dates</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon tv">
|
||||
<i class="fas fa-tv"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 id="series-total">-</h3>
|
||||
<p>TV Series</p>
|
||||
<small id="episodes-total">- episodes</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon missing">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 id="missing-dates-total">-</h3>
|
||||
<p>Missing Dates</p>
|
||||
<small id="no-valid-source-total">- no valid source</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon activity">
|
||||
<i class="fas fa-history"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 id="recent-activity">-</h3>
|
||||
<p>Recent Activity</p>
|
||||
<small>Last 7 days</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);">
|
||||
<i class="fas fa-ban"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 id="skipped-total">-</h3>
|
||||
<p>Skipped Items</p>
|
||||
<small id="skipped-breakdown">- movies, - episodes</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-charts">
|
||||
<div class="chart-card">
|
||||
<h3><i class="fas fa-chart-pie"></i> Movie Sources</h3>
|
||||
<div id="movie-sources-chart" class="chart-container"></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3><i class="fas fa-chart-pie"></i> Episode Sources</h3>
|
||||
<div id="episode-sources-chart" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Movies Tab -->
|
||||
<div class="tab-content" id="movies">
|
||||
<div class="content-header">
|
||||
<h2><i class="fas fa-film"></i> Movies Database</h2>
|
||||
<div class="content-controls">
|
||||
<div class="search-controls">
|
||||
<div class="search-box">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="movies-search" placeholder="Search title/path...">
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<input type="text" id="movies-imdb-search" placeholder="Search IMDb ID...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-controls">
|
||||
<select id="movies-filter-date">
|
||||
<option value="">All Movies</option>
|
||||
<option value="true">With Dates</option>
|
||||
<option value="false">Missing Dates</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
</select>
|
||||
<select id="movies-filter-source">
|
||||
<option value="">All Sources</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="refreshMovies()">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="data-table sortable-table" id="movies-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 0, 'text')" style="cursor: pointer;">
|
||||
Title <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 1, 'text')" style="cursor: pointer;">
|
||||
IMDb ID <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 2, 'date')" style="cursor: pointer;">
|
||||
Movie Released <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 3, 'date')" style="cursor: pointer;">
|
||||
Date Added to Library <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 4, 'text')" style="cursor: pointer;">
|
||||
Source <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 5, 'text')" style="cursor: pointer;">
|
||||
Date Type <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 6, 'text')" style="cursor: pointer;">
|
||||
Video File <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="movies-tbody">
|
||||
<tr>
|
||||
<td colspan="8" class="loading">Loading movies...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" id="movies-pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- TV Series Tab -->
|
||||
<div class="tab-content" id="tv">
|
||||
<div class="content-header">
|
||||
<h2><i class="fas fa-tv"></i> TV Series Database</h2>
|
||||
<div class="content-controls">
|
||||
<div class="search-controls">
|
||||
<div class="search-box">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="series-search" placeholder="Search title/path...">
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<input type="text" id="series-imdb-search" placeholder="Search IMDb ID...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-controls">
|
||||
<select id="series-filter-date">
|
||||
<option value="">All Series</option>
|
||||
<option value="complete">Fully Dated</option>
|
||||
<option value="incomplete">Missing Dates</option>
|
||||
<option value="none">No Dates</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
</select>
|
||||
<select id="series-filter-source">
|
||||
<option value="">All Sources</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="refreshSeries()">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="data-table sortable-table" id="series-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 0, 'text')" style="cursor: pointer;">
|
||||
Series Title <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 1, 'text')" style="cursor: pointer;">
|
||||
IMDb ID <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 2, 'number')" style="cursor: pointer;">
|
||||
Episodes <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 3, 'number')" style="cursor: pointer;">
|
||||
With Dates <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 4, 'number')" style="cursor: pointer;">
|
||||
With Video <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 5, 'number')" style="cursor: pointer;">
|
||||
Skipped <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="series-tbody">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">Loading series...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" id="series-pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- Reports Tab -->
|
||||
<div class="tab-content" id="reports">
|
||||
<div class="content-header">
|
||||
<h2><i class="fas fa-chart-bar"></i> Missing Dates Report</h2>
|
||||
<div class="content-controls">
|
||||
<button class="btn btn-primary" onclick="refreshReport()">
|
||||
<i class="fas fa-sync"></i> Refresh Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-summary" id="report-summary">
|
||||
<div class="summary-card">
|
||||
<h3>Movies</h3>
|
||||
<p><span id="report-movies-with">-</span> with dates</p>
|
||||
<p><span id="report-movies-missing">-</span> missing dates</p>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>Episodes</h3>
|
||||
<p><span id="report-episodes-with">-</span> with dates</p>
|
||||
<p><span id="report-episodes-missing">-</span> missing dates</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-content">
|
||||
<div class="report-section">
|
||||
<h3><i class="fas fa-film"></i> Movies Missing Dates</h3>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>IMDb ID</th>
|
||||
<th>Released</th>
|
||||
<th>Source</th>
|
||||
<th>Smart Fix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="report-movies-tbody">
|
||||
<tr>
|
||||
<td colspan="5" class="loading">Loading report...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-section">
|
||||
<h3><i class="fas fa-tv"></i> Episodes Missing Dates</h3>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Series</th>
|
||||
<th>Episode</th>
|
||||
<th>IMDb ID</th>
|
||||
<th>Aired</th>
|
||||
<th>Source</th>
|
||||
<th>Smart Fix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="report-episodes-tbody">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">Loading report...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled Scans Tab -->
|
||||
<div class="tab-content" id="scheduled-scans">
|
||||
<div class="content-header">
|
||||
<h2><i class="fas fa-clock"></i> Scheduled Scans</h2>
|
||||
<button class="btn btn-primary" id="add-schedule-btn">
|
||||
<i class="fas fa-plus"></i> Add Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Schedules Section -->
|
||||
<div class="section-card">
|
||||
<h3><i class="fas fa-list"></i> Active Schedules</h3>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="schedules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Mode</th>
|
||||
<th>Schedule</th>
|
||||
<th>Last Run</th>
|
||||
<th>Next Run</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedules-table-body">
|
||||
<!-- Schedules will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Execution History Section -->
|
||||
<div class="section-card">
|
||||
<h3><i class="fas fa-history"></i> Recent Executions</h3>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="executions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Schedule</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th>Items Processed</th>
|
||||
<th>Items Skipped</th>
|
||||
<th>Items Failed</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="executions-table-body">
|
||||
<!-- Executions will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools Tab -->
|
||||
<div class="tab-content" id="tools">
|
||||
<div class="content-header">
|
||||
<h2><i class="fas fa-tools"></i> Database Tools</h2>
|
||||
</div>
|
||||
|
||||
<div class="tools-grid">
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-exchange-alt"></i> Bulk Source Update</h3>
|
||||
<p>Change source for multiple items at once</p>
|
||||
<form id="bulk-update-form">
|
||||
<div class="form-group">
|
||||
<label>Media Type:</label>
|
||||
<select id="bulk-media-type" required>
|
||||
<option value="">Select type...</option>
|
||||
<option value="movies">Movies</option>
|
||||
<option value="episodes">Episodes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>From Source:</label>
|
||||
<input type="text" id="bulk-old-source" placeholder="e.g., no_valid_date_source" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>To Source:</label>
|
||||
<select id="bulk-new-source" required>
|
||||
<option value="">Select new source...</option>
|
||||
<option value="airdate">Air Date</option>
|
||||
<option value="digital_release">Digital Release</option>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="radarr:db.history.import">Radarr Import</option>
|
||||
<option value="sonarr:history.import">Sonarr Import</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="fas fa-exchange-alt"></i> Update Sources
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-search"></i> Manual Scan</h3>
|
||||
<p>Scan specific folders or perform full library scans</p>
|
||||
<form id="manual-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="scan-type" required>
|
||||
<option value="both">TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Mode:</label>
|
||||
<select id="scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="full">Full Scan</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Specific Path (Optional):</label>
|
||||
<input type="text" id="scan-path" placeholder="e.g., /mnt/unionfs/Media/TV/Series Name" title="Leave empty for full library scan">
|
||||
<small>Leave empty to scan entire library</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Start Scan
|
||||
</button>
|
||||
</form>
|
||||
<div id="scan-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="scan-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="scan-current-operation">Initializing...</span>
|
||||
<span id="scan-progress-text">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
||||
<p>View detailed database information</p>
|
||||
<div class="stats-display" id="detailed-stats">
|
||||
<p>Click refresh to load detailed statistics</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="loadDetailedStats()">
|
||||
<i class="fas fa-sync"></i> Refresh Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-upload"></i> Populate Database</h3>
|
||||
<p>Bulk import data from Radarr/Sonarr into NFOGuard database</p>
|
||||
<form id="populate-form">
|
||||
<div class="form-group">
|
||||
<label>Media Type:</label>
|
||||
<select id="populate-media-type" required>
|
||||
<option value="both">Movies & TV Shows</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Start Population
|
||||
</button>
|
||||
</form>
|
||||
<div id="populate-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="populate-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="populate-current-operation">Running...</span>
|
||||
<span id="populate-progress-text">In Progress</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="populate-results" class="stats-display" style="margin-top: 10px;"></div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopPopulatePolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Smart Fix Modal -->
|
||||
<div class="modal" id="smart-fix-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="smart-fix-title">Choose Date Source</h3>
|
||||
<button class="modal-close" onclick="closeSmartFixModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="smart-fix-content">
|
||||
<p>Loading available options...</p>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSmartFixModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal" id="edit-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">Edit Entry</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="edit-form">
|
||||
<input type="hidden" id="edit-imdb-id">
|
||||
<input type="hidden" id="edit-season">
|
||||
<input type="hidden" id="edit-episode">
|
||||
<input type="hidden" id="edit-media-type">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-dateadded">Date Added:</label>
|
||||
<input type="datetime-local" id="edit-dateadded">
|
||||
<small>Leave empty to clear date</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-source">Source:</label>
|
||||
<select id="edit-source" required>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="airdate">Air Date</option>
|
||||
<option value="digital_release">Digital Release</option>
|
||||
<option value="radarr:db.history.import">Radarr Import</option>
|
||||
<option value="sonarr:history.import">Sonarr Import</option>
|
||||
<option value="no_valid_date_source">No Valid Source</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Modal -->
|
||||
<div class="modal" id="schedule-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="schedule-modal-title">Add New Schedule</h3>
|
||||
<button class="modal-close" onclick="closeScheduleModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="schedule-form">
|
||||
<input type="hidden" id="schedule-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-name">Schedule Name:</label>
|
||||
<input type="text" id="schedule-name" required placeholder="e.g., Daily TV Incomplete Scan">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-description">Description:</label>
|
||||
<textarea id="schedule-description" placeholder="Optional description of what this schedule does"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="schedule-media-type">Media Type:</label>
|
||||
<select id="schedule-media-type" required>
|
||||
<option value="both">Both TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-scan-mode">Scan Mode:</label>
|
||||
<select id="schedule-scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
<option value="full">Full Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-cron">Schedule (Cron Expression):</label>
|
||||
<div class="cron-input-container">
|
||||
<input type="text" id="schedule-cron" required placeholder="0 2 * * *" pattern="^(\*|[0-5]?\d|\*\/[0-9]+)(\s+(\*|[0-5]?\d|\*\/[0-9]+)){4}$">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="cron-builder-btn">
|
||||
<i class="fas fa-magic"></i> Builder
|
||||
</button>
|
||||
</div>
|
||||
<small class="help-text">
|
||||
Examples: "0 2 * * *" (daily at 2 AM), "0 2 * * 0" (weekly on Sunday at 2 AM)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-paths">Specific Paths (Optional):</label>
|
||||
<textarea id="schedule-paths" placeholder="Leave empty to scan entire library, or specify paths separated by commas"></textarea>
|
||||
<small class="help-text">
|
||||
Example: /media/TV/Series Name, /media/Movies/Movie Name
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="schedule-enabled" checked>
|
||||
<span class="checkmark"></span>
|
||||
Enable this schedule
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeScheduleModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span id="schedule-submit-text">Create Schedule</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Builder Modal -->
|
||||
<div class="modal" id="cron-builder-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Cron Expression Builder</h3>
|
||||
<button class="modal-close" onclick="closeCronBuilder()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="cron-builder">
|
||||
<div class="cron-presets">
|
||||
<h4>Quick Presets:</h4>
|
||||
<div class="preset-buttons">
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 2 * * *')">Daily at 2 AM</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 2 * * 0')">Weekly (Sunday 2 AM)</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 2 1 * *')">Monthly (1st at 2 AM)</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 */6 * * *')">Every 6 Hours</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 */12 * * *')">Every 12 Hours</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cron-fields">
|
||||
<h4>Custom Schedule:</h4>
|
||||
<div class="field-group">
|
||||
<label>Minute (0-59):</label>
|
||||
<input type="text" id="cron-minute" value="0" placeholder="0">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Hour (0-23):</label>
|
||||
<input type="text" id="cron-hour" value="2" placeholder="2">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Day of Month (1-31):</label>
|
||||
<input type="text" id="cron-day" value="*" placeholder="*">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Month (1-12):</label>
|
||||
<input type="text" id="cron-month" value="*" placeholder="*">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Day of Week (0-6):</label>
|
||||
<input type="text" id="cron-dow" value="*" placeholder="*">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cron-preview">
|
||||
<h4>Preview:</h4>
|
||||
<div class="cron-expression">
|
||||
<code id="cron-preview-text">0 2 * * *</code>
|
||||
</div>
|
||||
<div class="cron-description" id="cron-description">
|
||||
Runs daily at 2:00 AM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCronBuilder()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="applyCronExpression()">Use This Schedule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/app.js?v=2.10.0-skipped-imdb-edit-v2"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
+78
-217
@@ -11,12 +11,12 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from core.database import NFOGuardDatabase
|
||||
from core.nfo_manager import NFOManager
|
||||
from core.path_mapper import PathMapper
|
||||
from clients.radarr_client import RadarrClient
|
||||
from clients.external_clients import ExternalClientManager
|
||||
from config.settings import config
|
||||
from utils.logging import _log
|
||||
from utils.imdb_utils import find_imdb_in_directory # Phase 3: Replaced NFOManager
|
||||
from utils.file_utils import find_media_path_by_imdb_and_title
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ def convert_utc_to_local(utc_iso_string: str) -> str:
|
||||
class MovieProcessor:
|
||||
"""Handles movie processing"""
|
||||
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager, path_mapper: PathMapper):
|
||||
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
|
||||
self.db = db
|
||||
self.nfo_manager = nfo_manager
|
||||
self.path_mapper = path_mapper
|
||||
self.radarr = RadarrClient(
|
||||
os.environ.get("RADARR_URL", ""),
|
||||
@@ -151,9 +151,9 @@ class MovieProcessor:
|
||||
_log("ERROR", f"Error checking movie completion for {imdb_id}: {e}")
|
||||
return False, f"Error checking completion: {e}"
|
||||
|
||||
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, shutdown_event=None) -> str:
|
||||
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, scan_mode: str = "smart", shutdown_event=None) -> str:
|
||||
"""Process a movie directory"""
|
||||
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
|
||||
imdb_id = find_imdb_in_directory(movie_path) # Phase 3: Using imdb_utils instead of NFOManager
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
|
||||
return "error"
|
||||
@@ -165,8 +165,9 @@ class MovieProcessor:
|
||||
else:
|
||||
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
|
||||
|
||||
# Check if we should skip this movie (unless forced or webhook mode)
|
||||
if not force_scan and not webhook_mode:
|
||||
# Check if we should skip this movie (unless forced, webhook mode, or incomplete mode)
|
||||
# Skip database optimization for incomplete mode since we need to check NFO files first
|
||||
if not force_scan and not webhook_mode and scan_mode != "incomplete":
|
||||
should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name)
|
||||
if should_skip:
|
||||
_log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
|
||||
@@ -196,6 +197,11 @@ class MovieProcessor:
|
||||
_log("WARNING", f"No video files found in: {movie_path} - skipping database entry")
|
||||
return "no_video_files"
|
||||
|
||||
# For incomplete mode: Start with NFO check to find missing dateadded elements
|
||||
if scan_mode == "incomplete":
|
||||
return self._process_movie_nfo_first(movie_path, imdb_id, shutdown_event)
|
||||
|
||||
# For smart/full modes: Use database-first optimization
|
||||
# TIER 1: Check database first (fastest - local lookup)
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
_log("DEBUG", f"Database lookup for {imdb_id}: {existing}")
|
||||
@@ -219,106 +225,16 @@ class MovieProcessor:
|
||||
if released and hasattr(released, 'isoformat'):
|
||||
released = released.isoformat()
|
||||
|
||||
# Create NFO with existing data and update files
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||
)
|
||||
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
|
||||
return "processed"
|
||||
else:
|
||||
_log("INFO", f"🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2")
|
||||
|
||||
# TIER 2: Check if NFO file has NFOGuard data and cache it in database
|
||||
nfo_path = movie_path / "movie.nfo"
|
||||
_log("INFO", f"🔍 TIER 2 - Checking NFO file: {nfo_path}")
|
||||
_log("INFO", f"🔍 TIER 2 - NFO exists: {nfo_path.exists()}")
|
||||
|
||||
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||
_log("INFO", f"🔍 TIER 2 - NFOGuard data extracted: {nfo_data}")
|
||||
|
||||
if nfo_data:
|
||||
_log("INFO", f"🚀 TIER 2 - Found NFOGuard data in NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
|
||||
dateadded = nfo_data["dateadded"]
|
||||
source = nfo_data["source"]
|
||||
released = nfo_data.get("released")
|
||||
|
||||
# Cache NFO data in database for future lookups
|
||||
# Fixed parameter order: imdb_id, released, dateadded, source
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
_log("INFO", f"✅ Cached NFO data in database for {imdb_id}")
|
||||
|
||||
# Update file mtimes if enabled (NFO is already correct)
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [nfo-cached]")
|
||||
return "processed"
|
||||
|
||||
# TIER 2.5: Check for any existing valid date data in NFO (even without lockdata marker)
|
||||
# Only use NFO dates if prioritize_nfo is enabled, otherwise check external APIs first
|
||||
existing_nfo_data = self._extract_any_valid_dates_from_nfo(nfo_path)
|
||||
if existing_nfo_data and config.manual_scan_prioritize_nfo:
|
||||
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
|
||||
_log("INFO", f"⚡ MANUAL_SCAN_PRIORITIZE_NFO=True - Using NFO date for speed")
|
||||
dateadded = existing_nfo_data["dateadded"]
|
||||
source = existing_nfo_data["source"]
|
||||
released = existing_nfo_data.get("released")
|
||||
|
||||
# Cache existing data in database and add proper NFOGuard formatting
|
||||
# Fixed parameter order: imdb_id, released, dateadded, source
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
_log("INFO", f"✅ Cached existing NFO data in database for {imdb_id}")
|
||||
|
||||
# Update NFO file to add NFOGuard formatting (lockdata, comment)
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||
)
|
||||
_log("INFO", f"✅ Added NFOGuard formatting to existing NFO for {imdb_id}")
|
||||
|
||||
# Update file mtimes if enabled
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [existing-nfo-enhanced]")
|
||||
return "processed"
|
||||
elif existing_nfo_data:
|
||||
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
|
||||
_log("INFO", f"🎯 MANUAL_SCAN_PRIORITIZE_NFO=False - Will verify against external APIs first")
|
||||
# Store NFO data as fallback but continue to TIER 3 to check external APIs
|
||||
nfo_fallback_data = existing_nfo_data
|
||||
|
||||
# TIER 1.5: Special handling for TMDB-only movies - extract dates from existing NFO
|
||||
if is_tmdb_fallback:
|
||||
tmdb_nfo_data = self._extract_dates_from_tmdb_nfo(nfo_path)
|
||||
if tmdb_nfo_data:
|
||||
_log("INFO", f"🎬 Using TMDB data from existing NFO file: {tmdb_nfo_data['dateadded']} (source: {tmdb_nfo_data['source']})")
|
||||
dateadded = tmdb_nfo_data["dateadded"]
|
||||
source = tmdb_nfo_data["source"]
|
||||
released = tmdb_nfo_data.get("released")
|
||||
|
||||
# Create NFO with NFOGuard fields added
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes if enabled
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
|
||||
# Save to database
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [tmdb-nfo]")
|
||||
return "processed"
|
||||
|
||||
# TIER 3: No cached data found - proceed with API lookups and verification
|
||||
# TIER 2: Query external APIs directly (NFO layer removed in Phase 2)
|
||||
_log("INFO", f"🔍 TIER 2 - No database cache, querying external APIs")
|
||||
|
||||
# Check for shutdown signal before expensive API operations
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
@@ -356,18 +272,8 @@ class MovieProcessor:
|
||||
final_source = f"{source}_as_dateadded" if source else "release_date_fallback"
|
||||
_log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})")
|
||||
|
||||
# Create NFO regardless of date availability (preserves existing metadata)
|
||||
if config.debug:
|
||||
print(f"🔍 TIER3 - config.manage_nfo: {config.manage_nfo}")
|
||||
if config.manage_nfo:
|
||||
if config.debug:
|
||||
print(f"🔍 TIER3 - Calling create_movie_nfo with final_dateadded: {final_dateadded}")
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, final_dateadded, released, final_source, config.lock_metadata
|
||||
)
|
||||
else:
|
||||
if config.debug:
|
||||
print(f"❌ TIER3 - manage_nfo is disabled, skipping NFO creation")
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
|
||||
# Skip remaining processing if no valid date found and file dates disabled
|
||||
if final_dateadded is None:
|
||||
@@ -381,15 +287,11 @@ class MovieProcessor:
|
||||
|
||||
_log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}")
|
||||
|
||||
# Update file mtimes (only if we have a valid date)
|
||||
if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED":
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
# File mtime operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
|
||||
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
|
||||
|
||||
# Yield control briefly during movie processing to allow web interface requests
|
||||
import time
|
||||
time.sleep(0.005) # 5ms yield per movie to improve responsiveness
|
||||
|
||||
# Save to database
|
||||
_log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}")
|
||||
@@ -403,85 +305,75 @@ class MovieProcessor:
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
|
||||
return "processed"
|
||||
|
||||
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract date information from TMDB-based NFO file"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None) -> str:
|
||||
"""Process movie for incomplete mode: Database-first then API (NFO checks removed in Phase 2)"""
|
||||
_log("INFO", f"🔍 INCOMPLETE MODE: Checking movie for missing data: {movie_path.name}")
|
||||
|
||||
try:
|
||||
root = self.nfo_manager._parse_nfo_with_tolerance(nfo_path)
|
||||
# Check for shutdown signal
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}")
|
||||
return "shutdown"
|
||||
|
||||
# Look for premiered date (from TMDB)
|
||||
premiered_elem = root.find('.//premiered')
|
||||
if premiered_elem is not None and premiered_elem.text:
|
||||
premiered_date = premiered_elem.text.strip()
|
||||
print(f"✅ Found TMDB premiered date: {premiered_date}")
|
||||
# STEP 1: Check database for existing data (Phase 2: NFO check removed)
|
||||
_log("DEBUG", f"STEP 1 - Checking database for existing data")
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
|
||||
return {
|
||||
"dateadded": premiered_date,
|
||||
"source": "tmdb:premiered_from_nfo",
|
||||
"released": premiered_date
|
||||
}
|
||||
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
|
||||
# Found in database - data is complete
|
||||
_log("INFO", f"✅ Database has dateadded={existing['dateadded']}")
|
||||
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
print(f"⚠️ Error parsing TMDB NFO for dates: {e}")
|
||||
# Convert datetime objects to strings
|
||||
if hasattr(dateadded, 'isoformat'):
|
||||
dateadded = dateadded.isoformat()
|
||||
if released and hasattr(released, 'isoformat'):
|
||||
released = released.isoformat()
|
||||
|
||||
return None
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
|
||||
return "processed"
|
||||
|
||||
def _extract_any_valid_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract any valid date information from NFO file, even without NFOGuard markers"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
# STEP 2: Database incomplete or missing, query APIs
|
||||
_log("DEBUG", f"STEP 2 - Querying APIs for missing data")
|
||||
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
# Check for shutdown signal before API calls
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping before API calls: {movie_path.name}")
|
||||
return "shutdown"
|
||||
|
||||
# Look for dateadded element (indicates previously processed by NFOGuard or similar)
|
||||
dateadded_elem = root.find('.//dateadded')
|
||||
premiered_elem = root.find('.//premiered')
|
||||
# Handle TMDB ID fallback case
|
||||
is_tmdb_fallback = imdb_id.startswith("tmdb-")
|
||||
|
||||
if dateadded_elem is not None and dateadded_elem.text:
|
||||
dateadded = dateadded_elem.text.strip()
|
||||
|
||||
# Try to determine source from NFOGuard comment if present
|
||||
source = "existing_nfo_data"
|
||||
try:
|
||||
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||
import re
|
||||
# Look for NFOGuard comment pattern
|
||||
source_match = re.search(r'<!--.*?NFOGuard.*?Source:\s*([^-\s]+).*?-->', nfo_content, re.DOTALL | re.IGNORECASE)
|
||||
if source_match:
|
||||
source = source_match.group(1).strip()
|
||||
_log("DEBUG", f"Found source in NFOGuard comment: {source}")
|
||||
if is_tmdb_fallback:
|
||||
# TMDB fallback processing - use file modification time
|
||||
_log("INFO", f"🔍 TMDB fallback processing for {imdb_id}")
|
||||
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||
_log("INFO", f"Using file mtime for TMDB movie: {dateadded}")
|
||||
else:
|
||||
# Try to infer source from dateadded format/content
|
||||
if "tmdb" in nfo_content.lower() or (premiered_elem and premiered_elem.text):
|
||||
source = "tmdb:digital"
|
||||
elif "radarr" in nfo_content.lower():
|
||||
source = "radarr:db.history.import"
|
||||
# Standard IMDb processing
|
||||
# Try to get digital release date from external APIs
|
||||
digital_date, digital_source = self._get_digital_release_date(imdb_id)
|
||||
|
||||
if digital_date:
|
||||
dateadded = digital_date
|
||||
source = digital_source
|
||||
released = digital_date # For movies, digital release is often the key date
|
||||
_log("INFO", f"Got digital release date from APIs: {dateadded} (source: {source})")
|
||||
else:
|
||||
source = "existing_nfo_data"
|
||||
_log("DEBUG", f"Inferred source from NFO content: {source}")
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Could not determine source from NFO content: {e}")
|
||||
# Last resort: file modification time
|
||||
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||
_log("INFO", f"Using file mtime as fallback: {dateadded}")
|
||||
|
||||
result = {
|
||||
"dateadded": dateadded,
|
||||
"source": source
|
||||
}
|
||||
# Save to database only (NFO operations removed in Phase 1)
|
||||
if dateadded:
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
|
||||
if premiered_elem is not None and premiered_elem.text:
|
||||
result["released"] = premiered_elem.text.strip()
|
||||
_log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {movie_path.name} (source: {source})")
|
||||
return "processed"
|
||||
else:
|
||||
_log("WARNING", f"Could not determine dateadded for movie: {movie_path.name}")
|
||||
return "error"
|
||||
|
||||
_log("INFO", f"✅ Found existing date data in NFO: dateadded={dateadded}, source={source}")
|
||||
return result
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
_log("DEBUG", f"Error parsing NFO for existing date data: {e}")
|
||||
|
||||
return None
|
||||
# NFO helper methods removed in Phase 2 - database is the single source of truth
|
||||
|
||||
def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]:
|
||||
"""Decide movie dates based on configuration and available data"""
|
||||
@@ -549,14 +441,7 @@ class MovieProcessor:
|
||||
# When using digital release date, store it as both dateadded and released
|
||||
return digital_date, digital_source, digital_date
|
||||
else:
|
||||
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks")
|
||||
|
||||
# Try Radarr's own NFO premiered date as fallback
|
||||
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
|
||||
if radarr_premiered:
|
||||
_log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}")
|
||||
# When using Radarr NFO premiered date, store it as both dateadded and released
|
||||
return radarr_premiered, "radarr:nfo.premiered", radarr_premiered
|
||||
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found")
|
||||
|
||||
else: # digital_then_import
|
||||
# Try digital release first
|
||||
@@ -615,31 +500,7 @@ class MovieProcessor:
|
||||
_log("ERROR", f"External clients error for {imdb_id}: {e}")
|
||||
return None, f"release:error:{str(e)}"
|
||||
|
||||
def _get_radarr_nfo_premiered_date(self, movie_path: Path) -> Optional[str]:
|
||||
"""Extract premiered date from Radarr's existing movie.nfo file"""
|
||||
try:
|
||||
nfo_path = movie_path / "movie.nfo"
|
||||
if not nfo_path.exists():
|
||||
_log("DEBUG", f"No existing NFO file found at {nfo_path}")
|
||||
return None
|
||||
|
||||
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||
|
||||
# Look for <premiered>YYYY-MM-DD</premiered>
|
||||
match = re.search(r'<premiered>(\d{4}-\d{2}-\d{2})</premiered>', nfo_content)
|
||||
if match:
|
||||
premiered_date = match.group(1)
|
||||
# Convert to ISO format with timezone
|
||||
iso_date = f"{premiered_date}T00:00:00+00:00"
|
||||
_log("INFO", f"✅ Found Radarr NFO premiered date: {premiered_date}")
|
||||
return iso_date
|
||||
else:
|
||||
_log("DEBUG", f"No <premiered> tag found in existing NFO")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error reading Radarr NFO file: {e}")
|
||||
return None
|
||||
# _get_radarr_nfo_premiered_date() removed in Phase 2 - no longer reading NFO files
|
||||
|
||||
def _log_failed_movie(self, movie_path: Path, imdb_id: str, reason: str, available_countries: List[str] = None):
|
||||
"""Log movies that failed to get valid dates to a debug file"""
|
||||
|
||||
+186
-204
@@ -11,13 +11,12 @@ from typing import Optional, Dict, List, Set, Tuple, Any
|
||||
from datetime import datetime
|
||||
|
||||
from core.database import NFOGuardDatabase
|
||||
from core.nfo_manager import NFOManager
|
||||
from core.async_nfo_manager import AsyncNFOManager
|
||||
from core.path_mapper import PathMapper
|
||||
from clients.sonarr_client import SonarrClient
|
||||
from clients.external_clients import ExternalClientManager
|
||||
from config.settings import config
|
||||
from utils.logging import _log
|
||||
from utils.imdb_utils import parse_imdb_from_path # Phase 3: Replaced NFOManager
|
||||
from utils.file_utils import (
|
||||
find_media_path_by_imdb_and_title,
|
||||
find_episodes_on_disk,
|
||||
@@ -32,10 +31,9 @@ from utils.async_file_utils import (
|
||||
class TVProcessor:
|
||||
"""Handles TV series processing"""
|
||||
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager, path_mapper: PathMapper):
|
||||
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
|
||||
self.db = db
|
||||
self.nfo_manager = nfo_manager
|
||||
self.async_nfo_manager = AsyncNFOManager(config.manager_brand, config.debug)
|
||||
self.path_mapper = path_mapper
|
||||
self.sonarr = SonarrClient(
|
||||
os.environ.get("SONARR_URL", ""),
|
||||
@@ -145,9 +143,9 @@ class TVProcessor:
|
||||
_log("ERROR", f"Error checking series completion for {imdb_id}: {e}")
|
||||
return False, f"Error checking completion: {e}"
|
||||
|
||||
def process_series(self, series_path: Path, force_scan: bool = False) -> str:
|
||||
def process_series(self, series_path: Path, force_scan: bool = False, scan_mode: str = "smart") -> str:
|
||||
"""Process a TV series directory"""
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
|
||||
return "error"
|
||||
@@ -155,7 +153,8 @@ class TVProcessor:
|
||||
_log("INFO", f"Processing TV series: {series_path.name}")
|
||||
|
||||
# Fast check first - avoid expensive filesystem scan if possible
|
||||
if not force_scan:
|
||||
# Skip fast optimization for incomplete mode since we need to check NFO files first
|
||||
if not force_scan and scan_mode != "incomplete":
|
||||
should_skip_fast, reason_fast, episodes_in_db = self.should_skip_series_fast(imdb_id, series_path.name)
|
||||
if should_skip_fast:
|
||||
_log("INFO", f"⚡ FAST SKIP: {series_path.name} [{imdb_id}] - {reason_fast}")
|
||||
@@ -192,19 +191,8 @@ class TVProcessor:
|
||||
if (season, episode) in disk_episodes:
|
||||
episode_count += 1
|
||||
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_dir,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
video_files = disk_episodes[(season, episode)]
|
||||
for video_file in video_files:
|
||||
self.nfo_manager.set_file_mtime(video_file, dateadded)
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
@@ -214,11 +202,6 @@ class TVProcessor:
|
||||
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
|
||||
# Continue processing other episodes
|
||||
|
||||
# Yield control every 3 episodes to allow other requests (webhooks, web interface)
|
||||
if episode_count % 3 == 0:
|
||||
import time
|
||||
time.sleep(0.1) # 100ms yield to improve responsiveness during episode processing
|
||||
_log("DEBUG", f"Processed {episode_count} episodes, yielding to allow other requests...")
|
||||
|
||||
# Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs
|
||||
pass
|
||||
@@ -231,12 +214,17 @@ class TVProcessor:
|
||||
return extract_title_from_directory_name(series_path.name)
|
||||
|
||||
|
||||
def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]]) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Gather episode air dates and date added information with database-first optimization"""
|
||||
_log("INFO", f"🎯 GATHERING EPISODE DATES for {imdb_id}: {len(disk_episodes)} episodes on disk")
|
||||
def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]], scan_mode: str = "smart") -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Gather episode air dates and date added information with optimization based on scan mode"""
|
||||
_log("INFO", f"🎯 GATHERING EPISODE DATES for {imdb_id}: {len(disk_episodes)} episodes on disk (mode: {scan_mode})")
|
||||
episode_dates = {}
|
||||
episodes_needing_lookup = []
|
||||
|
||||
# For incomplete mode: Start with NFO check to find missing dateadded elements
|
||||
if scan_mode == "incomplete":
|
||||
return self._gather_episode_dates_nfo_first(series_path, imdb_id, disk_episodes)
|
||||
|
||||
# For smart/full modes: Use database-first optimization
|
||||
# TIER 1: Check database first for existing dates (fastest)
|
||||
_log("DEBUG", f"TIER 1 - Checking database for existing episode dates for {len(disk_episodes)} episodes")
|
||||
db_cache_hits = 0
|
||||
@@ -258,64 +246,12 @@ class TVProcessor:
|
||||
# Not in database or incomplete - needs NFO check
|
||||
episodes_needing_nfo_check.append((season, episode))
|
||||
|
||||
_log("INFO", f"Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need NFO check: {len(episodes_needing_nfo_check)}")
|
||||
_log("INFO", f"TIER 1 - Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_nfo_check)}")
|
||||
|
||||
# TIER 2: Check NFO files for NFOGuard dates and cache them in database
|
||||
nfo_cache_hits = 0
|
||||
episodes_needing_lookup = []
|
||||
|
||||
if episodes_needing_nfo_check:
|
||||
_log("DEBUG", f"TIER 2 - Checking NFO files for NFOGuard dates for {len(episodes_needing_nfo_check)} episodes")
|
||||
|
||||
for (season, episode) in episodes_needing_nfo_check:
|
||||
# Look for existing NFO files for this episode
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
episode_files = disk_episodes[(season, episode)]
|
||||
|
||||
nfo_found = False
|
||||
for episode_file in episode_files:
|
||||
# Try to find matching NFO file
|
||||
nfo_path = episode_file.with_suffix('.nfo')
|
||||
if nfo_path.exists():
|
||||
# Extract NFOGuard data from episode NFO
|
||||
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||
if nfo_data:
|
||||
aired = nfo_data.get('aired')
|
||||
dateadded = nfo_data.get('dateadded')
|
||||
source = nfo_data.get('source', 'nfo_cache')
|
||||
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO data found - aired={aired}, dateadded={dateadded}, source={source}")
|
||||
|
||||
# Skip incomplete NFO files with "unknown" source or no useful dates
|
||||
if source == "unknown" and not dateadded and not aired:
|
||||
_log("INFO", f"S{season:02d}E{episode:02d}: Ignoring incomplete NFO file with source 'unknown' and no dates")
|
||||
break # Break out of NFO search to mark episode as needing API lookup
|
||||
|
||||
# Apply fallback logic if NFO has aired but no dateadded
|
||||
if not dateadded and aired:
|
||||
dateadded = aired
|
||||
source = f"{source}_aired_fallback" if source != 'nfo_cache' else 'nfo_aired_fallback'
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO has aired but no dateadded, using aired as fallback: {dateadded}")
|
||||
|
||||
if dateadded:
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
nfo_cache_hits += 1
|
||||
nfo_found = True
|
||||
|
||||
# Cache NFO data in database for future lookups
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
_log("DEBUG", f"NFO cache hit for S{season:02d}E{episode:02d}: {dateadded} - cached in DB")
|
||||
break
|
||||
|
||||
if not nfo_found:
|
||||
# No NFO data found - needs API lookup
|
||||
episodes_needing_lookup.append((season, episode))
|
||||
|
||||
_log("INFO", f"NFO cache hits: {nfo_cache_hits}/{len(episodes_needing_nfo_check)} episodes. Need API lookup: {len(episodes_needing_lookup)}")
|
||||
|
||||
# TIER 3: Only call Sonarr API for episodes not in database or NFO files
|
||||
# TIER 2: Query Sonarr API for episodes not in database (NFO layer removed in Phase 2)
|
||||
episodes_needing_lookup = episodes_needing_nfo_check # Renamed for clarity
|
||||
if episodes_needing_lookup:
|
||||
_log("DEBUG", f"TIER 3 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database and NFO files")
|
||||
_log("DEBUG", f"TIER 2 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database")
|
||||
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_lookup)
|
||||
|
||||
# Process episodes that needed lookup
|
||||
@@ -329,6 +265,12 @@ class TVProcessor:
|
||||
sonarr_data = sonarr_episodes[(season, episode)]
|
||||
aired = sonarr_data.get('airDate')
|
||||
dateadded = sonarr_data.get('dateAdded')
|
||||
|
||||
# Enhanced debugging for Season 0 (Specials)
|
||||
if season == 0:
|
||||
_log("DEBUG", f"SPECIALS DEBUG S{season:02d}E{episode:02d}: Full Sonarr data: {sonarr_data}")
|
||||
_log("DEBUG", f"SPECIALS DEBUG S{season:02d}E{episode:02d}: airDate='{aired}', dateAdded='{dateadded}'")
|
||||
|
||||
if dateadded:
|
||||
source = "sonarr:history.import"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
|
||||
@@ -377,6 +319,89 @@ class TVProcessor:
|
||||
|
||||
return episode_dates
|
||||
|
||||
def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]]) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Gather episode dates for incomplete mode: Database-first then API (NFO checks removed in Phase 2)"""
|
||||
_log("INFO", f"🔍 INCOMPLETE MODE: Checking {len(disk_episodes)} episodes for missing data")
|
||||
episode_dates = {}
|
||||
episodes_needing_api_lookup = []
|
||||
|
||||
# STEP 1: Check database for all episodes (NFO check removed in Phase 2)
|
||||
_log("DEBUG", f"STEP 1 - Checking database for {len(disk_episodes)} episodes")
|
||||
db_hits = 0
|
||||
|
||||
for (season, episode) in disk_episodes:
|
||||
db_result = self.db.get_episode_date(imdb_id, season, episode)
|
||||
|
||||
if db_result and db_result.get('dateadded'):
|
||||
# Found in database - use cached data
|
||||
aired = db_result.get('aired')
|
||||
dateadded = db_result.get('dateadded')
|
||||
source = db_result.get('source', 'database_cache')
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
db_hits += 1
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Database has dateadded={dateadded}")
|
||||
else:
|
||||
# Not in database or incomplete - needs API lookup
|
||||
episodes_needing_api_lookup.append((season, episode))
|
||||
|
||||
_log("INFO", f"Database hits: {db_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_api_lookup)}")
|
||||
|
||||
# STEP 2: For episodes missing from database, query Sonarr
|
||||
if episodes_needing_api_lookup:
|
||||
_log("DEBUG", f"STEP 2 - Querying Sonarr for {len(episodes_needing_api_lookup)} episodes missing from database")
|
||||
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_api_lookup)
|
||||
|
||||
for (season, episode) in episodes_needing_api_lookup:
|
||||
aired = None
|
||||
dateadded = None
|
||||
source = "unknown"
|
||||
|
||||
# Try Sonarr first
|
||||
if (season, episode) in sonarr_episodes:
|
||||
sonarr_data = sonarr_episodes[(season, episode)]
|
||||
aired = sonarr_data.get('airDate')
|
||||
dateadded = sonarr_data.get('dateAdded')
|
||||
|
||||
if dateadded:
|
||||
source = "sonarr:history.import"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
|
||||
else:
|
||||
source = "sonarr:no_import_date"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Sonarr has data but no dateAdded (aired: {aired})")
|
||||
|
||||
# Fallback to external sources if needed
|
||||
if not aired:
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: No aired date from Sonarr, trying external APIs")
|
||||
external_aired = self.external_clients.get_episode_air_date(imdb_id, season, episode)
|
||||
if external_aired:
|
||||
aired = external_aired
|
||||
if not dateadded:
|
||||
source = "external"
|
||||
_log("INFO", f"S{season:02d}E{episode:02d}: Found aired date from external APIs: {aired}")
|
||||
|
||||
# Use air date as fallback for dateadded if no import date found
|
||||
if not dateadded and aired:
|
||||
dateadded = aired
|
||||
if source == "sonarr:no_import_date":
|
||||
source = "sonarr:aired_fallback"
|
||||
elif source == "external":
|
||||
source = "external:aired_fallback"
|
||||
else:
|
||||
source = f"{source}_fallback" if source != "unknown" else "aired_fallback"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Using aired date as dateadded fallback: {dateadded}")
|
||||
|
||||
# Save to database for future lookups
|
||||
if dateadded or aired:
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
|
||||
_log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {len(episode_dates)} episodes processed")
|
||||
for (s, e), (aired, dateadded, source) in episode_dates.items():
|
||||
_log("INFO", f" S{s:02d}E{e:02d}: aired={aired}, dateadded={dateadded}, source={source}")
|
||||
|
||||
return episode_dates
|
||||
|
||||
def _get_sonarr_episodes(self, imdb_id: str, episodes_filter: List[Tuple[int, int]] = None) -> Dict[Tuple[int, int], Dict[str, Any]]:
|
||||
"""Get episode information from Sonarr including import history - optimized to only fetch needed episodes"""
|
||||
try:
|
||||
@@ -444,6 +469,11 @@ class TVProcessor:
|
||||
'dateAdded': None
|
||||
}
|
||||
|
||||
# Enhanced debugging for Season 0 (Specials)
|
||||
if season == 0:
|
||||
_log("DEBUG", f"SPECIALS SONARR RAW S{season:02d}E{episode_num:02d}: airDate='{episode.get('airDate')}', hasFile={episode.get('hasFile')}")
|
||||
_log("DEBUG", f"SPECIALS SONARR RAW S{season:02d}E{episode_num:02d}: Full episode data: {episode}")
|
||||
|
||||
# First try to get import date from history (more accurate)
|
||||
episode_id = episode.get('id')
|
||||
if episode_id and episode.get('hasFile'):
|
||||
@@ -453,11 +483,6 @@ class TVProcessor:
|
||||
episode_data['dateAdded'] = import_date
|
||||
_log("DEBUG", f"Got import date from history for S{season:02d}E{episode_num:02d}: {import_date}")
|
||||
|
||||
# Yield control every 5 API calls to allow other requests
|
||||
if api_calls_made % 5 == 0:
|
||||
import time
|
||||
time.sleep(0.01) # 10ms yield to other processes
|
||||
_log("DEBUG", f"Yielded after {api_calls_made} Sonarr API calls to allow other requests...")
|
||||
|
||||
# Fallback to episodeFile.dateAdded if history didn't work
|
||||
if not episode_data['dateAdded'] and episode.get('hasFile'):
|
||||
@@ -468,11 +493,6 @@ class TVProcessor:
|
||||
|
||||
episode_map[(season, episode_num)] = episode_data
|
||||
|
||||
# Also yield control every 20 episodes processed to prevent blocking
|
||||
if episodes_processed % 20 == 0:
|
||||
import time
|
||||
time.sleep(0.01) # 10ms yield for large episode lists
|
||||
_log("DEBUG", f"Processed {episodes_processed} episodes, yielding to allow other requests...")
|
||||
|
||||
if filter_set:
|
||||
_log("DEBUG", f"Made {api_calls_made} Sonarr history API calls for filtered episodes (instead of all episodes)")
|
||||
@@ -521,18 +541,8 @@ class TVProcessor:
|
||||
processed_count = 0
|
||||
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
||||
if (season, episode) in season_episodes:
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_path,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
video_files = season_episodes[(season, episode)]
|
||||
for video_file in video_files:
|
||||
self.nfo_manager.set_file_mtime(video_file, dateadded)
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
@@ -601,16 +611,8 @@ class TVProcessor:
|
||||
|
||||
aired, dateadded, source = episode_dates[(season_num, episode_num)]
|
||||
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_path,
|
||||
season_num, episode_num, aired, dateadded, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Update file mtime
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.set_file_mtime(episode_path, dateadded)
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
|
||||
# Save to database
|
||||
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
|
||||
@@ -638,7 +640,7 @@ class TVProcessor:
|
||||
Returns:
|
||||
Dictionary with processing results
|
||||
"""
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
|
||||
if not imdb_id:
|
||||
return {"status": "error", "reason": f"No IMDb ID found in series path: {series_path}"}
|
||||
|
||||
@@ -660,25 +662,8 @@ class TVProcessor:
|
||||
|
||||
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
||||
if (season, episode) in disk_episodes:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
|
||||
# Prepare NFO creation data
|
||||
if config.manage_nfo:
|
||||
episode_data_list.append({
|
||||
'season_dir': season_dir,
|
||||
'season': season,
|
||||
'episode': episode,
|
||||
'aired': aired,
|
||||
'dateadded': dateadded,
|
||||
'source': source,
|
||||
'lock_metadata': config.lock_metadata
|
||||
})
|
||||
|
||||
# Prepare mtime operations
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
video_files = disk_episodes[(season, episode)]
|
||||
for video_file in video_files:
|
||||
mtime_operations.append((video_file, dateadded))
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
@@ -688,27 +673,10 @@ class TVProcessor:
|
||||
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
|
||||
# Continue processing other episodes
|
||||
|
||||
# Process NFOs and mtimes concurrently
|
||||
# NFO and mtime operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
results = {}
|
||||
|
||||
if episode_data_list:
|
||||
_log("INFO", f"Creating {len(episode_data_list)} episode NFOs concurrently")
|
||||
nfo_results = await self.async_nfo_manager.async_batch_create_episode_nfos(
|
||||
episode_data_list,
|
||||
max_concurrent=config.max_concurrent
|
||||
)
|
||||
results['nfo_created'] = sum(nfo_results)
|
||||
results['nfo_failed'] = len(nfo_results) - sum(nfo_results)
|
||||
|
||||
if mtime_operations:
|
||||
_log("INFO", f"Setting mtimes for {len(mtime_operations)} files concurrently")
|
||||
mtime_results = await self.async_nfo_manager.async_batch_set_file_mtimes(
|
||||
mtime_operations,
|
||||
max_concurrent=10
|
||||
)
|
||||
results['mtime_updated'] = sum(mtime_results)
|
||||
results['mtime_failed'] = len(mtime_results) - sum(mtime_results)
|
||||
|
||||
_log("INFO", f"Completed async processing TV series: {series_path.name}")
|
||||
|
||||
return {
|
||||
@@ -765,7 +733,7 @@ class TVProcessor:
|
||||
|
||||
def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None:
|
||||
"""Process only the specific episodes mentioned in a webhook (targeted mode)"""
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
|
||||
return
|
||||
@@ -793,7 +761,7 @@ class TVProcessor:
|
||||
continue
|
||||
|
||||
# Check if episode file exists on disk
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
|
||||
season_dir = series_path / config.get_season_dir_name(season_num)
|
||||
if not season_dir.exists():
|
||||
_log("WARNING", f"Season directory not found: {season_dir}")
|
||||
continue
|
||||
@@ -812,21 +780,11 @@ class TVProcessor:
|
||||
|
||||
# Get episode date information - webhook processing prioritizes existing DB entries
|
||||
_log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}")
|
||||
aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata)
|
||||
aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata, webhook_episode)
|
||||
enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir)
|
||||
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_dir,
|
||||
season_num, episode_num, aired, dateadded, source, config.lock_metadata,
|
||||
enhanced_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
for episode_file in episode_files:
|
||||
self.nfo_manager.set_file_mtime(episode_file, dateadded)
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
|
||||
# Save to database
|
||||
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
|
||||
@@ -840,20 +798,20 @@ class TVProcessor:
|
||||
|
||||
episodes_processed += 1
|
||||
|
||||
# Create season/tvshow NFOs if any episodes were processed
|
||||
if episodes_processed > 0 and config.manage_nfo:
|
||||
seasons_processed = set()
|
||||
for webhook_episode in webhook_episodes:
|
||||
season_num = webhook_episode.get("seasonNumber")
|
||||
if season_num and season_num not in seasons_processed:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
|
||||
if season_dir.exists():
|
||||
self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||
seasons_processed.add(season_num)
|
||||
|
||||
# Get TVDB ID for better Emby compatibility
|
||||
tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id)
|
||||
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id)
|
||||
# DISABLED: Skip creating season.nfo and tvshow.nfo files (user only wants episode NFOs)
|
||||
# if episodes_processed > 0 and config.manage_nfo:
|
||||
# seasons_processed = set()
|
||||
# for webhook_episode in webhook_episodes:
|
||||
# season_num = webhook_episode.get("seasonNumber")
|
||||
# if season_num and season_num not in seasons_processed:
|
||||
# season_dir = series_path / config.get_season_dir_name(season_num)
|
||||
# if season_dir.exists():
|
||||
# self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||
# seasons_processed.add(season_num)
|
||||
#
|
||||
# # Get TVDB ID for better Emby compatibility
|
||||
# tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id)
|
||||
# self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id)
|
||||
|
||||
_log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed")
|
||||
|
||||
@@ -957,17 +915,46 @@ class TVProcessor:
|
||||
|
||||
return None
|
||||
|
||||
def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]:
|
||||
def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None, webhook_episode: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]:
|
||||
"""
|
||||
Get episode date for webhook processing - avoid treating webhook as gospel.
|
||||
Get episode date for webhook processing - database-first approach.
|
||||
|
||||
Logic:
|
||||
1. Check Sonarr import history FIRST (any history = show has existed for a while)
|
||||
2. If ANY import history exists → Use air date (webhook is likely upgrade/rename)
|
||||
3. If NO import history → Use webhook date (truly new show)
|
||||
1. Check NFOGuard database FIRST - if we have a date, use it (no re-processing)
|
||||
2. No date in DB? Check Sonarr import history with our rules:
|
||||
- Import before rename = use import date
|
||||
- Rename before import = use airdate
|
||||
- First import matches webhook = valid new episode
|
||||
3. Final fallback = airdate
|
||||
|
||||
This prevents upgrades from overriding dates for shows you've had for months/years.
|
||||
This prevents webhooks from overriding existing good data.
|
||||
"""
|
||||
|
||||
# STEP 1: Check NFOGuard database first
|
||||
existing_entry = None
|
||||
existing_date = None
|
||||
try:
|
||||
existing_entry = self.db.get_episode(imdb_id, season_num, episode_num)
|
||||
if existing_entry and existing_entry.get('date_added'):
|
||||
existing_date = existing_entry['date_added']
|
||||
_log("INFO", f"Found existing date in database for S{season_num:02d}E{episode_num:02d}: {existing_date}")
|
||||
|
||||
# For webhook processing, use existing data (fast path)
|
||||
# Manual scans will validate below
|
||||
# Still get aired date for NFO
|
||||
aired = None
|
||||
if series_metadata and 'episodes' in series_metadata:
|
||||
episode_data = series_metadata['episodes'].get((season_num, episode_num))
|
||||
if episode_data:
|
||||
aired = episode_data.get('airDate')
|
||||
|
||||
return aired, str(existing_date), "database:existing"
|
||||
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Database check failed for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
_log("INFO", f"No existing date in database for S{season_num:02d}E{episode_num:02d}, checking Sonarr import history")
|
||||
|
||||
# Get aired date and episode ID from Sonarr
|
||||
aired = None
|
||||
episode_id = None
|
||||
@@ -978,8 +965,8 @@ class TVProcessor:
|
||||
episode_id = episode_data.get('id')
|
||||
_log("DEBUG", f"Got aired date from Sonarr for S{season_num:02d}E{episode_num:02d}: {aired}")
|
||||
|
||||
# STEP 1: Check Sonarr import history FIRST (this is the key check)
|
||||
_log("INFO", f"Checking Sonarr import history for S{season_num:02d}E{episode_num:02d} to detect import vs rename events")
|
||||
# STEP 2: Check Sonarr import history (authoritative source)
|
||||
_log("INFO", f"Checking Sonarr import history for S{season_num:02d}E{episode_num:02d}")
|
||||
|
||||
if episode_id and hasattr(self, 'sonarr'):
|
||||
try:
|
||||
@@ -988,9 +975,8 @@ class TVProcessor:
|
||||
_log("DEBUG", f"Import history result: {import_history}")
|
||||
|
||||
if import_history:
|
||||
# Found actual import event - use this date
|
||||
_log("INFO", f"Found real import event for S{season_num:02d}E{episode_num:02d}: {import_history}")
|
||||
_log("INFO", f"Using first import date (not webhook): {import_history}")
|
||||
# Found actual import event from Sonarr
|
||||
_log("INFO", f"Found Sonarr import event for S{season_num:02d}E{episode_num:02d}: {import_history}")
|
||||
return aired, import_history, "sonarr:import_history"
|
||||
else:
|
||||
# No import events found - this means only renames/moves exist in history
|
||||
@@ -1053,17 +1039,13 @@ class TVProcessor:
|
||||
dateadded = episode_data.get('dateadded')
|
||||
|
||||
# Get IMDb ID
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
|
||||
if not imdb_id:
|
||||
return {"status": "error", "reason": "No IMDb ID found"}
|
||||
|
||||
# Create NFO if needed
|
||||
nfo_success = True
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
nfo_success = await self.async_nfo_manager.async_create_episode_nfo(
|
||||
season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata
|
||||
)
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
nfo_success = True # Always True since we're not creating NFOs anymore
|
||||
|
||||
# Update database
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, "webhook", True)
|
||||
|
||||
@@ -29,6 +29,19 @@ class TVSeriesProcessor:
|
||||
self.sonarr = sonarr_client
|
||||
self.external_manager = ExternalClientManager()
|
||||
|
||||
# Initialize Sonarr database client for high-performance queries
|
||||
self.sonarr_db = None
|
||||
try:
|
||||
from clients.sonarr_db_client import SonarrDbClient
|
||||
self.sonarr_db = SonarrDbClient.from_env()
|
||||
if self.sonarr_db:
|
||||
_log("INFO", "✅ SONARR DB MODE: Direct database access enabled")
|
||||
else:
|
||||
_log("INFO", "⚠️ SONARR API MODE: Using API (configure SONARR_DB_TYPE for better performance)")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Sonarr DB client initialization failed, using API: {e}")
|
||||
self.sonarr_db = None
|
||||
|
||||
def process_series_manual_scan(self, series_path: Path) -> bool:
|
||||
"""Process a TV series during manual scan"""
|
||||
_log("INFO", f"Processing TV series: {series_path.name}")
|
||||
@@ -219,11 +232,62 @@ class TVSeriesProcessor:
|
||||
return False
|
||||
|
||||
def _get_episode_dates_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str], str]:
|
||||
"""Get episode dates from Sonarr API with proper fallback"""
|
||||
"""Get episode dates from Sonarr (DB first, then API fallback)"""
|
||||
aired = None
|
||||
dateadded = None
|
||||
source = "no_data_found"
|
||||
|
||||
# TIER 1: Try Sonarr database (high performance)
|
||||
if self.sonarr_db:
|
||||
try:
|
||||
# Find series by IMDb
|
||||
series = self.sonarr_db.get_series_by_imdb(imdb_id)
|
||||
if series:
|
||||
series_id = series['id']
|
||||
|
||||
# Get all episodes for series
|
||||
episodes = self.sonarr_db.get_all_episodes_for_series(series_id)
|
||||
|
||||
# Find target episode
|
||||
target_episode = None
|
||||
for ep in episodes:
|
||||
if ep['season'] == season_num and ep['episode'] == episode_num:
|
||||
target_episode = ep
|
||||
break
|
||||
|
||||
if target_episode:
|
||||
# Get air date
|
||||
aired = target_episode.get('air_date')
|
||||
|
||||
# Try to get import date from history
|
||||
episode_id = target_episode['id']
|
||||
import_date, import_source = self.sonarr_db.get_episode_import_date(episode_id)
|
||||
|
||||
if import_date:
|
||||
dateadded = import_date
|
||||
source = import_source
|
||||
_log("INFO", f"✅ DB: Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
# Fallback to episode file date
|
||||
file_date = self.sonarr_db.get_episode_file_date(series_id, season_num, episode_num)
|
||||
if file_date:
|
||||
dateadded = file_date
|
||||
source = "sonarr:db.file.dateAdded"
|
||||
_log("INFO", f"✅ DB: Using file date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
# Use air date as fallback
|
||||
if aired:
|
||||
dateadded = convert_utc_to_local(aired)
|
||||
source = "sonarr:db.airDate"
|
||||
_log("WARNING", f"⚠️ DB: No import date, using air date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Sonarr DB query failed for S{season_num:02d}E{episode_num:02d}, falling back to API: {e}")
|
||||
|
||||
# TIER 2: Fall back to Sonarr API
|
||||
if not self.sonarr.enabled:
|
||||
_log("WARNING", "Sonarr not enabled, cannot get episode dates")
|
||||
return aired, dateadded, source
|
||||
@@ -266,15 +330,15 @@ class TVSeriesProcessor:
|
||||
# Sonarr import dates are already in local timezone despite 'Z' suffix
|
||||
# Remove 'Z' and use as-is to avoid double timezone conversion
|
||||
dateadded = import_date.replace('Z', '') if 'Z' in import_date else import_date
|
||||
source = "sonarr:history.import"
|
||||
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded} (no timezone conversion)")
|
||||
source = "sonarr:api.history.import"
|
||||
_log("INFO", f"✅ API: Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
# Fallback to airdate if no import history
|
||||
if aired:
|
||||
dateadded = convert_utc_to_local(aired)
|
||||
source = "sonarr:episode.airDateUtc"
|
||||
_log("WARNING", f"No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
|
||||
source = "sonarr:api.episode.airDateUtc"
|
||||
_log("WARNING", f"⚠️ API: No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
except Exception as e:
|
||||
@@ -371,21 +435,22 @@ class TVSeriesProcessor:
|
||||
return None, "no_external_data"
|
||||
|
||||
def _create_series_nfos(self, series_path: Path, imdb_id: str):
|
||||
"""Create tvshow.nfo and season.nfo files only if they don't exist"""
|
||||
# Create tvshow.nfo only if it doesn't exist
|
||||
tvshow_nfo = series_path / "tvshow.nfo"
|
||||
if not tvshow_nfo.exists():
|
||||
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id)
|
||||
else:
|
||||
_log("DEBUG", f"Skipping tvshow.nfo creation - already exists: {tvshow_nfo}")
|
||||
|
||||
# Create season.nfo for each season directory only if they don't exist
|
||||
for season_dir in series_path.iterdir():
|
||||
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||
season_num = self._extract_season_number(season_dir.name)
|
||||
if season_num is not None:
|
||||
season_nfo = season_dir / "season.nfo"
|
||||
if not season_nfo.exists():
|
||||
self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||
else:
|
||||
_log("DEBUG", f"Skipping season.nfo creation - already exists: {season_nfo}")
|
||||
"""DISABLED: Skip creating tvshow.nfo and season.nfo files (user only wants episode NFOs)"""
|
||||
# # Create tvshow.nfo only if it doesn't exist
|
||||
# tvshow_nfo = series_path / "tvshow.nfo"
|
||||
# if not tvshow_nfo.exists():
|
||||
# self.nfo_manager.create_tvshow_nfo(series_path, imdb_id)
|
||||
# else:
|
||||
# _log("DEBUG", f"Skipping tvshow.nfo creation - already exists: {tvshow_nfo}")
|
||||
#
|
||||
# # Create season.nfo for each season directory only if they don't exist
|
||||
# for season_dir in series_path.iterdir():
|
||||
# if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||
# season_num = self._extract_season_number(season_dir.name)
|
||||
# if season_num is not None:
|
||||
# season_nfo = season_dir / "season.nfo"
|
||||
# if not season_nfo.exists():
|
||||
# self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||
# else:
|
||||
# _log("DEBUG", f"Skipping season.nfo creation - already exists: {season_nfo}")
|
||||
pass # Function disabled - only process episode NFOs
|
||||
@@ -8,3 +8,5 @@ aiofiles==23.2.1
|
||||
aiohttp==3.8.6
|
||||
psutil==5.9.6
|
||||
python-dotenv==1.0.0
|
||||
APScheduler==3.10.4
|
||||
croniter==1.4.1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Scheduler module for NFOGuard
|
||||
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
NFOGuard Background Scheduler
|
||||
Manages scheduled scans using APScheduler with cron-like functionality
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.jobstores.memory import MemoryJobStore
|
||||
from apscheduler.executors.asyncio import AsyncIOExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NFOGuardScheduler:
|
||||
"""
|
||||
Background scheduler for NFOGuard that manages scheduled scans
|
||||
"""
|
||||
|
||||
def __init__(self, dependencies: Dict[str, Any]):
|
||||
"""Initialize the scheduler with dependencies"""
|
||||
self.dependencies = dependencies
|
||||
self.scheduler = None
|
||||
self.running = False
|
||||
|
||||
# Configure APScheduler
|
||||
jobstores = {
|
||||
'default': MemoryJobStore()
|
||||
}
|
||||
executors = {
|
||||
'default': AsyncIOExecutor()
|
||||
}
|
||||
job_defaults = {
|
||||
'coalesce': False,
|
||||
'max_instances': 1,
|
||||
'misfire_grace_time': 300 # 5 minutes
|
||||
}
|
||||
|
||||
self.scheduler = AsyncIOScheduler(
|
||||
jobstores=jobstores,
|
||||
executors=executors,
|
||||
job_defaults=job_defaults,
|
||||
timezone='UTC'
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
"""Start the scheduler and load existing schedules"""
|
||||
if self.running:
|
||||
logger.warning("Scheduler is already running")
|
||||
return
|
||||
|
||||
try:
|
||||
self.scheduler.start()
|
||||
self.running = True
|
||||
logger.info("✅ NFOGuard Scheduler started successfully")
|
||||
|
||||
# Load existing scheduled scans from database
|
||||
await self.load_schedules()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start scheduler: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the scheduler gracefully"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
try:
|
||||
self.scheduler.shutdown()
|
||||
self.running = False
|
||||
logger.info("✅ NFOGuard Scheduler stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping scheduler: {e}")
|
||||
|
||||
async def load_schedules(self):
|
||||
"""Load all enabled scheduled scans from database and add them to scheduler"""
|
||||
try:
|
||||
db = self.dependencies.get("db")
|
||||
if not db:
|
||||
logger.error("Database not available for loading schedules")
|
||||
return
|
||||
|
||||
# Get all enabled scheduled scans
|
||||
scheduled_scans = db.get_scheduled_scans(enabled_only=True)
|
||||
|
||||
for scan in scheduled_scans:
|
||||
await self.add_schedule(scan)
|
||||
|
||||
logger.info(f"Loaded {len(scheduled_scans)} scheduled scans")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load schedules: {e}")
|
||||
|
||||
async def add_schedule(self, scan: Dict[str, Any]):
|
||||
"""Add a scheduled scan to the scheduler"""
|
||||
try:
|
||||
job_id = f"scan_{scan['id']}"
|
||||
|
||||
# Remove existing job if it exists
|
||||
if self.scheduler.get_job(job_id):
|
||||
self.scheduler.remove_job(job_id)
|
||||
|
||||
# Create cron trigger
|
||||
trigger = CronTrigger.from_crontab(scan['cron_expression'])
|
||||
|
||||
# Add job to scheduler
|
||||
self.scheduler.add_job(
|
||||
func=self._execute_scheduled_scan,
|
||||
trigger=trigger,
|
||||
id=job_id,
|
||||
args=[scan['id']],
|
||||
name=f"Scheduled Scan: {scan['name']}",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Update next run time in database
|
||||
next_run = self.scheduler.get_job(job_id).next_run_time
|
||||
if next_run:
|
||||
db = self.dependencies.get("db")
|
||||
if db:
|
||||
db.update_scan_next_run(scan['id'], next_run)
|
||||
|
||||
logger.info(f"✅ Added scheduled scan: {scan['name']} ({scan['cron_expression']})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add schedule for scan {scan['id']}: {e}")
|
||||
|
||||
async def remove_schedule(self, scan_id: int):
|
||||
"""Remove a scheduled scan from the scheduler"""
|
||||
try:
|
||||
job_id = f"scan_{scan_id}"
|
||||
|
||||
if self.scheduler.get_job(job_id):
|
||||
self.scheduler.remove_job(job_id)
|
||||
logger.info(f"✅ Removed scheduled scan: {scan_id}")
|
||||
else:
|
||||
logger.warning(f"No job found for scan ID: {scan_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove schedule for scan {scan_id}: {e}")
|
||||
|
||||
async def update_schedule(self, scan: Dict[str, Any]):
|
||||
"""Update an existing scheduled scan"""
|
||||
try:
|
||||
# Remove old schedule and add new one
|
||||
await self.remove_schedule(scan['id'])
|
||||
if scan['enabled']:
|
||||
await self.add_schedule(scan)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update schedule for scan {scan['id']}: {e}")
|
||||
|
||||
async def _execute_scheduled_scan(self, scan_id: int):
|
||||
"""Execute a scheduled scan"""
|
||||
db = self.dependencies.get("db")
|
||||
if not db:
|
||||
logger.error(f"Database not available for executing scan {scan_id}")
|
||||
return
|
||||
|
||||
# Get scan details
|
||||
scan = db.get_scheduled_scan(scan_id)
|
||||
if not scan:
|
||||
logger.error(f"Scheduled scan {scan_id} not found")
|
||||
return
|
||||
|
||||
if not scan['enabled']:
|
||||
logger.info(f"Skipping disabled scan: {scan['name']}")
|
||||
return
|
||||
|
||||
execution_id = None
|
||||
try:
|
||||
logger.info(f"🚀 Starting scheduled scan: {scan['name']} (ID: {scan_id})")
|
||||
|
||||
# Create execution record
|
||||
execution_id = db.create_schedule_execution(
|
||||
schedule_id=scan_id,
|
||||
media_type=scan['media_type'],
|
||||
scan_mode=scan['scan_mode'],
|
||||
triggered_by="scheduler"
|
||||
)
|
||||
|
||||
# Update last run time
|
||||
db.update_scan_last_run(scan_id)
|
||||
|
||||
# Execute the actual scan
|
||||
result = await self._run_media_scan(scan, execution_id)
|
||||
|
||||
# Update execution with results
|
||||
db.update_schedule_execution(
|
||||
execution_id=execution_id,
|
||||
status="completed",
|
||||
items_processed=result.get('items_processed', 0),
|
||||
items_skipped=result.get('items_skipped', 0),
|
||||
items_failed=result.get('items_failed', 0),
|
||||
logs=result.get('logs', '')
|
||||
)
|
||||
|
||||
logger.info(f"✅ Completed scheduled scan: {scan['name']} - Processed: {result.get('items_processed', 0)}, Skipped: {result.get('items_skipped', 0)}, Failed: {result.get('items_failed', 0)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed scheduled scan: {scan['name']} - {e}")
|
||||
|
||||
if execution_id:
|
||||
db.update_schedule_execution(
|
||||
execution_id=execution_id,
|
||||
status="failed",
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def _run_media_scan(self, scan: Dict[str, Any], execution_id: int) -> Dict[str, Any]:
|
||||
"""Run the actual media scan based on scan configuration"""
|
||||
try:
|
||||
# Import scan functionality from existing modules
|
||||
from api.routes import run_tv_scan, run_movie_scan
|
||||
|
||||
media_type = scan['media_type']
|
||||
scan_mode = scan['scan_mode']
|
||||
specific_paths = scan.get('specific_paths', '').strip()
|
||||
|
||||
results = {
|
||||
'items_processed': 0,
|
||||
'items_skipped': 0,
|
||||
'items_failed': 0,
|
||||
'logs': []
|
||||
}
|
||||
|
||||
# Parse specific paths if provided
|
||||
paths = []
|
||||
if specific_paths:
|
||||
paths = [p.strip() for p in specific_paths.split(',') if p.strip()]
|
||||
|
||||
# Run TV scan if needed
|
||||
if media_type in ['tv', 'both']:
|
||||
logger.info(f"Running TV scan with mode: {scan_mode}")
|
||||
|
||||
# Use existing scan infrastructure
|
||||
tv_result = await self._execute_tv_scan(scan_mode, paths)
|
||||
|
||||
results['items_processed'] += tv_result.get('tv_series_processed', 0)
|
||||
results['items_skipped'] += tv_result.get('tv_series_skipped', 0)
|
||||
results['items_failed'] += tv_result.get('tv_series_failed', 0)
|
||||
results['logs'].append(f"TV Scan: {tv_result.get('message', 'Completed')}")
|
||||
|
||||
# Run movie scan if needed
|
||||
if media_type in ['movies', 'both']:
|
||||
logger.info(f"Running movie scan with mode: {scan_mode}")
|
||||
|
||||
# Use existing scan infrastructure
|
||||
movie_result = await self._execute_movie_scan(scan_mode, paths)
|
||||
|
||||
results['items_processed'] += movie_result.get('movies_processed', 0)
|
||||
results['items_skipped'] += movie_result.get('movies_skipped', 0)
|
||||
results['items_failed'] += movie_result.get('movies_failed', 0)
|
||||
results['logs'].append(f"Movie Scan: {movie_result.get('message', 'Completed')}")
|
||||
|
||||
results['logs'] = '\n'.join(results['logs'])
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in media scan execution: {e}")
|
||||
return {
|
||||
'items_processed': 0,
|
||||
'items_skipped': 0,
|
||||
'items_failed': 1,
|
||||
'logs': f"Scan failed: {str(e)}"
|
||||
}
|
||||
|
||||
async def _execute_tv_scan(self, scan_mode: str, specific_paths: list = None) -> Dict[str, Any]:
|
||||
"""Execute TV scan using existing infrastructure"""
|
||||
try:
|
||||
# This would integrate with the existing manual scan functionality
|
||||
# For now, return a placeholder result
|
||||
return {
|
||||
'tv_series_processed': 0,
|
||||
'tv_series_skipped': 0,
|
||||
'tv_series_failed': 0,
|
||||
'message': f'TV scan ({scan_mode}) - Integration pending'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"TV scan execution failed: {e}")
|
||||
return {
|
||||
'tv_series_processed': 0,
|
||||
'tv_series_skipped': 0,
|
||||
'tv_series_failed': 1,
|
||||
'message': f'TV scan failed: {str(e)}'
|
||||
}
|
||||
|
||||
async def _execute_movie_scan(self, scan_mode: str, specific_paths: list = None) -> Dict[str, Any]:
|
||||
"""Execute movie scan using existing infrastructure"""
|
||||
try:
|
||||
# This would integrate with the existing manual scan functionality
|
||||
# For now, return a placeholder result
|
||||
return {
|
||||
'movies_processed': 0,
|
||||
'movies_skipped': 0,
|
||||
'movies_failed': 0,
|
||||
'message': f'Movie scan ({scan_mode}) - Integration pending'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Movie scan execution failed: {e}")
|
||||
return {
|
||||
'movies_processed': 0,
|
||||
'movies_skipped': 0,
|
||||
'movies_failed': 1,
|
||||
'message': f'Movie scan failed: {str(e)}'
|
||||
}
|
||||
|
||||
async def run_manual_scan(self, scan_id: int) -> Dict[str, Any]:
|
||||
"""Manually trigger a scheduled scan"""
|
||||
try:
|
||||
db = self.dependencies.get("db")
|
||||
scan = db.get_scheduled_scan(scan_id)
|
||||
|
||||
if not scan:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Scheduled scan not found'
|
||||
}
|
||||
|
||||
# Execute the scan in the background
|
||||
asyncio.create_task(self._execute_scheduled_scan(scan_id))
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f"Manual execution of '{scan['name']}' started"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run manual scan {scan_id}: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def get_job_status(self, scan_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get the status of a scheduled job"""
|
||||
try:
|
||||
job_id = f"scan_{scan_id}"
|
||||
job = self.scheduler.get_job(job_id)
|
||||
|
||||
if not job:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get job status for scan {scan_id}: {e}")
|
||||
return None
|
||||
|
||||
def list_jobs(self) -> list:
|
||||
"""List all scheduled jobs"""
|
||||
try:
|
||||
jobs = []
|
||||
for job in self.scheduler.get_jobs():
|
||||
jobs.append({
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
})
|
||||
return jobs
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list jobs: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
scheduler_instance: Optional[NFOGuardScheduler] = None
|
||||
|
||||
|
||||
async def get_scheduler(dependencies: Dict[str, Any]) -> NFOGuardScheduler:
|
||||
"""Get or create the global scheduler instance"""
|
||||
global scheduler_instance
|
||||
|
||||
if scheduler_instance is None:
|
||||
scheduler_instance = NFOGuardScheduler(dependencies)
|
||||
await scheduler_instance.start()
|
||||
|
||||
return scheduler_instance
|
||||
|
||||
|
||||
async def shutdown_scheduler():
|
||||
"""Shutdown the global scheduler instance"""
|
||||
global scheduler_instance
|
||||
|
||||
if scheduler_instance:
|
||||
await scheduler_instance.stop()
|
||||
scheduler_instance = None
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NFOGuard Web Interface Starter
|
||||
Simple script to start web interface using existing config system
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Import existing configuration (keep using core config for simplicity)
|
||||
from config.settings import config
|
||||
|
||||
# Import existing database and components
|
||||
from core.database import NFOGuardDatabase
|
||||
|
||||
# Import web routes from existing system (now includes DELETE route)
|
||||
from api.web_routes import register_web_routes
|
||||
|
||||
# Import authentication system
|
||||
from api.auth import SimpleAuthMiddleware, AuthSession
|
||||
|
||||
|
||||
def create_web_app() -> FastAPI:
|
||||
"""Create FastAPI web application"""
|
||||
app = FastAPI(
|
||||
title="NFOGuard Web Interface",
|
||||
description="Web interface for NFOGuard media database management",
|
||||
version="2.9.0-fixes-only-files",
|
||||
docs_url=None, # Disable docs in production
|
||||
redoc_url=None
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def setup_static_files(app: FastAPI) -> None:
|
||||
"""Mount static file directories"""
|
||||
static_path = os.path.join(os.path.dirname(__file__), "nfoguard-web", "static")
|
||||
logo_path = os.path.join(os.path.dirname(__file__), "logo")
|
||||
|
||||
print(f"🔍 Checking static path: {static_path} (exists: {os.path.exists(static_path)})")
|
||||
print(f"🔍 Checking logo path: {logo_path} (exists: {os.path.exists(logo_path)})")
|
||||
|
||||
if os.path.exists(static_path):
|
||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||
print(f"✅ Mounted static files from: {static_path}")
|
||||
else:
|
||||
print(f"❌ Static path not found: {static_path}")
|
||||
|
||||
if os.path.exists(logo_path):
|
||||
app.mount("/logo", StaticFiles(directory=logo_path), name="logo")
|
||||
print(f"✅ Mounted logo files from: {logo_path}")
|
||||
else:
|
||||
print(f"❌ Logo path not found: {logo_path}")
|
||||
|
||||
# Serve index.html at root
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
index_file = os.path.join(static_path, "index.html")
|
||||
if os.path.exists(index_file):
|
||||
return FileResponse(index_file)
|
||||
else:
|
||||
return {"message": "NFOGuard Web Interface", "status": "running"}
|
||||
|
||||
# Serve favicon
|
||||
@app.get("/favicon.ico")
|
||||
async def serve_favicon():
|
||||
# Try to serve favicon from logo directory or static files
|
||||
favicon_paths = [
|
||||
os.path.join(logo_path, "favicon.ico"),
|
||||
os.path.join(static_path, "favicon.ico"),
|
||||
os.path.join(logo_path, "NFOGuardLogo.png") # Fallback to new logo
|
||||
]
|
||||
|
||||
for favicon_path in favicon_paths:
|
||||
if os.path.exists(favicon_path):
|
||||
return FileResponse(favicon_path)
|
||||
|
||||
# Return 204 No Content if no favicon found
|
||||
from fastapi import Response
|
||||
return Response(status_code=204)
|
||||
|
||||
# Health check endpoint for Docker
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for Docker container monitoring"""
|
||||
try:
|
||||
# Basic health check - verify the web service is responsive
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "nfoguard-web",
|
||||
"timestamp": time.time(),
|
||||
"version": "2.9.0-fixes-only-files"
|
||||
}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=503, detail=f"Health check failed: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for NFOGuard Web Interface"""
|
||||
print("🌐 Starting NFOGuard Web Interface...")
|
||||
|
||||
# Use existing config system
|
||||
web_host = os.environ.get("WEB_HOST", "0.0.0.0")
|
||||
web_port = int(os.environ.get("WEB_PORT", "8081"))
|
||||
|
||||
print(f"📊 Configuration: Port {web_port}")
|
||||
|
||||
# Create FastAPI app
|
||||
app = create_web_app()
|
||||
|
||||
# Initialize database using existing system
|
||||
try:
|
||||
db = NFOGuardDatabase(config)
|
||||
print(f"✅ Connected to database: {config.db_host}:{config.db_port}/{config.db_name}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect to database: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Setup authentication if enabled
|
||||
auth_enabled = getattr(config, 'web_auth_enabled', False)
|
||||
session_manager = None
|
||||
|
||||
if auth_enabled:
|
||||
session_timeout = getattr(config, 'web_auth_session_timeout', 3600)
|
||||
session_manager = AuthSession(timeout_seconds=session_timeout)
|
||||
print(f"🔐 Web authentication enabled (session timeout: {session_timeout}s)")
|
||||
else:
|
||||
print("🌐 Web authentication disabled")
|
||||
|
||||
# Create dependencies for dependency injection
|
||||
dependencies = {
|
||||
"db": db,
|
||||
"config": config,
|
||||
"nfo_manager": None, # Not needed for read-only web interface
|
||||
"movie_processor": None, # Not needed for read-only web interface
|
||||
"tv_processor": None, # Not needed for read-only web interface
|
||||
"auth_enabled": auth_enabled,
|
||||
"session_manager": session_manager
|
||||
}
|
||||
|
||||
# Add authentication middleware if enabled (BEFORE routes)
|
||||
if auth_enabled:
|
||||
app.add_middleware(SimpleAuthMiddleware, config=config, session_manager=session_manager)
|
||||
print("🔐 Authentication middleware added to web interface")
|
||||
|
||||
# Setup static files and routes
|
||||
setup_static_files(app)
|
||||
|
||||
# Register web routes (now includes DELETE /api/episodes/ route)
|
||||
register_web_routes(app, dependencies)
|
||||
print("✅ Registered web routes with DELETE /api/episodes/ support")
|
||||
|
||||
print(f"🚀 Starting web server on {web_host}:{web_port}")
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=web_host,
|
||||
port=web_port,
|
||||
workers=1,
|
||||
log_level="info",
|
||||
access_log=False
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Web interface shutdown by user")
|
||||
except Exception as e:
|
||||
print(f"❌ Web interface failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -843,3 +843,60 @@ body {
|
||||
.d-flex { display: flex; }
|
||||
.justify-content-between { justify-content: space-between; }
|
||||
.align-items-center { align-items: center; }
|
||||
|
||||
/* Manual Scan Styles */
|
||||
.scan-status {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--light-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.scan-progress {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--success-color));
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.scan-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.scan-info span:first-child {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.scan-info span:last-child {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
+80
-2
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NFOGuard - Database Management</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=manual-scan-ui">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
@@ -327,6 +327,51 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-search"></i> Manual Scan</h3>
|
||||
<p>Scan specific folders or perform full library scans</p>
|
||||
<form id="manual-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="scan-type" required>
|
||||
<option value="both">TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Mode:</label>
|
||||
<select id="scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="full">Full Scan</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Specific Path (Optional):</label>
|
||||
<input type="text" id="scan-path" placeholder="e.g., /mnt/unionfs/Media/TV/Series Name" title="Leave empty for full library scan">
|
||||
<small>Leave empty to scan entire library</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Start Scan
|
||||
</button>
|
||||
</form>
|
||||
<div id="scan-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="scan-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="scan-current-operation">Initializing...</span>
|
||||
<span id="scan-progress-text">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
||||
<p>View detailed database information</p>
|
||||
@@ -337,6 +382,39 @@
|
||||
<i class="fas fa-sync"></i> Refresh Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-upload"></i> Populate Database</h3>
|
||||
<p>Bulk import data from Radarr/Sonarr into NFOGuard database</p>
|
||||
<form id="populate-form">
|
||||
<div class="form-group">
|
||||
<label>Media Type:</label>
|
||||
<select id="populate-media-type" required>
|
||||
<option value="both">Movies & TV Shows</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Start Population
|
||||
</button>
|
||||
</form>
|
||||
<div id="populate-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="populate-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="populate-current-operation">Running...</span>
|
||||
<span id="populate-progress-text">In Progress</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="populate-results" class="stats-display" style="margin-top: 10px;"></div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopPopulatePolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -404,6 +482,6 @@
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/app.js?v=manual-scan-ui"></script>
|
||||
</body>
|
||||
</html>
|
||||
+496
-5
@@ -76,6 +76,8 @@ function initializeEventListeners() {
|
||||
// Forms
|
||||
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
|
||||
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
|
||||
document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan);
|
||||
document.getElementById('populate-form').addEventListener('submit', handlePopulateDatabase);
|
||||
}
|
||||
|
||||
// API calls
|
||||
@@ -503,10 +505,21 @@ function showEpisodesModal(data) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<button id="bulk-select-all" class="btn btn-sm btn-secondary" onclick="toggleSelectAll()">
|
||||
<i class="fas fa-check-square"></i> Select All
|
||||
</button>
|
||||
<button id="bulk-delete-selected" class="btn btn-sm btn-danger" onclick="bulkDeleteSelected()" style="margin-left: 10px;" disabled>
|
||||
<i class="fas fa-trash"></i> Delete Selected (<span id="selected-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="40px">
|
||||
<input type="checkbox" id="select-all-checkbox" onchange="toggleSelectAll()">
|
||||
</th>
|
||||
<th>Episode</th>
|
||||
<th>Aired</th>
|
||||
<th>Date Added</th>
|
||||
@@ -529,7 +542,10 @@ function showEpisodesModal(data) {
|
||||
`<td>${dateadded}</td>`;
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}" data-has-date="${!missingDate}">
|
||||
<tr class="${rowClass}" data-has-date="${!missingDate}" data-imdb="${data.series.imdb_id}" data-season="${episode.season}" data-episode="${episode.episode}">
|
||||
<td>
|
||||
<input type="checkbox" class="episode-checkbox" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
<td>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
|
||||
<td>${episode.aired || '-'}</td>
|
||||
${dateCell}
|
||||
@@ -667,6 +683,13 @@ async function smartFixMovie(imdbId) {
|
||||
}
|
||||
|
||||
async function smartFixEpisode(imdbId, season, episode) {
|
||||
// Validate parameters
|
||||
if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) {
|
||||
console.error('smartFixEpisode: Invalid parameters:', {imdbId, season, episode});
|
||||
showToast('Invalid episode parameters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`);
|
||||
showSmartFixModal('episode', options);
|
||||
@@ -694,7 +717,10 @@ function showSmartFixModal(mediaType, options) {
|
||||
if (mediaType === 'movie') {
|
||||
title.textContent = `Fix Date for Movie: ${options.imdb_id}`;
|
||||
} else {
|
||||
title.textContent = `Fix Date for Episode: ${options.imdb_id} S${options.season.toString().padStart(2, '0')}E${options.episode.toString().padStart(2, '0')}`;
|
||||
// Add validation for episode data
|
||||
const season = options.season || 0;
|
||||
const episode = options.episode || 0;
|
||||
title.textContent = `Fix Date for Episode: ${options.imdb_id} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Build options HTML
|
||||
@@ -910,7 +936,10 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour
|
||||
if (mediaType === 'movie') {
|
||||
title.textContent = `Edit Movie: ${options.imdb_id}`;
|
||||
} else {
|
||||
title.textContent = `Edit Episode: ${options.imdb_id} S${options.season.toString().padStart(2, '0')}E${options.episode.toString().padStart(2, '0')}`;
|
||||
// Add validation for episode data
|
||||
const season = options.season || 0;
|
||||
const episode = options.episode || 0;
|
||||
title.textContent = `Edit Episode: ${options.imdb_id} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Build enhanced edit form with date options
|
||||
@@ -1094,6 +1123,13 @@ async function handleEnhancedEditSubmit(event) {
|
||||
}
|
||||
|
||||
async function editEpisode(imdbId, season, episode, dateadded, source) {
|
||||
// Validate parameters
|
||||
if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) {
|
||||
console.error('editEpisode: Invalid parameters:', {imdbId, season, episode});
|
||||
showToast('Invalid episode parameters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load episode options to populate available dates
|
||||
const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`);
|
||||
@@ -1276,6 +1312,13 @@ Analysis:
|
||||
|
||||
// Episode deletion functionality
|
||||
async function deleteEpisode(imdbId, season, episode) {
|
||||
// Validate parameters
|
||||
if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) {
|
||||
console.error('deleteEpisode: Invalid parameters:', {imdbId, season, episode});
|
||||
showToast('Invalid episode parameters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const episodeStr = `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`;
|
||||
|
||||
// Confirmation dialog
|
||||
@@ -1284,7 +1327,7 @@ async function deleteEpisode(imdbId, season, episode) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/database/episode/${imdbId}/${season}/${episode}`, {
|
||||
const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -1327,7 +1370,7 @@ async function deleteMovie(imdbId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/database/movie/${imdbId}`, {
|
||||
const response = await fetch(`/api/movies/${imdbId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -1409,6 +1452,169 @@ async function checkAuthStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Scan Functions
|
||||
async function handleManualScan(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const scanType = document.getElementById('scan-type').value;
|
||||
const scanMode = document.getElementById('scan-mode').value;
|
||||
const scanPath = document.getElementById('scan-path').value.trim();
|
||||
|
||||
// Validate inputs
|
||||
if (!scanType || !scanMode) {
|
||||
showToast('❌ Please select scan type and mode', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams({
|
||||
scan_type: scanType,
|
||||
scan_mode: scanMode
|
||||
});
|
||||
|
||||
if (scanPath) {
|
||||
params.append('path', scanPath);
|
||||
}
|
||||
|
||||
try {
|
||||
// Show scan status
|
||||
showScanStatus();
|
||||
|
||||
// Start the scan
|
||||
showToast('🚀 Starting manual scan...', 'info');
|
||||
const response = await fetch(`/manual/scan?${params}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
showToast('✅ Scan started successfully', 'success');
|
||||
// Start polling for status
|
||||
startScanPolling();
|
||||
} else {
|
||||
showToast(`ℹ️ ${result.message || 'Scan completed'}`, 'info');
|
||||
hideScanStatus();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Manual scan failed:', error);
|
||||
showToast(`❌ Scan failed: ${error.message}`, 'error');
|
||||
hideScanStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function showScanStatus() {
|
||||
const scanStatus = document.getElementById('scan-status');
|
||||
const progressBar = document.getElementById('scan-progress-bar');
|
||||
const operationText = document.getElementById('scan-current-operation');
|
||||
const progressText = document.getElementById('scan-progress-text');
|
||||
|
||||
// Reset and show
|
||||
progressBar.style.width = '0%';
|
||||
operationText.textContent = 'Initializing scan...';
|
||||
progressText.textContent = '0%';
|
||||
scanStatus.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideScanStatus() {
|
||||
document.getElementById('scan-status').style.display = 'none';
|
||||
if (window.scanPollingInterval) {
|
||||
clearInterval(window.scanPollingInterval);
|
||||
window.scanPollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopScanPolling() {
|
||||
hideScanStatus();
|
||||
}
|
||||
|
||||
function startScanPolling() {
|
||||
// Poll every 2 seconds for scan status
|
||||
window.scanPollingInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/scan/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get scan status');
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
updateScanProgress(status);
|
||||
|
||||
// Stop polling if scan is complete
|
||||
if (!status.scanning) {
|
||||
stopScanPolling();
|
||||
showToast('✅ Scan completed!', 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to poll scan status:', error);
|
||||
// Don't show error toast repeatedly, just stop polling
|
||||
stopScanPolling();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function updateScanProgress(status) {
|
||||
const progressBar = document.getElementById('scan-progress-bar');
|
||||
const operationText = document.getElementById('scan-current-operation');
|
||||
const progressText = document.getElementById('scan-progress-text');
|
||||
|
||||
if (!status.scanning) {
|
||||
progressBar.style.width = '100%';
|
||||
operationText.textContent = 'Scan completed';
|
||||
progressText.textContent = '100%';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate overall progress
|
||||
let totalProgress = 0;
|
||||
let progressDetails = '';
|
||||
|
||||
if (status.scan_type === 'both' || status.scan_type === 'tv') {
|
||||
const tvProgress = status.tv_series_total > 0 ?
|
||||
((status.tv_series_processed + status.tv_series_skipped) / status.tv_series_total) * 50 : 0;
|
||||
totalProgress += tvProgress;
|
||||
|
||||
if (status.tv_series_total > 0) {
|
||||
progressDetails += `TV: ${status.tv_series_processed + status.tv_series_skipped}/${status.tv_series_total} `;
|
||||
}
|
||||
}
|
||||
|
||||
if (status.scan_type === 'both' || status.scan_type === 'movies') {
|
||||
const movieProgress = status.movies_total > 0 ?
|
||||
((status.movies_processed + status.movies_skipped) / status.movies_total) * 50 : 0;
|
||||
totalProgress += movieProgress;
|
||||
|
||||
if (status.movies_total > 0) {
|
||||
progressDetails += `Movies: ${status.movies_processed + status.movies_skipped}/${status.movies_total}`;
|
||||
}
|
||||
}
|
||||
|
||||
// For single type scans, use full 100%
|
||||
if (status.scan_type !== 'both') {
|
||||
totalProgress *= 2;
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
progressBar.style.width = `${Math.min(totalProgress, 100)}%`;
|
||||
progressText.textContent = `${Math.round(totalProgress)}%`;
|
||||
|
||||
// Update operation text
|
||||
if (status.current_operation) {
|
||||
operationText.textContent = status.current_operation;
|
||||
} else if (status.current_item) {
|
||||
operationText.textContent = `Processing: ${status.current_item}`;
|
||||
} else {
|
||||
operationText.textContent = progressDetails || 'Scanning...';
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
if (!confirm('Are you sure you want to logout?')) {
|
||||
return;
|
||||
@@ -1435,3 +1641,288 @@ async function logout() {
|
||||
showToast('❌ Logout error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk delete functions for TV episodes
|
||||
function toggleSelectAll() {
|
||||
const selectAllCheckbox = document.getElementById('select-all-checkbox');
|
||||
const episodeCheckboxes = document.querySelectorAll('.episode-checkbox');
|
||||
|
||||
if (selectAllCheckbox && episodeCheckboxes.length > 0) {
|
||||
const shouldCheck = selectAllCheckbox.checked;
|
||||
episodeCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = shouldCheck;
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
}
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
|
||||
const selectedCount = selectedCheckboxes.length;
|
||||
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
|
||||
const selectedCountSpan = document.getElementById('selected-count');
|
||||
|
||||
if (selectedCountSpan) {
|
||||
selectedCountSpan.textContent = selectedCount;
|
||||
}
|
||||
|
||||
if (bulkDeleteButton) {
|
||||
bulkDeleteButton.disabled = selectedCount === 0;
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const selectAllCheckbox = document.getElementById('select-all-checkbox');
|
||||
const allCheckboxes = document.querySelectorAll('.episode-checkbox');
|
||||
if (selectAllCheckbox && allCheckboxes.length > 0) {
|
||||
selectAllCheckbox.checked = selectedCount === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeleteSelected() {
|
||||
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
|
||||
const selectedCount = selectedCheckboxes.length;
|
||||
|
||||
if (selectedCount === 0) {
|
||||
showToast('❌ No episodes selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${selectedCount} episode(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
|
||||
const originalText = bulkDeleteButton.innerHTML;
|
||||
bulkDeleteButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
|
||||
bulkDeleteButton.disabled = true;
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// Process deletions
|
||||
for (const checkbox of selectedCheckboxes) {
|
||||
const row = checkbox.closest('tr');
|
||||
const imdbId = row.getAttribute('data-imdb');
|
||||
const season = parseInt(row.getAttribute('data-season'));
|
||||
const episode = parseInt(row.getAttribute('data-episode'));
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Remove the row from the table
|
||||
row.remove();
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
console.error(`Failed to delete S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
console.error(`Error deleting S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI
|
||||
updateEpisodeModalCounts();
|
||||
updateBulkDeleteButton();
|
||||
|
||||
// Reset button
|
||||
bulkDeleteButton.innerHTML = originalText;
|
||||
bulkDeleteButton.disabled = true;
|
||||
|
||||
// Show results
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
showToast(`✅ Successfully deleted ${successCount} episode(s)`, 'success');
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
showToast(`⚠️ Deleted ${successCount} episode(s), ${failCount} failed`, 'warning');
|
||||
} else {
|
||||
showToast(`❌ Failed to delete ${failCount} episode(s)`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Database Population Functions
|
||||
// ---------------------------
|
||||
|
||||
async function handlePopulateDatabase(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const mediaType = document.getElementById('populate-media-type').value;
|
||||
|
||||
// Validate input
|
||||
if (!mediaType) {
|
||||
showToast('❌ Please select media type', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm with user
|
||||
const confirmMsg = `Are you sure you want to populate the database with ${mediaType}? This will query Radarr/Sonarr and may take several minutes.`;
|
||||
if (!confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show populate status
|
||||
showPopulateStatus();
|
||||
|
||||
// Start the population
|
||||
showToast('🚀 Starting database population...', 'info');
|
||||
const response = await fetch(`/admin/populate-database?media_type=${mediaType}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
showToast('✅ Population started successfully', 'success');
|
||||
// Start polling for status
|
||||
startPopulatePolling();
|
||||
} else {
|
||||
showToast(`ℹ️ ${result.message || 'Population completed'}`, 'info');
|
||||
hidePopulateStatus();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Database population failed:', error);
|
||||
showToast(`❌ Population failed: ${error.message}`, 'error');
|
||||
hidePopulateStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function showPopulateStatus() {
|
||||
const populateStatus = document.getElementById('populate-status');
|
||||
const progressBar = document.getElementById('populate-progress-bar');
|
||||
const operationText = document.getElementById('populate-current-operation');
|
||||
const progressText = document.getElementById('populate-progress-text');
|
||||
const resultsDiv = document.getElementById('populate-results');
|
||||
|
||||
populateStatus.style.display = 'block';
|
||||
progressBar.style.width = '0%';
|
||||
operationText.textContent = 'Initializing...';
|
||||
progressText.textContent = '0%';
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
|
||||
function hidePopulateStatus() {
|
||||
const populateStatus = document.getElementById('populate-status');
|
||||
if (populateStatus) {
|
||||
populateStatus.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function startPopulatePolling() {
|
||||
// Poll every 2 seconds for populate status
|
||||
window.populatePollingInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/populate/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get populate status');
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
updatePopulateProgress(status);
|
||||
|
||||
// Stop polling if population is complete
|
||||
if (!status.running && status.completed) {
|
||||
stopPopulatePolling();
|
||||
showToast('✅ Database population completed!', 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to poll populate status:', error);
|
||||
// Don't show error toast repeatedly, just stop polling
|
||||
stopPopulatePolling();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopPopulatePolling() {
|
||||
if (window.populatePollingInterval) {
|
||||
clearInterval(window.populatePollingInterval);
|
||||
window.populatePollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePopulateProgress(status) {
|
||||
const progressBar = document.getElementById('populate-progress-bar');
|
||||
const operationText = document.getElementById('populate-current-operation');
|
||||
const progressText = document.getElementById('populate-progress-text');
|
||||
const resultsDiv = document.getElementById('populate-results');
|
||||
|
||||
if (status.error) {
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.style.backgroundColor = '#e74c3c';
|
||||
operationText.textContent = 'Error occurred';
|
||||
progressText.textContent = 'Failed';
|
||||
resultsDiv.innerHTML = `<p style="color: #e74c3c;">Error: ${status.error}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.running && status.completed) {
|
||||
progressBar.style.width = '100%';
|
||||
operationText.textContent = 'Population completed';
|
||||
progressText.textContent = '100%';
|
||||
|
||||
// Display results
|
||||
let resultsHTML = '<h4>Population Results:</h4>';
|
||||
|
||||
if (status.movies && status.movies.stats) {
|
||||
const m = status.movies.stats;
|
||||
resultsHTML += `
|
||||
<div style="margin: 10px 0;">
|
||||
<strong>Movies:</strong><br>
|
||||
Total: ${m.total || 0} | Added: ${m.added || 0} | Skipped: ${m.skipped || 0} | Errors: ${m.errors || 0}<br>
|
||||
Duration: ${m.duration ? m.duration.toFixed(2) : 0}s
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (status.tv && status.tv.stats) {
|
||||
const t = status.tv.stats;
|
||||
resultsHTML += `
|
||||
<div style="margin: 10px 0;">
|
||||
<strong>TV Shows:</strong><br>
|
||||
Series: ${t.total_series || 0} | Episodes: ${t.total_episodes || 0}<br>
|
||||
Added: ${t.added || 0} | Skipped: ${t.skipped || 0} | Errors: ${t.errors || 0}<br>
|
||||
Duration: ${t.duration ? t.duration.toFixed(2) : 0}s
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = resultsHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update progress based on status
|
||||
let totalProgress = 0;
|
||||
let progressDetails = '';
|
||||
|
||||
if (status.movies && status.movies.status === 'running') {
|
||||
totalProgress = 50;
|
||||
progressDetails = 'Processing movies...';
|
||||
} else if (status.movies && status.movies.status === 'completed') {
|
||||
totalProgress = 50;
|
||||
progressDetails = 'Movies completed, processing TV...';
|
||||
}
|
||||
|
||||
if (status.tv && status.tv.status === 'running') {
|
||||
totalProgress = 75;
|
||||
progressDetails = 'Processing TV shows...';
|
||||
} else if (status.tv && status.tv.status === 'completed') {
|
||||
totalProgress = 100;
|
||||
progressDetails = 'Completed';
|
||||
}
|
||||
|
||||
progressBar.style.width = `${totalProgress}%`;
|
||||
operationText.textContent = progressDetails;
|
||||
progressText.textContent = `${totalProgress}%`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
IMDb ID Extraction Utilities
|
||||
Parses IMDb IDs from directory/file names (no file I/O)
|
||||
Phase 3: Replaces NFOManager for IMDb ID extraction only
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_imdb_from_path(path: Path) -> Optional[str]:
|
||||
"""
|
||||
Extract IMDb ID from directory path or filename using regex patterns.
|
||||
Does NOT read any files - only parses the path string.
|
||||
|
||||
Supported patterns:
|
||||
- [imdb-tt1234567]
|
||||
- [tt1234567]
|
||||
- {imdb-tt1234567}
|
||||
- (imdb-tt1234567)
|
||||
- -tt1234567 (at end)
|
||||
- _tt1234567 (at end)
|
||||
|
||||
Args:
|
||||
path: Path object to parse
|
||||
|
||||
Returns:
|
||||
IMDb ID (e.g., "tt1234567") or None if not found
|
||||
"""
|
||||
path_str = str(path).lower()
|
||||
|
||||
# Try [imdb-ttXXXXXXX] format first (most explicit)
|
||||
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try standalone [ttXXXXXXX] format in brackets
|
||||
match = re.search(r'\[(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try {imdb-ttXXXXXXX} format with curly braces
|
||||
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try (imdb-ttXXXXXXX) format with parentheses
|
||||
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try ttXXXXXXX at end of filename/dirname (common pattern)
|
||||
match = re.search(r'[-_\s](tt\d+)$', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_imdb_in_directory(directory: Path) -> Optional[str]:
|
||||
"""
|
||||
Find IMDb ID from directory name or filenames within the directory.
|
||||
Does NOT read file contents - only checks filenames.
|
||||
|
||||
Args:
|
||||
directory: Directory path to search
|
||||
|
||||
Returns:
|
||||
IMDb ID or None if not found
|
||||
"""
|
||||
# First try directory name itself
|
||||
imdb_id = parse_imdb_from_path(directory)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Try all filenames in the directory
|
||||
if directory.is_dir():
|
||||
for file_path in directory.iterdir():
|
||||
if file_path.is_file():
|
||||
imdb_id = parse_imdb_from_path(file_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
return None
|
||||
+75
-1
@@ -10,6 +10,49 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
class SafeRotatingFileHandler(logging.handlers.RotatingFileHandler):
|
||||
"""A RotatingFileHandler that handles missing backup files gracefully"""
|
||||
|
||||
def doRollover(self):
|
||||
"""
|
||||
Override doRollover to handle missing backup files gracefully
|
||||
"""
|
||||
if self.stream:
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
|
||||
if self.backupCount > 0:
|
||||
# Remove the oldest backup if it exists
|
||||
oldest_backup = f"{self.baseFilename}.{self.backupCount}"
|
||||
if os.path.exists(oldest_backup):
|
||||
try:
|
||||
os.remove(oldest_backup)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass # Ignore if file doesn't exist or can't be removed
|
||||
|
||||
# Rename existing backups, skipping missing ones
|
||||
for i in range(self.backupCount - 1, 0, -1):
|
||||
sfn = f"{self.baseFilename}.{i}"
|
||||
dfn = f"{self.baseFilename}.{i + 1}"
|
||||
if os.path.exists(sfn):
|
||||
try:
|
||||
os.rename(sfn, dfn)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass # Skip if source doesn't exist or rename fails
|
||||
|
||||
# Rename the main log file
|
||||
dfn = f"{self.baseFilename}.1"
|
||||
if os.path.exists(self.baseFilename):
|
||||
try:
|
||||
os.rename(self.baseFilename, dfn)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass # Skip if main file doesn't exist
|
||||
|
||||
# Open the new log file
|
||||
if not self.delay:
|
||||
self.stream = self._open()
|
||||
|
||||
|
||||
class TimezoneAwareFormatter(logging.Formatter):
|
||||
"""Formatter that respects the container timezone"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -50,7 +93,13 @@ def _setup_file_logging():
|
||||
logger = logging.getLogger("NFOGuard")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
# Clear any existing handlers to avoid duplicates
|
||||
logger.handlers.clear()
|
||||
|
||||
# Try to set up file logging
|
||||
file_logging_enabled = False
|
||||
try:
|
||||
file_handler = SafeRotatingFileHandler(
|
||||
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
|
||||
)
|
||||
|
||||
@@ -59,6 +108,31 @@ def _setup_file_logging():
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
file_logging_enabled = True
|
||||
except Exception as e:
|
||||
# If RotatingFileHandler fails, try regular FileHandler
|
||||
try:
|
||||
file_handler = logging.FileHandler(log_dir / "nfoguard.log")
|
||||
formatter = TimezoneAwareFormatter(
|
||||
'[%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
file_logging_enabled = True
|
||||
except Exception as e2:
|
||||
# File logging not available (e.g., read-only filesystem)
|
||||
# Fall back to console-only logging silently
|
||||
pass
|
||||
|
||||
# If file logging failed, ensure console handler is added
|
||||
if not file_logging_enabled:
|
||||
console_handler = logging.StreamHandler()
|
||||
formatter = TimezoneAwareFormatter(
|
||||
'[%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
|
||||
+43
-24
@@ -3,18 +3,21 @@ Webhook Batching System for NFOGuard
|
||||
Handles batching and processing of webhook events to avoid processing storms
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Set
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from config.settings import config
|
||||
from utils.logging import _log
|
||||
from utils.imdb_utils import find_imdb_in_directory, parse_imdb_from_path # Phase 3: Replaced NFOManager
|
||||
|
||||
|
||||
class WebhookBatcher:
|
||||
"""Batches webhook events to avoid processing storms"""
|
||||
|
||||
def __init__(self, nfo_manager=None):
|
||||
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
|
||||
self.pending: Dict[str, Dict] = {}
|
||||
self.timers: Dict[str, threading.Timer] = {}
|
||||
self.processing: Set[str] = set()
|
||||
@@ -23,8 +26,6 @@ class WebhookBatcher:
|
||||
# Will be set by the application when processors are available
|
||||
self.tv_processor = None
|
||||
self.movie_processor = None
|
||||
# NFO manager for comprehensive IMDb detection
|
||||
self.nfo_manager = nfo_manager
|
||||
|
||||
def set_processors(self, tv_processor, movie_processor):
|
||||
"""Set the processor instances"""
|
||||
@@ -84,9 +85,8 @@ class WebhookBatcher:
|
||||
if media_type == 'movie':
|
||||
expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key
|
||||
|
||||
# Use comprehensive IMDb detection (directory, filenames, NFO files)
|
||||
if self.nfo_manager:
|
||||
detected_imdb = self.nfo_manager.find_movie_imdb_id(path_obj)
|
||||
# Use imdb_utils for IMDb detection (Phase 3: no NFO reading)
|
||||
detected_imdb = find_imdb_in_directory(path_obj)
|
||||
imdb_match = False
|
||||
if detected_imdb:
|
||||
# Compare with and without 'tt' prefix for flexibility
|
||||
@@ -94,26 +94,18 @@ class WebhookBatcher:
|
||||
imdb_match = True
|
||||
|
||||
if not imdb_match:
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in path {path_str}")
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} but found {detected_imdb} in {path_str}")
|
||||
_log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}")
|
||||
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
|
||||
return
|
||||
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
|
||||
else:
|
||||
# Fallback to simple string search if nfo_manager not available
|
||||
if expected_imdb not in path_str.lower():
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str} (fallback mode)")
|
||||
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
|
||||
return
|
||||
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
|
||||
|
||||
# CRITICAL: Validate that the path contains the expected IMDb ID for TV shows
|
||||
if media_type == 'tv':
|
||||
expected_imdb = key.replace('tv:', '') if key.startswith('tv:') else key
|
||||
|
||||
# Use comprehensive IMDb detection (directory, filenames, tvshow.nfo files)
|
||||
if self.nfo_manager:
|
||||
detected_imdb = self.nfo_manager.find_series_imdb_id(path_obj)
|
||||
# Use imdb_utils for IMDb detection (Phase 3: no NFO reading)
|
||||
detected_imdb = parse_imdb_from_path(path_obj)
|
||||
imdb_match = False
|
||||
if detected_imdb:
|
||||
# Compare with and without 'tt' prefix for flexibility
|
||||
@@ -121,18 +113,11 @@ class WebhookBatcher:
|
||||
imdb_match = True
|
||||
|
||||
if not imdb_match:
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in TV path {path_str}")
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} but found {detected_imdb} in TV {path_str}")
|
||||
_log("ERROR", f"Detected TV IMDb: {detected_imdb}, Expected: {expected_imdb}")
|
||||
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
|
||||
return
|
||||
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
|
||||
else:
|
||||
# Fallback to simple string search if nfo_manager not available
|
||||
if expected_imdb not in path_str.lower():
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in TV path {path_str} (fallback mode)")
|
||||
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
|
||||
return
|
||||
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
|
||||
|
||||
if not self.tv_processor:
|
||||
_log("ERROR", "TV processor not available")
|
||||
@@ -144,6 +129,12 @@ class WebhookBatcher:
|
||||
|
||||
if processing_mode == 'targeted' and episodes_data:
|
||||
_log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes")
|
||||
|
||||
# Handle sequential processing for bulk downloads
|
||||
if len(episodes_data) > 1 and config.sequential_delay > 0:
|
||||
_log("INFO", f"Processing {len(episodes_data)} episodes sequentially with {config.sequential_delay}s delay")
|
||||
self._process_episodes_sequentially(path_obj, episodes_data)
|
||||
else:
|
||||
self.tv_processor.process_webhook_episodes(path_obj, episodes_data)
|
||||
else:
|
||||
_log("INFO", f"Using series processing mode (fallback or configured)")
|
||||
@@ -164,6 +155,29 @@ class WebhookBatcher:
|
||||
with self.lock:
|
||||
self.processing.discard(key)
|
||||
|
||||
def _process_episodes_sequentially(self, path_obj: Path, episodes_data: list):
|
||||
"""Process episodes one by one with delays to avoid API spam"""
|
||||
total_episodes = len(episodes_data)
|
||||
for i, episode in enumerate(episodes_data, 1):
|
||||
try:
|
||||
season = episode.get('seasonNumber', '?')
|
||||
episode_num = episode.get('episodeNumber', '?')
|
||||
_log("INFO", f"Processing episode {i}/{total_episodes}: S{season:02d}E{episode_num:02d}")
|
||||
|
||||
# Process single episode
|
||||
self.tv_processor.process_webhook_episodes(path_obj, [episode])
|
||||
|
||||
# Add delay between episodes (except for the last one)
|
||||
if i < total_episodes and config.sequential_delay > 0:
|
||||
_log("INFO", f"Waiting {config.sequential_delay}s before next episode...")
|
||||
time.sleep(config.sequential_delay)
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing episode {i}/{total_episodes}: {e}")
|
||||
# Continue with next episode even if one fails
|
||||
|
||||
_log("INFO", f"Completed sequential processing of {total_episodes} episodes")
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""Get batch queue status"""
|
||||
with self.lock:
|
||||
@@ -196,7 +210,12 @@ class WebhookBatcher:
|
||||
|
||||
# Shutdown the thread pool executor
|
||||
try:
|
||||
# Use timeout parameter only if supported (Python 3.9+)
|
||||
import sys
|
||||
if sys.version_info >= (3, 9):
|
||||
self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds
|
||||
else:
|
||||
self.executor.shutdown(wait=True) # No timeout for older Python versions
|
||||
_log("INFO", "Thread pool executor shut down successfully")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Error shutting down thread pool: {e}")
|
||||
|
||||
Reference in New Issue
Block a user