Compare commits
84 Commits
017681cd55
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e883d896b9 | |||
| 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 |
@@ -138,6 +138,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
|
||||
|
||||
|
||||
@@ -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
|
||||
+75
-1
@@ -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
|
||||
@@ -107,4 +119,66 @@ class EpisodeResponse(BaseModel):
|
||||
last_updated: str
|
||||
series_path: str
|
||||
season_name: str
|
||||
episode_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]
|
||||
+1526
-33
File diff suppressed because it is too large
Load Diff
+864
-51
@@ -93,11 +93,11 @@ async def get_movies_list(dependencies: dict,
|
||||
params.append(source_filter)
|
||||
|
||||
if search:
|
||||
where_conditions.append("(imdb_id LIKE %s OR path LIKE %s)")
|
||||
where_conditions.append("(imdb_id ILIKE %s OR path ILIKE %s)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
if imdb_search:
|
||||
where_conditions.append("imdb_id LIKE %s")
|
||||
where_conditions.append("imdb_id ILIKE %s")
|
||||
params.append(f"%{imdb_search}%")
|
||||
|
||||
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||
@@ -162,11 +162,11 @@ async def get_tv_series_list(dependencies: dict,
|
||||
having_conditions = []
|
||||
|
||||
if search:
|
||||
where_conditions.append("(s.imdb_id LIKE %s OR s.path LIKE %s)")
|
||||
where_conditions.append("(s.imdb_id ILIKE %s OR s.path ILIKE %s)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
if imdb_search:
|
||||
where_conditions.append("s.imdb_id LIKE %s")
|
||||
where_conditions.append("s.imdb_id ILIKE %s")
|
||||
params.append(f"%{imdb_search}%")
|
||||
|
||||
if source_filter:
|
||||
@@ -581,11 +581,25 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi
|
||||
"""Update dateadded for a specific episode"""
|
||||
db = dependencies["db"]
|
||||
|
||||
print(f"🔍 DEBUG: update_episode_date called with dateadded={repr(dateadded)}, source={repr(source)}")
|
||||
|
||||
# Get existing episode
|
||||
episode_data = db.get_episode_date(imdb_id, season, episode)
|
||||
if not episode_data:
|
||||
raise HTTPException(status_code=404, detail="Episode not found")
|
||||
|
||||
# Handle special sources
|
||||
if source == "airdate" and episode_data.get('aired'):
|
||||
# Use aired date as dateadded for airdate source
|
||||
aired_date = episode_data.get('aired')
|
||||
if hasattr(aired_date, 'isoformat'):
|
||||
dateadded = aired_date.isoformat() + "T20:00:00" # Set to 8 PM on air date
|
||||
else:
|
||||
dateadded = str(aired_date) + "T20:00:00"
|
||||
print(f"🔍 DEBUG: Using air date as dateadded: {dateadded}")
|
||||
|
||||
print(f"🔍 DEBUG: Final dateadded value: {repr(dateadded)}")
|
||||
|
||||
# Update the date
|
||||
db.upsert_episode_date(
|
||||
imdb_id=imdb_id,
|
||||
@@ -597,42 +611,46 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi
|
||||
has_video_file=episode_data.get('has_video_file', False)
|
||||
)
|
||||
|
||||
# Create/update NFO file with new data
|
||||
nfo_manager = dependencies["nfo_manager"]
|
||||
config = dependencies["config"]
|
||||
|
||||
if config.manage_nfo:
|
||||
try:
|
||||
# Find the series directory based on IMDb ID
|
||||
series_path = None
|
||||
for tv_path in config.tv_paths:
|
||||
for series_dir in Path(tv_path).iterdir():
|
||||
if series_dir.is_dir() and imdb_id.lower() in series_dir.name.lower():
|
||||
series_path = series_dir
|
||||
break
|
||||
if series_path:
|
||||
break
|
||||
|
||||
if series_path:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
if season_dir.exists():
|
||||
nfo_manager.create_episode_nfo(
|
||||
season_dir=season_dir,
|
||||
season_num=season,
|
||||
episode_num=episode,
|
||||
aired=episode_data.get('aired'),
|
||||
dateadded=dateadded,
|
||||
source=source,
|
||||
lock_metadata=config.lock_metadata
|
||||
)
|
||||
print(f"✅ Updated NFO file for {imdb_id} S{season:02d}E{episode:02d}")
|
||||
else:
|
||||
print(f"⚠️ Season directory not found: {season_dir}")
|
||||
# Trigger NFO file update via core container
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import json
|
||||
import os
|
||||
|
||||
# Get core container connection details
|
||||
core_host = os.environ.get("CORE_API_HOST", "nfoguard")
|
||||
core_port = os.environ.get("CORE_API_PORT", "8080")
|
||||
|
||||
# Call core container to update NFO file
|
||||
nfo_update_url = f"http://{core_host}:{core_port}/api/episodes/{imdb_id}/{season}/{episode}/update-nfo"
|
||||
|
||||
# Create request data
|
||||
request_data = {
|
||||
"dateadded": dateadded,
|
||||
"source": source,
|
||||
"aired": episode_data.get('aired').isoformat() if episode_data.get('aired') else None
|
||||
}
|
||||
|
||||
# Make POST request to core container
|
||||
req = urllib.request.Request(
|
||||
nfo_update_url,
|
||||
data=json.dumps(request_data).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST'
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
if response.status == 200:
|
||||
print(f"✅ Database and NFO file updated for {imdb_id} S{season:02d}E{episode:02d}")
|
||||
else:
|
||||
print(f"⚠️ Series directory not found for {imdb_id}")
|
||||
print(f"✅ Database updated for {imdb_id} S{season:02d}E{episode:02d}")
|
||||
print(f"⚠️ NFO file update failed (HTTP {response.status})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error updating NFO file for {imdb_id} S{season:02d}E{episode:02d}: {e}")
|
||||
except Exception as e:
|
||||
print(f"✅ Database updated for {imdb_id} S{season:02d}E{episode:02d}")
|
||||
print(f"⚠️ NFO file update failed: {e}")
|
||||
print(f"ℹ️ NFO will be updated on next scan")
|
||||
|
||||
# Add to processing history
|
||||
db.add_processing_history(
|
||||
@@ -1237,15 +1255,40 @@ def register_web_routes(app, dependencies):
|
||||
|
||||
@app.post("/api/movies/{imdb_id}/update-date")
|
||||
async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"):
|
||||
return {"error": "Updates not available in web container. Use core container on port 8085."}
|
||||
return await update_movie_date(dependencies, imdb_id, dateadded, source)
|
||||
|
||||
@app.put("/api/movies/{imdb_id}")
|
||||
async def api_update_movie(imdb_id: str, dateadded: str = None, source: str = "manual"):
|
||||
return {"error": "Updates not available in web container. Use core container on port 8085."}
|
||||
return await update_movie_date(dependencies, imdb_id, dateadded, source)
|
||||
|
||||
@app.get("/api/movies/{imdb_id}/date-options")
|
||||
async def api_movie_date_options(imdb_id: str):
|
||||
return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."}
|
||||
return await get_movie_date_options(dependencies, imdb_id)
|
||||
|
||||
@app.delete("/api/movies/{imdb_id}")
|
||||
async def api_delete_movie(imdb_id: str):
|
||||
"""Delete a movie from the database"""
|
||||
db = dependencies["db"]
|
||||
|
||||
try:
|
||||
# Use the existing database method
|
||||
deleted = db.delete_movie(imdb_id)
|
||||
|
||||
if deleted:
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"message": f"Deleted movie {imdb_id}",
|
||||
"imdb_id": imdb_id
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions
|
||||
except Exception as e:
|
||||
print(f"❌ Error deleting movie: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete movie: {str(e)}")
|
||||
|
||||
# TV series endpoints
|
||||
@app.get("/api/series")
|
||||
@@ -1269,16 +1312,50 @@ def register_web_routes(app, dependencies):
|
||||
@app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date")
|
||||
async def api_update_episode_date(imdb_id: str, season: int, episode: int,
|
||||
dateadded: str = None, source: str = "manual"):
|
||||
return {"error": "Updates not available in web container. Use core container on port 8085."}
|
||||
return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source)
|
||||
|
||||
@app.put("/api/episodes/{imdb_id}/{season}/{episode}")
|
||||
async def api_update_episode(imdb_id: str, season: int, episode: int,
|
||||
dateadded: str = None, source: str = "manual"):
|
||||
return {"error": "Updates not available in web container. Use core container on port 8085."}
|
||||
async def api_update_episode(imdb_id: str, season: int, episode: int, request: Request):
|
||||
try:
|
||||
# Parse JSON body
|
||||
data = await request.json()
|
||||
dateadded = data.get('dateadded')
|
||||
source = data.get('source', 'manual')
|
||||
return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source)
|
||||
except Exception as e:
|
||||
print(f"❌ Error parsing episode update request: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Invalid request format: {str(e)}")
|
||||
|
||||
@app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options")
|
||||
async def api_episode_date_options(imdb_id: str, season: int, episode: int):
|
||||
return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."}
|
||||
return await get_episode_date_options(dependencies, imdb_id, season, episode)
|
||||
|
||||
@app.delete("/api/episodes/{imdb_id}/{season}/{episode}")
|
||||
async def api_delete_episode(imdb_id: str, season: int, episode: int):
|
||||
"""Delete an episode from the database"""
|
||||
db = dependencies["db"]
|
||||
|
||||
try:
|
||||
# Use the existing database method
|
||||
deleted = db.delete_episode(imdb_id, season, episode)
|
||||
|
||||
if deleted:
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"message": f"Deleted episode {imdb_id} S{season:02d}E{episode:02d}",
|
||||
"imdb_id": imdb_id,
|
||||
"season": season,
|
||||
"episode": episode
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Episode not found")
|
||||
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions
|
||||
except Exception as e:
|
||||
print(f"❌ Error deleting episode: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete episode: {str(e)}")
|
||||
|
||||
# Bulk operations
|
||||
@app.post("/api/bulk/update-source")
|
||||
@@ -1335,7 +1412,7 @@ def register_web_routes(app, dependencies):
|
||||
import socket
|
||||
|
||||
# Get core container URL
|
||||
core_host = os.environ.get("CORE_API_HOST", "nfoguard-core")
|
||||
core_host = os.environ.get("CORE_API_HOST", "nfoguard")
|
||||
core_port = os.environ.get("CORE_API_PORT", "8080")
|
||||
|
||||
# Forward query parameters from the request
|
||||
@@ -1348,8 +1425,8 @@ def register_web_routes(app, dependencies):
|
||||
# Create request with timeout (no body needed for query parameters)
|
||||
req = urllib.request.Request(core_url, method='POST')
|
||||
|
||||
# Make request with timeout
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
# Make request with timeout (increased for manual scans which can take several minutes)
|
||||
with urllib.request.urlopen(req, timeout=300) as response:
|
||||
response_data = response.read().decode('utf-8')
|
||||
return json.loads(response_data)
|
||||
|
||||
@@ -1383,7 +1460,7 @@ def register_web_routes(app, dependencies):
|
||||
import socket
|
||||
|
||||
# Get core container connection details
|
||||
core_host = os.environ.get("CORE_API_HOST", "nfoguard-core")
|
||||
core_host = os.environ.get("CORE_API_HOST", "nfoguard")
|
||||
core_port = os.environ.get("CORE_API_PORT", "8080")
|
||||
|
||||
try:
|
||||
@@ -1406,4 +1483,740 @@ def register_web_routes(app, dependencies):
|
||||
except json.JSONDecodeError:
|
||||
return {"scanning": False, "message": "Invalid response from core container"}
|
||||
except Exception as e:
|
||||
return {"scanning": False, "message": f"Unable to check scan status: {str(e)}"}
|
||||
return {"scanning": False, "message": f"Unable to check scan status: {str(e)}"}
|
||||
|
||||
# Register database admin routes
|
||||
register_database_admin_routes(app, dependencies)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE ADMIN INTERFACE
|
||||
# =============================================================================
|
||||
|
||||
async def execute_database_query(dependencies: dict, query: str, limit: int = 100):
|
||||
"""Execute a database query and return results"""
|
||||
db = dependencies["db"]
|
||||
|
||||
try:
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Add LIMIT if not already present in query
|
||||
if "LIMIT" not in query.upper() and not query.strip().endswith(";"):
|
||||
query += f" LIMIT {limit}"
|
||||
elif query.strip().endswith(";"):
|
||||
query = query.strip()[:-1] + f" LIMIT {limit};"
|
||||
|
||||
cursor.execute(query)
|
||||
|
||||
if query.strip().upper().startswith("SELECT"):
|
||||
results = cursor.fetchall()
|
||||
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"columns": columns,
|
||||
"rows": [dict(zip(columns, row)) for row in results],
|
||||
"row_count": len(results)
|
||||
}
|
||||
else:
|
||||
conn.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"message": f"Query executed successfully. Rows affected: {cursor.rowcount}",
|
||||
"rows_affected": cursor.rowcount
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"query": query,
|
||||
"error": str(e),
|
||||
"message": f"Database query failed: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
async def get_episodes_missing_nfo_dateadded(dependencies: dict):
|
||||
"""Find episodes and movies missing dateadded elements in NFO files via core container"""
|
||||
config = dependencies["config"]
|
||||
|
||||
try:
|
||||
print("🔧 Calling core container for NFO repair scan...")
|
||||
|
||||
# Call the core container's NFO repair scan endpoint
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
core_url = f"http://nfoguard:{config.core_api_port}/admin/nfo-repair-scan"
|
||||
print(f"🔍 DEBUG: Calling core container at: {core_url}")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=300.0) # 5 minute timeout for filesystem scan
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(core_url) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
print(f"✅ Core container scan complete: {result['total_missing']} items missing dateadded")
|
||||
|
||||
# Transform the data to match the expected legacy format
|
||||
episodes_missing = result['missing_items']['episodes']
|
||||
movies_missing = result['missing_items']['movies']
|
||||
|
||||
# Combine for legacy items format
|
||||
all_missing = []
|
||||
for episode in episodes_missing:
|
||||
all_missing.append({
|
||||
"imdb_id": episode["imdb_id"],
|
||||
"season": episode["season"],
|
||||
"episode": episode["episode"],
|
||||
"series_name": episode["series_name"],
|
||||
"series_path": episode.get("series_path"),
|
||||
"dateadded": episode["dateadded"],
|
||||
"nfo_path": episode["nfo_path"],
|
||||
"reason": episode.get("reason", "NFO missing dateadded element"),
|
||||
"media_type": "episode",
|
||||
"nfo_exists": True,
|
||||
"dateadded_in_nfo": False,
|
||||
"should_have_date": True
|
||||
})
|
||||
|
||||
for movie in movies_missing:
|
||||
all_missing.append({
|
||||
"imdb_id": movie["imdb_id"],
|
||||
"title": movie["title"],
|
||||
"dateadded": movie["dateadded"],
|
||||
"nfo_path": movie["nfo_path"],
|
||||
"media_type": "movie",
|
||||
"nfo_exists": True,
|
||||
"dateadded_in_nfo": False,
|
||||
"should_have_date": True
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_episodes_checked": result.get('total_tv_files_checked', 0),
|
||||
"total_movies_checked": result.get('total_movie_files_checked', 0),
|
||||
"total_items_checked": result.get('total_tv_files_checked', 0) + result.get('total_movie_files_checked', 0),
|
||||
"missing_dateadded_count": result['total_missing'], # Items that can be fixed
|
||||
"total_nfo_files_missing": result.get('total_nfo_files_missing', result['total_missing']), # All NFO files missing dateadded
|
||||
"tv_nfo_files_missing": result.get('tv_nfo_files_missing', 0),
|
||||
"movie_nfo_files_missing": result.get('movie_nfo_files_missing', 0),
|
||||
"items": all_missing[:50], # Limit display to first 50
|
||||
"debug_info": {
|
||||
"scan_method": "core_container_filesystem",
|
||||
"core_container_response": "success",
|
||||
"episodes_missing": result['episodes_missing'],
|
||||
"movies_missing": result['movies_missing'],
|
||||
"core_response_keys": list(result.keys()),
|
||||
"statistics": result.get('statistics', {})
|
||||
}
|
||||
}
|
||||
else:
|
||||
error_text = await response.text()
|
||||
print(f"❌ Core container scan failed: {response.status} - {error_text}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Core container scan failed: {response.status}",
|
||||
"message": f"Failed to check NFO files via core container: {response.status}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error calling core container for NFO repair scan: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Core container communication failed: {str(e)}",
|
||||
"message": f"Failed to check NFO files: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
async def fix_nfo_missing_dateadded(dependencies: dict):
|
||||
"""Fix NFO files missing dateadded elements via core container"""
|
||||
config = dependencies["config"]
|
||||
|
||||
try:
|
||||
print("🔧 Calling core container for NFO repair fix...")
|
||||
|
||||
# Call the core container's NFO repair fix endpoint
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
core_url = f"http://nfoguard:{config.core_api_port}/admin/nfo-repair-fix"
|
||||
print(f"🔍 DEBUG: Calling core container at: {core_url}")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=600.0) # 10 minute timeout for fix operation
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(core_url) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
print(f"✅ Core container fix complete: {result.get('fixed_count', 0)} fixed, {result.get('failed_count', 0)} failed")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"fixed_count": result.get("fixed_count", 0),
|
||||
"failed_count": result.get("failed_count", 0),
|
||||
"total_processed": result.get("total_processed", 0),
|
||||
"results": result.get("results", []),
|
||||
"message": f"Successfully fixed {result.get('fixed_count', 0)} NFO files"
|
||||
}
|
||||
else:
|
||||
error_text = await response.text()
|
||||
print(f"❌ Core container fix failed: {response.status} - {error_text}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Core container fix failed: {response.status}",
|
||||
"message": f"Failed to fix NFO files: {error_text}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error calling core container for NFO repair fix: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Core container communication failed: {str(e)}",
|
||||
"message": f"Failed to fix NFO files: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
async def get_missing_imdb_items(dependencies: dict):
|
||||
"""Get items missing IMDb IDs for manual review via core container"""
|
||||
config = dependencies["config"]
|
||||
|
||||
try:
|
||||
print("📋 Calling core container for missing IMDb items...")
|
||||
|
||||
# Call the core container's missing IMDb endpoint
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
core_url = f"http://nfoguard:{config.core_api_port}/admin/missing-imdb"
|
||||
print(f"🔍 DEBUG: Calling core container at: {core_url}")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=60.0) # 1 minute timeout
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(core_url) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
print(f"✅ Core container retrieved missing IMDb items: {result['summary']['total_missing']} items")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_missing": result['summary']['total_missing'],
|
||||
"tv_series_missing": result['summary']['tv_series'],
|
||||
"movies_missing": result['summary']['movies'],
|
||||
"items": result['missing_items'],
|
||||
"debug_info": {
|
||||
"scan_method": "core_container_database",
|
||||
"core_container_response": "success"
|
||||
}
|
||||
}
|
||||
else:
|
||||
error_text = await response.text()
|
||||
print(f"❌ Core container missing IMDb retrieval failed: {response.status} - {error_text}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Core container retrieval failed: {response.status}",
|
||||
"message": f"Failed to retrieve missing IMDb items via core container: {response.status}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error calling core container for missing IMDb items: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Core container communication failed: {str(e)}",
|
||||
"message": f"Failed to retrieve missing IMDb items: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
async def bulk_update_nfo_files(dependencies: dict, imdb_ids: list = None, fix_all: bool = False):
|
||||
"""Update NFO files with missing dateadded elements from database"""
|
||||
db = dependencies["db"]
|
||||
config = dependencies["config"]
|
||||
nfo_manager = dependencies["nfo_manager"]
|
||||
|
||||
if not config.manage_nfo:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "NFO management is disabled in configuration"
|
||||
}
|
||||
|
||||
try:
|
||||
# Build query based on parameters
|
||||
if fix_all:
|
||||
query = """
|
||||
SELECT e.imdb_id, e.season, e.episode, e.aired, e.dateadded, e.source,
|
||||
s.path as series_path
|
||||
FROM episodes e
|
||||
JOIN series s ON e.imdb_id = s.imdb_id
|
||||
WHERE e.dateadded IS NOT NULL
|
||||
AND e.has_video_file = TRUE
|
||||
ORDER BY e.imdb_id, e.season, e.episode
|
||||
"""
|
||||
params = []
|
||||
elif imdb_ids:
|
||||
placeholders = ','.join(['%s'] * len(imdb_ids))
|
||||
query = f"""
|
||||
SELECT e.imdb_id, e.season, e.episode, e.aired, e.dateadded, e.source,
|
||||
s.path as series_path
|
||||
FROM episodes e
|
||||
JOIN series s ON e.imdb_id = s.imdb_id
|
||||
WHERE e.imdb_id IN ({placeholders})
|
||||
AND e.dateadded IS NOT NULL
|
||||
AND e.has_video_file = TRUE
|
||||
ORDER BY e.imdb_id, e.season, e.episode
|
||||
"""
|
||||
params = imdb_ids
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No IMDb IDs specified and fix_all not enabled"
|
||||
}
|
||||
|
||||
# Get episodes to update
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query, params)
|
||||
episodes = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
if not episodes:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "No episodes found to update",
|
||||
"updated_count": 0
|
||||
}
|
||||
|
||||
# Update NFO files
|
||||
updated_count = 0
|
||||
errors = []
|
||||
|
||||
for ep in episodes:
|
||||
try:
|
||||
series_path = Path(ep['series_path'])
|
||||
season_dir = series_path / config.get_season_dir_name(ep['season'])
|
||||
|
||||
if not season_dir.exists():
|
||||
continue
|
||||
|
||||
# Update NFO file
|
||||
nfo_manager.create_episode_nfo(
|
||||
season_dir=season_dir,
|
||||
season_num=ep['season'],
|
||||
episode_num=ep['episode'],
|
||||
aired=ep['aired'],
|
||||
dateadded=ep['dateadded'],
|
||||
source=ep['source'],
|
||||
lock_metadata=config.lock_metadata
|
||||
)
|
||||
|
||||
updated_count += 1
|
||||
print(f"✅ Updated NFO: {ep['imdb_id']} S{ep['season']:02d}E{ep['episode']:02d}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to update {ep['imdb_id']} S{ep['season']:02d}E{ep['episode']:02d}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
print(f"❌ {error_msg}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Updated {updated_count} NFO files",
|
||||
"updated_count": updated_count,
|
||||
"total_episodes": len(episodes),
|
||||
"errors": errors[:10] # Limit errors to first 10
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"Bulk NFO update failed: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
# Add database admin routes to the web interface
|
||||
def register_database_admin_routes(app, dependencies):
|
||||
"""Register database admin routes"""
|
||||
|
||||
@app.post("/api/admin/database/query")
|
||||
async def api_database_query(query: str, limit: int = 100):
|
||||
"""Execute a database query"""
|
||||
return await execute_database_query(dependencies, query, limit)
|
||||
|
||||
@app.get("/api/admin/nfo/missing-dateadded")
|
||||
async def api_episodes_missing_nfo_dateadded():
|
||||
"""Get episodes missing dateadded in NFO files"""
|
||||
return await get_episodes_missing_nfo_dateadded(dependencies)
|
||||
|
||||
@app.get("/api/admin/missing-imdb")
|
||||
async def api_missing_imdb_items():
|
||||
"""Get items missing IMDb IDs for manual review"""
|
||||
return await get_missing_imdb_items(dependencies)
|
||||
|
||||
@app.post("/api/admin/nfo/bulk-update")
|
||||
async def api_bulk_update_nfo_files(imdb_ids: list = None, fix_all: bool = False):
|
||||
"""Bulk update NFO files with missing dateadded via core container"""
|
||||
# For now, we only support fix_all mode using the new core container approach
|
||||
if fix_all or not imdb_ids:
|
||||
return await fix_nfo_missing_dateadded(dependencies)
|
||||
else:
|
||||
# Legacy mode for specific IMDb IDs - fallback to old method
|
||||
return await bulk_update_nfo_files(dependencies, imdb_ids, fix_all)
|
||||
|
||||
@app.get("/api/admin/database/quick-queries")
|
||||
async def api_database_quick_queries():
|
||||
"""Get predefined quick queries for common tasks"""
|
||||
return {
|
||||
"success": True,
|
||||
"queries": [
|
||||
{
|
||||
"name": "Episodes missing dateadded in NFO",
|
||||
"description": "Find episodes with dateadded in DB but missing from NFO files",
|
||||
"query": "SELECT e.imdb_id, e.season, e.episode, e.dateadded, e.source, s.path FROM episodes e JOIN series s ON e.imdb_id = s.imdb_id WHERE e.dateadded IS NOT NULL AND e.has_video_file = TRUE ORDER BY e.last_updated DESC"
|
||||
},
|
||||
{
|
||||
"name": "Recent webhook episodes",
|
||||
"description": "Episodes processed in last 24 hours",
|
||||
"query": "SELECT * FROM episodes WHERE last_updated > NOW() - INTERVAL '1 day' ORDER BY last_updated DESC"
|
||||
},
|
||||
{
|
||||
"name": "Episodes by source type",
|
||||
"description": "Count episodes by their date source",
|
||||
"query": "SELECT source, COUNT(*) as count FROM episodes GROUP BY source ORDER BY count DESC"
|
||||
},
|
||||
{
|
||||
"name": "Series with most episodes",
|
||||
"description": "Series sorted by episode count",
|
||||
"query": "SELECT s.imdb_id, s.path, COUNT(e.episode) as episode_count FROM series s LEFT JOIN episodes e ON s.imdb_id = e.imdb_id GROUP BY s.imdb_id, s.path ORDER BY episode_count DESC"
|
||||
},
|
||||
{
|
||||
"name": "Episodes without video files",
|
||||
"description": "Episodes in database without video files",
|
||||
"query": "SELECT imdb_id, season, episode, dateadded, source FROM episodes WHERE has_video_file = FALSE ORDER BY imdb_id, season, episode"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Scheduled Scans Endpoints
|
||||
|
||||
@app.get("/api/admin/scheduled-scans")
|
||||
async def api_get_scheduled_scans():
|
||||
"""Get all scheduled scans"""
|
||||
return await get_scheduled_scans(dependencies)
|
||||
|
||||
@app.post("/api/admin/scheduled-scans")
|
||||
async def api_create_scheduled_scan(request: CreateScheduledScanRequest):
|
||||
"""Create a new scheduled scan"""
|
||||
return await create_scheduled_scan(dependencies, request)
|
||||
|
||||
@app.put("/api/admin/scheduled-scans/{scan_id}")
|
||||
async def api_update_scheduled_scan(scan_id: int, request: UpdateScheduledScanRequest):
|
||||
"""Update a scheduled scan"""
|
||||
return await update_scheduled_scan(dependencies, scan_id, request)
|
||||
|
||||
@app.delete("/api/admin/scheduled-scans/{scan_id}")
|
||||
async def api_delete_scheduled_scan(scan_id: int):
|
||||
"""Delete a scheduled scan"""
|
||||
return await delete_scheduled_scan(dependencies, scan_id)
|
||||
|
||||
@app.post("/api/admin/scheduled-scans/{scan_id}/toggle")
|
||||
async def api_toggle_scheduled_scan(scan_id: int):
|
||||
"""Toggle a scheduled scan enabled/disabled"""
|
||||
return await toggle_scheduled_scan(dependencies, scan_id)
|
||||
|
||||
@app.post("/api/admin/scheduled-scans/{scan_id}/run")
|
||||
async def api_run_scheduled_scan(scan_id: int):
|
||||
"""Manually run a scheduled scan"""
|
||||
return await run_scheduled_scan(dependencies, scan_id)
|
||||
|
||||
@app.get("/api/admin/scheduled-scans/executions")
|
||||
async def api_get_schedule_executions(schedule_id: int = None):
|
||||
"""Get schedule execution history"""
|
||||
return await get_schedule_executions(dependencies, schedule_id)
|
||||
|
||||
|
||||
# Scheduled Scans Functions
|
||||
|
||||
async def get_scheduled_scans(dependencies: dict):
|
||||
"""Get all scheduled scans"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
scans = db.get_scheduled_scans()
|
||||
|
||||
# Convert to response format
|
||||
scan_list = []
|
||||
for scan in scans:
|
||||
scan_dict = dict(scan)
|
||||
# Convert datetime objects to strings
|
||||
for field in ['created_at', 'updated_at', 'last_run_at', 'next_run_at']:
|
||||
if scan_dict.get(field):
|
||||
scan_dict[field] = scan_dict[field].isoformat()
|
||||
scan_list.append(scan_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"scans": scan_list
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def create_scheduled_scan(dependencies: dict, request: CreateScheduledScanRequest):
|
||||
"""Create a new scheduled scan"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Validate cron expression
|
||||
from croniter import croniter
|
||||
if not croniter.is_valid(request.cron_expression):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid cron expression"
|
||||
}
|
||||
|
||||
# Validate media type and scan mode
|
||||
if request.media_type not in ['tv', 'movies', 'both']:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid media type. Must be 'tv', 'movies', or 'both'"
|
||||
}
|
||||
|
||||
if request.scan_mode not in ['smart', 'full', 'incomplete']:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid scan mode. Must be 'smart', 'full', or 'incomplete'"
|
||||
}
|
||||
|
||||
# Create the scheduled scan
|
||||
scan_id = db.create_scheduled_scan(
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
cron_expression=request.cron_expression,
|
||||
media_type=request.media_type,
|
||||
scan_mode=request.scan_mode,
|
||||
specific_paths=request.specific_paths,
|
||||
enabled=request.enabled,
|
||||
created_by="web_interface"
|
||||
)
|
||||
|
||||
# Calculate next run time
|
||||
from croniter import croniter
|
||||
from datetime import datetime, timezone
|
||||
cron = croniter(request.cron_expression, datetime.now(timezone.utc))
|
||||
next_run = cron.get_next(datetime)
|
||||
db.update_scan_next_run(scan_id, next_run)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"scan_id": scan_id,
|
||||
"message": f"Scheduled scan '{request.name}' created successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def update_scheduled_scan(dependencies: dict, scan_id: int, request: UpdateScheduledScanRequest):
|
||||
"""Update a scheduled scan"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if scan exists
|
||||
existing_scan = db.get_scheduled_scan(scan_id)
|
||||
if not existing_scan:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Scheduled scan not found"
|
||||
}
|
||||
|
||||
# Validate cron expression if provided
|
||||
if request.cron_expression:
|
||||
from croniter import croniter
|
||||
if not croniter.is_valid(request.cron_expression):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid cron expression"
|
||||
}
|
||||
|
||||
# Validate media type and scan mode if provided
|
||||
if request.media_type and request.media_type not in ['tv', 'movies', 'both']:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid media type. Must be 'tv', 'movies', or 'both'"
|
||||
}
|
||||
|
||||
if request.scan_mode and request.scan_mode not in ['smart', 'full', 'incomplete']:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid scan mode. Must be 'smart', 'full', or 'incomplete'"
|
||||
}
|
||||
|
||||
# Update the scheduled scan
|
||||
success = db.update_scheduled_scan(
|
||||
scan_id=scan_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
cron_expression=request.cron_expression,
|
||||
media_type=request.media_type,
|
||||
scan_mode=request.scan_mode,
|
||||
specific_paths=request.specific_paths,
|
||||
enabled=request.enabled,
|
||||
updated_by="web_interface"
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to update scheduled scan"
|
||||
}
|
||||
|
||||
# Update next run time if cron expression changed
|
||||
if request.cron_expression:
|
||||
from croniter import croniter
|
||||
from datetime import datetime, timezone
|
||||
cron = croniter(request.cron_expression, datetime.now(timezone.utc))
|
||||
next_run = cron.get_next(datetime)
|
||||
db.update_scan_next_run(scan_id, next_run)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Scheduled scan updated successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def delete_scheduled_scan(dependencies: dict, scan_id: int):
|
||||
"""Delete a scheduled scan"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if scan exists
|
||||
existing_scan = db.get_scheduled_scan(scan_id)
|
||||
if not existing_scan:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Scheduled scan not found"
|
||||
}
|
||||
|
||||
# Delete the scheduled scan
|
||||
success = db.delete_scheduled_scan(scan_id)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to delete scheduled scan"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Scheduled scan '{existing_scan['name']}' deleted successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def toggle_scheduled_scan(dependencies: dict, scan_id: int):
|
||||
"""Toggle a scheduled scan enabled/disabled"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if scan exists
|
||||
existing_scan = db.get_scheduled_scan(scan_id)
|
||||
if not existing_scan:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Scheduled scan not found"
|
||||
}
|
||||
|
||||
# Toggle enabled status
|
||||
new_enabled = not existing_scan['enabled']
|
||||
success = db.update_scheduled_scan(
|
||||
scan_id=scan_id,
|
||||
enabled=new_enabled,
|
||||
updated_by="web_interface"
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to toggle scheduled scan"
|
||||
}
|
||||
|
||||
status = "enabled" if new_enabled else "disabled"
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Scheduled scan '{existing_scan['name']}' {status} successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def run_scheduled_scan(dependencies: dict, scan_id: int):
|
||||
"""Manually run a scheduled scan"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if scan exists
|
||||
existing_scan = db.get_scheduled_scan(scan_id)
|
||||
if not existing_scan:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Scheduled scan not found"
|
||||
}
|
||||
|
||||
# TODO: Implement actual scan execution
|
||||
# For now, just return a success message
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Manual execution of '{existing_scan['name']}' started",
|
||||
"note": "Scan execution will be implemented with the background scheduler"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def get_schedule_executions(dependencies: dict, schedule_id: int = None):
|
||||
"""Get schedule execution history"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
executions = db.get_schedule_executions(schedule_id)
|
||||
|
||||
# Convert to response format
|
||||
execution_list = []
|
||||
for execution in executions:
|
||||
execution_dict = dict(execution)
|
||||
# Convert datetime objects to strings
|
||||
for field in ['started_at', 'completed_at']:
|
||||
if execution_dict.get(field):
|
||||
execution_dict[field] = execution_dict[field].isoformat()
|
||||
execution_list.append(execution_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"executions": execution_list
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -61,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()
|
||||
@@ -171,6 +172,12 @@ class NFOGuardConfig:
|
||||
self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}")
|
||||
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"""
|
||||
|
||||
@@ -15,7 +15,8 @@ from utils.async_file_utils import (
|
||||
async_file_exists,
|
||||
async_set_file_mtime,
|
||||
async_batch_nfo_operations,
|
||||
async_concurrent_episode_processing
|
||||
async_concurrent_episode_processing,
|
||||
aiofiles
|
||||
)
|
||||
from utils.nfo_patterns import (
|
||||
create_basic_nfo_structure,
|
||||
@@ -24,6 +25,7 @@ from utils.nfo_patterns import (
|
||||
extract_imdb_id_from_text
|
||||
)
|
||||
from utils.validation import validate_date_string
|
||||
from utils.file_utils import VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
class AsyncNFOManager:
|
||||
@@ -33,6 +35,54 @@ class AsyncNFOManager:
|
||||
self.manager_brand = manager_brand
|
||||
self.debug = debug
|
||||
|
||||
async def _async_get_target_nfo_path(self, season_dir: Path, season: int, episode: int) -> Path:
|
||||
"""
|
||||
Get the target NFO path using video filename matching to prevent concatenation
|
||||
|
||||
Args:
|
||||
season_dir: Path to season directory
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
|
||||
Returns:
|
||||
Path to target NFO file (video-matching preferred, generic fallback)
|
||||
"""
|
||||
try:
|
||||
# Find video files in the season directory
|
||||
video_files = []
|
||||
if await async_file_exists(season_dir):
|
||||
import re
|
||||
try:
|
||||
entries = await aiofiles.os.listdir(season_dir)
|
||||
for entry in entries:
|
||||
entry_path = season_dir / entry
|
||||
if await aiofiles.os.path.isfile(entry_path):
|
||||
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||
# Parse episode info from filename
|
||||
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', entry)
|
||||
if match:
|
||||
s, e = int(match.group(1)), int(match.group(2))
|
||||
if s == season and e == episode:
|
||||
# Found matching video file - use its name for NFO
|
||||
target_nfo = season_dir / f"{entry_path.stem}.nfo"
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Video-matching NFO path: {target_nfo.name}")
|
||||
return target_nfo
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
_log("WARNING", f"Failed to scan season directory {season_dir}: {e}")
|
||||
|
||||
# Fallback to generic filename if no matching video found
|
||||
target_nfo = season_dir / f"S{season:02d}E{episode:02d}.nfo"
|
||||
if self.debug:
|
||||
_log("WARNING", f"No video file found for S{season:02d}E{episode:02d}, using generic: {target_nfo.name}")
|
||||
return target_nfo
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to determine NFO path for S{season:02d}E{episode:02d}: {e}")
|
||||
# Emergency fallback
|
||||
return season_dir / f"S{season:02d}E{episode:02d}.nfo"
|
||||
|
||||
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||
"""
|
||||
Async extract IMDb ID from directory path or filename
|
||||
@@ -184,7 +234,7 @@ class AsyncNFOManager:
|
||||
lock_metadata: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Async create episode NFO file
|
||||
Async create episode NFO file with proper video filename matching
|
||||
|
||||
Args:
|
||||
season_dir: Path to season directory
|
||||
@@ -199,8 +249,8 @@ class AsyncNFOManager:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
|
||||
nfo_path = season_dir / nfo_filename
|
||||
# Use proper video filename matching to prevent NFO concatenation
|
||||
nfo_path = await self._async_get_target_nfo_path(season_dir, season, episode)
|
||||
|
||||
# Prepare dates
|
||||
dates = {}
|
||||
|
||||
+391
-1
@@ -129,11 +129,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:
|
||||
@@ -441,6 +509,29 @@ 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 delete_orphaned_movies(self) -> List[Dict]:
|
||||
"""
|
||||
Find and delete movies that don't have corresponding video files on disk
|
||||
@@ -560,11 +651,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
|
||||
@@ -220,7 +220,7 @@ class EpisodeNFOManager:
|
||||
lockdata_elem = ET.SubElement(episode_elem, "lockdata")
|
||||
lockdata_elem.text = "true"
|
||||
|
||||
# Add comment with source
|
||||
# Add comment with source at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
|
||||
episode_elem.append(comment)
|
||||
|
||||
|
||||
+148
-47
@@ -50,6 +50,70 @@ class NFOManager:
|
||||
|
||||
return None
|
||||
|
||||
def parse_imdb_from_path_with_nfo_fallback(self, path: Path, sonarr_client=None, shutdown_event=None) -> Optional[str]:
|
||||
"""
|
||||
Enhanced IMDb detection that fallback to Sonarr ID lookup from NFO files
|
||||
|
||||
1. First try to parse IMDb ID from directory path/name
|
||||
2. If not found, scan NFO files in directory for Sonarr series ID
|
||||
3. Use Sonarr API to lookup series and extract IMDb ID
|
||||
"""
|
||||
# Primary: Try directory name first
|
||||
imdb_id = self.parse_imdb_from_path(path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Check for shutdown signal before expensive operations
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
# Fallback: Check NFO files for Sonarr series ID
|
||||
if not sonarr_client or not sonarr_client.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Look for episode NFO files in the directory (and subdirectories)
|
||||
nfo_files = []
|
||||
if path.is_dir():
|
||||
# Check current directory and season subdirectories
|
||||
nfo_files.extend(list(path.glob("*.nfo")))
|
||||
for subdir in path.iterdir():
|
||||
if subdir.is_dir() and subdir.name.lower().startswith('season'):
|
||||
nfo_files.extend(list(subdir.glob("*.nfo")))
|
||||
|
||||
# Extract Sonarr series ID from any NFO file
|
||||
for nfo_file in nfo_files:
|
||||
# Check for shutdown signal during NFO processing
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_file)
|
||||
root = tree.getroot()
|
||||
|
||||
# Look for Sonarr series ID
|
||||
for uniqueid in root.findall('.//uniqueid[@type="sonarr"]'):
|
||||
sonarr_id = uniqueid.text
|
||||
if sonarr_id and sonarr_id.isdigit():
|
||||
# Check for shutdown signal before API call
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
# Look up series in Sonarr to get IMDb ID
|
||||
series_data = sonarr_client.get_series_by_id(int(sonarr_id))
|
||||
if series_data:
|
||||
imdb_id = series_data.get('imdbId')
|
||||
if imdb_id:
|
||||
return imdb_id.replace('tt', '') if imdb_id.startswith('tt') else imdb_id
|
||||
|
||||
except Exception as e:
|
||||
continue # Skip this NFO file and try the next one
|
||||
|
||||
except Exception as e:
|
||||
pass # Fallback failed, return None
|
||||
|
||||
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():
|
||||
@@ -154,8 +218,10 @@ class NFOManager:
|
||||
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":
|
||||
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
|
||||
# This prevents incomplete NFO files from being marked as "complete"
|
||||
if (lockdata_elem is not None and lockdata_elem.text == "true" and
|
||||
dateadded_elem is not None and dateadded_elem.text):
|
||||
# Extract original source from NFOGuard comment, default to nfo_file_existing
|
||||
source = "nfo_file_existing"
|
||||
|
||||
@@ -183,12 +249,64 @@ class NFOManager:
|
||||
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}")
|
||||
except ET.ParseError as e:
|
||||
# Handle malformed XML files gracefully (common with external NFO files)
|
||||
print(f"🔍 NFO XML parse error (will try text fallback): {nfo_path.name}")
|
||||
# Try text-based fallback for extracting NFOGuard data
|
||||
return self._extract_nfoguard_data_text_fallback(nfo_path)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Unexpected error reading NFO file {nfo_path.name}: {e}")
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _extract_nfoguard_data_text_fallback(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Text-based fallback for extracting NFOGuard data from malformed XML files"""
|
||||
try:
|
||||
content = nfo_path.read_text(encoding='utf-8', errors='ignore')
|
||||
|
||||
# Look for NFOGuard elements using regex
|
||||
import re
|
||||
|
||||
# Check for lockdata=true first (NFOGuard marker)
|
||||
if not re.search(r'<lockdata>true</lockdata>', content, re.IGNORECASE):
|
||||
return None
|
||||
|
||||
# Extract dateadded
|
||||
dateadded_match = re.search(r'<dateadded>([^<]+)</dateadded>', content, re.IGNORECASE)
|
||||
if not dateadded_match:
|
||||
return None
|
||||
|
||||
dateadded = dateadded_match.group(1).strip()
|
||||
if not dateadded:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"dateadded": dateadded,
|
||||
"source": "nfo_file_existing" # Default source
|
||||
}
|
||||
|
||||
# Extract optional fields
|
||||
premiered_match = re.search(r'<premiered>([^<]+)</premiered>', content, re.IGNORECASE)
|
||||
if premiered_match:
|
||||
result["released"] = premiered_match.group(1).strip()
|
||||
|
||||
aired_match = re.search(r'<aired>([^<]+)</aired>', content, re.IGNORECASE)
|
||||
if aired_match:
|
||||
result["aired"] = aired_match.group(1).strip()
|
||||
|
||||
# Extract source from NFOGuard comment
|
||||
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', content)
|
||||
if source_match:
|
||||
result["source"] = source_match.group(1).strip()
|
||||
|
||||
print(f"✅ Extracted NFOGuard data via text fallback: dateadded={result.get('dateadded', 'None')}, source={result['source']}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Text fallback extraction failed for {nfo_path.name}: {e}")
|
||||
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"
|
||||
@@ -206,8 +324,10 @@ class NFOManager:
|
||||
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":
|
||||
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
|
||||
# This prevents incomplete episode NFO files from being marked as "complete"
|
||||
if (lockdata_elem is not None and lockdata_elem.text == "true" and
|
||||
dateadded_elem is not None and dateadded_elem.text):
|
||||
# Extract original source from NFOGuard comment, default to episode_nfo_existing
|
||||
source = "episode_nfo_existing"
|
||||
|
||||
@@ -342,10 +462,6 @@ class NFOManager:
|
||||
# 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
|
||||
@@ -384,6 +500,10 @@ class NFOManager:
|
||||
lockdata.text = "true"
|
||||
nfoguard_elements.append(lockdata)
|
||||
|
||||
# Add NFOGuard comment as the very last element (appears at bottom)
|
||||
nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ")
|
||||
nfoguard_elements.append(nfoguard_comment)
|
||||
|
||||
# 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:
|
||||
@@ -395,7 +515,7 @@ class NFOManager:
|
||||
tree = ET.ElementTree(movie)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write directly to file (comment is already embedded in XML)
|
||||
# Write directly to file (comment is already embedded in XML at bottom)
|
||||
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)
|
||||
@@ -455,22 +575,14 @@ class NFOManager:
|
||||
lockdata = ET.SubElement(tvshow, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} "
|
||||
# Add NFOGuard comment at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} ")
|
||||
tvshow.append(comment)
|
||||
|
||||
# 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)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
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 ""))
|
||||
@@ -517,22 +629,14 @@ class NFOManager:
|
||||
lockdata = ET.SubElement(season, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} "
|
||||
# Add NFOGuard comment at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} ")
|
||||
season.append(comment)
|
||||
|
||||
# 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)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
|
||||
print(f" Season: {season_number}")
|
||||
@@ -707,32 +811,29 @@ class NFOManager:
|
||||
aired_str = str(aired)
|
||||
aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str
|
||||
|
||||
# Debug logging for dateadded
|
||||
print(f"🔍 DEBUG: dateadded value: {repr(dateadded)} (type: {type(dateadded)})")
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(episode, "dateadded")
|
||||
# Convert datetime objects to strings
|
||||
dateadded_elem.text = str(dateadded)
|
||||
print(f"✅ Added dateadded to episode NFO: {dateadded}")
|
||||
else:
|
||||
print(f"❌ dateadded is empty/None, not adding to episode NFO")
|
||||
|
||||
# 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} "
|
||||
# Add NFOGuard comment at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
|
||||
episode.append(comment)
|
||||
|
||||
# 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)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
|
||||
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
|
||||
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
nfoguard:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -122,4 +122,23 @@ networks:
|
||||
# - 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
|
||||
# - 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+238
-315
@@ -96,11 +96,11 @@ async def get_movies_list(dependencies: dict,
|
||||
params.append(source_filter)
|
||||
|
||||
if search:
|
||||
where_conditions.append("(imdb_id LIKE %s OR path LIKE %s)")
|
||||
where_conditions.append("(imdb_id ILIKE %s OR path ILIKE %s)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
if imdb_search:
|
||||
where_conditions.append("imdb_id LIKE %s")
|
||||
where_conditions.append("imdb_id ILIKE %s")
|
||||
params.append(f"%{imdb_search}%")
|
||||
|
||||
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||
@@ -165,11 +165,11 @@ async def get_tv_series_list(dependencies: dict,
|
||||
having_conditions = []
|
||||
|
||||
if search:
|
||||
where_conditions.append("(s.imdb_id LIKE %s OR s.path LIKE %s)")
|
||||
where_conditions.append("(s.imdb_id ILIKE %s OR s.path ILIKE %s)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
if imdb_search:
|
||||
where_conditions.append("s.imdb_id LIKE %s")
|
||||
where_conditions.append("s.imdb_id ILIKE %s")
|
||||
params.append(f"%{imdb_search}%")
|
||||
|
||||
if source_filter:
|
||||
@@ -600,56 +600,26 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi
|
||||
has_video_file=episode_data.get('has_video_file', False)
|
||||
)
|
||||
|
||||
# Create/update NFO file with new data
|
||||
nfo_manager = dependencies["nfo_manager"]
|
||||
config = dependencies["config"]
|
||||
|
||||
if config.manage_nfo:
|
||||
try:
|
||||
# Find the series directory based on IMDb ID
|
||||
series_path = None
|
||||
for tv_path in config.tv_paths:
|
||||
for series_dir in Path(tv_path).iterdir():
|
||||
if series_dir.is_dir() and imdb_id.lower() in series_dir.name.lower():
|
||||
series_path = series_dir
|
||||
break
|
||||
if series_path:
|
||||
break
|
||||
|
||||
if series_path:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
if season_dir.exists():
|
||||
nfo_manager.create_episode_nfo(
|
||||
season_dir=season_dir,
|
||||
season_num=season,
|
||||
episode_num=episode,
|
||||
aired=episode_data.get('aired'),
|
||||
dateadded=dateadded,
|
||||
source=source,
|
||||
lock_metadata=config.lock_metadata
|
||||
)
|
||||
print(f"✅ Updated NFO file for {imdb_id} S{season:02d}E{episode:02d}")
|
||||
else:
|
||||
print(f"⚠️ Season directory not found: {season_dir}")
|
||||
else:
|
||||
print(f"⚠️ Series directory not found for {imdb_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error updating NFO file for {imdb_id} S{season:02d}E{episode:02d}: {e}")
|
||||
# NFO file management is handled by the core container
|
||||
# Web interface only updates database entries
|
||||
|
||||
# Add to processing history
|
||||
db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type="episode",
|
||||
event_type="manual_date_update",
|
||||
details={
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
"old_source": episode_data.get('source'),
|
||||
"new_source": source,
|
||||
"dateadded": dateadded
|
||||
}
|
||||
)
|
||||
try:
|
||||
db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type="episode",
|
||||
event_type="manual_date_update",
|
||||
details={
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
"old_source": episode_data.get('source'),
|
||||
"new_source": source,
|
||||
"dateadded": dateadded
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to add processing history: {e}")
|
||||
# Don't fail the entire update for history logging issues
|
||||
|
||||
return {"status": "success", "message": f"Updated episode {imdb_id} S{season:02d}E{episode:02d}"}
|
||||
|
||||
@@ -882,7 +852,7 @@ async def get_movie_date_options(dependencies: dict, imdb_id: str):
|
||||
|
||||
|
||||
async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int, episode: int):
|
||||
"""Get available date options for an episode"""
|
||||
"""Get available date options for an episode (simplified for web interface)"""
|
||||
print(f"🔍 DEBUG: get_episode_date_options called with imdb_id={imdb_id}, season={season}, episode={episode}")
|
||||
db = dependencies["db"]
|
||||
|
||||
@@ -936,267 +906,7 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int
|
||||
"description": f"Use original air date: {episode_data['aired']}"
|
||||
})
|
||||
|
||||
# Option 3: Active lookup from external sources
|
||||
try:
|
||||
# Get TV processor and clients from dependencies
|
||||
tv_processor = dependencies.get("tv_processor")
|
||||
print(f"🔍 DEBUG: tv_processor available: {tv_processor is not None}")
|
||||
if tv_processor:
|
||||
print(f"🔍 DEBUG: tv_processor has external_clients: {hasattr(tv_processor, 'external_clients')}")
|
||||
print(f"🔍 DEBUG: tv_processor has sonarr: {hasattr(tv_processor, 'sonarr')}")
|
||||
|
||||
if tv_processor and hasattr(tv_processor, 'external_clients'):
|
||||
external_clients = tv_processor.external_clients
|
||||
print(f"🔍 DEBUG: external_clients available: {external_clients is not None}")
|
||||
if external_clients:
|
||||
print(f"🔍 DEBUG: TMDB enabled: {external_clients.tmdb.enabled if hasattr(external_clients, 'tmdb') else 'No TMDB client'}")
|
||||
|
||||
# Check Sonarr for import dates
|
||||
if tv_processor.sonarr and tv_processor.sonarr.enabled:
|
||||
try:
|
||||
print(f"🔍 DEBUG: Attempting Sonarr lookup for {imdb_id}")
|
||||
# Look up the series and episode in Sonarr
|
||||
series_data = tv_processor.sonarr.series_by_imdb(imdb_id)
|
||||
|
||||
# If IMDb lookup fails, try direct series lookup as fallback
|
||||
if not series_data:
|
||||
print(f"🔍 DEBUG: IMDb lookup failed, trying direct series lookup")
|
||||
try:
|
||||
# Let's also debug what series are available
|
||||
all_series = tv_processor.sonarr.get_all_series()
|
||||
print(f"🔍 DEBUG: Found {len(all_series)} total series in Sonarr")
|
||||
|
||||
# Look for Lincoln Lawyer specifically
|
||||
lincoln_series = [s for s in all_series if 'lincoln' in s.get('title', '').lower()]
|
||||
print(f"🔍 DEBUG: Lincoln Lawyer series found: {len(lincoln_series)}")
|
||||
for ls in lincoln_series:
|
||||
print(f" - Title: '{ls.get('title')}', IMDb: '{ls.get('imdbId')}', ID: {ls.get('id')}")
|
||||
|
||||
# Try direct lookup first
|
||||
series_data = tv_processor.sonarr.series_by_imdb_direct(imdb_id)
|
||||
|
||||
# If still no match but we found Lincoln Lawyer series, try fuzzy matching
|
||||
if not series_data and lincoln_series:
|
||||
target_imdb_num = imdb_id.replace('tt', '').lower()
|
||||
print(f"🔍 DEBUG: Trying fuzzy match for IMDb number: {target_imdb_num}")
|
||||
|
||||
for ls in lincoln_series:
|
||||
ls_imdb = ls.get('imdbId', '')
|
||||
ls_imdb_num = ls_imdb.replace('tt', '').lower()
|
||||
print(f" - Comparing {target_imdb_num} vs {ls_imdb_num}")
|
||||
|
||||
# Check if IMDb numbers are close (within 10 digits)
|
||||
if ls_imdb_num and target_imdb_num:
|
||||
try:
|
||||
target_num = int(target_imdb_num)
|
||||
ls_num = int(ls_imdb_num)
|
||||
diff = abs(target_num - ls_num)
|
||||
print(f" - Numeric difference: {diff}")
|
||||
|
||||
if diff <= 10: # Allow small IMDb ID differences
|
||||
print(f"✅ Found close IMDb match: {ls_imdb} vs {imdb_id} (diff: {diff})")
|
||||
series_data = ls
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"⚠️ Direct series lookup also failed: {e}")
|
||||
import traceback
|
||||
print(f" Traceback: {traceback.format_exc()}")
|
||||
|
||||
print(f"🔍 DEBUG: Series data found: {series_data is not None}")
|
||||
if series_data:
|
||||
series_id = series_data.get('id')
|
||||
series_title = series_data.get('title', 'Unknown')
|
||||
print(f"🔍 DEBUG: Found series '{series_title}' with ID {series_id}")
|
||||
|
||||
if series_id:
|
||||
# Get episodes for the series
|
||||
print(f"🔍 DEBUG: Getting episodes for series {series_id}")
|
||||
episodes = tv_processor.sonarr.episodes_for_series(series_id)
|
||||
print(f"🔍 DEBUG: Found {len(episodes)} episodes")
|
||||
|
||||
for ep in episodes:
|
||||
ep_season = ep.get('seasonNumber')
|
||||
ep_episode = ep.get('episodeNumber')
|
||||
# Convert to int for proper comparison (handle both string and int from Sonarr)
|
||||
try:
|
||||
ep_season = int(ep_season) if ep_season is not None else None
|
||||
ep_episode = int(ep_episode) if ep_episode is not None else None
|
||||
except (ValueError, TypeError):
|
||||
continue # Skip episodes with invalid season/episode numbers
|
||||
|
||||
if ep_season == season and ep_episode == episode:
|
||||
episode_id = ep.get('id')
|
||||
ep_title = ep.get('title', 'Unknown')
|
||||
ep_air_date = ep.get('airDate') # Get air date from Sonarr
|
||||
print(f"🔍 DEBUG: Found target episode '{ep_title}' with ID {episode_id}, airDate: {ep_air_date}")
|
||||
|
||||
if episode_id:
|
||||
# Get import history for this specific episode
|
||||
print(f"🔍 DEBUG: Getting import history for episode {episode_id}")
|
||||
import_date = tv_processor.sonarr.get_episode_import_history(episode_id)
|
||||
print(f"🔍 DEBUG: Import date found: {import_date}")
|
||||
|
||||
if import_date:
|
||||
# Check if this is different from current date
|
||||
current_dateadded = episode_data.get('dateadded')
|
||||
current_date_str = current_dateadded.strftime('%Y-%m-%d') if current_dateadded else ''
|
||||
if not current_dateadded or not current_date_str.startswith(import_date[:10]):
|
||||
options.append({
|
||||
"type": "sonarr_import",
|
||||
"label": "Sonarr Import Date",
|
||||
"date": import_date,
|
||||
"source": "sonarr:import_history",
|
||||
"description": f"Import date from Sonarr: {import_date[:10]}"
|
||||
})
|
||||
print(f"✅ Added Sonarr import option: {import_date[:10]}")
|
||||
|
||||
# If no import date but we have air date from Sonarr, add as air date option
|
||||
if not import_date and ep_air_date:
|
||||
current_aired = episode_data.get('aired', '')
|
||||
current_dateadded = episode_data.get('dateadded', '')
|
||||
|
||||
# Add air date option if different from current or missing
|
||||
if not current_aired or current_aired != ep_air_date:
|
||||
options.append({
|
||||
"type": "sonarr_air",
|
||||
"label": "Sonarr Air Date",
|
||||
"date": f"{ep_air_date}T20:00:00",
|
||||
"source": "sonarr:airdate",
|
||||
"description": f"Air date from Sonarr: {ep_air_date}"
|
||||
})
|
||||
print(f"✅ Added Sonarr air date option: {ep_air_date}")
|
||||
|
||||
# If no dateadded, suggest using air date as import date fallback
|
||||
if not current_dateadded:
|
||||
options.append({
|
||||
"type": "sonarr_air_fallback",
|
||||
"label": "Use Air Date as Import Date",
|
||||
"date": f"{ep_air_date}T20:00:00",
|
||||
"source": "sonarr:aired_fallback",
|
||||
"description": f"Use Sonarr air date as import date: {ep_air_date}"
|
||||
})
|
||||
print(f"✅ Added Sonarr air date fallback option: {ep_air_date}")
|
||||
|
||||
break
|
||||
else:
|
||||
print(f"❌ No series found in Sonarr for {imdb_id}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to get Sonarr import date for {imdb_id} S{season:02d}E{episode:02d}: {e}")
|
||||
import traceback
|
||||
print(f" Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Check TMDB for episode air dates
|
||||
if external_clients.tmdb.enabled:
|
||||
try:
|
||||
print(f"🔍 DEBUG: Attempting TMDB lookup for {imdb_id}")
|
||||
# Get TMDB TV series ID from IMDb ID using find endpoint
|
||||
tv_find_result = external_clients.tmdb._get(f"/find/{imdb_id}", {"external_source": "imdb_id"})
|
||||
print(f"🔍 DEBUG: TMDB find result: {tv_find_result is not None}")
|
||||
print(f"🔍 DEBUG: TMDB raw response: {tv_find_result}")
|
||||
|
||||
# Check both tv_results and tv_episode_results
|
||||
tmdb_id = None
|
||||
tv_title = "Unknown"
|
||||
|
||||
if tv_find_result and tv_find_result.get("tv_results"):
|
||||
tv_results = tv_find_result.get("tv_results", [])
|
||||
print(f"🔍 DEBUG: Found {len(tv_results)} TV results")
|
||||
|
||||
if tv_results:
|
||||
tv_show = tv_results[0]
|
||||
tmdb_id = tv_show.get("id")
|
||||
tv_title = tv_show.get("name", "Unknown")
|
||||
print(f"🔍 DEBUG: Found TMDB series '{tv_title}' with ID {tmdb_id}")
|
||||
|
||||
# Fallback: Check tv_episode_results for show_id
|
||||
elif tv_find_result and tv_find_result.get("tv_episode_results"):
|
||||
episode_results = tv_find_result.get("tv_episode_results", [])
|
||||
print(f"🔍 DEBUG: Found {len(episode_results)} TV episode results")
|
||||
|
||||
if episode_results:
|
||||
tmdb_episode_data = episode_results[0]
|
||||
tmdb_id = tmdb_episode_data.get("show_id")
|
||||
episode_name = tmdb_episode_data.get("name", "Unknown")
|
||||
print(f"🔍 DEBUG: Found TMDB series via episode '{episode_name}' with show_id {tmdb_id}")
|
||||
|
||||
if tmdb_id:
|
||||
print(f"🔍 DEBUG: Using TMDB ID {tmdb_id} for series lookup")
|
||||
|
||||
# Get episode air date from TMDB
|
||||
print(f"🔍 DEBUG: Getting TMDB season {season} episodes for series {tmdb_id}")
|
||||
episodes = external_clients.tmdb.get_tv_season_episodes(tmdb_id, season)
|
||||
print(f"🔍 DEBUG: TMDB episodes found: {episodes}")
|
||||
|
||||
if episode in episodes:
|
||||
air_date = episodes[episode]
|
||||
print(f"🔍 DEBUG: TMDB air date for S{season:02d}E{episode:02d}: {air_date}")
|
||||
|
||||
if air_date:
|
||||
# Check if this is different from current aired date
|
||||
current_aired = episode_data.get('aired', '')
|
||||
if not current_aired or current_aired != air_date:
|
||||
options.append({
|
||||
"type": "tmdb_air",
|
||||
"label": "TMDB Air Date",
|
||||
"date": f"{air_date}T20:00:00", # Default to 8 PM
|
||||
"source": "tmdb:airdate",
|
||||
"description": f"Air date from TMDB: {air_date}"
|
||||
})
|
||||
print(f"✅ Added TMDB air date option: {air_date}")
|
||||
|
||||
# If no aired date in database, also add this as "Use Air Date" option
|
||||
if not current_aired:
|
||||
options.insert(1, { # Insert after current option
|
||||
"type": "airdate_tmdb",
|
||||
"label": "Use Air Date (TMDB)",
|
||||
"date": f"{air_date}T20:00:00",
|
||||
"source": "airdate",
|
||||
"description": f"Use air date from TMDB: {air_date}"
|
||||
})
|
||||
print(f"✅ Added 'Use Air Date' option from TMDB: {air_date}")
|
||||
else:
|
||||
print(f"❌ Episode {episode} not found in TMDB season {season} data")
|
||||
else:
|
||||
print(f"❌ No TV series ID found in TMDB for {imdb_id}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to get TMDB air date for {imdb_id} S{season:02d}E{episode:02d}: {e}")
|
||||
import traceback
|
||||
print(f" Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Check external clients for episode air dates (TVDB, OMDb)
|
||||
if hasattr(external_clients, 'get_episode_air_date'):
|
||||
try:
|
||||
air_date = external_clients.get_episode_air_date(imdb_id, season, episode)
|
||||
if air_date:
|
||||
# Check if this is different from current aired date
|
||||
current_aired = episode_data.get('aired', '')
|
||||
if not current_aired or current_aired != air_date:
|
||||
options.append({
|
||||
"type": "external_air",
|
||||
"label": "External Air Date",
|
||||
"date": f"{air_date}T20:00:00", # Default to 8 PM
|
||||
"source": "external:airdate",
|
||||
"description": f"Air date from external sources: {air_date}"
|
||||
})
|
||||
|
||||
# If no aired date in database and not already added from TMDB, add this as "Use Air Date" option
|
||||
if not current_aired and not any(opt.get('type') == 'airdate_tmdb' for opt in options):
|
||||
options.insert(1, { # Insert after current option
|
||||
"type": "airdate_external",
|
||||
"label": "Use Air Date (External)",
|
||||
"date": f"{air_date}T20:00:00",
|
||||
"source": "airdate",
|
||||
"description": f"Use air date from external sources: {air_date}"
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to get external air date for {imdb_id} S{season:02d}E{episode:02d}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ External source lookup failed for {imdb_id} S{season:02d}E{episode:02d}: {e}")
|
||||
|
||||
# Option 4: Manual entry
|
||||
# Option 3: Manual entry
|
||||
options.append({
|
||||
"type": "manual",
|
||||
"label": "Manual Entry",
|
||||
@@ -1216,4 +926,217 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int
|
||||
"episode": episode,
|
||||
"current_data": episode_data,
|
||||
"options": options
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def delete_episode(dependencies: dict, imdb_id: str, season: int, episode: int):
|
||||
"""Delete an episode from the database"""
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if episode exists
|
||||
episode_data = db.get_episode_date(imdb_id, season, episode)
|
||||
if not episode_data:
|
||||
raise HTTPException(status_code=404, detail="Episode not found")
|
||||
|
||||
# Delete from database
|
||||
try:
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"DELETE FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s",
|
||||
(imdb_id, season, episode)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type="episode",
|
||||
event_type="manual_deletion",
|
||||
details={
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
"deleted_source": episode_data.get('source'),
|
||||
"deleted_dateadded": episode_data.get('dateadded')
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to add processing history: {e}")
|
||||
|
||||
return {"success": True, "status": "success", "message": f"Deleted episode {imdb_id} S{season:02d}E{episode:02d}"}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error deleting episode: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete episode: {str(e)}")
|
||||
|
||||
|
||||
async def delete_movie(dependencies: dict, imdb_id: str):
|
||||
"""Delete a movie from the database"""
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if movie exists
|
||||
movie_data = db.get_movie_dates(imdb_id)
|
||||
if not movie_data:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
|
||||
# Delete from database
|
||||
try:
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (imdb_id,))
|
||||
conn.commit()
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type="movie",
|
||||
event_type="manual_deletion",
|
||||
details={
|
||||
"deleted_source": movie_data.get('source'),
|
||||
"deleted_dateadded": movie_data.get('dateadded'),
|
||||
"deleted_path": movie_data.get('path')
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to add processing history: {e}")
|
||||
|
||||
return {"success": True, "status": "success", "message": f"Deleted movie {imdb_id}"}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error deleting movie: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete movie: {str(e)}")
|
||||
|
||||
|
||||
def register_web_routes(app, dependencies):
|
||||
"""Register all web API routes with FastAPI app"""
|
||||
from fastapi import Request, Response
|
||||
|
||||
# Dashboard and stats endpoints
|
||||
@app.get("/api/dashboard")
|
||||
async def api_dashboard():
|
||||
return await get_dashboard_stats(dependencies)
|
||||
|
||||
@app.get("/api/dashboard/stats")
|
||||
async def api_dashboard_stats():
|
||||
return await get_dashboard_stats(dependencies)
|
||||
|
||||
# Movies endpoints
|
||||
@app.get("/api/movies")
|
||||
async def api_movies_list(skip: int = 0, limit: int = 100, has_date: bool = None,
|
||||
source_filter: str = None, search: str = None, imdb_search: str = None):
|
||||
return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search)
|
||||
|
||||
@app.post("/api/movies/{imdb_id}/update-date")
|
||||
async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"):
|
||||
return await update_movie_date(dependencies, imdb_id, dateadded, source)
|
||||
|
||||
@app.put("/api/movies/{imdb_id}")
|
||||
async def api_update_movie(imdb_id: str, dateadded: str = None, source: str = "manual"):
|
||||
return await update_movie_date(dependencies, imdb_id, dateadded, source)
|
||||
|
||||
@app.get("/api/movies/{imdb_id}/date-options")
|
||||
async def api_movie_date_options(imdb_id: str):
|
||||
return await get_movie_date_options(dependencies, imdb_id)
|
||||
|
||||
# TV series endpoints
|
||||
@app.get("/api/series")
|
||||
async def api_series_list(skip: int = 0, limit: int = 50, search: str = None,
|
||||
imdb_search: str = None, date_filter: str = None, source_filter: str = None):
|
||||
return await get_tv_series_list(dependencies, skip, limit, search, imdb_search, date_filter, source_filter)
|
||||
|
||||
@app.get("/api/series/{imdb_id}/episodes")
|
||||
async def api_series_episodes(imdb_id: str):
|
||||
return await get_series_episodes(dependencies, imdb_id)
|
||||
|
||||
@app.get("/api/series/sources")
|
||||
async def api_series_sources():
|
||||
return await get_series_sources(dependencies)
|
||||
|
||||
@app.get("/api/series/debug/date-distribution")
|
||||
async def api_debug_series_date_distribution():
|
||||
return await debug_series_date_distribution(dependencies)
|
||||
|
||||
# Episode endpoints - WORKING VERSIONS
|
||||
@app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date")
|
||||
async def api_update_episode_date(imdb_id: str, season: int, episode: int,
|
||||
dateadded: str = None, source: str = "manual"):
|
||||
return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source)
|
||||
|
||||
@app.put("/api/episodes/{imdb_id}/{season}/{episode}")
|
||||
async def api_update_episode(imdb_id: str, season: int, episode: int,
|
||||
dateadded: str = None, source: str = "manual"):
|
||||
return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source)
|
||||
|
||||
# Register DELETE route explicitly
|
||||
async def api_delete_episode_handler(imdb_id: str, season: int, episode: int):
|
||||
return await delete_episode(dependencies, imdb_id, season, episode)
|
||||
|
||||
app.add_api_route(
|
||||
"/api/episodes/{imdb_id}/{season}/{episode}",
|
||||
api_delete_episode_handler,
|
||||
methods=["DELETE"],
|
||||
name="delete_episode"
|
||||
)
|
||||
print("✅ DELETE /api/episodes/{imdb_id}/{season}/{episode} route registered")
|
||||
|
||||
@app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options")
|
||||
async def api_episode_date_options(imdb_id: str, season: int, episode: int):
|
||||
return await get_episode_date_options(dependencies, imdb_id, season, episode)
|
||||
|
||||
# Movie deletion endpoint
|
||||
@app.delete("/api/movies/{imdb_id}")
|
||||
async def api_delete_movie(imdb_id: str):
|
||||
return await delete_movie(dependencies, imdb_id)
|
||||
|
||||
# Bulk operations
|
||||
@app.post("/api/bulk/update-source")
|
||||
async def api_bulk_update_source(media_type: str, old_source: str, new_source: str):
|
||||
return await bulk_update_source(dependencies, media_type, old_source, new_source)
|
||||
|
||||
# Reports
|
||||
@app.get("/api/reports/missing-dates")
|
||||
async def api_missing_dates_report():
|
||||
return await get_missing_dates_report(dependencies)
|
||||
|
||||
# Authentication endpoints (for web interface compatibility)
|
||||
@app.get("/api/auth/status")
|
||||
async def api_auth_status(request: Request):
|
||||
"""Check authentication status"""
|
||||
auth_enabled = dependencies.get("auth_enabled", False)
|
||||
|
||||
if not auth_enabled:
|
||||
return {"authenticated": True, "auth_enabled": False, "message": "Authentication disabled"}
|
||||
|
||||
session_manager = dependencies.get("session_manager")
|
||||
if not session_manager:
|
||||
return {"authenticated": False, "auth_enabled": True, "message": "Session manager not available"}
|
||||
|
||||
session_token = request.cookies.get("nfoguard_session")
|
||||
if session_token:
|
||||
username = session_manager.get_session_user(session_token)
|
||||
if username:
|
||||
return {"authenticated": True, "auth_enabled": True, "username": username}
|
||||
|
||||
return {"authenticated": False, "auth_enabled": True, "message": "Not authenticated"}
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def api_auth_logout(request: Request, response: Response):
|
||||
"""Logout endpoint - clears session"""
|
||||
session_manager = dependencies.get("session_manager")
|
||||
if session_manager:
|
||||
session_token = request.cookies.get("nfoguard_session")
|
||||
if session_token:
|
||||
session_manager.delete_session(session_token)
|
||||
|
||||
response.delete_cookie("nfoguard_session")
|
||||
return {"status": "logged_out", "message": "Session cleared"}
|
||||
|
||||
# Health endpoint
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for container monitoring"""
|
||||
return {"status": "healthy", "service": "nfoguard-web"}
|
||||
|
||||
print("✅ Web routes registered successfully")
|
||||
@@ -225,6 +225,153 @@ class WebDatabase:
|
||||
"""
|
||||
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:
|
||||
|
||||
@@ -49,32 +49,10 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Header Logo and Text Layout */
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-logo .logo {
|
||||
height: 60px;
|
||||
width: auto;
|
||||
/* Clean display for new logo */
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header-content h1 i {
|
||||
@@ -84,28 +62,6 @@ body {
|
||||
.header-content p {
|
||||
opacity: 0.9;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive logo layout */
|
||||
@media (max-width: 768px) {
|
||||
.header-logo {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-logo .logo {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Authentication Status */
|
||||
@@ -886,4 +842,61 @@ body {
|
||||
.d-block { display: block; }
|
||||
.d-flex { display: flex; }
|
||||
.justify-content-between { justify-content: space-between; }
|
||||
.align-items-center { align-items: center; }
|
||||
.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;
|
||||
}
|
||||
+264
-67
@@ -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>
|
||||
@@ -12,12 +12,8 @@
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="header-logo">
|
||||
<div class="header-text">
|
||||
<h1>NFOGuard</h1>
|
||||
<p>Database Management & Reporting</p>
|
||||
</div>
|
||||
</div>
|
||||
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
|
||||
<p>Database Management & Reporting</p>
|
||||
</div>
|
||||
<div class="auth-status" id="auth-status" style="display: none;">
|
||||
<span class="auth-user">
|
||||
@@ -40,6 +36,9 @@
|
||||
<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>
|
||||
@@ -50,24 +49,6 @@
|
||||
<main class="main-content">
|
||||
<!-- Dashboard Tab -->
|
||||
<div class="tab-content active" id="dashboard">
|
||||
<!-- Scan Status Banner -->
|
||||
<div class="scan-status-banner" id="dashboard-scan-status" style="display: none;">
|
||||
<div class="scan-status-content">
|
||||
<div class="scan-status-icon">
|
||||
<i class="fas fa-sync fa-spin"></i>
|
||||
</div>
|
||||
<div class="scan-status-info">
|
||||
<h4>Scan in Progress</h4>
|
||||
<p id="dashboard-scan-text">Processing media files...</p>
|
||||
<div class="scan-progress-mini">
|
||||
<div class="progress-bar-mini">
|
||||
<div class="progress-fill-mini" id="dashboard-scan-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon movies">
|
||||
@@ -309,37 +290,114 @@
|
||||
</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>
|
||||
|
||||
<!-- Scan Status Display -->
|
||||
<div class="scan-status-card" id="scan-status" style="display: none;">
|
||||
<div class="scan-status-header">
|
||||
<h3><i class="fas fa-sync fa-spin"></i> Scan in Progress</h3>
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="scan-progress"></div>
|
||||
</div>
|
||||
<span class="progress-text" id="scan-progress-text">Initializing...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools-grid">
|
||||
<!-- Manual Scan Tools -->
|
||||
<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>Run manual scans on your media library</p>
|
||||
<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">Both (Movies & TV)</option>
|
||||
<option value="both">TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
<option value="tv">TV Series Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -347,39 +405,34 @@
|
||||
<select id="scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="full">Full Scan</option>
|
||||
<option value="incomplete">Incomplete Items Only</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">
|
||||
<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>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-folder"></i> Custom Directory Scan</h3>
|
||||
<p>Scan a specific directory or path</p>
|
||||
<form id="custom-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Directory Path:</label>
|
||||
<input type="text" id="scan-path" placeholder="/media/Movies/specific-folder" />
|
||||
<small>Enter the full path to scan (will be auto-formatted)</small>
|
||||
<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>
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="custom-scan-type" required>
|
||||
<option value="both">Auto-detect</option>
|
||||
<option value="movies">Movies</option>
|
||||
<option value="tv">TV Series</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-search"></i> Scan Directory
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
||||
<p>View detailed database information</p>
|
||||
@@ -454,9 +507,153 @@
|
||||
</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"></script>
|
||||
<script src="/static/js/app.js?v=scheduled-scans-system"></script>
|
||||
</body>
|
||||
</html>
|
||||
+344
-259
@@ -5,8 +5,6 @@ let currentTab = 'dashboard';
|
||||
let currentMoviesPage = 1;
|
||||
let currentSeriesPage = 1;
|
||||
let dashboardData = null;
|
||||
let seriesSourcesLoaded = false;
|
||||
let authRetryAttempts = 0;
|
||||
|
||||
// Initialize app
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -14,6 +12,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeEventListeners();
|
||||
checkAuthStatus(); // Check authentication status on page load
|
||||
loadDashboard();
|
||||
loadSeriesSources();
|
||||
});
|
||||
|
||||
// Tab management
|
||||
@@ -50,9 +49,6 @@ function switchTab(tabName) {
|
||||
break;
|
||||
case 'tv':
|
||||
loadSeries();
|
||||
if (!seriesSourcesLoaded) {
|
||||
loadSeriesSources();
|
||||
}
|
||||
break;
|
||||
case 'reports':
|
||||
loadReport();
|
||||
@@ -79,13 +75,8 @@ function initializeEventListeners() {
|
||||
|
||||
// Forms
|
||||
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
|
||||
|
||||
// Manual scan forms
|
||||
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
|
||||
document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan);
|
||||
document.getElementById('custom-scan-form').addEventListener('submit', handleCustomScan);
|
||||
|
||||
// Custom directory input auto-formatting
|
||||
document.getElementById('scan-path').addEventListener('input', handleDirectoryFormatting);
|
||||
}
|
||||
|
||||
// API calls
|
||||
@@ -117,9 +108,6 @@ async function loadDashboard() {
|
||||
dashboardData = await apiCall('/api/dashboard');
|
||||
updateDashboardStats();
|
||||
updateDashboardCharts();
|
||||
|
||||
// Check if there's an ongoing scan when dashboard loads
|
||||
await checkScanStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard:', error);
|
||||
}
|
||||
@@ -128,6 +116,9 @@ async function loadDashboard() {
|
||||
function updateDashboardStats() {
|
||||
if (!dashboardData) return;
|
||||
|
||||
// Debug: Log dashboard data to see what fields are available
|
||||
console.log('Dashboard data received:', dashboardData);
|
||||
|
||||
const moviesTotal = dashboardData.movies_total || 0;
|
||||
const moviesWithDates = dashboardData.movies_with_dates || 0;
|
||||
const moviesWithoutDates = dashboardData.movies_without_dates || (moviesTotal - moviesWithDates);
|
||||
@@ -208,6 +199,7 @@ async function loadMovies(page = 1) {
|
||||
const sourceFilter = document.getElementById('movies-filter-source').value;
|
||||
|
||||
const skip = (page - 1) * 100;
|
||||
console.log(`DEBUG: loadMovies called with page=${page}, calculated skip=${skip}`);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
skip: skip,
|
||||
@@ -362,7 +354,6 @@ async function loadSeriesSources() {
|
||||
});
|
||||
|
||||
select.value = currentValue;
|
||||
seriesSourcesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load series sources:', error);
|
||||
}
|
||||
@@ -385,6 +376,7 @@ async function loadSeries(page = 1) {
|
||||
const sourceFilter = document.getElementById('series-filter-source').value;
|
||||
|
||||
const skip = (page - 1) * 50;
|
||||
console.log(`DEBUG: loadSeries called with page=${page}, calculated skip=${skip}`);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
skip: skip,
|
||||
@@ -512,10 +504,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>
|
||||
@@ -538,7 +541,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}
|
||||
@@ -676,6 +682,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);
|
||||
@@ -703,7 +716,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
|
||||
@@ -856,6 +872,47 @@ async function loadDetailedStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkUpdate(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const mediaType = document.getElementById('bulk-media-type').value;
|
||||
const oldSource = document.getElementById('bulk-old-source').value;
|
||||
const newSource = document.getElementById('bulk-new-source').value;
|
||||
|
||||
if (!mediaType || !oldSource || !newSource) {
|
||||
showToast('Please fill in all fields', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`This will update all ${mediaType} with source "${oldSource}" to "${newSource}". Continue?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiCall('/api/bulk/update-source', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
media_type: mediaType,
|
||||
old_source: oldSource,
|
||||
new_source: newSource
|
||||
})
|
||||
});
|
||||
|
||||
showToast(result.message, 'success');
|
||||
|
||||
// Reset form
|
||||
document.getElementById('bulk-update-form').reset();
|
||||
|
||||
// Refresh current tab
|
||||
if (currentTab === 'movies') loadMovies(currentMoviesPage);
|
||||
if (currentTab === 'tv') loadSeries(currentSeriesPage);
|
||||
if (currentTab === 'reports') loadReport();
|
||||
if (currentTab === 'dashboard') loadDashboard();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Bulk update failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit modal functions
|
||||
async function editMovie(imdbId, dateadded, source) {
|
||||
@@ -878,7 +935,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
|
||||
@@ -1062,6 +1122,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`);
|
||||
@@ -1244,6 +1311,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
|
||||
@@ -1252,7 +1326,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'
|
||||
@@ -1295,7 +1369,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'
|
||||
@@ -1363,16 +1437,8 @@ async function checkAuthStatus() {
|
||||
authUsernameSpan.textContent = authStatus.username;
|
||||
authStatusDiv.style.display = 'flex';
|
||||
} else if (authStatus.auth_enabled && !authStatus.authenticated) {
|
||||
// This can happen briefly during page load - retry once after a short delay
|
||||
if (authRetryAttempts < 1) {
|
||||
authRetryAttempts++;
|
||||
console.debug('Auth enabled but not authenticated - retrying auth check in 500ms');
|
||||
setTimeout(() => {
|
||||
checkAuthStatus();
|
||||
}, 500);
|
||||
} else {
|
||||
console.warn('Auth enabled but not authenticated after retry - middleware may be misconfigured');
|
||||
}
|
||||
// This shouldn't happen if middleware is working, but handle it
|
||||
console.warn('Auth enabled but not authenticated - middleware may be misconfigured');
|
||||
} else {
|
||||
// Authentication disabled - hide auth status
|
||||
authStatusDiv.style.display = 'none';
|
||||
@@ -1385,6 +1451,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;
|
||||
@@ -1412,248 +1641,104 @@ async function logout() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Manual Scan Functions
|
||||
// ===========================
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManualScan(event) {
|
||||
event.preventDefault();
|
||||
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');
|
||||
|
||||
const scanType = document.getElementById('scan-type').value;
|
||||
const scanMode = document.getElementById('scan-mode').value;
|
||||
if (selectedCountSpan) {
|
||||
selectedCountSpan.textContent = selectedCount;
|
||||
}
|
||||
|
||||
if (!scanType || !scanMode) {
|
||||
showToast('Please select both scan type and mode', 'warning');
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
showToast('🔍 Starting manual scan...', 'info');
|
||||
updateScanStatus('Starting manual scan...', true);
|
||||
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'));
|
||||
|
||||
// Send as query parameters instead of JSON body
|
||||
const params = new URLSearchParams({
|
||||
scan_type: scanType,
|
||||
scan_mode: scanMode
|
||||
});
|
||||
|
||||
const result = await apiCall(`/manual/scan?${params}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
showToast(`✅ Manual scan initiated: ${result.message}`, 'success');
|
||||
|
||||
// Track scan start time for status monitoring
|
||||
try {
|
||||
await apiCall('/api/scan/track', { method: 'POST' });
|
||||
} catch (e) {
|
||||
console.log('Failed to track scan start:', e);
|
||||
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);
|
||||
}
|
||||
|
||||
// Start polling for scan status
|
||||
startScanStatusPolling();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Manual scan failed:', error);
|
||||
showToast(`❌ Manual scan failed: ${error.message}`, 'error');
|
||||
updateScanStatus('Scan failed', false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomScan(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const customDirectory = document.getElementById('scan-path').value.trim();
|
||||
const customScanType = document.getElementById('custom-scan-type').value;
|
||||
|
||||
if (!customDirectory) {
|
||||
showToast('Please enter a directory path', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customScanType) {
|
||||
showToast('Please select a scan type', 'warning');
|
||||
return;
|
||||
}
|
||||
// Update UI
|
||||
updateEpisodeModalCounts();
|
||||
updateBulkDeleteButton();
|
||||
|
||||
try {
|
||||
showToast('🔍 Starting custom directory scan...', 'info');
|
||||
updateScanStatus('Starting custom directory scan...', true);
|
||||
|
||||
// Send as query parameters instead of JSON body
|
||||
const params = new URLSearchParams({
|
||||
path: customDirectory,
|
||||
scan_type: customScanType,
|
||||
scan_mode: 'smart' // Default to smart mode for custom scans
|
||||
});
|
||||
|
||||
const result = await apiCall(`/manual/scan?${params}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
showToast(`✅ Custom scan initiated: ${result.message}`, 'success');
|
||||
|
||||
// Track scan start time for status monitoring
|
||||
try {
|
||||
await apiCall('/api/scan/track', { method: 'POST' });
|
||||
} catch (e) {
|
||||
console.log('Failed to track scan start:', e);
|
||||
}
|
||||
|
||||
// Start polling for scan status
|
||||
startScanStatusPolling();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Custom scan failed:', error);
|
||||
showToast(`❌ Custom scan failed: ${error.message}`, 'error');
|
||||
updateScanStatus('Scan failed', false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDirectoryFormatting(event) {
|
||||
const input = event.target;
|
||||
let value = input.value;
|
||||
// Reset button
|
||||
bulkDeleteButton.innerHTML = originalText;
|
||||
bulkDeleteButton.disabled = true;
|
||||
|
||||
// Auto-format common directory patterns
|
||||
if (value && !value.startsWith('/')) {
|
||||
// Add leading slash for absolute paths
|
||||
value = '/' + value;
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
// Remove multiple consecutive slashes
|
||||
value = value.replace(/\/+/g, '/');
|
||||
|
||||
// Remove trailing slash unless it's just '/'
|
||||
if (value.length > 1 && value.endsWith('/')) {
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Scan Status Functions
|
||||
// ===========================
|
||||
|
||||
let scanStatusInterval = null;
|
||||
|
||||
function updateScanStatus(message, isActive = false) {
|
||||
const statusBanner = document.getElementById('dashboard-scan-status');
|
||||
const statusText = document.getElementById('dashboard-scan-text');
|
||||
|
||||
if (!statusBanner || !statusText) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusText.textContent = message;
|
||||
|
||||
if (isActive) {
|
||||
statusBanner.className = 'scan-status-banner active';
|
||||
statusBanner.style.display = 'block';
|
||||
// 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 {
|
||||
statusBanner.className = 'scan-status-banner';
|
||||
// Don't hide completely, just mark as inactive
|
||||
setTimeout(() => {
|
||||
if (statusBanner.className === 'scan-status-banner') {
|
||||
statusBanner.style.display = 'none';
|
||||
}
|
||||
}, 3000);
|
||||
showToast(`❌ Failed to delete ${failCount} episode(s)`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkScanStatus() {
|
||||
try {
|
||||
const status = await apiCall('/api/scan/status');
|
||||
|
||||
if (status.scanning) {
|
||||
// Build detailed status message from core container data
|
||||
let message = status.message || 'Scan in progress...';
|
||||
|
||||
// Add progress details if available
|
||||
if (status.current_operation === 'tv' && status.tv_series_total > 0) {
|
||||
const progress = `${status.tv_series_processed}/${status.tv_series_total}`;
|
||||
message = `Processing TV series (${progress})`;
|
||||
if (status.current_item) {
|
||||
message += ` | Current: ${status.current_item}`;
|
||||
}
|
||||
if (status.elapsed_seconds) {
|
||||
const elapsed = formatElapsedTime(status.elapsed_seconds);
|
||||
message += ` | ${elapsed} elapsed`;
|
||||
}
|
||||
} else if (status.current_operation === 'movies' && status.movies_total > 0) {
|
||||
const progress = `${status.movies_processed}/${status.movies_total}`;
|
||||
message = `Processing movies (${progress})`;
|
||||
if (status.current_item) {
|
||||
message += ` | Current: ${status.current_item}`;
|
||||
}
|
||||
if (status.elapsed_seconds) {
|
||||
const elapsed = formatElapsedTime(status.elapsed_seconds);
|
||||
message += ` | ${elapsed} elapsed`;
|
||||
}
|
||||
} else if (status.elapsed_seconds) {
|
||||
const elapsed = formatElapsedTime(status.elapsed_seconds);
|
||||
message = `Scan in progress | ${elapsed} elapsed`;
|
||||
if (status.current_item) {
|
||||
message += ` | Current: ${status.current_item}`;
|
||||
}
|
||||
}
|
||||
|
||||
updateScanStatus(message, true);
|
||||
|
||||
// Start polling if not already polling
|
||||
if (!scanStatusInterval) {
|
||||
startScanStatusPolling();
|
||||
}
|
||||
return true; // Continue polling
|
||||
} else {
|
||||
updateScanStatus(status.message || 'No scan in progress', false);
|
||||
return false; // Stop polling
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to check scan status:', error);
|
||||
updateScanStatus('Unable to check scan status', false);
|
||||
return false; // Stop polling on error
|
||||
}
|
||||
}
|
||||
|
||||
function formatElapsedTime(seconds) {
|
||||
if (seconds >= 60) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
function startScanStatusPolling() {
|
||||
// Don't start if already polling
|
||||
if (scanStatusInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check status every 2 seconds
|
||||
scanStatusInterval = setInterval(async () => {
|
||||
const shouldContinue = await checkScanStatus();
|
||||
|
||||
if (!shouldContinue) {
|
||||
clearInterval(scanStatusInterval);
|
||||
scanStatusInterval = null;
|
||||
|
||||
// Refresh dashboard data after scan completes
|
||||
if (currentTab === 'dashboard') {
|
||||
setTimeout(loadDashboard, 1000);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
}
|
||||
|
||||
function stopScanStatusPolling() {
|
||||
if (scanStatusInterval) {
|
||||
clearInterval(scanStatusInterval);
|
||||
scanStatusInterval = null;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ 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)
|
||||
if not imdb_id:
|
||||
@@ -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}")
|
||||
@@ -400,6 +406,141 @@ class MovieProcessor:
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
|
||||
return "processed"
|
||||
|
||||
def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None) -> str:
|
||||
"""Process movie for incomplete mode: Check NFO files first for missing dateadded elements"""
|
||||
_log("INFO", f"🔍 NFO-FIRST MODE: Checking movie for missing dateadded in NFO file: {movie_path.name}")
|
||||
|
||||
# Check for shutdown signal
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie NFO-first processing: {movie_path.name}")
|
||||
return "shutdown"
|
||||
|
||||
nfo_path = movie_path / "movie.nfo"
|
||||
|
||||
# STEP 1: Check if NFO file exists and has dateadded
|
||||
_log("DEBUG", f"STEP 1 - Checking NFO file for missing dateadded: {nfo_path}")
|
||||
_log("INFO", f"🔍 NFO exists: {nfo_path.exists()}")
|
||||
|
||||
if nfo_path.exists():
|
||||
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||
_log("INFO", f"🔍 NFOGuard data extracted: {nfo_data}")
|
||||
|
||||
if nfo_data and nfo_data.get('dateadded'):
|
||||
# NFO has dateadded - this movie is complete
|
||||
_log("INFO", f"✅ NFO has dateadded={nfo_data['dateadded']}, movie marked as complete")
|
||||
dateadded = nfo_data["dateadded"]
|
||||
source = nfo_data["source"]
|
||||
released = nfo_data.get("released")
|
||||
|
||||
# Cache NFO data in database for future lookups
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
|
||||
# Update file mtimes if needed
|
||||
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-complete]")
|
||||
return "processed"
|
||||
else:
|
||||
# NFO exists but missing dateadded
|
||||
_log("INFO", f"🔍 NFO exists but missing dateadded - needs DB/API lookup")
|
||||
else:
|
||||
# No NFO file found
|
||||
_log("INFO", f"🔍 No NFO file found - needs DB/API lookup")
|
||||
|
||||
# STEP 2: For movies missing dateadded in NFO, check database
|
||||
_log("DEBUG", f"STEP 2 - Checking database for missing dateadded")
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
|
||||
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
|
||||
# Found in database - use cached data and will add to NFO
|
||||
_log("INFO", f"✅ Database has dateadded={existing['dateadded']} - will add to NFO")
|
||||
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
|
||||
|
||||
# Convert datetime objects to strings for NFO manager
|
||||
if hasattr(dateadded, 'isoformat'):
|
||||
dateadded = dateadded.isoformat()
|
||||
if released and hasattr(released, 'isoformat'):
|
||||
released = released.isoformat()
|
||||
|
||||
# Create NFO with database data
|
||||
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)
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-to-nfo]")
|
||||
return "processed"
|
||||
|
||||
# STEP 3: For movies still missing dateadded, query APIs
|
||||
_log("DEBUG", f"STEP 3 - Querying APIs for missing dateadded")
|
||||
|
||||
# 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"
|
||||
|
||||
# Handle TMDB ID fallback case
|
||||
is_tmdb_fallback = imdb_id.startswith("tmdb-")
|
||||
|
||||
if is_tmdb_fallback:
|
||||
# TMDB fallback processing
|
||||
_log("INFO", f"🔍 TMDB fallback processing for {imdb_id}")
|
||||
|
||||
# Check for existing TMDB NFO date data
|
||||
tmdb_data = self._extract_dates_from_tmdb_nfo(nfo_path)
|
||||
if tmdb_data and tmdb_data.get('dateadded'):
|
||||
dateadded = tmdb_data['dateadded']
|
||||
released = tmdb_data.get('released')
|
||||
source = "tmdb_nfo"
|
||||
else:
|
||||
# Use file modification time as fallback for TMDB
|
||||
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||
_log("INFO", f"Using file mtime as fallback for TMDB movie: {dateadded}")
|
||||
else:
|
||||
# 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:
|
||||
# Check Radarr NFO for premiered date
|
||||
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
|
||||
if radarr_premiered:
|
||||
dateadded = radarr_premiered
|
||||
source = "radarr_nfo"
|
||||
released = radarr_premiered
|
||||
_log("INFO", f"Got premiered date from Radarr NFO: {dateadded}")
|
||||
else:
|
||||
# Last resort: file modification time
|
||||
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||
_log("INFO", f"Using file mtime as last resort: {dateadded}")
|
||||
|
||||
# Save to database and create NFO
|
||||
if dateadded:
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
|
||||
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)
|
||||
|
||||
_log("INFO", f"🔍 NFO-FIRST COMPLETE: {movie_path.name} (source: {source})")
|
||||
return "processed"
|
||||
else:
|
||||
_log("WARNING", f"Could not determine dateadded for movie: {movie_path.name}")
|
||||
return "error"
|
||||
|
||||
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():
|
||||
|
||||
+206
-36
@@ -145,7 +145,7 @@ 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)
|
||||
if not imdb_id:
|
||||
@@ -155,7 +155,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}")
|
||||
@@ -194,7 +195,7 @@ class TVProcessor:
|
||||
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_dir,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata
|
||||
@@ -226,12 +227,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
|
||||
@@ -264,7 +270,7 @@ class TVProcessor:
|
||||
|
||||
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)
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
episode_files = disk_episodes[(season, episode)]
|
||||
|
||||
nfo_found = False
|
||||
@@ -324,6 +330,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}")
|
||||
@@ -372,6 +384,131 @@ 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: Check NFO files first for missing dateadded elements"""
|
||||
_log("INFO", f"🔍 NFO-FIRST MODE: Checking {len(disk_episodes)} episodes for missing dateadded in NFO files")
|
||||
episode_dates = {}
|
||||
episodes_needing_db_check = []
|
||||
episodes_needing_api_lookup = []
|
||||
|
||||
# STEP 1: Check each NFO file to determine if dateadded is missing
|
||||
_log("DEBUG", f"STEP 1 - Checking NFO files for missing dateadded elements")
|
||||
nfo_complete_count = 0
|
||||
|
||||
for (season, episode) in disk_episodes:
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
episode_files = disk_episodes[(season, episode)]
|
||||
|
||||
nfo_has_dateadded = False
|
||||
nfo_data = None
|
||||
|
||||
# Look for NFO file for this episode
|
||||
for episode_file in episode_files:
|
||||
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 and nfo_data.get('dateadded'):
|
||||
# NFO has dateadded - this episode is complete
|
||||
aired = nfo_data.get('aired')
|
||||
dateadded = nfo_data.get('dateadded')
|
||||
source = nfo_data.get('source', 'nfo_cache')
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
nfo_complete_count += 1
|
||||
nfo_has_dateadded = True
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO has dateadded={dateadded}, marked as complete")
|
||||
break
|
||||
|
||||
if not nfo_has_dateadded:
|
||||
# NFO missing or missing dateadded - needs further processing
|
||||
if nfo_data:
|
||||
# NFO exists but missing dateadded
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO exists but missing dateadded - needs DB/API lookup")
|
||||
else:
|
||||
# No NFO file found
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: No NFO file found - needs DB/API lookup")
|
||||
episodes_needing_db_check.append((season, episode))
|
||||
|
||||
_log("INFO", f"NFO files with dateadded: {nfo_complete_count}/{len(disk_episodes)} episodes. Need DB check: {len(episodes_needing_db_check)}")
|
||||
|
||||
# STEP 2: For episodes missing dateadded in NFO, check database
|
||||
if episodes_needing_db_check:
|
||||
_log("DEBUG", f"STEP 2 - Checking database for {len(episodes_needing_db_check)} episodes missing dateadded")
|
||||
db_hits = 0
|
||||
|
||||
for (season, episode) in episodes_needing_db_check:
|
||||
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 and will add to NFO later
|
||||
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} - will add to NFO")
|
||||
else:
|
||||
# Not in database or incomplete - needs API lookup
|
||||
episodes_needing_api_lookup.append((season, episode))
|
||||
|
||||
_log("INFO", f"Database hits: {db_hits}/{len(episodes_needing_db_check)} episodes. Need API lookup: {len(episodes_needing_api_lookup)}")
|
||||
|
||||
# STEP 3: For episodes still missing dateadded, query Sonarr
|
||||
if episodes_needing_api_lookup:
|
||||
_log("DEBUG", f"STEP 3 - Querying Sonarr for {len(episodes_needing_api_lookup)} episodes missing from NFO and 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"🔍 NFO-FIRST 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:
|
||||
@@ -439,6 +576,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'):
|
||||
@@ -645,7 +787,7 @@ 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)
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
|
||||
# Prepare NFO creation data
|
||||
if config.manage_nfo:
|
||||
@@ -778,7 +920,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
|
||||
@@ -797,7 +939,7 @@ 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
|
||||
@@ -825,20 +967,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")
|
||||
|
||||
@@ -942,17 +1084,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
|
||||
@@ -963,8 +1134,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:
|
||||
@@ -973,9 +1144,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
|
||||
@@ -1045,7 +1215,7 @@ class TVProcessor:
|
||||
# Create NFO if needed
|
||||
nfo_success = True
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
nfo_success = await self.async_nfo_manager.async_create_episode_nfo(
|
||||
season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata
|
||||
)
|
||||
|
||||
@@ -371,21 +371,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
|
||||
+23
-6
@@ -5,18 +5,19 @@ 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
|
||||
# Import existing configuration (keep using core config for simplicity)
|
||||
from config.settings import config
|
||||
|
||||
# Import existing database and components
|
||||
# Import existing database and components
|
||||
from core.database import NFOGuardDatabase
|
||||
|
||||
# Import web routes from existing system
|
||||
# Import web routes from existing system (now includes DELETE route)
|
||||
from api.web_routes import register_web_routes
|
||||
|
||||
# Import authentication system
|
||||
@@ -82,6 +83,22 @@ def setup_static_files(app: FastAPI) -> None:
|
||||
# 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.8.0-web"
|
||||
}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=503, detail=f"Health check failed: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
@@ -116,7 +133,7 @@ def main():
|
||||
else:
|
||||
print("🌐 Web authentication disabled")
|
||||
|
||||
# Create dependencies for dependency injection (simplified for web-only)
|
||||
# Create dependencies for dependency injection
|
||||
dependencies = {
|
||||
"db": db,
|
||||
"config": config,
|
||||
@@ -129,15 +146,15 @@ def main():
|
||||
|
||||
# Add authentication middleware if enabled (BEFORE routes)
|
||||
if auth_enabled:
|
||||
# Pass the session manager to middleware so it uses the same instance
|
||||
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
|
||||
# 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}")
|
||||
|
||||
|
||||
+58
-1
@@ -842,4 +842,61 @@ body {
|
||||
.d-block { display: block; }
|
||||
.d-flex { display: flex; }
|
||||
.justify-content-between { justify-content: space-between; }
|
||||
.align-items-center { align-items: center; }
|
||||
.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;
|
||||
}
|
||||
+47
-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>
|
||||
@@ -404,6 +449,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>
|
||||
+313
-6
@@ -76,6 +76,7 @@ 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);
|
||||
}
|
||||
|
||||
// API calls
|
||||
@@ -503,10 +504,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 +541,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 +682,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 +716,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 +935,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 +1122,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 +1311,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 +1326,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 +1369,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 +1451,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;
|
||||
@@ -1434,4 +1639,106 @@ async function logout() {
|
||||
console.error('Logout failed:', error);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
+68
-8
@@ -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,15 +93,32 @@ def _setup_file_logging():
|
||||
logger = logging.getLogger("NFOGuard")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
|
||||
)
|
||||
# Clear any existing handlers to avoid duplicates
|
||||
logger.handlers.clear()
|
||||
|
||||
try:
|
||||
file_handler = SafeRotatingFileHandler(
|
||||
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
|
||||
)
|
||||
|
||||
formatter = TimezoneAwareFormatter(
|
||||
'[%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
except Exception as e:
|
||||
# If RotatingFileHandler fails, fall back to regular FileHandler
|
||||
print(f"Warning: Could not setup rotating file handler ({e}), using regular file handler")
|
||||
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)
|
||||
except Exception as e2:
|
||||
print(f"Error: Could not setup any file logging: {e2}")
|
||||
|
||||
formatter = TimezoneAwareFormatter(
|
||||
'[%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
return logger
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -144,7 +145,13 @@ class WebhookBatcher:
|
||||
|
||||
if processing_mode == 'targeted' and episodes_data:
|
||||
_log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes")
|
||||
self.tv_processor.process_webhook_episodes(path_obj, episodes_data)
|
||||
|
||||
# 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)")
|
||||
self.tv_processor.process_series(path_obj)
|
||||
@@ -164,6 +171,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:
|
||||
|
||||
Reference in New Issue
Block a user