Compare commits

..

129 Commits

Author SHA1 Message Date
sbcrumb e883d896b9 Merge pull request 'dev' (#66) from dev into main
Local Docker Build (Main) / build (push) Successful in 18s
Local Docker Build (Main) / deploy (push) Successful in 0s
Reviewed-on: #66
2025-11-02 12:41:58 -05:00
sbcrumb 041caf0d0d update to deletes
Local Docker Build (Dev) / build-dev (push) Successful in 6s
Local Docker Build (Main) / build (pull_request) Successful in 7s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-29 13:33:45 -04:00
sbcrumb 7fffaac247 bulk delete
Local Docker Build (Dev) / build-dev (push) Successful in 18s
2025-10-29 10:06:35 -04:00
sbcrumb 6e491fb091 update: tasks
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-29 09:13:18 -04:00
sbcrumb 4b71b0e7ee update: remove autofix
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-29 08:52:31 -04:00
sbcrumb 7bab45a9aa autfix update
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 20:09:55 -04:00
sbcrumb 7f0ad27009 update autfix
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 20:07:08 -04:00
sbcrumb 614dab632a update fix .nfo
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 20:04:04 -04:00
sbcrumb 43449ca7a3 emby lookup fix
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 20:00:28 -04:00
sbcrumb db0a43dd18 db lookup routes
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 19:55:10 -04:00
sbcrumb 6711e926a6 update : tasks sch
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-10-28 18:26:28 -04:00
sbcrumb f11839e005 feat: add cron for scans
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 17:33:08 -04:00
sbcrumb 6d2435a037 update scan
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 17:21:29 -04:00
sbcrumb b1598114d9 update: scan to check .nfo files first
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 17:16:15 -04:00
sbcrumb b709a377c8 update: to handle specials
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 17:00:56 -04:00
sbcrumb babe5ac0df udpates to detect specials
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-10-28 16:51:21 -04:00
sbcrumb a4c07eaa19 update: fix scans
Local Docker Build (Dev) / build-dev (push) Successful in 24s
2025-10-28 16:27:52 -04:00
sbcrumb a1a04b2711 web: scan updates
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 15:38:27 -04:00
sbcrumb 1dbad2f50b fix: manual scans
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-10-28 15:29:11 -04:00
sbcrumb afdbe1f755 web: update web interface
Local Docker Build (Dev) / build-dev (push) Successful in 8s
2025-10-28 15:07:03 -04:00
sbcrumb 1916dc2646 nfo: updates to matching
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 14:56:22 -04:00
sbcrumb 36ab22ee3d db: updates
Local Docker Build (Main) / build (push) Successful in 11s
Local Docker Build (Main) / deploy (push) Successful in 0s
Local Docker Build (Dev) / build-dev (push) Successful in 34s
Local Docker Build (Main) / build (pull_request) Successful in 10s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-28 12:35:53 -04:00
sbcrumb c6a8cce509 improvments: updates to scans etc 2025-10-28 12:35:53 -04:00
sbcrumb 2eda166328 updates 2025-10-28 12:35:53 -04:00
sbcrumb f31430256e debug for movies missing .nfo 2025-10-28 12:35:53 -04:00
sbcrumb ed3d7e903c nfo: improvments 2025-10-28 12:35:53 -04:00
sbcrumb 0b9f450b1d imdb: missing imdge improvments 2025-10-28 12:35:53 -04:00
sbcrumb 10a9677804 Merge pull request 'dev' (#62) from dev into main
Local Docker Build (Main) / build (push) Successful in 5s
Local Docker Build (Main) / deploy (push) Successful in 0s
Reviewed-on: #62
2025-10-26 15:45:14 -04:00
sbcrumb d6488a99bd nfo: improvments to processing
Local Docker Build (Dev) / build-dev (push) Successful in 5s
Local Docker Build (Main) / build (pull_request) Successful in 33s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-26 15:42:05 -04:00
sbcrumb d1480e1b96 improvment: nfo verifcations
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 15:30:11 -04:00
sbcrumb c6b6b0ef64 web: table update and nfo scan update
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 15:23:35 -04:00
sbcrumb 9fbb9e37ab web: missing nfo serach updates
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 15:15:29 -04:00
sbcrumb d933ac7585 update: update the nfo scan
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 13:42:50 -04:00
sbcrumb d50ac88237 web: change to a grep for missing nfo
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 13:36:59 -04:00
sbcrumb 9c8da4d63b updates: debugs for 35 without date
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 13:29:03 -04:00
sbcrumb 14ae2a205c updates: debugs
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 13:22:11 -04:00
sbcrumb a68f7ee4bb updates: web scan
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 13:16:05 -04:00
sbcrumb 63ab35b24c updatre: web scans
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 13:09:43 -04:00
sbcrumb 6b9e0b43f8 web: nfo refresh debug
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 13:04:55 -04:00
sbcrumb d4f4bcf722 web: debugging
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 12:55:00 -04:00
sbcrumb 4cb9250c3a web: debug
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 12:49:36 -04:00
sbcrumb 24f05079f3 web: change url from nfoguard-core to nfoguard
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 12:46:22 -04:00
sbcrumb 483bcc9d25 web: more nfo scan debugs
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 12:43:12 -04:00
sbcrumb 0b27fc1c65 web: debug
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 12:40:20 -04:00
sbcrumb 07cdbf1a29 web: web debugs
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 12:37:49 -04:00
sbcrumb eba70ec045 web: more debugsy
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 12:35:03 -04:00
sbcrumb f38ca539d6 web: switch to aiohttp
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 12:32:57 -04:00
sbcrumb 254c9c9c5f web: more debug
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 12:30:29 -04:00
sbcrumb 319838d305 web: debug nfo fix
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 12:14:06 -04:00
sbcrumb 4d7d9752fc web: debug with dates
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 12:11:11 -04:00
sbcrumb 5d3754b401 web: more date add debugs
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 11:54:45 -04:00
sbcrumb ee403f9ac7 web: debugs
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 11:47:47 -04:00
sbcrumb 4516a80208 web: debugging
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 11:45:46 -04:00
sbcrumb eb66af837f web: debug
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 11:39:57 -04:00
sbcrumb 1d837102e9 web: manual update debug
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 11:32:59 -04:00
sbcrumb cb46b56243 web: fix manual date fixes
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 11:29:35 -04:00
sbcrumb 2a972f0e14 web: fix web routes
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 11:25:59 -04:00
sbcrumb 18d1b45609 web: new scans to interface
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-26 11:10:28 -04:00
sbcrumb f46f1df7cb web: fix missing .nfo dates and manual date changes
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-26 11:04:55 -04:00
sbcrumb cf6ee5d6d8 web: debugs
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 15:58:01 -04:00
sbcrumb ef899a05c7 web: debugs
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 15:54:08 -04:00
sbcrumb 8c850ab38c web: debug
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 15:51:34 -04:00
sbcrumb 059d3bae3f web: debug
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-25 15:47:43 -04:00
sbcrumb 98da1a07f0 web: debuging
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-25 15:43:53 -04:00
sbcrumb cb62ddb187 web: fixes/debug
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-25 15:38:06 -04:00
sbcrumb 4713bf5ed1 web: delete debug
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-25 15:36:03 -04:00
sbcrumb 228053a573 web: more web debugs
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-25 15:15:50 -04:00
sbcrumb 6d64812c94 WEB: INTERFACE AND AUTH FAILS TO DELETE
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 15:11:25 -04:00
sbcrumb b3adf45f7b updates: to web delete
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 15:06:19 -04:00
sbcrumb 0666a4d9a9 fix: web interface not deleting lines
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 15:01:46 -04:00
sbcrumb 2f4d4638ef web: not deleteing
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 14:52:14 -04:00
sbcrumb ff24e0c431 web: unable to delete in webinterface
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 14:47:28 -04:00
sbcrumb def8d293b9 fix: add batch delay
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 13:11:26 -04:00
sbcrumb 952319b061 fixes: dates sonarr
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 13:07:57 -04:00
sbcrumb 4c4a805c5d fix: rename and airdate fixes
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-25 12:30:44 -04:00
sbcrumb 2811d30352 fix: not using airdate when rename is first event in sonarr
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 12:27:52 -04:00
sbcrumb 47311864cf disable: remove season.nfo and tvshow.nfo from being created
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 12:22:12 -04:00
sbcrumb ab48d485b8 fix: docker hangs
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 12:09:23 -04:00
sbcrumb 1314ee3e70 debug: sonarr webhook issues
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-25 12:02:15 -04:00
sbcrumb 7b62d73753 feat: update maintarr payload
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-24 16:35:11 -04:00
sbcrumb e054c945ff feat: add support for Maintainarr webhook
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-24 16:30:08 -04:00
sbcrumb f6c5ccd2b6 web: heatlh check
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-24 13:26:58 -04:00
sbcrumb 7fb07fcddf fix: nfoguard data at bottom of file
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-23 10:53:35 -04:00
sbcrumb 6946ce2082 fix: nfoguard info being put at top
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-23 09:56:35 -04:00
sbcrumb 017681cd55 Merge pull request 'dev' (#60) from dev into main
Local Docker Build (Main) / build (push) Successful in 5s
Local Docker Build (Main) / deploy (push) Successful in 0s
Local Docker Build (Dev) / build-dev (push) Successful in 5s
Local Docker Build (Main) / build (pull_request) Successful in 4s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
Reviewed-on: #60
2025-10-22 21:27:03 -04:00
sbcrumb a37d552303 version bump 2025-10-22 21:27:03 -04:00
sbcrumb 864a82cbc5 web: updates 2025-10-22 21:27:03 -04:00
sbcrumb 68baa1766c web: status dash updates 2025-10-22 21:27:03 -04:00
sbcrumb 413c8ad6f6 web: status bug fix 2025-10-22 21:27:03 -04:00
sbcrumb 1ceccfe31d logs: update to status 2025-10-22 21:27:03 -04:00
sbcrumb ec36ce7b0e fix: dashboard status 2025-10-22 21:27:03 -04:00
sbcrumb 2583a668bc fix: remove logo 2025-10-22 21:27:03 -04:00
sbcrumb 6d9e057a91 web: fix smart scan 2025-10-22 21:27:03 -04:00
sbcrumb 18ae174276 fix: scan type 2025-10-22 21:27:03 -04:00
sbcrumb 4752b424f5 web: add httpx 2025-10-22 21:27:03 -04:00
sbcrumb cebc31e24c web: fix to run from web 2025-10-22 21:27:03 -04:00
sbcrumb 5c0ba04832 auth: fix auth issues 2025-10-22 21:27:03 -04:00
sbcrumb 4ba8db48f7 fix: middleware 2025-10-22 21:27:03 -04:00
sbcrumb f67663a338 jave: updates 2025-10-22 21:27:03 -04:00
sbcrumb 543c5f44e7 web: update java issues 2025-10-22 21:27:03 -04:00
sbcrumb fc950007cc web: remove bulk update 2025-10-22 21:27:03 -04:00
sbcrumb 59dbf93f70 web: update to interface 2025-10-22 21:27:03 -04:00
sbcrumb 337bb4e049 log updates 2025-10-22 21:27:03 -04:00
sbcrumb 4a2d410921 push logo 2025-10-22 21:27:03 -04:00
sbcrumb 43f4851f40 update 2025-10-22 21:27:03 -04:00
sbcrumb df15ce141f iupdate 2025-10-22 21:27:03 -04:00
sbcrumb ac30a91879 web: we udpates 2025-10-22 21:27:03 -04:00
sbcrumb 835d313b6c web: Logo new update 2025-10-22 21:27:03 -04:00
sbcrumb 24dce6c970 web: logo updates 2025-10-22 21:27:03 -04:00
sbcrumb e4ef60847a web: fixes for logo 2025-10-22 21:27:03 -04:00
sbcrumb 3ac5aa3121 fix: missing logo 2025-10-22 21:27:03 -04:00
sbcrumb 66a80992b5 fixes: web and build 2025-10-22 21:27:03 -04:00
sbcrumb 78813b9189 fixes: web 2025-10-22 21:27:03 -04:00
sbcrumb c61c35ebad fix: env files and logo 2025-10-22 21:27:03 -04:00
sbcrumb 80a09cf66e web: dynamic ports redirect errors 2025-10-22 21:27:03 -04:00
sbcrumb df17305160 web: remove old one 2025-10-22 21:27:03 -04:00
sbcrumb b55b786787 fix: web routes 2025-10-22 21:27:03 -04:00
sbcrumb 470d77c28f web: fix issues with entrypoint 2025-10-22 21:27:03 -04:00
sbcrumb 916f237e68 web: fixed 2025-10-22 21:27:03 -04:00
sbcrumb c1ab3cd874 fix: docker build 2025-10-22 21:27:03 -04:00
sbcrumb 379f7a6e44 fix: web interface 2025-10-22 21:27:03 -04:00
sbcrumb 50fe5b272f fix: resolve web container import paths and ThreadPoolExecutor compatibility 2025-10-22 21:27:03 -04:00
sbcrumb 46465d1749 fix: shutdown hangs 2025-10-22 21:27:03 -04:00
sbcrumb ea69ad9202 feat: add new container for sperate web interface 2025-10-22 21:27:03 -04:00
sbcrumb decad07a0d feat: seperate webintreface 2025-10-22 21:27:03 -04:00
sbcrumb c84d08b266 feat: seperate webinterface from main app 2025-10-22 21:27:03 -04:00
sbcrumb ca796bdb58 Merge branch 'dev'
Local Docker Build (Main) / build (push) Successful in 4s
Local Docker Build (Main) / deploy (push) Successful in 0s
2025-10-22 08:37:51 -04:00
sbcrumb e6bb5293c4 Merge pull request 'dev' (#57) from dev into main
Local Docker Build (Main) / build (push) Successful in 4s
Local Docker Build (Main) / deploy (push) Successful in 0s
Reviewed-on: #57
2025-10-20 14:17:48 -04:00
sbcrumb af779f9c59 fix: turn off hard coded debug 2025-10-20 14:17:48 -04:00
30 changed files with 5685 additions and 914 deletions
+5
View File
@@ -138,6 +138,11 @@ BATCH_DELAY=5.0
# Maximum concurrent series processing # Maximum concurrent series processing
MAX_CONCURRENT_SERIES=3 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 # API timeout in seconds
TIMEOUT_SECONDS=45 TIMEOUT_SECONDS=45
+207
View File
@@ -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
+74
View File
@@ -31,6 +31,18 @@ class RadarrWebhook(BaseModel):
extra = "allow" 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): class HealthResponse(BaseModel):
"""Health check response model""" """Health check response model"""
status: str status: str
@@ -108,3 +120,65 @@ class EpisodeResponse(BaseModel):
series_path: str series_path: str
season_name: 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]
+1523 -30
View File
File diff suppressed because it is too large Load Diff
+859 -46
View File
@@ -93,11 +93,11 @@ async def get_movies_list(dependencies: dict,
params.append(source_filter) params.append(source_filter)
if search: 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}%"]) params.extend([f"%{search}%", f"%{search}%"])
if imdb_search: if imdb_search:
where_conditions.append("imdb_id LIKE %s") where_conditions.append("imdb_id ILIKE %s")
params.append(f"%{imdb_search}%") params.append(f"%{imdb_search}%")
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" 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 = [] having_conditions = []
if search: 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}%"]) params.extend([f"%{search}%", f"%{search}%"])
if imdb_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}%") params.append(f"%{imdb_search}%")
if source_filter: 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""" """Update dateadded for a specific episode"""
db = dependencies["db"] db = dependencies["db"]
print(f"🔍 DEBUG: update_episode_date called with dateadded={repr(dateadded)}, source={repr(source)}")
# Get existing episode # Get existing episode
episode_data = db.get_episode_date(imdb_id, season, episode) episode_data = db.get_episode_date(imdb_id, season, episode)
if not episode_data: if not episode_data:
raise HTTPException(status_code=404, detail="Episode not found") 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 # Update the date
db.upsert_episode_date( db.upsert_episode_date(
imdb_id=imdb_id, 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) has_video_file=episode_data.get('has_video_file', False)
) )
# Create/update NFO file with new data # Trigger NFO file update via core container
nfo_manager = dependencies["nfo_manager"]
config = dependencies["config"]
if config.manage_nfo:
try: try:
# Find the series directory based on IMDb ID import urllib.request
series_path = None import urllib.parse
for tv_path in config.tv_paths: import json
for series_dir in Path(tv_path).iterdir(): import os
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: # Get core container connection details
season_dir = series_path / config.tv_season_dir_format.format(season=season) core_host = os.environ.get("CORE_API_HOST", "nfoguard")
if season_dir.exists(): core_port = os.environ.get("CORE_API_PORT", "8080")
nfo_manager.create_episode_nfo(
season_dir=season_dir, # Call core container to update NFO file
season_num=season, nfo_update_url = f"http://{core_host}:{core_port}/api/episodes/{imdb_id}/{season}/{episode}/update-nfo"
episode_num=episode,
aired=episode_data.get('aired'), # Create request data
dateadded=dateadded, request_data = {
source=source, "dateadded": dateadded,
lock_metadata=config.lock_metadata "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'
) )
print(f"✅ Updated NFO file for {imdb_id} S{season:02d}E{episode:02d}")
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: else:
print(f"⚠️ Season directory not found: {season_dir}") print(f"✅ Database updated for {imdb_id} S{season:02d}E{episode:02d}")
else: print(f"⚠️ NFO file update failed (HTTP {response.status})")
print(f"⚠️ Series directory not found for {imdb_id}")
except Exception as e: except Exception as e:
print(f"❌ Error updating NFO file for {imdb_id} S{season:02d}E{episode:02d}: {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 # Add to processing history
db.add_processing_history( db.add_processing_history(
@@ -1237,15 +1255,40 @@ def register_web_routes(app, dependencies):
@app.post("/api/movies/{imdb_id}/update-date") @app.post("/api/movies/{imdb_id}/update-date")
async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"): 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}") @app.put("/api/movies/{imdb_id}")
async def api_update_movie(imdb_id: str, dateadded: str = None, source: str = "manual"): 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") @app.get("/api/movies/{imdb_id}/date-options")
async def api_movie_date_options(imdb_id: str): 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 # TV series endpoints
@app.get("/api/series") @app.get("/api/series")
@@ -1269,16 +1312,50 @@ def register_web_routes(app, dependencies):
@app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date") @app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date")
async def api_update_episode_date(imdb_id: str, season: int, episode: int, async def api_update_episode_date(imdb_id: str, season: int, episode: int,
dateadded: str = None, source: str = "manual"): 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}") @app.put("/api/episodes/{imdb_id}/{season}/{episode}")
async def api_update_episode(imdb_id: str, season: int, episode: int, async def api_update_episode(imdb_id: str, season: int, episode: int, request: Request):
dateadded: str = None, source: str = "manual"): try:
return {"error": "Updates not available in web container. Use core container on port 8085."} # 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") @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options")
async def api_episode_date_options(imdb_id: str, season: int, episode: int): 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 # Bulk operations
@app.post("/api/bulk/update-source") @app.post("/api/bulk/update-source")
@@ -1335,7 +1412,7 @@ def register_web_routes(app, dependencies):
import socket import socket
# Get core container URL # 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") core_port = os.environ.get("CORE_API_PORT", "8080")
# Forward query parameters from the request # 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) # Create request with timeout (no body needed for query parameters)
req = urllib.request.Request(core_url, method='POST') req = urllib.request.Request(core_url, method='POST')
# Make request with timeout # Make request with timeout (increased for manual scans which can take several minutes)
with urllib.request.urlopen(req, timeout=30) as response: with urllib.request.urlopen(req, timeout=300) as response:
response_data = response.read().decode('utf-8') response_data = response.read().decode('utf-8')
return json.loads(response_data) return json.loads(response_data)
@@ -1383,7 +1460,7 @@ def register_web_routes(app, dependencies):
import socket import socket
# Get core container connection details # 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") core_port = os.environ.get("CORE_API_PORT", "8080")
try: try:
@@ -1407,3 +1484,739 @@ def register_web_routes(app, dependencies):
return {"scanning": False, "message": "Invalid response from core container"} return {"scanning": False, "message": "Invalid response from core container"}
except Exception as e: 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)
}
+14 -6
View File
@@ -150,6 +150,10 @@ class SonarrClient:
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}") _log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
return None 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]]: def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
"""Get all episodes for a series""" """Get all episodes for a series"""
return self._get("/episode", {"seriesId": series_id}) or [] return self._get("/episode", {"seriesId": series_id}) or []
@@ -222,7 +226,7 @@ class SonarrClient:
import_date = earliest_import["date"] import_date = earliest_import["date"]
_log("INFO", f"Found import date: {import_date} for episode {episode_id}") _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: if rename_events:
earliest_rename = min(rename_events, key=lambda x: x["date"]) earliest_rename = min(rename_events, key=lambda x: x["date"])
rename_date = earliest_rename["date"] rename_date = earliest_rename["date"]
@@ -230,12 +234,16 @@ class SonarrClient:
try: try:
import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00")) import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00"))
rename_dt = datetime.fromisoformat(rename_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 import happened BEFORE rename, it's valid original import
if days_diff > 30: if import_dt <= rename_dt:
_log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date") _log("INFO", f"Import {import_date} happened before/during rename {rename_date} - using import date")
return rename_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: except Exception as e:
_log("DEBUG", f"Error comparing dates: {e}") _log("DEBUG", f"Error comparing dates: {e}")
+7
View File
@@ -61,6 +61,7 @@ class NFOGuardConfig:
# Batching and performance # Batching and performance
self.batch_delay = self._get_float_env("BATCH_DELAY", 5.0, 0.1, 300.0) 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.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 # Database
self.db_type = os.environ.get("DB_TYPE", "sqlite").lower() self.db_type = os.environ.get("DB_TYPE", "sqlite").lower()
@@ -172,6 +173,12 @@ class NFOGuardConfig:
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower() 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() 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: def _load_auth_settings(self) -> None:
"""Load web interface authentication settings""" """Load web interface authentication settings"""
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False) self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
+54 -4
View File
@@ -15,7 +15,8 @@ from utils.async_file_utils import (
async_file_exists, async_file_exists,
async_set_file_mtime, async_set_file_mtime,
async_batch_nfo_operations, async_batch_nfo_operations,
async_concurrent_episode_processing async_concurrent_episode_processing,
aiofiles
) )
from utils.nfo_patterns import ( from utils.nfo_patterns import (
create_basic_nfo_structure, create_basic_nfo_structure,
@@ -24,6 +25,7 @@ from utils.nfo_patterns import (
extract_imdb_id_from_text extract_imdb_id_from_text
) )
from utils.validation import validate_date_string from utils.validation import validate_date_string
from utils.file_utils import VIDEO_EXTENSIONS
class AsyncNFOManager: class AsyncNFOManager:
@@ -33,6 +35,54 @@ class AsyncNFOManager:
self.manager_brand = manager_brand self.manager_brand = manager_brand
self.debug = debug 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 def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
""" """
Async extract IMDb ID from directory path or filename Async extract IMDb ID from directory path or filename
@@ -184,7 +234,7 @@ class AsyncNFOManager:
lock_metadata: bool = True lock_metadata: bool = True
) -> bool: ) -> bool:
""" """
Async create episode NFO file Async create episode NFO file with proper video filename matching
Args: Args:
season_dir: Path to season directory season_dir: Path to season directory
@@ -199,8 +249,8 @@ class AsyncNFOManager:
True if successful, False otherwise True if successful, False otherwise
""" """
try: try:
nfo_filename = f"S{season:02d}E{episode:02d}.nfo" # Use proper video filename matching to prevent NFO concatenation
nfo_path = season_dir / nfo_filename nfo_path = await self._async_get_target_nfo_path(season_dir, season, episode)
# Prepare dates # Prepare dates
dates = {} dates = {}
+391 -1
View File
@@ -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 # 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_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_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_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_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): def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
"""Insert or update series record""" """Insert or update series record"""
with self.get_connection() as conn: with self.get_connection() as conn:
@@ -441,6 +509,29 @@ class NFOGuardDatabase:
return deleted_count > 0 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]: def delete_orphaned_movies(self) -> List[Dict]:
""" """
Find and delete movies that don't have corresponding video files on disk Find and delete movies that don't have corresponding video files on disk
@@ -560,11 +651,310 @@ class NFOGuardDatabase:
return deleted_series 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): def close(self):
"""Close all database connections""" """Close all database connections"""
if hasattr(self._local, 'connection'): if hasattr(self._local, 'connection'):
try: 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() self._local.connection.close()
delattr(self._local, 'connection') 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 pass # Connection may already be closed
+1 -1
View File
@@ -220,7 +220,7 @@ class EpisodeNFOManager:
lockdata_elem = ET.SubElement(episode_elem, "lockdata") lockdata_elem = ET.SubElement(episode_elem, "lockdata")
lockdata_elem.text = "true" 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} ") comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
episode_elem.append(comment) episode_elem.append(comment)
+148 -47
View File
@@ -50,6 +50,70 @@ class NFOManager:
return None 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]: def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
"""Extract IMDb ID from NFO file content""" """Extract IMDb ID from NFO file content"""
if not nfo_path.exists(): if not nfo_path.exists():
@@ -154,8 +218,10 @@ class NFOManager:
aired_elem = root.find('.//aired') # For TV episodes aired_elem = root.find('.//aired') # For TV episodes
lockdata_elem = root.find('.//lockdata') lockdata_elem = root.find('.//lockdata')
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded) # Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
if lockdata_elem is not None and lockdata_elem.text == "true": # 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 # Extract original source from NFOGuard comment, default to nfo_file_existing
source = "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')}") 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 return result
except (ET.ParseError, Exception) as e: except ET.ParseError as e:
print(f"⚠️ Error parsing NFO for NFOGuard data: {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 pass
return None 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]]: 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""" """Extract NFOGuard-managed dates from existing episode NFO file"""
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo" nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
@@ -206,8 +324,10 @@ class NFOManager:
aired_elem = root.find('.//aired') aired_elem = root.find('.//aired')
lockdata_elem = root.find('.//lockdata') lockdata_elem = root.find('.//lockdata')
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded) # Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
if lockdata_elem is not None and lockdata_elem.text == "true": # 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 # Extract original source from NFOGuard comment, default to episode_nfo_existing
source = "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 # This ensures they appear as a group at the bottom of the file
nfoguard_elements = [] 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 # Add IMDb uniqueid
uniqueid = ET.Element("uniqueid", type="imdb", default="true") uniqueid = ET.Element("uniqueid", type="imdb", default="true")
uniqueid.text = imdb_id uniqueid.text = imdb_id
@@ -384,6 +500,10 @@ class NFOManager:
lockdata.text = "true" lockdata.text = "true"
nfoguard_elements.append(lockdata) 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 # Now append all NFOGuard elements to the movie in one batch
# This ensures they appear as a contiguous block at the bottom # This ensures they appear as a contiguous block at the bottom
for elem in nfoguard_elements: for elem in nfoguard_elements:
@@ -395,7 +515,7 @@ class NFOManager:
tree = ET.ElementTree(movie) tree = ET.ElementTree(movie)
ET.indent(tree, space=" ", level=0) 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: with open(nfo_path, 'w', encoding='utf-8') as f:
f.write('<?xml version="1.0" encoding="utf-8"?>\n') f.write('<?xml version="1.0" encoding="utf-8"?>\n')
tree.write(f, encoding='unicode', xml_declaration=False) tree.write(f, encoding='unicode', xml_declaration=False)
@@ -455,22 +575,14 @@ class NFOManager:
lockdata = ET.SubElement(tvshow, "lockdata") lockdata = ET.SubElement(tvshow, "lockdata")
lockdata.text = "true" lockdata.text = "true"
# Add NFOGuard comment at the beginning # Add NFOGuard comment at the bottom
comment_text = f" Created by {self.manager_brand} " comment = ET.Comment(f" Created by {self.manager_brand} ")
tvshow.append(comment)
# Write file with proper formatting # Write file with proper formatting
tree = ET.ElementTree(tvshow) tree = ET.ElementTree(tvshow)
ET.indent(tree, space=" ", level=0) ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
# Write to string first to add comment
xml_str = ET.tostring(tvshow, encoding='unicode')
# Add XML declaration and comment
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
# Write to file
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write(full_xml)
print(f"✅ Successfully created/updated TV show NFO: {nfo_path}") print(f"✅ Successfully created/updated TV show NFO: {nfo_path}")
print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else "")) 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 = ET.SubElement(season, "lockdata")
lockdata.text = "true" lockdata.text = "true"
# Add NFOGuard comment at the beginning # Add NFOGuard comment at the bottom
comment_text = f" Created by {self.manager_brand} " comment = ET.Comment(f" Created by {self.manager_brand} ")
season.append(comment)
# Write file with proper formatting # Write file with proper formatting
tree = ET.ElementTree(season) tree = ET.ElementTree(season)
ET.indent(tree, space=" ", level=0) ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
# Write to string first to add comment
xml_str = ET.tostring(season, encoding='unicode')
# Add XML declaration and comment
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
# Write to file
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write(full_xml)
print(f"✅ Successfully created/updated season NFO: {nfo_path}") print(f"✅ Successfully created/updated season NFO: {nfo_path}")
print(f" Season: {season_number}") print(f" Season: {season_number}")
@@ -707,32 +811,29 @@ class NFOManager:
aired_str = str(aired) aired_str = str(aired)
aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str 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: if dateadded:
dateadded_elem = ET.SubElement(episode, "dateadded") dateadded_elem = ET.SubElement(episode, "dateadded")
# Convert datetime objects to strings # Convert datetime objects to strings
dateadded_elem.text = str(dateadded) 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 # Add lockdata at the very end
if lock_metadata: if lock_metadata:
lockdata = ET.SubElement(episode, "lockdata") lockdata = ET.SubElement(episode, "lockdata")
lockdata.text = "true" lockdata.text = "true"
# Add NFOGuard comment at the beginning # Add NFOGuard comment at the bottom
comment_text = f" Created by {self.manager_brand} - Source: {source} " comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
episode.append(comment)
# Write file with proper formatting # Write file with proper formatting
tree = ET.ElementTree(episode) tree = ET.ElementTree(episode)
ET.indent(tree, space=" ", level=0) ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
# Write to string first to add comment
xml_str = ET.tostring(episode, encoding='unicode')
# Add XML declaration and comment
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
# Write to file
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write(full_xml)
print(f"✅ Successfully created/updated episode NFO: {nfo_path}") print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}") print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
+20 -1
View File
@@ -88,7 +88,7 @@ services:
nfoguard: nfoguard:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081/"] test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -123,3 +123,22 @@ networks:
# - Webhooks remain responsive during long scans # - Webhooks remain responsive during long scans
# - Independent scaling and resource allocation # - 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
+12
View File
@@ -146,6 +146,18 @@ def signal_handler(signum, frame):
_log("WARNING", f"Error closing database: {e}") _log("WARNING", f"Error closing database: {e}")
_log("INFO", "Graceful shutdown complete") _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) sys.exit(0)
+225 -302
View File
@@ -96,11 +96,11 @@ async def get_movies_list(dependencies: dict,
params.append(source_filter) params.append(source_filter)
if search: 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}%"]) params.extend([f"%{search}%", f"%{search}%"])
if imdb_search: if imdb_search:
where_conditions.append("imdb_id LIKE %s") where_conditions.append("imdb_id ILIKE %s")
params.append(f"%{imdb_search}%") params.append(f"%{imdb_search}%")
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" 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 = [] having_conditions = []
if search: 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}%"]) params.extend([f"%{search}%", f"%{search}%"])
if imdb_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}%") params.append(f"%{imdb_search}%")
if source_filter: if source_filter:
@@ -600,44 +600,11 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi
has_video_file=episode_data.get('has_video_file', False) has_video_file=episode_data.get('has_video_file', False)
) )
# Create/update NFO file with new data # NFO file management is handled by the core container
nfo_manager = dependencies["nfo_manager"] # Web interface only updates database entries
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}")
# Add to processing history # Add to processing history
try:
db.add_processing_history( db.add_processing_history(
imdb_id=imdb_id, imdb_id=imdb_id,
media_type="episode", media_type="episode",
@@ -650,6 +617,9 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi
"dateadded": dateadded "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}"} 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): 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}") print(f"🔍 DEBUG: get_episode_date_options called with imdb_id={imdb_id}, season={season}, episode={episode}")
db = dependencies["db"] 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']}" "description": f"Use original air date: {episode_data['aired']}"
}) })
# Option 3: Active lookup from external sources # Option 3: Manual entry
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
options.append({ options.append({
"type": "manual", "type": "manual",
"label": "Manual Entry", "label": "Manual Entry",
@@ -1217,3 +927,216 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int
"current_data": episode_data, "current_data": episode_data,
"options": options "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")
+147
View File
@@ -225,6 +225,153 @@ class WebDatabase:
""" """
return self.execute_query(query) 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): def close(self):
"""Close database connection""" """Close database connection"""
if self.connection: if self.connection:
+58 -45
View File
@@ -49,32 +49,10 @@ body {
text-align: center; 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 { .header-content h1 {
font-size: 2rem; font-size: 2rem;
font-weight: 300; font-weight: 300;
margin-bottom: 0.25rem; margin-bottom: 0.5rem;
} }
.header-content h1 i { .header-content h1 i {
@@ -84,28 +62,6 @@ body {
.header-content p { .header-content p {
opacity: 0.9; opacity: 0.9;
font-size: 1rem; 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 */ /* Authentication Status */
@@ -887,3 +843,60 @@ body {
.d-flex { display: flex; } .d-flex { display: flex; }
.justify-content-between { justify-content: space-between; } .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;
}
+257 -60
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NFOGuard - Database Management</title> <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"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>
<body> <body>
@@ -12,13 +12,9 @@
<!-- Header --> <!-- Header -->
<header class="app-header"> <header class="app-header">
<div class="header-content"> <div class="header-content">
<div class="header-logo"> <h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
<div class="header-text">
<h1>NFOGuard</h1>
<p>Database Management & Reporting</p> <p>Database Management & Reporting</p>
</div> </div>
</div>
</div>
<div class="auth-status" id="auth-status" style="display: none;"> <div class="auth-status" id="auth-status" style="display: none;">
<span class="auth-user"> <span class="auth-user">
<i class="fas fa-user"></i> <span id="auth-username">Loading...</span> <i class="fas fa-user"></i> <span id="auth-username">Loading...</span>
@@ -40,6 +36,9 @@
<button class="nav-tab" data-tab="reports"> <button class="nav-tab" data-tab="reports">
<i class="fas fa-chart-bar"></i> Reports <i class="fas fa-chart-bar"></i> Reports
</button> </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"> <button class="nav-tab" data-tab="tools">
<i class="fas fa-tools"></i> Tools <i class="fas fa-tools"></i> Tools
</button> </button>
@@ -50,24 +49,6 @@
<main class="main-content"> <main class="main-content">
<!-- Dashboard Tab --> <!-- Dashboard Tab -->
<div class="tab-content active" id="dashboard"> <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="dashboard-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon movies"> <div class="stat-icon movies">
@@ -309,37 +290,114 @@
</div> </div>
</div> </div>
<!-- Scheduled Scans Tab -->
<div class="tab-content" id="scheduled-scans">
<div class="content-header">
<h2><i class="fas fa-clock"></i> Scheduled Scans</h2>
<button class="btn btn-primary" id="add-schedule-btn">
<i class="fas fa-plus"></i> Add Schedule
</button>
</div>
<!-- Active Schedules Section -->
<div class="section-card">
<h3><i class="fas fa-list"></i> Active Schedules</h3>
<div class="table-container">
<table class="data-table" id="schedules-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Mode</th>
<th>Schedule</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="schedules-table-body">
<!-- Schedules will be loaded here -->
</tbody>
</table>
</div>
</div>
<!-- Execution History Section -->
<div class="section-card">
<h3><i class="fas fa-history"></i> Recent Executions</h3>
<div class="table-container">
<table class="data-table" id="executions-table">
<thead>
<tr>
<th>Schedule</th>
<th>Started</th>
<th>Duration</th>
<th>Status</th>
<th>Items Processed</th>
<th>Items Skipped</th>
<th>Items Failed</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="executions-table-body">
<!-- Executions will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Tools Tab --> <!-- Tools Tab -->
<div class="tab-content" id="tools"> <div class="tab-content" id="tools">
<div class="content-header"> <div class="content-header">
<h2><i class="fas fa-tools"></i> Database Tools</h2> <h2><i class="fas fa-tools"></i> Database Tools</h2>
</div> </div>
<!-- Scan Status Display --> <div class="tools-grid">
<div class="scan-status-card" id="scan-status" style="display: none;"> <div class="tool-card">
<div class="scan-status-header"> <h3><i class="fas fa-exchange-alt"></i> Bulk Source Update</h3>
<h3><i class="fas fa-sync fa-spin"></i> Scan in Progress</h3> <p>Change source for multiple items at once</p>
<div class="scan-progress"> <form id="bulk-update-form">
<div class="progress-bar"> <div class="form-group">
<div class="progress-fill" id="scan-progress"></div> <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>
<span class="progress-text" id="scan-progress-text">Initializing...</span> <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>
<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> </div>
<button type="submit" class="btn btn-warning">
<i class="fas fa-exchange-alt"></i> Update Sources
</button>
</form>
</div> </div>
<div class="tools-grid">
<!-- Manual Scan Tools -->
<div class="tool-card"> <div class="tool-card">
<h3><i class="fas fa-search"></i> Manual Scan</h3> <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"> <form id="manual-scan-form">
<div class="form-group"> <div class="form-group">
<label>Scan Type:</label> <label>Scan Type:</label>
<select id="scan-type" required> <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="movies">Movies Only</option>
<option value="tv">TV Series Only</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -347,38 +405,33 @@
<select id="scan-mode" required> <select id="scan-mode" required>
<option value="smart">Smart (Recommended)</option> <option value="smart">Smart (Recommended)</option>
<option value="full">Full Scan</option> <option value="full">Full Scan</option>
<option value="incomplete">Incomplete Items Only</option> <option value="incomplete">Incomplete Only</option>
</select> </select>
</div> </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 <i class="fas fa-play"></i> Start Scan
</button> </button>
</form> </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>
<div class="scan-info">
<div class="tool-card"> <span id="scan-current-operation">Initializing...</span>
<h3><i class="fas fa-folder"></i> Custom Directory Scan</h3> <span id="scan-progress-text">0%</span>
<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> </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> </div>
<button type="submit" class="btn-primary"> <button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
<i class="fas fa-search"></i> Scan Directory <i class="fas fa-times"></i> Hide Status
</button> </button>
</form>
</div> </div>
</div>
<div class="tool-card"> <div class="tool-card">
<h3><i class="fas fa-database"></i> Database Statistics</h3> <h3><i class="fas fa-database"></i> Database Statistics</h3>
@@ -454,9 +507,153 @@
</div> </div>
</div> </div>
<!-- Schedule Modal -->
<div class="modal" id="schedule-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="schedule-modal-title">Add New Schedule</h3>
<button class="modal-close" onclick="closeScheduleModal()">&times;</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()">&times;</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 --> <!-- Toast Notifications -->
<div class="toast-container" id="toast-container"></div> <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> </body>
</html> </html>
+334 -249
View File
@@ -5,8 +5,6 @@ let currentTab = 'dashboard';
let currentMoviesPage = 1; let currentMoviesPage = 1;
let currentSeriesPage = 1; let currentSeriesPage = 1;
let dashboardData = null; let dashboardData = null;
let seriesSourcesLoaded = false;
let authRetryAttempts = 0;
// Initialize app // Initialize app
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -14,6 +12,7 @@ document.addEventListener('DOMContentLoaded', function() {
initializeEventListeners(); initializeEventListeners();
checkAuthStatus(); // Check authentication status on page load checkAuthStatus(); // Check authentication status on page load
loadDashboard(); loadDashboard();
loadSeriesSources();
}); });
// Tab management // Tab management
@@ -50,9 +49,6 @@ function switchTab(tabName) {
break; break;
case 'tv': case 'tv':
loadSeries(); loadSeries();
if (!seriesSourcesLoaded) {
loadSeriesSources();
}
break; break;
case 'reports': case 'reports':
loadReport(); loadReport();
@@ -79,13 +75,8 @@ function initializeEventListeners() {
// Forms // Forms
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit); document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
// Manual scan forms
document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan); 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 // API calls
@@ -117,9 +108,6 @@ async function loadDashboard() {
dashboardData = await apiCall('/api/dashboard'); dashboardData = await apiCall('/api/dashboard');
updateDashboardStats(); updateDashboardStats();
updateDashboardCharts(); updateDashboardCharts();
// Check if there's an ongoing scan when dashboard loads
await checkScanStatus();
} catch (error) { } catch (error) {
console.error('Failed to load dashboard:', error); console.error('Failed to load dashboard:', error);
} }
@@ -128,6 +116,9 @@ async function loadDashboard() {
function updateDashboardStats() { function updateDashboardStats() {
if (!dashboardData) return; 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 moviesTotal = dashboardData.movies_total || 0;
const moviesWithDates = dashboardData.movies_with_dates || 0; const moviesWithDates = dashboardData.movies_with_dates || 0;
const moviesWithoutDates = dashboardData.movies_without_dates || (moviesTotal - moviesWithDates); 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 sourceFilter = document.getElementById('movies-filter-source').value;
const skip = (page - 1) * 100; const skip = (page - 1) * 100;
console.log(`DEBUG: loadMovies called with page=${page}, calculated skip=${skip}`);
const params = new URLSearchParams({ const params = new URLSearchParams({
skip: skip, skip: skip,
@@ -362,7 +354,6 @@ async function loadSeriesSources() {
}); });
select.value = currentValue; select.value = currentValue;
seriesSourcesLoaded = true;
} catch (error) { } catch (error) {
console.error('Failed to load series sources:', 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 sourceFilter = document.getElementById('series-filter-source').value;
const skip = (page - 1) * 50; const skip = (page - 1) * 50;
console.log(`DEBUG: loadSeries called with page=${page}, calculated skip=${skip}`);
const params = new URLSearchParams({ const params = new URLSearchParams({
skip: skip, skip: skip,
@@ -512,10 +504,21 @@ function showEpisodesModal(data) {
</label> </label>
</div> </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"> <div class="table-container">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th width="40px">
<input type="checkbox" id="select-all-checkbox" onchange="toggleSelectAll()">
</th>
<th>Episode</th> <th>Episode</th>
<th>Aired</th> <th>Aired</th>
<th>Date Added</th> <th>Date Added</th>
@@ -538,7 +541,10 @@ function showEpisodesModal(data) {
`<td>${dateadded}</td>`; `<td>${dateadded}</td>`;
return ` 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>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
<td>${episode.aired || '-'}</td> <td>${episode.aired || '-'}</td>
${dateCell} ${dateCell}
@@ -676,6 +682,13 @@ async function smartFixMovie(imdbId) {
} }
async function smartFixEpisode(imdbId, season, episode) { 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 { try {
const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`);
showSmartFixModal('episode', options); showSmartFixModal('episode', options);
@@ -703,7 +716,10 @@ function showSmartFixModal(mediaType, options) {
if (mediaType === 'movie') { if (mediaType === 'movie') {
title.textContent = `Fix Date for Movie: ${options.imdb_id}`; title.textContent = `Fix Date for Movie: ${options.imdb_id}`;
} else { } 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 // 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 // Edit modal functions
async function editMovie(imdbId, dateadded, source) { async function editMovie(imdbId, dateadded, source) {
@@ -878,7 +935,10 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour
if (mediaType === 'movie') { if (mediaType === 'movie') {
title.textContent = `Edit Movie: ${options.imdb_id}`; title.textContent = `Edit Movie: ${options.imdb_id}`;
} else { } 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 // Build enhanced edit form with date options
@@ -1062,6 +1122,13 @@ async function handleEnhancedEditSubmit(event) {
} }
async function editEpisode(imdbId, season, episode, dateadded, source) { 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 { try {
// Load episode options to populate available dates // Load episode options to populate available dates
const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`);
@@ -1244,6 +1311,13 @@ Analysis:
// Episode deletion functionality // Episode deletion functionality
async function deleteEpisode(imdbId, season, episode) { 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')}`; const episodeStr = `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`;
// Confirmation dialog // Confirmation dialog
@@ -1252,7 +1326,7 @@ async function deleteEpisode(imdbId, season, episode) {
} }
try { try {
const response = await fetch(`/database/episode/${imdbId}/${season}/${episode}`, { const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -1295,7 +1369,7 @@ async function deleteMovie(imdbId) {
} }
try { try {
const response = await fetch(`/database/movie/${imdbId}`, { const response = await fetch(`/api/movies/${imdbId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -1363,16 +1437,8 @@ async function checkAuthStatus() {
authUsernameSpan.textContent = authStatus.username; authUsernameSpan.textContent = authStatus.username;
authStatusDiv.style.display = 'flex'; authStatusDiv.style.display = 'flex';
} else if (authStatus.auth_enabled && !authStatus.authenticated) { } else if (authStatus.auth_enabled && !authStatus.authenticated) {
// This can happen briefly during page load - retry once after a short delay // This shouldn't happen if middleware is working, but handle it
if (authRetryAttempts < 1) { console.warn('Auth enabled but not authenticated - middleware may be misconfigured');
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');
}
} else { } else {
// Authentication disabled - hide auth status // Authentication disabled - hide auth status
authStatusDiv.style.display = 'none'; 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() { async function logout() {
if (!confirm('Are you sure you want to logout?')) { if (!confirm('Are you sure you want to logout?')) {
return; return;
@@ -1412,248 +1641,104 @@ async function logout() {
} }
} }
// =========================== // Bulk delete functions for TV episodes
// Manual Scan Functions function toggleSelectAll() {
// =========================== const selectAllCheckbox = document.getElementById('select-all-checkbox');
const episodeCheckboxes = document.querySelectorAll('.episode-checkbox');
async function handleManualScan(event) { if (selectAllCheckbox && episodeCheckboxes.length > 0) {
event.preventDefault(); const shouldCheck = selectAllCheckbox.checked;
episodeCheckboxes.forEach(checkbox => {
const scanType = document.getElementById('scan-type').value; checkbox.checked = shouldCheck;
const scanMode = document.getElementById('scan-mode').value;
if (!scanType || !scanMode) {
showToast('Please select both scan type and mode', 'warning');
return;
}
try {
showToast('🔍 Starting manual scan...', 'info');
updateScanStatus('Starting manual scan...', true);
// Send as query parameters instead of JSON body
const params = new URLSearchParams({
scan_type: scanType,
scan_mode: scanMode
}); });
updateBulkDeleteButton();
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);
}
// 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) { function updateBulkDeleteButton() {
event.preventDefault(); 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 customDirectory = document.getElementById('scan-path').value.trim(); if (selectedCountSpan) {
const customScanType = document.getElementById('custom-scan-type').value; selectedCountSpan.textContent = selectedCount;
if (!customDirectory) {
showToast('Please enter a directory path', 'warning');
return;
} }
if (!customScanType) { if (bulkDeleteButton) {
showToast('Please select a scan type', 'warning'); bulkDeleteButton.disabled = selectedCount === 0;
return;
} }
try { // Update select all checkbox state
showToast('🔍 Starting custom directory scan...', 'info'); const selectAllCheckbox = document.getElementById('select-all-checkbox');
updateScanStatus('Starting custom directory scan...', true); const allCheckboxes = document.querySelectorAll('.episode-checkbox');
if (selectAllCheckbox && allCheckboxes.length > 0) {
// Send as query parameters instead of JSON body selectAllCheckbox.checked = selectedCount === allCheckboxes.length;
const params = new URLSearchParams({ selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
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) { async function bulkDeleteSelected() {
const input = event.target; const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
let value = input.value; const selectedCount = selectedCheckboxes.length;
// Auto-format common directory patterns if (selectedCount === 0) {
if (value && !value.startsWith('/')) { showToast('❌ No episodes selected', 'error');
// 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; return;
} }
statusText.textContent = message; if (!confirm(`Are you sure you want to delete ${selectedCount} episode(s)? This action cannot be undone.`)) {
return;
}
if (isActive) { const bulkDeleteButton = document.getElementById('bulk-delete-selected');
statusBanner.className = 'scan-status-banner active'; const originalText = bulkDeleteButton.innerHTML;
statusBanner.style.display = 'block'; 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 { } else {
statusBanner.className = 'scan-status-banner'; failCount++;
// Don't hide completely, just mark as inactive console.error(`Failed to delete S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, response.message);
setTimeout(() => {
if (statusBanner.className === 'scan-status-banner') {
statusBanner.style.display = 'none';
} }
}, 3000);
}
}
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) { } catch (error) {
console.error('Failed to check scan status:', error); failCount++;
updateScanStatus('Unable to check scan status', false); console.error(`Error deleting S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, error);
return false; // Stop polling on error }
} }
}
function formatElapsedTime(seconds) { // Update UI
if (seconds >= 60) { updateEpisodeModalCounts();
const minutes = Math.floor(seconds / 60); updateBulkDeleteButton();
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`; // 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 { } else {
return `${seconds}s`; showToast(`❌ Failed to delete ${failCount} episode(s)`, 'error');
}
}
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;
} }
} }
+144 -3
View File
@@ -151,7 +151,7 @@ class MovieProcessor:
_log("ERROR", f"Error checking movie completion for {imdb_id}: {e}") _log("ERROR", f"Error checking movie completion for {imdb_id}: {e}")
return False, f"Error checking completion: {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""" """Process a movie directory"""
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path) imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
if not imdb_id: if not imdb_id:
@@ -165,8 +165,9 @@ class MovieProcessor:
else: else:
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})") _log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
# Check if we should skip this movie (unless forced or webhook mode) # Check if we should skip this movie (unless forced, webhook mode, or incomplete mode)
if not force_scan and not webhook_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) should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name)
if should_skip: if should_skip:
_log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}") _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") _log("WARNING", f"No video files found in: {movie_path} - skipping database entry")
return "no_video_files" 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) # TIER 1: Check database first (fastest - local lookup)
existing = self.db.get_movie_dates(imdb_id) existing = self.db.get_movie_dates(imdb_id)
_log("DEBUG", f"Database lookup for {imdb_id}: {existing}") _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})") _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
return "processed" 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]]: def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract date information from TMDB-based NFO file""" """Extract date information from TMDB-based NFO file"""
if not nfo_path.exists(): if not nfo_path.exists():
+206 -36
View File
@@ -145,7 +145,7 @@ class TVProcessor:
_log("ERROR", f"Error checking series completion for {imdb_id}: {e}") _log("ERROR", f"Error checking series completion for {imdb_id}: {e}")
return False, f"Error checking completion: {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""" """Process a TV series directory"""
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
if not imdb_id: if not imdb_id:
@@ -155,7 +155,8 @@ class TVProcessor:
_log("INFO", f"Processing TV series: {series_path.name}") _log("INFO", f"Processing TV series: {series_path.name}")
# Fast check first - avoid expensive filesystem scan if possible # 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) should_skip_fast, reason_fast, episodes_in_db = self.should_skip_series_fast(imdb_id, series_path.name)
if should_skip_fast: if should_skip_fast:
_log("INFO", f"⚡ FAST SKIP: {series_path.name} [{imdb_id}] - {reason_fast}") _log("INFO", f"⚡ FAST SKIP: {series_path.name} [{imdb_id}] - {reason_fast}")
@@ -194,7 +195,7 @@ class TVProcessor:
# Create NFO # Create NFO
if config.manage_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( self.nfo_manager.create_episode_nfo(
season_dir, season_dir,
season, episode, aired, dateadded, source, config.lock_metadata season, episode, aired, dateadded, source, config.lock_metadata
@@ -226,12 +227,17 @@ class TVProcessor:
return extract_title_from_directory_name(series_path.name) 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]]: 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 database-first optimization""" """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") _log("INFO", f"🎯 GATHERING EPISODE DATES for {imdb_id}: {len(disk_episodes)} episodes on disk (mode: {scan_mode})")
episode_dates = {} episode_dates = {}
episodes_needing_lookup = [] 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) # 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") _log("DEBUG", f"TIER 1 - Checking database for existing episode dates for {len(disk_episodes)} episodes")
db_cache_hits = 0 db_cache_hits = 0
@@ -264,7 +270,7 @@ class TVProcessor:
for (season, episode) in episodes_needing_nfo_check: for (season, episode) in episodes_needing_nfo_check:
# Look for existing NFO files for this episode # 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)] episode_files = disk_episodes[(season, episode)]
nfo_found = False nfo_found = False
@@ -324,6 +330,12 @@ class TVProcessor:
sonarr_data = sonarr_episodes[(season, episode)] sonarr_data = sonarr_episodes[(season, episode)]
aired = sonarr_data.get('airDate') aired = sonarr_data.get('airDate')
dateadded = sonarr_data.get('dateAdded') 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: if dateadded:
source = "sonarr:history.import" source = "sonarr:history.import"
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}") _log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
@@ -372,6 +384,131 @@ class TVProcessor:
return episode_dates 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]]: 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""" """Get episode information from Sonarr including import history - optimized to only fetch needed episodes"""
try: try:
@@ -439,6 +576,11 @@ class TVProcessor:
'dateAdded': None '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) # First try to get import date from history (more accurate)
episode_id = episode.get('id') episode_id = episode.get('id')
if episode_id and episode.get('hasFile'): if episode_id and episode.get('hasFile'):
@@ -645,7 +787,7 @@ class TVProcessor:
for (season, episode), (aired, dateadded, source) in episode_dates.items(): for (season, episode), (aired, dateadded, source) in episode_dates.items():
if (season, episode) in disk_episodes: 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 # Prepare NFO creation data
if config.manage_nfo: if config.manage_nfo:
@@ -778,7 +920,7 @@ class TVProcessor:
continue continue
# Check if episode file exists on disk # 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(): if not season_dir.exists():
_log("WARNING", f"Season directory not found: {season_dir}") _log("WARNING", f"Season directory not found: {season_dir}")
continue continue
@@ -797,7 +939,7 @@ class TVProcessor:
# Get episode date information - webhook processing prioritizes existing DB entries # 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}") _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) 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 # Create NFO
@@ -825,20 +967,20 @@ class TVProcessor:
episodes_processed += 1 episodes_processed += 1
# Create season/tvshow NFOs if any episodes were processed # DISABLED: Skip creating season.nfo and tvshow.nfo files (user only wants episode NFOs)
if episodes_processed > 0 and config.manage_nfo: # if episodes_processed > 0 and config.manage_nfo:
seasons_processed = set() # seasons_processed = set()
for webhook_episode in webhook_episodes: # for webhook_episode in webhook_episodes:
season_num = webhook_episode.get("seasonNumber") # season_num = webhook_episode.get("seasonNumber")
if season_num and season_num not in seasons_processed: # if season_num and season_num not in seasons_processed:
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 season_dir.exists(): # if season_dir.exists():
self.nfo_manager.create_season_nfo(season_dir, season_num) # self.nfo_manager.create_season_nfo(season_dir, season_num)
seasons_processed.add(season_num) # seasons_processed.add(season_num)
#
# Get TVDB ID for better Emby compatibility # # Get TVDB ID for better Emby compatibility
tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) # tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id)
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_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") _log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed")
@@ -942,17 +1084,46 @@ class TVProcessor:
return None 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: Logic:
1. Check Sonarr import history FIRST (any history = show has existed for a while) 1. Check NFOGuard database FIRST - if we have a date, use it (no re-processing)
2. If ANY import history exists Use air date (webhook is likely upgrade/rename) 2. No date in DB? Check Sonarr import history with our rules:
3. If NO import history Use webhook date (truly new show) - 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 # Get aired date and episode ID from Sonarr
aired = None aired = None
episode_id = None episode_id = None
@@ -963,8 +1134,8 @@ class TVProcessor:
episode_id = episode_data.get('id') episode_id = episode_data.get('id')
_log("DEBUG", f"Got aired date from Sonarr for S{season_num:02d}E{episode_num:02d}: {aired}") _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) # STEP 2: Check Sonarr import history (authoritative source)
_log("INFO", f"Checking Sonarr import history for S{season_num:02d}E{episode_num:02d} to detect import vs rename events") _log("INFO", f"Checking Sonarr import history for S{season_num:02d}E{episode_num:02d}")
if episode_id and hasattr(self, 'sonarr'): if episode_id and hasattr(self, 'sonarr'):
try: try:
@@ -973,9 +1144,8 @@ class TVProcessor:
_log("DEBUG", f"Import history result: {import_history}") _log("DEBUG", f"Import history result: {import_history}")
if import_history: if import_history:
# Found actual import event - use this date # Found actual import event from Sonarr
_log("INFO", f"Found real import event for S{season_num:02d}E{episode_num:02d}: {import_history}") _log("INFO", f"Found Sonarr import event for S{season_num:02d}E{episode_num:02d}: {import_history}")
_log("INFO", f"Using first import date (not webhook): {import_history}")
return aired, import_history, "sonarr:import_history" return aired, import_history, "sonarr:import_history"
else: else:
# No import events found - this means only renames/moves exist in history # No import events found - this means only renames/moves exist in history
@@ -1045,7 +1215,7 @@ class TVProcessor:
# Create NFO if needed # Create NFO if needed
nfo_success = True nfo_success = True
if config.manage_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)
nfo_success = await self.async_nfo_manager.async_create_episode_nfo( nfo_success = await self.async_nfo_manager.async_create_episode_nfo(
season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata
) )
+19 -18
View File
@@ -371,21 +371,22 @@ class TVSeriesProcessor:
return None, "no_external_data" return None, "no_external_data"
def _create_series_nfos(self, series_path: Path, imdb_id: str): def _create_series_nfos(self, series_path: Path, imdb_id: str):
"""Create tvshow.nfo and season.nfo files only if they don't exist""" """DISABLED: Skip creating tvshow.nfo and season.nfo files (user only wants episode NFOs)"""
# Create tvshow.nfo only if it doesn't exist # # Create tvshow.nfo only if it doesn't exist
tvshow_nfo = series_path / "tvshow.nfo" # tvshow_nfo = series_path / "tvshow.nfo"
if not tvshow_nfo.exists(): # if not tvshow_nfo.exists():
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id) # self.nfo_manager.create_tvshow_nfo(series_path, imdb_id)
else: # else:
_log("DEBUG", f"Skipping tvshow.nfo creation - already exists: {tvshow_nfo}") # _log("DEBUG", f"Skipping tvshow.nfo creation - already exists: {tvshow_nfo}")
#
# Create season.nfo for each season directory only if they don't exist # # Create season.nfo for each season directory only if they don't exist
for season_dir in series_path.iterdir(): # for season_dir in series_path.iterdir():
if season_dir.is_dir() and self._is_season_directory(season_dir.name): # if season_dir.is_dir() and self._is_season_directory(season_dir.name):
season_num = self._extract_season_number(season_dir.name) # season_num = self._extract_season_number(season_dir.name)
if season_num is not None: # if season_num is not None:
season_nfo = season_dir / "season.nfo" # season_nfo = season_dir / "season.nfo"
if not season_nfo.exists(): # if not season_nfo.exists():
self.nfo_manager.create_season_nfo(season_dir, season_num) # self.nfo_manager.create_season_nfo(season_dir, season_num)
else: # else:
_log("DEBUG", f"Skipping season.nfo creation - already exists: {season_nfo}") # _log("DEBUG", f"Skipping season.nfo creation - already exists: {season_nfo}")
pass # Function disabled - only process episode NFOs
+2
View File
@@ -8,3 +8,5 @@ aiofiles==23.2.1
aiohttp==3.8.6 aiohttp==3.8.6
psutil==5.9.6 psutil==5.9.6
python-dotenv==1.0.0 python-dotenv==1.0.0
APScheduler==3.10.4
croniter==1.4.1
+1
View File
@@ -0,0 +1 @@
# Scheduler module for NFOGuard
+396
View File
@@ -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
+22 -5
View File
@@ -5,18 +5,19 @@ Simple script to start web interface using existing config system
""" """
import os import os
import sys import sys
import time
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
# Import existing configuration # Import existing configuration (keep using core config for simplicity)
from config.settings import config from config.settings import config
# Import existing database and components # Import existing database and components
from core.database import NFOGuardDatabase 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 from api.web_routes import register_web_routes
# Import authentication system # Import authentication system
@@ -83,6 +84,22 @@ def setup_static_files(app: FastAPI) -> None:
from fastapi import Response from fastapi import Response
return Response(status_code=204) 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(): def main():
"""Main entry point for NFOGuard Web Interface""" """Main entry point for NFOGuard Web Interface"""
@@ -116,7 +133,7 @@ def main():
else: else:
print("🌐 Web authentication disabled") print("🌐 Web authentication disabled")
# Create dependencies for dependency injection (simplified for web-only) # Create dependencies for dependency injection
dependencies = { dependencies = {
"db": db, "db": db,
"config": config, "config": config,
@@ -129,15 +146,15 @@ def main():
# Add authentication middleware if enabled (BEFORE routes) # Add authentication middleware if enabled (BEFORE routes)
if auth_enabled: 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) app.add_middleware(SimpleAuthMiddleware, config=config, session_manager=session_manager)
print("🔐 Authentication middleware added to web interface") print("🔐 Authentication middleware added to web interface")
# Setup static files and routes # Setup static files and routes
setup_static_files(app) setup_static_files(app)
# Register web routes # Register web routes (now includes DELETE /api/episodes/ route)
register_web_routes(app, dependencies) register_web_routes(app, dependencies)
print("✅ Registered web routes with DELETE /api/episodes/ support")
print(f"🚀 Starting web server on {web_host}:{web_port}") print(f"🚀 Starting web server on {web_host}:{web_port}")
+57
View File
@@ -843,3 +843,60 @@ body {
.d-flex { display: flex; } .d-flex { display: flex; }
.justify-content-between { justify-content: space-between; } .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
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NFOGuard - Database Management</title> <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"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>
<body> <body>
@@ -327,6 +327,51 @@
</form> </form>
</div> </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"> <div class="tool-card">
<h3><i class="fas fa-database"></i> Database Statistics</h3> <h3><i class="fas fa-database"></i> Database Statistics</h3>
<p>View detailed database information</p> <p>View detailed database information</p>
@@ -404,6 +449,6 @@
<!-- Toast Notifications --> <!-- Toast Notifications -->
<div class="toast-container" id="toast-container"></div> <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> </body>
</html> </html>
+312 -5
View File
@@ -76,6 +76,7 @@ function initializeEventListeners() {
// Forms // Forms
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit); document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate); document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan);
} }
// API calls // API calls
@@ -503,10 +504,21 @@ function showEpisodesModal(data) {
</label> </label>
</div> </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"> <div class="table-container">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th width="40px">
<input type="checkbox" id="select-all-checkbox" onchange="toggleSelectAll()">
</th>
<th>Episode</th> <th>Episode</th>
<th>Aired</th> <th>Aired</th>
<th>Date Added</th> <th>Date Added</th>
@@ -529,7 +541,10 @@ function showEpisodesModal(data) {
`<td>${dateadded}</td>`; `<td>${dateadded}</td>`;
return ` 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>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
<td>${episode.aired || '-'}</td> <td>${episode.aired || '-'}</td>
${dateCell} ${dateCell}
@@ -667,6 +682,13 @@ async function smartFixMovie(imdbId) {
} }
async function smartFixEpisode(imdbId, season, episode) { 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 { try {
const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`);
showSmartFixModal('episode', options); showSmartFixModal('episode', options);
@@ -694,7 +716,10 @@ function showSmartFixModal(mediaType, options) {
if (mediaType === 'movie') { if (mediaType === 'movie') {
title.textContent = `Fix Date for Movie: ${options.imdb_id}`; title.textContent = `Fix Date for Movie: ${options.imdb_id}`;
} else { } 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 // Build options HTML
@@ -910,7 +935,10 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour
if (mediaType === 'movie') { if (mediaType === 'movie') {
title.textContent = `Edit Movie: ${options.imdb_id}`; title.textContent = `Edit Movie: ${options.imdb_id}`;
} else { } 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 // Build enhanced edit form with date options
@@ -1094,6 +1122,13 @@ async function handleEnhancedEditSubmit(event) {
} }
async function editEpisode(imdbId, season, episode, dateadded, source) { 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 { try {
// Load episode options to populate available dates // Load episode options to populate available dates
const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`);
@@ -1276,6 +1311,13 @@ Analysis:
// Episode deletion functionality // Episode deletion functionality
async function deleteEpisode(imdbId, season, episode) { 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')}`; const episodeStr = `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`;
// Confirmation dialog // Confirmation dialog
@@ -1284,7 +1326,7 @@ async function deleteEpisode(imdbId, season, episode) {
} }
try { try {
const response = await fetch(`/database/episode/${imdbId}/${season}/${episode}`, { const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -1327,7 +1369,7 @@ async function deleteMovie(imdbId) {
} }
try { try {
const response = await fetch(`/database/movie/${imdbId}`, { const response = await fetch(`/api/movies/${imdbId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-Type': 'application/json' '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() { async function logout() {
if (!confirm('Are you sure you want to logout?')) { if (!confirm('Are you sure you want to logout?')) {
return; return;
@@ -1435,3 +1640,105 @@ async function logout() {
showToast('❌ Logout error', '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');
}
}
+61 -1
View File
@@ -10,6 +10,49 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo 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): class TimezoneAwareFormatter(logging.Formatter):
"""Formatter that respects the container timezone""" """Formatter that respects the container timezone"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -50,7 +93,11 @@ def _setup_file_logging():
logger = logging.getLogger("NFOGuard") logger = logging.getLogger("NFOGuard")
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
file_handler = logging.handlers.RotatingFileHandler( # Clear any existing handlers to avoid duplicates
logger.handlers.clear()
try:
file_handler = SafeRotatingFileHandler(
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3 log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
) )
@@ -59,6 +106,19 @@ def _setup_file_logging():
) )
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
logger.addHandler(file_handler) 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}")
return logger return logger
+30
View File
@@ -3,6 +3,7 @@ Webhook Batching System for NFOGuard
Handles batching and processing of webhook events to avoid processing storms Handles batching and processing of webhook events to avoid processing storms
""" """
import threading import threading
import time
from pathlib import Path from pathlib import Path
from typing import Dict, Set from typing import Dict, Set
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -144,6 +145,12 @@ class WebhookBatcher:
if processing_mode == 'targeted' and episodes_data: if processing_mode == 'targeted' and episodes_data:
_log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes") _log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes")
# Handle sequential processing for bulk downloads
if len(episodes_data) > 1 and config.sequential_delay > 0:
_log("INFO", f"Processing {len(episodes_data)} episodes sequentially with {config.sequential_delay}s delay")
self._process_episodes_sequentially(path_obj, episodes_data)
else:
self.tv_processor.process_webhook_episodes(path_obj, episodes_data) self.tv_processor.process_webhook_episodes(path_obj, episodes_data)
else: else:
_log("INFO", f"Using series processing mode (fallback or configured)") _log("INFO", f"Using series processing mode (fallback or configured)")
@@ -164,6 +171,29 @@ class WebhookBatcher:
with self.lock: with self.lock:
self.processing.discard(key) 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: def get_status(self) -> Dict:
"""Get batch queue status""" """Get batch queue status"""
with self.lock: with self.lock: