Compare commits

..
660 Commits
Author SHA1 Message Date
sbcrumb c85df54d5a updates for acfivity dashboard
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 10:26:32 -05:00
sbcrumb 3ccf062267 update to skip files
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 09:23:06 -05:00
sbcrumb 76fd839fa8 update to api for manual entry
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 09:15:43 -05:00
sbcrumb a5dd635cc4 updates: fixes for imdb manual add and manual add dates
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 09:08:09 -05:00
sbcrumb 79f292ff8e update: to imdb and skipped items
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-04 08:55:06 -05:00
sbcrumb 2f0537ef1d update with the ability to add IMDB to skipped
Local Docker Build (Dev) / build-dev (push) Successful in 35s
2025-11-04 08:48:22 -05:00
sbcrumb d501c4364c update to tv
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 20:40:42 -05:00
sbcrumb a326825347 update: movie missing imdb
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 19:49:26 -05:00
sbcrumb c0ed1d9630 we bupdated
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 19:25:41 -05:00
sbcrumb 1f20b4397d update to web interface
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 19:05:43 -05:00
sbcrumb b0b2e87447 web test
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 16:49:14 -05:00
sbcrumb 7932b9cfba update to skiped files
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 16:37:42 -05:00
sbcrumb c9b099a218 web interface updates
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 16:20:21 -05:00
sbcrumb d83ba054b3 update to skipped files
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 16:04:42 -05:00
sbcrumb 3e3957234d udpate add bulk update of TV eps
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 15:52:42 -05:00
sbcrumb 1e292434c0 fix: tracking
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 15:45:17 -05:00
sbcrumb b48f1220e2 update to api error when scanning
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 15:40:13 -05:00
sbcrumb dc28508bd2 update web route wtih correct if
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 15:29:53 -05:00
sbcrumb db203b5f26 update to movie db population
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 15:27:17 -05:00
sbcrumb c7d12dadaf update to skipped movie
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 15:21:19 -05:00
sbcrumb 2d5688d313 update db
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 15:05:43 -05:00
sbcrumb d078decbb4 db radarr fixes
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 14:50:35 -05:00
sbcrumb 788b7e79f6 updates: more web interface and scan fixes
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 14:38:21 -05:00
sbcrumb 04b2afdb19 update: webinterface updates
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 14:31:39 -05:00
sbcrumb 65896f1974 movies: imdb lookup
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 14:22:58 -05:00
sbcrumb 01887f082a update: to reading IMDB's
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 14:14:42 -05:00
sbcrumb 73967da3e6 fix: movie fixes
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 14:07:00 -05:00
sbcrumb e397f0639b database: updates
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 11:11:00 -05:00
sbcrumb 2feac00b4c update: updates to web populate
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 11:06:11 -05:00
sbcrumb 099ec12560 web: updates to populate
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 11:03:27 -05:00
sbcrumb c91c33e9b2 udpate: web populate fix
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 10:54:06 -05:00
sbcrumb de2203a53d udpates: to database population
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 10:49:30 -05:00
sbcrumb 5305e327b3 web: populate link updates
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-11-03 10:37:04 -05:00
sbcrumb 942b9f7f63 feat: add sonarr DB integration vs API
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 10:26:56 -05:00
sbcrumb c2c720cd8b update to web interface
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-03 09:30:50 -05:00
sbcrumb d414da5bf3 updates to new phase
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-11-02 20:23:31 -05:00
sbcrumb 97a9f3431a refactor: update and remove anything to do with NFO files
Local Docker Build (Dev) / build-dev (push) Successful in 35s
moving to an all DB approach since radarr overwrites .nfo files
2025-11-02 13:43:28 -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 (Main) / build (pull_request) Successful in 33s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
Local Docker Build (Dev) / build-dev (push) Successful in 5s
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 b65ba5d15d updates: to time again
Local Docker Build (Dev) / build-dev (push) Successful in 4s
Local Docker Build (Main) / build (pull_request) Successful in 4s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-20 19:49:01 -04:00
sbcrumb 53fe4dd9a3 fix: timezone not being respected in imports
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-20 19:40:50 -04:00
sbcrumb a55da8d189 fix:
Local Docker Build (Dev) / build-dev (push) Successful in 4s
removed auth on database endpoints
2025-10-20 16:48:47 -04:00
sbcrumb 8b683a9fad fix: Store actual movie release dates in database
Local Docker Build (Dev) / build-dev (push) Successful in 4s
- Movie processor now stores release dates in 'released' field
  - Added backfill endpoint for existing movies missing release dates
  - Fixes empty "Movie Released" column in web interface
2025-10-20 16:45:15 -04:00
sbcrumb 000fb85c03 fix: Add missing config parameter to debug scripts
Local Docker Build (Dev) / build-dev (push) Successful in 4s
- Fixed debug_movie.py and debug_tv.py to pass required config parameter
  - Resolves TypeError when initializing NFOGuardDatabase
  - Both scripts now work properly for command-line debugging
2025-10-20 16:37:10 -04:00
sbcrumb aafb85ba69 Update VERSION
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-20 16:29:38 -04:00
sbcrumb 57122769df Merge pull request 'feat: Add optional web interface authentication with session management' (#58) from web-interface-password into dev
Local Docker Build (Dev) / build-dev (push) Successful in 4s
Reviewed-on: #58
2025-10-20 16:24:17 -04:00
sbcrumb c66c19dc59 feat: Add optional web interface authentication with session management
Local Docker Build (Dev) / build-dev (pull_request) Successful in 3s
Authentication Features:
  - Optional HTTP Basic Auth with session cookies for web interface protection
  - Configurable username/password with session timeout (default: 1 hour)
  - Selective route protection (web interface protected, webhooks/APIs remain public)
  - Authentication status display with logout functionality in web UI
  - Session cleanup and graceful logout with browser re-authentication prompt

  Configuration:
  - WEB_AUTH_ENABLED in .env (default: false - no breaking changes)
  - WEB_AUTH_USERNAME/WEB_AUTH_PASSWORD in .env.secrets
  - WEB_AUTH_SESSION_TIMEOUT configurable (5min-24h range)

  Development Tools:
  - Added debug_tv.py for TV series/episode debugging (series, season, episode levels)
  - Comprehensive episode data inspection with validation and sources breakdown
  - Complements existing debug_movie.py for complete media debugging coverage

  Technical Implementation:
  - SimpleAuthMiddleware with FastAPI integration
  - Session management with automatic cleanup
  - Authentication status API endpoints
  - Responsive CSS styling for auth UI elements
  - JavaScript functions for auth checking and logout
2025-10-20 16:21:50 -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
sbcrumb 1ad2a3d945 Merge branch 'dev' of 192.168.253.221:sbcrumb/nfoguard into dev
Local Docker Build (Dev) / build-dev (push) Successful in 3s
Local Docker Build (Main) / build (pull_request) Successful in 4s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-20 14:16:07 -04:00
sbcrumb c1240964ee fix: turn off hard coded debug 2025-10-20 14:15:14 -04:00
sbcrumb 5ade14c63e Merge branch 'main' into dev
Local Docker Build (Dev) / build-dev (push) Successful in 3s
Local Docker Build (Main) / build (pull_request) Successful in 4s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-20 14:07:26 -04:00
sbcrumb 445206de4b Merge pull request 'fix: not shutting down during scan' (#55) from dev into main
Local Docker Build (Main) / build (push) Successful in 5s
Local Docker Build (Main) / deploy (push) Successful in 0s
Reviewed-on: #55
2025-10-20 11:40:55 -04:00
sbcrumb 1e2cf49c10 fix: not shutting down during scan 2025-10-20 11:40:55 -04:00
sbcrumb 114628ad5c Merge branch 'main' into dev
Local Docker Build (Dev) / build-dev (push) Successful in 4s
Local Docker Build (Main) / build (pull_request) Successful in 4s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-20 11:40:47 -04:00
sbcrumb 748392c425 fix: not shutting down during scan
Local Docker Build (Dev) / build-dev (push) Successful in 4s
Local Docker Build (Main) / build (pull_request) Successful in 4s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-20 10:36:23 -04:00
sbcrumb 55de6284c8 Merge pull request 'fixes: web interface and movie processing' (#54) from dev into main
Local Docker Build (Main) / build (push) Successful in 4s
Local Docker Build (Main) / deploy (push) Successful in 1s
Reviewed-on: #54
2025-10-20 10:27:38 -04:00
sbcrumb 808322c7c7 fix: update to not add empty dirs
Local Docker Build (Dev) / build-dev (push) Successful in 4s
Local Docker Build (Main) / build (pull_request) Successful in 4s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-20 10:26:29 -04:00
sbcrumb 148937ae08 chore: update to examples and readme
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-20 09:32:28 -04:00
sbcrumb f6a161a014 fix: web interface 422 errors
Local Docker Build (Main) / build (push) Successful in 30s
Local Docker Build (Main) / deploy (push) Successful in 1s
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-20 08:51:14 -04:00
sbcrumb 741a283df0 fixes: debugs for 422 and delete option
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-20 08:42:53 -04:00
sbcrumb 3c4e47b08e fixes: web interface shutdown
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-20 08:30:55 -04:00
sbcrumb eb9bfbf82e db:sonarr
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-19 12:47:03 -04:00
sbcrumb 8ef4d1076c web: fixes
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-19 12:20:34 -04:00
sbcrumb 3c4cac6177 db: fixes
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-19 10:28:32 -04:00
sbcrumb d1d1e1a3d1 update: to sonarr process
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-19 10:14:53 -04:00
sbcrumb 7089c883a9 db: gtit rid of sqllite
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-19 09:54:57 -04:00
sbcrumb 783f02f7d7 movies: fix movie db issues
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 21:00:23 -04:00
sbcrumb 0734b4d14d featuer: smart scan to skip ones with dates
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 15:20:57 -04:00
sbcrumb 55d5f891b5 improv: phase 1 of faster scan and tracking of scans
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 15:16:02 -04:00
sbcrumb beb471d081 fix: fixing db time again
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 14:47:47 -04:00
sbcrumb dc96df70f8 db: timezone issues
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 14:44:56 -04:00
sbcrumb fdc8703044 fix: fix logs to ETC vs UTC
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 14:40:57 -04:00
sbcrumb c8b98f6765 fix: log start of manual scan and stop
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 14:38:38 -04:00
sbcrumb 852c45bbb5 db: updates for TV shows in interface
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 14:28:10 -04:00
sbcrumb 46fa6b3b75 db: fixes
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 14:21:57 -04:00
sbcrumb 3b32f7e023 updates:?
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 14:12:51 -04:00
sbcrumb e06df9c8b0 db: more
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 09:37:53 -04:00
sbcrumb 6c353dcaf8 db: debug
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 09:32:42 -04:00
sbcrumb cb18c358c6 db: more sb improvments
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 09:27:15 -04:00
sbcrumb 6ef1d2fd89 db: fixing postgress issues
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 09:23:25 -04:00
sbcrumb 762031d3f3 db: update
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-18 09:07:45 -04:00
sbcrumb 95945f7189 db improvments
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-17 21:18:37 -04:00
sbcrumb d552d97440 db: insert tables
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-17 21:09:42 -04:00
sbcrumb 5630022f6e fix: db creation
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-17 21:07:22 -04:00
sbcrumb 9535e91db6 db: fix db password
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-17 21:00:58 -04:00
sbcrumb 6e859ad112 gd: fix
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-17 20:59:07 -04:00
sbcrumb 6b2d610b20 fix: db issues
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-17 20:57:22 -04:00
sbcrumb e89cbca125 feat:update to postgress over sqllite
Local Docker Build (Dev) / build-dev (push) Successful in 3s
2025-10-17 20:47:25 -04:00
sbcrumb 5ecf46df92 update: fix docker shutdown issues
Local Docker Build (Dev) / build-dev (push) Successful in 30s
2025-10-17 10:32:25 -04:00
sbcrumb 87930efb79 fix: tier levels not working as well as an option to ignore the .nfo file and query
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-17 10:16:12 -04:00
sbcrumb 6d5635f56a sonarr: fuzzy date match debug
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-16 10:04:06 -04:00
sbcrumb fd2f8f2c51 fix: fuzzy match
Local Docker Build (Dev) / build-dev (push) Successful in 3s
2025-10-16 10:00:37 -04:00
sbcrumb 47eb74da06 debug: debug airdates in TV
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-16 09:50:04 -04:00
sbcrumb 113b44b9ab testing: testing airdate pulls
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-16 09:29:29 -04:00
sbcrumb 8a3e01d966 fix: shows not seeing airdates
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-16 09:17:03 -04:00
sbcrumb c5177ba920 sonarr: fix airdates not being used.
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-16 08:51:54 -04:00
sbcrumb d17f924192 fix: nfo cache
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 21:13:51 -04:00
sbcrumb 857d423ae4 fix: airdates again
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 20:58:26 -04:00
sbcrumb 4ea202cca2 fix: airdate issues
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 18:51:16 -04:00
sbcrumb b8d85e356a fix: more webui issues
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-15 17:29:53 -04:00
sbcrumb 366eaa2877 🛡️ Complete Protection Implemented:
Local Docker Build (Dev) / build-dev (push) Successful in 4s
1. Enhanced Debugging: Will show exact episode dict structure and contents
  2. Safe Conversion: Try/catch around int(episode) with detailed error logging
  3. Format String Protection: All print statements now have fallback error handling
  4. Response Safety: Safe episode conversion for API response object
2025-10-15 16:58:39 -04:00
sbcrumb 987ca1977b fix: update for UI and dates
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 16:55:02 -04:00
sbcrumb 4b77872fd5 web: more web updated and improvements
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-15 16:02:51 -04:00
sbcrumb d02ee396e9 fix: not finding aridates
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 11:07:18 -04:00
sbcrumb 9850dc596a web: trying to fix date look up
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 11:02:32 -04:00
sbcrumb 068507adce web:updates again
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 10:57:24 -04:00
sbcrumb ecc70812af web: more webinterface updates
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 10:52:51 -04:00
sbcrumb 9f05e4cb92 web: performance improvments
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 10:42:38 -04:00
sbcrumb 79a311f8ec web: fix for unresponsive web interface
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 10:13:25 -04:00
sbcrumb 909df0cc83 web: update web interface for tv eps missing date
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 09:55:28 -04:00
sbcrumb 913aed1f60 fix: data issue not populating
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 08:21:27 -04:00
sbcrumb 0dfc296bf4 fix: db not filling in .nfo data
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-15 07:56:56 -04:00
sbcrumb 66ba17991e fix:updates for interface
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-14 16:38:30 -04:00
sbcrumb f903606d3a fix: fix webinterface broken when manual scan
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-14 16:29:27 -04:00
sbcrumb 051f9d7173 fix: broken interface
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-14 16:16:05 -04:00
sbcrumb 536fc70350 feature: auto populate date
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-14 16:11:55 -04:00
sbcrumb bb9c12a0be fix: date
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-14 16:04:19 -04:00
sbcrumb 0bf72c1e5c feat:fix
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-14 15:59:52 -04:00
sbcrumb 00f2b77bfb fix: smart fix date issues
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-14 15:53:11 -04:00
sbcrumb 212e51e54f fix: smart dates
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-14 15:50:09 -04:00
sbcrumb 6fae41aef5 fix: digital realease date
Local Docker Build (Dev) / build-dev (push) Successful in 4s
2025-10-14 15:46:07 -04:00
sbcrumb 249b55964e Merge web-interface branch with enhanced UI features and improved date parsing
- Added Smart Fix modal with multiple date source options
- Enhanced edit modal with automatic date option population
- Improved date parsing with timezone handling and validation
- Added debug functionality for database inspection
- Fixed search issues and enhanced web interface controls

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-10-14 15:32:37 -04:00
sbcrumb 7b7ac6f179 fix: web interface improvments
Local Docker Build (Dev) / build-dev (pull_request) Successful in 4s
2025-10-14 15:27:40 -04:00
sbcrumb 8de6a58209 Merge pull request 'fix: search issues in webinterfacey' (#51) from web-interface into dev
Local Docker Build (Dev) / build-dev (push) Successful in 4s
Reviewed-on: #51
2025-10-14 15:18:55 -04:00
sbcrumb dcace84406 fix: search issues in webinterfacey 2025-10-14 15:18:55 -04:00
sbcrumb 8da23013ff fix: search issues in webinterfacey 2025-10-14 15:18:08 -04:00
sbcrumb b34691b22a Merge pull request 'feat: add webinterface' (#50) from web-interface into dev
Reviewed-on: #50
2025-10-14 14:58:15 -04:00
sbcrumb cebed7a6bb feat: add webinterface 2025-10-14 14:57:17 -04:00
sbcrumb 867bd008c5 fix: Improve Docker container shutdown handling
- Add proper signal handlers for graceful shutdown of all components
- Implement WebhookBatcher.shutdown() to properly close ThreadPoolExecutor and cancel timers
- Add NFOGuardDatabase.close() method to close thread-local database connections
- Increase uvicorn graceful shutdown timeout to 15 seconds
- Add cleanup in finally block to ensure resources are freed even if signal handler fails
- Reduce logging and server header overhead for better performance

This should resolve Docker container shutdown issues where containers don't respond to SIGTERM properly.

Bump version to 2.1.1

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-10-12 13:02:07 -04:00
sbcrumb c8fde41dfb fix: Ensure NFOGuard metadata appears at bottom of movie NFO files
- Improve element removal logic to only remove clearly NFOGuard-managed elements
- Use batch element addition to ensure NFOGuard metadata appears as contiguous block at bottom
- Better preserve existing non-NFOGuard metadata fields
- Fix issue where lockdata/dateadded appeared scattered throughout NFO instead of grouped at bottom

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-10-12 12:56:10 -04:00
sbcrumb a559873999 release: Clean up debug logging and bump to v2.1.0
FEATURES ADDED:
- Comprehensive TV IMDb detection (folder/filename/NFO)
- TV show batch validation prevents wrong series processing
- Fixed glob pattern for exact IMDb matching
- Enhanced webhook path mapping reliability

CLEANUP:
- Removed temporary debug print statements
- Clean production-ready logging
- Maintains all core improvements from debugging session

FIXES:
- Resolves Girls (2012) vs Golden Girls matching issue
- Proper bracket escaping in glob patterns
- Comprehensive IMDb detection for both movies and TV shows
2025-10-12 11:15:20 -04:00
sbcrumb baa6474d12 debug: Force path mapper debug logging to diagnose webhook path mapping failure
- Enable path_debug temporarily during webhook path mapping
- Version 2.0.28 to track path mapping diagnostics
- Should show Sonarr root folder matching and TV path mapping logic
2025-10-12 11:06:02 -04:00
sbcrumb 1a98c406a2 Merge pull request 'sonarr-matching' (#49) from sonarr-matching into dev
Reviewed-on: #49
2025-10-12 11:03:40 -04:00
sbcrumb 97aa2042a3 Merge branch 'dev' into sonarr-matching 2025-10-12 11:03:33 -04:00
sbcrumb 53d7753231 sonarr-matching (#48)
Reviewed-on: #48
2025-10-12 11:02:36 -04:00
sbcrumb 7216491d76 debug: Add comprehensive webhook path mapping debug logging
- Track webhook path provided from Sonarr webhooks
- Show path mapper availability and mapping results
- Trace why webhook paths fail and fallback to search occurs
- Version 2.0.27 for webhook path debugging
2025-10-12 11:01:59 -04:00
sbcrumb a5b2381023 resolve: Fix version conflict - use 2.0.26 from dev branch 2025-10-12 10:59:50 -04:00
sbcrumb 80df0a0278 chore: Bump version to 2.0.26 for enhanced debug logging 2025-10-12 10:57:33 -04:00
sbcrumb e780b9e125 debug: Add comprehensive logging to path search and title fallback
- Show all search paths being checked and their existence status
- Debug title fallback logic showing cleaned title comparisons
- Track exactly where false matches occur (e.g., 'girls' in 'thegoldengirls')
- Identify why only some search paths are processed
2025-10-12 10:57:20 -04:00
sbcrumb 59f562c2fa Merge pull request 'sonarr-matching' (#46) from sonarr-matching into dev
Reviewed-on: #46
2025-10-12 10:53:50 -04:00
sbcrumb 716dcc05e8 chore: Bump version to 2.0.25 for sonarr-matching fixes
- Increment version for comprehensive TV IMDb detection fixes
- Includes glob pattern fix and batch validation improvements
- Ready for deployment and testing
2025-10-12 10:53:50 -04:00
sbcrumb 000d57e9fe debug: Add logging to find_media_path_by_imdb_and_title to trace search behavior
- Add debug prints to see glob patterns being used
- Track number of matches found and which paths are returned
- Help diagnose why wrong TV series paths are still being found
2025-10-12 10:53:50 -04:00
sbcrumb a0f676a368 chore: Bump version to 2.0.25 for sonarr-matching fixes
- Increment version for comprehensive TV IMDb detection fixes
- Includes glob pattern fix and batch validation improvements
- Ready for deployment and testing
2025-10-12 10:52:48 -04:00
sbcrumb 60bbf9440a debug: Add logging to find_media_path_by_imdb_and_title to trace search behavior
- Add debug prints to see glob patterns being used
- Track number of matches found and which paths are returned
- Help diagnose why wrong TV series paths are still being found
2025-10-12 10:51:52 -04:00
sbcrumb a3f7fbe68e Merge pull request 'fix: Correct IMDb ID pattern matching in TV show webhook processing' (#45) from sonarr-matching into dev
Reviewed-on: #45
2025-10-12 10:47:02 -04:00
sbcrumb 348f83d547 feat: Add comprehensive IMDb detection for TV shows
- Add find_series_imdb_id method to check directory, filenames, and tvshow.nfo
- Add TV show batch validation in webhook batcher (like movies)
- Ensures TV webhooks process correct series by validating IMDb match
- Prevents mismatched processing like Girls (2012) -> Gachiakuta (2025)
- Comprehensive detection checks: folder name, file names, NFO content
2025-10-12 10:46:43 -04:00
sbcrumb 41bccb82b1 fix: Correct IMDb ID pattern matching in TV show webhook processing
- Fix glob pattern to escape brackets for literal [imdb-ID] matching
- Resolves issue where webhook for Girls (2012) [tt1723816] was incorrectly matching Gachiakuta (2025) [tt32612521]
- Previous pattern treated brackets as character sets instead of literal strings
2025-10-12 10:42:26 -04:00
sbcrumb 0827d30a76 Fix NFO extraction to handle lockdata+aired but no dateadded
The root issue was that NFO extraction methods required both dateadded
AND lockdata=true to return any data. But "The Chosen" NFOs have
lockdata=true and aired dates but no dateadded field, so extraction
returned None and TIER 2 fallback logic never triggered.

Changes:
- Modified extract_nfoguard_dates_from_nfo() to accept lockdata=true NFOs
- Added support for extracting aired dates from episode NFOs
- Updated logic to return data when lockdata=true regardless of dateadded
- Now TIER 2 will properly apply aired->dateadded fallback logic
- Updated version to 2.0.24

This ensures episodes with incomplete NFO metadata get proper fallback
processing instead of being ignored by the three-tier system.

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-10-11 14:38:37 -04:00
sbcrumb 5b9f5c44e1 Fix TV processor TIER 2 fallback logic for NFO files with aired but no dateadded
The issue was that TIER 2 (NFO cache) wasn't applying the aired date fallback
when NFO files contained aired dates but no dateadded values. This caused
episodes like "The Chosen" to show "no date added just air" because the
system would find lockdata in TIER 2 but reject it due to missing dateadded,
without applying the fallback logic.

Changes:
- Enhanced TIER 2 NFO processing to apply aired->dateadded fallback
- Added debug logging for NFO fallback cases
- Updated version to 2.0.23

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-10-11 13:12:36 -04:00
sbcrumb 21bfb97f4b Merge pull request 'update 2' (#44) from missin-sonarr into dev
Reviewed-on: #44
2025-10-11 09:49:53 -04:00
sbcrumb 14d551fc36 update 2 2025-10-11 09:49:14 -04:00
sbcrumb be075f884e Merge pull request 'Implement optimal caching hierarchy for both movies and TV shows' (#43) from sonarrissues into dev
Reviewed-on: #43
2025-10-10 16:18:34 -04:00
sbcrumb fb3040c554 Implement optimal caching hierarchy for both movies and TV shows
Perfect the caching strategy as requested:
1. Database check first (fastest lookup)
2. NFO file extraction with database caching
3. API calls only when absolutely necessary

Movies: Fixed order from NFO->DB->API to DB->NFO->API
TV Shows: Added missing NFO extraction tier between database and API

Benefits:
- First scan: Extract existing NFOGuard data from NFOs and cache in DB
- Subsequent scans: ~99% database cache hits, minimal API calls
- NFO recovery: Rebuild database from existing NFO files
- Webhook processing: Cache webhook dates in database immediately

Expected performance: Manual scans now use 3-tier optimization
2025-10-10 16:18:34 -04:00
sbcrumb e609c32df0 Merge pull request 'sonarrissues' (#42) from sonarrissues into dev
Reviewed-on: #42
2025-10-10 15:31:28 -04:00
sbcrumb 88b75230a7 Bump version to 2.0.21 to resolve merge conflict with dev branch 2025-10-10 15:30:06 -04:00
sbcrumb 035f2f6e02 Optimize TV processor performance with database-first episode lookup
Major performance improvement for manual scans:
- Check NFOGuard database before making Sonarr API calls
- Only query Sonarr history for episodes missing from database
- Filter Sonarr episode processing to only needed episodes
- Add detailed logging for cache hits vs API calls

Expected improvement: 172 API calls -> 0-10 API calls for typical scans
2025-10-10 15:30:06 -04:00
sbcrumb 76dcc0be33 Merge pull request 'Fix rename webhook targeting using episodeId lookup' (#39) from sonarrissues into dev
Reviewed-on: #39
2025-10-10 14:17:05 -04:00
sbcrumb d6c1b73959 Fix rename webhook targeting using episodeId lookup
Found that rename events contain episodeId field which can be used to fetch specific episode details. Replaced complex episode data extraction with direct API call to /api/v3/episode/{episodeId} to get season/episode numbers for targeted processing.

This should resolve rename webhooks processing entire series instead of single episodes.
2025-10-10 14:17:05 -04:00
sbcrumb ace671ab08 Merge pull request 'Add comprehensive rename event debugging' (#38) from sonarrissues into dev
Reviewed-on: #38
2025-10-10 14:11:53 -04:00
sbcrumb e03249bb36 Add comprehensive rename event debugging
Enhanced debugging for Sonarr rename events to understand the actual structure of episodeFileRenamed history records. Added checks for episode data in multiple possible locations (episode, episodeFile, data objects) to identify why season/episode information is missing.

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-10-10 14:11:07 -04:00
sbcrumb f78e8cf924 Merge pull request 'fix: Improve Sonarr history API query for rename events' (#37) from sonarr-api into dev
Reviewed-on: #37
2025-10-10 14:03:40 -04:00
sbcrumb a5eb23e886 fix: Improve Sonarr history API query for rename events
- Remove invalid eventType parameter that was causing 400 error
- Fetch recent history and filter client-side for episodeFileRenamed events
- Add better datetime parsing for event filtering
- Increase page size to 50 to catch more recent events
2025-10-10 14:03:40 -04:00
sbcrumb fe59875719 Merge pull request 'fix: Use environment variable directly for Sonarr API key' (#36) from sonarr-api into dev
Reviewed-on: #36
2025-10-10 11:17:29 -04:00
sbcrumb 4dff85c5c9 fix: Use environment variable directly for Sonarr API key
- Changed from config.sonarr_api_key to os.environ.get('SONARR_API_KEY', '')
- Matches pattern used in tv_processor.py for Sonarr API access
2025-10-10 11:17:02 -04:00
sbcrumb d12a634dd1 Merge pull request 'fix: Correct config attribute name for Sonarr URL in rename webhook handler' (#35) from rename-sonarr into dev
Reviewed-on: #35
2025-10-10 11:07:49 -04:00
sbcrumb 0e24f74dcb fix: Correct config attribute name for Sonarr URL in rename webhook handler
- Changed config.sonarr_base_url to config.sonarr_url to match actual config object
- Fixes AttributeError that was preventing rename webhook targeting from working
2025-10-10 11:02:53 -04:00
sbcrumb 6e70497be2 Merge pull request 'fix: Add targeted mode for rename webhooks using Sonarr history API' (#34) from rename into dev
Reviewed-on: #34
2025-10-10 10:40:16 -04:00
sbcrumb 3d0dae995a fix: Add targeted mode for rename webhooks using Sonarr history API
- Rename webhooks don't include episodeFile data from Sonarr
- Added fallback to query recent episodeFileRenamed events from history
- Uses 1-hour window to find most recent rename for targeted processing
- Prevents full series processing on single episode renames
2025-10-10 10:40:16 -04:00
sbcrumb 74c30d647e Merge pull request 'rename' (#33) from rename into dev
Reviewed-on: #33
2025-10-10 10:31:12 -04:00
sbcrumb 5cc7281f06 bump: Version 2.0.13 for rename webhook debug logging 2025-10-10 10:30:31 -04:00
sbcrumb 6e15d79b8f debug: Add comprehensive logging for rename webhook episodeFile analysis 2025-10-10 10:29:15 -04:00
sbcrumb f98fa9626c Merge pull request 'sonarr-all' (#32) from sonarr-all into dev
Reviewed-on: #32
2025-10-09 18:13:06 -04:00
sbcrumb 3787ca2d23 Fix all Sonarr webhooks: Force targeted processing for single episodes
- Extend episodeFile extraction to ALL webhook types (Download, Rename, Upgrade)
- Force targeted mode when episodes_data has ≤3 episodes to prevent full series processing
- Add debug logging to track episode extraction and processing mode decisions
- Prevents processing 177 episodes when only 1-3 episodes are involved
- Applies to imports, renames, and upgrades - not just renames
- Version bump to 2.0.12

This ensures that single episode operations (most common) only process the specific
episode(s) involved instead of scanning the entire series.
2025-10-09 18:11:47 -04:00
sbcrumb e897656396 Fix TV rename webhooks: Implement targeted episode processing
- Extract episode info from episodeFile when webhook.episodes is empty for rename events
- Create proper episode data structure for process_webhook_episodes method
- Prevents full series processing (177 episodes) for single episode renames
- Dramatically improves performance for rename operations
- Version bump to 2.0.11

Resolves issue where rename webhooks were falling back to full series processing instead of targeted mode.
2025-10-09 18:10:11 -04:00
sbcrumb 84322d91d0 Merge pull request 'Fix TV webhook processing: Add missing process_webhook_episodes method' (#31) from tv-webhook into dev
Reviewed-on: #31
2025-10-09 18:04:19 -04:00
sbcrumb ed78a81a8e Fix TV webhook processing: Add missing process_webhook_episodes method
- Add process_webhook_episodes method to TVProcessor class for targeted episode processing
- Implement helper methods: _parse_episode_from_filename, _get_sonarr_series_metadata, _get_episode_metadata, _extract_title_from_filename, _get_webhook_episode_date
- Enables webhook-based episode processing with title extraction and metadata handling
- Resolves 'TVProcessor' object has no attribute 'process_webhook_episodes' error
- Version bump to 2.0.10
2025-10-09 18:04:19 -04:00
sbcrumb 7ff2de6e66 Merge pull request 'sonarr-date' (#30) from sonarr-date into dev
Reviewed-on: #30
2025-10-05 10:43:34 -04:00
sbcrumb 5ade28c118 fix: Only fallback to airdate when Sonarr has no import date
- Modified fallback logic to only use airdate as dateadded when source is NOT 'sonarr:history.import'
- This prevents using airdate fallback when Sonarr has valid import dates available
- Ensures Sonarr import dates are prioritized over airdate fallbacks
- Addresses issue where episodes with valid Sonarr import dates were still falling back to airdates
2025-10-05 10:42:35 -04:00
sbcrumb 191e6f5f3f fix: Use Sonarr import history for TV episode date detection
- Modified _get_sonarr_episodes to call get_episode_import_history() for each episode
- This retrieves actual import dates from Sonarr's history API instead of just episodeFile.dateAdded
- Added fallback to episodeFile.dateAdded if history lookup fails
- Updated source tracking to use 'sonarr:history.import' for better debugging
- Added debug logging to show which method found the import date
- Should resolve issue where episodes fall back to aired dates despite valid Sonarr imports
2025-10-05 10:29:13 -04:00
sbcrumb 0dafcd64b2 Merge code-remove branch with TV processing fixes
Resolves TV series processing errors by fixing method calls and adding missing functionality:
- Fixed SonarrClient method names (series_by_imdb, episodes_for_series)
- Added missing get_episode_air_date method to ExternalClientManager
- Merged into dev branch at version 2.0.7
2025-10-04 11:43:37 -04:00
sbcrumb 0980ba0761 Fix TV processing issues: correct method names and add missing episode air date method
- Fix SonarrClient method call from get_series_by_imdb to series_by_imdb
- Fix SonarrClient method call from get_episodes to episodes_for_series
- Add missing get_episode_air_date method to ExternalClientManager
- Method uses TMDB and OMDb APIs to fetch episode air dates
- Version bump to 2.0.7
2025-10-04 11:41:35 -04:00
sbcrumb 251f84edbe Merge pull request 'code-remove' (#28) from code-remove into dev
Reviewed-on: #28
2025-10-04 11:13:47 -04:00
sbcrumb 816982c0eb Add missing psutil dependency to complete requirements
- Add psutil==5.9.6 for monitoring metrics functionality
- Container confirmed: manage_nfo=True (NFO creation enabled)
- All external dependencies now included in requirements.txt
- Version bump to 2.0.6 for complete dependency resolution
2025-10-04 11:13:47 -04:00
sbcrumb 7670a16ca3 Resolve conflicts: keep complete dependencies and version 2.0.6 2025-10-04 11:13:30 -04:00
sbcrumb acf741e6bb Add missing psutil dependency to complete requirements
- Add psutil==5.9.6 for monitoring metrics functionality
- Container confirmed: manage_nfo=True (NFO creation enabled)
- All external dependencies now included in requirements.txt
- Version bump to 2.0.6 for complete dependency resolution
2025-10-04 11:12:23 -04:00
sbcrumb 05ce62fc0e Merge pull request 'Add missing aiohttp dependency to requirements.txt' (#27) from code-remove into dev
Reviewed-on: #27
2025-10-04 11:09:37 -04:00
sbcrumb 01e96038ff Add missing aiohttp dependency to requirements.txt
- Add aiohttp==3.8.6 to fix ModuleNotFoundError in runtime_validator
- Container now shows config: manage_nfo=True (confirms NFO creation enabled)
- Version bump to 2.0.5
2025-10-04 11:09:37 -04:00
sbcrumb 17aec56952 Add missing aiohttp dependency to requirements.txt
- Add aiohttp==3.8.6 to fix ModuleNotFoundError in runtime_validator
- Container now shows config: manage_nfo=True (confirms NFO creation enabled)
- Version bump to 2.0.5
2025-10-04 11:08:58 -04:00
sbcrumb fc3cb50913 Merge pull request 'code-remove' (#26) from code-remove into dev
Reviewed-on: #26
2025-10-04 11:07:42 -04:00
sbcrumb 64791cb830 Resolve VERSION conflict: keep 2.0.4 for import fixes 2025-10-04 11:07:14 -04:00
sbcrumb d0e4182d38 Fix missing Tuple import in async_nfo_manager.py
- Add Tuple to typing imports to resolve NameError
- Fixes container startup failure after modular refactoring
- Version bump to 2.0.4
2025-10-04 11:06:07 -04:00
sbcrumb 22b78782eb Merge pull request 'code-remove' (#25) from code-remove into dev
Reviewed-on: #25
2025-10-04 11:04:48 -04:00
sbcrumb 8552fcfa87 Fix Docker entrypoint to use modular main.py instead of monolithic nfoguard.py
- Update Dockerfile to exec main.py instead of deleted nfoguard.py
- Fixes container startup failure after monolithic code removal
- Version bump to 2.0.3 for Docker compatibility fix
2025-10-04 11:04:48 -04:00
sbcrumb 08ff77760b Resolve VERSION conflict: keep 2.0.3 for Docker fixes 2025-10-04 11:04:27 -04:00
sbcrumb 4c2ebae4dc Fix Docker entrypoint to use modular main.py instead of monolithic nfoguard.py
- Update Dockerfile to exec main.py instead of deleted nfoguard.py
- Fixes container startup failure after monolithic code removal
- Version bump to 2.0.3 for Docker compatibility fix
2025-10-04 11:01:56 -04:00
sbcrumb 95ddc607f4 Merge pull request 'Remove monolithic nfoguard.py and add comprehensive debug logging' (#24) from code-remove into dev
Reviewed-on: #24
2025-10-04 11:00:21 -04:00
sbcrumb 72614af928 Remove monolithic nfoguard.py and add comprehensive debug logging
- Remove 2467-line monolithic nfoguard.py file to prevent confusion
- Add debug prints to identify why NFO metadata isn't being added
- Track config.manage_nfo values and function calls
- Debug dateadded parameter passing through all tiers
- Update version to 2.0.2
- This will help identify if manage_nfo is disabled or function not called
2025-10-04 11:00:21 -04:00
sbcrumb 863019f42a Remove monolithic nfoguard.py and add comprehensive debug logging
- Remove 2467-line monolithic nfoguard.py file to prevent confusion
- Add debug prints to identify why NFO metadata isn't being added
- Track config.manage_nfo values and function calls
- Debug dateadded parameter passing through all tiers
- This will help identify if manage_nfo is disabled or function not called

🤖 Generated with [Claude Code](https://claude.ai/code)
2025-10-04 10:57:50 -04:00
sbcrumb b06fd3f752 Merge pull request 'fix: webhook again' (#23) from webhook-again into dev
Reviewed-on: #23
2025-10-02 16:38:19 -04:00
sbcrumb 6c5ca59ed4 fix: webhook again 2025-10-02 16:37:39 -04:00
sbcrumb e7c29ddec4 Merge pull request 'fix: fixing webhook processing' (#22) from webhook-fix into dev
Reviewed-on: #22
2025-10-02 16:33:20 -04:00
sbcrumb 9f86b02665 fix: fixing webhook processing 2025-10-02 16:31:07 -04:00
sbcrumb 72bfcd9eed Merge pull request 'feat: metrics' (#21) from metrics into dev
Reviewed-on: #21
2025-09-29 09:23:48 -04:00
sbcrumb ba0eed0daf feat: metrics 2025-09-29 09:23:48 -04:00
sbcrumb 8b130deec6 Merge pull request 'feat: add config validation' (#20) from improvments into dev
Reviewed-on: #20
2025-09-29 08:59:47 -04:00
sbcrumb 7e1dd50ea0 feat: add config validation 2025-09-29 08:59:47 -04:00
sbcrumb 241d671d85 Merge branch 'main' into dev 2025-09-29 08:55:03 -04:00
sbcrumb 7ae8ce0024 dev (#18)
Reviewed-on: #18
2025-09-27 17:52:46 -04:00
sbcrumb 820c5472c3 Merge pull request 'feat: implement comprehensive async I/O capabilities' (#17) from async into dev
Reviewed-on: #17
2025-09-27 13:19:54 -04:00
sbcrumb 82eb29fd63 feat: implement comprehensive async I/O capabilities
- Add utils/async_file_utils.py with aiofiles-based operations
- Create core/async_nfo_manager.py for concurrent NFO processing
- Add async methods to TVProcessor for concurrent episode handling
- Implement async_find_episodes_on_disk() for faster scanning
- Add async_batch_create_episode_nfos() for concurrent NFO creation
- Support async_process_multiple_series() for parallel processing
- Optimize I/O performance for large media libraries
- Enable controlled concurrency with semaphores and rate limiting

Performance improvements:
- Concurrent file operations reduce I/O wait times
- Batch processing minimizes individual file access overhead
- Configurable concurrency limits prevent system overload
- Async directory scanning for faster episode discovery
2025-09-27 13:17:54 -04:00
sbcrumb 09612aeb4e version: Update to 2.0.0 for major architecture and code quality improvements
- Bump version to 2.0.0 to reflect major changes
- Represents completion of modular architecture transformation
- Includes comprehensive code quality improvements (Phase 3)
- All new utility modules and error handling systems
- Breaking changes: modular structure, new import paths
2025-09-27 13:09:46 -04:00
sbcrumb 6d840753a5 Merge branch 'improvements' into dev 2025-09-27 13:07:47 -04:00
sbcrumb 1a544dcba3 fix: restore .gitea directory and CI workflows
- Restore .gitea/workflows/ci.yml for main branch builds
- Restore .gitea/workflows/ci-dev.yml for dev branch builds
- Ensures Gitea CI pipeline will function properly
- Fixes issue where .gitea directory was accidentally removed
2025-09-27 13:05:41 -04:00
sbcrumb fa1e4e2ec7 improvements (#15)
Reviewed-on: #15
2025-09-27 13:00:08 -04:00
sbcrumb cab1a27144 chore: ensure .gitea directory is tracked and protected
- Remove .gitea/ from .git/info/exclude to allow tracking
- Add .gitea/workflows/ CI configuration files to repository
- Prevents accidental deletion of Gitea-specific CI workflows
- Ensures consistent build pipeline across repository instances
2025-09-27 12:59:15 -04:00
sbcrumb d7426281de feature: more omprovments phase 3 2025-09-27 12:53:20 -04:00
sbcrumb 03d9db9364 update gitignore 2025-09-27 12:47:34 -04:00
sbcrumb d14e2ef3cc refactor: split up from mono app to more modular design 2025-09-27 12:44:29 -04:00
sbcrumb 8da7853cca chore: untrack Gitea-only directories for GitHub sync protection 2025-09-27 11:42:39 -04:00
sbcrumb 2e7922dc8b dev to main (#14)
Reviewed-on: #14
Co-authored-by: SBCrumb <[email protected]>
Co-committed-by: SBCrumb <[email protected]>
2025-09-27 11:19:36 -04:00
sbcrumb ce1a599fd9 stop tvshow.nfo and show.nfo 2025-09-26 17:07:12 -04:00
sbcrumb 3826895275 title search 2025-09-26 16:59:12 -04:00
sbcrumb d2ad048f61 tv update again 2025-09-26 16:49:57 -04:00
sbcrumb 7027ae2745 update to nfoguard.py 2025-09-26 16:42:35 -04:00
sbcrumb 3d7460ad76 verixon update 2025-09-26 16:25:08 -04:00
sbcrumb 806370bd5f airdate fallback 2025-09-26 15:56:40 -04:00
sbcrumb fc80c08041 tuple update 2025-09-26 15:21:45 -04:00
sbcrumb 2c4dd274ff retry for the AI 2025-09-26 15:19:37 -04:00
sbcrumb c19d0f6290 update 2025-09-26 14:56:01 -04:00
sbcrumbandClaude b9e3ae3a5e debug: Add extensive logging for episode detection
Added detailed debug logging to diagnose why High Potential shows "0 episodes" despite having video files:

- Log season directory detection and pattern matching
- Log video file scanning and episode filename parsing
- Log season number extraction and matching logic
- Log final episode count and processing decisions

This will help identify where the episode detection is failing in the pipeline.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2025-09-26 14:37:41 -04:00
sbcrumbandClaude 4d30ea840f fix: Add Sonarr direct lookup fallback for reliable series detection
The /series/lookup endpoint sometimes fails to find series that exist in Sonarr. Added fallback to series_by_imdb_direct() method which scans all series directly.

Changes:
- Try Sonarr lookup endpoint first (fast)
- Fall back to direct series scan if lookup fails (slower but more reliable)
- Apply same logic to both episode dates and metadata methods
- Version bump to 2.0.5-sonarr-direct-lookup

This should find High Potential, Breaking Bad, and other series that exist in Sonarr but weren't found via the lookup endpoint.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2025-09-26 14:14:40 -04:00
sbcrumbandClaude 27677e49f8 fix: External API fallback flow and conditional NFO creation
Fixed two critical issues:
1. External API fallback was never reached because early returns when series/episodes not found in Sonarr
2. tvshow.nfo and season.nfo were always created even if they already existed

Changes:
- Restructured Sonarr lookup to fall through to external APIs when series/episodes not found
- Made tvshow.nfo and season.nfo creation conditional (only if files don't exist)
- Added debug logging for skipped NFO creation
- Version bump to 2.0.4-fallback-fix

Now series like High Potential should properly fall back to TMDB/OMDb for episode airdates.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2025-09-26 14:12:18 -04:00
sbcrumbandClaude 2e6d8a35e7 feat: Add external API fallback for TV episode airdates
When Sonarr lookup fails to find a series or episode data, the system now falls back to external APIs (TMDB, OMDb) to get episode air dates. This ensures episodes still get proper dateadded values even when not found in Sonarr.

Changes:
- Add ExternalClientManager integration to TVSeriesProcessor
- Implement _get_episode_airdate_from_external_apis method
- Support TMDB TV episode lookups with IMDb->TMDB ID conversion
- Support OMDb episode season lookups with date parsing
- Version bump to 2.0.3-external-fallback

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2025-09-26 13:56:57 -04:00
sbcrumb 12767d7350 fix: Remove invalid webhook_secret parameter from SonarrClient
SonarrClient constructor only accepts base_url and api_key parameters.
Version bump to 2.0.2-clean
2025-09-26 13:47:11 -04:00
sbcrumb 4411804725 fix: Sonarr client initialization error
Fix AttributeError for missing sonarr_url config attributes.
Use environment variables directly like the original code.
Version bump to 2.0.1-clean
2025-09-26 13:45:16 -04:00
sbcrumb b431107abd feat: Complete clean TV NFO processing implementation v2.0.0
- New EpisodeNFOManager class with video filename matching
- NFO files now match video filenames instead of S01E01.nfo format
- Clean TVSeriesProcessor with proper Sonarr API integration
- Proper date lookup: database -> Sonarr import history -> airdate fallback
- Updated manual scan endpoint to use new clean processor
- Removed all migration logic and nfo_migration_required sources

This is a complete rewrite of TV episode processing with focus on:
1. NFO filenames matching video files
2. Proper dateadded field population from Sonarr
3. Clean separation of concerns
4. Robust fallback logic
2025-09-26 13:39:41 -04:00
sbcrumb 9b0da8b6ed fix: Remove nfo_migration_required source and ensure proper date lookup
- Remove bogus 'nfo_migration_required' source that bypassed proper date lookup
- Ensure episodes with existing NFOs still get processed through Sonarr API
- Episodes should get proper dateadded from Sonarr import history or airdate fallback
- Version bump to 1.9.3-longnames
2025-09-26 10:11:52 -04:00
sbcrumb 9bc8955ad4 fix: Preserve and convert NFO filenames to match video files
- Preserve existing long-named NFO files instead of migrating to short names
- Convert existing short NFO names (S01E01.nfo) to match video filenames
- Create new NFOs with long names based on matching video files
- Only fallback to short names if no matching video file exists
2025-09-26 09:56:23 -04:00
sbcrumb a4c333aa85 Merge pull request 'version: Update version to 1.9.1 o push to github' (#13) from dev into main
Reviewed-on: #13
2025-09-25 14:39:07 -04:00
sbcrumb f515d70241 version: Update version to 1.9.1 o push to github 2025-09-25 14:38:17 -04:00
sbcrumb ade771f661 Merge branch 'dev' 2025-09-25 14:26:31 -04:00
sbcrumb 3e8ea79cbb update emby dll 2025-09-25 14:23:28 -04:00
sbcrumbandClaude 2b6fd27913 Fix movie webhook comprehensive IMDb detection and batch processing
- Fix movie webhook to use comprehensive IMDb detection (dir, filename, movie.nfo) instead of simple string search
- Fix batch processing NameError: use path_obj instead of undefined path variable
- Version bump to 1.8.3

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2025-09-25 13:31:27 -04:00
sbcrumbandClaude a8f8e0a7bd Fix movie webhook processing and organize NFOGuard fields
- Fix WebhookBatcher missing nfo_manager attribute that caused movie processing errors
- Move NFOGuard comment from top to bottom of episode NFOs with other NFOGuard fields
- All NFOGuard additions now grouped together at bottom for better organization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2025-09-25 13:11:23 -04:00
sbcrumb 5e3d4845b0 fix: Use comprehensive IMDb detection for movie batch validation
- Replace simple string search with find_movie_imdb_id() method
- Check directory name, filenames, and NFO file content for IMDb ID
- Handle both full IMDb IDs (tt26394837) and number formats
- Add detailed logging for detected vs expected IMDb matching
- Fix validation failures when IMDb ID is in filename/NFO but not directory name

Resolves movie webhook processing failures when directory lacks IMDb ID
but files contain proper identification.
2025-09-25 10:38:27 -04:00
sbcrumb 6a20337181 fix: Improve title detection logic to prevent duplicates
- Fix flawed title detection that caused duplicates when migrating NFOs
- Add robust title validation (check for non-empty, non-whitespace text)
- Improve debugging output to show title detection process
- Properly handle existing valid titles vs missing/empty titles
- Remove invalid title elements before adding filename-extracted titles

Resolves duplicate <title> elements in NFO files during migration.
2025-09-25 10:33:03 -04:00
sbcrumb f1420cac58 fix: Add title extraction directly to create_episode_nfo method
- Add _extract_title_from_filename method to NFOManager class
- Integrate title extraction fallback in create_episode_nfo method
- Extract titles when NFO lacks title element or has empty title
- Support both .mkv and .mp4 files with comprehensive logging
- Fix applied to actual NFO creation method being used by episodes

Resolves title extraction by working in the correct code path
rather than relying on metadata processing that wasn't being called.
2025-09-25 09:40:26 -04:00
sbcrumb 39c1e54922 debug: Add INFO log to track if _get_episode_metadata is called 2025-09-25 09:36:08 -04:00
sbcrumb e870474f07 debug: Change DEBUG logs to INFO to trace title extraction 2025-09-25 09:32:58 -04:00
sbcrumb b4fe0cb7fe bump: Version 1.8.2 with title extraction functionality 2025-09-25 09:26:09 -04:00
sbcrumb fef99b9bfd Merge pull request 'titlename-fix' (#10) from titlename-fix into dev
Reviewed-on: #10
2025-09-25 09:15:07 -04:00
sbcrumb c9567ce70b update: update version number 2025-09-25 09:15:07 -04:00
sbcrumb ba488cdbea Merge pull request 'titlename-fix' (#9) from titlename-fix into dev
Reviewed-on: #9
2025-09-25 09:08:50 -04:00
sbcrumb c33a26460d debug: Add comprehensive debugging for title extraction
- Add detailed debug logging to _extract_title_from_filename() method
- Log file search, regex pattern matching, and title cleanup process
- Fix Tier 2 processing to call _get_episode_metadata() for title extraction
- Add debug logging to _get_episode_metadata() to trace execution flow
- Include both .mkv and .mp4 file checking with detailed logs

This should help identify why title extraction isn't working for
recreated NFO files and provide visibility into the extraction process.
2025-09-25 09:08:50 -04:00
sbcrumb 91031ba321 Merge pull request 'fix: Add title enhancement for existing NFO files' (#8) from titlename-fix into dev
Reviewed-on: #8
2025-09-25 09:01:04 -04:00
sbcrumb a1921936f7 fix: Add title enhancement for existing NFO files
- Modify extract_nfoguard_dates_from_episode_nfo() to detect missing titles
- Add enhance_existing_episode_nfo_with_title() method to update NFOs with titles
- Enhance Tier 1 processing to check for missing titles in existing NFO files
- Extract titles from filenames and update existing NFOs that lack title elements
- Fix NFO filename case to match S01E01.nfo format
- Preserves all existing NFO content while adding missing title elements

Addresses issue where existing NFO files with NFOGuard metadata were
skipping title extraction due to Tier 1 optimization caching.
2025-09-25 09:01:04 -04:00
sbcrumb 7131aa211c Merge pull request 'fix: Add filename-based title extraction for TV episodes' (#7) from titlename-fix into dev
Reviewed-on: #7
2025-09-25 08:54:51 -04:00
sbcrumb 16d9956202 fix: Add filename-based title extraction for TV episodes
- Add _extract_title_from_filename() method to extract episode titles from video filenames
- Enhance _get_episode_metadata() to use filename fallback when Sonarr API doesn't provide titles
- Update all _get_episode_metadata() calls to pass season directory for filename extraction
- Extract titles from patterns like: Series-S01E01-Episode Title[WEBDL-1080p][AAC2.0][h264].mkv
- Preserves existing Sonarr API behavior while providing fallback for missing titles
- Fixes episodes with missing <title> elements in NFO files when Sonarr data is incomplete

Resolves issue where episodes processed via sonarr:history.import had empty NFO titles
despite clear title information being available in the video filenames.
2025-09-25 08:53:56 -04:00
sbcrumb edaa92ef9a dev (#6)
Reviewed-on: #6
2025-09-24 20:06:45 -04:00
sbcrumb c21a0df9c1 Merge pull request 'fix: nfo' (#5) from nfofix into dev
Reviewed-on: #5
2025-09-24 19:36:18 -04:00
sbcrumb 27430a7ead fix: nfo 2025-09-24 19:35:39 -04:00
sbcrumb 210af8ac4b tv-nfo (#4)
Reviewed-on: #4
Co-authored-by: SBCrumb <[email protected]>
Co-committed-by: SBCrumb <[email protected]>
2025-09-24 18:53:48 -04:00
sbcrumb ca2e77f4c7 remove .local 2025-09-24 15:14:40 -04:00
sbcrumb 70cc716cb8 remove .local 2025-09-24 15:14:18 -04:00
sbcrumb 18b6dad7ce Merge branch 'dev' 2025-09-24 15:11:24 -04:00
sbcrumb bd9ce8f48d feat: NFOGuard v1.8.0 - Comprehensive three-tier optimization system with international fallback
Major features and improvements:

🚀 THREE-TIER PERFORMANCE OPTIMIZATION:
- Tier 1: NFO file caching (99% faster, instant recovery)
- Tier 2: Database caching (90% faster, skip API calls)
- Tier 3: Full API processing (normal speed, only when needed)
- Extended to both movies AND TV episodes for complete coverage

🌍 INTERNATIONAL RELEASE DATE SUPPORT:
- Smart fallback hierarchy: US → English-speaking countries → any country
- Handles movies without US release dates (e.g., The Fabric of Christmas 2023)
- Prioritizes culturally relevant dates while ensuring comprehensive coverage

🎬 TMDB ID FALLBACK SYSTEM:
- Support for movies with TMDB IDs but no IMDb IDs (e.g., For the One 2024)
- Tolerant XML parsing handles NFO files with trailing URLs
- Extracts premiered dates from existing NFO files as fallback source

📊 ENHANCED DEBUGGING & MONITORING:
- Failed movies logged to logs/failed_movies.log for troubleshooting
- Comprehensive logging shows which optimization tier is used
- Enhanced IMDb/TMDB ID detection with detailed status messages

🔧 DATABASE REBUILD CAPABILITIES:
- Instant recovery from existing NFO files without API calls
- Perfect for migrations, disaster recovery, and system rebuilds
- Maintains performance even with thousands of movies/episodes

💾 SMART CACHING & NFO MANAGEMENT:
- NFOGuard fields properly positioned at bottom for Emby plugin compatibility
- Database-first optimization eliminates redundant API calls
- Intelligent should_query logic prevents unnecessary processing

Technical improvements include tolerant XML parsing, three-tier optimization for TV episodes, English-speaking country prioritization, TMDB fallback processing, failed movies debug logging, comprehensive database rebuild support, and performance optimizations eliminating 90%+ processing time on warm systems.
2025-09-24 14:19:31 -04:00
sbcrumb 0bd7c3f883 bump: Update VERSION to 1.8.0 for major release
Comprehensive three-tier optimization system with international fallback support
2025-09-24 14:18:21 -04:00
sbcrumb bd7538abbd feat: Add dedicated failed movies debug log
- Create logs/failed_movies.log for movies with no_valid_date_source
- Logs movie name, IMDb ID, failure reason, and timestamp
- Helps users identify problematic movies needing manual attention
- Useful for debugging API issues and monitoring system health
- Documented in SUMMARY.md action items for troubleshooting workflow
2025-09-24 14:10:02 -04:00
sbcrumb aa9f9d9194 feat: Prioritize English-speaking countries in release date fallback
- Add two-tier international fallback: English-speaking first, then any country
- English-speaking countries: GB, CA, AU, NZ, IE
- For The Fabric of Christmas (2023): will try AU, CA before BE
- Maintains cultural relevance while ensuring comprehensive coverage
- Clear logging shows which tier is used for transparency
2025-09-24 14:06:11 -04:00
sbcrumb fc3bd67138 feat: Add international release date fallback for movies without US dates
- Add fallback logic to use any available country when US release dates unavailable
- Maintains same release type priority (Digital, Physical, Theatrical)
- Should resolve The Fabric of Christmas (2023) no_valid_date_source issue
- Logs which country is used as fallback for transparency
- Available countries for this movie: AU, BE, CA
2025-09-24 14:05:10 -04:00
sbcrumb f49b1632fd fix: Add missing ElementTree import and use tolerant XML parser
- Add missing xml.etree.ElementTree import to nfoguard.py
- Fix _extract_dates_from_tmdb_nfo to use tolerant XML parser
- Resolves 'name ET is not defined' error in TMDB fallback processing
- For the One (2024) should now process successfully with premiered date
2025-09-24 13:58:00 -04:00
sbcrumb 830c1d9ae4 fix: Use tolerant XML parser for TMDB ID detection in NFO files
- Replace ET.parse() with _parse_nfo_with_tolerance() in parse_imdb_from_nfo
- Handles NFO files with URLs appended after </movie> tag
- Should resolve For the One (2024) TMDB ID detection issue
- NFO had TMDB ID 1339758 but trailing URLs prevented XML parsing
2025-09-24 13:53:52 -04:00
sbcrumb bfdf02ebd1 fix: Improve TMDB fallback detection and logging for movies without IMDb IDs
- Update find_movie_imdb_id to properly log TMDB vs IMDb ID detection
- Fix error message to indicate search for both IMDb AND TMDB IDs
- Add critical workflow reminder to SUMMARY.md for code-only development
- Should resolve For the One (2024) processing with TMDB ID 1339758
2025-09-24 13:49:54 -04:00
sbcrumb 994f430d45 feat: Implement comprehensive three-tier optimization system for movies and TV episodes
- Add NFO file extraction for TV episodes with dateadded detection
- Extend three-tier optimization (NFO cache, DB cache, full processing) to TV episodes
- Add TMDB ID fallback support for movies without IMDb IDs
- Implement Tier 1.5 processing for TMDB-only movies using premiered dates
- Update documentation with complete TV episode optimization details
- Add database rebuild capabilities for both movies and TV content
- Performance improvements eliminate 90%+ of processing time on warm systems
2025-09-24 13:37:01 -04:00
sbcrumb ea661bcd45 feat: Add TMDB ID detection for movies without IMDb IDs
Added fallback detection for movies that only have TMDB IDs in NFO files.
When no IMDb ID is found, the system now:

- Detects TMDB ID from <uniqueid type="tmdb"> elements
- Logs a helpful warning with the TMDB ID for manual lookup
- Provides clear guidance that manual IMDb lookup may be needed

This helps identify movies like "For the One (2024)" that have TMDB data
but are missing IMDb identifiers, allowing users to manually add IMDb IDs
to directory names or filenames when available.

Future enhancement: Could implement TMDB->IMDb API conversion.
2025-09-24 12:36:16 -04:00
sbcrumb d6d24e8840 feat: Add three-tier optimization for TV episodes
Extended the revolutionary three-tier performance system to TV episodes:

TIER 1 (Fastest): Episode NFO File Check
- Detects existing NFOGuard data in episode NFO files (dateadded + lockdata)
- Skips all database queries AND Sonarr API calls
- Perfect for database rebuilds from existing episode NFO files

TIER 2 (Fast): Episode Database Check
- Uses complete episode database entries when available
- Skips expensive Sonarr API calls for series/episode metadata
- Creates NFO from cached data only

TIER 3 (Normal): Full Episode Processing
- Only when neither NFO nor database have data
- Queries Sonarr APIs, processes dates, enhanced metadata

TV shows now benefit from same performance improvements as movies:
- Database rebuilds from existing NFO files (instant)
- Subsequent scans 90%+ faster for processed episodes
- Large TV libraries process efficiently with intelligent caching

This completes the performance optimization system across all media types.
2025-09-24 11:29:20 -04:00
sbcrumb 3889d3a31c feat: Add three-tier optimization for maximum performance
Implemented smart caching system that dramatically reduces processing time:

TIER 1 (Fastest): NFO File Check
- If NFO already has NFOGuard dateadded + lockdata, use it directly
- Skips both database queries AND API calls
- Perfect for movies already processed by NFOGuard

TIER 2 (Fast): Database Check
- If database has complete dateadded data, use it
- Skips expensive API calls to TMDB/OMDB/Radarr
- Creates NFO from cached data

TIER 3 (Slowest): Full Processing
- Only when neither NFO nor database have data
- Queries all APIs, processes dates, saves to database

This should dramatically improve full scan performance, especially
on subsequent runs where most movies already have NFOGuard data.

Expected speedup: 90%+ reduction in processing time for warm scans.
2025-09-24 11:21:38 -04:00
sbcrumb 172a364e6e debug: Add IMDb ID detection logging and database-first optimization
Added comprehensive debugging for IMDb ID detection to identify issues
with directories that don't have IMDb IDs in folder names but do have
them in filenames (like Adulthood (2025) [tt26657977]).

Also added database-first optimization that skips expensive API calls
when we already have complete data in the database. This should:
- Speed up full rescans significantly
- Reduce unnecessary API calls to TMDB/OMDB/Radarr
- Keep database size appropriate by not re-querying known movies

This should resolve both the small database size issue and improve
performance for movies that already have valid dateadded entries.
2025-09-24 11:16:56 -04:00
sbcrumb ed03f23272 fix: Move NFOGuard comment to bottom with other fields
Moved the NFOGuard source comment from the top of the XML file to
just before our field additions at the bottom. This creates a clear
separation showing where NFOGuard's modifications begin.

Expected structure:
[existing rich metadata]
<!-- NFOGuard - Source: tmdb:digital -->
<uniqueid type="imdb" default="true">tt123</uniqueid>
<premiered>2024-08-20</premiered>
<dateadded>2024-08-20T00:00:00+00:00</dateadded>
<lockdata>true</lockdata>
2025-09-24 10:08:10 -04:00
sbcrumb fc3c190c1b fix: Ensure NFOGuard fields are appended at bottom of NFO files
Changed from ET.SubElement() to ET.Element() + explicit append() to
guarantee that NFOGuard-managed fields appear at the very bottom of
NFO files after all existing content.

This is critical because:
- Emby plugin specifically looks for dateadded field
- All NFOGuard fields should be grouped together at the bottom
- Preserves existing rich metadata while clearly marking our additions

Added debug logging for dateadded field to confirm it's being written.
2025-09-24 10:06:29 -04:00
sbcrumb 17914ff8f4 debug: Add NFO creation debugging for Inside Out 2 issue
Added debug logging to understand why rich existing NFO metadata
isn't being preserved when NFOGuard updates files.

This will show:
- Parameters passed to create_movie_nfo
- Whether existing premiered dates are being preserved
- Why NFOGuard fields aren't being added to the bottom

This should help diagnose why Inside Out 2 NFO isn't getting
NFOGuard's dateadded and lockdata fields appended.
2025-09-24 10:02:52 -04:00
sbcrumb bf7ee784a6 debug: Add logging to diagnose Flow movie processing issue
Added debug logs to understand why Flow (2019) isn't calling external APIs
while Ambush (2023) works correctly. This will show:

- should_query calculation and reasoning
- _decide_movie_dates entry point and logic path
- Whether existing database data is being used incorrectly

This should help identify why digital release date lookup works for some
movies but not others with identical database states.
2025-09-24 09:11:28 -04:00
sbcrumb e995c27655 fix: Query external APIs when database entry has no dateadded
The system was skipping external API calls when a database entry existed
but had no useful data (dateadded: None). This caused movies to get minimal
NFO files even when digital release dates were available.

Added condition to should_query logic: also query APIs when we have an
existing database entry but no dateadded value.

This fixes the Flow (2019) issue where it should fetch digital release
dates from TMDB/OMDB but was returning "no_valid_date_source" instead.
2025-09-24 08:49:46 -04:00
sbcrumb 03a71527b4 feat: Add version-gitea tags to main branch builds
Updated main workflow to create version-gitea tagged images
(e.g. 1.7.0-gitea) alongside the standard latest tag.

This provides clear identification that the image came from
the Gitea build system and includes the version number for
easy tracking.

Main branch images will now be available as:
- sbcrumb/nfoguard:latest (standard)
- sbcrumb/nfoguard:1.7.0-gitea (versioned Gitea build)
2025-09-24 08:39:49 -04:00
sbcrumb 99ffc08c9b feat: Add version-specific dev tags for easier identification
- Bump version to 1.7.0 for NFO release date fix
- Add version-dev-gitea tag format (e.g. 1.7.0-dev-gitea)
- Push version tag to Gitea registry alongside dev tag
- Update build output to show clear pull command

This makes it easy to identify which dev build you're running
and ensures you get the latest Gitea dev build with the NFO fixes.
2025-09-24 08:38:22 -04:00
sbcrumb 5e36796e7e fix: Update CI workflows to use correct repository paths
Local Docker Build (Dev) / build-dev (push) Successful in 18s
Updated both main and dev workflows to use sbcrumb/nfoguard instead of
jskala/NFOguard for repository cloning and Docker registry paths.

This fixes the build failures where workflows were trying to clone from
the old repository location that no longer exists.

Changes:
- Updated git clone URLs in both ci.yml and ci-dev.yml
- Fixed Docker registry paths for image tagging and pushing
- Updated cache image manifest checking paths
2025-09-24 08:34:23 -04:00
sbcrumb c983e93a76 fix: Use release dates as dateadded fallback when import dates unavailable
Local Docker Build (Dev) / build-dev (push) Failing after 0s
When Radarr can't provide import/download dates, the system now properly
uses digital release dates, theatrical dates, or air dates as the dateadded
value for both NFO files and database storage.

This fixes the issue where movies would get minimal NFO content with
"no_valid_date_source" when valid release dates were available but not
being saved to the database for future use.

Key changes:
- Use release date as dateadded when import date is None but release date exists
- Ensure all found dates are saved to database for future manual scans
- Maintain proper source tracking for debugging
- Fix NFO files getting minimal content when valid dates exist
2025-09-24 08:31:12 -04:00
sbcrumb d854233470 docs: Document successful GitHub synchronization resolution
- Record repository sync status and resolution steps
- Explain GitHub force-push timestamp behavior
- Document current synchronized state between Gitea and GitHub
- Clarify expected 1-commit differential due to private file exclusion
2025-09-23 17:54:52 -04:00
sbcrumb 8329098590 docs: Update SUMMARY.md with enhanced workflow and privacy protection details
Local Docker Build (Main) / build (push) Successful in 14s
Local Docker Build (Main) / deploy (push) Successful in 0s
Local Docker Build (Dev) / build-dev (push) Successful in 18s
- Document emergency privacy breach resolution
- Add professional Git workflow documentation
- Detail protection features and command safeguards
- Include code examples of privacy protection measures
2025-09-23 17:50:20 -04:00
sbcrumb a99fb3e9c9 enhance: Add intelligent workflow guidance and error handling
- Command suggestions for typos and alternatives
- Workflow state analysis with next steps
- Enhanced error messages with examples
- Progress indicators throughout workflow
- Automated guidance for feature development
2025-09-23 17:40:07 -04:00
sbcrumb d85d9bf63a update: template
Local Docker Build (Main) / build (push) Successful in 23s
Local Docker Build (Main) / deploy (push) Successful in 1s
2025-09-23 17:24:57 -04:00
sbcrumb 4ba9611b70 Merge pull request 'Update README.md' (#1) from dev into main
Local Docker Build (Main) / build (push) Successful in 29s
Local Docker Build (Dev) / build-dev (push) Successful in 19s
Local Docker Build (Main) / deploy (push) Successful in 0s
Reviewed-on: #1
2025-09-23 17:21:42 -04:00
sbcrumb e90dcb2f6b Update README.md
Local Docker Build (Main) / build (pull_request) Successful in 23s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
Local Docker Build (Dev) / build-dev (push) Successful in 33s
2025-09-23 17:20:40 -04:00
sbcrumb 053a29ba8f fix: Update workflow configuration for current deployment setup
Local Docker Build (Main) / build (push) Successful in 24s
Local Docker Build (Main) / deploy (push) Successful in 0s
Local Docker Build (Dev) / build-dev (push) Successful in 28s
- Align repository references with current infrastructure
- Update Docker registry namespace consistency
- Ensure proper CI/CD pipeline functionality
2025-09-23 16:27:31 -04:00
sbcrumb 7ad0dd8ca2 feat: NFOGuard v1.7.2 - Complete media management system
Local Docker Build (Dev) / build-dev (push) Failing after 0s
Local Docker Build (Main) / build (push) Failing after 0s
Local Docker Build (Main) / deploy (push) Has been skipped
Initial clean repository with full NFOGuard codebase:
- Docker-based media file processing
- Webhook integration for Radarr/Sonarr
- NFO file management and organization
- Emby/Jellyfin plugin integration
- TMDB API integration for metadata
- Smart episode and movie renaming
- Comprehensive dual-repository development workflow

Author: SBCrumb
2025-09-23 16:12:58 -04:00
jskala 3a31ba8866 Merge pull request 'plugin-update' (#21) from plugin-update into dev
Reviewed-on: jskala/NFOguard#21
2025-09-23 12:17:55 -04:00
sbcrumb 814156035e update: Version bump for emby-plugin 2025-09-23 12:17:55 -04:00
sbcrumb c24625274e docs: Add critical .local directory security reminder
- Emphasize .local/ must NEVER be committed to GitHub
- Document security breach procedures if .local/ exposed publicly
- Reinforce that .local/ contains sensitive development information
- Add warning about privacy violations and Claude attribution details
2025-09-23 12:17:55 -04:00
sbcrumb c2dc0b2beb fix: Clean up commit template
- Remove co-authorship lines from template
- Keep template clean for releases
2025-09-22 14:26:49 -04:00
sbcrumb 3e017a3d17 Merge branch 'main' into dev 2025-09-22 14:23:10 -04:00
sbcrumb 91e749ccdc restore: Add .local development documentation back to Gitea
- Restore .local/ directory with all development documentation
- Files include SUMMARY.md, commit.md, workflow.md, and setup templates
- These files stay local and in Gitea only (excluded from GitHub)
2025-09-22 14:20:22 -04:00
sbcrumb 4d1dc3b06f fix: Configure .local/ directory as Gitea-only (not GitHub)
- Add .local/ to .gitignore to exclude from GitHub releases
- .local/ contains private development documentation and notes
- Files remain available locally and in Gitea for development reference
2025-09-22 13:59:14 -04:00
sbcrumb 41ece0af86 Establish shared history with GitHub main 2025-09-22 12:35:59 -04:00
sbcrumb 1848ed61bf Merge branch 'dev' 2025-09-22 12:24:04 -04:00
sbcrumb c04fe8d083 debug optional 2025-09-22 12:18:40 -04:00
sbcrumb 3e5c80279f fix: nfo logic long vs shortname 2025-09-22 11:02:12 -04:00
sbcrumb de3896b8c3 fix: add debug for .nfo ussues 2025-09-22 10:54:22 -04:00
jskala b1484641d9 fix: update tv .nfo handling
updated TV .nfo handling
2025-09-22 10:52:20 -04:00
sbcrumb 07b3110c72 fix branch issue in workflow 2025-09-22 10:35:05 -04:00
sbcrumb 96de17e0c5 update docker for force build 2025-09-22 10:31:26 -04:00
sbcrumb 7de99ef945 trying to add gitea 2025-09-22 10:12:07 -04:00
sbcrumb 95273be0b0 update flow 2025-09-22 10:07:39 -04:00
sbcrumb a74feeb67e udpates to flow and nfo manager for TV 2025-09-22 10:03:59 -04:00
sbcrumb e7749620f6 better docker handling for shutdown 2025-09-22 09:30:50 -04:00
sbcrumb f563c67405 update sonarr .nfo flow 2025-09-22 09:25:03 -04:00
sbcrumb 93cee77ce0 stop main flow when dev runs 2025-09-22 09:13:08 -04:00
sbcrumb 927d7cdeff another workflow upate local 2025-09-22 09:04:01 -04:00
sbcrumb a4b1042b65 update local workflow 2025-09-22 09:01:21 -04:00
sbcrumb 09b28abcdc make local dir for ignores in local docs 2025-09-22 08:36:46 -04:00
sbcrumb fa3f41e6fd update Dev 2025-09-21 09:32:27 -04:00
sbcrumb b881c7944c initial updates to mirror github files 2025-09-21 09:30:12 -04:00
sbcrumb c6d5d2e6dc updates 2025-09-21 09:28:06 -04:00
sbcrumb 22483717c4 initial update 2025-09-21 09:25:36 -04:00
sbcrumb 49d63a9136 Bump version from 1.6.7 to 1.6.8
MAJOR IMPROVEMENTS:
  • Enhanced movie detection for directories without IMDb IDs in folder names
  • Fixed NFO processing to execute before date validation (preserves existing metadata)
  • Added smart episode NFO migration to standardized S##E## format
  • Resolved database save issues with proper upsert operations for dateadded values
  • Implemented comprehensive IMDb ID detection from filenames and NFO content

  TECHNICAL CHANGES:
  • nfoguard.py:1008-1025 - Fixed early exit logic to process NFOs regardless of date availability
  • core/nfo_manager.py - Added find_existing_episode_nfo() and enhanced episode processing
  • core/database.py - Fixed upsert operations to properly save dateadded values
  • Enhanced find_movie_imdb_id() to detect IMDb IDs from multiple sources

  CORE FIXES:
  • Movie NFO processing now happens before date validation checks
  • Episode NFOs with long names automatically migrated to S##E## format
  • Database operations properly handle both new entries and updates
  • Movies without IMDb in directory names now detected via filenames/NFO content
  • All existing metadata preserved during NFO standardization

  RESULTS:
  ✅ Existing movie.nfo files receive full NFOGuard processing
  ✅ Episode NFOs standardized to proper S##E## naming convention
  ✅ Database persistence works correctly for all date scenarios
  ✅ Enhanced movie detection covers edge cases
  ✅ Maintained backward compatibility with existing configurations
2025-09-21 08:53:58 -04:00
sbcrumb fcc21d8eb7 fix: NFO processing overhaul - comprehensive metadata management
Commit Message:
  fix: NFO processing overhaul - comprehensive metadata management

  MAJOR IMPROVEMENTS:
  • Enhanced movie detection for directories without IMDb IDs in folder names
  • Fixed NFO processing to execute before date validation (preserves existing metadata)
  • Added smart episode NFO migration to standardized S##E## format
  • Resolved database save issues with proper upsert operations for dateadded values
  • Implemented comprehensive IMDb ID detection from filenames and NFO content

  TECHNICAL CHANGES:
  • nfoguard.py:1008-1025 - Fixed early exit logic to process NFOs regardless of date availability
  • core/nfo_manager.py - Added find_existing_episode_nfo() and enhanced episode processing
  • core/database.py - Fixed upsert operations to properly save dateadded values
  • Enhanced find_movie_imdb_id() to detect IMDb IDs from multiple sources

  CORE FIXES:
  • Movie NFO processing now happens before date validation checks
  • Episode NFOs with long names automatically migrated to S##E## format
  • Database operations properly handle both new entries and updates
  • Movies without IMDb in directory names now detected via filenames/NFO content
  • All existing metadata preserved during NFO standardization

  RESULTS:
  ✅ Existing movie.nfo files receive full NFOGuard processing
  ✅ Episode NFOs standardized to proper S##E## naming convention
  ✅ Database persistence works correctly for all date scenarios
  ✅ Enhanced movie detection covers edge cases
  ✅ Maintained backward compatibility with existing configurations
2025-09-21 08:52:41 -04:00
sbcrumb cd22cd3340 feat: enhance NFO processing and movie detection
* fix: NFO processing overhaul - looking GOOD!

  Listen up, buddy! Your boy Cat just made this NFO processing
  smoother than my pompadour on a good hair day!

  WHAT'S THE DEAL?
  • Movie NFOs were getting DISSED when dates went missing
  • Episode NFOs with long names were cluttering up the joint
  • Nobody wants a messy media library - that's just WRONG!

  WHAT I FIXED (because I'm THAT good):
  • Movies: NFO processing now happens FIRST, baby! No more
    skipping when dates are wonky - we standardize EVERYTHING!
  • TV Shows: Long episode NFO names? GONE! We migrate that
    metadata to proper S##E## format and trash the old junk
  • Dates get moved to the bottom where they belong (it's all
    about the STYLE!)
  • NFOGuard signatures added to everything (gotta sign your work!)

  FILES TOUCHED:
  • nfoguard.py:1008-1025 - Fixed that early exit nonsense
  • core/nfo_manager.py - Added find_existing_episode_nfo() function
  • core/nfo_manager.py - Enhanced create_episode_nfo() with migration

  RESULTS:
  ✨ Existing movie.nfo files get the full treatment
  ✨ Long episode NFO names become sleek S##E## format
  ✨ All metadata preserved (we're not ANIMALS!)
  ✨ Dates organized properly at the bottom
  ✨ NFOGuard branding on everything

  This code is now looking SO good, it should be in GQ!
  What's not to like about perfection?

* testing: Testing database date adds for old files

* database debuging

* fixing database calls as well as ID for imdb

* fix: dirs with no imdb in title

movies with no imdb getting skipped

* update: fix core parsing
2025-09-20 20:24:02 -04:00
sbcrumb d5937b13be Docker compose update (#10)
* chore: update docker compose example

update/correct missing emby bind point for .dll
2025-09-19 16:40:23 -04:00
sbcrumb 3abef7057c Delete docker-compose.example
remove old docker-compose example
2025-09-19 16:28:52 -04:00
sbcrumb 31d2c6404a chore: workflow (#7)
* Update release-drafter to use DockerHub instead of GHCR
2025-09-19 15:00:29 -04:00
sbcrumb 5dce72841b chore: update versions 2025-09-19 14:48:30 -04:00
sbcrumb 605e78c093 feat: update/add workflow 2025-09-19 14:42:17 -04:00
sbcrumb 32d614f2f8 Update Emby plugin availability in README
Update readme to remove discord requirement for .dll file
2025-09-19 08:26:40 -04:00
sbcrumb 82310b5f43 feat: adding .dll file to docker image 2025-09-19 08:23:38 -04:00
jskala f5fd954ebb Merge pull request 'feat: add DLL to docker image' (#16) from dev into main
Reviewed-on: jskala/NFOguard#16
2025-09-19 08:18:04 -04:00
sbcrumb 4eeb30a922 chore: update version number 2025-09-19 08:18:04 -04:00
sbcrumb cce9840b10 chore: add DLL to docker image
Added in the Emby DLL file for the project to copy to plugin dir
2025-09-19 08:18:04 -04:00
sbcrumb b1c5c825f8 chore: cleanup old comment lines
clean up older comment lines from old code base.
2025-09-17 21:51:12 -04:00
sbcrumb cbfd21c4ee feat: upload intial code
Uploading intial Project Code
2025-09-17 21:44:30 -04:00
jskala 77b0cdf898 Merge pull request 'fix workflow' (#15) from dev into main
Reviewed-on: jskala/NFOguard#15
2025-09-17 15:47:35 -04:00
sbcrumb 7734377638 fix workflow 2025-09-17 15:44:45 -04:00
sbcrumb 539b09fc5b update to TVDB 2025-09-17 15:32:34 -04:00
sbcrumb 532ea02a5b update to docker 2025-09-17 15:23:29 -04:00
sbcrumb d64228cc82 Add NFO-based identification and smart episode renaming v1.6.0
- Add dual identification system: directory names + NFO file parsing
- Support <uniqueid type="imdb">, <imdbid>, <imdb> XML tags
- Implement smart episode NFO renaming to S##E##.nfo format
- Detect existing Sonarr/other NFO files and preserve metadata
- Add comprehensive Project.md for context recovery
- Update documentation with new capabilities
2025-09-16 21:14:09 -04:00
sbcrumb 86235142a0 Fix Discord link and improve README content
Updated README to enhance clarity and correct links.
2025-09-16 20:42:36 -04:00
sbcrumb 44fdc1cdf0 Update README with alpha notice and Discord link
Added alpha software notice and Discord plugin information to README.
2025-09-16 17:03:46 -04:00
sbcrumb f272baf2a4 Add sample Docker Compose configuration for NFOGuard
This file contains a sample Docker Compose configuration for NFOGuard, including service definitions, environment variables, volume mappings, health checks, and setup instructions.
2025-09-16 17:01:12 -04:00
sbcrumb aae36ac145 Add .env.secrets template for configuration 2025-09-16 16:58:55 -04:00
sbcrumb faa320d299 Clarify Radarr database connection requirements
Updated Radarr database connection comments for clarity and requirements.
2025-09-16 16:56:51 -04:00
sbcrumb fab3da938f Update README.md 2025-09-16 16:49:53 -04:00
sbcrumb 0e9b117ff4 Revise LICENSE with new copyright and terms 2025-09-16 16:47:30 -04:00
sbcrumb cd31caf313 Update download links in README.md 2025-09-16 16:43:10 -04:00
sbcrumb b7d695ab62 Create README.md for NFOGuard project 2025-09-16 16:41:27 -04:00
sbcrumb 2c13fd778a Initial commit 2025-09-16 16:39:22 -04:00
sbcrumb 220ca3ab5f Fix VERSION conflict and add v1.5.5 release notes 2025-09-16 16:08:37 -04:00
sbcrumb 768eb01563 update version number 2025-09-16 16:04:15 -04:00
sbcrumb 722f260726 Fix Docker build with PostgreSQL dependencies 2025-09-16 16:02:23 -04:00
sbcrumb 5e2a3b1aba Fix Docker build with PostgreSQL dependencies 2025-09-16 15:59:13 -04:00
sbcrumb 552addbdb9 Fix docker build missing context 2025-09-16 15:55:36 -04:00
sbcrumb 9df0b969b0 fix python 2025-09-16 15:53:47 -04:00
sbcrumb 571d0400b2 python fix 2025-09-16 15:47:54 -04:00
sbcrumb 339d023b85 update to python 3.13 2025-09-16 15:38:51 -04:00
sbcrumb bc63d6a5a4 git docker hub perm issue 2025-09-16 15:32:17 -04:00
sbcrumb 9e5c6feb47 adding docker workflow 2025-09-16 15:29:22 -04:00
sbcrumb 1353b53cc8 CRITICAL: Fix database upsert bug preventing manual scan date persistence
**Root Cause**: upsert_movie_dates() was UPDATE-only, not proper upsert
- Manual scans failed to save dateadded to database (NULL values)
- Webhooks found database entries but with NULL dateadded
- Fell back to current timestamp instead of using proper import dates

**Database Fix**:
- Changed core/database.py upsert_movie_dates() from UPDATE to INSERT OR REPLACE
- Now properly saves dateadded during manual scans
- Preserves existing path with COALESCE fallback

**Webhook Enhancement**:
- Added comprehensive debug logging for database lookups
- Enhanced webhook date decision logic with proper fallback chain
- Only uses current timestamp as absolute last resort

**Impact**:
- Movies: Manual scans now persist dates, webhooks find existing entries ✅
- TV Shows: Not affected - already using proper INSERT OR REPLACE ✅
- Version: 1.5.5
2025-09-15 12:18:53 -04:00
sbcrumb ed4a9c0a19 Fix Radarr rename event handling - add missing event type filtering
- Add event type filtering to Radarr webhook handler (Download, Upgrade, Rename)
- Ensures consistency with Sonarr webhook processing
- Fixes issue where Radarr rename events weren't properly updating NFO files
- Bump version to v1.5.3
- Update SUMMARY.md with complete webhook workflow documentation
2025-09-15 09:35:41 -04:00
sbcrumb df04cf9aec update 2025-09-15 09:16:28 -04:00
sbcrumb 75b8338b86 remove hardcode debug statments in favor of variables 2025-09-14 14:38:14 -04:00
sbcrumb 1dae7df3d2 updates to paths again 2025-09-14 14:23:25 -04:00
sbcrumb 1a562f7b41 missing path 2025-09-14 14:21:09 -04:00
sbcrumb f5ae8c7768 cleanup 2025-09-14 14:19:39 -04:00
sbcrumb a9c90d2939 remove debug 2025-09-14 14:15:19 -04:00
sbcrumb 2657729975 dates for TV shows 2025-09-14 13:56:31 -04:00
sbcrumb d7db924510 dates at bottom 2025-09-14 13:54:04 -04:00
sbcrumb 179f8f51d7 nfo dates to the bottom 2025-09-14 13:49:15 -04:00
sbcrumb e7b65cfed6 nfo fix again 2025-09-14 13:44:56 -04:00
sbcrumb 49266d651a fix nfo 2025-09-14 13:40:22 -04:00
sbcrumb 05ba16666e another DB table 2025-09-14 13:31:54 -04:00
sbcrumb b1f670415d database fix 2025-09-14 13:29:02 -04:00
sbcrumb 609f17713a fix path mapping wrong with smartstring 2025-09-14 13:25:32 -04:00
sbcrumb d168cc8317 path helper 2025-09-14 13:21:05 -04:00
sbcrumb 7b72d2d20b update DB tables 2025-09-14 13:15:48 -04:00
sbcrumb 04b0202903 removal of hard codeing 2025-09-14 13:13:13 -04:00
sbcrumb c20cefb571 fix hardcoded paths 2025-09-14 13:07:50 -04:00
sbcrumb bbe5f8c892 push updates to paths again 2025-09-14 13:03:41 -04:00
sbcrumb 27cc2f878e update movie paths 2025-09-14 12:56:47 -04:00
jskala ec11a54e02 Merge pull request 'github-claude' (#4) from github-claude into dev
Reviewed-on: jskala/NFOguard#4
2025-09-14 12:51:54 -04:00
sbcrumb ebd1191598 updates to flow 2025-09-14 12:51:15 -04:00
sbcrumb e741e4b046 fix: isolate movie and TV webhook processing to prevent cross-contamination
- Add prefixed batch keys (movie:imdbid, tv:imdbid) to prevent IMDb ID collisions
- Add path existence validation for Radarr webhooks to reject invalid mappings early
- Remove duplicate Radarr webhook handler code
- Add debug scripts for troubleshooting webhook and path mapping issues
- Create corrected .env template with fixed TV_PATHS and SONARR_ROOT_FOLDERS

This fixes the issue where TV path mapping failures caused movie webhooks
to process wrong movies due to shared batch queue corruption.
2025-09-14 12:43:07 -04:00
sbcrumb 91881262ce docs: complete API keys documentation in README.md
Documentation Enhancement:
- Add missing TVDB_API_KEY to all relevant sections in README.md
- Create comprehensive API keys reference table with purposes and sources
- Add centralized API Keys Configuration section with clear examples
- Document how to resolve "TVDB API key not configured" warnings
- Include direct links to obtain API keys from each service

API Keys Covered:
- TMDB_API_KEY - Movie release dates and metadata fallbacks
- TVDB_API_KEY - TV show metadata and Emby compatibility (was missing!)
- RADARR_API_KEY - Movie import history and database access
- SONARR_API_KEY - TV episode import history

This resolves user questions about the TVDB API key warning and provides
complete documentation for all external API integrations.
2025-09-14 12:07:23 -04:00
sbcrumb 5b7d4cdbd0 feat: improve NFO file organization by moving NFOGuard elements to bottom
NFO Organization Enhancement:
- Move all NFOGuard elements (dateadded, lockdata, comments) to bottom of NFO files
- Remove existing NFOGuard elements and re-add at bottom for clean organization
- Provides better separation between media metadata and NFOGuard management
- Easier to read NFO files when Radarr/Sonarr has already populated extensive metadata

Before: NFOGuard elements mixed throughout existing metadata
After: All media metadata first, then NFOGuard elements grouped at bottom

This addresses user feedback about NFO readability when files already contain
extensive metadata from Radarr, making it easier to see both media info and
NFOGuard management details in a logical order.
2025-09-14 11:21:44 -04:00
sbcrumb df5785e8ad feat: MAJOR - implement webhook-first architecture with database priority
Revolutionary Workflow Changes (v0.7.0):
This fundamentally changes how NFOGuard handles timestamps and processing priority.

Webhooks = Source of Truth:
- First webhook fires → Use current timestamp → Store as permanent database entry
- Subsequent webhooks (upgrades) → Check database → Use original first-seen timestamp
- No more API calls during webhook processing → Webhook timing is ultimate authority
- Movies and TV episodes both use webhook-first approach

Manual Scans = Smart Fallback Logic:
- Priority 1: Our database (webhook timestamps) - database always wins
- Priority 2: Sonarr/Radarr import history (first import only)
- Priority 3: Air date as dateadded (final fallback)

Technical Implementation:
- Enhanced _get_webhook_episode_date() to use current timestamp as source of truth
- Added webhook_mode parameter to process_movie() for separate webhook logic
- All manual scans prioritize database entries before making API calls
- All timestamps converted to container timezone (Eastern Time)
- Enhanced debug logging for database lookups and timestamp decisions

Expected Workflow:
First download at 8:30am → webhook timestamp stored in database
Upgrade at 2:00pm → database entry found → original 8:30am timestamp preserved
Manual scan → database entry found → 8:30am timestamp used

This ensures the first-seen webhook timestamp is the permanent source of truth,
with upgrades and manual scans always preserving the original download time.
2025-09-14 10:52:43 -04:00
sbcrumb e188706001 fix: webhook episode processing now uses real Sonarr import history
Critical Fix for Episode Import Dates:
- Webhook episodes now query Sonarr import history instead of using current time
- Fix _get_webhook_episode_date() to follow same logic as manual scans
- Convert import dates from UTC to local timezone for NFO dateadded fields
- Episodes now show 'sonarr:history.import' source instead of 'webhook:new_download'

Before: <dateadded>2025-09-14T12:40:07+00:00</dateadded> (current webhook time)
After:  <dateadded>2025-09-14T08:40:07-04:00</dateadded> (real import time in local timezone)

This fixes the issue where episodes downloaded at 8:40am EST were showing
12:40pm UTC webhook time instead of the actual import timestamp.

Enhanced webhook processing now:
1. Checks database for existing entries (prevents duplicates)
2. Queries Sonarr import history for real import dates
3. Converts UTC import dates to local timezone
4. Only uses current time as last resort for true new downloads
2025-09-14 10:41:43 -04:00
sbcrumb d8754e41a7 fix: NFO timestamp consistency and enhanced episode processing debug
NFO Management Timestamp Fix:
- Fix NFO management comments to use local timezone instead of UTC
- Both movie and TV episode NFO files now show consistent local timezone
- Comments now show: <!-- managed by NFOGuard at 2025-09-14T09:29:06-04:00 -->

Enhanced Episode Processing Debug:
- Add comprehensive debug logging for webhook episode database lookups
- Track IMDb ID and season/episode info throughout processing pipeline
- Add database write verification to catch storage issues immediately
- Enhanced logging will help identify duplicate processing root causes

This addresses the issue where episodes were being treated as new downloads
instead of finding existing database entries, helping prevent duplicate processing.
2025-09-14 10:27:05 -04:00
sbcrumb 7af7b1c43e feat: complete timezone support for NFO dateadded fields
- Fix NFO dateadded timestamps to use local timezone instead of UTC
- Convert Radarr/Sonarr import dates from UTC to local timezone for NFO files
- Update webhook episode processing to use local time for new downloads
- Convert file modification time fallbacks to local timezone
- Preserve historical dates (aired, premiered) in original UTC format
- Replace all placeholder logging functions with centralized timezone-aware logging
- Add convert_utc_to_local() utility function for timestamp conversion
- Now all logs AND NFO files show consistent local timezone formatting

Users with TZ=America/New_York will now see:
- Logs: [2025-09-14T09:37:00-04:00]
- NFO files: <dateadded>2025-09-14T09:37:00-04:00</dateadded>
2025-09-14 09:56:27 -04:00
sbcrumb 5c7300fc74 update: VERSION to v0.6.4 and add development workflow instructions
- Update VERSION file from 0.6.1 to 0.6.4 to match timezone logging release
- Add development workflow instructions to SUMMARY.md for future updates
- Document Claude Code's authority to write commits and push changes
- Establish version consistency requirements across VERSION, SUMMARY.md, and git tags
2025-09-14 09:51:11 -04:00
sbcrumb 17ef94ec4f feat: implement timezone-aware logging system
- Fix inconsistent timestamp formats in logs for easier troubleshooting
- All logging systems now respect Docker TZ environment variable (e.g., TZ=America/New_York)
- Add TimezoneAwareFormatter for Python's standard logging module
- Enhance _log function with robust timezone support and fallbacks
- Replace placeholder logging functions with centralized core.logging._log
- Support both zoneinfo (Python 3.9+) and pytz (older versions)
- Users now see consistent timezone formatting across all log outputs
2025-09-14 09:45:32 -04:00
sbcrumb f4c56f6cbe feat: smart NFO management and correct episode date field mapping
💾 Smart NFO File Management:
- Add intelligent content comparison to prevent unnecessary file overwrites
- Only update NFO files when content actually changes (ignoring timestamp differences)
- Dramatically reduce file system writes and improve scan performance
- Applied to both TV episodes and movies for comprehensive efficiency
- New _nfo_content_matches() method for smart content comparison

🗓️ Fix Episode Date Field Mapping:
- BREAKING: Correct date field usage in episode NFOs
- <aired> and <premiered>: Now use historical air dates (e.g., 1951-10-15)
- <dateadded>: Correctly shows actual download/import date (e.g., 2025-09-13)
- Resolves confusion between when show aired vs when user downloaded it
- Media servers now show historically accurate metadata with working "Recently Added"

🎯 Technical Changes:
- Enhanced create_episode_nfo() with content comparison logic
- Enhanced create_movie_nfo() with content comparison logic
- Separated historical date logic from import date logic
- Added comprehensive debug logging for file skip/update decisions

🏆 User Experience:
- Faster scans with fewer unnecessary file operations
- Correct historical context in media servers
- "Recently Added" functionality preserved and working
- No more metadata confusion between air dates and import dates

Example NFO output:
- <aired>1951-10-15</aired> (historical)
- <dateadded>2025-09-13T21:39:28+00:00</dateadded> (when downloaded)
- <premiered>1951-10-15</premiered> (historical)
2025-09-13 19:52:43 -04:00
sbcrumb 2d89e87191 feat: revolutionize TV episode date handling for webhook processing
🎯 Problem Fixed:
- TV episodes downloaded today showed historical air dates (1951) as dateadded
- Episodes appeared as old content instead of "recently added" in media servers
- Incorrect fallback logic used air dates instead of actual download times

⚡ Revolutionary Changes:
- Webhook-first processing: When Sonarr webhook fires → use current timestamp
- Database priority: Existing NFOguard entries preserved (no re-processing)
- Smart backfill: Manual scans check Sonarr import history first
- Clear separation: Webhook vs backfill use different logic paths

🔧 Technical Implementation:
- New method: _get_webhook_episode_date() for webhook-specific handling
- Enhanced method: _get_single_episode_date() for proper backfill priority
- Database-first approach: Always check NFOguard DB before external APIs

📋 Correct Processing Flow:
1. New episode download → Sonarr webhook → current time as dateadded
2. Re-download same episode → use existing stored date (preserve original)
3. Backfill scan → NFOguard DB → Sonarr history → air dates (last resort)
4. No bulk import - database populated through webhooks and manual scans

🏆 Results:
- Episodes downloaded today now show today's date as dateadded
- Historical air dates preserved in aired field for accuracy
- "Recently Added" functionality restored in media servers
- No regression for existing properly-dated episodes

🐛 Bug Fixes:
- Fixed f-string syntax error in core/nfo_manager.py:324
- Resolved backslash escaping issue preventing startup
2025-09-13 19:49:39 -04:00
sbcrumb f998a64cdb update f string 2025-09-13 19:23:16 -04:00
sbcrumb 1bf2725a5b iupdate 2025-09-13 19:15:22 -04:00
sbcrumb e3038ee279 updates to summarty 2025-09-12 15:59:42 -04:00
sbcrumb c6e4e071e4 cleanup 2025-09-12 15:46:02 -04:00
sbcrumb 558a786bdb chore:file cleanup 2025-09-12 15:41:56 -04:00
sbcrumb 4ef28c3021 updates 2025-09-12 13:54:29 -04:00
sbcrumb 77295cc400 various NFOguard versions 2025-09-12 10:30:12 -04:00
sbcrumb 08cddf7799 dll integration 2025-09-12 09:04:23 -04:00
sbcrumb dcbb25ca9e Fix Emby TV episode date handling by using <aired> field for import dates
- Issue: Emby displays DateCreated field which gets updated on metadata refresh
- Root cause: Emby ignores <dateadded> but reads <aired> for TV episodes
- Solution: Write import date to <aired> field instead of actual air date
- Emby will now display import date (Feb 19) instead of scan date (Sep 11)
- Maintains backward compatibility with fallback to actual air date
2025-09-11 16:45:36 -04:00
sbcrumb 8674b60c85 Fix CI pipeline docker history command failure
🔧 Pipeline Fix:
• Added error handling for docker history command that was failing
• Exit status 141 (SIGPIPE) from head command causing pipeline failure
• Build was actually successful, just diagnostic command failing

✅ Changes:
• Wrapped docker history in proper error handling
• Pipeline will continue even if history display fails
• Build success is preserved regardless of diagnostic command issues

🎯 Root Cause:
• Permission issue with Docker config file loading
• SIGPIPE when head command terminates docker history output
• Non-critical diagnostic causing critical failure
2025-09-11 11:37:55 -04:00
sbcrumb 86d2cee1f9 Complete TV webhook processing modes documentation update
📚 Comprehensive Documentation Update:
• Added TV webhook processing modes section to README.md
• Updated CHANGELOG.md with configurable processing feature details
• Enhanced SETUP.md with TV_WEBHOOK_PROCESSING_MODE configuration
• Added Step 7 testing procedures to TESTING.md for both modes

🔧 Configuration Documentation:
• Targeted Mode (default): Only process episodes mentioned in webhook
• Series Mode: Process entire series directory (legacy behavior)
• Smart fallback logic when episode data unavailable
• Clear examples and use case recommendations

📋 Testing Instructions:
• Step-by-step mode switching procedures
• Expected log output examples for each mode
• Environment variable configuration examples
• Docker restart procedures for testing

✨ User Benefits Documented:
• Efficiency comparison: targeted vs series processing
• File operation reduction for targeted mode
• Media server notification minimization
• Backward compatibility with existing setups
2025-09-11 11:26:39 -04:00
sbcrumb ac2753509d Add configurable TV webhook processing modes (targeted vs series)
⚙️ New Configuration Option:
• Added TV_WEBHOOK_PROCESSING_MODE environment variable
• targeted = Only process episodes mentioned in webhook (efficient, default)
• series = Process entire series directory (comprehensive, previous behavior)

🎯 Targeted Episode Processing:
• New process_webhook_episodes() method for precise processing
• Only updates NFOs for downloaded episodes (e.g., just S41E07)
• Reduces unnecessary file operations and Emby notifications
• Falls back to series processing if no episode data in webhook

🔧 Implementation Details:
• Modified Sonarr webhook handler to pass episode data
• Enhanced batch processor with mode detection
• Maintains backward compatibility with existing behavior
• Added comprehensive logging for processing mode selection

✨ Benefits:
• Efficient: 1 download = 1 episode processed (not entire series)
• Configurable: Users can choose preferred behavior via .env
• Compatible: Works with existing autoscan/Emby refresh systems
• Smart: Falls back gracefully when episode data unavailable
2025-09-11 11:22:36 -04:00
sbcrumb 01cd31505a Complete v0.6.0 documentation update and review
📚 Documentation Updates:
• Updated CHANGELOG.md with comprehensive v0.6.0 feature list
• Enhanced README.md with new TV processing endpoints and examples
• Updated SETUP.md with required Sonarr API key configuration
• Enhanced TESTING.md with v0.6.0 TV testing procedures
• Updated .env templates with proper Sonarr requirements

🔧 Configuration Updates:
• Mark SONARR_API_KEY as REQUIRED for v0.6.0+ Enhanced TV NFOs
• Updated version examples from 0.5.1 to 0.6.0
• Added JSON response examples for new TV endpoints
• Clarified URL-safe vs URL-encoded endpoint options

📋 Complete File Review:
• Verified all Python imports are consistent across modules
• Confirmed all typing imports (Dict, Any, List, Optional) are present
• Validated configuration templates match actual requirements
• Added comprehensive testing procedures for TV enhancements

✅ Ready for Production:
• All endpoints documented with examples
• Configuration requirements clearly specified
• Testing procedures comprehensive for troubleshooting
• Backward compatibility maintained throughout
2025-09-11 10:10:05 -04:00
sbcrumb 52fa0b6060 Add missing get_episode_date method and bump version to v0.6.0
🐛 Database Fix:
• Add missing get_episode_date() method to NFOGuardDatabase
• Fixes: 'NFOGuardDatabase' object has no attribute 'get_episode_date'

🔄 Version Update:
• Bump VERSION from 0.2.22 to 0.6.0
• Major feature release with comprehensive TV enhancements

✅ Now Works:
• Single season processing via /tv/scan-season
• Single episode processing via /tv/scan-episode
• Enhanced NFO generation with Sonarr API metadata
• Database queries for existing episode dates
2025-09-11 09:59:15 -04:00
sbcrumb 03bdf2bf9f Fix TV endpoints to use proper JSON request models
🐛 API Fix:
• Add TVSeasonRequest and TVEpisodeRequest Pydantic models
• Update /tv/scan-season and /tv/scan-episode to accept JSON body
• Fix query parameter validation errors

✅ Now Works:
• curl -X POST "/tv/scan-season" -H "Content-Type: application/json" -d '{...}'
• curl -X POST "/tv/scan-episode" -H "Content-Type: application/json" -d '{...}'
2025-09-11 09:52:26 -04:00
sbcrumb aff6179ddb Fix missing typing imports in NFOManager
🐛 Fix:
• Add missing Dict, Any imports from typing module
• Resolves NameError: name 'Dict' is not defined
2025-09-11 09:46:23 -04:00
sbcrumb c705fb4d6d Add comprehensive TV show enhancements and URL-safe endpoints
✨ Features Added:
• Single season/episode processing capability via new endpoints
• Enhanced NFO generation with full Sonarr API metadata (titles, plots, ratings)
• NFOGuard timestamp tracking in all NFO source comments
• URL-safe /tv/scan-season and /tv/scan-episode endpoints

🔧 Technical Improvements:
• Fixed URL encoding issues for paths with spaces and special characters
• Enhanced TVProcessor with process_season() and process_episode_file() methods
• Rich metadata extraction from Sonarr API (episode titles, plots, runtime, ratings)
• XML escaping for special characters in metadata
• Comprehensive episode parsing (SxxExx and numeric formats)

📚 Documentation:
• Updated README with new TV processing endpoints
• Added enhanced NFO generation examples showing before/after
• Documented URL-safe alternatives to manual scan paths

🎯 User Benefits:
• Can now process single seasons: Chicago Fire Season 13
• Can now process single episodes: S13E21 files
• Enhanced NFO files with full episode metadata for better Emby/Plex experience
• Timestamps show when NFOGuard processed each file
2025-09-11 09:39:42 -04:00
sbcrumb c7c783570c Add configurable TMDB release type priority system
- Added TMDB_TYPE_PRIORITY environment variable for user-configurable release type preferences
- Default priority: 4,5,3,2,6,1 (Digital → Physical → Theatrical → Limited → TV → Premiere)
- Replaces random first-available selection with intelligent priority-based selection
- Enhanced logging shows selected type and full priority order
- Users can customize priority based on their preferences (e.g. theatrical first)

Version bumped to 0.2.22
2025-09-10 10:17:07 -04:00
sbcrumb 192f59e80c Document comprehensive date selection priority system in README
- Added detailed explanation of 4-tier fallback hierarchy
- Documents all TMDB release types (1-6) with clear descriptions
- Explains rename-first detection and Radarr NFO fallback logic
- Shows difference between import_then_digital vs digital_then_import priorities
- Version bumped to 0.2.21
2025-09-10 10:07:23 -04:00
sbcrumb 5df4db5878 Comprehensive fallback enhancement: Accept any TMDB type + Radarr NFO premiered dates
- TMDB now accepts ANY release type (1-6+) for maximum coverage
- Added Radarr NFO <premiered> date extraction as additional fallback
- For rename-first movies: Import → Any TMDB → Radarr NFO → File dates
- Much more aggressive about using ANY reasonable date over bad file dates
- Covers edge cases where TMDB has unusual release types

Version bumped to 0.2.20
2025-09-10 10:01:20 -04:00
sbcrumb c778cc18eb Add TMDB Type 2 (Limited theatrical) support for modern movies
- Enhanced digital release detection to accept Type 2 (Limited theatrical) dates
- Fixes "To Catch a Killer (2023)" and similar movies that only have festival/limited releases
- Better coverage for modern movies that premiere at film festivals before wide release
- Type 2 dates are better than bad file modification dates for rename-first scenarios

Version bumped to 0.2.19
2025-09-10 09:58:19 -04:00
sbcrumb 6bd7297c37 Add TMDB Type 6 (TV premiere) support for older movies and TV specials
- Enhanced digital release detection to also accept Type 6 (TV premiere) dates
- Fixes issue with 1966 "How the Grinch Stole Christmas!" and similar TV specials
- Detailed debugging revealed Type 6 was available but not being used
- Now properly handles older content that premiered on TV rather than theaters

Version bumped to 0.2.18
2025-09-10 09:29:07 -04:00
sbcrumb 23a71e066d Fix TMDB country configuration and add comprehensive API debugging
- Fixed ExternalClientManager to properly use TMDB_COUNTRY environment variable
- Added detailed TMDB API call logging to debug why external clients return None
- Will show available countries, release types, and specific API responses
- Should resolve the "no_valid_date_source" issue for rename-first movies

Version bumped to 0.2.17
2025-09-10 09:22:17 -04:00
sbcrumb c857a61d0b Bump version to 0.2.16 for TMDB debugging release
- Updated version for enhanced TMDB API debugging
- Container rebuild needed to see new diagnostic logs
- Will help identify TMDB API key and external client issues
2025-09-10 09:15:37 -04:00
sbcrumb 47a2286953 Add detailed logging to debug TMDB API fallback issues
- Enhanced _get_digital_release_date with comprehensive logging
- Added external clients configuration check in debug endpoint
- Will show if TMDB API key is configured and external clients initialized
- Added error handling for external client calls
2025-09-10 09:13:12 -04:00
sbcrumb f586185a4f Fix debug endpoint to use global movie_processor instance
- Corrected debug endpoint to use global movie_processor variable
- Was incorrectly trying to access batcher.movie_processor (doesn't exist)
- Now properly tests full TMDB fallback pipeline for rename-first movies
2025-09-10 09:08:09 -04:00
sbcrumb fc983ed472 Fix private repo authentication and enhance debug endpoint
- Add authentication to git clone commands in CI workflows for private repo support
- Enhanced debug endpoint to test FULL movie processing pipeline (not just database)
- Debug now shows both database result AND final TMDB fallback decision
- Will reveal if TMDB API calls are working correctly for rename-first scenarios
2025-09-10 09:04:26 -04:00
sbcrumb bf7fb27fdb Fix dev workflow hanging and implement runtime branch detection
- Fix duplicate Dockerfile sections that were causing build hangs
- Replace complex build args with simple runtime git branch detection
- Revert CI workflows to working state (remove build args and debug code)
- Add automatic version tagging: dev branch shows "0.2.15-dev"
- Enhanced movie fallback logic for rename-first scenarios
- Improved logging to trace movie date decision process
2025-09-10 08:54:47 -04:00
sbcrumb 6f356b1942 versions 2025-09-10 08:29:04 -04:00
sbcrumb 4e786d775b Add configurable TV season directory naming
Features:
- TV_SEASON_DIR_FORMAT: Configurable season folder creation (Season {season:02d})
- TV_SEASON_DIR_PATTERN: Configurable season folder detection (season )
- Support for different naming conventions (Season 01, S01, Season01, etc.)
- Updated .env.template with TV configuration examples

Fixes hardcoded 'Season ' patterns for better flexibility
2025-09-09 20:25:03 -04:00
sbcrumb 4f12d77d98 Fix CI pipe error in docker history command
- Add fallback to prevent exit 141 from broken pipe
- Build succeeded, just a minor display issue
2025-09-09 20:15:12 -04:00
sbcrumb 6ce4db099a Create dev branch with dedicated CI workflow
- Add ci-dev.yml for dev branch builds
- Builds to nfoguard:dev tags (separate from main)
- Independent cache: dev-buildcache
- Separate deployment flow
- Updated main CI to ignore dev branch

Usage: docker pull 192.168.253.221:3000/jskala/nfoguard:dev
2025-09-09 20:14:06 -04:00
sbcrumb 49e2ec6c88 Fix EventType detection: movieFileRenamed is 8, not 7
Found via database inspection:
- The Matrix first event: EventType 8 (movieFileRenamed)
- Followed by EventType 3 (downloadFolderImported) upgrade
- Was checking for EventType 7, should be EventType 8

This should now correctly detect rename-first scenarios
and prefer digital release dates over upgrade dates.
2025-09-09 18:28:40 -04:00
sbcrumb 6f04031e33 Add debug logging to diagnose Matrix history issue
- Show first 3 events in movie history
- Log event types to identify if first event is rename (Type 7)
- Debug why Matrix (tt0133093) not detecting rename-first pattern
2025-09-09 18:02:33 -04:00
sbcrumb f1814abdcd cahce 2 2025-09-09 17:25:39 -04:00
sbcrumb af94337ad0 build cache 2025-09-09 17:24:33 -04:00
sbcrumb 2198d796b9 fix order 2025-09-09 17:20:33 -04:00
sbcrumb afe93b4a8a imdb updates 2025-09-09 17:13:14 -04:00
sbcrumb 8be7452f0e iupdate 2025-09-09 17:04:59 -04:00
sbcrumb 53f3f7f2f0 Clean up temporary troubleshooting files 2025-09-09 16:56:14 -04:00
sbcrumb 740aa9036b all local 2025-09-09 16:45:02 -04:00
sbcrumb 41ef3d80c8 smaller image 2025-09-09 16:43:47 -04:00
sbcrumb c1e1a0529e updates AGAIN 2025-09-09 16:40:57 -04:00
sbcrumb d59df0f2c9 local 2025-09-09 16:39:33 -04:00
sbcrumb ba7d6c3620 up 2025-09-09 16:37:49 -04:00
sbcrumb db7d63fe15 fix docker 2025-09-09 16:36:06 -04:00
sbcrumb 51e63ae71b remove https 2025-09-09 16:34:41 -04:00
sbcrumb 27cb38c3bb git updates 2025-09-09 16:26:07 -04:00
sbcrumb f29d060d59 docker reg update 2025-09-09 16:19:53 -04:00
sbcrumb 8aeef814b0 push 2025-09-09 16:12:24 -04:00
sbcrumb 9d3d798055 update again sudo 2025-09-09 16:09:11 -04:00
sbcrumb 626f9bd86e update docker flow to local reg 2025-09-09 16:07:16 -04:00
sbcrumb 39073dcb70 workflow changes chat 2025-09-09 16:00:36 -04:00
sbcrumb c0dd5df45d update no sudo 2025-09-09 15:45:36 -04:00
sbcrumb 07719b7cf3 update workflow to temp 2025-09-09 15:44:09 -04:00
sbcrumb 59ffeb46fb update workflow username 2025-09-09 15:42:46 -04:00
sbcrumb 9755bd87b2 push to registry 2025-09-09 15:18:58 -04:00
sbcrumb f8f5f95a34 update docker file 2025-09-09 15:07:53 -04:00
sbcrumb 679d2facae update docker 2025-09-09 15:04:51 -04:00
sbcrumb 00127fbd98 update 2025-09-09 15:02:48 -04:00
sbcrumb 10c1d3c4fe remove old gitflow 2025-09-09 14:58:45 -04:00
sbcrumb 58f83a8523 install nodejs 2025-09-09 14:57:58 -04:00
sbcrumb 0dcd132355 Remove GitHub workflows - migrated to Gitea 2025-09-09 14:57:38 -04:00
sbcrumb ac5bbfdc79 workflow updates for backend 2025-09-09 14:53:56 -04:00
sbcrumb 9ae77cae5e workflow updates 2025-09-09 14:52:08 -04:00
sbcrumb 9342261c7d runner setup 2025-09-09 14:49:45 -04:00
sbcrumb 13fedd6fe0 Migrate workflows from .github to .gitea 2025-09-09 14:45:54 -04:00
sbcrumb 15a2427a25 update readme 2025-09-09 11:12:01 -04:00
sbcrumb 4a9aecbc22 updates 2025-09-09 10:59:45 -04:00
sbcrumb 49a2c1788a adding docker example updates 2025-09-09 10:44:35 -04:00
sbcrumb 4685c970af add api secrets seperate from .env 2025-09-09 10:20:46 -04:00
sbcrumb b2938758e2 fix file date release to option not required 2025-09-09 10:03:54 -04:00
sbcrumb 0aa9655d25 digital logic updates 2025-09-09 09:52:55 -04:00
sbcrumb d75016bbf2 tmdb debuging 2025-09-09 09:39:43 -04:00
sbcrumb fed82a9e13 digital release logic 2025-09-09 09:30:20 -04:00
sbcrumb bbbf4b63cc digital release date 2025-09-09 09:18:33 -04:00
sbcrumb 149bf9acd3 added back tmdb 2025-09-09 08:55:02 -04:00
sbcrumb dd215d2f38 db updates 2025-09-09 08:50:21 -04:00
sbcrumb 9fcf5d8303 bulk update 2025-09-09 08:36:25 -04:00
sbcrumb f851e817d9 update db 2025-09-08 18:05:05 -04:00
sbcrumb 19b40c0fd5 update to db call 2025-09-08 18:00:50 -04:00
sbcrumb 935307a74d update to database only 2025-09-08 17:56:43 -04:00
sbcrumb 6f47df201d db error 2025-09-08 17:45:06 -04:00
sbcrumb e618b71620 updates again 2025-09-08 17:42:02 -04:00
sbcrumb 678f84b6ef c4-rdarr db updates 2025-09-08 17:33:41 -04:00
sbcrumb 0def0e33af fix(radarr): improve event type parsing and grab event validation
- Fix string-based event type parsing (grabbed, downloadFolderImported, etc.)
- Filter grab events to only count actual downloads with metadata
- Fix JSON parsing errors for dict-type event data
- Enhanced chronological processing for accurate import date detection
- Validate grab events require sourceTitle or indexer info

Fixes issue where library addition events were mistaken for download grabs,
ensuring only real download timestamps are used for
2025-09-08 15:43:04 -04:00
sbcrumb 65794ae038 fix looking at radarr in a string and us eventid 2025-09-08 15:35:33 -04:00
sbcrumb cd77065a0f update logs and try statments 2025-09-08 15:29:30 -04:00
sbcrumb e389893e14 use date earliest 2025-09-08 15:14:32 -04:00
sbcrumb 7dbe014f03 update path maps 2025-09-08 15:08:01 -04:00
sbcrumb 4e4ddbf92a updates to api and logging 2025-09-08 15:04:20 -04:00
sbcrumb e40e80bfb5 updates to api calls 2025-09-08 14:50:03 -04:00
sbcrumb 157ba581b7 new event ID lookup 2025-09-08 14:38:17 -04:00
sbcrumb 134fb0c5c3 feat: Track grab dates as fallback when no imports found 2025-09-08 13:34:48 -04:00
sbcrumb 8702c9556d feat: Improved import detection and reduced debug noise 2025-09-08 13:25:17 -04:00
sbcrumb 900da1ba4f feat: Enhanced Radarr import detection with flexible path matching and improved logging 2025-09-08 12:18:53 -04:00
sbcrumb 6e3651ca39 feat: Enhanced Radarr import detection with flexible path matching 2025-09-08 12:16:59 -04:00
sbcrumb 78f73845fd better radarr debug 2025-09-08 12:14:51 -04:00
sbcrumb 38bcb9cf28 api radarr debugs 2025-09-08 12:14:04 -04:00
sbcrumb 78c09e3b13 running down radarr API issues 2025-09-08 12:06:01 -04:00
sbcrumb e4da166300 radarr updates 2025-09-08 11:49:18 -04:00
sbcrumb e89eb73bb4 radarr api issues again 2025-09-08 11:41:30 -04:00
sbcrumb 1ea7edeefa more api radarr changes 2025-09-08 11:33:39 -04:00
sbcrumb 946b024fd3 more radarr debugs 2025-09-08 11:20:00 -04:00
sbcrumb 75bf1b470e api update 2025-09-08 11:06:56 -04:00
sbcrumb 932db05646 another api debug 2025-09-08 10:58:29 -04:00
sbcrumb 282717314a api updates 2025-09-08 10:55:03 -04:00
sbcrumb af6e5b5ad1 added radarr debug dates 2025-09-08 10:38:16 -04:00
sbcrumb f942b4c253 radarr date troubshooting 2025-09-08 10:29:28 -04:00
sbcrumb 5cc237461c updates for debug scan 2025-09-08 10:21:38 -04:00
sbcrumb 5537b3b158 update missing module 2025-09-08 10:10:42 -04:00
sbcrumb f73875acdd refactor 2025-09-08 10:05:04 -04:00
sbcrumb e6d65c4982 another radarr fix 2025-09-08 09:23:27 -04:00
sbcrumb ee7525625c radarr updates 2025-09-08 09:13:22 -04:00
sbcrumb 71250c9ddb updates to radarr 2025-09-08 09:05:52 -04:00
sbcrumb d8f304c352 updates 2025-09-08 08:57:29 -04:00
sbcrumb c3d1f50cfb update 2025-09-07 21:13:46 -04:00
sbcrumb 085e43a37b update 2025-09-07 20:58:02 -04:00
sbcrumb d3d7e268e5 add loggin 2025-09-07 13:45:11 -04:00
sbcrumb 87e05a363d fix: prever TMDB over radarr for release date fallbacks 2025-09-07 13:17:16 -04:00
sbcrumb 92dc1c24de ifix dates 2025-09-07 13:11:35 -04:00
sbcrumb edf292211e dates 2025-09-07 12:57:52 -04:00
sbcrumb aef482506e fix 2025-09-07 12:44:01 -04:00
sbcrumb 3e8774b285 fix: dat 2025-09-07 12:34:49 -04:00
sbcrumb 5274c06223 fix: date issues 2025-09-07 12:28:06 -04:00
sbcrumb 8942c7426f chre:fix date issues 2025-09-07 12:18:33 -04:00
sbcrumb 5866713e1b chore:fix radarr date issue 2025-09-07 12:14:01 -04:00
sbcrumb 4680448b89 chore: update date handling in radarr 2025-09-07 11:55:50 -04:00
sbcrumb f9422c7202 update media cachce and version 2025-09-07 11:33:30 -04:00
sbcrumb e84d3fd64e update media cache again 2025-09-07 11:33:30 -04:00
sbcrumb ae1b0e2e06 workflow setup 2025-09-07 11:30:30 -04:00
sbcrumb 1d780d74bc Create VERSION 2025-09-07 11:17:49 -04:00
sbcrumb 5782b3970d feat: intial commits 2025-09-07 11:16:15 -04:00
sbcrumb 51b263ffe7 update webhook 2025-08-30 10:56:26 -04:00
sbcrumb 7b1c694f82 Intial push 2025-08-30 10:49:21 -04:00
sbcrumb 8f3bf42917 Workflow 2025-08-30 10:38:51 -04:00
sbcrumb 6afe5947e8 Initial commit 2025-08-29 16:16:52 -04:00
57 changed files with 15063 additions and 4908 deletions

No files matched your search

+56 -11
View File
@@ -20,6 +20,23 @@ SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6
# Download detection paths (for identifying downloads vs existing files) # Download detection paths (for identifying downloads vs existing files)
DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/ DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
# ===========================================
# SERVER CONFIGURATION
# ===========================================
# Timezone for container logging and timestamps
TZ=America/New_York
# NFOGuard Core API (webhooks, processing, database management)
CORE_API_HOST=0.0.0.0
CORE_API_PORT=8080
# NFOGuard Web Interface (dashboard, series/movie management)
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8081
# External port where web interface is accessible (for dynamic port reference)
WEB_EXTERNAL_PORT=8081
# =========================================== # ===========================================
# DATABASE CONFIGURATION # DATABASE CONFIGURATION
# =========================================== # ===========================================
@@ -34,6 +51,7 @@ DB_TYPE=postgresql
DB_HOST=nfoguard-db # Container name from docker-compose DB_HOST=nfoguard-db # Container name from docker-compose
DB_PORT=5432 DB_PORT=5432
DB_NAME=nfoguard DB_NAME=nfoguard
DB_USER=nfoguard
# Legacy SQLite Configuration (Deprecated in v2.6+) # Legacy SQLite Configuration (Deprecated in v2.6+)
# Only use for existing SQLite installations # Only use for existing SQLite installations
@@ -52,6 +70,21 @@ RADARR_DB_PORT=5432
RADARR_DB_NAME=radarr-main RADARR_DB_NAME=radarr-main
RADARR_DB_USER=postgres RADARR_DB_USER=postgres
# ===========================================
# SONARR DATABASE CONNECTION (RECOMMENDED)
# ===========================================
# Direct database access for better performance
# Phase 7: High-performance TV episode import date detection
SONARR_DB_TYPE=postgresql
SONARR_DB_HOST=192.168.255.50
SONARR_DB_PORT=5432
SONARR_DB_NAME=sonarr-main
SONARR_DB_USER=postgres
# Alternative: SQLite (if Sonarr uses SQLite)
# SONARR_DB_TYPE=sqlite
# SONARR_DB_PATH=/path/to/sonarr.db
# =========================================== # ===========================================
# API CONNECTIONS (OPTIONAL) # API CONNECTIONS (OPTIONAL)
# =========================================== # ===========================================
@@ -79,18 +112,22 @@ ALLOW_FILE_DATE_FALLBACK=false
TMDB_COUNTRY=US TMDB_COUNTRY=US
# =========================================== # ===========================================
# NFO FILE MANAGEMENT # NFO FILE MANAGEMENT (DEPRECATED - Phase 1 Migration)
# =========================================== # ===========================================
# Create/update .nfo files # NFO file operations have been removed in favor of database-only architecture
MANAGE_NFO=true # These settings are preserved for backward compatibility but no longer have effect
# The PostgreSQL database is now the single source of truth for all metadata
# Update file modification times to match import dates # Create/update .nfo files (DEPRECATED - no longer used)
FIX_DIR_MTIMES=true MANAGE_NFO=false
# Add lockdata tags to prevent metadata overwrites # Update file modification times to match import dates (DEPRECATED - no longer used)
LOCK_METADATA=true FIX_DIR_MTIMES=false
# Brand name in NFO comments # Add lockdata tags to prevent metadata overwrites (DEPRECATED - no longer used)
LOCK_METADATA=false
# Brand name in NFO comments (DEPRECATED - no longer used)
MANAGER_BRAND=NFOGuard MANAGER_BRAND=NFOGuard
# =========================================== # ===========================================
@@ -120,6 +157,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
@@ -136,7 +178,10 @@ PATH_DEBUG=false
SUPPRESS_TVDB_WARNINGS=true SUPPRESS_TVDB_WARNINGS=true
# =========================================== # ===========================================
# SERVER CONFIGURATION # WEB INTERFACE AUTHENTICATION
# =========================================== # ===========================================
# Port for webhook server # Enable web interface authentication (default: false)
PORT=8080 WEB_AUTH_ENABLED=false
# Session timeout in seconds (default: 3600 = 1 hour)
WEB_AUTH_SESSION_TIMEOUT=3600
+8
View File
@@ -39,3 +39,11 @@ JELLYSEERR_API_KEY=your_jellyseerr_api_key
# Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading # Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading
# Get free API key at: https://thetvdb.com/api-information # Get free API key at: https://thetvdb.com/api-information
TVDB_API_KEY=your_tvdb_api_key TVDB_API_KEY=your_tvdb_api_key
# ===========================================
# WEB INTERFACE AUTHENTICATION (OPTIONAL)
# ===========================================
# Web interface login credentials (only used if WEB_AUTH_ENABLED=true in .env)
# Set WEB_AUTH_ENABLED=true in .env file to enable authentication
WEB_AUTH_USERNAME=admin
WEB_AUTH_PASSWORD=your_secure_web_password
-33
View File
@@ -1,33 +0,0 @@
# NFOGuard Secrets Configuration Template
# Copy this to .env.secrets and add your actual API keys
# This file should NOT be committed to version control
# Add .env.secrets to your .gitignore
# ===========================================
# RADARR DATABASE CREDENTIALS
# ===========================================
# Database password for PostgreSQL connection
RADARR_DB_PASSWORD=your_radarr_db_password
# ===========================================
# API KEYS (SENSITIVE DATA)
# ===========================================
# TMDB API key for digital release dates
TMDB_API_KEY=your_tmdb_api_key
# OMDb API key for DVD/digital release dates (optional)
OMDB_API_KEY=your_omdb_api_key
# Radarr API key (optional - for fallback only)
RADARR_API_KEY=your_radarr_api_key
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
SONARR_API_KEY=your_sonarr_api_key
# Jellyseerr API key (optional)
JELLYSEERR_API_KEY=your_jellyseerr_api_key
# TVDB API key (RECOMMENDED for v0.6.1+ Emby TV NFO Compatibility)
# Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading
# Get free API key at: https://thetvdb.com/api-information
TVDB_API_KEY=your_tvdb_api_key
-124
View File
@@ -1,124 +0,0 @@
# ===========================================
# NFOGuard Configuration - CORRECTED VERSION
# ===========================================
# Main configuration file - safe to share for debugging
# Sensitive data (API keys, passwords) are in .env.secrets
# ===========================================
# MEDIA PATHS (REQUIRED) - FIXED
# ===========================================
# Container paths (what NFOGuard sees inside container)
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
TV_PATHS=/media/TV/tv,/media/TV/tv6
# Radarr paths (what Radarr sees on the host system)
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6
# Sonarr paths (what Sonarr sees on the host system) - FIXED VARIABLE NAME AND PATHS
SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6
# Download detection paths (for identifying downloads vs existing files)
DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
# ===========================================
# DATABASE CONFIGURATION
# ===========================================
# NFOGuard SQLite database location
DB_PATH=/app/data/media_dates.db
# Log file directory
LOG_DIR=/app/data/logs
# ===========================================
# RADARR DATABASE CONNECTION (RECOMMENDED)
# ===========================================
# Direct database access for better performance
RADARR_DB_TYPE=postgresql
RADARR_DB_HOST=192.168.255.50
RADARR_DB_PORT=5432
RADARR_DB_NAME=radarr-main
RADARR_DB_USER=postgres
# ===========================================
# API CONNECTIONS (OPTIONAL)
# ===========================================
# API keys are stored in .env.secrets for security
RADARR_URL=http://radarr:7878
SONARR_URL=http://sonarr:8989
JELLYSEERR_URL=http://jellyseerr:5055
# ===========================================
# RELEASE DATE PROCESSING
# ===========================================
# Priority order for release date fallbacks (digital, physical, theatrical)
RELEASE_DATE_PRIORITY=digital,physical,theatrical
#RELEASE_DATE_PRIORITY=digital,theatrical,physical
ENABLE_SMART_DATE_VALIDATION=true
MAX_RELEASE_DATE_GAP_YEARS=10
# Prefer API release dates over file modification dates for manual imports
PREFER_RELEASE_DATES_OVER_FILE_DATES=true
# Disable file date fallback completely (recommended for clean imports)
ALLOW_FILE_DATE_FALLBACK=false
# TMDB country for regional release date preferences
TMDB_COUNTRY=US
# ===========================================
# NFO FILE MANAGEMENT
# ===========================================
# Create/update .nfo files
MANAGE_NFO=true
# Update file modification times to match import dates
FIX_DIR_MTIMES=true
# Add lockdata tags to prevent metadata overwrites
LOCK_METADATA=true
# Brand name in NFO comments
MANAGER_BRAND=NFOGuard
# ===========================================
# PROCESSING BEHAVIOR
# ===========================================
# Movie date update strategy
MOVIE_DATE_UPDATE_MODE=overwrite
MOVIE_PRESERVE_EXISTING=false
# When to update existing dates (always, missing_only, never)
UPDATE_MODE=always
# File modification time behavior (update, leave_alone)
MTIME_BEHAVIOR=update
# ===========================================
# PERFORMANCE & BATCHING
# ===========================================
# Delay before processing batched events (seconds)
BATCH_DELAY=5.0
# Maximum concurrent series processing
MAX_CONCURRENT_SERIES=3
# API timeout in seconds
TIMEOUT_SECONDS=45
# ===========================================
# DEBUGGING
# ===========================================
# Enable verbose logging (true/false)
DEBUG=false
# Enable path mapping debug output (true/false)
PATH_DEBUG=false
# Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical
SUPPRESS_TVDB_WARNINGS=true
# ===========================================
# SERVER CONFIGURATION
# ===========================================
# Port for webhook server
PORT=8080
+165
View File
@@ -0,0 +1,165 @@
name: Local Docker Build (Dev)
on:
push:
branches: [ dev ]
pull_request:
branches: [ dev ]
jobs:
build-dev:
runs-on: host
steps:
- name: Pre-cleanup
run: |
docker system prune -f
docker builder prune -f --filter until=24h
- name: Checkout code (DEV)
run: |
echo "Current workspace: $(pwd)"
# Clean workspace and temp directory first
rm -rf * .git* 2>/dev/null || true
rm -rf /tmp/repo 2>/dev/null || true
# Clone the repository since Gitea runner doesn't auto-checkout
echo "Cloning repository..."
git clone --branch dev http://${{ secrets.username }}:${{ secrets.token }}@192.168.253.221:3000/sbcrumb/nfoguard.git /tmp/repo
cp -r /tmp/repo/* .
cp -r /tmp/repo/.* . 2>/dev/null || true
rm -rf /tmp/repo
echo "Repository cloned successfully"
ls -la
echo "Checking Dockerfile:"
head -30 Dockerfile
- name: Check Docker availability (DEV)
run: |
echo "Checking Docker installation..."
which docker || (echo "Docker not found in PATH" && exit 1)
docker --version || (echo "Docker not available" && exit 1)
docker info || (echo "Docker daemon not running" && exit 1)
- name: Configure Docker for local registry (DEV)
run: |
echo "Configuring Docker client for insecure local registry..."
LOCAL_REGISTRY="192.168.253.221:3000"
export DOCKER_CONFIG=/tmp/docker-config-dev
mkdir -p $DOCKER_CONFIG
# Create Docker client config for insecure registry
cat > $DOCKER_CONFIG/config.json << EOF
{
"auths": {},
"HttpHeaders": {
"User-Agent": "Docker-Client/20.10.0 (linux)"
},
"credsStore": "",
"credHelpers": {},
"experimental": "disabled"
}
EOF
echo "Docker client config created for DEV"
echo "Testing registry connectivity..."
curl -s "http://$LOCAL_REGISTRY/v2/" && echo "✅ Registry accessible via HTTP" || echo "❌ Registry not accessible"
- name: Build Docker image with caching (DEV)
run: |
echo "Building DEV Docker image with layer caching..."
LOCAL_REGISTRY="192.168.253.221:3000"
CACHE_IMAGE="$LOCAL_REGISTRY/sbcrumb/nfoguard:dev-buildcache"
# Clear any existing Docker configs to prevent permission issues
unset DOCKER_CONFIG
export HOME=/tmp/docker-home-dev
mkdir -p $HOME
# Enable Docker BuildKit for better caching
export DOCKER_BUILDKIT=1
# Check if DEV cache image exists and pull it
echo "Checking for existing DEV cache image..."
CACHE_ARGS=""
if curl -s "http://$LOCAL_REGISTRY/v2/sbcrumb/nfoguard/manifests/dev-buildcache" > /dev/null 2>&1; then
echo "✅ DEV cache manifest found, pulling image..."
if docker pull "$CACHE_IMAGE" 2>/dev/null; then
echo "✅ DEV cache image pulled successfully"
CACHE_ARGS="--cache-from=$CACHE_IMAGE"
else
echo "⚠️ DEV cache manifest exists but pull failed"
fi
else
echo "ℹ️ No DEV cache image found (first build or cache expired)"
fi
# Build DEV image with cache (if available)
echo "Building DEV image with layer caching..."
VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
echo "Building version: $VERSION"
docker build \
$CACHE_ARGS \
--build-arg GIT_BRANCH=dev \
--build-arg BUILD_SOURCE=gitea \
--tag nfoguard:dev \
--tag nfoguard:${VERSION}-dev-gitea \
--tag nfoguard:dev-${{ github.sha }} \
--tag "$CACHE_IMAGE" \
.
echo "DEV Docker image built and tagged successfully"
- name: Login and Push to Gitea registry (DEV tags)
continue-on-error: true
run: |
echo "Using LOCAL IP ONLY for DEV push!"
export DOCKER_CONFIG=/tmp/docker-config-dev
LOCAL_REGISTRY="192.168.253.221:3000"
echo "Registry: $LOCAL_REGISTRY (DEV TAGS)"
# Try login to local registry
if echo "${{ secrets.token }}" | docker --config $DOCKER_CONFIG login "$LOCAL_REGISTRY" -u "${{ secrets.username }}" --password-stdin 2>/dev/null; then
echo "✅ DEV Login succeeded"
# Tag for local registry
VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
docker tag nfoguard:dev "$LOCAL_REGISTRY/sbcrumb/nfoguard:dev"
docker tag nfoguard:${VERSION}-dev-gitea "$LOCAL_REGISTRY/sbcrumb/nfoguard:${VERSION}-dev-gitea"
docker tag nfoguard:dev "$LOCAL_REGISTRY/sbcrumb/nfoguard:dev-${{ github.sha }}"
# Push DEV images
echo "=== Pushing DEV cache image ==="
timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/sbcrumb/nfoguard:dev-buildcache" && echo "✅ DEV cache pushed" || echo "⚠️ DEV cache push failed"
echo "=== Pushing DEV latest tag ==="
if timeout 120 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/sbcrumb/nfoguard:dev"; then
echo "✅ DEV tag pushed successfully"
echo "=== Pushing version-dev-gitea tag ==="
if timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/sbcrumb/nfoguard:${VERSION}-dev-gitea"; then
echo "✅ Version-dev-gitea tag pushed successfully: ${VERSION}-dev-gitea"
fi
if timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/sbcrumb/nfoguard:dev-${{ github.sha }}"; then
echo "✅ DEV SHA tag pushed successfully"
fi
fi
else
echo "❌ DEV local registry login failed - continuing to Docker Hub"
fi
echo ""
echo "🎉 DEV build completed successfully!"
echo ""
VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
echo "📦 Available DEV images:"
echo " Local only: nfoguard:dev"
echo " Version tag: nfoguard:${VERSION}-dev-gitea"
echo " Local SHA: nfoguard:dev-${{ github.sha }}"
echo ""
echo "🐳 Pull with: docker pull 192.168.253.221:3000/sbcrumb/nfoguard:${VERSION}-dev-gitea"
+240
View File
@@ -0,0 +1,240 @@
name: Local Docker Build (Main)
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: host
steps:
- name: Pre-cleanup Docker space
run: |
echo "🧹 Cleaning up Docker space before build..."
docker system prune -f
docker builder prune -f --filter until=24h
docker image prune -af --filter until=48h
echo "📊 Docker space after cleanup:"
docker system df
- name: Checkout code
run: |
echo "Current workspace: $(pwd)"
# Clean workspace and temp directory first
rm -rf * .git* 2>/dev/null || true
rm -rf /tmp/repo 2>/dev/null || true
# Clone the repository since Gitea runner doesn't auto-checkout
echo "Cloning repository..."
git clone --branch main http://${{ secrets.username }}:${{ secrets.token }}@192.168.253.221:3000/sbcrumb/nfoguard.git /tmp/repo
cp -r /tmp/repo/* .
cp -r /tmp/repo/.* . 2>/dev/null || true
rm -rf /tmp/repo
echo "Repository cloned successfully"
ls -la
echo "Checking Dockerfile:"
head -30 Dockerfile
- name: Check Docker availability
run: |
echo "Checking Docker installation..."
which docker || (echo "Docker not found in PATH" && exit 1)
docker --version || (echo "Docker not available" && exit 1)
docker info || (echo "Docker daemon not running" && exit 1)
- name: Configure Docker for local registry
run: |
echo "Configuring Docker client for insecure local registry..."
LOCAL_REGISTRY="192.168.253.221:3000"
# We can't use sudo in the container, so we'll configure the client differently
export DOCKER_CONFIG=/tmp/docker-config
mkdir -p $DOCKER_CONFIG
# Create Docker client config for insecure registry
cat > $DOCKER_CONFIG/config.json << EOF
{
"auths": {},
"HttpHeaders": {
"User-Agent": "Docker-Client/20.10.0 (linux)"
},
"credsStore": "",
"credHelpers": {},
"experimental": "disabled"
}
EOF
echo "Docker client config created"
echo "Note: Since we can't modify daemon config in container, we'll use --insecure-registry flag"
echo "Testing registry connectivity..."
curl -s "http://$LOCAL_REGISTRY/v2/" && echo "✅ Registry accessible via HTTP" || echo "❌ Registry not accessible"
- name: Build Docker image with caching
run: |
echo "Building Docker image with layer caching..."
LOCAL_REGISTRY="192.168.253.221:3000"
CACHE_IMAGE="$LOCAL_REGISTRY/sbcrumb/nfoguard:buildcache"
# Clear any existing Docker configs to prevent permission issues
unset DOCKER_CONFIG
export HOME=/tmp/docker-home
mkdir -p $HOME
# Enable Docker BuildKit for better caching
export DOCKER_BUILDKIT=1
# Check if cache image exists and pull it
echo "Checking for existing cache image..."
CACHE_ARGS=""
if curl -s "http://$LOCAL_REGISTRY/v2/sbcrumb/nfoguard/manifests/buildcache" > /dev/null 2>&1; then
echo "✅ Cache manifest found, pulling image..."
if docker pull "$CACHE_IMAGE" 2>/dev/null; then
echo "✅ Cache image pulled successfully"
CACHE_ARGS="--cache-from=$CACHE_IMAGE"
else
echo "⚠️ Cache manifest exists but pull failed"
fi
else
echo "ℹ️ No cache image found (first build or cache expired)"
fi
# Build with cache (if available)
echo "Building with layer caching..."
VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
echo "Building version: $VERSION"
docker build \
$CACHE_ARGS \
--build-arg GIT_BRANCH=main \
--build-arg BUILD_SOURCE=gitea \
--tag nfoguard:latest \
--tag nfoguard:${VERSION}-gitea \
--tag nfoguard:${{ github.sha }} \
--tag "$CACHE_IMAGE" \
.
echo "Docker image built and tagged successfully"
# Show layer info for debugging (with error handling)
echo "=== Image layer history ==="
if docker history nfoguard:latest --format "table {{.CreatedBy}}\t{{.Size}}" 2>/dev/null; then
echo "✅ Image history displayed successfully"
else
echo "⚠️ Could not display image history (permissions issue, but build succeeded)"
fi
- name: Login and Push to Gitea registry (local IP only)
continue-on-error: true # Don't fail the build if local registry fails
run: |
echo "Using LOCAL IP ONLY - no Cloudflare tunnel!"
export DOCKER_CONFIG=/tmp/docker-config
# Force local IP - never use domain
LOCAL_REGISTRY="192.168.253.221:3000"
echo "Registry: $LOCAL_REGISTRY (LOCAL IP ONLY)"
# Check image size
IMAGE_SIZE=$(docker images nfoguard:latest --format "table {{.Size}}" | tail -n1)
echo "Image size: $IMAGE_SIZE"
echo "Testing local registry connectivity..."
curl -s "http://$LOCAL_REGISTRY/v2/" && echo "✅ Local registry accessible via HTTP" || echo "❌ Local registry not accessible"
# Try multiple approaches to handle the HTTP issue
echo "Attempting login to LOCAL registry..."
# Approach 1: Try with explicit HTTP in auth
if echo "${{ secrets.token }}" | docker --config $DOCKER_CONFIG login "$LOCAL_REGISTRY" -u "${{ secrets.username }}" --password-stdin 2>/dev/null; then
echo "✅ Login succeeded with direct method"
# Tag for local registry (cache image already tagged in build step)
VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
docker tag nfoguard:latest "$LOCAL_REGISTRY/sbcrumb/nfoguard:latest"
docker tag nfoguard:${VERSION}-gitea "$LOCAL_REGISTRY/sbcrumb/nfoguard:${VERSION}-gitea"
docker tag nfoguard:latest "$LOCAL_REGISTRY/sbcrumb/nfoguard:${{ github.sha }}"
echo "Pushing to LOCAL registry (gigabit speed!)..."
# Push with reasonable timeout for local network
echo "=== Pushing cache image (for faster future builds) ==="
timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/sbcrumb/nfoguard:buildcache" && echo "✅ Cache image pushed" || echo "⚠️ Cache push failed"
echo "=== Pushing latest tag to LOCAL IP ==="
if timeout 120 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/sbcrumb/nfoguard:latest"; then
echo "✅ Latest tag pushed successfully to LOCAL registry"
echo "=== Pushing version-gitea tag ==="
if timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/sbcrumb/nfoguard:${VERSION}-gitea"; then
echo "✅ Version-gitea tag pushed successfully: ${VERSION}-gitea"
fi
echo "=== Pushing SHA tag to LOCAL IP ==="
if timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/sbcrumb/nfoguard:${{ github.sha }}"; then
echo "✅ SHA tag pushed successfully to LOCAL registry"
else
echo "⚠️ SHA tag push failed but latest succeeded"
fi
else
echo "❌ Push to LOCAL registry failed"
echo "This is likely because Docker daemon needs insecure-registry configuration"
fi
else
echo "❌ Local registry login failed"
echo "This is expected - Docker daemon needs insecure-registry configuration"
echo ""
echo "🔧 To fix, run on the host machine:"
echo "sudo mkdir -p /etc/docker"
echo 'echo '"'"'{"insecure-registries":["'$LOCAL_REGISTRY'"]}'"'"' | sudo tee /etc/docker/daemon.json'
echo "sudo systemctl restart docker"
echo ""
echo "⏭️ Continuing to Docker Hub push..."
fi
echo "🎉 Local registry step completed!"
echo ""
echo "🎉 Local build completed successfully!"
echo ""
VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
echo "📦 Available images:"
echo " Local Docker: nfoguard:latest"
echo " Version tag: nfoguard:${VERSION}-gitea"
echo " Local SHA: nfoguard:${{ github.sha }}"
echo " Local Gitea: 192.168.253.221:3000/sbcrumb/nfoguard:latest"
echo ""
echo "🐳 Pull with: docker pull 192.168.253.221:3000/sbcrumb/nfoguard:${VERSION}-gitea"
- name: Post-cleanup Docker space
if: always() # Run even if build fails
run: |
echo "🧹 Final cleanup to prevent disk space issues..."
docker system prune -f
docker builder prune -af --filter until=24h
echo "📊 Final Docker space usage:"
docker system df
deploy:
needs: build
runs-on: host
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy application
run: |
echo "Deploying application..."
# Add your deployment commands here
# Examples:
# docker-compose pull
# docker-compose up -d
# or
# docker stop nfoguard || true
# docker run -d --name nfoguard -p 8080:8080 nfoguard:latest
echo "Deployment completed"
+3
View File
@@ -32,6 +32,9 @@ RUN pip install --no-cache-dir --upgrade pip && \
# Copy application code # Copy application code
COPY . . COPY . .
# Copy web starter file to root directory
COPY start_web.py .
# Create git metadata for version detection based on build arg # Create git metadata for version detection based on build arg
RUN mkdir -p .git && \ RUN mkdir -p .git && \
echo "ref: refs/heads/${GIT_BRANCH}" > .git/HEAD echo "ref: refs/heads/${GIT_BRANCH}" > .git/HEAD
+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
+55 -15
View File
@@ -8,17 +8,11 @@
--- ---
> **⚠️ ALPHA SOFTWARE NOTICE ⚠️**
>
> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features.
>
> **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required. > **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required.
> >
> **💬 Community Feedback**: Join our Discord if you’d like to share feedback, test new features early, or discuss improvements with other users: > **💬 Community Feedback**: Join our Discord if you'd like to share feedback, test new features, or discuss improvements with other users:
> >
> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)** > **[Join Discord: https://discord.gg/ZykJRGt72b](https://discord.gg/ZykJRGt72b)**
>
> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.*
--- ---
@@ -38,7 +32,7 @@ NFOGuard automatically updates movie and TV show NFO files with proper release d
- **Batch Processing** - Efficient handling of multiple files simultaneously - **Batch Processing** - Efficient handling of multiple files simultaneously
- **PostgreSQL Database** - Production-ready database with optimized queries - **PostgreSQL Database** - Production-ready database with optimized queries
- **Smart Skip Logic** - Database-first checking eliminates expensive filesystem scans - **Smart Skip Logic** - Database-first checking eliminates expensive filesystem scans
- **88% Scan Optimization** - TV library scans reduced from hours to minutes - **88% Scan Optimization** - Subsequent scans reduced from hours to minutes via smart skip logic
### **Web Interface & Management** ### **Web Interface & Management**
- **Complete Web UI** - Episode and movie management with filtering and search - **Complete Web UI** - Episode and movie management with filtering and search
@@ -64,9 +58,9 @@ NFOGuard automatically updates movie and TV show NFO files with proper release d
### 1. Download Configuration Files ### 1. Download Configuration Files
```bash ```bash
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.template wget https://raw.githubusercontent.com/sbcrumb/nfoguard/main/.env.template
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.secrets.template wget https://raw.githubusercontent.com/sbcrumb/nfoguard/main/.env.secrets.template
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/docker-compose.example.yml wget https://raw.githubusercontent.com/sbcrumb/nfoguard/main/docker-compose.example.yml
``` ```
### 2. Configure Environment ### 2. Configure Environment
@@ -149,10 +143,10 @@ image: sbcrumb/nfoguard:dev
### Specific Version ### Specific Version
```yaml ```yaml
image: sbcrumb/nfoguard:v2.0.0 # Latest with monitoring & validation image: sbcrumb/nfoguard:v2.6.7 # Latest with PostgreSQL & optimization
``` ```
> **🚀 Version 2.0.0** includes major architecture improvements, async I/O performance enhancements, comprehensive monitoring, and configuration validation. > **🚀 Version 2.6.7** includes major architecture improvements, PostgreSQL database migration, 88% scan optimization, async I/O performance enhancements, comprehensive monitoring, and configuration validation.
## 🔗 Webhook Setup ## 🔗 Webhook Setup
@@ -184,8 +178,44 @@ curl -X POST "http://localhost:8080/manual/scan?path=/media/movies"
# Bulk update all movies from Radarr database # Bulk update all movies from Radarr database
curl -X POST "http://localhost:8080/bulk/update" curl -X POST "http://localhost:8080/bulk/update"
# Verify NFO files match database data
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=both"
# Fix NFO files that don't match database
curl -X POST "http://localhost:8080/database/fix/nfo-sync?media_type=both"
``` ```
### NFO Verification & Synchronization
NFOGuard includes comprehensive verification tools to ensure NFO files remain synchronized with database dates:
```bash
# Verify all NFO files (movies and episodes)
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=both"
# Verify only movies
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=movies"
# Verify only TV episodes
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=episodes"
# Fix detected issues by regenerating NFO files
curl -X POST "http://localhost:8080/database/fix/nfo-sync?media_type=both"
```
**Verification Checks:**
- **Missing NFO files** - Database entries without corresponding NFO files
- **Empty NFO files** - NFO files that exist but contain no content
- **Date mismatches** - Database dates don't match NFO file dates
- **Source mismatches** - Database source doesn't match NFO source information
**Fix Operations:**
- Regenerates NFO files from database data for any detected issues
- Preserves existing metadata while updating NFOGuard date sections
- Only operates when `MANAGE_NFO=true` is configured
- Creates proper movie.nfo and episode S##E##.nfo files
### API Endpoints ### API Endpoints
#### **Core Operations** #### **Core Operations**
@@ -214,6 +244,16 @@ curl -X POST "http://localhost:8080/bulk/update"
| `/api/v1/metrics/errors` | GET | Error metrics and recent failures | | `/api/v1/metrics/errors` | GET | Error metrics and recent failures |
| `/api/v1/metrics/system` | GET | System resource metrics | | `/api/v1/metrics/system` | GET | System resource metrics |
#### **Database Management**
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/database/backfill/movie-release-dates` | POST | Backfill missing release dates for existing movies |
| `/database/cleanup/orphaned-episodes` | POST | Delete episodes without video files |
| `/database/cleanup/orphaned-movies` | POST | Delete movies without video files |
| `/database/cleanup/orphaned-series` | POST | Delete TV series without directories |
| `/database/verify/nfo-sync` | POST | Verify that database dates match NFO file contents |
| `/database/fix/nfo-sync` | POST | Fix NFO sync issues by regenerating NFO files from database data |
#### **Configuration & Debugging** #### **Configuration & Debugging**
| Endpoint | Method | Purpose | | Endpoint | Method | Purpose |
|----------|--------|---------| |----------|--------|---------|
@@ -499,7 +539,7 @@ NFOGuard will ignore:
## 🆘 Support ## 🆘 Support
- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues) - **Issues**: [GitHub Issues](https://github.com/sbcrumb/nfoguard/issues)
- **Documentation**: See `SETUP.md` for detailed instructions - **Documentation**: See `SETUP.md` for detailed instructions
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard) - **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
+1 -1
View File
@@ -132,7 +132,7 @@ services:
- ./.env:/app/.env:ro # Main configuration - ./.env:/app/.env:ro # Main configuration
- ./.env.secrets:/app/.env.secrets:ro # Secrets - ./.env.secrets:/app/.env.secrets:ro # Secrets
environment: environment:
- PORT=8080 - CORE_API_PORT=8080
depends_on: depends_on:
- radarr-postgres - radarr-postgres
``` ```
+1 -1
View File
@@ -1 +1 @@
2.6.7 2.10.0-skipped-imdb-edit-v6
+184
View File
@@ -0,0 +1,184 @@
"""
Simple authentication middleware for NFOGuard web interface
Provides basic HTTP auth and session management for web interface protection
"""
import secrets
import hashlib
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from fastapi import HTTPException, status, Request, Response
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from starlette.middleware.base import BaseHTTPMiddleware
class AuthSession:
"""Simple session management for web interface"""
def __init__(self, timeout_seconds: int = 3600):
self.sessions: Dict[str, Dict[str, Any]] = {}
self.timeout_seconds = timeout_seconds
def create_session(self, username: str) -> str:
"""Create a new session and return session token"""
session_token = secrets.token_urlsafe(32)
self.sessions[session_token] = {
"username": username,
"created_at": datetime.utcnow(),
"last_activity": datetime.utcnow()
}
return session_token
def validate_session(self, session_token: str) -> bool:
"""Validate session token and update last activity"""
if not session_token or session_token not in self.sessions:
return False
session = self.sessions[session_token]
now = datetime.utcnow()
# Check if session expired
if (now - session["last_activity"]).seconds > self.timeout_seconds:
del self.sessions[session_token]
return False
# Update last activity
session["last_activity"] = now
return True
def get_session_user(self, session_token: str) -> Optional[str]:
"""Get username from valid session"""
if self.validate_session(session_token):
return self.sessions[session_token]["username"]
return None
def delete_session(self, session_token: str) -> None:
"""Delete a session (logout)"""
if session_token in self.sessions:
del self.sessions[session_token]
def cleanup_expired_sessions(self) -> None:
"""Remove expired sessions"""
now = datetime.utcnow()
expired_tokens = []
for token, session in self.sessions.items():
if (now - session["last_activity"]).seconds > self.timeout_seconds:
expired_tokens.append(token)
for token in expired_tokens:
del self.sessions[token]
class SimpleAuthMiddleware(BaseHTTPMiddleware):
"""Simple authentication middleware for web interface routes"""
def __init__(self, app, config, session_manager=None):
super().__init__(app)
self.config = config
self.session_manager = session_manager or AuthSession(config.web_auth_session_timeout)
self.security = HTTPBasic()
# Routes that require authentication (web interface)
self.protected_routes = [
"/", # Main web interface
"/static/", # Static files (CSS, JS)
"/api/movies", # Web API endpoints
"/api/series",
"/api/episodes",
"/api/dashboard"
]
# Routes that are always public (webhooks, health checks, API endpoints)
self.public_routes = [
"/webhook/",
"/health",
"/logo/", # Logo files should always be accessible
"/favicon.ico", # Favicon should always be accessible
"/ping",
"/api/v1/health",
"/api/v1/metrics",
"/database/", # Database management endpoints (API access)
"/manual/", # Manual scan endpoints (API access)
"/debug/", # Debug endpoints (API access)
"/test/", # Test endpoints (API access)
"/bulk/" # Bulk operation endpoints (API access)
]
async def dispatch(self, request: Request, call_next):
"""Process request through authentication middleware"""
# Skip authentication if disabled
if not self.config.web_auth_enabled:
return await call_next(request)
# Check if route requires authentication
path = request.url.path
needs_auth = any(path.startswith(route) for route in self.protected_routes)
is_public = any(path.startswith(route) for route in self.public_routes)
if is_public or not needs_auth:
return await call_next(request)
# Check for existing session
session_token = request.cookies.get("nfoguard_session")
if session_token and self.session_manager.validate_session(session_token):
# Valid session, proceed
return await call_next(request)
# Check for HTTP Basic Auth
auth_header = request.headers.get("authorization")
if auth_header and auth_header.startswith("Basic "):
credentials = self._parse_basic_auth(auth_header)
if credentials and self._validate_credentials(credentials.username, credentials.password):
# Create session for successful login
session_token = self.session_manager.create_session(credentials.username)
response = await call_next(request)
response.set_cookie(
key="nfoguard_session",
value=session_token,
max_age=self.config.web_auth_session_timeout,
httponly=True,
secure=False # Set to True if using HTTPS
)
return response
# Authentication required
return self._auth_required_response()
def _parse_basic_auth(self, auth_header: str) -> Optional[HTTPBasicCredentials]:
"""Parse HTTP Basic Auth header"""
try:
import base64
encoded_credentials = auth_header.split(" ")[1]
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
username, password = decoded_credentials.split(":", 1)
return HTTPBasicCredentials(username=username, password=password)
except Exception:
return None
def _validate_credentials(self, username: str, password: str) -> bool:
"""Validate username and password"""
return (username == self.config.web_auth_username and
password == self.config.web_auth_password)
def _auth_required_response(self) -> Response:
"""Return 401 response with WWW-Authenticate header"""
return Response(
content="Authentication required",
status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Basic realm=\"NFOGuard Web Interface\""}
)
def create_auth_dependencies(config) -> Dict[str, Any]:
"""Create authentication-related dependencies for dependency injection"""
session_manager = AuthSession(config.web_auth_session_timeout)
return {
"session_manager": session_manager,
"auth_enabled": config.web_auth_enabled,
"auth_config": {
"username": config.web_auth_username,
"timeout": config.web_auth_session_timeout
}
}
+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]
+2347 -130
View File
File diff suppressed because it is too large. Load diff
+1488 -47
View File
File diff suppressed because it is too large. Load diff
+47
View File
@@ -165,6 +165,53 @@ class RadarrDbClient:
return None return None
def get_all_movies(self) -> List[Dict[str, Any]]:
"""
Get all movies from Radarr database (only movies with files)
Returns:
List of dictionaries with movie info
"""
query = """
SELECT
m."Id" as id,
m."Path" as path,
m."Added" as added,
mm."ImdbId" as imdb_id,
mm."Title" as title,
mm."Year" as year,
mm."DigitalRelease" as digital_release,
mm."PhysicalRelease" as physical_release,
mm."InCinemas" as in_cinemas,
(SELECT COUNT(*) FROM "MovieFiles" mf WHERE mf."MovieId" = m."Id") as file_count
FROM "Movies" m
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
WHERE (SELECT COUNT(*) FROM "MovieFiles" mf WHERE mf."MovieId" = m."Id") > 0
ORDER BY mm."Title"
"""
if self.db_type == "sqlite":
# SQLite uses ? placeholders but this query has none
pass
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query)
rows = cursor.fetchall()
if rows:
return [dict(row) if self.db_type == "sqlite" else row for row in rows]
return []
except Exception as e:
_log("ERROR", f"Database query error getting all movies: {e}")
return []
def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]: def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
""" """
Get earliest import date from History table, accounting for upgrade scenarios Get earliest import date from History table, accounting for upgrade scenarios
+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}")
+582
View File
@@ -0,0 +1,582 @@
#!/usr/bin/env python3
"""
Direct Sonarr Database Client for NFOGuard
Provides high-performance access to Sonarr's SQLite/PostgreSQL database
"""
import os
import sqlite3
import psycopg2
import psycopg2.extras
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple, Union
from urllib.parse import urlparse
from core.logging import _log
class SonarrDbClient:
"""Direct database client for Sonarr's SQLite or PostgreSQL database"""
def __init__(self,
db_type: str = "sqlite",
db_path: Optional[str] = None,
db_host: Optional[str] = None,
db_port: Optional[int] = None,
db_name: Optional[str] = None,
db_user: Optional[str] = None,
db_password: Optional[str] = None):
"""
Initialize Sonarr database client
Args:
db_type: "sqlite" or "postgresql"
db_path: Path to SQLite database file
db_host: PostgreSQL host
db_port: PostgreSQL port
db_name: PostgreSQL database name
db_user: PostgreSQL username
db_password: PostgreSQL password
"""
self.db_type = db_type.lower()
self.db_path = db_path
self.db_host = db_host
self.db_port = db_port or 5432
self.db_name = db_name
self.db_user = db_user
self.db_password = db_password
self._test_connection()
@classmethod
def from_env(cls) -> Optional['SonarrDbClient']:
"""Create client from environment variables"""
db_type = os.environ.get("SONARR_DB_TYPE", "").lower()
if not db_type:
return None
if db_type == "sqlite":
db_path = os.environ.get("SONARR_DB_PATH")
if not db_path or not Path(db_path).exists():
_log("WARNING", f"SONARR_DB_PATH not found or invalid: {db_path}")
return None
return cls(db_type="sqlite", db_path=db_path)
elif db_type == "postgresql":
# Support both individual vars and connection string
db_url = os.environ.get("SONARR_DB_URL")
if db_url:
parsed = urlparse(db_url)
return cls(
db_type="postgresql",
db_host=parsed.hostname,
db_port=parsed.port or 5432,
db_name=parsed.path.lstrip('/'),
db_user=parsed.username,
db_password=parsed.password
)
else:
return cls(
db_type="postgresql",
db_host=os.environ.get("SONARR_DB_HOST"),
db_port=int(os.environ.get("SONARR_DB_PORT", "5432")),
db_name=os.environ.get("SONARR_DB_NAME"),
db_user=os.environ.get("SONARR_DB_USER"),
db_password=os.environ.get("SONARR_DB_PASSWORD")
)
else:
_log("ERROR", f"Unsupported database type: {db_type}")
return None
def _test_connection(self) -> None:
"""Test database connection on initialization"""
try:
conn = self._get_connection()
if conn:
conn.close()
_log("INFO", f"Connected to Sonarr {self.db_type} database successfully")
else:
raise Exception("Failed to create connection")
except Exception as e:
_log("ERROR", f"Failed to connect to Sonarr database: {e}")
raise
def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
"""Get database connection"""
if self.db_type == "sqlite":
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
elif self.db_type == "postgresql":
conn = psycopg2.connect(
host=self.db_host,
port=self.db_port,
database=self.db_name,
user=self.db_user,
password=self.db_password
)
return conn
else:
raise ValueError(f"Unsupported database type: {self.db_type}")
def get_series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
"""
Find series by IMDb ID using database query
Returns:
Dictionary with series info including id, imdb_id, title, path
"""
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
query = """
SELECT
"Id" as id,
"ImdbId" as imdb_id,
"TvdbId" as tvdb_id,
"Title" as title,
"Path" as path,
"Added" as added
FROM "Series"
WHERE "ImdbId" = %s
"""
if self.db_type == "sqlite":
query = query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, (imdb_id,))
row = cursor.fetchone()
if row:
return dict(row) if self.db_type == "sqlite" else row
except Exception as e:
_log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
return None
def get_all_episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
"""
Get all episodes for a series
Args:
series_id: Sonarr series ID
Returns:
List of episode dictionaries with season, episode, air_date
"""
query = """
SELECT
"Id" as id,
"SeasonNumber" as season,
"EpisodeNumber" as episode,
"Title" as title,
"AirDate" as air_date,
"EpisodeFileId" as episode_file_id
FROM "Episodes"
WHERE "SeriesId" = %s
ORDER BY "SeasonNumber", "EpisodeNumber"
"""
if self.db_type == "sqlite":
query = query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, (series_id,))
rows = cursor.fetchall()
if self.db_type == "sqlite":
return [dict(row) for row in rows]
else:
return rows
except Exception as e:
_log("ERROR", f"Database query error for series {series_id}: {e}")
return []
def get_episode_import_date(self, episode_id: int) -> Tuple[Optional[str], str]:
"""
Get earliest import date for an episode from History table
Args:
episode_id: Sonarr episode ID
Returns:
(date_iso, source_description)
"""
# Query for earliest import event (EventType 3)
import_query = """
SELECT
"Date" as event_date,
"EventType" as event_type
FROM "History"
WHERE "EpisodeId" = %s
AND "EventType" = 3
ORDER BY "Date" ASC
LIMIT 1
"""
if self.db_type == "sqlite":
import_query = import_query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
# Try import events first
cursor.execute(import_query, (episode_id,))
row = cursor.fetchone()
if row:
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
if isinstance(event_date, str):
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
else:
dt = event_date.replace(tzinfo=timezone.utc)
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
return date_iso, "sonarr:db.history.import"
except Exception as e:
_log("ERROR", f"Database query error for episode {episode_id}: {e}")
return None, "sonarr:db.no_import_found"
def get_episode_file_date(self, series_id: int, season: int, episode: int) -> Optional[str]:
"""
Get episode file DateAdded as fallback
Args:
series_id: Sonarr series ID
season: Season number
episode: Episode number
Returns:
ISO date string or None
"""
# First get the episode ID to find the file
episode_query = """
SELECT "EpisodeFileId"
FROM "Episodes"
WHERE "SeriesId" = %s
AND "SeasonNumber" = %s
AND "EpisodeNumber" = %s
"""
file_query = """
SELECT "DateAdded"
FROM "EpisodeFiles"
WHERE "Id" = %s
"""
if self.db_type == "sqlite":
episode_query = episode_query.replace("%s", "?")
file_query = file_query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
# Get episode file ID
cursor.execute(episode_query, (series_id, season, episode))
row = cursor.fetchone()
if row:
file_id = row['EpisodeFileId'] if self.db_type == "postgresql" else row[0]
if file_id:
# Get file date
cursor.execute(file_query, (file_id,))
file_row = cursor.fetchone()
if file_row:
date_value = file_row['DateAdded'] if self.db_type == "postgresql" else file_row[0]
if isinstance(date_value, str):
dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
else:
dt = date_value.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
except Exception as e:
_log("ERROR", f"Database query error for episode file: {e}")
return None
def bulk_import_dates_for_series(self, series_id: int) -> Dict[Tuple[int, int], Tuple[Optional[str], str]]:
"""
Get import dates for all episodes in a series in a single query
Args:
series_id: Sonarr series ID
Returns:
Dictionary mapping (season, episode) -> (date_iso, source)
"""
query = """
SELECT
e."SeasonNumber" as season,
e."EpisodeNumber" as episode,
e."Id" as episode_id,
MIN(h."Date") as earliest_import
FROM "Episodes" e
LEFT JOIN "History" h ON e."Id" = h."EpisodeId" AND h."EventType" = 3
WHERE e."SeriesId" = %s
GROUP BY e."SeasonNumber", e."EpisodeNumber", e."Id"
ORDER BY e."SeasonNumber", e."EpisodeNumber"
"""
if self.db_type == "sqlite":
query = query.replace("%s", "?")
results = {}
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, (series_id,))
rows = cursor.fetchall()
for row in rows:
if self.db_type == "postgresql":
season, episode, episode_id, earliest_import = row['season'], row['episode'], row['episode_id'], row['earliest_import']
else:
season, episode, episode_id, earliest_import = row[0], row[1], row[2], row[3]
if earliest_import:
if isinstance(earliest_import, str):
dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
else:
dt = earliest_import.replace(tzinfo=timezone.utc)
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
results[(season, episode)] = (date_iso, "sonarr:db.bulk.import")
else:
results[(season, episode)] = (None, "sonarr:db.bulk.no_import")
except Exception as e:
_log("ERROR", f"Bulk query error for series {series_id}: {e}")
return results
def get_database_stats(self) -> Dict[str, Any]:
"""Get basic statistics about the Sonarr database"""
stats = {}
queries = {
"total_series": 'SELECT COUNT(*) FROM "Series"',
"total_episodes": 'SELECT COUNT(*) FROM "Episodes"',
"total_episode_files": 'SELECT COUNT(*) FROM "EpisodeFiles"',
"total_history_events": 'SELECT COUNT(*) FROM "History"',
"import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
"grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
}
try:
with self._get_connection() as conn:
cursor = conn.cursor()
for stat_name, query in queries.items():
cursor.execute(query)
result = cursor.fetchone()
stats[stat_name] = result[0] if result else 0
except Exception as e:
_log("ERROR", f"Stats query error: {e}")
stats["error"] = str(e)
return stats
def health_check(self) -> Dict[str, Any]:
"""
Comprehensive health check for the Sonarr database connection
Returns:
Dictionary with health status, connection info, and basic functionality tests
"""
health = {
"status": "healthy",
"database_type": self.db_type,
"connection": "ok",
"readable": False,
"tables_exist": False,
"sample_data": False,
"issues": [],
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
}
try:
with self._get_connection() as conn:
cursor = conn.cursor()
# Test 1: Basic read
try:
cursor.execute('SELECT 1')
result = cursor.fetchone()
if result and result[0] == 1:
health["readable"] = True
health["connection"] = "readable"
else:
health["issues"].append("Basic SELECT query failed")
except Exception as e:
health["issues"].append(f"Read test failed: {e}")
health["status"] = "degraded"
# Test 2: Check required tables
required_tables = ["Series", "Episodes", "EpisodeFiles", "History"]
existing_tables = []
try:
if self.db_type == "postgresql":
cursor.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('Series', 'Episodes', 'EpisodeFiles', 'History')
""")
else: # SQLite
cursor.execute("""
SELECT name
FROM sqlite_master
WHERE type='table'
AND name IN ('Series', 'Episodes', 'EpisodeFiles', 'History')
""")
rows = cursor.fetchall()
existing_tables = [row[0] for row in rows]
if len(existing_tables) == len(required_tables):
health["tables_exist"] = True
else:
missing = set(required_tables) - set(existing_tables)
health["issues"].append(f"Missing tables: {list(missing)}")
health["status"] = "degraded"
health["existing_tables"] = existing_tables
except Exception as e:
health["issues"].append(f"Table check failed: {e}")
health["status"] = "degraded"
# Test 3: Check for sample data
if health["tables_exist"]:
try:
cursor.execute('SELECT COUNT(*) FROM "Series"')
series_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM "Episodes"')
episode_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM "History"')
history_count = cursor.fetchone()[0]
if series_count > 0 and episode_count > 0:
health["sample_data"] = True
health["series_count"] = series_count
health["episode_count"] = episode_count
health["history_count"] = history_count
else:
health["issues"].append(f"Low data counts - Series: {series_count}, Episodes: {episode_count}")
except Exception as e:
health["issues"].append(f"Sample data check failed: {e}")
# Test 4: Test a real query
if health["sample_data"]:
try:
cursor.execute("""
SELECT COUNT(*)
FROM "Series"
WHERE "ImdbId" IS NOT NULL
""")
imdb_series = cursor.fetchone()[0]
health["series_with_imdb"] = imdb_series
if imdb_series > 0:
health["functional"] = True
else:
health["issues"].append("No series with IMDb IDs found")
except Exception as e:
health["issues"].append(f"Functional test failed: {e}")
health["status"] = "degraded"
except Exception as e:
health["status"] = "error"
health["connection"] = "failed"
health["issues"].append(f"Connection failed: {e}")
_log("ERROR", f"Database health check failed: {e}")
# Overall status
if health["issues"]:
if health["status"] == "healthy":
health["status"] = "degraded"
# Add connection details (safe info only)
health["connection_info"] = {
"type": self.db_type,
"host": self.db_host if self.db_type == "postgresql" else None,
"port": self.db_port if self.db_type == "postgresql" else None,
"database": self.db_name if self.db_type == "postgresql" else None,
"path": self.db_path if self.db_type == "sqlite" else None
}
return health
if __name__ == "__main__":
# Test the database client
print("Testing SonarrDbClient...")
# Test with environment variables
client = SonarrDbClient.from_env()
if client:
print("✅ Connected to Sonarr database")
# Test stats
stats = client.get_database_stats()
print(f"Database stats: {stats}")
# Test series lookup
test_series = client.get_series_by_imdb("tt1628033") # Top Gear from your data
if test_series:
print(f"Found test series: {test_series}")
# Test episodes
series_id = test_series['id']
episodes = client.get_all_episodes_for_series(series_id)
print(f"Found {len(episodes)} episodes")
# Test bulk import dates
if episodes:
import_dates = client.bulk_import_dates_for_series(series_id)
print(f"Import dates for {len(import_dates)} episodes")
else:
print("Test series not found")
else:
print("❌ Could not connect to database - check environment variables")
+30
View File
@@ -45,6 +45,9 @@ class NFOGuardConfig:
def _load_configuration(self) -> None: def _load_configuration(self) -> None:
"""Load all configuration from environment variables""" """Load all configuration from environment variables"""
# Server configuration
self._load_server_settings()
# Core paths - Required # Core paths - Required
self._load_paths() self._load_paths()
@@ -58,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()
@@ -80,6 +84,9 @@ class NFOGuardConfig:
# TV processing # TV processing
self._load_tv_settings() self._load_tv_settings()
# Web interface authentication
self._load_auth_settings()
def _load_paths(self) -> None: def _load_paths(self) -> None:
"""Load and validate path configuration""" """Load and validate path configuration"""
tv_paths_env = os.environ.get("TV_PATHS", "") tv_paths_env = os.environ.get("TV_PATHS", "")
@@ -115,6 +122,16 @@ class NFOGuardConfig:
current_value=movie_paths_env current_value=movie_paths_env
) )
def _load_server_settings(self) -> None:
"""Load server configuration"""
# Core API settings (webhooks, processing, database management)
self.core_api_host = os.environ.get("CORE_API_HOST", "0.0.0.0")
self.core_api_port = self._get_int_env("CORE_API_PORT", 8080, 1024, 65535)
# Web API settings (dashboard, web interface) - for reference/connection
self.web_api_host = os.environ.get("WEB_API_HOST", "0.0.0.0")
self.web_api_port = self._get_int_env("WEB_API_PORT", 8081, 1024, 65535)
def _load_external_connections(self) -> None: def _load_external_connections(self) -> None:
"""Load external API and database connection settings""" """Load external API and database connection settings"""
# API URLs # API URLs
@@ -156,6 +173,19 @@ 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:
"""Load web interface authentication settings"""
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
self.web_auth_username = os.environ.get("WEB_AUTH_USERNAME", "admin")
self.web_auth_password = os.environ.get("WEB_AUTH_PASSWORD", "")
self.web_auth_session_timeout = self._get_int_env("WEB_AUTH_SESSION_TIMEOUT", 3600, 300, 86400) # 1 hour default, 5min-24h range
def _get_int_env(self, name: str, default: int, min_val: int, max_val: int) -> int: def _get_int_env(self, name: str, default: int, min_val: int, max_val: int) -> int:
"""Get integer environment variable with validation""" """Get integer environment variable with validation"""
value_str = os.environ.get(name) value_str = os.environ.get(name)
-423
View File
@@ -1,423 +0,0 @@
"""
Async NFO Manager for NFOGuard
High-performance async NFO file operations with concurrent processing
"""
import asyncio
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple
from datetime import datetime
import xml.etree.ElementTree as ET
from utils.logging import _log
from utils.async_file_utils import (
async_read_nfo_file,
async_write_nfo_file,
async_file_exists,
async_set_file_mtime,
async_batch_nfo_operations,
async_concurrent_episode_processing
)
from utils.nfo_patterns import (
create_basic_nfo_structure,
extract_imdb_from_nfo_content,
extract_dates_from_nfo,
extract_imdb_id_from_text
)
from utils.validation import validate_date_string
class AsyncNFOManager:
"""Async NFO file manager with concurrent processing capabilities"""
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
self.manager_brand = manager_brand
self.debug = debug
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
"""
Async extract IMDb ID from directory path or filename
Args:
path: Path to examine
Returns:
IMDb ID if found, None otherwise
"""
# Use the sync version since it's just string processing
return extract_imdb_id_from_text(str(path))
async def async_parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
"""
Async extract IMDb ID from NFO file content
Args:
nfo_path: Path to NFO file
Returns:
IMDb ID if found, None otherwise
"""
root = await async_read_nfo_file(nfo_path)
if root is None:
return None
return extract_imdb_from_nfo_content(root)
async def async_find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
"""
Async find IMDb ID from directory name, filenames, or NFO file
Args:
movie_dir: Path to movie directory
Returns:
IMDb ID if found, None otherwise
"""
if self.debug:
_log("DEBUG", f"Async searching for IMDb ID in: {movie_dir.name}")
# First try directory name
imdb_id = await self.async_parse_imdb_from_path(movie_dir)
if imdb_id:
if self.debug:
_log("DEBUG", f"Found IMDb ID in directory name: {imdb_id}")
return imdb_id
# Try all files in the directory concurrently
if await async_file_exists(movie_dir):
try:
from utils.async_file_utils import aiofiles
entries = await aiofiles.os.listdir(movie_dir)
# Create tasks to check all files
async def check_file(filename: str) -> Optional[str]:
file_path = movie_dir / filename
if await aiofiles.os.path.isfile(file_path):
return await self.async_parse_imdb_from_path(file_path)
return None
# Check all files concurrently
results = await asyncio.gather(
*[check_file(filename) for filename in entries],
return_exceptions=True
)
# Find first valid IMDb ID
for result in results:
if isinstance(result, str) and result:
if self.debug:
_log("DEBUG", f"Found IMDb ID in filename: {result}")
return result
except Exception as e:
_log("WARNING", f"Failed to scan directory {movie_dir}: {e}")
# Finally, try NFO file content
nfo_path = movie_dir / "movie.nfo"
imdb_id = await self.async_parse_imdb_from_nfo(nfo_path)
if imdb_id:
if self.debug:
_log("DEBUG", f"Found IMDb ID in NFO file: {imdb_id}")
return imdb_id
if self.debug:
_log("DEBUG", f"No IMDb ID found for: {movie_dir.name}")
return None
async def async_create_movie_nfo(
self,
movie_dir: Path,
imdb_id: str,
dateadded: str,
premiered: Optional[str] = None,
lock_metadata: bool = True
) -> bool:
"""
Async create movie NFO file
Args:
movie_dir: Path to movie directory
imdb_id: IMDb ID
dateadded: Date added
premiered: Optional premiere date
lock_metadata: Whether to lock metadata
Returns:
True if successful, False otherwise
"""
try:
nfo_path = movie_dir / "movie.nfo"
# Prepare dates
dates = {"dateadded": dateadded}
if premiered and validate_date_string(premiered):
dates["premiered"] = premiered
# Create NFO structure
root = create_basic_nfo_structure(
media_type="movie",
title=movie_dir.name,
imdb_id=imdb_id,
dates=dates,
additional_fields={"source": self.manager_brand}
)
# Write NFO file asynchronously
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
if success and self.debug:
_log("DEBUG", f"Created movie NFO: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to create movie NFO for {movie_dir}: {e}")
return False
async def async_create_episode_nfo(
self,
season_dir: Path,
season: int,
episode: int,
aired: Optional[str] = None,
dateadded: Optional[str] = None,
source: str = "unknown",
lock_metadata: bool = True
) -> bool:
"""
Async create episode NFO file
Args:
season_dir: Path to season directory
season: Season number
episode: Episode number
aired: Optional air date
dateadded: Optional date added
source: Source of the data
lock_metadata: Whether to lock metadata
Returns:
True if successful, False otherwise
"""
try:
nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
nfo_path = season_dir / nfo_filename
# Prepare dates
dates = {}
if aired and validate_date_string(aired):
dates["aired"] = aired
if dateadded and validate_date_string(dateadded):
dates["dateadded"] = dateadded
# Create NFO structure
root = create_basic_nfo_structure(
media_type="episodedetails",
title=f"S{season:02d}E{episode:02d}",
dates=dates,
additional_fields={
"season": str(season),
"episode": str(episode),
"source": source
}
)
# Write NFO file asynchronously
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
if success and self.debug:
_log("DEBUG", f"Created episode NFO: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to create episode NFO S{season:02d}E{episode:02d}: {e}")
return False
async def async_batch_create_episode_nfos(
self,
episode_data_list: List[Dict[str, Any]],
max_concurrent: int = 5
) -> List[bool]:
"""
Batch create multiple episode NFOs concurrently
Args:
episode_data_list: List of episode data dictionaries
max_concurrent: Maximum concurrent NFO operations
Returns:
List of success/failure results
"""
async def _create_episode_nfo(episode_data: Dict[str, Any]) -> bool:
return await self.async_create_episode_nfo(
season_dir=episode_data.get('season_dir'),
season=episode_data.get('season'),
episode=episode_data.get('episode'),
aired=episode_data.get('aired'),
dateadded=episode_data.get('dateadded'),
source=episode_data.get('source', 'unknown'),
lock_metadata=episode_data.get('lock_metadata', True)
)
return await async_concurrent_episode_processing(
episode_data_list,
_create_episode_nfo,
max_concurrent
)
async def async_set_file_mtime(self, file_path: Path, date_str: str) -> bool:
"""
Async set file modification time from date string
Args:
file_path: Path to file
date_str: Date string in ISO format
Returns:
True if successful, False otherwise
"""
try:
if not validate_date_string(date_str):
_log("WARNING", f"Invalid date format for mtime: {date_str}")
return False
# Parse date string to timestamp
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
timestamp = dt.timestamp()
# Set file mtime asynchronously
success = await async_set_file_mtime(file_path, timestamp)
if success and self.debug:
_log("DEBUG", f"Set mtime for {file_path}: {date_str}")
return success
except Exception as e:
_log("ERROR", f"Failed to set mtime for {file_path}: {e}")
return False
async def async_batch_set_file_mtimes(
self,
file_mtime_pairs: List[Tuple[Path, str]],
max_concurrent: int = 10
) -> List[bool]:
"""
Batch set file modification times concurrently
Args:
file_mtime_pairs: List of (file_path, date_str) tuples
max_concurrent: Maximum concurrent operations
Returns:
List of success/failure results
"""
async def _set_single_mtime(file_path: Path, date_str: str) -> bool:
return await self.async_set_file_mtime(file_path, date_str)
# Create tasks for all mtime operations
semaphore = asyncio.Semaphore(max_concurrent)
async def _set_mtime_with_semaphore(file_path: Path, date_str: str) -> bool:
async with semaphore:
return await _set_single_mtime(file_path, date_str)
tasks = [
_set_mtime_with_semaphore(file_path, date_str)
for file_path, date_str in file_mtime_pairs
]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [result if isinstance(result, bool) else False for result in results]
async def async_validate_nfo_integrity(
self,
nfo_paths: List[Path],
max_concurrent: int = 10
) -> Dict[str, Any]:
"""
Async validate integrity of multiple NFO files
Args:
nfo_paths: List of NFO file paths to validate
max_concurrent: Maximum concurrent validations
Returns:
Dictionary with validation results and statistics
"""
results = {
'total_files': len(nfo_paths),
'valid_files': 0,
'invalid_files': 0,
'missing_files': 0,
'validation_errors': [],
'file_results': {}
}
async def _validate_single_nfo(nfo_path: Path) -> Dict[str, Any]:
file_result = {
'path': str(nfo_path),
'exists': False,
'valid_xml': False,
'has_imdb_id': False,
'has_dates': False,
'error': None
}
try:
# Check if file exists
if not await async_file_exists(nfo_path):
file_result['error'] = 'File does not exist'
return file_result
file_result['exists'] = True
# Try to parse NFO
root = await async_read_nfo_file(nfo_path)
if root is None:
file_result['error'] = 'Failed to parse XML'
return file_result
file_result['valid_xml'] = True
# Check for IMDb ID
imdb_id = extract_imdb_from_nfo_content(root)
file_result['has_imdb_id'] = bool(imdb_id)
# Check for dates
dates = extract_dates_from_nfo(root)
file_result['has_dates'] = any(dates.values())
except Exception as e:
file_result['error'] = str(e)
return file_result
# Validate all files concurrently
semaphore = asyncio.Semaphore(max_concurrent)
async def _validate_with_semaphore(nfo_path: Path) -> Dict[str, Any]:
async with semaphore:
return await _validate_single_nfo(nfo_path)
tasks = [_validate_with_semaphore(nfo_path) for nfo_path in nfo_paths]
file_results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
for i, result in enumerate(file_results):
if isinstance(result, dict):
path_str = str(nfo_paths[i])
results['file_results'][path_str] = result
if not result['exists']:
results['missing_files'] += 1
elif result['valid_xml']:
results['valid_files'] += 1
else:
results['invalid_files'] += 1
if result.get('error'):
results['validation_errors'].append(f"{path_str}: {result['error']}")
return results
+643 -6
View File
@@ -108,6 +108,8 @@ class NFOGuardDatabase:
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS movies ( CREATE TABLE IF NOT EXISTS movies (
imdb_id VARCHAR(20) PRIMARY KEY, imdb_id VARCHAR(20) PRIMARY KEY,
title TEXT,
year INTEGER,
path TEXT NOT NULL, path TEXT NOT NULL,
released DATE, released DATE,
dateadded TIMESTAMP, dateadded TIMESTAMP,
@@ -117,6 +119,44 @@ class NFOGuardDatabase:
) )
""") """)
# Add title and year columns if they don't exist (migration for existing databases)
cursor.execute("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='movies' AND column_name='title') THEN
ALTER TABLE movies ADD COLUMN title TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='movies' AND column_name='year') THEN
ALTER TABLE movies ADD COLUMN year INTEGER;
END IF;
END $$;
""")
# Add skipped and skip_reason columns if they don't exist (migration for skip tracking)
cursor.execute("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='movies' AND column_name='skipped') THEN
ALTER TABLE movies ADD COLUMN skipped BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='movies' AND column_name='skip_reason') THEN
ALTER TABLE movies ADD COLUMN skip_reason TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='episodes' AND column_name='skipped') THEN
ALTER TABLE episodes ADD COLUMN skipped BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='episodes' AND column_name='skip_reason') THEN
ALTER TABLE episodes ADD COLUMN skip_reason TEXT;
END IF;
END $$;
""")
# Processing history table # Processing history table
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS processing_history ( CREATE TABLE IF NOT EXISTS processing_history (
@@ -129,11 +169,79 @@ class NFOGuardDatabase:
) )
""") """)
# Missing IMDb tracking table
cursor.execute("""
CREATE TABLE IF NOT EXISTS missing_imdb (
id SERIAL PRIMARY KEY,
file_path TEXT NOT NULL UNIQUE,
media_type VARCHAR(20) NOT NULL,
folder_name TEXT,
filename TEXT,
discovered_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_checked TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
check_count INTEGER DEFAULT 1,
notes TEXT,
resolved BOOLEAN DEFAULT FALSE,
resolved_at TIMESTAMP,
resolved_imdb_id VARCHAR(20)
)
""")
# Scheduled scans table
cursor.execute("""
CREATE TABLE IF NOT EXISTS scheduled_scans (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
cron_expression VARCHAR(100) NOT NULL,
media_type VARCHAR(20) NOT NULL CHECK (media_type IN ('tv', 'movies', 'both')),
scan_mode VARCHAR(20) NOT NULL CHECK (scan_mode IN ('smart', 'full', 'incomplete')),
specific_paths TEXT,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_run_at TIMESTAMP,
next_run_at TIMESTAMP,
run_count INTEGER DEFAULT 0,
created_by VARCHAR(100),
updated_by VARCHAR(100)
)
""")
# Schedule execution history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS schedule_executions (
id SERIAL PRIMARY KEY,
schedule_id INTEGER NOT NULL,
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
status VARCHAR(50) NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
media_type VARCHAR(20) NOT NULL,
scan_mode VARCHAR(20) NOT NULL,
items_processed INTEGER DEFAULT 0,
items_skipped INTEGER DEFAULT 0,
items_failed INTEGER DEFAULT 0,
execution_time_seconds INTEGER,
error_message TEXT,
logs TEXT,
triggered_by VARCHAR(100),
FOREIGN KEY (schedule_id) REFERENCES scheduled_scans(id) ON DELETE CASCADE
)
""")
# Create indexes for PostgreSQL # 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:
@@ -187,25 +295,28 @@ class NFOGuardDatabase:
""", (imdb_id, path, timestamp)) """, (imdb_id, path, timestamp))
def upsert_movie_dates(self, imdb_id: str, released: Optional[str], def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
dateadded: Optional[str], source: str, has_video_file: bool = False): dateadded: Optional[str], source: str, has_video_file: bool = False,
title: Optional[str] = None, year: Optional[int] = None):
"""Insert or update movie date record""" """Insert or update movie date record"""
import os import os
if os.environ.get("DEBUG", "false").lower() == "true": if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}") print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, title={title}, dateadded={dateadded}, source={source}")
with self.get_connection() as conn: with self.get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
timestamp = datetime.utcnow() timestamp = datetime.utcnow()
cursor.execute(""" cursor.execute("""
INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated) INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated)
VALUES (%s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s) VALUES (%s, %s, %s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s)
ON CONFLICT (imdb_id) DO UPDATE SET ON CONFLICT (imdb_id) DO UPDATE SET
title = EXCLUDED.title,
year = EXCLUDED.year,
released = EXCLUDED.released, released = EXCLUDED.released,
dateadded = EXCLUDED.dateadded, dateadded = EXCLUDED.dateadded,
source = EXCLUDED.source, source = EXCLUDED.source,
has_video_file = EXCLUDED.has_video_file, has_video_file = EXCLUDED.has_video_file,
last_updated = EXCLUDED.last_updated last_updated = EXCLUDED.last_updated
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, timestamp)) """, (imdb_id, title, year, imdb_id, released, dateadded, source, has_video_file, timestamp))
# Debug: Check what was actually saved # Debug: Check what was actually saved
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,)) cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,))
@@ -214,6 +325,74 @@ class NFOGuardDatabase:
if os.environ.get("DEBUG", "false").lower() == "true": if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] if result else 'NOT_FOUND'}") print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] if result else 'NOT_FOUND'}")
def mark_movie_skipped(self, imdb_id: str, title: str, year: int, path: str, reason: str):
"""Mark a movie as skipped with reason"""
with self.get_connection() as conn:
cursor = conn.cursor()
timestamp = datetime.utcnow()
cursor.execute("""
INSERT INTO movies (imdb_id, title, year, path, skipped, skip_reason, has_video_file, last_updated)
VALUES (%s, %s, %s, %s, TRUE, %s, FALSE, %s)
ON CONFLICT (imdb_id) DO UPDATE SET
title = EXCLUDED.title,
year = EXCLUDED.year,
path = EXCLUDED.path,
skipped = TRUE,
skip_reason = EXCLUDED.skip_reason,
last_updated = EXCLUDED.last_updated
""", (imdb_id, title, year, path, reason, timestamp))
def mark_episode_skipped(self, imdb_id: str, season: int, episode: int, reason: str):
"""Mark an episode as skipped with reason"""
with self.get_connection() as conn:
cursor = conn.cursor()
timestamp = datetime.utcnow()
cursor.execute("""
INSERT INTO episodes (imdb_id, season, episode, skipped, skip_reason, has_video_file, last_updated)
VALUES (%s, %s, %s, TRUE, %s, FALSE, %s)
ON CONFLICT (imdb_id, season, episode) DO UPDATE SET
skipped = TRUE,
skip_reason = EXCLUDED.skip_reason,
last_updated = EXCLUDED.last_updated
""", (imdb_id, season, episode, reason, timestamp))
def clear_movie_skipped(self, imdb_id: str):
"""Clear skipped flag for a movie"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE movies SET skipped = FALSE, skip_reason = NULL
WHERE imdb_id = %s
""", (imdb_id,))
def clear_episode_skipped(self, imdb_id: str, season: int, episode: int):
"""Clear skipped flag for an episode"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE episodes SET skipped = FALSE, skip_reason = NULL
WHERE imdb_id = %s AND season = %s AND episode = %s
""", (imdb_id, season, episode))
def get_skipped_counts(self) -> Dict:
"""Get counts of skipped movies and episodes"""
with self.get_connection() as conn:
cursor = conn.cursor()
# Count skipped movies
cursor.execute("SELECT COUNT(*) as count FROM movies WHERE skipped = TRUE")
skipped_movies = cursor.fetchone()['count']
# Count skipped episodes
cursor.execute("SELECT COUNT(*) as count FROM episodes WHERE skipped = TRUE")
skipped_episodes = cursor.fetchone()['count']
return {
'movies': skipped_movies,
'episodes': skipped_episodes,
'total': skipped_movies + skipped_episodes
}
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]: def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
"""Get all episodes for a series""" """Get all episodes for a series"""
with self.get_connection() as conn: with self.get_connection() as conn:
@@ -258,6 +437,81 @@ class NFOGuardDatabase:
""", (imdb_id, media_type, event_type, datetime.utcnow().isoformat(), """, (imdb_id, media_type, event_type, datetime.utcnow().isoformat(),
json.dumps(details) if details else None)) json.dumps(details) if details else None))
def migrate_movie_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool:
"""Migrate a movie from placeholder IMDb ID to real IMDb ID"""
with self.get_connection() as conn:
cursor = conn.cursor()
# Check if old record exists
cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (old_imdb_id,))
old_record = cursor.fetchone()
if not old_record:
return False
old_data = dict(old_record)
# Check if new IMDb ID already exists
cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (new_imdb_id,))
if cursor.fetchone():
# New IMDb ID already exists, just delete the old one
cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,))
return True
# Create new record with correct IMDb ID
cursor.execute("""
INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source,
has_video_file, last_updated, skipped, skip_reason)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (new_imdb_id, old_data.get('title'), old_data.get('year'),
old_data.get('path'), old_data.get('released'), old_data.get('dateadded'),
old_data.get('source'), old_data.get('has_video_file'),
datetime.utcnow(), False, None)) # Clear skipped status
# Delete old placeholder record
cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,))
return True
def migrate_series_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool:
"""Migrate a series and all its episodes from placeholder IMDb ID to real IMDb ID"""
with self.get_connection() as conn:
cursor = conn.cursor()
# Check if old series exists
cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (old_imdb_id,))
old_series = cursor.fetchone()
if not old_series:
return False
old_series_data = dict(old_series)
# Check if new series IMDb ID already exists
cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (new_imdb_id,))
if cursor.fetchone():
# New series already exists, migrate episodes and delete old series
cursor.execute("""
UPDATE episodes SET imdb_id = %s WHERE imdb_id = %s
""", (new_imdb_id, old_imdb_id))
cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,))
return True
# Create new series record
cursor.execute("""
INSERT INTO series (imdb_id, path, last_updated, metadata)
VALUES (%s, %s, %s, %s)
""", (new_imdb_id, old_series_data.get('path'),
datetime.utcnow(), old_series_data.get('metadata')))
# Migrate all episodes to new series IMDb ID and clear skipped status
cursor.execute("""
UPDATE episodes
SET imdb_id = %s, skipped = FALSE, skip_reason = NULL
WHERE imdb_id = %s
""", (new_imdb_id, old_imdb_id))
# Delete old placeholder series
cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,))
return True
def get_stats(self) -> Dict[str, Any]: def get_stats(self) -> Dict[str, Any]:
"""Get database statistics""" """Get database statistics"""
with self.get_connection() as conn: with self.get_connection() as conn:
@@ -441,6 +695,90 @@ 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 update_movie_file_info(self, imdb_id: str, path: str, has_video_file: bool = True) -> bool:
"""
Update path and video file status for an existing movie
Used when population finds a video file for a manually-added movie
Args:
imdb_id: Movie IMDb ID
path: File path to the video file
has_video_file: Whether a video file exists (default True)
Returns:
True if movie was updated, False if not found
"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE movies
SET path = %s,
has_video_file = %s,
last_updated = %s
WHERE imdb_id = %s
""", (path, has_video_file, datetime.utcnow(), imdb_id))
updated_count = cursor.rowcount
conn.commit()
return updated_count > 0
def update_episode_file_info(self, imdb_id: str, season: int, episode: int,
path: str, has_video_file: bool = True) -> bool:
"""
Update path and video file status for an existing episode
Used when population finds a video file for a manually-added episode
Args:
imdb_id: Series IMDb ID
season: Season number
episode: Episode number
path: File path to the video file
has_video_file: Whether a video file exists (default True)
Returns:
True if episode was updated, False if not found
"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE episodes
SET path = %s,
has_video_file = %s,
last_updated = %s
WHERE imdb_id = %s AND season = %s AND episode = %s
""", (path, has_video_file, datetime.utcnow(), imdb_id, season, episode))
updated_count = cursor.rowcount
conn.commit()
return updated_count > 0
def delete_orphaned_movies(self) -> List[Dict]: 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 +898,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
+476
View File
@@ -0,0 +1,476 @@
#!/usr/bin/env python3
"""
Database Populator for NFOGuard
Bulk populates the NFOGuard database from Radarr/Sonarr
Phase 4: Replace NFO-based initial population with direct DB/API queries
"""
import time
import hashlib
from typing import Dict, List, Optional, Tuple
from datetime import datetime
from pathlib import Path
from core.database import NFOGuardDatabase
from clients.radarr_client import RadarrClient
from clients.sonarr_client import SonarrClient
from utils.logging import _log
from utils.imdb_utils import parse_imdb_from_path
class DatabasePopulator:
"""Populates NFOGuard database from Radarr/Sonarr sources"""
def __init__(self, db: NFOGuardDatabase, radarr_client: RadarrClient, sonarr_client: SonarrClient):
self.db = db
self.radarr = radarr_client
self.sonarr = sonarr_client
def populate_movies(self) -> Dict[str, any]:
"""
Populate movies from Radarr database/API
Returns:
Dictionary with statistics: {
'total': int,
'added': int,
'updated': int,
'skipped': int,
'errors': int,
'duration': float
}
"""
_log("INFO", "Starting movie population from Radarr")
start_time = time.time()
stats = {
'total': 0,
'added': 0,
'updated': 0,
'skipped': 0,
'errors': 0,
'duration': 0.0,
'skipped_items': [] # Track what was skipped and why
}
try:
# Get all movies from Radarr database
if not hasattr(self.radarr, 'db_client') or not self.radarr.db_client:
_log("ERROR", "Radarr database client not available - cannot populate movies")
stats['errors'] += 1
return stats
movies = self.radarr.db_client.get_all_movies()
if not movies:
_log("WARNING", "No movies found in Radarr database")
return stats
stats['total'] = len(movies)
_log("INFO", f"Found {stats['total']} movies in Radarr")
# Process each movie
for movie in movies:
try:
# Get movie path first (we'll need it for IMDb extraction)
path = movie.get('path', '')
# Try to get IMDb ID from Radarr database
imdb_id = movie.get('imdb_id')
# If not in database, try extracting from directory/filename
if not imdb_id and path:
imdb_id = parse_imdb_from_path(Path(path))
if imdb_id:
_log("DEBUG", f"Extracted IMDb ID {imdb_id} from path for: {movie.get('title')}")
if not imdb_id:
# Generate placeholder IMDb ID using hash of path
path_hash = hashlib.md5(path.encode()).hexdigest()[:12]
imdb_id = f"missing-{path_hash}"
skip_reason = 'No IMDb ID found'
skip_info = {
'title': movie.get('title', 'Unknown'),
'year': movie.get('year'),
'imdb_id': imdb_id,
'path': path,
'reason': skip_reason
}
stats['skipped_items'].append(skip_info)
_log("DEBUG", f"Movie without IMDb ID: {movie.get('title')} (path: {path}), using placeholder {imdb_id}")
# Mark as skipped in database with placeholder IMDb ID
self.db.mark_movie_skipped(
imdb_id=imdb_id,
title=movie.get('title', 'Unknown'),
year=movie.get('year', 0),
path=path,
reason=skip_reason
)
stats['skipped'] += 1
continue
# Check if movie already exists in database
existing = self.db.get_movie_dates(imdb_id)
if existing and existing.get('dateadded'):
# Already in database - update file path and video status if needed
existing_path = existing.get('path')
if not existing_path or existing_path == 'unknown' or existing_path != path:
_log("INFO", f"Movie {imdb_id} exists but updating file info: {path}")
self.db.update_movie_file_info(imdb_id, path, has_video_file=True)
# Add to processing history
try:
self.db.add_processing_history(
imdb_id=imdb_id,
media_type='movie',
event_type='file_info_update',
details={'path': path}
)
except Exception as e:
_log("WARNING", f"Failed to add processing history for {imdb_id}: {e}")
stats['updated'] += 1
else:
_log("DEBUG", f"Movie {imdb_id} already in database with correct path, skipping")
continue
# Get release date
released = None
if movie.get('digital_release'):
released = movie.get('digital_release')
source_type = 'radarr:digital'
elif movie.get('physical_release'):
released = movie.get('physical_release')
source_type = 'radarr:physical'
elif movie.get('in_cinemas'):
released = movie.get('in_cinemas')
source_type = 'radarr:theatrical'
else:
source_type = 'radarr:unknown'
# Get import date from Radarr history using Radarr's internal movie ID
radarr_movie_id = movie.get('id')
if radarr_movie_id:
# get_movie_import_date returns tuple (date, source)
import_date, import_source = self.radarr.get_movie_import_date(radarr_movie_id)
if import_date:
dateadded = import_date
source = import_source
elif released:
# Use release date as fallback
dateadded = released
source = f'{source_type}_fallback'
else:
skip_reason = 'No import date in Radarr history and no release dates available'
skip_info = {
'title': movie.get('title', 'Unknown'),
'year': movie.get('year'),
'imdb_id': imdb_id,
'reason': skip_reason
}
stats['skipped_items'].append(skip_info)
_log("DEBUG", f"No date available for movie {imdb_id}, skipping")
# Mark as skipped in database for troubleshooting
self.db.mark_movie_skipped(
imdb_id=imdb_id,
title=movie.get('title', 'Unknown'),
year=movie.get('year', 0),
path=path or 'unknown',
reason=skip_reason
)
stats['skipped'] += 1
continue
elif released:
# No Radarr ID, use release date
dateadded = released
source = f'{source_type}_fallback'
else:
skip_reason = 'No Radarr movie ID and no release dates available'
skip_info = {
'title': movie.get('title', 'Unknown'),
'year': movie.get('year'),
'imdb_id': imdb_id,
'reason': skip_reason
}
stats['skipped_items'].append(skip_info)
_log("DEBUG", f"No date available for movie {imdb_id}, skipping")
# Mark as skipped in database for troubleshooting
self.db.mark_movie_skipped(
imdb_id=imdb_id,
title=movie.get('title', 'Unknown'),
year=movie.get('year', 0),
path=path or 'unknown',
reason=skip_reason
)
stats['skipped'] += 1
continue
# Insert into database with title and year
title = movie.get('title')
year = movie.get('year')
self.db.upsert_movie_dates(
imdb_id, released, dateadded, source,
has_video_file=True, title=title, year=year
)
# Add to processing history
try:
self.db.add_processing_history(
imdb_id=imdb_id,
media_type='movie',
event_type='database_population',
details={'source': source, 'title': title}
)
except Exception as e:
_log("WARNING", f"Failed to add processing history for {imdb_id}: {e}")
stats['added'] += 1
_log("DEBUG", f"Added movie {imdb_id}: {title} ({year}) (source: {source})")
except Exception as e:
_log("ERROR", f"Error processing movie {movie.get('title', 'unknown')}: {e}")
stats['errors'] += 1
continue
except Exception as e:
_log("ERROR", f"Error during movie population: {e}")
stats['errors'] += 1
stats['duration'] = time.time() - start_time
_log("INFO", f"Movie population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s")
# Log details of skipped items
if stats['skipped_items']:
_log("INFO", f"Skipped items details ({len(stats['skipped_items'])} total):")
for item in stats['skipped_items']:
_log("INFO", f" - {item['title']} ({item.get('year', 'N/A')}) [{item.get('imdb_id', 'No IMDb')}]: {item['reason']}")
return stats
def populate_tv_episodes(self) -> Dict[str, any]:
"""
Populate TV episodes from Sonarr API
Returns:
Dictionary with statistics: {
'total_series': int,
'total_episodes': int,
'added': int,
'updated': int,
'skipped': int,
'errors': int,
'duration': float
}
"""
_log("INFO", "Starting TV episode population from Sonarr")
start_time = time.time()
stats = {
'total_series': 0,
'total_episodes': 0,
'added': 0,
'updated': 0,
'skipped': 0,
'errors': 0,
'duration': 0.0,
'skipped_items': [] # Track what was skipped and why
}
try:
# Get all series from Sonarr
all_series = self.sonarr.get_all_series()
if not all_series:
_log("WARNING", "No series found in Sonarr")
return stats
stats['total_series'] = len(all_series)
_log("INFO", f"Found {stats['total_series']} series in Sonarr")
# Process each series
for series in all_series:
try:
imdb_id = series.get('imdbId')
series_id = series.get('id')
series_path = series.get('path', '')
series_title = series.get('title', 'Unknown')
if not imdb_id:
# Generate placeholder IMDb ID using hash of path
path_hash = hashlib.md5(series_path.encode()).hexdigest()[:12]
imdb_id = f"missing-{path_hash}"
_log("DEBUG", f"Series without IMDb ID: {series_title} (path: {series_path}), using placeholder {imdb_id}")
# Update series record
self.db.upsert_series(imdb_id, series_path)
# Try high-performance database bulk query first
sonarr_db = getattr(self.sonarr, 'db_client', None)
bulk_import_dates = {}
if sonarr_db:
try:
_log("DEBUG", f"Using DB bulk query for {series_title}")
bulk_import_dates = sonarr_db.bulk_import_dates_for_series(series_id)
_log("DEBUG", f"✅ Got {len(bulk_import_dates)} import dates from DB for {series_title}")
except Exception as e:
_log("WARNING", f"DB bulk query failed for {series_title}, falling back to API: {e}")
# Get all episodes for this series
episodes = self.sonarr.episodes_for_series(series_id)
if not episodes:
continue
_log("DEBUG", f"Processing {len(episodes)} episodes for {series_title}")
# Process each episode
for episode in episodes:
try:
season_num = episode.get('seasonNumber', 0)
episode_num = episode.get('episodeNumber', 0)
episode_title = episode.get('title', 'Unknown')
if season_num < 0 or episode_num <= 0:
continue
stats['total_episodes'] += 1
# Check if episode already exists
existing = self.db.get_episode_date(imdb_id, season_num, episode_num)
if existing and existing.get('dateadded'):
# Already in database - update file path and video status if needed
existing_path = existing.get('path')
episode_path = episode.get('path', 'unknown')
if not existing_path or existing_path == 'unknown' or existing_path != episode_path:
_log("INFO", f"Episode {imdb_id} S{season_num:02d}E{episode_num:02d} exists but updating file info: {episode_path}")
self.db.update_episode_file_info(imdb_id, season_num, episode_num, episode_path, has_video_file=True)
# Add to processing history
try:
self.db.add_processing_history(
imdb_id=imdb_id,
media_type='episode',
event_type='file_info_update',
details={'season': season_num, 'episode': episode_num, 'path': episode_path}
)
except Exception as e:
_log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}")
stats['updated'] += 1
continue
# Only process episodes that have video files
has_file = episode.get('hasFile', False)
if not has_file:
# No video file - skip silently (intentionally filtered)
continue
# Get air date
aired = episode.get('airDate')
# Get import date
dateadded = None
source = None
# Try bulk DB result first
if (season_num, episode_num) in bulk_import_dates:
dateadded, source = bulk_import_dates[(season_num, episode_num)]
# Fall back to API query
else:
episode_id = episode.get('id')
if episode_id:
import_date = self.sonarr.get_episode_import_history(episode_id)
if import_date:
dateadded = import_date
source = 'sonarr:api.import_history'
# Fallback to air date if no import date
if not dateadded and aired:
dateadded = aired
source = 'sonarr:aired_fallback'
elif not dateadded:
# No date available
skip_reason = 'No import date from Sonarr history and no air date available'
skip_info = {
'title': series_title,
'episode_title': episode_title,
'season': season_num,
'episode': episode_num,
'reason': skip_reason
}
stats['skipped_items'].append(skip_info)
# Mark as skipped in database for troubleshooting
self.db.mark_episode_skipped(
imdb_id=imdb_id,
season=season_num,
episode=episode_num,
reason=skip_reason
)
stats['skipped'] += 1
continue
# Insert into database
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, has_file)
# Add to processing history
try:
self.db.add_processing_history(
imdb_id=imdb_id,
media_type='episode',
event_type='database_population',
details={'season': season_num, 'episode': episode_num, 'source': source, 'title': episode_title}
)
except Exception as e:
_log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}")
stats['added'] += 1
except Exception as e:
_log("ERROR", f"Error processing episode S{season_num:02d}E{episode_num:02d} of {series_title}: {e}")
stats['errors'] += 1
continue
except Exception as e:
_log("ERROR", f"Error processing series {series.get('title', 'unknown')}: {e}")
stats['errors'] += 1
continue
except Exception as e:
_log("ERROR", f"Error during TV episode population: {e}")
stats['errors'] += 1
stats['duration'] = time.time() - start_time
_log("INFO", f"TV episode population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s")
# Log details of skipped items
if stats['skipped_items']:
_log("INFO", f"Skipped episodes details ({len(stats['skipped_items'])} total):")
for item in stats['skipped_items'][:20]: # Only log first 20 to avoid spam
_log("INFO", f" - {item['title']} S{str(item['season']).zfill(2)}E{str(item['episode']).zfill(2)} ({item.get('episode_title', 'Unknown')}): {item['reason']}")
if len(stats['skipped_items']) > 20:
_log("INFO", f" ... and {len(stats['skipped_items']) - 20} more (see web interface for full list)")
return stats
def populate_all(self) -> Dict[str, any]:
"""
Populate both movies and TV episodes
Returns:
Combined statistics dictionary
"""
_log("INFO", "Starting full database population")
start_time = time.time()
movie_stats = self.populate_movies()
tv_stats = self.populate_tv_episodes()
combined_stats = {
'movies': movie_stats,
'tv': tv_stats,
'total_duration': time.time() - start_time
}
_log("INFO", f"Full database population complete in {combined_stats['total_duration']:.2f}s")
return combined_stats
-276
View File
@@ -1,276 +0,0 @@
#!/usr/bin/env python3
"""
Episode NFO Manager - Handles TV episode NFO creation with video filename matching
Core principle: NFO filenames should match video filenames
"""
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
import re
from .logging import _log
class EpisodeNFOManager:
"""Manages episode NFO files with video filename matching"""
def __init__(self, manager_brand: str = "NFOGuard"):
self.manager_brand = manager_brand
def find_video_files_for_season(self, season_dir: Path) -> Dict[Tuple[int, int], List[Path]]:
"""Find all video files in season directory, grouped by (season, episode)"""
if not season_dir.exists():
_log("DEBUG", f"Season directory does not exist: {season_dir}")
return {}
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
episodes = {}
_log("DEBUG", f"Scanning video files in: {season_dir}")
for video_file in season_dir.iterdir():
_log("DEBUG", f"Checking file: {video_file.name} (is_file: {video_file.is_file()}, suffix: {video_file.suffix.lower()})")
if (video_file.is_file() and
video_file.suffix.lower() in video_extensions):
episode_info = self._parse_episode_from_filename(video_file.name)
_log("DEBUG", f"Episode parsing for '{video_file.name}': {episode_info}")
if episode_info:
season_num, episode_num = episode_info
key = (season_num, episode_num)
if key not in episodes:
episodes[key] = []
episodes[key].append(video_file)
_log("DEBUG", f"Added video file: S{season_num:02d}E{episode_num:02d} → {video_file.name}")
_log("DEBUG", f"Total video files found: {len(episodes)} episodes")
return episodes
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Extract season and episode numbers from filename"""
# Try S##E## format first (most common)
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
# Try ##x## format
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
return None
def find_nfo_for_episode(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find existing NFO file for episode (prefer video-matching filename)"""
if not season_dir.exists():
return None
# First, look for NFO files that match video filenames
video_files = self.find_video_files_for_season(season_dir)
key = (season_num, episode_num)
if key in video_files:
for video_file in video_files[key]:
potential_nfo = season_dir / f"{video_file.stem}.nfo"
if potential_nfo.exists():
_log("DEBUG", f"Found video-matching NFO: {potential_nfo.name}")
return potential_nfo
# Fallback: look for short name NFO
short_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
if short_nfo.exists():
_log("DEBUG", f"Found short-name NFO: {short_nfo.name}")
return short_nfo
# Last resort: search all NFO files for matching season/episode data
for nfo_file in season_dir.glob("*.nfo"):
if self._nfo_matches_episode(nfo_file, season_num, episode_num):
_log("DEBUG", f"Found matching NFO by content: {nfo_file.name}")
return nfo_file
return None
def _nfo_matches_episode(self, nfo_path: Path, season_num: int, episode_num: int) -> bool:
"""Check if NFO file contains the specified season/episode"""
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
if root.tag == "episodedetails":
season_elem = root.find("season")
episode_elem = root.find("episode")
if (season_elem is not None and episode_elem is not None and
season_elem.text and episode_elem.text):
try:
file_season = int(season_elem.text)
file_episode = int(episode_elem.text)
return file_season == season_num and file_episode == episode_num
except ValueError:
pass
except (ET.ParseError, Exception):
pass
return False
def get_target_nfo_path(self, season_dir: Path, season_num: int, episode_num: int) -> Path:
"""Get the target NFO path (prefer video filename, fallback to short name)"""
video_files = self.find_video_files_for_season(season_dir)
key = (season_num, episode_num)
if key in video_files and video_files[key]:
# Use the first video file found (handle multiple files gracefully)
video_file = video_files[key][0]
target_nfo = season_dir / f"{video_file.stem}.nfo"
_log("DEBUG", f"Target NFO will match video: {target_nfo.name}")
return target_nfo
else:
# Fallback to short name if no video file found
target_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
_log("WARNING", f"No video file found for S{season_num:02d}E{episode_num:02d}, using short name: {target_nfo.name}")
return target_nfo
def migrate_nfo_to_video_filename(self, season_dir: Path, season_num: int, episode_num: int) -> bool:
"""If short-name NFO exists, rename it to match video filename"""
existing_nfo = self.find_nfo_for_episode(season_dir, season_num, episode_num)
target_nfo = self.get_target_nfo_path(season_dir, season_num, episode_num)
# If we already have the right filename, nothing to do
if existing_nfo and existing_nfo == target_nfo:
return True
# If we have an NFO but it doesn't match target, rename it
if existing_nfo and existing_nfo != target_nfo:
try:
_log("INFO", f"Migrating NFO filename: {existing_nfo.name} -> {target_nfo.name}")
existing_nfo.rename(target_nfo)
return True
except Exception as e:
_log("ERROR", f"Failed to rename NFO: {e}")
return False
return False
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
aired: Optional[str], dateadded: Optional[str], source: str,
title: Optional[str] = None, plot: Optional[str] = None) -> bool:
"""Create or update episode NFO with video filename matching"""
# Get the target NFO path (matching video filename)
nfo_path = self.get_target_nfo_path(season_dir, season_num, episode_num)
# Migrate existing NFO if needed
self.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
try:
# Load existing NFO if it exists
episode_elem = None
if nfo_path.exists():
try:
tree = ET.parse(nfo_path)
episode_elem = tree.getroot()
if episode_elem.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>")
# Remove NFOGuard-managed fields (we'll re-add them)
for tag in ["season", "episode", "aired", "premiered", "dateadded", "lockdata"]:
existing = episode_elem.find(tag)
if existing is not None:
episode_elem.remove(existing)
_log("DEBUG", f"Loaded existing NFO content for {nfo_path.name}")
except (ET.ParseError, ValueError) as e:
_log("WARNING", f"Corrupted NFO file {nfo_path.name}: {e}. Creating new one.")
episode_elem = None
# Create new structure if needed
if episode_elem is None:
episode_elem = ET.Element("episodedetails")
# Add title if provided and not already present
if title and not episode_elem.find("title"):
title_elem = ET.SubElement(episode_elem, "title")
title_elem.text = title
# Add plot if provided and not already present
if plot and not episode_elem.find("plot"):
plot_elem = ET.SubElement(episode_elem, "plot")
plot_elem.text = plot
# Add NFOGuard fields at the end
season_elem = ET.SubElement(episode_elem, "season")
season_elem.text = str(season_num)
episode_num_elem = ET.SubElement(episode_elem, "episode")
episode_num_elem.text = str(episode_num)
if aired:
aired_elem = ET.SubElement(episode_elem, "aired")
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
# Also add premiered for compatibility
premiered_elem = ET.SubElement(episode_elem, "premiered")
premiered_elem.text = aired[:10] if len(aired) >= 10 else aired
if dateadded:
dateadded_elem = ET.SubElement(episode_elem, "dateadded")
dateadded_elem.text = dateadded
# Add lockdata
lockdata_elem = ET.SubElement(episode_elem, "lockdata")
lockdata_elem.text = "true"
# Add comment with source
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
episode_elem.append(comment)
# Write the NFO file
tree = ET.ElementTree(episode_elem)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
_log("INFO", f"✅ Created/updated episode NFO: {nfo_path.name}")
_log("INFO", f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, DateAdded: {dateadded}, Source: {source}")
return True
except Exception as e:
_log("ERROR", f"Failed to create episode NFO {nfo_path}: {e}")
return False
def extract_nfoguard_data(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed data from existing NFO"""
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
if root.tag != "episodedetails":
return None
# Look for NFOGuard fields
dateadded_elem = root.find("dateadded")
aired_elem = root.find("aired")
lockdata_elem = root.find("lockdata")
# Only consider it NFOGuard-managed if it has dateadded and lockdata
if (dateadded_elem is not None and dateadded_elem.text and
lockdata_elem is not None and lockdata_elem.text == "true"):
result = {
"dateadded": dateadded_elem.text.strip(),
"source": "existing_nfo"
}
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
_log("DEBUG", f"Found NFOGuard data in {nfo_path.name}: {result}")
return result
except (ET.ParseError, Exception) as e:
_log("WARNING", f"Error parsing NFO {nfo_path.name}: {e}")
return None
-789
View File
@@ -1,789 +0,0 @@
#!/usr/bin/env python3
"""
NFO Manager for creating and managing metadata files
Handles NFO creation for movies, TV shows, seasons, and episodes
"""
import os
import xml.etree.ElementTree as ET
from pathlib import Path
from datetime import datetime
from typing import Optional, Dict, Any, Tuple
import re
class NFOManager:
"""Manages NFO file creation and updates"""
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
self.manager_brand = manager_brand
self.debug = debug
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
"""Extract IMDb ID from directory path or filename"""
# Look for various IMDb patterns in both directory and file names
path_str = str(path).lower()
# Try [imdb-ttXXXXXXX] format first (most explicit)
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
if match:
return match.group(1)
# Try standalone [ttXXXXXXX] format in brackets
match = re.search(r'\[(tt\d+)\]', path_str)
if match:
return match.group(1)
# Try {imdb-ttXXXXXXX} format with curly braces
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
if match:
return match.group(1)
# Try (imdb-ttXXXXXXX) format with parentheses
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
if match:
return match.group(1)
# Try ttXXXXXXX at end of filename/dirname (common pattern)
match = re.search(r'[-_\s](tt\d+)$', path_str)
if match:
return match.group(1)
return None
def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
"""Extract IMDb ID from NFO file content"""
if not nfo_path.exists():
return None
try:
root = self._parse_nfo_with_tolerance(nfo_path)
# Check for <uniqueid type="imdb">ttXXXXXX</uniqueid>
imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]')
if imdb_uniqueid is not None and imdb_uniqueid.text:
imdb_id = imdb_uniqueid.text.strip()
if imdb_id.startswith('tt'):
return imdb_id
# Check for legacy <imdbid>ttXXXXXX</imdbid>
imdbid_elem = root.find('.//imdbid')
if imdbid_elem is not None and imdbid_elem.text:
imdb_id = imdbid_elem.text.strip()
if imdb_id.startswith('tt'):
return imdb_id
# Check for legacy <imdb>ttXXXXXX</imdb>
imdb_elem = root.find('.//imdb')
if imdb_elem is not None and imdb_elem.text:
imdb_id = imdb_elem.text.strip()
if imdb_id.startswith('tt'):
return imdb_id
# Last resort: Check for TMDB ID as fallback identifier
# This handles movies that only have TMDB IDs in NFO files
tmdb_uniqueid = root.find('.//uniqueid[@type="tmdb"]')
if tmdb_uniqueid is not None and tmdb_uniqueid.text:
tmdb_id = tmdb_uniqueid.text.strip()
if tmdb_id.isdigit():
print(f"⚠️ Found TMDB ID {tmdb_id} but no IMDb ID - using TMDB ID as fallback")
# Return TMDB ID with prefix to distinguish from IMDb IDs
return f"tmdb-{tmdb_id}"
except (ET.ParseError, Exception):
# Skip corrupted or non-XML files
pass
return None
def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
"""Find IMDb ID from directory name, filenames, or NFO file"""
# First try directory name
imdb_id = self.parse_imdb_from_path(movie_dir)
if imdb_id:
return imdb_id
# Try all files in the directory for IMDb ID patterns
for file_path in movie_dir.iterdir():
if file_path.is_file():
imdb_id = self.parse_imdb_from_path(file_path)
if imdb_id:
return imdb_id
# Finally, try NFO file content (including TMDB fallback)
nfo_path = movie_dir / "movie.nfo"
imdb_id = self.parse_imdb_from_nfo(nfo_path)
if imdb_id:
return imdb_id
return None
def find_series_imdb_id(self, series_dir: Path) -> Optional[str]:
"""Find IMDb ID from TV series directory name, filenames, or tvshow.nfo file"""
# First try directory name
imdb_id = self.parse_imdb_from_path(series_dir)
if imdb_id:
return imdb_id
# Try all files in the directory for IMDb ID patterns
for file_path in series_dir.iterdir():
if file_path.is_file():
imdb_id = self.parse_imdb_from_path(file_path)
if imdb_id:
return imdb_id
# Finally, try tvshow.nfo file content
nfo_path = series_dir / "tvshow.nfo"
imdb_id = self.parse_imdb_from_nfo(nfo_path)
if imdb_id:
return imdb_id
return None
def extract_nfoguard_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed dates from existing NFO file"""
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Look for NFOGuard fields
dateadded_elem = root.find('.//dateadded')
premiered_elem = root.find('.//premiered')
aired_elem = root.find('.//aired') # For TV episodes
lockdata_elem = root.find('.//lockdata')
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
if lockdata_elem is not None and lockdata_elem.text == "true":
# Extract original source from NFOGuard comment, default to nfo_file_existing
source = "nfo_file_existing"
# Parse XML content to find NFOGuard comment with source
nfo_content = nfo_path.read_text(encoding='utf-8')
import re
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
if source_match:
source = source_match.group(1).strip()
print(f"🔍 Extracted original source from NFO comment: {source}")
result = {
"source": source
}
if dateadded_elem is not None and dateadded_elem.text:
result["dateadded"] = dateadded_elem.text.strip()
if premiered_elem is not None and premiered_elem.text:
result["released"] = premiered_elem.text.strip()
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
print(f"✅ Found NFOGuard data in NFO: dateadded={result.get('dateadded', 'None')}, source={source}, released={result.get('released', 'None')}, aired={result.get('aired', 'None')}")
return result
except (ET.ParseError, Exception) as e:
print(f"⚠️ Error parsing NFO for NFOGuard data: {e}")
pass
return None
def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed dates from existing episode NFO file"""
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
nfo_path = season_path / nfo_filename
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Look for NFOGuard fields in episode NFO
dateadded_elem = root.find('.//dateadded')
aired_elem = root.find('.//aired')
lockdata_elem = root.find('.//lockdata')
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
if lockdata_elem is not None and lockdata_elem.text == "true":
# Extract original source from NFOGuard comment, default to episode_nfo_existing
source = "episode_nfo_existing"
# Parse XML content to find NFOGuard comment with source
nfo_content = nfo_path.read_text(encoding='utf-8')
import re
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
if source_match:
source = source_match.group(1).strip()
print(f"🔍 Extracted original source from episode NFO comment: {source}")
result = {
"source": source
}
if dateadded_elem is not None and dateadded_elem.text:
result["dateadded"] = dateadded_elem.text.strip()
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result.get('dateadded', 'None')}, source={source}, aired={result.get('aired', 'None')}")
return result
except (ET.ParseError, Exception) as e:
print(f"⚠️ Error parsing episode NFO for NFOGuard data: {e}")
pass
return None
def _parse_nfo_with_tolerance(self, nfo_path: Path):
"""Parse NFO file with tolerance for URLs appended after XML"""
try:
# First try normal parsing
tree = ET.parse(nfo_path)
return tree.getroot()
except ET.ParseError as e:
# If parsing fails, try to extract just the XML part
try:
with open(nfo_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find the last </movie> tag and truncate after it
last_movie_end = content.rfind('</movie>')
if last_movie_end != -1:
xml_content = content[:last_movie_end + 8] # +8 for </movie>
# Try parsing the truncated content
root = ET.fromstring(xml_content)
print(f"✅ Successfully parsed NFO after removing trailing content: {nfo_path.name}")
return root
else:
# Re-raise original error if we can't find </movie>
raise e
except Exception:
# Re-raise original error if our fix attempt fails
raise e
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
released: Optional[str] = None, source: str = "unknown",
lock_metadata: bool = True) -> None:
"""Create or update movie.nfo file preserving existing content"""
nfo_path = movie_dir / "movie.nfo"
# Debug output only if DEBUG=true in environment
import os
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 create_movie_nfo called: imdb_id={imdb_id}, dateadded={dateadded}, released={released}, source={source}")
print(f"🔍 NFO path: {nfo_path}")
print(f"🔍 NFO exists: {nfo_path.exists()}")
try:
# Try to load existing NFO file
if nfo_path.exists():
try:
# Try to parse the XML, handling URLs appended after </movie>
movie = self._parse_nfo_with_tolerance(nfo_path)
# Ensure root element is <movie>
if movie.tag != "movie":
raise ValueError("Root element is not <movie>")
# Only remove elements that are clearly NFOGuard-managed
# Look for elements that have NFOGuard characteristics
elements_to_remove = []
# Remove lockdata=true (this is definitely NFOGuard)
for lockdata in movie.findall("lockdata"):
if lockdata.text == "true":
elements_to_remove.append(lockdata)
# Remove IMDb uniqueids that we manage
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
elements_to_remove.append(uniqueid)
# For dateadded/premiered/year, only remove if they appear to be NFOGuard-managed
# (i.e., if lockdata=true exists, these are likely ours)
has_nfoguard_lockdata = any(ld.text == "true" for ld in movie.findall("lockdata"))
if has_nfoguard_lockdata:
# This NFO was managed by NFOGuard, safe to remove our fields
for tag in ["dateadded", "premiered", "year"]:
existing = movie.find(tag)
if existing is not None:
# Store the value before removing (for premiered/year)
if tag == "premiered" and not released:
print(f"🔍 Preserving existing premiered date: {existing.text}")
released = existing.text
elements_to_remove.append(existing)
else:
# No NFOGuard lockdata found, be more conservative
# Only remove dateadded if it looks like NFOGuard format (ISO timestamp)
dateadded_elem = movie.find("dateadded")
if dateadded_elem is not None and dateadded_elem.text:
# NFOGuard uses ISO format like "2025-10-12 16:26:02"
if re.match(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', dateadded_elem.text.strip()):
elements_to_remove.append(dateadded_elem)
# Remove all identified elements
for elem in elements_to_remove:
movie.remove(elem)
except (ET.ParseError, ValueError) as e:
print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...")
print(f" Creating new clean NFO file to replace corrupted one")
movie = ET.Element("movie")
else:
# Create new NFO structure
movie = ET.Element("movie")
# Create all NFOGuard elements first, then append them in correct order
# This ensures they appear as a group at the bottom of the file
nfoguard_elements = []
# Add NFOGuard comment marker as the first of our additions
nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ")
nfoguard_elements.append(nfoguard_comment)
# Add IMDb uniqueid
uniqueid = ET.Element("uniqueid", type="imdb", default="true")
uniqueid.text = imdb_id
nfoguard_elements.append(uniqueid)
# Add premiered date if we have it
if released:
premiered_elem = ET.Element("premiered")
premiered_elem.text = released[:10] if len(released) >= 10 else released
nfoguard_elements.append(premiered_elem)
# Extract year from premiered date for consistency
try:
year_value = released[:4] if len(released) >= 4 else None
if year_value and year_value.isdigit():
year_elem = ET.Element("year")
year_elem.text = year_value
nfoguard_elements.append(year_elem)
except:
pass # Skip year if we can't extract it
# Add dateadded - THIS IS CRITICAL FOR EMBY PLUGIN
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 About to add dateadded: {dateadded} (type: {type(dateadded)})")
if dateadded:
dateadded_elem = ET.Element("dateadded")
dateadded_elem.text = dateadded
nfoguard_elements.append(dateadded_elem)
print(f"✅ Adding dateadded to NFO: {dateadded}")
else:
print(f"❌ dateadded is empty/None, not adding to NFO")
# Add lockdata at the very end
if lock_metadata:
lockdata = ET.Element("lockdata")
lockdata.text = "true"
nfoguard_elements.append(lockdata)
# Now append all NFOGuard elements to the movie in one batch
# This ensures they appear as a contiguous block at the bottom
for elem in nfoguard_elements:
movie.append(elem)
print(f"✅ Added {len(nfoguard_elements)} NFOGuard elements to bottom of NFO")
# Write file with proper formatting
tree = ET.ElementTree(movie)
ET.indent(tree, space=" ", level=0)
# Write directly to file (comment is already embedded in XML)
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write('<?xml version="1.0" encoding="utf-8"?>\n')
tree.write(f, encoding='unicode', xml_declaration=False)
print(f"✅ Successfully created/updated movie NFO: {nfo_path}")
print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}")
except Exception as e:
print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}")
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
"""Create or update tvshow.nfo file preserving existing content"""
nfo_path = series_dir / "tvshow.nfo"
try:
# Try to load existing NFO file
if nfo_path.exists():
try:
tree = ET.parse(nfo_path)
tvshow = tree.getroot()
# Ensure root element is <tvshow>
if tvshow.tag != "tvshow":
raise ValueError("Root element is not <tvshow>")
# Remove existing NFOGuard-managed elements to avoid duplicates
# These will be re-added at the bottom
for tag in ["lockdata"]:
existing = tvshow.find(tag)
if existing is not None:
tvshow.remove(existing)
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
for uniqueid in tvshow.findall("uniqueid[@type='imdb']"):
tvshow.remove(uniqueid)
except (ET.ParseError, ValueError) as e:
print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...")
print(f" Creating new clean tvshow.nfo file to replace corrupted one")
tvshow = ET.Element("tvshow")
else:
# Create new NFO structure
tvshow = ET.Element("tvshow")
# Add NFOGuard fields at the bottom
# Add IMDb uniqueid at the end
imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true")
imdb_uniqueid.text = imdb_id
# Add TVDB ID if available (preserve existing or add new)
if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"):
tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
tvdb_uniqueid.text = tvdb_id
# Add lockdata at the very end
lockdata = ET.SubElement(tvshow, "lockdata")
lockdata.text = "true"
# Add NFOGuard comment at the beginning
comment_text = f" Created by {self.manager_brand} "
# Write file with proper formatting
tree = ET.ElementTree(tvshow)
ET.indent(tree, space=" ", level=0)
# Write to string first to add comment
xml_str = ET.tostring(tvshow, encoding='unicode')
# Add XML declaration and comment
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
# Write to file
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write(full_xml)
print(f"✅ Successfully created/updated TV show NFO: {nfo_path}")
print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else ""))
except Exception as e:
print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}")
def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
"""Create or update season.nfo file preserving existing content"""
nfo_path = season_dir / "season.nfo"
try:
season_dir.mkdir(exist_ok=True)
# Try to load existing NFO file
if nfo_path.exists():
try:
tree = ET.parse(nfo_path)
season = tree.getroot()
# Ensure root element is <season>
if season.tag != "season":
raise ValueError("Root element is not <season>")
# Remove existing NFOGuard-managed elements
for tag in ["seasonnumber", "lockdata"]:
existing = season.find(tag)
if existing is not None:
season.remove(existing)
except (ET.ParseError, ValueError) as e:
print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...")
print(f" Creating new clean season.nfo file to replace corrupted one")
season = ET.Element("season")
else:
# Create new NFO structure
season = ET.Element("season")
# Add NFOGuard fields at the bottom
seasonnumber = ET.SubElement(season, "seasonnumber")
seasonnumber.text = str(season_number)
# Add lockdata at the end
lockdata = ET.SubElement(season, "lockdata")
lockdata.text = "true"
# Add NFOGuard comment at the beginning
comment_text = f" Created by {self.manager_brand} "
# Write file with proper formatting
tree = ET.ElementTree(season)
ET.indent(tree, space=" ", level=0)
# Write to string first to add comment
xml_str = ET.tostring(season, encoding='unicode')
# Add XML declaration and comment
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
# Write to file
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write(full_xml)
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
print(f" Season: {season_number}")
except Exception as e:
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find any existing episode NFO file that matches season/episode"""
if not season_dir.exists():
return None
# Look for NFO files in the season directory
for nfo_file in season_dir.glob("*.nfo"):
# Check if this NFO contains the right season/episode
try:
tree = ET.parse(nfo_file)
root = tree.getroot()
if root.tag == "episodedetails":
# Check for season/episode elements
season_elem = root.find("season")
episode_elem = root.find("episode")
if (season_elem is not None and episode_elem is not None and
season_elem.text and episode_elem.text):
try:
file_season = int(season_elem.text)
file_episode = int(episode_elem.text)
if file_season == season_num and file_episode == episode_num:
print(f"🔍 Found existing episode NFO: {nfo_file.name}")
return nfo_file
except ValueError:
continue
except (ET.ParseError, Exception):
# Skip corrupted or non-XML files
continue
return None
def _get_target_episode_nfo_name(self, season_dir: Path, season_num: int, episode_num: int) -> str:
"""Get target NFO filename - prefer existing NFO, then matching video file, fallback to short name"""
if not season_dir.exists():
return f"S{season_num:02d}E{episode_num:02d}.nfo"
# First check if an NFO already exists for this episode
existing_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
if existing_nfo:
print(f"📂 Existing NFO found, will preserve filename: {existing_nfo.name}")
return existing_nfo.name
# Look for video files with matching season/episode
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".flv", ".webm"]
for video_file in season_dir.iterdir():
if (video_file.is_file() and
video_file.suffix.lower() in video_extensions):
# Parse episode info from video filename
episode_info = self._parse_episode_from_filename(video_file.name)
if episode_info and episode_info == (season_num, episode_num):
# Found matching video file - use its name for NFO
target_nfo_name = f"{video_file.stem}.nfo"
print(f"🎯 Target NFO will match video: {target_nfo_name}")
return target_nfo_name
# Fallback to short name if no matching video found
short_name = f"S{season_num:02d}E{episode_num:02d}.nfo"
print(f"⚠️ No matching video file found, using short name: {short_name}")
return short_name
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Parse season and episode numbers from filename"""
# Try S##E## format first
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
# Try ##x## format
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
return None
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
aired: Optional[str], dateadded: Optional[str], source: str,
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
"""Create or update episode NFO file preserving existing content"""
# Get target NFO filename (prefer long name matching video file)
target_nfo_name = self._get_target_episode_nfo_name(season_dir, season_num, episode_num)
nfo_path = season_dir / target_nfo_name
try:
# Check for existing NFO file at target location
source_nfo_path = nfo_path if nfo_path.exists() else None
if source_nfo_path:
try:
tree = ET.parse(source_nfo_path)
episode = tree.getroot()
# Ensure root element is <episodedetails>
if episode.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>")
print(f"📝 Updating existing NFO: {nfo_path.name}")
# Preserve existing content fields and store NFOGuard-managed fields for re-adding
preserved_values = {}
# Extract and preserve NFOGuard-managed fields (we'll re-add these at the bottom)
nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
for tag in nfoguard_fields:
existing = episode.find(tag)
if existing is not None:
# Store the value before removing
if tag == "aired" and not aired:
aired = existing.text # Preserve existing aired date
preserved_values[tag] = existing.text
episode.remove(existing)
# Important: DO NOT remove content fields like title, plot, runtime, premiered, etc.
# These should be preserved from the long-named NFO files
# Debug: Show what fields are preserved after removing NFOGuard fields (if DEBUG enabled)
if self.debug:
preserved_fields = [elem.tag for elem in episode]
if preserved_fields:
print(f" 🔍 Content preserved after cleanup: {', '.join(preserved_fields)}")
else:
print(f" ℹ️ NFO contains only NFOGuard metadata (no additional content fields)")
except (ET.ParseError, ValueError) as e:
print(f"⚠️ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...")
print(f" Creating new clean episode NFO file to replace corrupted one")
episode = ET.Element("episodedetails")
else:
# Create new NFO structure
episode = ET.Element("episodedetails")
# Add enhanced metadata only if not already present (preserve existing from long-named NFO)
if enhanced_metadata:
if enhanced_metadata.get("title") and not episode.find("title"):
title_elem = ET.SubElement(episode, "title")
title_elem.text = enhanced_metadata["title"]
if enhanced_metadata.get("overview") and not episode.find("plot"):
plot_elem = ET.SubElement(episode, "plot")
plot_elem.text = enhanced_metadata["overview"]
if enhanced_metadata.get("runtime") and not episode.find("runtime"):
runtime_elem = ET.SubElement(episode, "runtime")
runtime_elem.text = str(enhanced_metadata["runtime"])
# Add NFOGuard fields at the bottom
# Basic episode info at the end
season_elem = ET.SubElement(episode, "season")
season_elem.text = str(season_num)
episode_elem = ET.SubElement(episode, "episode")
episode_elem.text = str(episode_num)
# Dates at the end
if aired:
aired_elem = ET.SubElement(episode, "aired")
# Convert datetime objects to strings
aired_str = str(aired)
aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str
if dateadded:
dateadded_elem = ET.SubElement(episode, "dateadded")
# Convert datetime objects to strings
dateadded_elem.text = str(dateadded)
# Add lockdata at the very end
if lock_metadata:
lockdata = ET.SubElement(episode, "lockdata")
lockdata.text = "true"
# Add NFOGuard comment at the beginning
comment_text = f" Created by {self.manager_brand} - Source: {source} "
# Write file with proper formatting
tree = ET.ElementTree(episode)
ET.indent(tree, space=" ", level=0)
# Write to string first to add comment
xml_str = ET.tostring(episode, encoding='unicode')
# Add XML declaration and comment
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
# Write to file
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write(full_xml)
print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
# NFO file created/updated successfully
pass
except Exception as e:
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
def set_file_mtime(self, file_path: Path, iso_timestamp) -> None:
"""Set file modification time to match import date"""
try:
# Convert datetime objects to strings first
if hasattr(iso_timestamp, 'isoformat'):
iso_timestamp = iso_timestamp.isoformat()
elif not isinstance(iso_timestamp, str):
iso_timestamp = str(iso_timestamp)
# Parse ISO timestamp
if iso_timestamp.endswith('Z'):
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
elif '+' in iso_timestamp or 'T' in iso_timestamp:
dt = datetime.fromisoformat(iso_timestamp)
elif ' ' in iso_timestamp:
# Handle space-separated datetime format (e.g., "2025-10-16 20:31:22")
dt = datetime.fromisoformat(iso_timestamp.replace(' ', 'T'))
else:
# Assume it's a simple date (e.g., "2025-10-16")
dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00')
# Convert to timestamp
timestamp = dt.timestamp()
# Set both access and modification times
os.utime(file_path, (timestamp, timestamp))
print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}")
except Exception as e:
print(f"❌ Error setting mtime for {file_path}: {e}")
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
"""Update modification times for all video files in movie directory"""
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
updated_files = []
for file_path in movie_dir.iterdir():
if file_path.is_file() and file_path.suffix.lower() in video_exts:
self.set_file_mtime(file_path, iso_timestamp)
updated_files.append(file_path.name)
if updated_files:
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
else:
print(f"⚠️ No video files found to update in {movie_dir.name}")
+2 -1
View File
@@ -10,6 +10,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from core.database import NFOGuardDatabase from core.database import NFOGuardDatabase
from config.settings import config
def debug_movie(imdb_id: str): def debug_movie(imdb_id: str):
"""Debug a specific movie's data""" """Debug a specific movie's data"""
@@ -17,7 +18,7 @@ def debug_movie(imdb_id: str):
print("=" * 50) print("=" * 50)
# Initialize database # Initialize database
db = NFOGuardDatabase() db = NFOGuardDatabase(config=config)
# Get movie data # Get movie data
movie = db.get_movie_dates(imdb_id) movie = db.get_movie_dates(imdb_id)
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""
Debug script to check specific TV series/episode data in NFOGuard database
"""
import os
import sys
from pathlib import Path
# Add the project root to the path
sys.path.insert(0, str(Path(__file__).parent))
from core.database import NFOGuardDatabase
from config.settings import config
def debug_series(imdb_id: str):
"""Debug a specific TV series' data"""
print(f"📺 DEBUG TV SERIES: {imdb_id}")
print("=" * 60)
# Initialize database
db = NFOGuardDatabase(config=config)
# Get series episodes
episodes = db.get_series_episodes(imdb_id)
if not episodes:
print(f"❌ TV series {imdb_id} not found in database")
return
print(f"📊 SERIES OVERVIEW:")
print(f" IMDb ID: {imdb_id}")
print(f" Total Episodes: {len(episodes)}")
# Count episodes by status
with_dates = sum(1 for ep in episodes if ep.get('dateadded'))
without_dates = len(episodes) - with_dates
with_video = sum(1 for ep in episodes if ep.get('has_video_file'))
print(f" Episodes with dates: {with_dates}")
print(f" Episodes without dates: {without_dates}")
print(f" Episodes with video files: {with_video}")
# Group by season
seasons = {}
for ep in episodes:
season = ep.get('season', 'Unknown')
if season not in seasons:
seasons[season] = []
seasons[season].append(ep)
print(f" Seasons: {len(seasons)} ({', '.join(f'S{s}' if isinstance(s, int) else str(s) for s in sorted(seasons.keys()))})")
# Show sources breakdown
sources = {}
for ep in episodes:
source = ep.get('source', 'None')
sources[source] = sources.get(source, 0) + 1
print(f"\n📈 SOURCES BREAKDOWN:")
for source, count in sorted(sources.items(), key=lambda x: x[1], reverse=True):
print(f" {source}: {count} episodes")
# Show recent episodes (last 10 by date added)
episodes_with_dates = [ep for ep in episodes if ep.get('dateadded')]
recent_episodes = sorted(episodes_with_dates, key=lambda x: x.get('last_updated', ''), reverse=True)[:10]
if recent_episodes:
print(f"\n🕒 RECENT EPISODES (by last_updated):")
for ep in recent_episodes:
season = ep.get('season', '?')
episode = ep.get('episode', '?')
dateadded = ep.get('dateadded', 'None')
source = ep.get('source', 'None')
video = "✅" if ep.get('has_video_file') else "❌"
print(f" S{season:02d}E{episode:02d}: {dateadded} | {source} | Video: {video}")
def debug_episode(imdb_id: str, season: int, episode: int):
"""Debug a specific episode's data"""
print(f"📺 DEBUG TV EPISODE: {imdb_id} S{season:02d}E{episode:02d}")
print("=" * 60)
# Initialize database
db = NFOGuardDatabase(config=config)
# Get specific episode
episode_data = db.get_episode_date(imdb_id, season, episode)
if not episode_data:
print(f"❌ Episode S{season:02d}E{episode:02d} for series {imdb_id} not found in database")
return
print("📊 RAW EPISODE DATA:")
for key, value in episode_data.items():
print(f" {key}: {repr(value)}")
print("\n📺 FORMATTED EPISODE DATA:")
print(f" Series IMDb: {episode_data.get('imdb_id', 'Unknown')}")
print(f" Season/Episode: S{episode_data.get('season', '?'):02d}E{episode_data.get('episode', '?'):02d}")
print(f" Title: {episode_data.get('title', 'Unknown')}")
print(f" Air Date: {episode_data.get('air_date', 'None')}")
print(f" Date Added: {episode_data.get('dateadded', 'None')}")
print(f" Source: {episode_data.get('source', 'None')}")
print(f" Has Video: {episode_data.get('has_video_file', False)}")
print(f" Video Path: {episode_data.get('video_path', 'None')}")
print(f" Last Updated: {episode_data.get('last_updated', 'None')}")
# Check if air date is valid
air_date = episode_data.get('air_date')
if air_date and air_date.strip():
try:
from datetime import datetime
test_date = f"{air_date}T00:00:00"
parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00'))
print(f"\n✅ Air date is valid: {parsed}")
except Exception as e:
print(f"\n❌ Air date is INVALID: {e}")
else:
print(f"\n⚠️ Air date is empty or None")
# Check if dateadded is valid
dateadded = episode_data.get('dateadded')
if dateadded and dateadded.strip():
try:
from datetime import datetime
if isinstance(dateadded, str):
test_date = f"{dateadded}T00:00:00" if 'T' not in dateadded else dateadded
parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00'))
else:
parsed = dateadded
print(f"✅ Date added is valid: {parsed}")
except Exception as e:
print(f"❌ Date added is INVALID: {e}")
else:
print(f"⚠️ Date added is empty or None")
def debug_season(imdb_id: str, season: int):
"""Debug all episodes in a specific season"""
print(f"📺 DEBUG TV SEASON: {imdb_id} Season {season}")
print("=" * 60)
# Initialize database
db = NFOGuardDatabase(config=config)
# Get series episodes
all_episodes = db.get_series_episodes(imdb_id)
season_episodes = [ep for ep in all_episodes if ep.get('season') == season]
if not season_episodes:
print(f"❌ No episodes found for season {season} of series {imdb_id}")
return
print(f"📊 SEASON {season} OVERVIEW:")
print(f" Total Episodes: {len(season_episodes)}")
# Sort by episode number
season_episodes.sort(key=lambda x: x.get('episode', 0))
with_dates = sum(1 for ep in season_episodes if ep.get('dateadded'))
without_dates = len(season_episodes) - with_dates
with_video = sum(1 for ep in season_episodes if ep.get('has_video_file'))
print(f" Episodes with dates: {with_dates}")
print(f" Episodes without dates: {without_dates}")
print(f" Episodes with video files: {with_video}")
print(f"\n📋 EPISODE LIST:")
for ep in season_episodes:
episode_num = ep.get('episode', '?')
title = ep.get('title', 'Unknown')[:30] + ('...' if len(ep.get('title', '')) > 30 else '')
dateadded = ep.get('dateadded', 'None')
source = ep.get('source', 'None')
video = "✅" if ep.get('has_video_file') else "❌"
air_date = ep.get('air_date', 'None')
print(f" E{episode_num:02d}: {title:<33} | Added: {dateadded} | Air: {air_date} | Video: {video}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage:")
print(" python debug_tv.py <imdb_id> # Debug entire series")
print(" python debug_tv.py <imdb_id> <season> # Debug specific season")
print(" python debug_tv.py <imdb_id> <season> <episode> # Debug specific episode")
print("\nExamples:")
print(" python debug_tv.py tt0121955 # Debug South Park")
print(" python debug_tv.py tt0121955 27 # Debug South Park Season 27")
print(" python debug_tv.py tt0121955 27 6 # Debug South Park S27E06")
sys.exit(1)
imdb_id = sys.argv[1]
if not imdb_id.startswith('tt'):
imdb_id = f'tt{imdb_id}'
if len(sys.argv) == 4:
# Debug specific episode
season = int(sys.argv[2])
episode = int(sys.argv[3])
debug_episode(imdb_id, season, episode)
elif len(sys.argv) == 3:
# Debug specific season
season = int(sys.argv[2])
debug_season(imdb_id, season)
else:
# Debug entire series
debug_series(imdb_id)
+144
View File
@@ -0,0 +1,144 @@
# NFOGuard Production Docker Compose - 3-Container Architecture
#
# RECOMMENDED SETUP: Separated core processing and web interface for optimal performance
#
# This is the default configuration providing:
# - Performance isolation between web and processing
# - Webhook responsiveness during scans
# - Independent scaling and updates
# - Professional web interface with branding
#
# For legacy single-container setup, see: docker-compose.legacy-single.yml
services:
# PostgreSQL Database
nfoguard-db:
image: postgres:15-alpine
container_name: nfoguard-db
restart: unless-stopped
env_file:
- .env
- .env.secrets
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- TZ=${TZ}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "${DB_EXTERNAL_PORT:-5432}:5432" # Optional external access
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-nfoguard} -d ${DB_NAME:-nfoguard}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- nfoguard-network
# NFOGuard Core (Processing Engine)
nfoguard:
image: sbcrumb/nfoguard:latest
container_name: nfoguard-core
restart: unless-stopped
env_file:
- .env
- .env.secrets
environment:
- TZ=${TZ}
- WEB_EXTERNAL_PORT=${WEB_EXTERNAL_PORT}
volumes:
# Media paths (adjust to your setup)
- /mnt/unionfs/Media/TV:/media/TV:ro
- /mnt/unionfs/Media/Movies:/media/Movies:ro
# Data persistence
- nfoguard_data:/app/data
# Logs
- nfoguard_logs:/app/data/logs
ports:
- "${CORE_API_PORT:-8080}:8080" # Core API (webhooks, processing)
depends_on:
nfoguard-db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- nfoguard-network
# NFOGuard Web Interface
nfoguard-web:
image: sbcrumb/nfoguard:latest # Same image as core!
container_name: nfoguard-web
restart: unless-stopped
command: ["python", "start_web.py"] # Different entry point
env_file:
- .env
- .env.secrets
environment:
- TZ=${TZ:-America/New_York}
ports:
- "${WEB_API_PORT:-8081}:8081" # Web Interface
depends_on:
nfoguard-db:
condition: service_healthy
nfoguard:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- nfoguard-network
volumes:
postgres_data:
driver: local
nfoguard_data:
driver: local
nfoguard_logs:
driver: local
networks:
nfoguard-network:
driver: bridge
# Configuration Notes:
# 1. Core Processing (nfoguard): Handles webhooks, scanning, NFO management
# 2. Web Interface (nfoguard-web): Lightweight dashboard and management
# 3. Database (nfoguard-db): Shared PostgreSQL database
#
# Port Configuration:
# - Core API: ${CORE_API_PORT:-8080} (webhooks, processing)
# - Web Interface: ${WEB_API_PORT:-8081} (dashboard)
# - Database: ${DB_EXTERNAL_PORT:-5432} (optional external access)
#
# Performance Benefits:
# - Web interface operations don't impact core processing
# - Webhooks remain responsive during long scans
# - Independent scaling and resource allocation
# - Separated concerns for maintenance and updates
# NFOGuard Core (Processing Engine)
nfoguard:
# ... other settings ...
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/simple"]
interval: 30s
timeout: 15s # Increased from 10s
retries: 3
start_period: 60s # Increased from 40s
# NFOGuard Web Interface
nfoguard-web:
# ... other settings ...
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
interval: 30s
timeout: 15s # Increased from 10s
retries: 3
start_period: 30s # Increased from 10s
@@ -1,3 +1,14 @@
# NFOGuard Legacy Single-Container Configuration
#
# DEPRECATED: This is the legacy single-container setup where web interface
# and core processing run in the same container, which can cause performance
# issues during intensive scans.
#
# RECOMMENDED: Use docker-compose.example.yml for the new 3-container
# architecture with better performance isolation.
#
# This file is maintained for backward compatibility and migration purposes.
version: '3.8' version: '3.8'
services: services:
Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

+36 -12
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
NFOGuard - Automated NFO file management for Radarr and Sonarr NFOGuard Core - Automated NFO file management and processing engine
Modular architecture with webhook processing and intelligent date handling Core processing container with webhooks, scanning, and database management
Web interface separated to nfoguard-web container
""" """
import os import os
import sys import sys
@@ -15,11 +16,13 @@ from fastapi import FastAPI
# Import configuration first # Import configuration first
from config.settings import config from config.settings import config
# Authentication removed - handled by separate web container
from utils.logging import _log from utils.logging import _log
# Import core components # Import core components
from core.database import NFOGuardDatabase from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager # from core.nfo_manager import NFOManager # Phase 3: Removed - no longer needed
from core.path_mapper import PathMapper from core.path_mapper import PathMapper
# Import clients # Import clients
@@ -90,20 +93,20 @@ def initialize_components():
# Initialize core components # Initialize core components
db = NFOGuardDatabase(config=config) db = NFOGuardDatabase(config=config)
nfo_manager = NFOManager(config.manager_brand, config.debug) # nfo_manager = NFOManager(config.manager_brand, config.debug) # Phase 3: Removed
path_mapper = PathMapper(config) path_mapper = PathMapper(config)
# Initialize processors # Initialize processors (nfo_manager=None for backward compatibility)
tv_processor = TVProcessor(db, nfo_manager, path_mapper) tv_processor = TVProcessor(db, None, path_mapper)
movie_processor = MovieProcessor(db, nfo_manager, path_mapper) movie_processor = MovieProcessor(db, None, path_mapper)
# Initialize webhook batcher with nfo_manager for comprehensive IMDb detection # Initialize webhook batcher (no longer needs nfo_manager - Phase 3)
batcher = WebhookBatcher(nfo_manager) batcher = WebhookBatcher(nfo_manager=None)
batcher.set_processors(tv_processor, movie_processor) batcher.set_processors(tv_processor, movie_processor)
return { return {
"db": db, "db": db,
"nfo_manager": nfo_manager, # "nfo_manager": nfo_manager, # Phase 3: Removed
"path_mapper": path_mapper, "path_mapper": path_mapper,
"tv_processor": tv_processor, "tv_processor": tv_processor,
"movie_processor": movie_processor, "movie_processor": movie_processor,
@@ -143,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)
@@ -172,6 +187,9 @@ def main():
# Initialize components # Initialize components
dependencies = initialize_components() dependencies = initialize_components()
# Note: Authentication and web interface handled by separate nfoguard-web container
_log("INFO", "Core API: Authentication handled by separate web container")
# Store dependencies globally for signal handler access # Store dependencies globally for signal handler access
signal_handler.dependencies = dependencies signal_handler.dependencies = dependencies
@@ -179,10 +197,16 @@ def main():
register_routes(app, dependencies) register_routes(app, dependencies)
try: try:
# Core API configuration (webhooks, processing, database management)
core_host = config.core_api_host if hasattr(config, 'core_api_host') else "0.0.0.0"
core_port = config.core_api_port if hasattr(config, 'core_api_port') else 8080
_log("INFO", f"🚀 Starting NFOGuard Core API on {core_host}:{core_port}")
uvicorn.run( uvicorn.run(
app, app,
host="0.0.0.0", host=core_host,
port=int(os.environ.get("PORT", "8080")), port=core_port,
reload=False, reload=False,
access_log=False, # Reduce logging overhead access_log=False, # Reduce logging overhead
server_header=False, # Reduce response overhead server_header=False, # Reduce response overhead
+1
View File
@@ -0,0 +1 @@
# NFOGuard Web Interface Package
+1
View File
@@ -0,0 +1 @@
# NFOGuard Web API Package
+182
View File
@@ -0,0 +1,182 @@
"""
Simple authentication middleware for NFOGuard web interface
Provides basic HTTP auth and session management for web interface protection
"""
import secrets
import hashlib
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from fastapi import HTTPException, status, Request, Response
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from starlette.middleware.base import BaseHTTPMiddleware
class AuthSession:
"""Simple session management for web interface"""
def __init__(self, timeout_seconds: int = 3600):
self.sessions: Dict[str, Dict[str, Any]] = {}
self.timeout_seconds = timeout_seconds
def create_session(self, username: str) -> str:
"""Create a new session and return session token"""
session_token = secrets.token_urlsafe(32)
self.sessions[session_token] = {
"username": username,
"created_at": datetime.utcnow(),
"last_activity": datetime.utcnow()
}
return session_token
def validate_session(self, session_token: str) -> bool:
"""Validate session token and update last activity"""
if not session_token or session_token not in self.sessions:
return False
session = self.sessions[session_token]
now = datetime.utcnow()
# Check if session expired
if (now - session["last_activity"]).seconds > self.timeout_seconds:
del self.sessions[session_token]
return False
# Update last activity
session["last_activity"] = now
return True
def get_session_user(self, session_token: str) -> Optional[str]:
"""Get username from valid session"""
if self.validate_session(session_token):
return self.sessions[session_token]["username"]
return None
def delete_session(self, session_token: str) -> None:
"""Delete a session (logout)"""
if session_token in self.sessions:
del self.sessions[session_token]
def cleanup_expired_sessions(self) -> None:
"""Remove expired sessions"""
now = datetime.utcnow()
expired_tokens = []
for token, session in self.sessions.items():
if (now - session["last_activity"]).seconds > self.timeout_seconds:
expired_tokens.append(token)
for token in expired_tokens:
del self.sessions[token]
class SimpleAuthMiddleware(BaseHTTPMiddleware):
"""Simple authentication middleware for web interface routes"""
def __init__(self, app, config):
super().__init__(app)
self.config = config
self.session_manager = AuthSession(config.web_auth_session_timeout)
self.security = HTTPBasic()
# Routes that require authentication (web interface)
self.protected_routes = [
"/", # Main web interface
"/static/", # Static files (CSS, JS)
"/api/movies", # Web API endpoints
"/api/series",
"/api/episodes",
"/api/dashboard"
]
# Routes that are always public (webhooks, health checks, API endpoints)
self.public_routes = [
"/webhook/",
"/health",
"/ping",
"/api/v1/health",
"/api/v1/metrics",
"/database/", # Database management endpoints (API access)
"/manual/", # Manual scan endpoints (API access)
"/debug/", # Debug endpoints (API access)
"/test/", # Test endpoints (API access)
"/bulk/" # Bulk operation endpoints (API access)
]
async def dispatch(self, request: Request, call_next):
"""Process request through authentication middleware"""
# Skip authentication if disabled
if not self.config.web_auth_enabled:
return await call_next(request)
# Check if route requires authentication
path = request.url.path
needs_auth = any(path.startswith(route) for route in self.protected_routes)
is_public = any(path.startswith(route) for route in self.public_routes)
if is_public or not needs_auth:
return await call_next(request)
# Check for existing session
session_token = request.cookies.get("nfoguard_session")
if session_token and self.session_manager.validate_session(session_token):
# Valid session, proceed
return await call_next(request)
# Check for HTTP Basic Auth
auth_header = request.headers.get("authorization")
if auth_header and auth_header.startswith("Basic "):
credentials = self._parse_basic_auth(auth_header)
if credentials and self._validate_credentials(credentials.username, credentials.password):
# Create session for successful login
session_token = self.session_manager.create_session(credentials.username)
response = await call_next(request)
response.set_cookie(
key="nfoguard_session",
value=session_token,
max_age=self.config.web_auth_session_timeout,
httponly=True,
secure=False # Set to True if using HTTPS
)
return response
# Authentication required
return self._auth_required_response()
def _parse_basic_auth(self, auth_header: str) -> Optional[HTTPBasicCredentials]:
"""Parse HTTP Basic Auth header"""
try:
import base64
encoded_credentials = auth_header.split(" ")[1]
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
username, password = decoded_credentials.split(":", 1)
return HTTPBasicCredentials(username=username, password=password)
except Exception:
return None
def _validate_credentials(self, username: str, password: str) -> bool:
"""Validate username and password"""
return (username == self.config.web_auth_username and
password == self.config.web_auth_password)
def _auth_required_response(self) -> Response:
"""Return 401 response with WWW-Authenticate header"""
return Response(
content="Authentication required",
status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Basic realm=\"NFOGuard Web Interface\""}
)
def create_auth_dependencies(config) -> Dict[str, Any]:
"""Create authentication-related dependencies for dependency injection"""
session_manager = AuthSession(config.web_auth_session_timeout)
return {
"session_manager": session_manager,
"auth_enabled": config.web_auth_enabled,
"auth_config": {
"username": config.web_auth_username,
"timeout": config.web_auth_session_timeout
}
}
File diff suppressed because it is too large. Load diff
+70
View File
@@ -0,0 +1,70 @@
"""
NFOGuard Web Interface Configuration
Lightweight configuration for web-only container
"""
import os
def _bool_env(name: str, default: bool = False) -> bool:
"""Convert environment variable to boolean"""
value = os.environ.get(name, "").lower()
return value in ("true", "1", "yes", "on")
class WebConfig:
"""Configuration for NFOGuard Web Interface"""
def __init__(self):
self._load_server_settings()
self._load_database_settings()
self._load_auth_settings()
self._load_ui_settings()
def _load_server_settings(self) -> None:
"""Load web server configuration"""
self.web_host = os.environ.get("WEB_HOST", "0.0.0.0")
self.web_port = int(os.environ.get("WEB_PORT", "8081"))
self.web_workers = int(os.environ.get("WEB_WORKERS", "1"))
self.web_debug = _bool_env("WEB_DEBUG", False)
# Core NFOGuard API connection (for some operations)
self.core_api_host = os.environ.get("CORE_API_HOST", "nfoguard")
self.core_api_port = int(os.environ.get("CORE_API_PORT", "8080"))
self.core_api_url = f"http://{self.core_api_host}:{self.core_api_port}"
def _load_database_settings(self) -> None:
"""Load database configuration (read-only access)"""
self.db_type = os.environ.get("DB_TYPE", "postgresql").lower()
self.db_host = os.environ.get("DB_HOST", "nfoguard-db")
self.db_port = int(os.environ.get("DB_PORT", "5432"))
self.db_name = os.environ.get("DB_NAME", "nfoguard")
self.db_user = os.environ.get("DB_USER", "nfoguard")
self.db_password = os.environ.get("DB_PASSWORD", "")
if not self.db_password:
raise ValueError("DB_PASSWORD must be set for web interface database access")
def _load_auth_settings(self) -> None:
"""Load web interface authentication settings"""
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
self.web_auth_username = os.environ.get("WEB_AUTH_USERNAME", "admin")
self.web_auth_password = os.environ.get("WEB_AUTH_PASSWORD", "")
self.web_auth_session_timeout = int(os.environ.get("WEB_AUTH_SESSION_TIMEOUT", "3600"))
if self.web_auth_enabled and not self.web_auth_password:
raise ValueError("WEB_AUTH_PASSWORD must be set when authentication is enabled")
def _load_ui_settings(self) -> None:
"""Load UI-specific settings"""
self.app_title = os.environ.get("APP_TITLE", "NFOGuard")
self.app_subtitle = os.environ.get("APP_SUBTITLE", "Database Management & Reporting")
self.pagination_limit = int(os.environ.get("PAGINATION_LIMIT", "50"))
self.refresh_interval = int(os.environ.get("REFRESH_INTERVAL", "30")) # seconds
# Logo configuration
self.logo_enabled = _bool_env("LOGO_ENABLED", True)
self.logo_path = "/static/logo/NFOguardLogoPlain.png"
# Global config instance
web_config = WebConfig()
+1
View File
@@ -0,0 +1 @@
# NFOGuard Web Core Components
+385
View File
@@ -0,0 +1,385 @@
"""
NFOGuard Web Database - Lightweight Read-Only Database Access
Optimized for web interface queries with minimal dependencies
"""
import psycopg2
import psycopg2.extras
from typing import Dict, List, Optional, Any, Tuple
import logging
logger = logging.getLogger(__name__)
class WebDatabase:
"""Lightweight database access for web interface"""
def __init__(self, db_type: str, host: str, port: int, database: str, user: str, password: str):
self.db_type = db_type.lower()
self.host = host
self.port = port
self.database = database
self.user = user
self.password = password
self.connection = None
# Connect to database
self._connect()
def _connect(self):
"""Connect to PostgreSQL database"""
if self.db_type != "postgresql":
raise ValueError("Web interface only supports PostgreSQL")
try:
self.connection = psycopg2.connect(
host=self.host,
port=self.port,
database=self.database,
user=self.user,
password=self.password,
cursor_factory=psycopg2.extras.RealDictCursor
)
# Set to autocommit for read operations
self.connection.autocommit = True
logger.info(f"Connected to PostgreSQL: {self.host}:{self.port}/{self.database}")
except Exception as e:
logger.error(f"Failed to connect to database: {e}")
raise
def execute_query(self, query: str, params: Optional[Tuple] = None) -> List[Dict[str, Any]]:
"""Execute a SELECT query and return results"""
try:
with self.connection.cursor() as cursor:
cursor.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Query failed: {query[:100]}... Error: {e}")
raise
def execute_single(self, query: str, params: Optional[Tuple] = None) -> Optional[Dict[str, Any]]:
"""Execute a query and return single result"""
results = self.execute_query(query, params)
return results[0] if results else None
def execute_scalar(self, query: str, params: Optional[Tuple] = None) -> Any:
"""Execute a query and return single value"""
result = self.execute_single(query, params)
return list(result.values())[0] if result else None
# Dashboard Statistics
def get_dashboard_stats(self) -> Dict[str, Any]:
"""Get dashboard statistics"""
stats = {}
# Movie statistics
movie_query = """
SELECT
COUNT(*) as total_movies,
COUNT(CASE WHEN dateadded IS NOT NULL AND source != 'unknown' THEN 1 END) as movies_with_dates,
COUNT(CASE WHEN dateadded IS NULL OR source = 'unknown' THEN 1 END) as movies_without_dates
FROM movies
"""
movie_stats = self.execute_single(movie_query)
stats.update(movie_stats)
# TV statistics
tv_query = """
SELECT
COUNT(DISTINCT imdb_id) as total_series,
COUNT(*) as total_episodes,
COUNT(CASE WHEN dateadded IS NOT NULL AND source != 'unknown' THEN 1 END) as episodes_with_dates,
COUNT(CASE WHEN dateadded IS NULL OR source = 'unknown' THEN 1 END) as episodes_without_dates
FROM episodes
"""
tv_stats = self.execute_single(tv_query)
stats.update(tv_stats)
return stats
# Movie queries
def get_movies(self, skip: int = 0, limit: int = 50, has_date: Optional[bool] = None) -> List[Dict[str, Any]]:
"""Get movies with pagination"""
where_clause = ""
params = []
if has_date is not None:
if has_date:
where_clause = "WHERE dateadded IS NOT NULL AND source != 'unknown'"
else:
where_clause = "WHERE dateadded IS NULL OR source = 'unknown'"
query = f"""
SELECT imdb_id, title, year, dateadded, released, source, last_updated
FROM movies
{where_clause}
ORDER BY title, year
LIMIT %s OFFSET %s
"""
params.extend([limit, skip])
return self.execute_query(query, params)
def get_movie_count(self, has_date: Optional[bool] = None) -> int:
"""Get total movie count"""
where_clause = ""
params = []
if has_date is not None:
if has_date:
where_clause = "WHERE dateadded IS NOT NULL AND source != 'unknown'"
else:
where_clause = "WHERE dateadded IS NULL OR source = 'unknown'"
query = f"SELECT COUNT(*) FROM movies {where_clause}"
return self.execute_scalar(query, params)
# TV Series queries
def get_series(self, skip: int = 0, limit: int = 50, date_filter: str = "none") -> List[Dict[str, Any]]:
"""Get TV series with episode statistics"""
where_clause = ""
if date_filter == "complete":
where_clause = """
WHERE NOT EXISTS (
SELECT 1 FROM episodes e2
WHERE e2.imdb_id = e.imdb_id
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
)
"""
elif date_filter == "incomplete":
where_clause = """
WHERE EXISTS (
SELECT 1 FROM episodes e2
WHERE e2.imdb_id = e.imdb_id
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
)
"""
query = f"""
SELECT
e.imdb_id,
e.series_title,
COUNT(*) as total_episodes,
COUNT(CASE WHEN e.dateadded IS NOT NULL AND e.source != 'unknown' THEN 1 END) as episodes_with_dates,
COUNT(CASE WHEN e.dateadded IS NULL OR e.source = 'unknown' THEN 1 END) as episodes_without_dates,
MAX(e.last_updated) as last_updated
FROM episodes e
{where_clause}
GROUP BY e.imdb_id, e.series_title
ORDER BY e.series_title
LIMIT %s OFFSET %s
"""
return self.execute_query(query, [limit, skip])
def get_series_count(self, date_filter: str = "none") -> int:
"""Get total series count"""
where_clause = ""
if date_filter == "complete":
where_clause = """
WHERE NOT EXISTS (
SELECT 1 FROM episodes e2
WHERE e2.imdb_id = e.imdb_id
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
)
"""
elif date_filter == "incomplete":
where_clause = """
WHERE EXISTS (
SELECT 1 FROM episodes e2
WHERE e2.imdb_id = e.imdb_id
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
)
"""
query = f"""
SELECT COUNT(DISTINCT imdb_id)
FROM episodes e
{where_clause}
"""
return self.execute_scalar(query)
def get_episodes_for_series(self, imdb_id: str) -> List[Dict[str, Any]]:
"""Get all episodes for a series"""
query = """
SELECT imdb_id, series_title, season, episode, episode_title,
dateadded, source, last_updated
FROM episodes
WHERE imdb_id = %s
ORDER BY season, episode
"""
return self.execute_query(query, [imdb_id])
# Source statistics
def get_series_sources(self) -> List[Dict[str, Any]]:
"""Get source statistics for series"""
query = """
SELECT
source,
COUNT(DISTINCT imdb_id) as series_count,
COUNT(*) as episode_count
FROM episodes
WHERE source != 'unknown'
GROUP BY source
ORDER BY series_count DESC, episode_count DESC
"""
return self.execute_query(query)
# Episode-specific methods for web interface
def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]:
"""Get episode data including dates"""
query = """
SELECT imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated
FROM episodes
WHERE imdb_id = %s AND season = %s AND episode = %s
"""
return self.execute_single(query, (imdb_id, season, episode))
def upsert_episode_date(self, imdb_id: str, season: int, episode: int,
aired: Optional[str], dateadded: Optional[str],
source: str, has_video_file: bool = False) -> None:
"""Update or insert episode date information"""
# First check if episode exists
existing = self.get_episode_date(imdb_id, season, episode)
# Temporarily disable autocommit for the transaction
original_autocommit = self.connection.autocommit
self.connection.autocommit = False
try:
with self.connection.cursor() as cursor:
if existing:
# Update existing episode
query = """
UPDATE episodes
SET aired = %s, dateadded = %s, source = %s, has_video_file = %s, last_updated = NOW()
WHERE imdb_id = %s AND season = %s AND episode = %s
"""
cursor.execute(query, (aired, dateadded, source, has_video_file, imdb_id, season, episode))
else:
# Insert new episode
query = """
INSERT INTO episodes (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
"""
cursor.execute(query, (imdb_id, season, episode, aired, dateadded, source, has_video_file))
self.connection.commit()
except Exception as e:
self.connection.rollback()
logger.error(f"Failed to upsert episode date: {e}")
raise
finally:
# Restore original autocommit setting
self.connection.autocommit = original_autocommit
def get_movie_dates(self, imdb_id: str) -> Optional[Dict]:
"""Get movie data including dates"""
query = """
SELECT imdb_id, path, released, dateadded, source, has_video_file, last_updated
FROM movies
WHERE imdb_id = %s
"""
return self.execute_single(query, (imdb_id,))
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
dateadded: Optional[str], source: str,
has_video_file: bool = False, path: str = "") -> None:
"""Update or insert movie date information"""
# First check if movie exists
existing = self.get_movie_dates(imdb_id)
# Temporarily disable autocommit for the transaction
original_autocommit = self.connection.autocommit
self.connection.autocommit = False
try:
with self.connection.cursor() as cursor:
if existing:
# Update existing movie
query = """
UPDATE movies
SET released = %s, dateadded = %s, source = %s, has_video_file = %s, last_updated = NOW()
WHERE imdb_id = %s
"""
cursor.execute(query, (released, dateadded, source, has_video_file, imdb_id))
else:
# Insert new movie
query = """
INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
"""
cursor.execute(query, (imdb_id, path, released, dateadded, source, has_video_file))
self.connection.commit()
except Exception as e:
self.connection.rollback()
logger.error(f"Failed to upsert movie dates: {e}")
raise
finally:
# Restore original autocommit setting
self.connection.autocommit = original_autocommit
def get_connection(self):
"""Get database connection for advanced operations"""
return self.connection
def _get_first_value(self, row):
"""Extract first value from a database row (compatibility method)"""
if row is None:
return None
if isinstance(row, dict):
return list(row.values())[0] if row else None
return row[0] if row else None
def get_stats(self) -> Dict[str, Any]:
"""Get basic database statistics (compatibility method)"""
return self.get_dashboard_stats()
def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Dict) -> None:
"""Add processing history entry (simplified for web interface)"""
# Temporarily disable autocommit for the transaction
original_autocommit = self.connection.autocommit
self.connection.autocommit = False
try:
with self.connection.cursor() as cursor:
# Check if processing_history table exists
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'processing_history'
)
""")
table_exists = cursor.fetchone()[0]
if table_exists:
query = """
INSERT INTO processing_history (imdb_id, media_type, event_type, details, processed_at)
VALUES (%s, %s, %s, %s, NOW())
"""
import json
cursor.execute(query, (imdb_id, media_type, event_type, json.dumps(details)))
self.connection.commit()
else:
# Table doesn't exist, skip logging
logger.debug("Processing history table not found, skipping log entry")
except Exception as e:
self.connection.rollback()
logger.error(f"Failed to add processing history: {e}")
# Don't raise, this is non-critical
finally:
# Restore original autocommit setting
self.connection.autocommit = original_autocommit
def close(self):
"""Close database connection"""
if self.connection:
self.connection.close()
logger.info("Database connection closed")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

+142
View File
@@ -0,0 +1,142 @@
"""
NFOGuard Web Interface - Separated Web Application
Lightweight FastAPI application for web interface only
"""
import asyncio
import signal
import sys
import os
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
# Add current directory and parent directory to path for imports
sys.path.append(str(Path(__file__).parent))
sys.path.append(str(Path(__file__).parent.parent))
# Import web-specific configuration
from config.web_settings import web_config
# Import database (lightweight, read-only access)
from core.web_database import WebDatabase
# Import web routes and authentication
from api.web_routes import register_web_routes
from api.auth import SimpleAuthMiddleware, create_auth_dependencies
def create_web_app() -> FastAPI:
"""Create FastAPI web application"""
app = FastAPI(
title="NFOGuard Web Interface",
description="Web interface for NFOGuard media database management",
version="2.9.0-fixes-only-files",
docs_url="/docs" if web_config.web_debug else None,
redoc_url="/redoc" if web_config.web_debug else None
)
return app
def initialize_web_database() -> WebDatabase:
"""Initialize web database connection (read-only optimized)"""
return WebDatabase(
db_type=web_config.db_type,
host=web_config.db_host,
port=web_config.db_port,
database=web_config.db_name,
user=web_config.db_user,
password=web_config.db_password
)
def setup_static_files(app: FastAPI) -> None:
"""Mount static file directories"""
# Mount main static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Mount logo separately for easy access
app.mount("/logo", StaticFiles(directory="logo"), name="logo")
# Serve index.html at root
@app.get("/")
async def serve_index():
return FileResponse("static/index.html")
def setup_signal_handlers():
"""Setup graceful shutdown signal handlers"""
def signal_handler(signum, frame):
print(f"\n🛑 Received signal {signum}, shutting down web interface...")
# Web interface can shutdown immediately (no background processing)
sys.exit(0)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
def main():
"""Main entry point for NFOGuard Web Interface"""
print("🌐 Starting NFOGuard Web Interface...")
print(f"📊 Configuration: Port {web_config.web_port}, Auth: {'Enabled' if web_config.web_auth_enabled else 'Disabled'}")
# Setup signal handlers
setup_signal_handlers()
# Create FastAPI app
app = create_web_app()
# Initialize database
try:
db = initialize_web_database()
print(f"✅ Connected to database: {web_config.db_host}:{web_config.db_port}/{web_config.db_name}")
except Exception as e:
print(f"❌ Failed to connect to database: {e}")
sys.exit(1)
# Create dependencies for dependency injection
dependencies = {
"db": db,
"config": web_config
}
# Add authentication dependencies if enabled
if web_config.web_auth_enabled:
auth_deps = create_auth_dependencies(web_config)
dependencies.update(auth_deps)
# Add authentication middleware
app.add_middleware(SimpleAuthMiddleware, config=web_config)
print(f"🔐 Web authentication enabled for user: {web_config.web_auth_username}")
else:
print("🔓 Web authentication disabled - interface is public")
# Setup static files and routes
setup_static_files(app)
# Register web routes
register_web_routes(app, dependencies)
print(f"🚀 Starting web server on {web_config.web_host}:{web_config.web_port}")
try:
uvicorn.run(
app,
host=web_config.web_host,
port=web_config.web_port,
workers=web_config.web_workers,
log_level="debug" if web_config.web_debug else "info",
access_log=web_config.web_debug
)
except KeyboardInterrupt:
print("\n🛑 Web interface shutdown by user")
except Exception as e:
print(f"❌ Web interface failed: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+902
View File
@@ -0,0 +1,902 @@
/* NFOGuard Web Interface Styles */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
--dark-color: #343a40;
--light-color: #f8f9fa;
--border-color: #dee2e6;
--text-muted: #6c757d;
--shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--dark-color);
background-color: #f5f5f5;
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 0;
box-shadow: var(--shadow-lg);
position: relative;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
text-align: center;
}
.header-content h1 {
font-size: 2rem;
font-weight: 300;
margin-bottom: 0.5rem;
}
.header-content h1 i {
margin-right: 0.5rem;
}
.header-content p {
opacity: 0.9;
font-size: 1rem;
}
/* Authentication Status */
.auth-status {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
gap: 1rem;
color: white;
font-size: 0.9rem;
}
.auth-user {
display: flex;
align-items: center;
gap: 0.5rem;
opacity: 0.9;
}
.auth-logout {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s ease;
}
.auth-logout:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
}
.nav-tabs {
max-width: 1200px;
margin: 1rem auto 0;
padding: 0 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.nav-tab {
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-tab:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.nav-tab.active {
background: rgba(255, 255, 255, 0.9);
color: var(--dark-color);
}
/* Main Content */
.main-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
width: 100%;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Dashboard */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.stat-icon.movies { background: linear-gradient(135deg, #667eea, #764ba2); }
.stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); }
.stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); }
.stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); }
.stat-info h3 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-info p {
font-weight: 500;
margin-bottom: 0.25rem;
}
.stat-info small {
color: var(--text-muted);
font-size: 0.85rem;
}
.dashboard-charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.chart-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.chart-card h3 {
margin-bottom: 1rem;
color: var(--dark-color);
}
.chart-container {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: var(--light-color);
border-radius: 0.25rem;
color: var(--text-muted);
}
/* Content Header */
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.content-header h2 {
color: var(--dark-color);
font-weight: 600;
}
.content-controls {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.search-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-controls {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.search-box {
position: relative;
display: flex;
align-items: center;
}
.search-box i {
position: absolute;
left: 0.75rem;
color: var(--text-muted);
}
.search-box input {
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 0.9rem;
width: 250px;
}
.search-box input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
transform: translateY(-1px);
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover {
background-color: #1e7e34;
}
.btn-warning {
background-color: var(--warning-color);
color: var(--dark-color);
}
.btn-warning:hover {
background-color: #e0a800;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* Tables */
.table-container {
background: white;
border-radius: 0.5rem;
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 1rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background-color: var(--light-color);
font-weight: 600;
color: var(--dark-color);
position: sticky;
top: 0;
}
.data-table tr:hover {
background-color: rgba(0, 123, 255, 0.05);
}
.data-table .loading {
text-align: center;
color: var(--text-muted);
font-style: italic;
padding: 2rem;
}
/* Status badges */
.badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-success {
background-color: #d4edda;
color: #155724;
}
.badge-warning {
background-color: #fff3cd;
color: #856404;
}
.badge-danger {
background-color: #f8d7da;
color: #721c24;
}
.badge-secondary {
background-color: #e9ecef;
color: #495057;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
.pagination .btn {
padding: 0.5rem 0.75rem;
}
.pagination .page-info {
margin: 0 1rem;
color: var(--text-muted);
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 0.9rem;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: var(--text-muted);
font-size: 0.8rem;
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
/* Modal */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 0.5rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted);
}
.modal-close:hover {
color: var(--dark-color);
}
.modal-body {
padding: 1.5rem;
}
/* Higher z-index for edit modals that appear on top of other modals */
#edit-modal, #smart-fix-modal {
z-index: 1100 !important;
}
/* Reports */
.report-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-card {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: var(--shadow);
text-align: center;
}
.summary-card h3 {
margin-bottom: 1rem;
color: var(--dark-color);
}
.summary-card p {
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.summary-card span {
font-weight: 700;
color: var(--primary-color);
}
.report-section {
margin-bottom: 2rem;
}
.report-section h3 {
margin-bottom: 1rem;
color: var(--dark-color);
}
/* Tools */
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.tool-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.tool-card h3 {
margin-bottom: 0.5rem;
color: var(--dark-color);
}
.tool-card p {
margin-bottom: 1.5rem;
color: var(--text-muted);
}
.stats-display {
background: var(--light-color);
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
min-height: 100px;
}
/* Toast notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
}
.toast {
background: white;
border-radius: 0.25rem;
box-shadow: var(--shadow-lg);
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
min-width: 300px;
border-left: 4px solid var(--primary-color);
animation: slideIn 0.3s ease;
}
.toast.success {
border-left-color: var(--success-color);
}
.toast.warning {
border-left-color: var(--warning-color);
}
.toast.error {
border-left-color: var(--danger-color);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Smart Fix Modal */
.smart-fix-options {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.option-card {
border: 2px solid var(--border-color);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.option-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow);
}
.option-label {
display: block;
padding: 1rem;
cursor: pointer;
margin: 0;
}
.option-label input[type="radio"] {
margin-right: 0.75rem;
margin-top: 0.1rem;
width: auto;
}
.option-content h4 {
margin: 0 0 0.5rem 0;
color: var(--dark-color);
font-size: 1rem;
}
.option-content p {
margin: 0 0 0.5rem 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.option-content small {
color: var(--text-muted);
font-size: 0.8rem;
}
.manual-date-input {
width: 100% !important;
margin-top: 0.5rem !important;
}
.option-card input[type="radio"]:checked + .option-content {
color: var(--primary-color);
}
.option-card:has(input[type="radio"]:checked) {
border-color: var(--primary-color);
background-color: rgba(0, 123, 255, 0.05);
}
/* Additional badge styles */
.badge-info {
background-color: #d1ecf1;
color: #0c5460;
}
/* Enhanced Edit Modal Date Options */
.date-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.date-option-card {
border: 1px solid var(--border-color);
border-radius: 0.375rem;
transition: all 0.2s ease;
}
.date-option-card:hover {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
}
.date-option-label {
display: block;
padding: 0.75rem;
cursor: pointer;
margin: 0;
}
.date-option-label input[type="radio"] {
margin-right: 0.5rem;
margin-top: 0.1rem;
width: auto;
}
.date-option-content h4 {
margin: 0 0 0.25rem 0;
color: var(--dark-color);
font-size: 0.9rem;
font-weight: 600;
}
.date-option-content p {
margin: 0 0 0.25rem 0;
color: var(--text-muted);
font-size: 0.8rem;
}
.date-option-content small {
color: var(--primary-color);
font-size: 0.75rem;
font-weight: 500;
}
.date-option-card input[type="radio"]:checked + .date-option-content h4 {
color: var(--primary-color);
}
.date-option-card:has(input[type="radio"]:checked) {
border-color: var(--primary-color);
background-color: rgba(0, 123, 255, 0.03);
}
/* Responsive */
@media (max-width: 768px) {
.content-header {
flex-direction: column;
align-items: stretch;
}
.content-controls {
justify-content: center;
}
.search-box input {
width: 200px;
}
.nav-tabs {
flex-direction: column;
gap: 0.25rem;
}
.data-table {
font-size: 0.8rem;
}
.data-table th,
.data-table td {
padding: 0.5rem 0.25rem;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.tools-grid {
grid-template-columns: 1fr;
}
}
/* Utility classes */
.text-center { text-align: center; }
.text-muted { color: var(--text-muted); }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.d-none { display: none; }
.d-block { display: block; }
.d-flex { display: flex; }
.justify-content-between { justify-content: space-between; }
.align-items-center { align-items: center; }
/* Manual Scan Styles */
.scan-status {
margin-top: 1rem;
padding: 1rem;
background-color: var(--light-color);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
}
.scan-progress {
margin-bottom: 1rem;
}
.progress-bar {
width: 100%;
height: 1.5rem;
background-color: #e9ecef;
border-radius: 0.375rem;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), var(--success-color));
transition: width 0.3s ease;
width: 0%;
}
.scan-info {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
}
.scan-info span:first-child {
color: var(--text-muted);
}
.scan-info span:last-child {
font-weight: 600;
color: var(--primary-color);
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: var(--text-muted);
font-size: 0.875rem;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
+732
View File
@@ -0,0 +1,732 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NFOGuard - Database Management</title>
<link rel="stylesheet" href="/static/css/styles.css?v=2.10.0-skipped-imdb-edit-v2">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="header-content">
<h1><i class="fas fa-shield-alt"></i> NFOGuard <span style="font-size: 0.5em; color: #888;">v2.10.0-skipped-imdb-edit-v2</span></h1>
<p>Database Management & Reporting</p>
</div>
<div class="auth-status" id="auth-status" style="display: none;">
<span class="auth-user">
<i class="fas fa-user"></i> <span id="auth-username">Loading...</span>
</span>
<button class="auth-logout" id="logout-btn" onclick="logout()">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</div>
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard">
<i class="fas fa-tachometer-alt"></i> Dashboard
</button>
<button class="nav-tab" data-tab="movies">
<i class="fas fa-film"></i> Movies
</button>
<button class="nav-tab" data-tab="tv">
<i class="fas fa-tv"></i> TV Series
</button>
<button class="nav-tab" data-tab="reports">
<i class="fas fa-chart-bar"></i> Reports
</button>
<button class="nav-tab" data-tab="scheduled-scans">
<i class="fas fa-clock"></i> Scheduled Scans
</button>
<button class="nav-tab" data-tab="tools">
<i class="fas fa-tools"></i> Tools
</button>
</nav>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Dashboard Tab -->
<div class="tab-content active" id="dashboard">
<div class="dashboard-grid">
<div class="stat-card">
<div class="stat-icon movies">
<i class="fas fa-film"></i>
</div>
<div class="stat-info">
<h3 id="movies-total">-</h3>
<p>Total Movies</p>
<small id="movies-with-dates">- with dates</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon tv">
<i class="fas fa-tv"></i>
</div>
<div class="stat-info">
<h3 id="series-total">-</h3>
<p>TV Series</p>
<small id="episodes-total">- episodes</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon missing">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="stat-info">
<h3 id="missing-dates-total">-</h3>
<p>Missing Dates</p>
<small id="no-valid-source-total">- no valid source</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon activity">
<i class="fas fa-history"></i>
</div>
<div class="stat-info">
<h3 id="recent-activity">-</h3>
<p>Recent Activity</p>
<small>Last 7 days</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);">
<i class="fas fa-ban"></i>
</div>
<div class="stat-info">
<h3 id="skipped-total">-</h3>
<p>Skipped Items</p>
<small id="skipped-breakdown">- movies, - episodes</small>
</div>
</div>
</div>
<div class="dashboard-charts">
<div class="chart-card">
<h3><i class="fas fa-chart-pie"></i> Movie Sources</h3>
<div id="movie-sources-chart" class="chart-container"></div>
</div>
<div class="chart-card">
<h3><i class="fas fa-chart-pie"></i> Episode Sources</h3>
<div id="episode-sources-chart" class="chart-container"></div>
</div>
</div>
</div>
<!-- Movies Tab -->
<div class="tab-content" id="movies">
<div class="content-header">
<h2><i class="fas fa-film"></i> Movies Database</h2>
<div class="content-controls">
<div class="search-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="movies-search" placeholder="Search title/path...">
</div>
<div class="search-box">
<i class="fas fa-hashtag"></i>
<input type="text" id="movies-imdb-search" placeholder="Search IMDb ID...">
</div>
</div>
<div class="filter-controls">
<select id="movies-filter-date">
<option value="">All Movies</option>
<option value="true">With Dates</option>
<option value="false">Missing Dates</option>
<option value="skipped">Skipped</option>
</select>
<select id="movies-filter-source">
<option value="">All Sources</option>
</select>
<button class="btn btn-primary" onclick="refreshMovies()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
</div>
<div class="table-container">
<table class="data-table sortable-table" id="movies-table">
<thead>
<tr>
<th class="sortable" onclick="sortTable('movies-tbody', 0, 'text')" style="cursor: pointer;">
Title <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 1, 'text')" style="cursor: pointer;">
IMDb ID <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 2, 'date')" style="cursor: pointer;">
Movie Released <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 3, 'date')" style="cursor: pointer;">
Date Added to Library <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 4, 'text')" style="cursor: pointer;">
Source <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 5, 'text')" style="cursor: pointer;">
Date Type <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('movies-tbody', 6, 'text')" style="cursor: pointer;">
Video File <i class="fas fa-sort"></i>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="movies-tbody">
<tr>
<td colspan="8" class="loading">Loading movies...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="movies-pagination"></div>
</div>
<!-- TV Series Tab -->
<div class="tab-content" id="tv">
<div class="content-header">
<h2><i class="fas fa-tv"></i> TV Series Database</h2>
<div class="content-controls">
<div class="search-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="series-search" placeholder="Search title/path...">
</div>
<div class="search-box">
<i class="fas fa-hashtag"></i>
<input type="text" id="series-imdb-search" placeholder="Search IMDb ID...">
</div>
</div>
<div class="filter-controls">
<select id="series-filter-date">
<option value="">All Series</option>
<option value="complete">Fully Dated</option>
<option value="incomplete">Missing Dates</option>
<option value="none">No Dates</option>
<option value="skipped">Skipped</option>
</select>
<select id="series-filter-source">
<option value="">All Sources</option>
</select>
<button class="btn btn-primary" onclick="refreshSeries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
</div>
<div class="table-container">
<table class="data-table sortable-table" id="series-table">
<thead>
<tr>
<th class="sortable" onclick="sortTable('series-tbody', 0, 'text')" style="cursor: pointer;">
Series Title <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 1, 'text')" style="cursor: pointer;">
IMDb ID <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 2, 'number')" style="cursor: pointer;">
Episodes <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 3, 'number')" style="cursor: pointer;">
With Dates <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 4, 'number')" style="cursor: pointer;">
With Video <i class="fas fa-sort"></i>
</th>
<th class="sortable" onclick="sortTable('series-tbody', 5, 'number')" style="cursor: pointer;">
Skipped <i class="fas fa-sort"></i>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="series-tbody">
<tr>
<td colspan="6" class="loading">Loading series...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="series-pagination"></div>
</div>
<!-- Reports Tab -->
<div class="tab-content" id="reports">
<div class="content-header">
<h2><i class="fas fa-chart-bar"></i> Missing Dates Report</h2>
<div class="content-controls">
<button class="btn btn-primary" onclick="refreshReport()">
<i class="fas fa-sync"></i> Refresh Report
</button>
</div>
</div>
<div class="report-summary" id="report-summary">
<div class="summary-card">
<h3>Movies</h3>
<p><span id="report-movies-with">-</span> with dates</p>
<p><span id="report-movies-missing">-</span> missing dates</p>
</div>
<div class="summary-card">
<h3>Episodes</h3>
<p><span id="report-episodes-with">-</span> with dates</p>
<p><span id="report-episodes-missing">-</span> missing dates</p>
</div>
</div>
<div class="report-content">
<div class="report-section">
<h3><i class="fas fa-film"></i> Movies Missing Dates</h3>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Title</th>
<th>IMDb ID</th>
<th>Released</th>
<th>Source</th>
<th>Smart Fix</th>
</tr>
</thead>
<tbody id="report-movies-tbody">
<tr>
<td colspan="5" class="loading">Loading report...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="report-section">
<h3><i class="fas fa-tv"></i> Episodes Missing Dates</h3>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Series</th>
<th>Episode</th>
<th>IMDb ID</th>
<th>Aired</th>
<th>Source</th>
<th>Smart Fix</th>
</tr>
</thead>
<tbody id="report-episodes-tbody">
<tr>
<td colspan="6" class="loading">Loading report...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Scheduled Scans Tab -->
<div class="tab-content" id="scheduled-scans">
<div class="content-header">
<h2><i class="fas fa-clock"></i> Scheduled Scans</h2>
<button class="btn btn-primary" id="add-schedule-btn">
<i class="fas fa-plus"></i> Add Schedule
</button>
</div>
<!-- Active Schedules Section -->
<div class="section-card">
<h3><i class="fas fa-list"></i> Active Schedules</h3>
<div class="table-container">
<table class="data-table" id="schedules-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Mode</th>
<th>Schedule</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="schedules-table-body">
<!-- Schedules will be loaded here -->
</tbody>
</table>
</div>
</div>
<!-- Execution History Section -->
<div class="section-card">
<h3><i class="fas fa-history"></i> Recent Executions</h3>
<div class="table-container">
<table class="data-table" id="executions-table">
<thead>
<tr>
<th>Schedule</th>
<th>Started</th>
<th>Duration</th>
<th>Status</th>
<th>Items Processed</th>
<th>Items Skipped</th>
<th>Items Failed</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="executions-table-body">
<!-- Executions will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Tools Tab -->
<div class="tab-content" id="tools">
<div class="content-header">
<h2><i class="fas fa-tools"></i> Database Tools</h2>
</div>
<div class="tools-grid">
<div class="tool-card">
<h3><i class="fas fa-exchange-alt"></i> Bulk Source Update</h3>
<p>Change source for multiple items at once</p>
<form id="bulk-update-form">
<div class="form-group">
<label>Media Type:</label>
<select id="bulk-media-type" required>
<option value="">Select type...</option>
<option value="movies">Movies</option>
<option value="episodes">Episodes</option>
</select>
</div>
<div class="form-group">
<label>From Source:</label>
<input type="text" id="bulk-old-source" placeholder="e.g., no_valid_date_source" required>
</div>
<div class="form-group">
<label>To Source:</label>
<select id="bulk-new-source" required>
<option value="">Select new source...</option>
<option value="airdate">Air Date</option>
<option value="digital_release">Digital Release</option>
<option value="manual">Manual</option>
<option value="radarr:db.history.import">Radarr Import</option>
<option value="sonarr:history.import">Sonarr Import</option>
</select>
</div>
<button type="submit" class="btn btn-warning">
<i class="fas fa-exchange-alt"></i> Update Sources
</button>
</form>
</div>
<div class="tool-card">
<h3><i class="fas fa-search"></i> Manual Scan</h3>
<p>Scan specific folders or perform full library scans</p>
<form id="manual-scan-form">
<div class="form-group">
<label>Scan Type:</label>
<select id="scan-type" required>
<option value="both">TV Shows & Movies</option>
<option value="tv">TV Shows Only</option>
<option value="movies">Movies Only</option>
</select>
</div>
<div class="form-group">
<label>Scan Mode:</label>
<select id="scan-mode" required>
<option value="smart">Smart (Recommended)</option>
<option value="full">Full Scan</option>
<option value="incomplete">Incomplete Only</option>
</select>
</div>
<div class="form-group">
<label>Specific Path (Optional):</label>
<input type="text" id="scan-path" placeholder="e.g., /mnt/unionfs/Media/TV/Series Name" title="Leave empty for full library scan">
<small>Leave empty to scan entire library</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-play"></i> Start Scan
</button>
</form>
<div id="scan-status" class="scan-status" style="display: none;">
<div class="scan-progress">
<div class="progress-bar">
<div class="progress-fill" id="scan-progress-bar"></div>
</div>
<div class="scan-info">
<span id="scan-current-operation">Initializing...</span>
<span id="scan-progress-text">0%</span>
</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
<i class="fas fa-times"></i> Hide Status
</button>
</div>
</div>
<div class="tool-card">
<h3><i class="fas fa-database"></i> Database Statistics</h3>
<p>View detailed database information</p>
<div class="stats-display" id="detailed-stats">
<p>Click refresh to load detailed statistics</p>
</div>
<button class="btn btn-secondary" onclick="loadDetailedStats()">
<i class="fas fa-sync"></i> Refresh Stats
</button>
</div>
<div class="tool-card">
<h3><i class="fas fa-upload"></i> Populate Database</h3>
<p>Bulk import data from Radarr/Sonarr into NFOGuard database</p>
<form id="populate-form">
<div class="form-group">
<label>Media Type:</label>
<select id="populate-media-type" required>
<option value="both">Movies & TV Shows</option>
<option value="movies">Movies Only</option>
<option value="tv">TV Shows Only</option>
</select>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-play"></i> Start Population
</button>
</form>
<div id="populate-status" class="scan-status" style="display: none;">
<div class="scan-progress">
<div class="progress-bar">
<div class="progress-fill" id="populate-progress-bar"></div>
</div>
<div class="scan-info">
<span id="populate-current-operation">Running...</span>
<span id="populate-progress-text">In Progress</span>
</div>
</div>
<div id="populate-results" class="stats-display" style="margin-top: 10px;"></div>
<button class="btn btn-secondary btn-sm" onclick="stopPopulatePolling()">
<i class="fas fa-times"></i> Hide Status
</button>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Smart Fix Modal -->
<div class="modal" id="smart-fix-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="smart-fix-title">Choose Date Source</h3>
<button class="modal-close" onclick="closeSmartFixModal()">&times;</button>
</div>
<div class="modal-body">
<div id="smart-fix-content">
<p>Loading available options...</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeSmartFixModal()">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<div class="modal" id="edit-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Edit Entry</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="edit-form">
<input type="hidden" id="edit-imdb-id">
<input type="hidden" id="edit-season">
<input type="hidden" id="edit-episode">
<input type="hidden" id="edit-media-type">
<div class="form-group">
<label for="edit-dateadded">Date Added:</label>
<input type="datetime-local" id="edit-dateadded">
<small>Leave empty to clear date</small>
</div>
<div class="form-group">
<label for="edit-source">Source:</label>
<select id="edit-source" required>
<option value="manual">Manual</option>
<option value="airdate">Air Date</option>
<option value="digital_release">Digital Release</option>
<option value="radarr:db.history.import">Radarr Import</option>
<option value="sonarr:history.import">Sonarr Import</option>
<option value="no_valid_date_source">No Valid Source</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<!-- Schedule Modal -->
<div class="modal" id="schedule-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="schedule-modal-title">Add New Schedule</h3>
<button class="modal-close" onclick="closeScheduleModal()">&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 -->
<div class="toast-container" id="toast-container"></div>
<script src="/static/js/app.js?v=2.10.0-skipped-imdb-edit-v2"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
-2554
View File
File diff suppressed because it is too large. Load diff
+84 -219
View File
@@ -11,12 +11,12 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from core.database import NFOGuardDatabase from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.path_mapper import PathMapper from core.path_mapper import PathMapper
from clients.radarr_client import RadarrClient from clients.radarr_client import RadarrClient
from clients.external_clients import ExternalClientManager from clients.external_clients import ExternalClientManager
from config.settings import config from config.settings import config
from utils.logging import _log from utils.logging import _log
from utils.imdb_utils import find_imdb_in_directory # Phase 3: Replaced NFOManager
from utils.file_utils import find_media_path_by_imdb_and_title from utils.file_utils import find_media_path_by_imdb_and_title
@@ -68,9 +68,9 @@ def convert_utc_to_local(utc_iso_string: str) -> str:
class MovieProcessor: class MovieProcessor:
"""Handles movie processing""" """Handles movie processing"""
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper): def __init__(self, db: NFOGuardDatabase, nfo_manager, path_mapper: PathMapper):
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
self.db = db self.db = db
self.nfo_manager = nfo_manager
self.path_mapper = path_mapper self.path_mapper = path_mapper
self.radarr = RadarrClient( self.radarr = RadarrClient(
os.environ.get("RADARR_URL", ""), os.environ.get("RADARR_URL", ""),
@@ -151,9 +151,9 @@ 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 = find_imdb_in_directory(movie_path) # Phase 3: Using imdb_utils instead of NFOManager
if not imdb_id: if not imdb_id:
_log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}") _log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
return "error" return "error"
@@ -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}")
@@ -219,106 +225,16 @@ class MovieProcessor:
if released and hasattr(released, 'isoformat'): if released and hasattr(released, 'isoformat'):
released = released.isoformat() released = released.isoformat()
# Create NFO with existing data and update files # NFO file operations removed - database is now the single source of truth
if config.manage_nfo: # (Phase 1: Remove NFO file write operations)
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-cached]") _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
return "processed" return "processed"
else: else:
_log("INFO", f"🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2") _log("INFO", f"🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2")
# TIER 2: Check if NFO file has NFOGuard data and cache it in database # TIER 2: Query external APIs directly (NFO layer removed in Phase 2)
nfo_path = movie_path / "movie.nfo" _log("INFO", f"🔍 TIER 2 - No database cache, querying external APIs")
_log("INFO", f"🔍 TIER 2 - Checking NFO file: {nfo_path}")
_log("INFO", f"🔍 TIER 2 - NFO exists: {nfo_path.exists()}")
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
_log("INFO", f"🔍 TIER 2 - NFOGuard data extracted: {nfo_data}")
if nfo_data:
_log("INFO", f"🚀 TIER 2 - Found NFOGuard data in NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
dateadded = nfo_data["dateadded"]
source = nfo_data["source"]
released = nfo_data.get("released")
# Cache NFO data in database for future lookups
# Fixed parameter order: imdb_id, released, dateadded, source
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"✅ Cached NFO data in database for {imdb_id}")
# Update file mtimes if enabled (NFO is already correct)
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [nfo-cached]")
return "processed"
# TIER 2.5: Check for any existing valid date data in NFO (even without lockdata marker)
# Only use NFO dates if prioritize_nfo is enabled, otherwise check external APIs first
existing_nfo_data = self._extract_any_valid_dates_from_nfo(nfo_path)
if existing_nfo_data and config.manual_scan_prioritize_nfo:
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
_log("INFO", f"⚡ MANUAL_SCAN_PRIORITIZE_NFO=True - Using NFO date for speed")
dateadded = existing_nfo_data["dateadded"]
source = existing_nfo_data["source"]
released = existing_nfo_data.get("released")
# Cache existing data in database and add proper NFOGuard formatting
# Fixed parameter order: imdb_id, released, dateadded, source
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"✅ Cached existing NFO data in database for {imdb_id}")
# Update NFO file to add NFOGuard formatting (lockdata, comment)
if config.manage_nfo:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
_log("INFO", f"✅ Added NFOGuard formatting to existing NFO for {imdb_id}")
# Update file mtimes if enabled
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [existing-nfo-enhanced]")
return "processed"
elif existing_nfo_data:
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
_log("INFO", f"🎯 MANUAL_SCAN_PRIORITIZE_NFO=False - Will verify against external APIs first")
# Store NFO data as fallback but continue to TIER 3 to check external APIs
nfo_fallback_data = existing_nfo_data
# TIER 1.5: Special handling for TMDB-only movies - extract dates from existing NFO
if is_tmdb_fallback:
tmdb_nfo_data = self._extract_dates_from_tmdb_nfo(nfo_path)
if tmdb_nfo_data:
_log("INFO", f"🎬 Using TMDB data from existing NFO file: {tmdb_nfo_data['dateadded']} (source: {tmdb_nfo_data['source']})")
dateadded = tmdb_nfo_data["dateadded"]
source = tmdb_nfo_data["source"]
released = tmdb_nfo_data.get("released")
# Create NFO with NFOGuard fields added
if config.manage_nfo:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
# Update file mtimes if enabled
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
# Save to database
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [tmdb-nfo]")
return "processed"
# TIER 3: No cached data found - proceed with API lookups and verification
# Check for shutdown signal before expensive API operations # Check for shutdown signal before expensive API operations
if shutdown_event and shutdown_event.is_set(): if shutdown_event and shutdown_event.is_set():
@@ -356,18 +272,8 @@ class MovieProcessor:
final_source = f"{source}_as_dateadded" if source else "release_date_fallback" final_source = f"{source}_as_dateadded" if source else "release_date_fallback"
_log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})") _log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})")
# Create NFO regardless of date availability (preserves existing metadata) # NFO file operations removed - database is now the single source of truth
if config.debug: # (Phase 1: Remove NFO file write operations)
print(f"🔍 TIER3 - config.manage_nfo: {config.manage_nfo}")
if config.manage_nfo:
if config.debug:
print(f"🔍 TIER3 - Calling create_movie_nfo with final_dateadded: {final_dateadded}")
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, final_dateadded, released, final_source, config.lock_metadata
)
else:
if config.debug:
print(f"❌ TIER3 - manage_nfo is disabled, skipping NFO creation")
# Skip remaining processing if no valid date found and file dates disabled # Skip remaining processing if no valid date found and file dates disabled
if final_dateadded is None: if final_dateadded is None:
@@ -381,15 +287,11 @@ class MovieProcessor:
_log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}") _log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}")
# Update file mtimes (only if we have a valid date) # File mtime operations removed - database is now the single source of truth
if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED": # (Phase 1: Remove NFO file write operations)
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}") _log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
# Yield control briefly during movie processing to allow web interface requests
import time
time.sleep(0.005) # 5ms yield per movie to improve responsiveness
# Save to database # Save to database
_log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}") _log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}")
@@ -403,85 +305,75 @@ class MovieProcessor:
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})") _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
return "processed" return "processed"
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]: def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None) -> str:
"""Extract date information from TMDB-based NFO file""" """Process movie for incomplete mode: Database-first then API (NFO checks removed in Phase 2)"""
if not nfo_path.exists(): _log("INFO", f"🔍 INCOMPLETE MODE: Checking movie for missing data: {movie_path.name}")
return None
try: # Check for shutdown signal
root = self.nfo_manager._parse_nfo_with_tolerance(nfo_path) if shutdown_event and shutdown_event.is_set():
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}")
return "shutdown"
# Look for premiered date (from TMDB) # STEP 1: Check database for existing data (Phase 2: NFO check removed)
premiered_elem = root.find('.//premiered') _log("DEBUG", f"STEP 1 - Checking database for existing data")
if premiered_elem is not None and premiered_elem.text: existing = self.db.get_movie_dates(imdb_id)
premiered_date = premiered_elem.text.strip()
print(f"✅ Found TMDB premiered date: {premiered_date}")
return { if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
"dateadded": premiered_date, # Found in database - data is complete
"source": "tmdb:premiered_from_nfo", _log("INFO", f"✅ Database has dateadded={existing['dateadded']}")
"released": premiered_date dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
}
except (ET.ParseError, Exception) as e: # Convert datetime objects to strings
print(f"⚠️ Error parsing TMDB NFO for dates: {e}") if hasattr(dateadded, 'isoformat'):
dateadded = dateadded.isoformat()
if released and hasattr(released, 'isoformat'):
released = released.isoformat()
return None _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
return "processed"
def _extract_any_valid_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]: # STEP 2: Database incomplete or missing, query APIs
"""Extract any valid date information from NFO file, even without NFOGuard markers""" _log("DEBUG", f"STEP 2 - Querying APIs for missing data")
if not nfo_path.exists():
return None
try: # Check for shutdown signal before API calls
import xml.etree.ElementTree as ET if shutdown_event and shutdown_event.is_set():
tree = ET.parse(nfo_path) _log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping before API calls: {movie_path.name}")
root = tree.getroot() return "shutdown"
# Look for dateadded element (indicates previously processed by NFOGuard or similar) # Handle TMDB ID fallback case
dateadded_elem = root.find('.//dateadded') is_tmdb_fallback = imdb_id.startswith("tmdb-")
premiered_elem = root.find('.//premiered')
if dateadded_elem is not None and dateadded_elem.text: if is_tmdb_fallback:
dateadded = dateadded_elem.text.strip() # TMDB fallback processing - use file modification time
_log("INFO", f"🔍 TMDB fallback processing for {imdb_id}")
# Try to determine source from NFOGuard comment if present dateadded, source, released = self._get_file_mtime_date(movie_path)
source = "existing_nfo_data" _log("INFO", f"Using file mtime for TMDB movie: {dateadded}")
try:
nfo_content = nfo_path.read_text(encoding='utf-8')
import re
# Look for NFOGuard comment pattern
source_match = re.search(r'<!--.*?NFOGuard.*?Source:\s*([^-\s]+).*?-->', nfo_content, re.DOTALL | re.IGNORECASE)
if source_match:
source = source_match.group(1).strip()
_log("DEBUG", f"Found source in NFOGuard comment: {source}")
else: else:
# Try to infer source from dateadded format/content # Standard IMDb processing
if "tmdb" in nfo_content.lower() or (premiered_elem and premiered_elem.text): # Try to get digital release date from external APIs
source = "tmdb:digital" digital_date, digital_source = self._get_digital_release_date(imdb_id)
elif "radarr" in nfo_content.lower():
source = "radarr:db.history.import" 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: else:
source = "existing_nfo_data" # Last resort: file modification time
_log("DEBUG", f"Inferred source from NFO content: {source}") dateadded, source, released = self._get_file_mtime_date(movie_path)
except Exception as e: _log("INFO", f"Using file mtime as fallback: {dateadded}")
_log("DEBUG", f"Could not determine source from NFO content: {e}")
result = { # Save to database only (NFO operations removed in Phase 1)
"dateadded": dateadded, if dateadded:
"source": source self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
}
if premiered_elem is not None and premiered_elem.text: _log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {movie_path.name} (source: {source})")
result["released"] = premiered_elem.text.strip() return "processed"
else:
_log("WARNING", f"Could not determine dateadded for movie: {movie_path.name}")
return "error"
_log("INFO", f"✅ Found existing date data in NFO: dateadded={dateadded}, source={source}") # NFO helper methods removed in Phase 2 - database is the single source of truth
return result
except (ET.ParseError, Exception) as e:
_log("DEBUG", f"Error parsing NFO for existing date data: {e}")
return None
def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]: def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]:
"""Decide movie dates based on configuration and available data""" """Decide movie dates based on configuration and available data"""
@@ -530,7 +422,8 @@ class MovieProcessor:
# Compare dates - prefer release date if it's reasonable # Compare dates - prefer release date if it's reasonable
if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id): if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id):
_log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date") _log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date")
return digital_date, digital_source, released # When using digital release date, store it as both dateadded and released
return digital_date, digital_source, digital_date
else: else:
# Convert file date to local timezone for NFO files # Convert file date to local timezone for NFO files
local_file_date = convert_utc_to_local(import_date) local_file_date = convert_utc_to_local(import_date)
@@ -545,21 +438,17 @@ class MovieProcessor:
return local_import_date, import_source, released return local_import_date, import_source, released
elif digital_date: elif digital_date:
_log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}") _log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}")
return digital_date, digital_source, released # When using digital release date, store it as both dateadded and released
return digital_date, digital_source, digital_date
else: else:
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks") _log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found")
# Try Radarr's own NFO premiered date as fallback
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
if radarr_premiered:
_log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}")
return radarr_premiered, "radarr:nfo.premiered", released
else: # digital_then_import else: # digital_then_import
# Try digital release first # Try digital release first
digital_date, digital_source = self._get_digital_release_date(imdb_id) digital_date, digital_source = self._get_digital_release_date(imdb_id)
if digital_date: if digital_date:
return digital_date, digital_source, released # When using digital release date, store it as both dateadded and released
return digital_date, digital_source, digital_date
# Fall back to import history # Fall back to import history
if radarr_movie: if radarr_movie:
@@ -611,31 +500,7 @@ class MovieProcessor:
_log("ERROR", f"External clients error for {imdb_id}: {e}") _log("ERROR", f"External clients error for {imdb_id}: {e}")
return None, f"release:error:{str(e)}" return None, f"release:error:{str(e)}"
def _get_radarr_nfo_premiered_date(self, movie_path: Path) -> Optional[str]: # _get_radarr_nfo_premiered_date() removed in Phase 2 - no longer reading NFO files
"""Extract premiered date from Radarr's existing movie.nfo file"""
try:
nfo_path = movie_path / "movie.nfo"
if not nfo_path.exists():
_log("DEBUG", f"No existing NFO file found at {nfo_path}")
return None
nfo_content = nfo_path.read_text(encoding='utf-8')
# Look for <premiered>YYYY-MM-DD</premiered>
match = re.search(r'<premiered>(\d{4}-\d{2}-\d{2})</premiered>', nfo_content)
if match:
premiered_date = match.group(1)
# Convert to ISO format with timezone
iso_date = f"{premiered_date}T00:00:00+00:00"
_log("INFO", f"✅ Found Radarr NFO premiered date: {premiered_date}")
return iso_date
else:
_log("DEBUG", f"No <premiered> tag found in existing NFO")
return None
except Exception as e:
_log("ERROR", f"Error reading Radarr NFO file: {e}")
return None
def _log_failed_movie(self, movie_path: Path, imdb_id: str, reason: str, available_countries: List[str] = None): def _log_failed_movie(self, movie_path: Path, imdb_id: str, reason: str, available_countries: List[str] = None):
"""Log movies that failed to get valid dates to a debug file""" """Log movies that failed to get valid dates to a debug file"""
+186 -204
View File
@@ -11,13 +11,12 @@ from typing import Optional, Dict, List, Set, Tuple, Any
from datetime import datetime from datetime import datetime
from core.database import NFOGuardDatabase from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.async_nfo_manager import AsyncNFOManager
from core.path_mapper import PathMapper from core.path_mapper import PathMapper
from clients.sonarr_client import SonarrClient from clients.sonarr_client import SonarrClient
from clients.external_clients import ExternalClientManager from clients.external_clients import ExternalClientManager
from config.settings import config from config.settings import config
from utils.logging import _log from utils.logging import _log
from utils.imdb_utils import parse_imdb_from_path # Phase 3: Replaced NFOManager
from utils.file_utils import ( from utils.file_utils import (
find_media_path_by_imdb_and_title, find_media_path_by_imdb_and_title,
find_episodes_on_disk, find_episodes_on_disk,
@@ -32,10 +31,9 @@ from utils.async_file_utils import (
class TVProcessor: class TVProcessor:
"""Handles TV series processing""" """Handles TV series processing"""
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper): def __init__(self, db: NFOGuardDatabase, nfo_manager, path_mapper: PathMapper):
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
self.db = db self.db = db
self.nfo_manager = nfo_manager
self.async_nfo_manager = AsyncNFOManager(config.manager_brand, config.debug)
self.path_mapper = path_mapper self.path_mapper = path_mapper
self.sonarr = SonarrClient( self.sonarr = SonarrClient(
os.environ.get("SONARR_URL", ""), os.environ.get("SONARR_URL", ""),
@@ -145,9 +143,9 @@ 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 = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
if not imdb_id: if not imdb_id:
_log("ERROR", f"No IMDb ID found in series path: {series_path}") _log("ERROR", f"No IMDb ID found in series path: {series_path}")
return "error" return "error"
@@ -155,7 +153,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}")
@@ -192,19 +191,8 @@ class TVProcessor:
if (season, episode) in disk_episodes: if (season, episode) in disk_episodes:
episode_count += 1 episode_count += 1
# Create NFO # NFO file operations removed - database is now the single source of truth
if config.manage_nfo: # (Phase 1: Remove NFO file write operations)
season_dir = series_path / config.tv_season_dir_format.format(season=season)
self.nfo_manager.create_episode_nfo(
season_dir,
season, episode, aired, dateadded, source, config.lock_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
video_files = disk_episodes[(season, episode)]
for video_file in video_files:
self.nfo_manager.set_file_mtime(video_file, dateadded)
# Save to database # Save to database
try: try:
@@ -214,11 +202,6 @@ class TVProcessor:
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}") _log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
# Continue processing other episodes # Continue processing other episodes
# Yield control every 3 episodes to allow other requests (webhooks, web interface)
if episode_count % 3 == 0:
import time
time.sleep(0.1) # 100ms yield to improve responsiveness during episode processing
_log("DEBUG", f"Processed {episode_count} episodes, yielding to allow other requests...")
# Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs # Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs
pass pass
@@ -231,12 +214,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
@@ -258,64 +246,12 @@ class TVProcessor:
# Not in database or incomplete - needs NFO check # Not in database or incomplete - needs NFO check
episodes_needing_nfo_check.append((season, episode)) episodes_needing_nfo_check.append((season, episode))
_log("INFO", f"Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need NFO check: {len(episodes_needing_nfo_check)}") _log("INFO", f"TIER 1 - Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_nfo_check)}")
# TIER 2: Check NFO files for NFOGuard dates and cache them in database # TIER 2: Query Sonarr API for episodes not in database (NFO layer removed in Phase 2)
nfo_cache_hits = 0 episodes_needing_lookup = episodes_needing_nfo_check # Renamed for clarity
episodes_needing_lookup = []
if episodes_needing_nfo_check:
_log("DEBUG", f"TIER 2 - Checking NFO files for NFOGuard dates for {len(episodes_needing_nfo_check)} episodes")
for (season, episode) in episodes_needing_nfo_check:
# Look for existing NFO files for this episode
season_dir = series_path / config.tv_season_dir_format.format(season=season)
episode_files = disk_episodes[(season, episode)]
nfo_found = False
for episode_file in episode_files:
# Try to find matching NFO file
nfo_path = episode_file.with_suffix('.nfo')
if nfo_path.exists():
# Extract NFOGuard data from episode NFO
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
if nfo_data:
aired = nfo_data.get('aired')
dateadded = nfo_data.get('dateadded')
source = nfo_data.get('source', 'nfo_cache')
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO data found - aired={aired}, dateadded={dateadded}, source={source}")
# Skip incomplete NFO files with "unknown" source or no useful dates
if source == "unknown" and not dateadded and not aired:
_log("INFO", f"S{season:02d}E{episode:02d}: Ignoring incomplete NFO file with source 'unknown' and no dates")
break # Break out of NFO search to mark episode as needing API lookup
# Apply fallback logic if NFO has aired but no dateadded
if not dateadded and aired:
dateadded = aired
source = f"{source}_aired_fallback" if source != 'nfo_cache' else 'nfo_aired_fallback'
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO has aired but no dateadded, using aired as fallback: {dateadded}")
if dateadded:
episode_dates[(season, episode)] = (aired, dateadded, source)
nfo_cache_hits += 1
nfo_found = True
# Cache NFO data in database for future lookups
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
_log("DEBUG", f"NFO cache hit for S{season:02d}E{episode:02d}: {dateadded} - cached in DB")
break
if not nfo_found:
# No NFO data found - needs API lookup
episodes_needing_lookup.append((season, episode))
_log("INFO", f"NFO cache hits: {nfo_cache_hits}/{len(episodes_needing_nfo_check)} episodes. Need API lookup: {len(episodes_needing_lookup)}")
# TIER 3: Only call Sonarr API for episodes not in database or NFO files
if episodes_needing_lookup: if episodes_needing_lookup:
_log("DEBUG", f"TIER 3 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database and NFO files") _log("DEBUG", f"TIER 2 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database")
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_lookup) sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_lookup)
# Process episodes that needed lookup # Process episodes that needed lookup
@@ -329,6 +265,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}")
@@ -377,6 +319,89 @@ 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: Database-first then API (NFO checks removed in Phase 2)"""
_log("INFO", f"🔍 INCOMPLETE MODE: Checking {len(disk_episodes)} episodes for missing data")
episode_dates = {}
episodes_needing_api_lookup = []
# STEP 1: Check database for all episodes (NFO check removed in Phase 2)
_log("DEBUG", f"STEP 1 - Checking database for {len(disk_episodes)} episodes")
db_hits = 0
for (season, episode) in disk_episodes:
db_result = self.db.get_episode_date(imdb_id, season, episode)
if db_result and db_result.get('dateadded'):
# Found in database - use cached data
aired = db_result.get('aired')
dateadded = db_result.get('dateadded')
source = db_result.get('source', 'database_cache')
episode_dates[(season, episode)] = (aired, dateadded, source)
db_hits += 1
_log("DEBUG", f"S{season:02d}E{episode:02d}: Database has dateadded={dateadded}")
else:
# Not in database or incomplete - needs API lookup
episodes_needing_api_lookup.append((season, episode))
_log("INFO", f"Database hits: {db_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_api_lookup)}")
# STEP 2: For episodes missing from database, query Sonarr
if episodes_needing_api_lookup:
_log("DEBUG", f"STEP 2 - Querying Sonarr for {len(episodes_needing_api_lookup)} episodes missing from database")
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_api_lookup)
for (season, episode) in episodes_needing_api_lookup:
aired = None
dateadded = None
source = "unknown"
# Try Sonarr first
if (season, episode) in sonarr_episodes:
sonarr_data = sonarr_episodes[(season, episode)]
aired = sonarr_data.get('airDate')
dateadded = sonarr_data.get('dateAdded')
if dateadded:
source = "sonarr:history.import"
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
else:
source = "sonarr:no_import_date"
_log("DEBUG", f"S{season:02d}E{episode:02d}: Sonarr has data but no dateAdded (aired: {aired})")
# Fallback to external sources if needed
if not aired:
_log("DEBUG", f"S{season:02d}E{episode:02d}: No aired date from Sonarr, trying external APIs")
external_aired = self.external_clients.get_episode_air_date(imdb_id, season, episode)
if external_aired:
aired = external_aired
if not dateadded:
source = "external"
_log("INFO", f"S{season:02d}E{episode:02d}: Found aired date from external APIs: {aired}")
# Use air date as fallback for dateadded if no import date found
if not dateadded and aired:
dateadded = aired
if source == "sonarr:no_import_date":
source = "sonarr:aired_fallback"
elif source == "external":
source = "external:aired_fallback"
else:
source = f"{source}_fallback" if source != "unknown" else "aired_fallback"
_log("DEBUG", f"S{season:02d}E{episode:02d}: Using aired date as dateadded fallback: {dateadded}")
# Save to database for future lookups
if dateadded or aired:
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
episode_dates[(season, episode)] = (aired, dateadded, source)
_log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {len(episode_dates)} episodes processed")
for (s, e), (aired, dateadded, source) in episode_dates.items():
_log("INFO", f" S{s:02d}E{e:02d}: aired={aired}, dateadded={dateadded}, source={source}")
return episode_dates
def _get_sonarr_episodes(self, imdb_id: str, episodes_filter: List[Tuple[int, int]] = None) -> Dict[Tuple[int, int], Dict[str, Any]]: 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:
@@ -444,6 +469,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'):
@@ -453,11 +483,6 @@ class TVProcessor:
episode_data['dateAdded'] = import_date episode_data['dateAdded'] = import_date
_log("DEBUG", f"Got import date from history for S{season:02d}E{episode_num:02d}: {import_date}") _log("DEBUG", f"Got import date from history for S{season:02d}E{episode_num:02d}: {import_date}")
# Yield control every 5 API calls to allow other requests
if api_calls_made % 5 == 0:
import time
time.sleep(0.01) # 10ms yield to other processes
_log("DEBUG", f"Yielded after {api_calls_made} Sonarr API calls to allow other requests...")
# Fallback to episodeFile.dateAdded if history didn't work # Fallback to episodeFile.dateAdded if history didn't work
if not episode_data['dateAdded'] and episode.get('hasFile'): if not episode_data['dateAdded'] and episode.get('hasFile'):
@@ -468,11 +493,6 @@ class TVProcessor:
episode_map[(season, episode_num)] = episode_data episode_map[(season, episode_num)] = episode_data
# Also yield control every 20 episodes processed to prevent blocking
if episodes_processed % 20 == 0:
import time
time.sleep(0.01) # 10ms yield for large episode lists
_log("DEBUG", f"Processed {episodes_processed} episodes, yielding to allow other requests...")
if filter_set: if filter_set:
_log("DEBUG", f"Made {api_calls_made} Sonarr history API calls for filtered episodes (instead of all episodes)") _log("DEBUG", f"Made {api_calls_made} Sonarr history API calls for filtered episodes (instead of all episodes)")
@@ -521,18 +541,8 @@ class TVProcessor:
processed_count = 0 processed_count = 0
for (season, episode), (aired, dateadded, source) in episode_dates.items(): for (season, episode), (aired, dateadded, source) in episode_dates.items():
if (season, episode) in season_episodes: if (season, episode) in season_episodes:
# Create NFO # NFO file operations removed - database is now the single source of truth
if config.manage_nfo: # (Phase 1: Remove NFO file write operations)
self.nfo_manager.create_episode_nfo(
season_path,
season, episode, aired, dateadded, source, config.lock_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
video_files = season_episodes[(season, episode)]
for video_file in video_files:
self.nfo_manager.set_file_mtime(video_file, dateadded)
# Save to database # Save to database
try: try:
@@ -601,16 +611,8 @@ class TVProcessor:
aired, dateadded, source = episode_dates[(season_num, episode_num)] aired, dateadded, source = episode_dates[(season_num, episode_num)]
# Create NFO # NFO file operations removed - database is now the single source of truth
if config.manage_nfo: # (Phase 1: Remove NFO file write operations)
self.nfo_manager.create_episode_nfo(
season_path,
season_num, episode_num, aired, dateadded, source, config.lock_metadata
)
# Update file mtime
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.set_file_mtime(episode_path, dateadded)
# Save to database # Save to database
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
@@ -638,7 +640,7 @@ class TVProcessor:
Returns: Returns:
Dictionary with processing results Dictionary with processing results
""" """
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
if not imdb_id: if not imdb_id:
return {"status": "error", "reason": f"No IMDb ID found in series path: {series_path}"} return {"status": "error", "reason": f"No IMDb ID found in series path: {series_path}"}
@@ -660,25 +662,8 @@ class TVProcessor:
for (season, episode), (aired, dateadded, source) in episode_dates.items(): 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) # NFO file operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
# Prepare NFO creation data
if config.manage_nfo:
episode_data_list.append({
'season_dir': season_dir,
'season': season,
'episode': episode,
'aired': aired,
'dateadded': dateadded,
'source': source,
'lock_metadata': config.lock_metadata
})
# Prepare mtime operations
if config.fix_dir_mtimes and dateadded:
video_files = disk_episodes[(season, episode)]
for video_file in video_files:
mtime_operations.append((video_file, dateadded))
# Save to database # Save to database
try: try:
@@ -688,27 +673,10 @@ class TVProcessor:
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}") _log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
# Continue processing other episodes # Continue processing other episodes
# Process NFOs and mtimes concurrently # NFO and mtime operations removed - database is now the single source of truth
# (Phase 1: Remove NFO file write operations)
results = {} results = {}
if episode_data_list:
_log("INFO", f"Creating {len(episode_data_list)} episode NFOs concurrently")
nfo_results = await self.async_nfo_manager.async_batch_create_episode_nfos(
episode_data_list,
max_concurrent=config.max_concurrent
)
results['nfo_created'] = sum(nfo_results)
results['nfo_failed'] = len(nfo_results) - sum(nfo_results)
if mtime_operations:
_log("INFO", f"Setting mtimes for {len(mtime_operations)} files concurrently")
mtime_results = await self.async_nfo_manager.async_batch_set_file_mtimes(
mtime_operations,
max_concurrent=10
)
results['mtime_updated'] = sum(mtime_results)
results['mtime_failed'] = len(mtime_results) - sum(mtime_results)
_log("INFO", f"Completed async processing TV series: {series_path.name}") _log("INFO", f"Completed async processing TV series: {series_path.name}")
return { return {
@@ -765,7 +733,7 @@ class TVProcessor:
def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None: def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None:
"""Process only the specific episodes mentioned in a webhook (targeted mode)""" """Process only the specific episodes mentioned in a webhook (targeted mode)"""
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
if not imdb_id: if not imdb_id:
_log("ERROR", f"No IMDb ID found in series path: {series_path}") _log("ERROR", f"No IMDb ID found in series path: {series_path}")
return return
@@ -793,7 +761,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
@@ -812,21 +780,11 @@ 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 # NFO file operations removed - database is now the single source of truth
if config.manage_nfo: # (Phase 1: Remove NFO file write operations)
self.nfo_manager.create_episode_nfo(
season_dir,
season_num, episode_num, aired, dateadded, source, config.lock_metadata,
enhanced_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
for episode_file in episode_files:
self.nfo_manager.set_file_mtime(episode_file, dateadded)
# Save to database # Save to database
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
@@ -840,20 +798,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")
@@ -957,17 +915,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
@@ -978,8 +965,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:
@@ -988,9 +975,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
@@ -1053,17 +1039,13 @@ class TVProcessor:
dateadded = episode_data.get('dateadded') dateadded = episode_data.get('dateadded')
# Get IMDb ID # Get IMDb ID
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
if not imdb_id: if not imdb_id:
return {"status": "error", "reason": "No IMDb ID found"} return {"status": "error", "reason": "No IMDb ID found"}
# Create NFO if needed # NFO file operations removed - database is now the single source of truth
nfo_success = True # (Phase 1: Remove NFO file write operations)
if config.manage_nfo: nfo_success = True # Always True since we're not creating NFOs anymore
season_dir = series_path / config.tv_season_dir_format.format(season=season)
nfo_success = await self.async_nfo_manager.async_create_episode_nfo(
season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata
)
# Update database # Update database
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, "webhook", True) self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, "webhook", True)
+91 -24
View File
@@ -29,6 +29,19 @@ class TVSeriesProcessor:
self.sonarr = sonarr_client self.sonarr = sonarr_client
self.external_manager = ExternalClientManager() self.external_manager = ExternalClientManager()
# Initialize Sonarr database client for high-performance queries
self.sonarr_db = None
try:
from clients.sonarr_db_client import SonarrDbClient
self.sonarr_db = SonarrDbClient.from_env()
if self.sonarr_db:
_log("INFO", "✅ SONARR DB MODE: Direct database access enabled")
else:
_log("INFO", "⚠️ SONARR API MODE: Using API (configure SONARR_DB_TYPE for better performance)")
except Exception as e:
_log("WARNING", f"Sonarr DB client initialization failed, using API: {e}")
self.sonarr_db = None
def process_series_manual_scan(self, series_path: Path) -> bool: def process_series_manual_scan(self, series_path: Path) -> bool:
"""Process a TV series during manual scan""" """Process a TV series during manual scan"""
_log("INFO", f"Processing TV series: {series_path.name}") _log("INFO", f"Processing TV series: {series_path.name}")
@@ -219,11 +232,62 @@ class TVSeriesProcessor:
return False return False
def _get_episode_dates_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str], str]: def _get_episode_dates_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str], str]:
"""Get episode dates from Sonarr API with proper fallback""" """Get episode dates from Sonarr (DB first, then API fallback)"""
aired = None aired = None
dateadded = None dateadded = None
source = "no_data_found" source = "no_data_found"
# TIER 1: Try Sonarr database (high performance)
if self.sonarr_db:
try:
# Find series by IMDb
series = self.sonarr_db.get_series_by_imdb(imdb_id)
if series:
series_id = series['id']
# Get all episodes for series
episodes = self.sonarr_db.get_all_episodes_for_series(series_id)
# Find target episode
target_episode = None
for ep in episodes:
if ep['season'] == season_num and ep['episode'] == episode_num:
target_episode = ep
break
if target_episode:
# Get air date
aired = target_episode.get('air_date')
# Try to get import date from history
episode_id = target_episode['id']
import_date, import_source = self.sonarr_db.get_episode_import_date(episode_id)
if import_date:
dateadded = import_date
source = import_source
_log("INFO", f"✅ DB: Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
return aired, dateadded, source
# Fallback to episode file date
file_date = self.sonarr_db.get_episode_file_date(series_id, season_num, episode_num)
if file_date:
dateadded = file_date
source = "sonarr:db.file.dateAdded"
_log("INFO", f"✅ DB: Using file date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
return aired, dateadded, source
# Use air date as fallback
if aired:
dateadded = convert_utc_to_local(aired)
source = "sonarr:db.airDate"
_log("WARNING", f"⚠️ DB: No import date, using air date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
return aired, dateadded, source
except Exception as e:
_log("WARNING", f"Sonarr DB query failed for S{season_num:02d}E{episode_num:02d}, falling back to API: {e}")
# TIER 2: Fall back to Sonarr API
if not self.sonarr.enabled: if not self.sonarr.enabled:
_log("WARNING", "Sonarr not enabled, cannot get episode dates") _log("WARNING", "Sonarr not enabled, cannot get episode dates")
return aired, dateadded, source return aired, dateadded, source
@@ -263,16 +327,18 @@ class TVSeriesProcessor:
if episode_id: if episode_id:
import_date = self.sonarr.get_episode_import_history(episode_id) import_date = self.sonarr.get_episode_import_history(episode_id)
if import_date: if import_date:
dateadded = convert_utc_to_local(import_date) # Sonarr import dates are already in local timezone despite 'Z' suffix
source = "sonarr:history.import" # Remove 'Z' and use as-is to avoid double timezone conversion
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}") dateadded = import_date.replace('Z', '') if 'Z' in import_date else import_date
source = "sonarr:api.history.import"
_log("INFO", f"✅ API: Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
return aired, dateadded, source return aired, dateadded, source
# Fallback to airdate if no import history # Fallback to airdate if no import history
if aired: if aired:
dateadded = convert_utc_to_local(aired) dateadded = convert_utc_to_local(aired)
source = "sonarr:episode.airDateUtc" source = "sonarr:api.episode.airDateUtc"
_log("WARNING", f"No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}") _log("WARNING", f"⚠️ API: No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
return aired, dateadded, source return aired, dateadded, source
except Exception as e: except Exception as e:
@@ -369,21 +435,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
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
NFOGuard Web Interface Starter
Simple script to start web interface using existing config system
"""
import os
import sys
import time
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
# Import existing configuration (keep using core config for simplicity)
from config.settings import config
# Import existing database and components
from core.database import NFOGuardDatabase
# Import web routes from existing system (now includes DELETE route)
from api.web_routes import register_web_routes
# Import authentication system
from api.auth import SimpleAuthMiddleware, AuthSession
def create_web_app() -> FastAPI:
"""Create FastAPI web application"""
app = FastAPI(
title="NFOGuard Web Interface",
description="Web interface for NFOGuard media database management",
version="2.9.0-fixes-only-files",
docs_url=None, # Disable docs in production
redoc_url=None
)
return app
def setup_static_files(app: FastAPI) -> None:
"""Mount static file directories"""
static_path = os.path.join(os.path.dirname(__file__), "nfoguard-web", "static")
logo_path = os.path.join(os.path.dirname(__file__), "logo")
print(f"🔍 Checking static path: {static_path} (exists: {os.path.exists(static_path)})")
print(f"🔍 Checking logo path: {logo_path} (exists: {os.path.exists(logo_path)})")
if os.path.exists(static_path):
app.mount("/static", StaticFiles(directory=static_path), name="static")
print(f"✅ Mounted static files from: {static_path}")
else:
print(f"❌ Static path not found: {static_path}")
if os.path.exists(logo_path):
app.mount("/logo", StaticFiles(directory=logo_path), name="logo")
print(f"✅ Mounted logo files from: {logo_path}")
else:
print(f"❌ Logo path not found: {logo_path}")
# Serve index.html at root
@app.get("/")
async def serve_index():
index_file = os.path.join(static_path, "index.html")
if os.path.exists(index_file):
return FileResponse(index_file)
else:
return {"message": "NFOGuard Web Interface", "status": "running"}
# Serve favicon
@app.get("/favicon.ico")
async def serve_favicon():
# Try to serve favicon from logo directory or static files
favicon_paths = [
os.path.join(logo_path, "favicon.ico"),
os.path.join(static_path, "favicon.ico"),
os.path.join(logo_path, "NFOGuardLogo.png") # Fallback to new logo
]
for favicon_path in favicon_paths:
if os.path.exists(favicon_path):
return FileResponse(favicon_path)
# Return 204 No Content if no favicon found
from fastapi import Response
return Response(status_code=204)
# Health check endpoint for Docker
@app.get("/health")
async def health_check():
"""Health check endpoint for Docker container monitoring"""
try:
# Basic health check - verify the web service is responsive
return {
"status": "healthy",
"service": "nfoguard-web",
"timestamp": time.time(),
"version": "2.9.0-fixes-only-files"
}
except Exception as e:
from fastapi import HTTPException
raise HTTPException(status_code=503, detail=f"Health check failed: {e}")
def main():
"""Main entry point for NFOGuard Web Interface"""
print("🌐 Starting NFOGuard Web Interface...")
# Use existing config system
web_host = os.environ.get("WEB_HOST", "0.0.0.0")
web_port = int(os.environ.get("WEB_PORT", "8081"))
print(f"📊 Configuration: Port {web_port}")
# Create FastAPI app
app = create_web_app()
# Initialize database using existing system
try:
db = NFOGuardDatabase(config)
print(f"✅ Connected to database: {config.db_host}:{config.db_port}/{config.db_name}")
except Exception as e:
print(f"❌ Failed to connect to database: {e}")
sys.exit(1)
# Setup authentication if enabled
auth_enabled = getattr(config, 'web_auth_enabled', False)
session_manager = None
if auth_enabled:
session_timeout = getattr(config, 'web_auth_session_timeout', 3600)
session_manager = AuthSession(timeout_seconds=session_timeout)
print(f"🔐 Web authentication enabled (session timeout: {session_timeout}s)")
else:
print("🌐 Web authentication disabled")
# Create dependencies for dependency injection
dependencies = {
"db": db,
"config": config,
"nfo_manager": None, # Not needed for read-only web interface
"movie_processor": None, # Not needed for read-only web interface
"tv_processor": None, # Not needed for read-only web interface
"auth_enabled": auth_enabled,
"session_manager": session_manager
}
# Add authentication middleware if enabled (BEFORE routes)
if auth_enabled:
app.add_middleware(SimpleAuthMiddleware, config=config, session_manager=session_manager)
print("🔐 Authentication middleware added to web interface")
# Setup static files and routes
setup_static_files(app)
# Register web routes (now includes DELETE /api/episodes/ route)
register_web_routes(app, dependencies)
print("✅ Registered web routes with DELETE /api/episodes/ support")
print(f"🚀 Starting web server on {web_host}:{web_port}")
try:
uvicorn.run(
app,
host=web_host,
port=web_port,
workers=1,
log_level="info",
access_log=False
)
except KeyboardInterrupt:
print("\n🛑 Web interface shutdown by user")
except Exception as e:
print(f"❌ Web interface failed: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+97
View File
@@ -39,6 +39,7 @@ body {
color: white; color: white;
padding: 1rem 0; padding: 1rem 0;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
position: relative;
} }
.header-content { .header-content {
@@ -63,6 +64,45 @@ body {
font-size: 1rem; font-size: 1rem;
} }
/* Authentication Status */
.auth-status {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
gap: 1rem;
color: white;
font-size: 0.9rem;
}
.auth-user {
display: flex;
align-items: center;
gap: 0.5rem;
opacity: 0.9;
}
.auth-logout {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s ease;
}
.auth-logout:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
}
.nav-tabs { .nav-tabs {
max-width: 1200px; max-width: 1200px;
margin: 1rem auto 0; margin: 1rem auto 0;
@@ -803,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;
}
+88 -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>
@@ -15,6 +15,14 @@
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1> <h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
<p>Database Management & Reporting</p> <p>Database Management & Reporting</p>
</div> </div>
<div class="auth-status" id="auth-status" style="display: none;">
<span class="auth-user">
<i class="fas fa-user"></i> <span id="auth-username">Loading...</span>
</span>
<button class="auth-logout" id="logout-btn" onclick="logout()">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</div>
<nav class="nav-tabs"> <nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard"> <button class="nav-tab active" data-tab="dashboard">
<i class="fas fa-tachometer-alt"></i> Dashboard <i class="fas fa-tachometer-alt"></i> Dashboard
@@ -319,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>
@@ -329,6 +382,39 @@
<i class="fas fa-sync"></i> Refresh Stats <i class="fas fa-sync"></i> Refresh Stats
</button> </button>
</div> </div>
<div class="tool-card">
<h3><i class="fas fa-upload"></i> Populate Database</h3>
<p>Bulk import data from Radarr/Sonarr into NFOGuard database</p>
<form id="populate-form">
<div class="form-group">
<label>Media Type:</label>
<select id="populate-media-type" required>
<option value="both">Movies & TV Shows</option>
<option value="movies">Movies Only</option>
<option value="tv">TV Shows Only</option>
</select>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-play"></i> Start Population
</button>
</form>
<div id="populate-status" class="scan-status" style="display: none;">
<div class="scan-progress">
<div class="progress-bar">
<div class="progress-fill" id="populate-progress-bar"></div>
</div>
<div class="scan-info">
<span id="populate-current-operation">Running...</span>
<span id="populate-progress-text">In Progress</span>
</div>
</div>
<div id="populate-results" class="stats-display" style="margin-top: 10px;"></div>
<button class="btn btn-secondary btn-sm" onclick="stopPopulatePolling()">
<i class="fas fa-times"></i> Hide Status
</button>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
@@ -396,6 +482,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>
+555 -5
View File
@@ -10,6 +10,7 @@ let dashboardData = null;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initializeTabs(); initializeTabs();
initializeEventListeners(); initializeEventListeners();
checkAuthStatus(); // Check authentication status on page load
loadDashboard(); loadDashboard();
loadSeriesSources(); loadSeriesSources();
}); });
@@ -75,6 +76,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); document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan);
document.getElementById('populate-form').addEventListener('submit', handlePopulateDatabase);
} }
// API calls // API calls
@@ -502,10 +505,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>
@@ -528,7 +542,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}
@@ -666,6 +683,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);
@@ -693,7 +717,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
@@ -909,7 +936,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
@@ -1093,6 +1123,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`);
@@ -1275,6 +1312,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
@@ -1283,7 +1327,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'
@@ -1326,7 +1370,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'
@@ -1376,3 +1420,509 @@ function updateEpisodeModalCounts() {
`; `;
} }
} }
// ===========================
// Authentication Functions
// ===========================
async function checkAuthStatus() {
try {
const response = await fetch('/api/auth/status');
const authStatus = await response.json();
const authStatusDiv = document.getElementById('auth-status');
const authUsernameSpan = document.getElementById('auth-username');
if (authStatus.auth_enabled && authStatus.authenticated) {
// Show authentication status with username
authUsernameSpan.textContent = authStatus.username;
authStatusDiv.style.display = 'flex';
} else if (authStatus.auth_enabled && !authStatus.authenticated) {
// This shouldn't happen if middleware is working, but handle it
console.warn('Auth enabled but not authenticated - middleware may be misconfigured');
} else {
// Authentication disabled - hide auth status
authStatusDiv.style.display = 'none';
}
} catch (error) {
console.error('Failed to check authentication status:', error);
// Hide auth status on error
document.getElementById('auth-status').style.display = 'none';
}
}
// Manual Scan Functions
async function handleManualScan(event) {
event.preventDefault();
const scanType = document.getElementById('scan-type').value;
const scanMode = document.getElementById('scan-mode').value;
const scanPath = document.getElementById('scan-path').value.trim();
// Validate inputs
if (!scanType || !scanMode) {
showToast('❌ Please select scan type and mode', 'error');
return;
}
// Build query parameters
const params = new URLSearchParams({
scan_type: scanType,
scan_mode: scanMode
});
if (scanPath) {
params.append('path', scanPath);
}
try {
// Show scan status
showScanStatus();
// Start the scan
showToast('🚀 Starting manual scan...', 'info');
const response = await fetch(`/manual/scan?${params}`, {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.status === 'started') {
showToast('✅ Scan started successfully', 'success');
// Start polling for status
startScanPolling();
} else {
showToast(`ℹ️ ${result.message || 'Scan completed'}`, 'info');
hideScanStatus();
}
} catch (error) {
console.error('Manual scan failed:', error);
showToast(`❌ Scan failed: ${error.message}`, 'error');
hideScanStatus();
}
}
function showScanStatus() {
const scanStatus = document.getElementById('scan-status');
const progressBar = document.getElementById('scan-progress-bar');
const operationText = document.getElementById('scan-current-operation');
const progressText = document.getElementById('scan-progress-text');
// Reset and show
progressBar.style.width = '0%';
operationText.textContent = 'Initializing scan...';
progressText.textContent = '0%';
scanStatus.style.display = 'block';
}
function hideScanStatus() {
document.getElementById('scan-status').style.display = 'none';
if (window.scanPollingInterval) {
clearInterval(window.scanPollingInterval);
window.scanPollingInterval = null;
}
}
function stopScanPolling() {
hideScanStatus();
}
function startScanPolling() {
// Poll every 2 seconds for scan status
window.scanPollingInterval = setInterval(async () => {
try {
const response = await fetch('/api/scan/status');
if (!response.ok) {
throw new Error('Failed to get scan status');
}
const status = await response.json();
updateScanProgress(status);
// Stop polling if scan is complete
if (!status.scanning) {
stopScanPolling();
showToast('✅ Scan completed!', 'success');
}
} catch (error) {
console.error('Failed to poll scan status:', error);
// Don't show error toast repeatedly, just stop polling
stopScanPolling();
}
}, 2000);
}
function updateScanProgress(status) {
const progressBar = document.getElementById('scan-progress-bar');
const operationText = document.getElementById('scan-current-operation');
const progressText = document.getElementById('scan-progress-text');
if (!status.scanning) {
progressBar.style.width = '100%';
operationText.textContent = 'Scan completed';
progressText.textContent = '100%';
return;
}
// Calculate overall progress
let totalProgress = 0;
let progressDetails = '';
if (status.scan_type === 'both' || status.scan_type === 'tv') {
const tvProgress = status.tv_series_total > 0 ?
((status.tv_series_processed + status.tv_series_skipped) / status.tv_series_total) * 50 : 0;
totalProgress += tvProgress;
if (status.tv_series_total > 0) {
progressDetails += `TV: ${status.tv_series_processed + status.tv_series_skipped}/${status.tv_series_total} `;
}
}
if (status.scan_type === 'both' || status.scan_type === 'movies') {
const movieProgress = status.movies_total > 0 ?
((status.movies_processed + status.movies_skipped) / status.movies_total) * 50 : 0;
totalProgress += movieProgress;
if (status.movies_total > 0) {
progressDetails += `Movies: ${status.movies_processed + status.movies_skipped}/${status.movies_total}`;
}
}
// For single type scans, use full 100%
if (status.scan_type !== 'both') {
totalProgress *= 2;
}
// Update progress bar
progressBar.style.width = `${Math.min(totalProgress, 100)}%`;
progressText.textContent = `${Math.round(totalProgress)}%`;
// Update operation text
if (status.current_operation) {
operationText.textContent = status.current_operation;
} else if (status.current_item) {
operationText.textContent = `Processing: ${status.current_item}`;
} else {
operationText.textContent = progressDetails || 'Scanning...';
}
}
async function logout() {
if (!confirm('Are you sure you want to logout?')) {
return;
}
try {
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
if (response.ok) {
showToast('✅ Logged out successfully', 'success');
// Reload page to trigger authentication prompt
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
showToast('❌ Logout failed', 'error');
}
} catch (error) {
console.error('Logout failed:', error);
showToast('❌ Logout error', 'error');
}
}
// Bulk delete functions for TV episodes
function toggleSelectAll() {
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const episodeCheckboxes = document.querySelectorAll('.episode-checkbox');
if (selectAllCheckbox && episodeCheckboxes.length > 0) {
const shouldCheck = selectAllCheckbox.checked;
episodeCheckboxes.forEach(checkbox => {
checkbox.checked = shouldCheck;
});
updateBulkDeleteButton();
}
}
function updateBulkDeleteButton() {
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
const selectedCountSpan = document.getElementById('selected-count');
if (selectedCountSpan) {
selectedCountSpan.textContent = selectedCount;
}
if (bulkDeleteButton) {
bulkDeleteButton.disabled = selectedCount === 0;
}
// Update select all checkbox state
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const allCheckboxes = document.querySelectorAll('.episode-checkbox');
if (selectAllCheckbox && allCheckboxes.length > 0) {
selectAllCheckbox.checked = selectedCount === allCheckboxes.length;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
}
}
async function bulkDeleteSelected() {
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
if (selectedCount === 0) {
showToast('❌ No episodes selected', 'error');
return;
}
if (!confirm(`Are you sure you want to delete ${selectedCount} episode(s)? This action cannot be undone.`)) {
return;
}
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
const originalText = bulkDeleteButton.innerHTML;
bulkDeleteButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
bulkDeleteButton.disabled = true;
let successCount = 0;
let failCount = 0;
// Process deletions
for (const checkbox of selectedCheckboxes) {
const row = checkbox.closest('tr');
const imdbId = row.getAttribute('data-imdb');
const season = parseInt(row.getAttribute('data-season'));
const episode = parseInt(row.getAttribute('data-episode'));
try {
const response = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, {
method: 'DELETE'
});
if (response.success) {
// Remove the row from the table
row.remove();
successCount++;
} else {
failCount++;
console.error(`Failed to delete S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, response.message);
}
} catch (error) {
failCount++;
console.error(`Error deleting S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, error);
}
}
// Update UI
updateEpisodeModalCounts();
updateBulkDeleteButton();
// Reset button
bulkDeleteButton.innerHTML = originalText;
bulkDeleteButton.disabled = true;
// Show results
if (successCount > 0 && failCount === 0) {
showToast(`✅ Successfully deleted ${successCount} episode(s)`, 'success');
} else if (successCount > 0 && failCount > 0) {
showToast(`⚠️ Deleted ${successCount} episode(s), ${failCount} failed`, 'warning');
} else {
showToast(`❌ Failed to delete ${failCount} episode(s)`, 'error');
}
}
// ---------------------------
// Database Population Functions
// ---------------------------
async function handlePopulateDatabase(event) {
event.preventDefault();
const mediaType = document.getElementById('populate-media-type').value;
// Validate input
if (!mediaType) {
showToast('❌ Please select media type', 'error');
return;
}
// Confirm with user
const confirmMsg = `Are you sure you want to populate the database with ${mediaType}? This will query Radarr/Sonarr and may take several minutes.`;
if (!confirm(confirmMsg)) {
return;
}
try {
// Show populate status
showPopulateStatus();
// Start the population
showToast('🚀 Starting database population...', 'info');
const response = await fetch(`/admin/populate-database?media_type=${mediaType}`, {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.status === 'started') {
showToast('✅ Population started successfully', 'success');
// Start polling for status
startPopulatePolling();
} else {
showToast(`ℹ️ ${result.message || 'Population completed'}`, 'info');
hidePopulateStatus();
}
} catch (error) {
console.error('Database population failed:', error);
showToast(`❌ Population failed: ${error.message}`, 'error');
hidePopulateStatus();
}
}
function showPopulateStatus() {
const populateStatus = document.getElementById('populate-status');
const progressBar = document.getElementById('populate-progress-bar');
const operationText = document.getElementById('populate-current-operation');
const progressText = document.getElementById('populate-progress-text');
const resultsDiv = document.getElementById('populate-results');
populateStatus.style.display = 'block';
progressBar.style.width = '0%';
operationText.textContent = 'Initializing...';
progressText.textContent = '0%';
resultsDiv.innerHTML = '';
}
function hidePopulateStatus() {
const populateStatus = document.getElementById('populate-status');
if (populateStatus) {
populateStatus.style.display = 'none';
}
}
function startPopulatePolling() {
// Poll every 2 seconds for populate status
window.populatePollingInterval = setInterval(async () => {
try {
const response = await fetch('/api/populate/status');
if (!response.ok) {
throw new Error('Failed to get populate status');
}
const status = await response.json();
updatePopulateProgress(status);
// Stop polling if population is complete
if (!status.running && status.completed) {
stopPopulatePolling();
showToast('✅ Database population completed!', 'success');
}
} catch (error) {
console.error('Failed to poll populate status:', error);
// Don't show error toast repeatedly, just stop polling
stopPopulatePolling();
}
}, 2000);
}
function stopPopulatePolling() {
if (window.populatePollingInterval) {
clearInterval(window.populatePollingInterval);
window.populatePollingInterval = null;
}
}
function updatePopulateProgress(status) {
const progressBar = document.getElementById('populate-progress-bar');
const operationText = document.getElementById('populate-current-operation');
const progressText = document.getElementById('populate-progress-text');
const resultsDiv = document.getElementById('populate-results');
if (status.error) {
progressBar.style.width = '100%';
progressBar.style.backgroundColor = '#e74c3c';
operationText.textContent = 'Error occurred';
progressText.textContent = 'Failed';
resultsDiv.innerHTML = `<p style="color: #e74c3c;">Error: ${status.error}</p>`;
return;
}
if (!status.running && status.completed) {
progressBar.style.width = '100%';
operationText.textContent = 'Population completed';
progressText.textContent = '100%';
// Display results
let resultsHTML = '<h4>Population Results:</h4>';
if (status.movies && status.movies.stats) {
const m = status.movies.stats;
resultsHTML += `
<div style="margin: 10px 0;">
<strong>Movies:</strong><br>
Total: ${m.total || 0} | Added: ${m.added || 0} | Skipped: ${m.skipped || 0} | Errors: ${m.errors || 0}<br>
Duration: ${m.duration ? m.duration.toFixed(2) : 0}s
</div>
`;
}
if (status.tv && status.tv.stats) {
const t = status.tv.stats;
resultsHTML += `
<div style="margin: 10px 0;">
<strong>TV Shows:</strong><br>
Series: ${t.total_series || 0} | Episodes: ${t.total_episodes || 0}<br>
Added: ${t.added || 0} | Skipped: ${t.skipped || 0} | Errors: ${t.errors || 0}<br>
Duration: ${t.duration ? t.duration.toFixed(2) : 0}s
</div>
`;
}
resultsDiv.innerHTML = resultsHTML;
return;
}
// Update progress based on status
let totalProgress = 0;
let progressDetails = '';
if (status.movies && status.movies.status === 'running') {
totalProgress = 50;
progressDetails = 'Processing movies...';
} else if (status.movies && status.movies.status === 'completed') {
totalProgress = 50;
progressDetails = 'Movies completed, processing TV...';
}
if (status.tv && status.tv.status === 'running') {
totalProgress = 75;
progressDetails = 'Processing TV shows...';
} else if (status.tv && status.tv.status === 'completed') {
totalProgress = 100;
progressDetails = 'Completed';
}
progressBar.style.width = `${totalProgress}%`;
operationText.textContent = progressDetails;
progressText.textContent = `${totalProgress}%`;
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
IMDb ID Extraction Utilities
Parses IMDb IDs from directory/file names (no file I/O)
Phase 3: Replaces NFOManager for IMDb ID extraction only
"""
import re
from pathlib import Path
from typing import Optional
def parse_imdb_from_path(path: Path) -> Optional[str]:
"""
Extract IMDb ID from directory path or filename using regex patterns.
Does NOT read any files - only parses the path string.
Supported patterns:
- [imdb-tt1234567]
- [tt1234567]
- {imdb-tt1234567}
- (imdb-tt1234567)
- -tt1234567 (at end)
- _tt1234567 (at end)
Args:
path: Path object to parse
Returns:
IMDb ID (e.g., "tt1234567") or None if not found
"""
path_str = str(path).lower()
# Try [imdb-ttXXXXXXX] format first (most explicit)
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
if match:
return match.group(1)
# Try standalone [ttXXXXXXX] format in brackets
match = re.search(r'\[(tt\d+)\]', path_str)
if match:
return match.group(1)
# Try {imdb-ttXXXXXXX} format with curly braces
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
if match:
return match.group(1)
# Try (imdb-ttXXXXXXX) format with parentheses
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
if match:
return match.group(1)
# Try ttXXXXXXX at end of filename/dirname (common pattern)
match = re.search(r'[-_\s](tt\d+)$', path_str)
if match:
return match.group(1)
return None
def find_imdb_in_directory(directory: Path) -> Optional[str]:
"""
Find IMDb ID from directory name or filenames within the directory.
Does NOT read file contents - only checks filenames.
Args:
directory: Directory path to search
Returns:
IMDb ID or None if not found
"""
# First try directory name itself
imdb_id = parse_imdb_from_path(directory)
if imdb_id:
return imdb_id
# Try all filenames in the directory
if directory.is_dir():
for file_path in directory.iterdir():
if file_path.is_file():
imdb_id = parse_imdb_from_path(file_path)
if imdb_id:
return imdb_id
return None
+75 -1
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,13 @@ 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 to set up file logging
file_logging_enabled = False
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 +108,31 @@ def _setup_file_logging():
) )
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
logger.addHandler(file_handler) logger.addHandler(file_handler)
file_logging_enabled = True
except Exception as e:
# If RotatingFileHandler fails, try regular FileHandler
try:
file_handler = logging.FileHandler(log_dir / "nfoguard.log")
formatter = TimezoneAwareFormatter(
'[%(asctime)s] %(levelname)s: %(message)s'
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
file_logging_enabled = True
except Exception as e2:
# File logging not available (e.g., read-only filesystem)
# Fall back to console-only logging silently
pass
# If file logging failed, ensure console handler is added
if not file_logging_enabled:
console_handler = logging.StreamHandler()
formatter = TimezoneAwareFormatter(
'[%(asctime)s] %(levelname)s: %(message)s'
)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger return logger
+43 -24
View File
@@ -3,18 +3,21 @@ 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
from config.settings import config from config.settings import config
from utils.logging import _log from utils.logging import _log
from utils.imdb_utils import find_imdb_in_directory, parse_imdb_from_path # Phase 3: Replaced NFOManager
class WebhookBatcher: class WebhookBatcher:
"""Batches webhook events to avoid processing storms""" """Batches webhook events to avoid processing storms"""
def __init__(self, nfo_manager=None): def __init__(self, nfo_manager=None):
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
self.pending: Dict[str, Dict] = {} self.pending: Dict[str, Dict] = {}
self.timers: Dict[str, threading.Timer] = {} self.timers: Dict[str, threading.Timer] = {}
self.processing: Set[str] = set() self.processing: Set[str] = set()
@@ -23,8 +26,6 @@ class WebhookBatcher:
# Will be set by the application when processors are available # Will be set by the application when processors are available
self.tv_processor = None self.tv_processor = None
self.movie_processor = None self.movie_processor = None
# NFO manager for comprehensive IMDb detection
self.nfo_manager = nfo_manager
def set_processors(self, tv_processor, movie_processor): def set_processors(self, tv_processor, movie_processor):
"""Set the processor instances""" """Set the processor instances"""
@@ -84,9 +85,8 @@ class WebhookBatcher:
if media_type == 'movie': if media_type == 'movie':
expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key
# Use comprehensive IMDb detection (directory, filenames, NFO files) # Use imdb_utils for IMDb detection (Phase 3: no NFO reading)
if self.nfo_manager: detected_imdb = find_imdb_in_directory(path_obj)
detected_imdb = self.nfo_manager.find_movie_imdb_id(path_obj)
imdb_match = False imdb_match = False
if detected_imdb: if detected_imdb:
# Compare with and without 'tt' prefix for flexibility # Compare with and without 'tt' prefix for flexibility
@@ -94,26 +94,18 @@ class WebhookBatcher:
imdb_match = True imdb_match = True
if not imdb_match: if not imdb_match:
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in path {path_str}") _log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} but found {detected_imdb} in {path_str}")
_log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}") _log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}")
_log("ERROR", f"This prevents processing wrong movies due to batch corruption") _log("ERROR", f"This prevents processing wrong movies due to batch corruption")
return return
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}") _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
else:
# Fallback to simple string search if nfo_manager not available
if expected_imdb not in path_str.lower():
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str} (fallback mode)")
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
return
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
# CRITICAL: Validate that the path contains the expected IMDb ID for TV shows # CRITICAL: Validate that the path contains the expected IMDb ID for TV shows
if media_type == 'tv': if media_type == 'tv':
expected_imdb = key.replace('tv:', '') if key.startswith('tv:') else key expected_imdb = key.replace('tv:', '') if key.startswith('tv:') else key
# Use comprehensive IMDb detection (directory, filenames, tvshow.nfo files) # Use imdb_utils for IMDb detection (Phase 3: no NFO reading)
if self.nfo_manager: detected_imdb = parse_imdb_from_path(path_obj)
detected_imdb = self.nfo_manager.find_series_imdb_id(path_obj)
imdb_match = False imdb_match = False
if detected_imdb: if detected_imdb:
# Compare with and without 'tt' prefix for flexibility # Compare with and without 'tt' prefix for flexibility
@@ -121,18 +113,11 @@ class WebhookBatcher:
imdb_match = True imdb_match = True
if not imdb_match: if not imdb_match:
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in TV path {path_str}") _log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} but found {detected_imdb} in TV {path_str}")
_log("ERROR", f"Detected TV IMDb: {detected_imdb}, Expected: {expected_imdb}") _log("ERROR", f"Detected TV IMDb: {detected_imdb}, Expected: {expected_imdb}")
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption") _log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
return return
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}") _log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
else:
# Fallback to simple string search if nfo_manager not available
if expected_imdb not in path_str.lower():
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in TV path {path_str} (fallback mode)")
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
return
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
if not self.tv_processor: if not self.tv_processor:
_log("ERROR", "TV processor not available") _log("ERROR", "TV processor not available")
@@ -144,6 +129,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 +155,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:
@@ -196,7 +210,12 @@ class WebhookBatcher:
# Shutdown the thread pool executor # Shutdown the thread pool executor
try: try:
# Use timeout parameter only if supported (Python 3.9+)
import sys
if sys.version_info >= (3, 9):
self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds
else:
self.executor.shutdown(wait=True) # No timeout for older Python versions
_log("INFO", "Thread pool executor shut down successfully") _log("INFO", "Thread pool executor shut down successfully")
except Exception as e: except Exception as e:
_log("WARNING", f"Error shutting down thread pool: {e}") _log("WARNING", f"Error shutting down thread pool: {e}")