Compare commits

..

1 Commits

Author SHA1 Message Date
TylerCG
da19d5e4b9 used ai for some mods 2025-12-24 12:01:40 -05:00
41 changed files with 1354 additions and 74217 deletions

View File

@ -1,406 +0,0 @@
{
"P:\\anime\\The Greatest Demon Lord Is Reborn as a Typical Nobody": 5968497118,
"P:\\anime\\So, I Can't Play H!": 2528562389,
"P:\\anime\\The Ossan Newbie Adventurer, Trained to Death by the Most Powerful Party, Became Invincible": 4634410199,
"P:\\anime\\Kaifuku Jutsushi no Yarinaoshi": 3384572882,
"P:\\anime\\Gleipnir": 6112213174,
"P:\\anime\\Banished from the Hero's Party, I Decided to Live a Quiet Life in the Countryside (2021)": 10648931598,
"P:\\anime\\I'm Standing on a Million Lives": 8789518857,
"P:\\anime\\Lv1 Maou to One Room Yuusha": 2352794776,
"P:\\anime\\The 8th son! Are you kidding me! (2020)": 2984369205,
"P:\\anime\\Good Bye, Dragon Life (2024)": 3039400658,
"P:\\anime\\Shinchou Yuusha": 7936747223,
"P:\\anime\\Battle Game in 5 Seconds": 5594736882,
"P:\\anime\\Infinite Dendrogram": 13754309450,
"P:\\anime\\Magical Senpai": 3401214483,
"P:\\anime\\Noumin Kanren no Skill bakka Agetetara Nazeka Tsuyoku Natta": 3858206539,
"P:\\anime\\No Game No Life": 2517488069,
"P:\\anime\\Keikenzumi na Kimi to": 8117430342,
"P:\\anime\\Broken Blade": 3859920179,
"P:\\anime\\Gachiakuta (2025)": 10697119170,
"P:\\anime\\Princess Connect": 12937047999,
"P:\\anime\\ReLife": 1565479993,
"P:\\anime\\Shinobi no Ittoki (2022)": 3896251319,
"P:\\anime\\World Break - Aria of Curse for a Holy Swordsman (2015)": 5244615290,
"P:\\anime\\Chillin' in Another World with Level 2 Super Cheat Powers": 4646756870,
"P:\\anime\\Vinland Saga (2019)": 4723680531,
"P:\\anime\\Bucchigiri!!": 3895924967,
"P:\\anime\\Kyoukai Senki": 6697938339,
"P:\\anime\\Shachou Battle No Jikan Desu": 7376322052,
"P:\\anime\\The Foolish Angel Dances With the Devil": 4048585416,
"P:\\anime\\Bokutachi no Remake": 1499113600,
"P:\\anime\\Handyman Saitou in Another World": 3817097529,
"P:\\anime\\Welcome to Demon School! Iruma-kun (2019)": 26889513042,
"P:\\anime\\Alya Sometimes Hides Her Feelings in Russian": 4232637768,
"P:\\anime\\Dead Mount Death Play (2023)": 9605188624,
"P:\\anime\\Demon Slayer - Kimetsu no Yaiba (2019)": 21376385353,
"P:\\anime\\Kaiju No. 8": 8770165001,
"P:\\anime\\DNAngel": 6235958953,
"P:\\anime\\Adam's Sweet Agony": 71,
"P:\\anime\\Leadale no Daichi nite": 1473622878,
"P:\\anime\\Isekai de Cheat Skill": 4311581943,
"P:\\anime\\Val x Love (2019)": 634008306,
"P:\\anime\\SHOSHIMIN - How to Become Ordinary": 21152251460,
"P:\\anime\\UchiMusume": 6085582077,
"P:\\anime\\Kino no Tabi": 3227343894,
"P:\\anime\\Shokei Shoujo no Virgin Road": 2660059361,
"P:\\anime\\Arknights": 5905302117,
"P:\\anime\\Don't Toy With Me Miss Nagatoro": 4485844086,
"P:\\anime\\Death March": 2905475121,
"P:\\anime\\Ascendance of a Bookworm": 5407349860,
"P:\\anime\\Loner Life in Another World": 25508136936,
"P:\\anime\\Blast of Tempest": 6315813655,
"P:\\anime\\The Eminence in Shadow": 12848813083,
"P:\\anime\\I'm Living with an Otaku NEET Kunoichi!! (2025)": 6529531138,
"P:\\anime\\Rent a girlfriend": 5319970861,
"P:\\anime\\B The Beginning": 9274520504,
"P:\\anime\\FLCL": 3233214173,
"P:\\anime\\Demon Lord 2099 (2024)": 4298604298,
"P:\\anime\\Fairy Gone": 5688255513,
"P:\\anime\\Fantasy \u00d7 Hunter (2021)": 3990876164,
"P:\\anime\\Outbreak Company": 6048060793,
"P:\\anime\\4 Cut Hero": 4259631497,
"P:\\anime\\Peach Boy Riverside": 1507820436,
"P:\\anime\\Lazarus (2025)": 5916959141,
"P:\\anime\\Ragna Crimson": 9249575950,
"P:\\anime\\Jujutsu Kaisen": 20957528147,
"P:\\anime\\Harem in the Labyrinth of Another World": 2871442057,
"P:\\anime\\Sekirei (2008)": 9623765425,
"P:\\anime\\Reign of the Seven Spellblades": 4104496247,
"P:\\anime\\By the Grace of the Gods": 5345602025,
"P:\\anime\\Captain Earth": 5810590243,
"P:\\anime\\Air Gear (2006)": 8918228002,
"P:\\anime\\My Instant Death Ability Is So Overpowered, No One in This Other World Stands a Chance Against Me!": 4454494342,
"P:\\anime\\Maburaho": 9553356823,
"P:\\anime\\One Pace": 5154953305,
"P:\\anime\\Love Tyrant": 2852328170,
"P:\\anime\\Sword Art Online (2012)": 42953654496,
"P:\\anime\\My Hero Academia": 91570180658,
"P:\\anime\\SAKAMOTO DAYS (2025)": 12135764735,
"P:\\anime\\No Guns Life (2019)": 12216429324,
"P:\\anime\\Undead Unluck": 8811674477,
"P:\\anime\\Guilty Crown": 6872382355,
"P:\\anime\\Radiant (2018)": 9278741705,
"P:\\anime\\Tokyo Revengers": 9193065093,
"P:\\anime\\How Not to Summon a Demon Lord": 6700089914,
"P:\\anime\\The Demon Sword Master of Excalibur Academy": 3453095058,
"P:\\anime\\Meikyuu Black Company": 2658074731,
"P:\\anime\\Toradora!": 6558115868,
"P:\\anime\\Maken-Ki! Battling Venus (2011)": 3500680574,
"P:\\anime\\Why Does Nobody Remember Me in This World! (2024)": 4152176021,
"P:\\anime\\YU-NO - A Girl Who Chants Love at the Bound of This World (2019)": 6254598586,
"P:\\anime\\Heroic Age": 7205431055,
"P:\\anime\\Solo Leveling": 16655700087,
"P:\\anime\\Cop Craft": 1913887249,
"P:\\anime\\Urusei Yatsura (2022)": 7969472192,
"P:\\anime\\Deepy Insanity The Lost Child": 8187291543,
"P:\\anime\\I'm the Evil Lord of an Intergalactic Empire! (2025)": 4135726123,
"P:\\anime\\King's Raid": 9278771931,
"P:\\anime\\Robotics;Notes": 5662173084,
"P:\\anime\\Kemono Jihen": 7461581399,
"P:\\anime\\Noragami": 7371770717,
"P:\\anime\\Food Wars": 39136542103,
"P:\\anime\\Kemono Michi Rise Up (2019)": 7529875102,
"P:\\anime\\Akudama Drive": 7276183519,
"P:\\anime\\Heavenly Delusion": 4930379667,
"P:\\anime\\High School of the Dead": 6051229299,
"P:\\anime\\Hero Without a Class - Who Even Needs Skills!! (2025)": 11550176573,
"P:\\anime\\Tears to Tiara": 6916843507,
"P:\\anime\\Mobile Suit Gundam - Iron-Blooded Orphans": 22688263314,
"P:\\anime\\Murenase! Seton Gakuen": 8774144557,
"P:\\anime\\The Brilliant Healer's New Life in the Shadows": 4196047668,
"P:\\anime\\The Kingdoms of Ruin": 4953422715,
"P:\\anime\\The Great Jahy Will Not Be Defeated! (2021)": 7300409701,
"P:\\anime\\D.Gray-man": 4272945740,
"P:\\anime\\Ya Boy Kongming! (2022)": 2371722402,
"P:\\anime\\Grendizer U": 8793468670,
"P:\\anime\\ReZERO -Starting Life in Another World": 34808814228,
"P:\\anime\\Taboo Tattoo": 3570322719,
"P:\\anime\\KamiKatsu - Working for God in a Godless World (2023)": 4033372211,
"P:\\anime\\Boogiepop Phantom": 3775812052,
"P:\\anime\\Hamatora": 6142541816,
"P:\\anime\\A Boring World Where the Concept of Dirty Jokes Doesn't Exist": 4384721870,
"P:\\anime\\Tribe Nine": 7128583972,
"P:\\anime\\Build Divide Code Black": 7356540221,
"P:\\anime\\I'm Quitting Heroing (2022)": 2641278879,
"P:\\anime\\THE NEW GATE (2024)": 2272505431,
"P:\\anime\\I Was Reincarnated as the 7th Prince so I Can Take My Time Perfecting My Magical Ability (2024)": 13202089851,
"P:\\anime\\Sword Art Online Alternative - Gun Gale Online": 3803411762,
"P:\\anime\\SANDA (2025)": 6601681293,
"P:\\anime\\Dolls' Frontline": 7376834741,
"P:\\anime\\Casshern Sins (2008)": 9460351881,
"P:\\anime\\Revenger": 2130039433,
"P:\\anime\\The Great Cleric": 3320612535,
"P:\\anime\\Spice and Wolf - MERCHANT MEETS THE WISE WOLF": 8173191986,
"P:\\anime\\Villainess Level 99 - I May Be the Hidden Boss but I'm Not the Demon Lord (2024)": 3561222609,
"P:\\anime\\Muv-Luv Alternative": 13357929759,
"P:\\anime\\And You Thought There Is Never a Girl Online": 4452676547,
"P:\\anime\\Grisaia no Kajitsu": 5151626219,
"P:\\anime\\Yandere Dark Elf - She Chased Me All the Way From Another World (2025)": 6378005867,
"P:\\anime\\Tatoeba Last Dungeon": 4836583236,
"P:\\anime\\ZENSHU (2025)": 15428885323,
"P:\\anime\\Shikizakura": 2018217197,
"P:\\anime\\Magical Warfare": 3590141409,
"P:\\anime\\The Most Notorious Talker Runs the World's Greatest Clan": 15191321279,
"P:\\anime\\Tales of Wedding Rings": 20310093214,
"P:\\anime\\One-Punch Man (2015)": 21480447907,
"P:\\anime\\Let This Grieving Soul Retire!": 24270650972,
"P:\\anime\\DEAD DEAD DEMONS DEDEDEDE DESTRUCTION": 6024955773,
"P:\\anime\\Brave Bang Bravern!": 5660773541,
"P:\\anime\\No Longer Allowed in Another World": 3507605900,
"P:\\anime\\Black Lagoon": 3530474230,
"P:\\anime\\Lapis ReLights": 4027545928,
"P:\\anime\\Rokka Braves of the Six Flowers": 5477020823,
"P:\\anime\\That Time I Got Reincarnated as a Slime (2018)": 21976147633,
"P:\\anime\\Your Lie in April (2014)": 3698563140,
"P:\\anime\\Amagi Brilliant Park": 2905611585,
"P:\\anime\\Ore dake Haireru Kakushi Dungeon": 4974018022,
"P:\\anime\\Overlord": 19235478562,
"P:\\anime\\Nozo X Kimi": 63,
"P:\\anime\\Life With an Ordinary Guy Who Reincarnated Into a Total Fantasy Knockout (2022)": 7944272179,
"P:\\anime\\Am I Actually the Strongest": 4272833723,
"P:\\anime\\Btooom!": 3101208509,
"P:\\anime\\Trigun Stampede": 13265836559,
"P:\\anime\\Overflow": 1602757167,
"P:\\anime\\Zom 100 - Bucket List of the Dead (2023)": 4941498609,
"P:\\anime\\Dusk Beyond the End of the World (2025)": 7418826849,
"P:\\anime\\Somali to Mori no Kamisama": 1461553114,
"P:\\anime\\Armed Girl's Machiavellism": 5134830058,
"P:\\anime\\Kaijin Kaihatsu-bu no Kuroitsu-san": 8782789658,
"P:\\anime\\Beheneko - The Elf-Girl's Cat Is Secretly an S-Ranked Monster! (2025)": 16419141963,
"P:\\anime\\Tatsuki Fujimoto 17-26 (2025)": 2165081620,
"P:\\anime\\I Couldn't Become a Hero, so I Reluctantly Decided To Get a Job (2013)": 3099289383,
"P:\\anime\\Etotama": 3578916471,
"P:\\anime\\Ranking of Kings": 14621466110,
"P:\\anime\\Divine Gate (2016)": 5316940542,
"P:\\anime\\Chained Soldier": 5342727117,
"P:\\anime\\Mashle": 17394820927,
"P:\\anime\\Frieren - Beyond Journey's End": 11535858054,
"P:\\anime\\Sakugan": 7367853913,
"P:\\anime\\Summer Time Render (2022)": 12201595066,
"P:\\anime\\Is It Wrong to Try to Pick Up Girls in a Dungeon": 14990233709,
"P:\\anime\\The Strongest Magician in the Demon Lord's Army was a Human": 2371668942,
"P:\\anime\\Hokkaido Gals Are Super Adorable!": 3798126702,
"P:\\anime\\Tsugumomo": 3588842344,
"P:\\anime\\Koi wa Sekai Seifuku no Ato de": 8842484921,
"P:\\anime\\Pseudo Harem": 11144999080,
"P:\\anime\\Rascal Does Not Dream of Bunny Girl Senpai (2018)": 22453345353,
"P:\\anime\\Dr.Stone": 8061461697,
"P:\\anime\\Kyuukyoku Shinka Shita Full Dive RPG ga Genjitsu yori mo Kusogee Dattara": 2946703318,
"P:\\anime\\The Daily Life of the Immortal King": 16185152763,
"P:\\anime\\Medaka Box": 7415352854,
"P:\\anime\\ID Invaded": 3399670994,
"P:\\anime\\Arifureta - From Commonplace to World's Strongest (2019)": 25705179606,
"P:\\anime\\So I'm a Spider, So What\uf025": 8812944012,
"P:\\anime\\Mobile Suit Gundam GQuuuuuuX (2025)": 6651663995,
"P:\\anime\\Kyokou Suiri": 8904306316,
"P:\\anime\\Punch Line": 3182400207,
"P:\\anime\\Spice and Wolf (2008)": 19280134842,
"P:\\anime\\Giant Beasts of Ars (2023)": 5083927761,
"P:\\anime\\Xam'd - Lost Memories (2008)": 5865586067,
"P:\\anime\\Fantasia Sango - Realm of Legends": 4577794096,
"P:\\anime\\The Devil Is a Part-Timer! (2013)": 12904787079,
"P:\\anime\\I'm the Villainess, So I'm Taming the Final Boss": 8846571865,
"P:\\anime\\The Faraway Paladin": 8109669375,
"P:\\anime\\2.5 Dimensional Seduction (2024)": 25882183452,
"P:\\anime\\Hortensia Saga": 7471222614,
"P:\\anime\\Yoasobi Gurashi! (2024)": 59,
"P:\\anime\\PLUTO (2023)": 10080474308,
"P:\\anime\\Code Geass - Roz\u00e9 of the Recapture": 12458298795,
"P:\\anime\\Sabikui Bisco": 8845023203,
"P:\\anime\\Dimension W": 6030623909,
"P:\\anime\\My One-Hit Kill Sister": 3952345545,
"P:\\anime\\Nura Rise of the Yokai Clan (2010)": 6880946329,
"P:\\anime\\Wandering Witch - The Journey of Elaina (2020)": 4009196941,
"P:\\anime\\Our Last Crusade or the Rise of a New World (2020)": 22376200276,
"P:\\anime\\Chillin' in My 30s after Getting Fired from the Demon King's Army (2023)": 8923284328,
"P:\\anime\\The Legend of the Legendary Heroes": 3321747819,
"P:\\anime\\Viral Hit (2024)": 4222663705,
"P:\\anime\\My Isekai Life (2023)": 5128126734,
"P:\\anime\\Danganronpa": 20640550319,
"P:\\anime\\The Apothecary Diaries (2023)": 13984546814,
"P:\\anime\\Do You Love Your Mom and Her Two-Hit Multi-Target Attacks! (2019)": 7458697426,
"P:\\anime\\Tondemo Skill de Isekai Hourou Meshi": 3743859331,
"P:\\anime\\The Unwanted Undead Adventurer": 3551145665,
"P:\\anime\\Horimiya": 4883874524,
"P:\\anime\\Majutsushi Orphen Hagure Tabi": 8043422997,
"P:\\anime\\Mahouka Koukou no Rettousei": 12595036509,
"P:\\anime\\Classroom of the elite": 4317927091,
"P:\\anime\\'Tis Time for Torture, Princess": 3456434199,
"P:\\anime\\To be Hero X (2025)": 9231210440,
"P:\\anime\\Enen no Shouboutai": 17624413412,
"P:\\anime\\New Saga (2025)": 3640546484,
"P:\\anime\\Triage X": 2888953825,
"P:\\anime\\The Fable": 6468034435,
"P:\\anime\\Good Night World": 6328301164,
"P:\\anime\\As a Reincarnated Aristocrat, I'll Use My Appraisal Skill To Rise in the World": 7971179978,
"P:\\anime\\Strike the Blood": 16209826871,
"P:\\anime\\Bakemonogatari": 3058292879,
"P:\\anime\\The Quintessential Quintuplets": 9307789725,
"P:\\anime\\Record of Ragnarok": 4724846924,
"P:\\anime\\The Case Study of Vanitas (2021)": 23448375312,
"P:\\anime\\Seven Mortal Sins": 6941414132,
"P:\\anime\\SPY x FAMILY (2022)": 32564008225,
"P:\\anime\\Fumetsu no Anata e": 30402249614,
"P:\\anime\\Edens Zero (2021)": 24008630035,
"P:\\anime\\Tokyo Majin": 8240109758,
"P:\\anime\\Saga of Tanya the Evil (2017)": 5356263633,
"P:\\anime\\Isekai Cheat Magician": 6956708991,
"P:\\anime\\Devils and Realist": 9782071975,
"P:\\anime\\Hero Return": 3795654831,
"P:\\anime\\Log Horizon": 30428201748,
"P:\\anime\\Akame ga Kill!": 9281445041,
"P:\\anime\\Dog & Scissors": 4070030221,
"P:\\anime\\Chainsaw Man (2022)": 3962550947,
"P:\\anime\\Toaru Kagaku no Accelerator": 4470774794,
"P:\\anime\\The Aristocrat\u2019s Otherworldly Adventure - Serving Gods Who Go Too Far (2023)": 14415952754,
"P:\\anime\\The Misfit of Demon King Academy": 11880070022,
"P:\\anime\\Chobits": 3750727774,
"P:\\anime\\Devil May Cry (2025)": 2598556744,
"P:\\anime\\Bloodivores": 1944037383,
"P:\\anime\\Plunderer": 11316610908,
"P:\\anime\\Steins;Gate": 4725618067,
"P:\\anime\\Shomin Sample": 8260184285,
"P:\\anime\\Oshi No Ko": 3823754198,
"P:\\anime\\Summoned to Another World for a Second Time (2023)": 8794846955,
"P:\\anime\\KonoSuba": 24362870774,
"P:\\anime\\Deadman Wonderland": 2631932266,
"P:\\anime\\Sword of the Demon Hunter - Kijin Gentosho (2025)": 25576883003,
"P:\\anime\\Call of the Night (2022)": 2104316829,
"P:\\anime\\Chaos Dragon (2015)": 8975083681,
"P:\\anime\\Sono Bisque Doll wa Koi o Suru": 4778669831,
"P:\\anime\\My Life as Inukai-san's Dog (2023)": 3968549174,
"P:\\anime\\Tensai Ouji no Akaji Kokka Saisei Jutsu": 7768516948,
"P:\\anime\\Mr. Villain's Day Off": 5828680585,
"P:\\anime\\My Girlfriend is Shobitch": 4575335545,
"P:\\anime\\Junketsu no Maria (Maria the Virgin Witch)": 3102318111,
"P:\\anime\\I Left my A-Rank Party to Help My Former Students Reach the Dungeon Depths!": 8709504239,
"P:\\anime\\Sunday Without God": 4038886818,
"P:\\anime\\Tate no Yuusha no Nariagari": 24178141736,
"P:\\anime\\You are Ms. Servant (2024)": 4971082404,
"P:\\anime\\Orient": 7905080784,
"P:\\anime\\Joran - The Princess of Snow and Blood (2021)": 4285203115,
"P:\\anime\\BOFURI I Don't Want to Get Hurt, so I'll Max Out My Defense. (2020)": 3933320248,
"P:\\anime\\Girls Bravo": 5883170984,
"P:\\anime\\Tantei wa Mou, Shindeiru": 1474340872,
"P:\\anime\\Black Summoner": 4567460661,
"P:\\anime\\Wistoria - Wand and Sword (2024)": 6857088069,
"P:\\anime\\Assassins Pride": 4073372515,
"P:\\anime\\GATE": 6973714236,
"P:\\anime\\Hunter x Hunter (2011)": 28776368971,
"P:\\anime\\WITCH WATCH (2025)": 36023680385,
"P:\\anime\\VanDread (2000)": 7637961912,
"P:\\anime\\Gibiate": 8871671340,
"P:\\anime\\Tojima Tanzaburo Wants to Be a Kamen Rider": 17458489042,
"P:\\anime\\Chiikawa": 201629499,
"P:\\anime\\Skeleton Knight in Another World": 4720673665,
"P:\\anime\\Welcome to the N.H.K. (2006)": 2905064932,
"P:\\anime\\Seirei Gensouki": 12072776481,
"P:\\anime\\Even Given the Worthless Appraiser Class, I'm Actually the Strongest (2025)": 3877147709,
"P:\\anime\\Seraph of the End": 8836558304,
"P:\\anime\\86 - Eighty Six (2021)": 11346777283,
"P:\\anime\\I May Be a Guild Receptionist, But I'll Solo Any Boss to Clock Out on Time (2025)": 3401364461,
"P:\\anime\\That Time I Got Reincarnated as a Slime (2018": 39885493376,
"P:\\anime\\Magi Adventure of Sinbad": 4379068780,
"P:\\anime\\Gosick": 7565536865,
"P:\\anime\\Lord Marksman and Vanadis": 3650176287,
"P:\\anime\\Aesthetica of a Rogue Hero": 3926285815,
"P:\\anime\\The Wrong Way To Use Healing Magic (2024)": 4887942602,
"P:\\anime\\Undead Girl Murder Farce": 1425415656,
"P:\\anime\\World's End Harem (2021)": 3758753966,
"P:\\anime\\The Healer who Was Banished From His Party, Is, In Fact, The Strongest": 3180656302,
"P:\\anime\\Great Pretender": 6791244533,
"P:\\anime\\Bye Bye, Earth (2024)": 6901980665,
"P:\\anime\\To LOVE-Ru (2008)": 24039134215,
"P:\\anime\\My Senpai is Annoying": 2636570562,
"P:\\anime\\Machikado Mazoku": 2372896297,
"P:\\anime\\Tsukimichi - Moonlit Fantasy (2021)": 6355047942,
"P:\\anime\\Beyond the Boundary (2013)": 10548084129,
"P:\\anime\\The God of High School": 5246905321,
"P:\\anime\\Suicide Squad Isekai (2024)": 9987227506,
"P:\\anime\\\u00dcbel Blatt (2025)": 4818553182,
"P:\\anime\\Fractale": 2349352593,
"P:\\anime\\Rosario + Vampire (2008)": 10733216754,
"P:\\anime\\THE RED RANGER Becomes an Adventurer in Another World (2025)": 4815193468,
"P:\\anime\\Wise Man's Grandchild (2019)": 5251808321,
"P:\\anime\\Liar Liar (2023)": 3742043301,
"P:\\anime\\Listeners": 7623419250,
"P:\\anime\\Tokyo Ghoul": 3007724887,
"P:\\anime\\Ninja Kamui": 5301196868,
"P:\\anime\\Apocalypse Hotel (2025)": 7672503907,
"P:\\anime\\Megami-ryou no Ryoubo-kun": 1113458183,
"P:\\anime\\Demon King Daimao": 3779405140,
"P:\\anime\\Shikkakumon no Saikyou Kenja": 12316965814,
"P:\\anime\\The Dawn of the Witch (2022)": 5090411078,
"P:\\anime\\An Archdemon's Dilemma - How To Love Your Elf Bride": 4148806570,
"P:\\anime\\Tower of God": 4300739979,
"P:\\anime\\Prison School": 5023091161,
"P:\\anime\\Domestic Girlfriend": 4526369861,
"P:\\anime\\I Parry Everything": 4757235421,
"P:\\anime\\Classroom for Heroes": 4024333345,
"P:\\anime\\My Hero Academia - Vigilantes (2025)": 6139355726,
"P:\\anime\\The Iceblade Sorcerer Shall Rule the World": 4957596749,
"P:\\anime\\A Terrified Teacher at Ghoul School!": 7877236936,
"P:\\anime\\Deca-Dence": 5223908805,
"P:\\anime\\Otomege Sekai wa Mob ni Kibishii Sekai desu": 4212433019,
"P:\\anime\\Hai to Gensou no Grimgar": 2769261873,
"P:\\anime\\Black Clover": 9257210643,
"P:\\anime\\The World's Finest Assassin Gets Reincarnated in Another World as an Aristocrat (2021)": 13724318948,
"P:\\anime\\Mob Psycho 100": 15587250285,
"P:\\anime\\Mecha-Ude - Mechanical Arms": 7833270141,
"P:\\anime\\Moonrise (2025)": 7415752085,
"P:\\anime\\Blood Lad (2013)": 3963930670,
"P:\\anime\\From Old Country Bumpkin to Master Swordsman (2025)": 5043093837,
"P:\\anime\\Cowboy Bebop": 10008334150,
"P:\\anime\\Toilet-Bound Hanako-kun (2020)": 7614925159,
"P:\\anime\\The Familiar of Zero": 11204697509,
"P:\\anime\\Servamp (2016)": 3825335418,
"P:\\anime\\Mushoku Tensei - Jobless Reincarnation (2021)": 28463561868,
"P:\\anime\\Masou Gakuen HxH": 5217076051,
"P:\\anime\\Knight's & Magic": 5462539205,
"P:\\anime\\DAN DA DAN (2024)": 10293363650,
"P:\\anime\\Terror in Resonance (2014)": 2651080911,
"P:\\anime\\Masamune-kun no Revenge": 2189592644,
"P:\\anime\\Darwin's Game": 2896376206,
"P:\\anime\\Green Green": 4573681221,
"P:\\anime\\Heion Sedai no Idaten-tachi": 4801708412,
"P:\\anime\\The Reincarnation of the Strongest Exorcist in Another World": 4530803804,
"P:\\anime\\My Daughter Left the Nest and Returned an S-Rank Adventurer (2023)": 8845481436,
"P:\\anime\\Hamefura": 8824881383,
"P:\\anime\\Recovery of an MMO Junkie": 2534587997,
"P:\\anime\\Astra Lost in Space (2019)": 5522863567,
"P:\\anime\\Mobile Suit Gundam The Witch from Mercury": 4233451823,
"P:\\anime\\Unnamed Memory": 6391216954,
"P:\\anime\\My Home Hero": 10316487117,
"P:\\anime\\Noblesse": 3106598797,
"P:\\anime\\Quality Assurance in Another World": 6689588963,
"P:\\anime\\The Saint's Magic Power is Omnipotent": 2353633162,
"P:\\anime\\Gods' Games We Play": 9805921920,
"P:\\anime\\High School D\u00d7D (2012)": 138488392829,
"P:\\anime\\Buddy Daddies": 3249476770,
"P:\\anime\\The Testament of Sister New Devil": 9051073176,
"P:\\anime\\Why the Hell are You Here, Teacher!! (2019)": 1437748496,
"P:\\anime\\Grenadier": 4272486895,
"P:\\anime\\.deletedByTMM": 2941377788,
"P:\\anime\\Dragonar Academy": 5046527432,
"P:\\anime\\Genjitsu Shugi Yuusha no Oukoku Saikenki": 10459881239,
"P:\\anime\\Darling in the FranXX": 8035916582,
"P:\\anime\\Platinum End": 7525590740,
"P:\\anime\\Go! Go! Loser Ranger! (2024)": 8801862587,
"P:\\anime\\Mission - Yozakura Family": 9503269395,
"P:\\anime\\Attack on Titan": 49531319741,
"P:\\anime\\The Kings Avatar": 8090986074,
"P:\\anime\\Gokukoku no Brynhildr": 1836411221,
"P:\\anime\\Pok\u00e9mon (1997)": 64280309172,
"P:\\anime\\Trigun": 7302317846,
"P:\\anime\\Failure Frame - I Became the Strongest and Annihilated Everything with Low-Level Spells (2024)": 14994809254,
"P:\\anime\\The Daily Life of a Middle-Aged Online Shopper in Another World (2025)": 20550758199,
"P:\\anime\\Vermeil in Gold (2022)": 3029688342,
"P:\\anime\\A Returner's Magic Should Be Special": 4013798232,
"P:\\anime\\The Legendary Hero Is Dead! (2023)": 3859024813,
"P:\\anime\\Golden Boy": 1780893800,
"P:\\anime\\Tenchi Muyo! War on Geminar": 5177920884,
"P:\\anime\\Tokyo Ravens": 3797091570,
"P:\\anime\\Ore wo Suki nano wa Omae dake ka yo": 4930678131,
"P:\\anime\\Beast Tamer": 4948757269
}

File diff suppressed because it is too large Load Diff

View File

@ -1,328 +0,0 @@
{
"P:\\tv\\1883": 4514294832,
"P:\\tv\\1923": 22125507023,
"P:\\tv\\3 Body Problem": 11369334730,
"P:\\tv\\30 Rock (2006)": 81412969909,
"P:\\tv\\A Knight of the Seven Kingdoms (2026)": 1632634109,
"P:\\tv\\Abbott Elementary (2021)": 24595462535,
"P:\\tv\\Adults (2025)": 6845585714,
"P:\\tv\\Adventuring Academy": 62196997373,
"P:\\tv\\Agatha All Along": 3411637969,
"P:\\tv\\Alien - Earth (2025)": 2926145405,
"P:\\tv\\Amazing Stories (2020)": 4281304458,
"P:\\tv\\American Gods (2017)": 43921706762,
"P:\\tv\\American Horror Story": 142468660014,
"P:\\tv\\Andor (2022)": 25679584728,
"P:\\tv\\Arcane (2021)": 19588567847,
"P:\\tv\\Assembly Required (2021)": 5737519036,
"P:\\tv\\Avenue 5": 12572813494,
"P:\\tv\\Bad Monkey": 7767595411,
"P:\\tv\\Ballers": 13002096756,
"P:\\tv\\Band of Brothers (2001)": 15129362120,
"P:\\tv\\Banshee (2013)": 25030541772,
"P:\\tv\\Barry": 31934844666,
"P:\\tv\\BattleBots": 61,
"P:\\tv\\BattleBots (2015)": 69,
"P:\\tv\\Being Human (2011)": 66311454464,
"P:\\tv\\Belgravia - The Next Chapter": 8340040939,
"P:\\tv\\Below Deck": 47516712212,
"P:\\tv\\Below Deck Down Under (2022)": 37998649217,
"P:\\tv\\Below Deck Mediterranean": 45303859402,
"P:\\tv\\Below Deck Sailing Yacht": 12706704039,
"P:\\tv\\Better Call Saul": 31152560439,
"P:\\tv\\Billions": 31141419259,
"P:\\tv\\Billy the Kid": 44803721006,
"P:\\tv\\Black Bird (2022)": 5893929480,
"P:\\tv\\Black Sails (2014)": 11356486450,
"P:\\tv\\Brooklyn Nine Nine": 45722673163,
"P:\\tv\\Bupkis": 13034439710,
"P:\\tv\\Canada's Drag Race": 115276595002,
"P:\\tv\\Canada's Drag Race vs The World": 7844155647,
"P:\\tv\\Catch-22": 7113496871,
"P:\\tv\\Chad Powers (2025)": 2474659236,
"P:\\tv\\Chilling Adventures of Sabrina (2018)": 23147355371,
"P:\\tv\\Chuck": 32193192829,
"P:\\tv\\Citadel": 2339699246,
"P:\\tv\\Citadel - Diana": 13304679453,
"P:\\tv\\Cobra Kai": 39761471967,
"P:\\tv\\Continuum (2012)": 29352883496,
"P:\\tv\\Countdown (2025)": 8935252687,
"P:\\tv\\Counterpart": 4875616955,
"P:\\tv\\Creature Commandos (2024)": 2331424358,
"P:\\tv\\Crowd Control": 9644641207,
"P:\\tv\\Cyberpunk - Edgerunners (2022)": 11313875182,
"P:\\tv\\Daredevil - Born Again (2025)": 7647367391,
"P:\\tv\\Dark Side of the Ring": 11863132534,
"P:\\tv\\Dateline NBC (1992)": 24340373652,
"P:\\tv\\Death and Other Details": 17844763765,
"P:\\tv\\Detroiters (2017)": 33750584701,
"P:\\tv\\Dimension 20": 577785429493,
"P:\\tv\\Dimension 20's Adventuring Party": 15389848562,
"P:\\tv\\Dirk Gently's Holistic Detective Agency (2016)": 11935610182,
"P:\\tv\\Dirty Laundry": 27626331672,
"P:\\tv\\Doctor Who (2005)": 5820708419,
"P:\\tv\\Dopesick": 2571994785,
"P:\\tv\\DOTA - Dragon's Blood (2021)": 12538510766,
"P:\\tv\\Dracula (2020)": 2147285246,
"P:\\tv\\Dune - Prophecy": 3330003290,
"P:\\tv\\Dungeons & Dragons": 6660128393,
"P:\\tv\\Dwight in Shining Armor": 75,
"P:\\tv\\English Teacher": 7603165476,
"P:\\tv\\Euphoria": 40925172559,
"P:\\tv\\Extraordinary": 6934203888,
"P:\\tv\\Extrapolations": 6690715385,
"P:\\tv\\Face Off (2011)": 83155672195,
"P:\\tv\\Fallen (2024)": 4161867429,
"P:\\tv\\Fallout": 16423829993,
"P:\\tv\\Fargo (2014)": 93247402537,
"P:\\tv\\Father Brown": 18896564477,
"P:\\tv\\Fired on Mars (2023)": 3590992124,
"P:\\tv\\Firefly (2002)": 7517428895,
"P:\\tv\\From Dusk Till Dawn - The Series (2014)": 5360771338,
"P:\\tv\\Galavant": 12147863291,
"P:\\tv\\Game Changer": 38317757866,
"P:\\tv\\Game Changers (2024)": 5880504271,
"P:\\tv\\Game Of Thrones": 119681469870,
"P:\\tv\\Gastronauts": 9365810750,
"P:\\tv\\Gen V (2023)": 16871757804,
"P:\\tv\\Ghosts (2019)": 40703143881,
"P:\\tv\\Ghosts (2021)": 4574333812,
"P:\\tv\\Goosebumps (2023)": 8257419062,
"P:\\tv\\Gordon Ramsay's Food Stars (2023)": 6344621632,
"P:\\tv\\Government Cheese (2025)": 15970704500,
"P:\\tv\\Gravity Falls": 31900305156,
"P:\\tv\\Halo": 6961206915,
"P:\\tv\\Harley and the Davidsons": 76,
"P:\\tv\\Harley Quinn": 20857796821,
"P:\\tv\\Harry Potter - Wizards of Baking (2024)": 23545641052,
"P:\\tv\\Haunted Hotel (2025)": 4735071992,
"P:\\tv\\Hawkeye": 13524278345,
"P:\\tv\\Hazbin Hotel (2024)": 10906489515,
"P:\\tv\\Hero Inside (2023)": 7372329680,
"P:\\tv\\High Potential": 28419769568,
"P:\\tv\\His & Hers (2026)": 3047263128,
"P:\\tv\\Hitmen (2020)": 12274410846,
"P:\\tv\\Home Economics": 14315967074,
"P:\\tv\\Home Improvement 1991": 48878774505,
"P:\\tv\\House of Guinness (2025)": 5444928896,
"P:\\tv\\House of the Dragon": 23959073249,
"P:\\tv\\iCarly (2021)": 19966043984,
"P:\\tv\\Impractical Jokers": 13357380400,
"P:\\tv\\In the Dark (2019)": 2555891397,
"P:\\tv\\Ink Master": 23329086486,
"P:\\tv\\Interior Chinatown": 3167640001,
"P:\\tv\\Invincible (2021)": 28264834693,
"P:\\tv\\Ironheart (2025)": 3153557870,
"P:\\tv\\Its Always Sunny in Philadelphia": 84650830434,
"P:\\tv\\Jentry Chau vs. the Underworld (2024)": 1406237358,
"P:\\tv\\Junior Taskmaster (2024)": 4133620030,
"P:\\tv\\Jury Duty": 8010062372,
"P:\\tv\\Kaos": 5164057710,
"P:\\tv\\Kevin Can F-k Himself": 11614889793,
"P:\\tv\\Killer Cakes": 3673781461,
"P:\\tv\\Kim's Convenience": 30475634673,
"P:\\tv\\Kitchen Nightmares UK": 11563663098,
"P:\\tv\\Kitchen Nightmares US": 56092851597,
"P:\\tv\\Knuckles": 2140786440,
"P:\\tv\\Krypton (2018)": 10875524680,
"P:\\tv\\Landman (2024)": 35939850573,
"P:\\tv\\Last Man Standing": 49393251846,
"P:\\tv\\Lawmen - Bass Reeves (2023)": 5363156538,
"P:\\tv\\Lessons in Chemistry (2023)": 5485801173,
"P:\\tv\\Letterkenny": 63,
"P:\\tv\\Life After People (2009)": 45628647899,
"P:\\tv\\Loki": 20082144632,
"P:\\tv\\Love Island (US) (2019)": 20699120877,
"P:\\tv\\Love, Death & Robots (2019)": 8204860116,
"P:\\tv\\Lucky Hank": 7336222432,
"P:\\tv\\Ludwig (2024)": 2400198257,
"P:\\tv\\Made For Love (2021)": 2211136772,
"P:\\tv\\Make Some Noise": 28902809441,
"P:\\tv\\Man Down (2013)": 5077144151,
"P:\\tv\\Married at First Sight (2014)": 30275711911,
"P:\\tv\\Married... with Children (1987)": 64228823786,
"P:\\tv\\Marvel's The Punisher (2017)": 32242478897,
"P:\\tv\\Matlock (2024)": 34470939613,
"P:\\tv\\Mayor of Kingstown (2021)": 65464041666,
"P:\\tv\\Mighty Nein (2025)": 6138965943,
"P:\\tv\\MobLand (2025)": 6622179548,
"P:\\tv\\Modern Family": 82788065200,
"P:\\tv\\Monarch Legacy of Monsters": 18371826949,
"P:\\tv\\Monet's Slumber Party": 8253206091,
"P:\\tv\\Moon Knight": 10976093361,
"P:\\tv\\Mr. & Mrs. Smith (2024)": 5316681916,
"P:\\tv\\Murder She Wrote": 12095973826,
"P:\\tv\\Murderbot (2025)": 18338040970,
"P:\\tv\\Mythic Quest": 16965795814,
"P:\\tv\\New Girl": 40676856398,
"P:\\tv\\Nobody Wants This": 11516933757,
"P:\\tv\\Obi-Wan Kenobi": 13867986923,
"P:\\tv\\One More Time (2024)": 6434473461,
"P:\\tv\\Only Murders in the Building (2021)": 2379838148,
"P:\\tv\\Our Flag Means Death": 2107045664,
"P:\\tv\\Outlander": 27364180668,
"P:\\tv\\Over the Garden Wall": 2937573633,
"P:\\tv\\Pantheon": 13397374449,
"P:\\tv\\Paradise (2025)": 8024209737,
"P:\\tv\\Parks and Recreation": 37277190974,
"P:\\tv\\Parlor Room": 12022280605,
"P:\\tv\\Passion for punchlines": 75514795,
"P:\\tv\\Peacemaker (2022)": 13199970800,
"P:\\tv\\Percy Jackson and the Olympians": 3558450335,
"P:\\tv\\Platonic (2023)": 17488146510,
"P:\\tv\\Pok\u00e9mon Concierge (2023)": 1134616527,
"P:\\tv\\Poppa\u2019s House": 13794748297,
"P:\\tv\\Power (2014)": 20414619656,
"P:\\tv\\Quantum Leap (1989)": 39284023472,
"P:\\tv\\Quantum Leap 2022": 8902776416,
"P:\\tv\\Quiet On Set - The Dark Side Of Kids TV": 12191520028,
"P:\\tv\\Raised by wolves": 9720677524,
"P:\\tv\\Reacher (2022)": 17521873037,
"P:\\tv\\Resident Alien (2021)": 17522605407,
"P:\\tv\\Rick and Morty": 31672318625,
"P:\\tv\\Royal Pains (2009)": 1247586112,
"P:\\tv\\Running Man": 10279755878,
"P:\\tv\\Rupaul's Drag Race": 80794664433,
"P:\\tv\\Rupaul's Drag Race All Stars": 60579323023,
"P:\\tv\\RuPaul's Drag Race Down Under": 27454793482,
"P:\\tv\\Rupaul's Drag Race UK": 110914388896,
"P:\\tv\\Rupaul's Drag Race Vegas Revue": 2532474468,
"P:\\tv\\Rupauls Drag Race UK vs The World": 39825098114,
"P:\\tv\\SAS Rogue Heroes (2022)": 10733559643,
"P:\\tv\\Saving Hope": 33116225358,
"P:\\tv\\Scenes from a Marriage (US)": 12493986505,
"P:\\tv\\Schitt's Creek": 9325109901,
"P:\\tv\\Schmigadoon!": 6206632733,
"P:\\tv\\SCORPION": 54081802764,
"P:\\tv\\Secret Celebrity RuPaul's Drag Race": 4211193920,
"P:\\tv\\Secret Level": 2810124465,
"P:\\tv\\See": 12316511887,
"P:\\tv\\Selfie": 5013734266,
"P:\\tv\\Severance": 15044806873,
"P:\\tv\\She-Hulk Attorney at Law": 10233633417,
"P:\\tv\\Sherlock (2010)": 21904285116,
"P:\\tv\\Shetland": 18537045340,
"P:\\tv\\Shifting Gears (2025)": 16679709509,
"P:\\tv\\Shoresy": 10934645992,
"P:\\tv\\Shrinking (2023)": 18645583692,
"P:\\tv\\Sh\u014dgun": 20899988683,
"P:\\tv\\Silicon Valley (2014)": 63657428121,
"P:\\tv\\Silo (2023)": 12897630564,
"P:\\tv\\Sirens (2025)": 4246622090,
"P:\\tv\\Smartypants": 15959708127,
"P:\\tv\\Smiling Friends": 5633340834,
"P:\\tv\\Solar Opposites": 1138214210,
"P:\\tv\\Son of Zorn (2016)": 6780978712,
"P:\\tv\\South Park": 70261225261,
"P:\\tv\\Spartacus": 75639017886,
"P:\\tv\\Special Ops Lioness": 9765393961,
"P:\\tv\\Squid Game (2021)": 22082475135,
"P:\\tv\\St. Denis Medical (2024)": 22285465422,
"P:\\tv\\Star Strek Strange New Worlds": 13781151928,
"P:\\tv\\Star Trek Lower Decks": 33090597113,
"P:\\tv\\Star Wars - Skeleton Crew (2024)": 2940779001,
"P:\\tv\\Stargirl": 9507100884,
"P:\\tv\\Station Eleven": 2708694925,
"P:\\tv\\Stranger Things (2016)": 66712664909,
"P:\\tv\\Suits LA (2025)": 22274831381,
"P:\\tv\\Superman and Lois": 44881535930,
"P:\\tv\\Supernatural": 209274293691,
"P:\\tv\\Sweetpea": 2706241673,
"P:\\tv\\Swimming with Sharks": 4426141798,
"P:\\tv\\Taboo (2017)": 19309841226,
"P:\\tv\\Taskmaster": 148786953529,
"P:\\tv\\Taskmaster (CA) (2022)": 2431664380,
"P:\\tv\\Taskmaster (NZ)": 71323320898,
"P:\\tv\\Taskmaster - Champion of Champions (2017)": 8672895672,
"P:\\tv\\Taskmaster AU": 20527610746,
"P:\\tv\\Taylor (2025)": 2621206209,
"P:\\tv\\Ted (2024)": 3024624414,
"P:\\tv\\Ted Lasso (2020)": 40007748469,
"P:\\tv\\Terminator Zero": 3384699699,
"P:\\tv\\The 10th Kingdom (2000)": 14174589505,
"P:\\tv\\The Amazing Digital Circus (2023)": 4739070191,
"P:\\tv\\The Bachelor": 40368931577,
"P:\\tv\\The Bachelorette": 9927266246,
"P:\\tv\\The Bear (2022)": 43665628138,
"P:\\tv\\The Big Door Prize": 2314902686,
"P:\\tv\\The Bondsman (2025)": 3112664353,
"P:\\tv\\The Book of Boba Fett": 12039417291,
"P:\\tv\\The Boys": 68010010167,
"P:\\tv\\The Chosen (2019)": 54241850899,
"P:\\tv\\The Closer": 47449608535,
"P:\\tv\\The Consultant (2023)": 74,
"P:\\tv\\The Continental (2023)": 1920206807,
"P:\\tv\\The Day of the Jackal (2024)": 17787097381,
"P:\\tv\\The Dragon Dentist": 11317084093,
"P:\\tv\\The Drew Carey Show (1995)": 70,
"P:\\tv\\The Edge of Sleep": 1358235145,
"P:\\tv\\The Eternaut": 17178505929,
"P:\\tv\\The Falcon and The Winter Soldier (2021)": 11657055937,
"P:\\tv\\The Fall of Diddy (2025)": 2431035593,
"P:\\tv\\The Fall of the House of Usher (2023)": 16454192941,
"P:\\tv\\The Forsytes (2025)": 4034792830,
"P:\\tv\\The Franchise (2024)": 2981270395,
"P:\\tv\\The Gentlemen (2024)": 5224500371,
"P:\\tv\\The Gilded Age": 90505242840,
"P:\\tv\\The Goes Wrong Show (2019)": 3676343887,
"P:\\tv\\The Good Lord Bird (2020)": 5619421375,
"P:\\tv\\The Great (2020)": 22361386693,
"P:\\tv\\The Great British Bake Off": 78,
"P:\\tv\\The IT Crowd (2006)": 9239572772,
"P:\\tv\\The Journal of the Mysterious Creatures (2019)": 92,
"P:\\tv\\The Last of Us": 30545352719,
"P:\\tv\\The Legend of Vox Machina": 25197294503,
"P:\\tv\\The Lord of the Rings - The Rings of Power": 12834498889,
"P:\\tv\\The Mandalorian": 36487773789,
"P:\\tv\\The Morning Show": 94311701751,
"P:\\tv\\The Newsroom": 27756667258,
"P:\\tv\\The Now": 836886747,
"P:\\tv\\The Offer": 9070667475,
"P:\\tv\\The Office (US)": 125989023411,
"P:\\tv\\The Old Man (2022)": 26139845941,
"P:\\tv\\The Originals (2013)": 72912846985,
"P:\\tv\\The Paper (2025)": 8102218176,
"P:\\tv\\The Penguin": 4459075060,
"P:\\tv\\The Pitt (2025)": 15872273391,
"P:\\tv\\The Pretender": 18425629462,
"P:\\tv\\The Queen's Gambit": 4100494817,
"P:\\tv\\The Rain (2018)": 2941174698,
"P:\\tv\\The Santa Clauses (2022)": 6400385164,
"P:\\tv\\The Second Best Hospital in the Galaxy (2024)": 3636394169,
"P:\\tv\\The Split": 7970767632,
"P:\\tv\\The Studio (2025)": 11530554023,
"P:\\tv\\The Take": 6020370013,
"P:\\tv\\The Terminal List - Dark Wolf (2025)": 9939046560,
"P:\\tv\\The Traitors (US) (2023)": 77109057146,
"P:\\tv\\The Trunk (2024)": 16810949304,
"P:\\tv\\The Umbrella Academy": 55348092191,
"P:\\tv\\Time Bandits (2024)": 6997478287,
"P:\\tv\\Tires (2024)": 5375794389,
"P:\\tv\\Titans (2018)": 31986198137,
"P:\\tv\\Tokyo Override (2024)": 3802255332,
"P:\\tv\\Tomb Raider - The Legend of Lara Croft": 9341088252,
"P:\\tv\\Trailer Park Boys (2001)": 41272144800,
"P:\\tv\\Tulsa King": 41351406080,
"P:\\tv\\Twisted Metal (2023)": 12547412897,
"P:\\tv\\Um, Actually": 14158856968,
"P:\\tv\\Unstable": 5444623642,
"P:\\tv\\Utopia (AU)": 8691287022,
"P:\\tv\\Very Important People": 16212483878,
"P:\\tv\\Vice Principals (2016)": 18406955713,
"P:\\tv\\Vikings (2013)": 194095449878,
"P:\\tv\\Villainous (2017)": 1961793524,
"P:\\tv\\Walker": 5492500161,
"P:\\tv\\Wandavision": 10099450034,
"P:\\tv\\Welcome to Chippendales (2022)": 10423545837,
"P:\\tv\\Welcome to Wrexham": 66664948104,
"P:\\tv\\What If": 21312022582,
"P:\\tv\\Wildemount Wildlings (2025)": 3348907992,
"P:\\tv\\Winning Time - The Rise of the Lakers Dynasty (2022)": 37911197652,
"P:\\tv\\Wolf Pack": 6844099384,
"P:\\tv\\Wonder Man (2026)": 3791573193,
"P:\\tv\\WondLa": 1399628000,
"P:\\tv\\Worst Cooks in America (2010)": 43432638837,
"P:\\tv\\Yellowstone (2018)": 89724605866,
"P:\\tv\\Young Sheldon": 21714069112,
"P:\\tv\\Your Honor (2020)": 25879839349
}

51
.vscode/launch.json vendored
View File

@ -1,51 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "FastAPI Backend",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": [
"api:app",
"--host",
"127.0.0.1",
"--port",
"8000",
"--reload"
],
"jinja": true,
"justMyCode": false,
"cwd": "${workspaceFolder}/webui",
"console": "integratedTerminal",
"env": {
"PYTHONUNBUFFERED": "1"
}
},
{
"name": "Svelte Frontend",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/webui/frontend",
"console": "integratedTerminal",
"skipFiles": [
"<node_internals>/**"
]
},
{
"name": "Run Both Servers",
"type": "compound",
"configurations": [
"FastAPI Backend",
"Svelte Frontend"
],
"stopAll": true
}
]
}

View File

@ -1,422 +0,0 @@
# Interactive Audio Stream Selection - Architecture Diagram
## System Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ main.py │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ArgumentParser │ │
│ │ --filter-audio (enables audio filtering) │ │
│ │ --interactive (enables interactive mode) ← NEW │ │
│ │ --cq, --r, --m, --language, --test (existing) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ normalize_input_path() → folder path │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ process_folder( │ │
│ │ filter_audio=True/False, │ │
│ │ interactive_audio=True/False ← NEW │ │
│ │ ) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ core/process_manager.py │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ process_folder(folder, ..., filter_audio, interactive) │ │
│ │ ↑ NEW param │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ For each video file: │ │
│ │ 1. Get source resolution & target resolution │ │
│ │ 2. Create audio_filter_config dict: │ │
│ │ { │ │
│ │ "enabled": filter_audio, │ │
│ │ "interactive": interactive_audio ← NEW FIELD │ │
│ │ } │ │
│ │ 3. Call run_ffmpeg() with audio_filter_config │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ core/encode_engine.py │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ run_ffmpeg( │ │
│ │ input_file, output_file, ..., │ │
│ │ audio_filter_config={enabled, interactive} │ │
│ │ ) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 1. streams = get_audio_streams(input_file) │ │
│ │ └─ Returns: [(index, ch, br, lang, meta), ...] │ │
│ │ │ │
│ │ 2. if audio_filter_config.get("enabled"): │ │
│ │ ├─ if audio_filter_config.get("interactive"): │ │
│ │ │ └─ Call: prompt_user_audio_selection(streams) ← ◆ │ │
│ │ │ [SHOWS PROMPT TO USER] │ │
│ │ │ └─ Returns: filtered_streams │ │
│ │ │ │ │
│ │ └─ else: │ │
│ │ └─ Call: filter_audio_streams(input_file, streams) │ │
│ │ (Automatic: keep best English + Commentary) │ │
│ │ └─ Returns: filtered_streams │ │
│ │ │ │
│ │ 3. For each stream in filtered_streams: │ │
│ │ ├─ choose_audio_bitrate() (codec selection) │ │
│ │ └─ Build FFmpeg codec params (-c:a, -b:a, etc.) │ │
│ │ │ │
│ │ 4. subprocess.run(ffmpeg_cmd) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ core/audio_handler.py │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ def prompt_user_audio_selection(streams) ← NEW FUNCTION │ │
│ │ ◆ Interactive User Prompt ◆ │ │
│ │ │ │
│ │ Display: │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 🎵 AUDIO STREAM SELECTION │ │ │
│ │ │ │ │ │
│ │ │ Stream #0: 2ch | Lang: eng | Bitrate: 128kbps │ │
│ │ │ Stream #1: 6ch | Lang: eng | Bitrate: 448kbps │ │
│ │ │ Stream #2: 2ch | Lang: spa | Bitrate: 128kbps │ │
│ │ │ Stream #3: 2ch | Lang: comment | Bitrate: 64kbps │ │
│ │ │ │ │ │
│ │ │ Keep streams: 1,3 │ │ │
│ │ │ │ │ │
│ │ │ ✅ Keeping 2 stream(s), removing 2 stream(s) │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Process: │ │
│ │ 1. Check if streams empty/single → return as-is │ │
│ │ 2. Display all streams with formatting │ │
│ │ 3. Prompt user for comma-separated indices │ │
│ │ 4. Parse and validate input │ │
│ │ 5. Filter streams to selected only │ │
│ │ 6. Log selections & removed streams │ │
│ │ 7. Return filtered_streams │ │
│ │ │ │
│ │ Error Handling: │ │
│ │ • Invalid input → Keep all (log warning) │ │
│ │ • No selections → Keep all (log warning) │ │
│ │ • Empty input → Keep all (user confirmed) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Data Flow Example
### User Command
```bash
python main.py "C:\Videos" --filter-audio --interactive
```
### Data Transformation
```
Step 1: ArgumentParser
─────────────────────
Input Args:
folder = "C:\Videos"
filter_audio = True
interactive_audio = True
Output: args object
────────────────────────────────────────────────────────
Step 2: main() → process_folder()
───────────────────────────────────
Input:
folder, filter_audio=True, interactive_audio=True
Output: Called with both flags
────────────────────────────────────────────────────────
Step 3: process_folder() → Builds audio_filter_config
──────────────────────────────────────────────────────
Input:
filter_audio=True
interactive_audio=True
Logic:
if filter_audio is not None:
audio_filter_config = {
"enabled": True,
"interactive": True ← NEW
}
Output: audio_filter_config dict
────────────────────────────────────────────────────────
Step 4: process_folder() → run_ffmpeg()
─────────────────────────────────────────
Input:
input_file = "movie.mkv"
audio_filter_config = {"enabled": True, "interactive": True}
Output: Called with config
────────────────────────────────────────────────────────
Step 5: run_ffmpeg() → Audio Stream Detection
──────────────────────────────────────────────
Input:
input_file = "movie.mkv"
Output:
streams = [
(0, 2, 128, "eng", 0), # Stream #0: 2ch English 128kbps
(1, 6, 448, "eng", 0), # Stream #1: 6ch English 448kbps
(2, 2, 128, "spa", 0), # Stream #2: 2ch Spanish 128kbps
(3, 2, 64, "und", 0) # Stream #3: 2ch Undefined 64kbps
]
────────────────────────────────────────────────────────
Step 6: Audio Filtering Decision
────────────────────────────────
Input:
audio_filter_config = {"enabled": True, "interactive": True}
streams = [4 streams above]
Logic:
if audio_filter_config.get("enabled"): ✓ True
if audio_filter_config.get("interactive"): ✓ True
→ Call prompt_user_audio_selection() ← INTERACTIVE PATH
Output: User prompt shown to console
────────────────────────────────────────────────────────
Step 7: prompt_user_audio_selection() → User Input
──────────────────────────────────────────────────────
Input:
streams = [4 streams]
Display:
🎵 AUDIO STREAM SELECTION
════════════════════════════════════════════════════
Stream #0: 2ch | Lang: eng | Bitrate: 128kbps
Stream #1: 6ch | Lang: eng | Bitrate: 448kbps
Stream #2: 2ch | Lang: spa | Bitrate: 128kbps
Stream #3: 2ch | Lang: undefined | Bitrate: 64kbps
Keep streams: ← WAIT FOR USER INPUT
User Input:
"1,3"
Parse:
selected_indices = {1, 3}
Filter:
filtered = [
(1, 6, 448, "eng", 0), ✓ Keep
(3, 2, 64, "und", 0) ✓ Keep
]
Output:
✅ Keeping 2 stream(s), removing 2 stream(s)
Return: filtered streams
────────────────────────────────────────────────────────
Step 8: Back to run_ffmpeg() → Codec Selection
──────────────────────────────────────────────
Input:
streams = [
(1, 6, 448, "eng", 0),
(3, 2, 64, "und", 0)
]
Process each stream:
Stream 1: 6ch → choose_audio_bitrate() → ("eac3", 384000)
Stream 3: 2ch → choose_audio_bitrate() → ("aac", 160000)
Output:
FFmpeg codec params:
-c:a:1 eac3 -b:a:1 384k -ac:1 6 -channel_layout:1 5.1
-c:a:3 aac -b:a:3 160k -ac:3 2 -channel_layout:3 stereo
────────────────────────────────────────────────────────
Step 9: FFmpeg Encoding
───────────────────────
Input:
ffmpeg -i movie.mkv \
-vf scale=... \
-c:v av1_nvenc \
-c:a:1 eac3 -b:a:1 384k ... \
-c:a:3 aac -b:a:3 160k ... \
output.mkv
Process:
FFmpeg encodes video and audio streams
Only streams 1 and 3 included (streams 0 and 2 excluded)
Output:
output.mkv (with only selected audio tracks)
```
## State Diagram
```
┌─────────────────────────────────┐
│ User Runs Script │
│ --filter-audio --interactive │
└──────────────┬──────────────────┘
┌─────────────────────────────────┐
│ Parse Arguments │
│ interactive_audio = True │
└──────────────┬──────────────────┘
┌─────────────────────────────────┐
│ process_folder() │
│ Build audio_filter_config │
│ {enabled: T, interactive: T} │
└──────────────┬──────────────────┘
┌────────────┴────────────┐
│ │
▼ ▼
For each file Detect audio streams
┌──────────────┐ get_audio_streams()
│ run_ffmpeg() │ └─ Returns 4 streams
└──────┬───────┘
┌──────────────────────────┐
│ Check filter enabled? │
│ audio_filter_config │
└──────┬─────────────┬─────┘
│ No │ Yes
│ ▼
│ ┌─────────────────────┐
│ │ Check interactive? │
│ └────┬────────────┬───┘
│ │ No │ Yes
│ │ ▼
│ │ ┌───────────────────┐
│ │ │ INTERACTIVE PROMPT│
│ │ │ Show streams │
│ │ │ Get user input │
│ │ │ Filter streams │
│ │ └─────────┬─────────┘
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Automatic Filter │ │
│ │ (Best English + │ │
│ │ Commentary) │ │
│ └─────────┬────────┘ │
│ │ │
└────────────────┴───────────┘
┌────────────────────────────────┐
│ Apply Codec Selection │
│ (for selected streams only) │
│ choose_audio_bitrate() │
└────────────┬───────────────────┘
┌────────────────────────────────┐
│ Build FFmpeg Command │
│ (with selected audio streams) │
└────────────┬───────────────────┘
┌────────────────────────────────┐
│ Run FFmpeg Encoding │
│ subprocess.run(cmd) │
└────────────┬───────────────────┘
┌────────────────────────────────┐
│ Success/Failure Handling │
│ Log Results │
└────────────┬───────────────────┘
┌────────────┴─────────┐
│ │
Next file? Process Complete
```
## Component Interaction
```
┌─────────────┐
│ main.py │
└──────┬──────┘
│ calls with (filter_audio, interactive_audio)
┌──────────────────────┐
│ process_manager.py │
├──────────────────────┤
│ • Build config │ ◄─── Set "interactive" field
│ • For each file: │ in audio_filter_config
│ └─ run_ffmpeg() │
└──────┬───────────────┘
│ passes audio_filter_config
┌──────────────────────┐
│ encode_engine.py │
├──────────────────────┤
│ • Check "enabled" │ ◄─── Decide which
│ • Check "interactive"│ filtering method
│ • Route to: │ to use
│ ├─ interactive path│
│ └─ automatic path │
└──────┬───────────────┘
│ passes streams
┌──────────────────────┐
│ audio_handler.py │
├──────────────────────┤
│ • Interactive: │
│ prompt_user_...() │◄──── NEW FUNCTION
│ └─ Show & filter │ Shows prompt
│ │ Gets user input
│ • Automatic: │ Returns filtered
│ filter_audio_...() │
│ └─ Logic filter │
└──────────────────────┘
│ returns filtered streams
┌──────────────────────┐
│ encode_engine.py │
├──────────────────────┤
│ • Codec selection │
│ • Build FFmpeg cmd │
│ • Run encoding │
└──────────────────────┘
```
---
This architecture ensures clean separation of concerns:
- **main.py**: CLI interface
- **process_manager.py**: Orchestration & config building
- **encode_engine.py**: FFmpeg command building & execution
- **audio_handler.py**: Audio detection & stream filtering
The interactive prompt is cleanly isolated in `audio_handler.py` and only called when needed.

View File

@ -1,81 +0,0 @@
# Dual Encoder Support - Implementation Complete ✅
## Features Added
The transcoder now supports switching between two video encoders via the `--encoder` CLI option:
### 1. **HEVC NVENC 10-bit** (Default)
- **Command**: `--encoder nvenc` or default (no flag needed)
- **Codec**: `hevc_nvenc`
- **Preset**: `slow` (high quality)
- **Bit Depth**: 10-bit
- **Pixel Format**: `yuv420p10le`
- **Use Case**: Best quality archival format, suitable for Plex compatibility
### 2. **AV1 NVENC 8-bit**
- **Command**: `--encoder av1`
- **Codec**: `av1_nvenc`
- **Preset**: `p7` (high quality)
- **Bit Depth**: 8-bit
- **Pixel Format**: `yuv420p`
- **Use Case**: Maximum file size reduction, modern playback devices
## Usage Examples
```bash
# Default to HEVC NVENC 10-bit with smart resolution scaling
python main.py "C:\Videos\Movies"
# Force AV1 NVENC 8-bit encoding
python main.py "C:\Videos\TV" --encoder av1
# AV1 with explicit resolution
python main.py "C:\Videos\Anime" --encoder av1 --r 1080
# AV1 with CQ mode at specific quality
python main.py "C:\Videos\Low-Res" --encoder av1 --cq 28
# AV1 with bitrate mode
python main.py "C:\Videos\Movies" --encoder av1 --m bitrate
# HEVC (explicit, though it's the default)
python main.py "C:\Videos\TV" --encoder nvenc --cq 26
```
## Configuration
Encoder settings are stored in `config.xml`:
```xml
<encoder default="nvenc">
<av1_nvenc preset="p7" bit_depth="8" pix_fmt="yuv420p" />
<hevc_nvenc preset="slow" bit_depth="10" pix_fmt="yuv420p10le" />
</encoder>
```
The `default="nvenc"` attribute can be changed, but CLI `--encoder` flag always takes precedence.
## Files Modified
1. **config.xml** - Added `<encoder>` section with both encoder configurations
2. **main.py** - Added `--encoder` argument, defaults to "nvenc"
3. **encode_engine.py** - Updated `run_ffmpeg()` to:
- Accept `encoder` parameter
- Dynamically set encoder codec, preset, bit depth, and pixel format
- Display encoder details in logging output
4. **process_manager.py** - Updated to:
- Accept and pass `encoder` parameter through processing pipeline
- Updated both Phase 1 (initial encode) and Phase 2 (bitrate retry) encode calls
## Quality Notes
| Aspect | HEVC NVENC | AV1 NVENC |
|--------|-----------|----------|
| **File Size** | ~80-90% of AV1 | Smallest (baseline) |
| **Quality** | Excellent | Excellent |
| **Preset** | slow (p6) | p7 |
| **Bit Depth** | 10-bit | 8-bit |
| **Compatibility** | Excellent (Plex) | Good (modern devices) |
| **Encoding Speed** | Fast | Fast |
Both encoders use NVIDIA GPU acceleration (NVENC) for fast encoding.

View File

@ -1,280 +0,0 @@
# Interactive Audio Stream Selection - Complete Implementation
## Overview
**COMPLETE** - Interactive audio stream selection feature has been successfully implemented.
Users can now view all available audio streams in each video file and select which ones to keep for encoding, providing fine-grained control over audio track inclusion.
## Features Implemented
### 1. Stream Display ✅
- Shows all audio streams with human-readable format
- Displays: Stream number, channel count, language code, bitrate
- Clear visual separation and organized layout
- Example: `Stream #0: 2ch | Lang: eng | Bitrate: 128kbps`
### 2. User Input ✅
- Accepts comma-separated stream indices: `0,1,3`
- Accepts single stream: `1`
- Accepts blank input (keep all streams)
- Input validation with helpful error messages
- Optional spaces in comma-separated list: `0, 1, 3`
### 3. Filtering ✅
- Removes non-selected streams from encoding
- Preserves original stream indices for FFmpeg mapping
- Logs all selections and removals
- Falls back to keeping all streams on invalid input
### 4. CLI Integration ✅
- New flag: `--interactive` (boolean)
- Works with `--filter-audio` flag
- Can be used independently (auto-enables filtering)
- Integrated into argument parser with help text
### 5. Processing Pipeline ✅
- Called from `run_ffmpeg()` in encode_engine.py
- Executed after stream detection
- Executed before codec selection
- Per-file prompting (allows different selections per video)
### 6. Logging ✅
- Logs user selections: `User selected X audio stream(s): [0, 1, 3]`
- Logs removed streams: `Removed X audio stream(s): [2]`
- Logs invalid input attempts
- Integrated with project's logging system
## File Changes Summary
### main.py
**Added**:
- `--interactive` argument to argparse
- Pass `args.interactive_audio` to `process_folder()`
**Lines Changed**: 2
### core/process_manager.py
**Added**:
- `interactive_audio: bool = False` parameter to function signature
- Logic to set `audio_filter_config["interactive"]` based on CLI args
- Auto-enable filtering if `--interactive` used without `--filter-audio`
**Lines Changed**: ~5
### core/encode_engine.py
**Added**:
- Import `prompt_user_audio_selection`
- Check for `audio_filter_config.get("interactive", False)`
- Route to interactive or automatic filtering accordingly
**Lines Changed**: ~5
### core/audio_handler.py
**Added**:
- `prompt_user_audio_selection()` function (64 lines)
- Comprehensive docstring
- User-friendly output formatting
- Input validation and error handling
- Logging integration
**Lines Changed**: +64 (new function)
## Code Structure
### Function: `prompt_user_audio_selection(streams: list) -> list`
**Location**: `core/audio_handler.py` (line 297)
**Parameters**:
- `streams`: List of (index, channels, bitrate, language, metadata) tuples
**Returns**:
- Filtered list containing only user-selected streams
**Key Features**:
1. Early return if 0-1 streams (no selection needed)
2. Display header with visual formatting
3. Show each stream with index, channels, language, bitrate
4. Prompt for user input with examples
5. Parse comma-separated input
6. Validate stream indices
7. Handle edge cases (empty input, invalid input)
8. Log results to project logger
9. Return filtered streams ready for encoding
**Error Handling**:
- ValueError on unparseable input → keep all
- No valid selections → keep all with warning
- Empty input → keep all (user confirmed)
## Execution Flow
```
User runs:
$ python main.py "C:\Videos" --filter-audio --interactive
main.py parses arguments
- filter_audio = True (from --filter-audio)
- interactive_audio = True (from --interactive)
process_folder() called with both flags
For each video file:
└─ run_ffmpeg() called
└─ get_audio_streams() detects streams
└─ Check audio_filter_config.enabled
└─ True: Apply filtering
└─ Check audio_filter_config.interactive
└─ True: Call prompt_user_audio_selection()
└─ [INTERACTIVE PROMPT APPEARS]
└─ User sees streams and selects
└─ Returns filtered stream list
└─ False: Call filter_audio_streams()
└─ Automatic filtering (keep best English + Commentary)
└─ Process selected streams for encoding
```
## Usage Examples
### Basic Interactive Mode
```bash
python main.py "C:\Videos\Movies" --filter-audio --interactive
```
### Combined with Other Options
```bash
python main.py "C:\Videos\TV" --filter-audio --interactive --cq 28 --r 1080 --language eng
```
### Interactive Without Explicit --filter-audio
```bash
python main.py "C:\Videos\Anime" --interactive
```
(Filtering is auto-enabled with interactive mode)
## Testing Scenarios
### Scenario 1: Multiple Audio Languages
**Input**: Video with English (stereo), English (5.1), Spanish, Commentary
**Expected**: Prompt shows 4 streams, user can select any combination
### Scenario 2: Invalid Selection
**Input**: User types "abc" or non-existent stream number
**Expected**: Tool logs warning, keeps all streams, continues
### Scenario 3: Single Audio Stream
**Input**: Video with only one audio track
**Expected**: Function returns early, no prompt shown
### Scenario 4: Empty Input
**Input**: User presses Enter without typing
**Expected**: All streams kept, confirmation message shown
## Backward Compatibility
✅ **Fully Backward Compatible**
- Existing `--filter-audio` behavior unchanged
- New feature is opt-in via `--interactive` flag
- Default behavior (no interactive) preserved
- No changes to config.xml schema required
- All existing scripts/automation continues to work
## Integration Points
### With Audio Language Tagging
- `--language eng --filter-audio --interactive` works together
- User selects streams, then language metadata applied to all
### With Resolution/CQ Options
- `--filter-audio --interactive --cq 28 --r 1080` fully compatible
- Interactive selection happens first, encoding follows
### With Test Mode
- `--filter-audio --interactive --test` shows interactive prompt on first file
- Useful for testing selections before batch encoding
## Performance Impact
✅ **Minimal Impact**
- Interactive prompt only appears when user explicitly requests it
- No performance overhead when `--interactive` not used
- Per-file prompt adds negligible time (user wait for input)
- No change to FFmpeg encoding performance
## Documentation Provided
1. **INTERACTIVE_AUDIO.md** - User guide with examples
2. **IMPLEMENTATION_NOTES.md** - Technical implementation details
3. **QUICK_REFERENCE.md** - Quick reference guide and FAQ
4. This summary document
## Completion Checklist
✅ Function implementation (prompt_user_audio_selection)
✅ CLI argument (--interactive)
✅ Integration with process_manager
✅ Integration with encode_engine
✅ Input validation
✅ Error handling
✅ Logging integration
✅ Backward compatibility
✅ Documentation
✅ Syntax validation
✅ Code review
## Example Output
When user runs with `--filter-audio --interactive`:
```
================================================================================
🎵 AUDIO STREAM SELECTION
================================================================================
Stream #0: 2ch | Lang: eng | Bitrate: 128kbps
Stream #1: 6ch | Lang: eng | Bitrate: 448kbps
Stream #2: 2ch | Lang: spa | Bitrate: 128kbps
Stream #3: 2ch | Lang: comment | Bitrate: 64kbps
────────────────────────────────────────────────────────────────────────────
Enter stream numbers to keep (comma-separated, e.g.: 1,2 or just 2)
Leave blank to keep all streams
────────────────────────────────────────────────────────────────────────────
➜ Keep streams: 1,3
✅ Keeping 2 stream(s), removing 2 stream(s)
🎬 Running CQ encode: output.mkv
...
```
## Next Steps (Optional Enhancements)
Future improvements could include:
- [ ] Preset buttons for common selections (e.g., "Best Audio", "English Only", "All")
- [ ] Auto-numbering display for clarity
- [ ] Arrow key selection interface (more interactive)
- [ ] Save/load selection templates for batch consistency
- [ ] GUI interface for stream selection
- [ ] Default selection from config for silent/batch operation
---
## Summary
The interactive audio stream selection feature is **complete and ready for use**. Users can now:
1. ✅ See all available audio streams with details
2. ✅ Choose which streams to keep for encoding
3. ✅ Get immediate confirmation of their selection
4. ✅ Have per-file control in batch operations
5. ✅ Maintain automatic fallback if input is invalid
The implementation is clean, well-documented, backward-compatible, and fully integrated into the existing codebase.

View File

@ -1,141 +0,0 @@
# Interactive Audio Stream Selection - Implementation Summary
## Changes Made
### 1. New Function: `prompt_user_audio_selection()` in audio_handler.py
- **Purpose**: Display audio streams and prompt user for selection
- **Input**: List of streams with (index, channels, bitrate, language, metadata)
- **Output**: Filtered list containing only user-selected streams
- **Features**:
- Displays stream info: `Stream #X: YYch | Lang: YYY | Bitrate: XYZkbps`
- Accepts comma-separated input: `1,2,3` or `1` or empty (keep all)
- Validates input and logs selections
- Falls back to keeping all streams on invalid input
### 2. Updated: `run_ffmpeg()` in encode_engine.py
- Now checks `audio_filter_config.get("interactive", False)`
- Routes to interactive prompt if `interactive=True`
- Routes to automatic filtering if `interactive=False`
- Both modes filter streams before codec selection
### 3. Updated: `process_folder()` in process_manager.py
- New parameter: `interactive_audio: bool = False`
- Builds audio_filter_config with both `enabled` and `interactive` fields
- If `--interactive` used without `--filter-audio`, enables both automatically
### 4. Updated: main.py
- New CLI argument: `--interactive`
- Action: `store_true` (binary flag)
- Passed through to `process_folder()`
- Help text: "Interactive mode: show audio streams and let user select which to keep (requires --filter-audio)"
## Usage Examples
### Example 1: Automatic Filtering (Existing)
```bash
python main.py "C:\Videos" --filter-audio
```
- Automatically keeps best English + Commentary
- No user interaction
### Example 2: Interactive Selection (New)
```bash
python main.py "C:\Videos" --filter-audio --interactive
```
- Shows each file's audio streams
- User picks which streams to keep
- Different selections per file allowed
### Example 3: Interactive Without --filter-audio
```bash
python main.py "C:\Videos" --interactive
```
- Same as Example 2 (enables filtering automatically)
- More intuitive UX
## Stream Display Format
When interactive mode runs, user sees:
```
================================================================================
🎵 AUDIO STREAM SELECTION
================================================================================
Stream #0: 2ch | Lang: eng | Bitrate: 128kbps
Stream #1: 6ch | Lang: eng | Bitrate: 448kbps
Stream #2: 2ch | Lang: spa | Bitrate: 128kbps
────────────────────────────────────────────────────────────────────────────
Enter stream numbers to keep (comma-separated, e.g.: 1,2 or just 2)
Leave blank to keep all streams
────────────────────────────────────────────────────────────────────────────
➜ Keep streams:
```
## Logging Output
When user selects streams:
```
✅ Keeping 2 stream(s), removing 1 stream(s)
User selected 2 audio stream(s): [1, 2]
Removed 1 audio stream(s): [0]
```
## Audio Filter Config Structure
**Old (Automatic only)**:
```python
{
"enabled": True/False
}
```
**New (With Interactive)**:
```python
{
"enabled": True/False,
"interactive": True/False
}
```
## Flow Diagram
```
main.py
└─ parse args (--filter-audio, --interactive)
└─ process_folder()
└─ for each file:
└─ run_ffmpeg()
└─ get_audio_streams()
└─ if audio_filter_config.enabled:
├─ if audio_filter_config.interactive:
│ └─ prompt_user_audio_selection() ← NEW
│ └─ [User sees streams and selects]
└─ else:
└─ filter_audio_streams() (automatic)
└─ encode with selected streams
```
## Input Validation
- **Valid**: `1`, `0,1,3`, `2, 3, 5` (spaces OK)
- **Invalid**: `abc`, `1.5`, `1-3` (ranges not supported)
- **On Invalid**: Keep all streams, log warning
## Edge Cases Handled
1. **No streams**: Return original (nothing to filter)
2. **Single stream**: Return as-is (no selection needed)
3. **Invalid stream indices**: Keep all streams
4. **Empty input**: Keep all streams
5. **No valid selections**: Keep all streams (with warning)
## Backward Compatibility
- Existing `--filter-audio` behavior unchanged (automatic mode)
- `--interactive` is optional, defaults to False
- No breaking changes to config.xml structure
- Language tagging (--language) still works alongside audio filtering

View File

@ -1,109 +0,0 @@
# Interactive Audio Stream Selection
## Overview
The conversion tool now supports **interactive audio stream selection**, allowing you to manually choose which audio tracks to keep during encoding rather than relying on automatic filtering.
## Usage
### Enable Interactive Mode
Use both `--filter-audio` and `--interactive` flags together:
```bash
python main.py "C:\path\to\videos" --filter-audio --interactive
```
### What Happens
When encoding each file with multiple audio streams:
1. **Audio Stream Display**
- The tool displays all available audio streams with details:
```
🎵 AUDIO STREAM SELECTION
================================================================================
Stream #0: 2ch | Lang: eng | Bitrate: 128kbps
Stream #1: 6ch | Lang: eng | Bitrate: 448kbps
Stream #2: 2ch | Lang: spa | Bitrate: 128kbps
Stream #3: 2ch | Lang: comment | Bitrate: 64kbps
```
2. **User Prompt**
- You're asked to select which streams to keep:
```
────────────────────────────────────────────────────────────────────────────
Enter stream numbers to keep (comma-separated, e.g.: 1,2 or just 2)
Leave blank to keep all streams
────────────────────────────────────────────────────────────────────────────
➜ Keep streams: 1,3
```
3. **Encoding
**
- Only selected streams are included in the encoded output
- Other streams are removed
- Selection is logged for reference
## Input Format
- **Multiple streams**: `0,1,3` or `0, 1, 3` (spaces optional)
- **Single stream**: `1` or `2`
- **Keep all**: Press Enter without typing anything
## Example Scenarios
### Scenario 1: Keep Main Audio Only
```
Streams:
Stream #0: 2ch (English, 128kbps)
Stream #1: 6ch (English Surround, 448kbps) ← Best quality
Stream #2: 2ch (Spanish, 128kbps)
Input: 1
Result: Only Stream #1 (6ch English Surround) is encoded
```
### Scenario 2: Keep Multiple Languages
```
Streams:
Stream #0: 2ch (English, 128kbps)
Stream #1: 6ch (English Surround, 448kbps)
Stream #2: 2ch (Spanish, 128kbps)
Stream #3: 2ch (Commentary, 64kbps)
Input: 1,2,3
Result: Streams #1, #2, and #3 are encoded (English Surround, Spanish, Commentary)
```
## Comparison with Automatic Filtering
### Automatic Mode (--filter-audio only)
- Keeps: Best English audio + all Commentary tracks
- No user interaction
- Faster batch processing
### Interactive Mode (--filter-audio --interactive)
- Shows all streams and asks user to choose
- Per-file control
- Better for selective archiving/organization
- Useful when automatic filtering doesn't match your preferences
## Logging
All user selections are logged to the conversion log for reference:
```
User selected 2 audio stream(s): [1, 3]
Removed 1 audio stream(s): [2]
```
## Notes
- Interactive mode requires `--filter-audio` to be enabled
- If you use `--interactive` without `--filter-audio`, filtering is automatically enabled
- Invalid input (non-existent stream numbers) falls back to keeping all streams
- Empty input keeps all audio streams unchanged
- The prompt appears for each file being encoded, allowing different selections per file

View File

@ -1,101 +0,0 @@
# Structured Logging with Media Context
## Overview
The conversion system now uses **structured JSON logging** to enable organized analysis and filtering of conversion results by media type, show name, season, and episode.
## Terminal vs Log Output
- **Terminal Output**: Clean, human-readable print statements (VIDEO/AUDIO/PROGRESS sections)
- **Log Output**: Rich structured JSON with full media context for programmatic analysis
## Media Context Fields
Extracted automatically from file path structure:
```python
{
"video_filename": "episode01.mkv",
"media_type": "tv", # "tv", "anime", "movie", or "other"
"show_name": "Breaking Bad",
"season": "01", # Optional (TV/anime only)
"episode": "01" # Optional (TV/anime only)
}
```
## Usage Examples
### Path Structure Recognition
**TV Show**:
```
P:\tv\Breaking Bad\season01\episode01.mkv
→ media_type: "tv", show_name: "Breaking Bad", season: "01", episode: "01"
```
**Anime**:
```
P:\anime\Demon Slayer\season02\e12.mkv
→ media_type: "anime", show_name: "Demon Slayer", season: "02", episode: "12"
```
**Movie**:
```
P:\movies\Inception.mkv
→ media_type: "movie", show_name: "Inception"
```
## Log Output Format
JSON logs contain both the message and media context:
```json
{
"timestamp": "2026-02-22 15:30:45",
"level": "INFO",
"message": "✅ CONVERSION COMPLETE: episode01[EHX].mkv",
"video_filename": "episode01.mkv",
"media_type": "tv",
"show_name": "Breaking Bad",
"season": "01",
"episode": "01",
"method": "CQ",
"original_size_mb": 4096.5,
"output_size_mb": 1843.2,
"reduction_pct": 55.0
}
```
## Filtering Logs Later
You can parse the JSON logs to group by show/season/episode:
```python
import json
# Filter all Breaking Bad conversions
with open("logs/conversion.log") as f:
for line in f:
entry = json.loads(line)
if entry.get("show_name") == "Breaking Bad":
print(f"S{entry['season']}E{entry['episode']}: {entry['reduction_pct']}% reduction")
```
## Current Implementation
**Files Updated**:
- `core/process_manager.py`:
- Added `get_media_context()` function to parse file paths
- Extracts media context once per file processing
- Passes context to all logging calls via `extra={}` parameter
- `core/logger_helper.py`:
- JsonFormatter automatically includes all extra fields in output
- Added `log_event()` helper for consistent structured logging
## Best Practices
1. Always call `get_media_context()` once per file
2. Pass result to all logging calls for that file: `logger.info(msg, extra=media_context)`
3. For additional context: `logger.info(msg, extra={**media_context, "custom_field": value})`
4. Parse logs with JSON reader for reliable data extraction

View File

@ -1,184 +0,0 @@
# AV1 Batch Video Transcoder - Project Structure
## Overview
A modular batch AV1 video transcoding system using NVIDIA's av1_nvenc codec (8-bit yuv420p) with intelligent audio/video processing, subtitle embedding, and optional audio language tagging.
## Recent Changes (Latest Session)
### Removed
- ❌ **Sonarr/Radarr integration** - Removed helper module, cache loading, and config sections (simplified to basic tagging)
- ❌ **Auto-rename functionality** - No longer renames based on Sonarr metadata
- ❌ **Web UI** - Removed `/webui` folder (can be added back if needed)
- ❌ **Rename tool** - Moved to separate `/rename` folder
### Added
- ✅ **Subtitle detection & embedding** - Auto-finds .vtt, .srt, .ass, .ssa, .sub files (including language-prefixed like .en.vtt)
- ✅ **Subtitle cleanup** - Deletes embedded subtitle files after successful encoding
- ✅ **Test mode** (`--test`) - Encodes first file, shows compression ratio, doesn't move files
- ✅ **Optional language tagging** (`--language`) - Only tags audio if explicitly provided (default: no tagging)
- ✅ **Always output MKV** - Changed from using source extension to always outputting .mkv
- ✅ **Improved subtitle matching** - Finds both exact matches (video.vtt) and language-prefixed (video.en.vtt)
### Refactored
- 🔧 **File structure reorganization**: Moved path_manager GUI, rename tool, and cache to separate folders
- 🔧 **Config simplification**: Removed Sonarr/Radarr sections, cleaner general settings
- 🔧 **Suffix handling**: Applied once during encoding, moved directly without re-tagging
- 🔧 **Audio language**: Changed from config-based default to CLI-only optional flag
## Architecture
### Entry Point
- **main.py** - CLI with argparse
- Arguments: `folder`, `--cq`, `--m {cq,bitrate}`, `--r {480,720,1080}`, `--test`, `--language`
- Loads config.xml, initializes logging
- Calls `process_folder()` from process_manager
### Core Modules
#### `core/config_helper.py`
- **`load_config_xml(config_path)`** - Parses XML configuration
- Returns dict with keys:
- `general`: processing_folder, suffix (" - [EHX]"), extensions, reduction_ratio_threshold, subtitles config
- `encode.cq`: CQ values per content type (tv_1080, tv_720, movie_1080, movie_720)
- `encode.fallback`: Bitrate fallback (Phase 2 retry)
- `audio`: Bitrate buckets for stereo/multichannel
- `path_mappings`: Windows ↔ Linux path conversion
#### `core/logger_helper.py`
- Sets up logging to `logs/conversion.log` (INFO+) and console (DEBUG+)
- Separate failure logger for `logs/conversion_failures.log`
- Captures encoding decisions, bitrates, resolutions, timings
#### `core/process_manager.py`
- **`process_folder(folder, cq, transcode_mode, resolution, config, tracker_file, test_mode, audio_language)`**
- Scans folder for video files
- **Per file**: Copy to temp, detect subtitles, analyze streams, encode, move, cleanup
- **Subtitle detection**: Looks for exact match + glob pattern (filename.*.ext)
- **Phase 1 (CQ)**: Try CQ-based encoding, check size threshold
- **Phase 2 (Bitrate)**: Retry failed files with bitrate mode
- **Cleanup**: Delete original + subtitle + temp copies on success
- **`_save_successful_encoding(...)`** - Moves file from temp → original folder
- File already has ` - [EHX]` suffix from temp_output filename
- Deletes original file, subtitle file, and temp copies
- Logs to CSV tracker
#### `core/encode_engine.py`
- **`run_ffmpeg(input_file, output_file, cq, scale_width, scale_height, src_width, src_height, filter_flags, audio_config, method, bitrate_config, subtitle_file, audio_language)`**
- Builds FFmpeg command with av1_nvenc codec (preset p7, pix_fmt yuv420p)
- Per-stream audio codec/bitrate decisions
- Conditional subtitle input mapping (if subtitle_file provided)
- Optional audio language metadata (only if audio_language not None)
- Returns: (orig_size, out_size, reduction_ratio)
#### `core/audio_handler.py`
- **`get_audio_streams(input_file)`** - Detects all audio streams with bitrate info
- **`choose_audio_bitrate(channels, avg_bitrate, audio_config, is_1080_class)`** - Returns (codec, target_bitrate) tuple
- Stereo 1080p: >192k → encode to 192k, ≤192k → copy
- Stereo 720p: >160k → encode to 160k, ≤160k → copy
- Multichannel: Encode to 384k (low) or 448k (medium)
#### `core/video_handler.py`
- **`get_source_resolution(input_file)`** - ffprobe detection
- **`determine_target_resolution(src_width, src_height, explicit_resolution)`** - Smart scaling
- If >1080p → scale to 1080p
- Else → preserve source
- Override with `--r {480,720,1080}`
## Workflow Example
```bash
python main.py "P:\tv\Supernatural\Season 7" --language eng
```
**Processing:**
1. Scan folder for .mkv/.mp4 files
2. For each file:
- Copy to `processing/Supernatural - S07E01 - Pilot.mkv`
- Look for subtitle: `Supernatural - S07E01 - Pilot.en.vtt` ✓ found
- Detect source: 1920x1080 (1080p) ✓
- Get audio streams: [AAC 2ch @ 192k, AC3 6ch @ 448k]
- Determine CQ: tv_1080 → CQ 28
- Build FFmpeg command:
- Video: av1_nvenc (CQ 28)
- Audio 0: Copy AAC (≤192k already good)
- Audio 1: Re-encode AC3 to AAC 6ch @ 448k
- Subtitles: Input subtitle, map as srt stream, language=eng
- Output: `processing/Supernatural - S07E01 - Pilot - [EHX].mkv`
- FFmpeg runs, outputs ~400MB (original 1.2GB)
- Check size: 400/1200 = 33.3% < 75% SUCCESS
- Move: `processing/... - [EHX].mkv``P:\tv\Supernatural\Season 7/... - [EHX].mkv`
- Cleanup: Delete original + subtitle + temp copy
- Log to CSV
**Result:**
- Original files gone
- New `Supernatural - S07E01 - Pilot - [EHX].mkv` (subtitle embedded, audio tagged with language=eng)
## Configuration
### config.xml Key Sections
```xml
<general>
<processing_folder>processing</processing_folder>
<suffix> - [EHX]</suffix>
<extensions>.mkv,.mp4</extensions>
<reduction_ratio_threshold>0.75</reduction_ratio_threshold>
<subtitles>
<enabled>true</enabled>
<extensions>.vtt,.srt,.ass,.ssa,.sub</extensions>
<codec>srt</codec>
</subtitles>
</general>
<encode>
<cq>
<tv_1080>28</tv_1080>
<tv_720>32</tv_720>
<movie_1080>32</movie_1080>
<movie_720>34</movie_720>
</cq>
</encode>
<audio>
<stereo>
<high>192000</high>
<medium>160000</medium>
</stereo>
<multi_channel>
<medium>448000</medium>
<low>384000</low>
</multi_channel>
</audio>
```
## File Movements
```
Original:
P:\tv\Show\Episode.mkv (1.2GB)
P:\tv\Show\Episode.en.vtt
During Encoding:
processing/Episode.mkv (temp copy)
processing/Episode - [EHX].mkv (encoding output)
After Success:
P:\tv\Show\Episode - [EHX].mkv (1.2GB → 400MB)
(original .mkv deleted)
(original .en.vtt deleted)
(temp folder cleaned)
```
## Validation Checklist
- ✅ All core modules import correctly
- ✅ Config loads without Sonarr/Radarr references
- ✅ Subtitle detection finds exact matches + language-prefixed files
- ✅ Audio language tagging only applied with --language flag
- ✅ Output always MKV regardless of source format
- ✅ Suffix applied once (in temp output filename)
- ✅ Subtitle files deleted with original files
- ✅ Test mode shows compression ratio and stops
- ✅ Phase 1 (CQ) and Phase 2 (Bitrate) retry logic works
- ✅ CSV tracking logs all conversions

View File

@ -1,182 +0,0 @@
# Interactive Audio Selection - Quick Reference
## Command Syntax
### Enable Interactive Audio Selection
```bash
python main.py "C:\path\to\videos" --filter-audio --interactive
```
### Other Flags (Optional)
```bash
--filter-audio --interactive --cq 28 --r 1080 --language eng --test
```
## What User Sees
### Per File Prompt (appears for each video)
```
================================================================================
🎵 AUDIO STREAM SELECTION
================================================================================
Stream #0: 2ch | Lang: eng | Bitrate: 128kbps
Stream #1: 6ch | Lang: eng | Bitrate: 448kbps
Stream #2: 2ch | Lang: spa | Bitrate: 128kbps
Stream #3: 2ch | Lang: comment | Bitrate: 64kbps
────────────────────────────────────────────────────────────────────────────
Enter stream numbers to keep (comma-separated, e.g.: 1,2 or just 2)
Leave blank to keep all streams
────────────────────────────────────────────────────────────────────────────
➜ Keep streams:
```
### User Input Examples
| Input | Result |
|-------|--------|
| `1` | Keep Stream #1 (6ch English, 448kbps) |
| `1,3` | Keep Streams #1 and #3 |
| `0,1,2` | Keep Streams #0, #1, and #2 |
| ` ` (blank) | Keep all 4 streams |
### Expected Output
```
✅ Keeping 1 stream(s), removing 3 stream(s)
🎬 Running CQ encode: output.mkv
...
```
## Features
**Per-File Control**: Different selections for each video
**Clear Display**: See channel count, language, bitrate for each stream
**Flexible Input**: Comma-separated numbers, optional spaces
**Safe Defaults**: Invalid input keeps all streams
**Logging**: All selections recorded in conversion log
**Backwards Compatible**: Doesn't break existing workflows
## Common Scenarios
### Movie with Multiple Audio Tracks
```
Stream #0: 2ch English (128kbps)
Stream #1: 6ch English Surround (448kbps) ← Main audio
Stream #2: 2ch Spanish (128kbps)
Stream #3: 2ch Commentary (64kbps)
Input: 1,3
Output: Keep English 5.1 + Commentary
```
### TV Episode with Multiple Languages
```
Stream #0: 6ch English (384kbps)
Stream #1: 6ch Spanish (384kbps)
Stream #2: 2ch Commentary (64kbps)
Input: 0,1,2
Output: Keep all (English, Spanish, Commentary)
```
### File with Only One Audio Track
```
Stream #0: 6ch English (448kbps)
Input: (blank or 0)
Output: Keep the only track
```
## FAQ
**Q: What if I provide an invalid stream number?**
A: The tool keeps all streams and logs a warning.
**Q: Can I specify stream ranges like "0-2"?**
A: No, use comma-separated individual numbers: "0,1,2"
**Q: Do I have to answer the prompt for every file?**
A: Yes, this allows different selections per file. Use automatic --filter-audio mode if you want consistent filtering across all files.
**Q: What happens with invalid input like "abc" or "1.5"?**
A: The tool keeps all streams and logs the invalid input. Then continues to the next file.
**Q: Does --interactive work alone?**
A: Yes! If you use --interactive without --filter-audio, filtering is automatically enabled with interactive mode.
**Q: Can I combine this with --language tagging?**
A: Yes! Use: `--filter-audio --interactive --language eng`
This lets you select streams AND tag them with language metadata.
## Integration Points
### When Called
- After audio stream detection in `run_ffmpeg()`
- Before codec selection and FFmpeg command building
- Only if `audio_filter_config.enabled = True` AND `audio_filter_config.interactive = True`
### Stream Information Provided
- **Index**: Stream number in FFmpeg (0-based)
- **Channels**: 2ch, 6ch, etc.
- **Language**: eng, spa, und (undefined), etc.
- **Bitrate**: Detected bitrate in kbps
### What Gets Removed
- All streams NOT selected by user
- Metadata and descriptors for removed streams
- No re-encoding of audio (codec decisions apply per stream)
## Tips & Tricks
### Keeping Only Surround Audio
Most videos have stereo + surround. To keep only 5.1/6ch:
```
Input: 1 (if Stream #1 is 6ch)
```
### Keeping All Commentary
Commentary tracks are usually indexed separately:
```
Input: 0,2,3 (Stream #0 main + #2 and #3 commentary)
```
### English Only
If you have multiple languages:
```
Input: 0,1 (Stream #0 and #1 English only)
```
## Log Output Examples
**Successful Selection**
```
User selected 2 audio stream(s): [1, 3]
Removed 1 audio stream(s): [0, 2]
```
**Invalid Input**
```
User provided invalid audio selection input
Keeping all audio streams
```
**No Selection**
```
Keeping all audio streams
```
## Troubleshooting
**Issue**: Prompt doesn't appear
- **Solution**: Make sure both --filter-audio AND --interactive are specified
**Issue**: Selection is ignored
- **Solution**: Check log file for errors. Verify stream indices exist.
**Issue**: Want automatic mode back
- **Solution**: Use --filter-audio alone (without --interactive)

167
README.md
View File

@ -1,167 +0,0 @@
# AV1 Batch Video Transcoder
A high-performance batch video transcoding tool using NVIDIA's **AV1 NVENC** codec with intelligent audio/subtitle handling and automatic quality optimization.
## ✨ Key Features
- **8-bit AV1 Encoding** - NVIDIA GPU acceleration (yuv420p, preset p7)
- **Smart Audio Processing** - Auto-detects bitrate, AAC for stereo, EAC3 for 5.1, downmixes, re-encodes only when needed
- **Audio Filtering** - Keep only best English audio + Commentary tracks (remove other languages)
- **Subtitle Embedding** - Auto-detects and embeds subtitles (.vtt, .srt, .ass, .ssa, .sub)
- **Smart Resolution** - Scales 4K→1080p, preserves lower resolutions
- **Two-Phase Encoding** - CQ mode first, automatic Bitrate fallback if size threshold exceeded
- **Automatic Cleanup** - Deletes originals + subtitles after successful encoding
- **Test Mode** - Encode one file, check compression ratio before batch processing
- **Optional Language Tagging** - Tag audio streams with language codes
- **CSV Tracking** - Detailed conversion logs with compression ratios
## 🚀 Quick Start
### Requirements
- **Python 3.8+**
- **FFmpeg** with libfdk-aac support
- **NVIDIA GPU** (GeForce RTX 2060+, Quadro, or newer)
- **NVIDIA CUDA Toolkit** (for av1_nvenc support)
### Installation
```bash
# Clone repository
git clone https://github.com/yourusername/conversion_project.git
cd conversion_project
# Install Python dependencies (if any needed in future)
# pip install -r requirements.txt
```
### Basic Usage
```bash
# Encode a TV folder (smart mode)
python main.py "P:\tv\Show Name"
# Test single file before batch processing
python main.py "P:\tv\Show Name" --test
# Force specific quality (CQ 30)
python main.py "P:\movies\Movie" --cq 30
# Force bitrate mode
python main.py "P:\tv\Show" --m bitrate
# Specific resolution
python main.py "P:\movies" --r 720
# Tag audio with language
python main.py "P:\tv\Show" --language eng
```
## 📖 Documentation
- **[Full Usage Guide](README_RESTRUCTURE.md)** - Detailed commands, features, troubleshooting
- **[Technical Architecture](PROJECT_STRUCTURE.md)** - Module breakdown, workflow, config reference
## ⚙️ Configuration
Edit `config.xml` to customize:
```xml
<!-- CQ quality per content type -->
<cq>
<tv_1080>28</tv_1080>
<tv_720>32</tv_720>
<movie_1080>32</movie_1080>
<movie_720>34</movie_720>
</cq>
<!-- Audio bitrate buckets -->
<audio>
<stereo>
<high>192000</high>
<medium>160000</medium>
</stereo>
<multi_channel>
<medium>448000</medium>
<low>384000</low>
</multi_channel>
</audio>
<!-- Subtitle auto-detection -->
<subtitles>
<enabled>true</enabled>
<extensions>.vtt,.srt,.ass,.ssa,.sub</extensions>
</subtitles>
```
## 📊 Example Output
**Input:**
```
Show.S01E01.mkv (1.5GB)
Show.S01E01.en.vtt (subtitle)
```
**Output:**
```
Show.S01E01 - [EHX].mkv (450MB, subtitle embedded, audio tagged)
```
**Compression:** 1.5GB → 450MB (30% ratio, 70% reduction)
## 🔧 Encoding Specs
| Setting | Value |
|---------|-------|
| Video Codec | AV1 (av1_nvenc) |
| Bit Depth | 8-bit (yuv420p) |
| GPU Preset | p1 (high quality) |
| Audio Codec | AAC |
| Audio Mode | Smart (copy or re-encode) |
| Container | MKV |
| Subtitles | Embedded SRT |
## 🎯 Workflow
1. **Scan** folder for video files
2. **Detect** subtitles, audio streams, resolution
3. **Encode** with AV1 codec (Phase 1: CQ)
4. **Check** size threshold (default 75%)
5. **Retry** with Bitrate mode if needed (Phase 2)
6. **Move** encoded file to original location
7. **Cleanup** original + subtitles + temp files
8. **Log** results to CSV tracker
## 📋 Requirements
- Windows 10/11 or Linux
- NVIDIA GPU with NVENC support
- NVIDIA CUDA Toolkit 11.0+
- FFmpeg compiled with av1_nvenc support
- Python 3.8+
## 🛠️ Troubleshooting
**Files not moving?**
- Check `reduction_ratio_threshold` in config.xml (default 0.75)
- Run with `--test` to see compression ratio
**Subtitles not embedding?**
- Verify filename: `video.en.vtt` or `video.vtt`
- Check config.xml `<subtitles><enabled>true</enabled>`
**Wrong audio quality?**
- Adjust CQ values in config.xml per content type
- Use `--cq` override: `python main.py folder --cq 30`
See [Full Guide](README_RESTRUCTURE.md) for more help.
## 📄 License
MIT
## 📞 Support
For detailed usage, see [README_RESTRUCTURE.md](README_RESTRUCTURE.md)
For technical architecture, see [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md)

View File

@ -1,184 +0,0 @@
# AV1 Batch Video Transcoder
A clean, modular batch video transcoding system using NVIDIA's AV1 NVENC codec with intelligent audio and subtitle handling.
## Project Structure
```
conversion_project/
├── main.py - CLI entry point for batch transcoding
├── config.xml - Configuration (encoding settings, audio buckets, etc.)
├── core/ - Core modules
│ ├── config_helper.py - XML configuration loader
│ ├── logger_helper.py - Logging setup
│ ├── process_manager.py - Main transcoding orchestration
│ ├── encode_engine.py - FFmpeg command builder and execution
│ ├── audio_handler.py - Audio stream analysis and bitrate decisions
│ ├── video_handler.py - Video resolution detection and scaling logic
│ └── hardware_helper.py - Hardware detection (GPU/CPU)
├── /rename/ - Separate rename utility (rolling_rename.py)
├── /path_manager/ - GUI path management (kept separate from conversion)
│ ├── gui_path_manager.py
│ ├── transcode.bat
│ ├── paths.txt
│ └── cache/
├── logs/ - Log files and conversion tracker CSV
├── processing/ - Temporary encoding files (cleaned up after move)
└── cache/ (removed) - Folder cache now in /path_manager/cache
```
## Quick Start
### Basic Usage
```bash
# Encode a folder (smart mode: CQ first, bitrate fallback if size exceeds 75%)
python main.py "P:\tv\Show Name"
# Force CQ mode with specific quality
python main.py "P:\movies\Movie" --cq 30
# Force Bitrate mode
python main.py "P:\tv\Show" --m bitrate
# Explicit resolution
python main.py "P:\movies\Movie" --r 1080
# Test mode: encode first file only, show compression ratio, don't move files
python main.py "P:\tv\Show" --test
# Optional: tag audio streams with language code
python main.py "P:\tv\Show" --language eng
```
## Features
- **Hardware Encoding**: NVIDIA av1_nvenc (8-bit yuv420p, preset p7)
- **Smart Audio**: Analyzes streams, re-encodes excessive bitrate, preserves good quality
- **Smart Video**: Detects source resolution, scales 4K→1080p, preserves lower resolutions
- **Subtitle Detection**: Auto-finds and embeds subtitles (vtt, srt, ass, ssa, sub)
- Supports language-prefixed files: `movie.en.vtt`, `movie.eng.vtt`
- Cleans up subtitle files after embedding
- **Two-Phase Encoding** (smart mode):
- Phase 1: Try CQ mode for quality
- Phase 2: Retry failed files with Bitrate mode
- **File Tagging**: Encodes output with ` - [EHX]` suffix
- **CSV Tracking**: Detailed conversion log with compression ratios
- **Automatic Cleanup**: Deletes originals + subtitles after successful move
## Configuration
Edit `config.xml` to customize:
- **CQ Values**: Per content type (tv_1080, tv_720, movie_1080, movie_720)
- **Audio Buckets**: Bitrate targets for stereo/multichannel
- **Fallback Bitrates**: Used in Phase 2 bitrate retry
- **Subtitle Settings**: Extensions to detect, codec for embedding
- **Path Mappings**: Windows ↔ Linux path conversion (optional)
## Encoding Process (Per File)
1. **Detect subtitles**: Looks for matching `.en.vtt`, `.srt`, etc.
2. **Analyze source**: Resolution, audio streams, bitrates
3. **FFmpeg encode**:
- Video: AV1 NVENC (8-bit yuv420p)
- Audio: Per-stream decisions (copy or re-encode)
- Subtitles: Embedded as SRT (if found)
4. **Size check**: Compare output vs original (default 75% threshold)
5. **Move file**: From temp folder → original location with `- [EHX]` suffix
6. **Cleanup**: Delete original file + subtitle file
## Audio Encoding Logic
```
Stereo audio?
├─ YES + 1080p: [>192kbps] ENCODE to 192k AAC, [≤192k] COPY
├─ YES + 720p: [>160kbps] ENCODE to 160k AAC, [≤160k] COPY
└─ NO (Multichannel): ENCODE to 384k/448k AAC (5.1)
```
## Removed Features
- ❌ Sonarr/Radarr integration (was complex, removed for simplicity)
- ❌ Auto-rename based on Sonarr metadata
- ❌ Web UI (kept separate if needed in future)
- ❌ Rename functionality (moved to `/rename` folder)
## Advanced Options
### Test Mode
Encodes first file only, shows compression ratio, leaves file in temp folder:
```bash
python main.py "P:\tv\Show" --test
```
Useful for: Testing CQ values, checking quality before batch conversion.
### Language Tagging (Optional)
Only tags audio if explicitly provided:
```bash
python main.py "P:\tv\Show" --language eng
```
Without `--language` flag, original audio metadata is preserved.
### Resolution Override
Force specific output resolution:
```bash
python main.py "P:\movies" --r 720 # Force 720p
python main.py "P:\tv" --r 1080 # Force 1080p
```
## Output Examples
**Input File:**
```
SupernaturalS07E21.mkv (size: 1.5GB)
SupernaturalS07E21.en.vtt (subtitle)
```
**Output:**
```
SupernaturalS07E21 - [EHX].mkv (size: 450MB, subtitle embedded)
(original files deleted)
```
## Troubleshooting
### Wrong Bitrate
Check CQ values in config.xml or use `--cq` override:
```bash
python main.py "P:\tv\Show" --cq 31
```
### Subtitles Not Embedding
- Verify file is named correctly: `filename.en.vtt` or `filename.vtt`
- Check `config.xml` has subtitles enabled and extensions listed
- Check logs for "Found subtitle" message
### Files Not Moving
Check if reduction ratio threshold (default 0.75) is exceeded:
```bash
python main.py "P:\tv\Show" --test # Check ratio in Phase 1
```
If ratio is high, lower CQ value or use bitrate mode.
## Logs
- `logs/conversion.log`: Detailed encoding info, errors, decisions
- `logs/conversion_tracker.csv`: Summary table of all conversions
- `logs/conversion_failures.log`: Failed file tracking

View File

@ -9,33 +9,14 @@
<processing_folder>processing</processing_folder>
<!-- File suffix added to encoded outputs -->
<suffix> - [EHX]</suffix>
<suffix> -EHX</suffix>
<!-- Allowed input extensions -->
<extensions>.mkv,.mp4</extensions>
<!-- File name tags to skip/ignore -->
<ignore_tags>ehx,._</ignore_tags> <!-- ehx = encoded tag, ._ = macOS metadata files -->
<!-- Reduction ratio threshold: output must be <= this % of original or encoding fails -->
<reduction_ratio_threshold>0.95</reduction_ratio_threshold>
<!-- Subtitle settings -->
<subtitles>
<enabled>true</enabled>
<extensions>.vtt,.srt,.ass,.ssa,.sub</extensions>
<codec>srt</codec>
</subtitles>
<!-- Audio track filtering: keep only best English audio + Commentary -->
<audio_filter>
<enabled>false</enabled>
<!-- When true: keeps primary English audio (most channels/bitrate) + any Commentary tracks -->
<!-- When false: keeps all audio tracks -->
</audio_filter>
<!-- Audio language tag -->
<audio_language>eng</audio_language>
<!-- Reduction ratio threshold: if output >= this ratio of input, retry/fail -->
<!-- Default 0.5 = 50% (generic). Can override with ratio flag -->
<reduction_ratio_threshold>0.65</reduction_ratio_threshold>
</general>
<!-- =============================
@ -44,32 +25,39 @@
<path_mappings>
<map from="P:\tv" to="/mnt/plex/tv" />
<map from="P:\anime" to="/mnt/plex/anime" />
<map from="P:\movies" to="/mnt/plex/movies" />
</path_mappings>
<!-- =============================
SONARR / RADARR SETTINGS
============================= -->
<services>
<sonarr>
<url>http://10.0.0.10:8989/api/v3</url>
<api_key>a3458e2a095e4e1c892626c4a4f6959f</api_key>
</sonarr>
<radarr>
<url>http://10.0.0.10:7878/api/v3</url>
<api_key></api_key>
</radarr>
</services>
<!-- =============================
ENCODE SETTINGS
============================= -->
<encode>
<!-- CQ defaults (per resolution / content type / encoder) -->
<!-- CQ defaults (per resolution / content type) -->
<cq>
<av1>
<tv_1080>32</tv_1080>
<tv_720>30</tv_720>
<anime_1080>32</anime_1080>
<anime_720>30</anime_720>
<movie_1080>32</movie_1080>
<movie_720>30</movie_720>
</av1>
<hevc>
<tv_1080>28</tv_1080>
<tv_720>26</tv_720>
<anime_1080>28</anime_1080>
<anime_720>26</anime_720>
<movie_1080>28</movie_1080>
<movie_720>26</movie_720>
</hevc>
<tv_1080>28</tv_1080>
<tv_720>32</tv_720>
<movie_1080>32</movie_1080>
<movie_720>34</movie_720>
</cq>
<crf>
<tv_1080>28</tv_1080>
<tv_720>32</tv_720>
<movie_1080>32</movie_1080>
<movie_720>34</movie_720>
</crf>
<!-- Fallback bitrate-based mode -->
<fallback>
@ -77,9 +65,9 @@
<maxrate_1080>1750k</maxrate_1080>
<bufsize_1080>2750k</bufsize_1080>
<bitrate_720>1200k</bitrate_720>
<maxrate_720>1450k</maxrate_720>
<bufsize_720>2200k</bufsize_720>
<bitrate_720>900k</bitrate_720>
<maxrate_720>1250k</maxrate_720>
<bufsize_720>1800k</bufsize_720>
</fallback>
<!-- Scale filter defaults -->
@ -94,14 +82,26 @@
============================= -->
<audio>
<stereo>
<low>128000</low>
<medium>160000</medium>
<high>192000</high>
<low>96000</low>
<medium>128000</medium>
<high>160000</high>
</stereo>
<multi_channel>
<low>384000</low>
<medium>448000</medium>
<medium>512000</medium>
<high>640000</high>
</multi_channel>
<codec_rules>
<use_opus_below_kbps>128</use_opus_below_kbps>
</codec_rules>
</audio>
<!-- =============================
IGNORE LIST (filenames to skip)
============================= -->
<ignore_tags>
<tag>ehx</tag>
<tag>megusta</tag>
</ignore_tags>
</config>

File diff suppressed because it is too large Load Diff

View File

@ -1,427 +0,0 @@
# core/audio_handler.py
"""Audio stream detection, bitrate calculation, and codec selection."""
import json
import os
import subprocess
import tempfile
from pathlib import Path
from core.logger_helper import setup_logger
logger = setup_logger(Path(__file__).parent.parent / "logs")
def calculate_stream_bitrate(input_file: Path, stream_index: int) -> int:
"""
Extract audio stream to temporary file using -c copy, capture bitrate from ffmpeg output.
Returns bitrate in kbps. Falls back to 0 (and uses metadata) if extraction fails.
Uses ffmpeg's reported bitrate which is more accurate than calculating from file size/duration.
"""
# Ensure input file exists and is readable
input_file = Path(input_file)
if not input_file.exists():
logger.error(f"Input file does not exist: {input_file}")
return 0
if not os.access(input_file, os.R_OK):
logger.error(f"Input file is not readable (permission denied): {input_file}")
return 0
# Use project processing directory for temp files
processing_dir = Path(__file__).parent.parent / "processing"
processing_dir.mkdir(exist_ok=True)
# Determine the codec of this audio stream first
probe_cmd = [
"ffprobe", "-v", "error",
"-select_streams", f"a:{stream_index}",
"-show_entries", "stream=codec_name",
"-of", "default=noprint_wrappers=1:nokey=1",
str(input_file)
]
try:
probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
codec_name = probe_result.stdout.strip().lower() if probe_result.stdout and probe_result.returncode == 0 else "aac"
except:
codec_name = "aac"
# Use MKA (Matroska Audio) which supports any codec
# This is a universal container that works with AC3, AAC, FLAC, DTS, Opus, etc.
temp_ext = ".mka"
temp_fd, temp_audio_path = tempfile.mkstemp(suffix=temp_ext, dir=str(processing_dir))
os.close(temp_fd)
try:
# Step 1: Extract audio stream with -c copy (lossless extraction)
# ffmpeg outputs bitrate info to stderr
extract_cmd = [
"ffmpeg", "-y", "-i", str(input_file),
"-map", f"0:a:{stream_index}",
"-c", "copy",
temp_audio_path
]
logger.debug(f"Extracting audio stream {stream_index} ({codec_name}) to temporary file for bitrate calculation...")
result = subprocess.run(extract_cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
# Check if extraction succeeded
if result.returncode != 0:
logger.warning(f"Stream {stream_index}: ffmpeg extraction failed (return code {result.returncode})")
if result.stderr:
logger.debug(f"ffmpeg stderr: {result.stderr[:300]}")
return 0
# Step 2: Parse bitrate from ffmpeg's output (stderr)
# Look for line like: "bitrate= 457.7kbits/s"
bitrate_kbps = 0
stderr_lines = result.stderr if result.stderr else ""
for line in stderr_lines.split("\n"):
if "bitrate=" in line:
# Extract bitrate value from line like "size= 352162KiB time=01:45:03.05 bitrate= 457.7kbits/s"
parts = line.split("bitrate=")
if len(parts) > 1:
bitrate_str = parts[1].strip().split("kbits/s")[0].strip()
try:
bitrate_kbps = int(float(bitrate_str))
logger.debug(f"Stream {stream_index}: Extracted bitrate from ffmpeg output: {bitrate_kbps} kbps")
break
except ValueError:
continue
# If we couldn't parse bitrate from output, fall back to calculation
if bitrate_kbps == 0:
logger.debug(f"Stream {stream_index}: Could not parse bitrate from ffmpeg output, calculating from file size...")
file_size_bytes = os.path.getsize(temp_audio_path)
# Get duration using ffprobe
duration_cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1:noprint_wrappers=1",
temp_audio_path
]
duration_result = subprocess.run(duration_cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
try:
duration_seconds = float(duration_result.stdout.strip()) if duration_result.stdout else 1.0
bitrate_kbps = int((file_size_bytes * 8) / duration_seconds / 1000)
logger.debug(f"Stream {stream_index}: Calculated bitrate from file: {bitrate_kbps} kbps")
except (ValueError, ZeroDivisionError):
logger.warning(f"Stream {stream_index}: Could not parse duration from ffprobe")
return 0
return bitrate_kbps
except Exception as e:
logger.warning(f"Failed to calculate bitrate for stream {stream_index}: {e}. Will fall back to metadata.")
return 0
finally:
# Clean up temporary audio file
try:
if os.path.exists(temp_audio_path):
os.remove(temp_audio_path)
logger.debug(f"Deleted temporary audio file: {temp_audio_path}")
except Exception as e:
logger.warning(f"Could not delete temporary file {temp_audio_path}: {e}")
def get_audio_streams(input_file: Path):
"""
Detect audio streams and calculate robust bitrates by extracting each stream.
Returns list of (index, channels, calculated_bitrate_kbps, language, metadata_bitrate_kbps, title)
"""
import re
# First, get full ffprobe output to extract language codes and titles
probe_cmd = ["ffprobe", "-v", "info", str(input_file)]
probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore')
# Parse language and title from output
language_map = {}
title_map = {}
stderr_output = probe_result.stderr if probe_result.stderr else ""
# Parse the verbose output to extract stream info and metadata
current_stream_idx = None
for line in stderr_output.split("\n"):
# Match "Stream #0:X(YYY)" where X is stream number, YYY is language
stream_match = re.search(r"Stream #0:(\d+)\((\w{3})\)", line)
if stream_match:
current_stream_idx = int(stream_match.group(1))
lang_code = stream_match.group(2)
language_map[current_stream_idx] = lang_code
# Match "title : <title text>" in metadata sections
if current_stream_idx is not None and "title" in line.lower():
title_match = re.search(r"title\s*:\s*(.+)$", line, re.IGNORECASE)
if title_match:
title_text = title_match.group(1).strip()
if title_text: # Only store if not empty
title_map[current_stream_idx] = title_text
logger.debug(f"Parsed title for stream {current_stream_idx}: '{title_text}'")
# Get audio stream details via JSON with tags
cmd = [
"ffprobe","-v","error","-select_streams","a",
"-show_entries","stream=index,channels,bit_rate,codec_name",
"-of","json", str(input_file)
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore')
try:
data = json.loads(result.stdout) if result.stdout else {"streams": []}
except (json.JSONDecodeError, TypeError):
data = {"streams": []}
streams = []
for stream_num, s in enumerate(data.get("streams", [])):
index = s["index"]
channels = s.get("channels", 2)
codec_name = s.get("codec_name", "unknown").upper()
# Get language from our parsed map, default to "und"
src_lang = language_map.get(index, "und")
# Get title from our parsed text output
title = title_map.get(index, "")
bit_rate_meta = int(s.get("bit_rate", 0)) if s.get("bit_rate") else 0
# Calculate robust bitrate by extracting the audio stream
calculated_bitrate_kbps = calculate_stream_bitrate(input_file, stream_num)
# If calculation failed, fall back to metadata
if calculated_bitrate_kbps == 0:
calculated_bitrate_kbps = int(bit_rate_meta / 1000) if bit_rate_meta else 160
logger.debug(f"Stream {index}: Using fallback bitrate {calculated_bitrate_kbps} kbps")
# Log title extraction for debugging
if title:
logger.debug(f"Stream {index}: Extracted title from metadata: '{title}'")
streams.append((index, channels, calculated_bitrate_kbps, src_lang, int(bit_rate_meta / 1000) if bit_rate_meta else 0, title, codec_name))
return streams
def choose_audio_bitrate(channels: int, bitrate_kbps: int, audio_config: dict, is_1080_class: bool) -> tuple:
"""
Choose audio codec and bitrate based on channel count, detected bitrate, and resolution.
Returns tuple: (codec, target_bitrate_bps)
- codec: "aac" (stereo), "eac3" (5.1), or "copy" (preserve original)
- target_bitrate_bps: target bitrate in bits/sec (0 if using "copy")
Rules:
Stereo + 1080p:
- Above 192k encode to 192k with AAC
- At/below 192k preserve (copy)
Stereo + 720p:
- Above 160k encode to 160k with AAC
- At/below 160k preserve (copy)
Multi-channel (5.1+):
- Below minimum threshold preserve original (copy)
- Low to medium use EAC3 codec
"""
# Normalize to 2ch or 6ch output
output_channels = 6 if channels >= 6 else 2
if output_channels == 2:
# Stereo logic - use AAC
if is_1080_class:
# 1080p+ stereo
high_br = audio_config["stereo"]["high"]
if bitrate_kbps > (high_br / 1000): # Above 192k
return ("aac", high_br)
else:
# Preserve original
return ("copy", 0)
else:
# 720p stereo
medium_br = audio_config["stereo"]["medium"]
if bitrate_kbps > (medium_br / 1000): # Above 160k
return ("aac", medium_br)
else:
# Preserve original
return ("copy", 0)
else:
# Multi-channel (6ch+) logic - use EAC3
low_br = audio_config["multi_channel"]["low"]
medium_br = audio_config["multi_channel"]["medium"]
# If below the lowest threshold, copy the original audio instead of re-encoding
if bitrate_kbps < (low_br / 1000):
logger.info(f"Multi-channel audio {bitrate_kbps}kbps < {low_br/1000:.0f}k minimum - copying original to avoid artifical inflation")
return ("copy", 0)
elif bitrate_kbps < (medium_br / 1000):
# Below medium, use low with EAC3
return ("eac3", low_br)
else:
# Medium and above, use medium with EAC3
return ("eac3", medium_br)
def filter_audio_streams(input_file: Path, streams: list) -> list:
"""
Filter audio streams to keep only best English audio + Commentary tracks.
Args:
input_file: Path to video file
streams: List of (index, channels, bitrate, language, metadata, title) tuples
Returns:
Filtered list of streams (original indices preserved for FFmpeg mapping)
"""
if not streams:
return streams
# Try to get stream metadata (title) to detect commentary
english_tracks = []
commentary_tracks = []
for stream_info in streams:
index, channels, bitrate, language, metadata, title = stream_info
# Check if commentary (in title or metadata)
is_commentary = "comment" in str(title).lower() or "comment" in str(metadata).lower()
# Determine if English (check language field or assume first is English if no language set)
is_english = (language and "eng" in language.lower()) or (not language)
if is_commentary:
commentary_tracks.append((index, channels, bitrate, stream_info))
elif is_english:
english_tracks.append((index, channels, bitrate, stream_info))
# If no English tracks, return original
if not english_tracks:
logger.info("No English audio tracks detected - keeping all audio")
return streams
# Pick best English track (most channels, then highest bitrate)
english_tracks.sort(key=lambda x: (-x[1], -x[2])) # Sort by channels desc, then bitrate desc
best_english = english_tracks[0][3] # Get original stream tuple
logger.info(f"Audio filter: Keeping best English track (index {best_english[0]}: {best_english[1]}ch @ {best_english[2]}kbps)")
# Build result: best English + all commentary
filtered = [best_english] + [ct[3] for ct in commentary_tracks]
if commentary_tracks:
logger.info(f"Audio filter: Also keeping {len(commentary_tracks)} commentary track(s)")
# Log removed tracks
removed_count = len(streams) - len(filtered)
if removed_count > 0:
logger.info(f"Audio filter: Removed {removed_count} non-English audio track(s)")
return filtered
def prompt_user_audio_selection(streams: list) -> list:
"""
Interactively prompt user to select which audio streams to keep.
Args:
streams: List of (index, channels, bitrate, language, metadata, title) tuples
Returns:
Filtered list containing only selected streams
"""
if not streams or len(streams) <= 1:
return streams
print("\n" + "="*80)
print("🎵 AUDIO STREAM SELECTION")
print("="*80)
# Display all streams with details
for index, channels, bitrate, language, metadata, title, codec_name in streams:
channels_display = f"{channels}ch"
lang_display = language if language != "und" else "undefined"
# Display title if available
if title:
title_display = f" | {title}"
else:
title_display = ""
print(f"\nStream #{index}: {channels_display} | Lang: {lang_display} | Codec: {codec_name} | Bitrate: {bitrate}kbps{title_display}")
print("\n" + "-"*80)
print("Enter stream numbers to keep (comma-separated, e.g.: 1,2 or just 2)")
print("Leave blank to keep all streams")
print("-"*80)
user_input = input("➜ Keep streams: ").strip()
# If empty, keep all
if not user_input:
print("✅ Keeping all audio streams\n")
return streams
# Parse user input
try:
selected_indices = set()
for part in user_input.split(","):
idx = int(part.strip())
selected_indices.add(idx)
except ValueError:
print("❌ Invalid input. Keeping all streams.")
logger.warning("User provided invalid audio selection input")
return streams
# Filter streams to only selected ones
filtered = [s for s in streams if s[0] in selected_indices]
if not filtered:
print("❌ No valid streams selected. Keeping all streams.")
logger.warning("User selected no valid streams")
return streams
# Log what was selected/removed
removed_count = len(streams) - len(filtered)
print(f"✅ Keeping {len(filtered)} stream(s), removing {removed_count} stream(s)\n")
logger.info(f"User selected {len(filtered)} audio stream(s): {[s[0] for s in filtered]}")
if removed_count > 0:
removed_indices = [s[0] for s in streams if s[0] not in selected_indices]
logger.info(f"Removed {removed_count} audio stream(s): {removed_indices}")
# Return filtered streams without strip_title field - let prompt_for_title_stripping handle that
return filtered
def prompt_for_title_stripping(filtered_streams: list) -> list:
"""
Prompt user to select which streams should have titles stripped.
Args:
filtered_streams: List of (index, channels, bitrate, language, metadata, title, codec_name) tuples
Returns:
Same list (no modifications - strip_all_titles is handled globally via CLI flag)
"""
streams_with_titles = [(s[0], s[5]) for s in filtered_streams if s[5]]
if not streams_with_titles:
return filtered_streams
print("\n" + "="*80)
print("📝 TITLE METADATA STRIPPING (Optional)")
print("="*80)
print("\nStreams with titles that can be stripped:\n")
for idx, title in streams_with_titles:
print(f" Stream #{idx}: \"{title}\"")
print("\n" + "-"*80)
print("Note: Use --strip-all-titles CLI flag to strip all titles globally")
print("-"*80 + "\n")
return filtered_streams

View File

@ -8,7 +8,6 @@ DEFAULT_XML = """<?xml version="1.0" encoding="UTF-8"?>
<processing_folder>processing</processing_folder>
<suffix> -EHX</suffix>
<extensions>.mkv,.mp4</extensions>
<ignore_tags>ehx,megusta</ignore_tags>
<reduction_ratio_threshold>0.5</reduction_ratio_threshold>
</general>
<path_mappings>
@ -25,10 +24,10 @@ DEFAULT_XML = """<?xml version="1.0" encoding="UTF-8"?>
<fallback>
<bitrate_1080>1500k</bitrate_1080>
<maxrate_1080>1750k</maxrate_1080>
<bufsize_1080>2750k</bufsize_1080>
<bufsize_1080>2250k</bufsize_1080>
<bitrate_720>900k</bitrate_720>
<maxrate_720>1250k</maxrate_720>
<bufsize_720>1800k</bufsize_720>
<bufsize_720>1600k</bufsize_720>
</fallback>
<filters>
<default>lanczos</default>
@ -38,15 +37,18 @@ DEFAULT_XML = """<?xml version="1.0" encoding="UTF-8"?>
<audio>
<stereo>
<low>64000</low>
<medium>128000</medium>
<high>160000</high>
<medium>96000</medium>
<high>128000</high>
</stereo>
<multi_channel>
<low>384000</low>
<medium>512000</medium>
<high>640000</high>
<low>160000</low>
<high>192000</high>
</multi_channel>
</audio>
<ignore_tags>
<tag>ehx</tag>
<tag>megusta</tag>
</ignore_tags>
</config>
"""
@ -69,19 +71,16 @@ def load_config_xml(path: Path) -> dict:
extensions_elem = general.find("extensions") if general is not None else None
extensions = extensions_elem.text.split(",") if extensions_elem is not None else [".mkv", ".mp4"]
ignore_tags_elem = general.find("ignore_tags") if general is not None else None
ignore_tags = ignore_tags_elem.text.split(",") if ignore_tags_elem is not None else ["ehx", "megusta"]
reduction_ratio_elem = general.find("reduction_ratio_threshold") if general is not None else None
reduction_ratio_threshold = float(reduction_ratio_elem.text) if reduction_ratio_elem is not None else 0.5
# --- Path Mappings ---
path_mappings = []
path_mappings = {}
for m in root.findall("path_mappings/map"):
f = m.attrib.get("from")
t = m.attrib.get("to")
if f and t:
path_mappings.append({"from": f, "to": t})
path_mappings[f] = t
# --- Encode ---
encode_elem = root.find("encode")
@ -91,80 +90,49 @@ def load_config_xml(path: Path) -> dict:
if encode_elem is not None:
cq_elem = encode_elem.find("cq")
if cq_elem is not None:
# Check if CQ has encoder-specific sub-elements (av1, hevc)
encoder_elems = list(cq_elem)
if encoder_elems and encoder_elems[0].tag in ["av1", "hevc"]:
# New nested structure with encoder-specific CQ values
for encoder_tag in cq_elem:
if encoder_tag.tag in ["av1", "hevc"]:
cq[encoder_tag.tag] = {}
for child in encoder_tag:
if child.text and child.text.strip():
cq[encoder_tag.tag][child.tag] = int(child.text.strip())
else:
# Old flat structure (backwards compatibility)
for child in cq_elem:
if child.text and child.text.strip():
cq[child.tag] = int(child.text.strip())
for child in cq_elem:
if child.text:
cq[child.tag] = int(child.text)
fallback_elem = encode_elem.find("fallback")
if fallback_elem is not None:
for child in fallback_elem:
if child.text and child.text.strip():
fallback[child.tag] = child.text.strip()
if child.text:
fallback[child.tag] = child.text
filters_elem = encode_elem.find("filters")
if filters_elem is not None:
for child in filters_elem:
if child.text and child.text.strip():
filters[child.tag] = child.text.strip()
if child.text:
filters[child.tag] = child.text
# --- Audio ---
audio = {"stereo": {}, "multi_channel": {}}
stereo_elem = root.find("audio/stereo")
if stereo_elem is not None:
for child in stereo_elem:
if child.text and child.text.strip():
audio["stereo"][child.tag] = int(child.text.strip())
if child.text:
audio["stereo"][child.tag] = int(child.text)
multi_elem = root.find("audio/multi_channel")
if multi_elem is not None:
for child in multi_elem:
if child.text and child.text.strip():
audio["multi_channel"][child.tag] = int(child.text.strip())
if child.text:
audio["multi_channel"][child.tag] = int(child.text)
# --- Services (Sonarr/Radarr) ---
services = {"sonarr": {}, "radarr": {}}
sonarr_elem = root.find("services/sonarr")
if sonarr_elem is not None:
url_elem = sonarr_elem.find("url")
api_elem = sonarr_elem.find("api_key")
rg_elem = sonarr_elem.find("new_release_group")
services["sonarr"] = {
"url": url_elem.text if url_elem is not None and url_elem.text else None,
"api_key": api_elem.text if api_elem is not None and api_elem.text else None,
"new_release_group": rg_elem.text if rg_elem is not None and rg_elem.text else "CONVERTED"
}
radarr_elem = root.find("services/radarr")
if radarr_elem is not None:
url_elem = radarr_elem.find("url")
api_elem = radarr_elem.find("api_key")
rg_elem = radarr_elem.find("new_release_group")
services["radarr"] = {
"url": url_elem.text if url_elem is not None and url_elem.text else None,
"api_key": api_elem.text if api_elem is not None and api_elem.text else None,
"new_release_group": rg_elem.text if rg_elem is not None and rg_elem.text else "CONVERTED"
}
# --- Ignore Tags ---
ignore_tags = []
for tag_elem in root.findall("ignore_tags/tag"):
if tag_elem.text:
ignore_tags.append(tag_elem.text)
return {
"processing_folder": processing_folder,
"suffix": suffix,
"extensions": [ext.lower() for ext in extensions],
"ignore_tags": [tag.strip() for tag in ignore_tags],
"reduction_ratio_threshold": reduction_ratio_threshold,
"path_mappings": path_mappings,
"encode": {"cq": cq, "fallback": fallback, "filters": filters},
"audio": audio,
"services": services
"ignore_tags": ignore_tags,
"reduction_ratio_threshold": reduction_ratio_threshold
}

View File

@ -1,335 +0,0 @@
# core/encode_engine.py
"""FFmpeg encoding engine with comprehensive logging."""
import subprocess
from pathlib import Path
from core.audio_handler import get_audio_streams, choose_audio_bitrate, filter_audio_streams, prompt_user_audio_selection, prompt_for_title_stripping
from core.logger_helper import setup_logger
logger = setup_logger(Path(__file__).parent.parent / "logs")
def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, scale_height: int,
src_width: int, src_height: int, filter_flags: str, audio_config: dict,
method: str, bitrate_config: dict, encoder: str = "nvenc", subtitle_files: list = None, audio_language: str = None,
audio_filter_config: dict = None, test_mode: bool = False, strip_all_titles: bool = False, src_bit_depth: int = None, unforce_subs: bool = False, no_encode: bool = False):
"""
Execute FFmpeg encoding/re-muxing with structured console output.
Args:
input_file: Path to source video file
output_file: Path for encoded output file
cq: Quality value (0-63, lower=better) for CQ mode
scale_width/height: Target resolution dimensions
src_width/height: Source resolution dimensions
filter_flags: Scaling filter algorithm (lanczos, bicubic, etc)
audio_config: Audio bitrate configuration dict
method: Encoding method - "CQ" or "Bitrate"
bitrate_config: Bitrate/maxrate/bufsize configuration dict
encoder: Video codec - "hevc", "av1", or "nvenc"
subtitle_files: List of external subtitle file paths (if any)
audio_language: ISO 639-2 language code to tag audio (e.g., "eng", "spa")
audio_filter_config: Audio filtering/selection configuration
test_mode: If True, only encode first 15 minutes, don't move files
strip_all_titles: If True, strip title metadata from all audio tracks
src_bit_depth: Source bit depth (8/10/12) for encoder auto-selection
unforce_subs: If True, remove forced flag from subtitle tracks
no_encode: If True, copy video/audio (re-mux only, skip encoding)
Returns:
tuple: (orig_size_bytes, output_size_bytes, reduction_ratio)
"""
streams = get_audio_streams(input_file)
# Apply audio filter if enabled
if audio_filter_config and audio_filter_config.get("enabled", False):
# Check if pre-selected streams provided
if audio_filter_config.get("preselected"):
# Use pre-selected streams (skip interactive)
preselected_str = audio_filter_config["preselected"]
try:
selected_indices = set()
for part in preselected_str.split(","):
idx = int(part.strip())
selected_indices.add(idx)
# Filter to only selected streams
streams = [s for s in streams if s[0] in selected_indices]
logger.info(f"Pre-selected audio streams: {[s[0] for s in streams]}")
except ValueError:
logger.warning(f"Invalid audio_select format: {preselected_str}. Using all streams.")
else:
# Check if interactive mode requested (via --filter-audio CLI flag)
# If audio_filter_config came from CLI, it has "interactive": True
if "interactive" in audio_filter_config and audio_filter_config.get("interactive", False):
# Interactive audio selection (show prompt to user)
streams = prompt_user_audio_selection(streams)
# Prompt for title stripping after stream selection
streams = prompt_for_title_stripping(streams)
else:
# Automatic filtering from config (keep best English + Commentary)
streams = filter_audio_streams(input_file, streams)
# Determine encoder display name and settings
if encoder == "av1":
encoder_name = "AV1 NVENC"
encoder_codec = "av1_nvenc"
encoder_preset = "p7" # p7 = fastest/lower quality (0-7 scale)
encoder_pix_fmt = "yuv420p"
encoder_bit_depth = "8-bit"
else: # default hevc = HEVC NVENC
encoder_name = "HEVC NVENC"
encoder_codec = "hevc_nvenc"
encoder_preset = "p7" # p7 = fastest/lower quality (0-7 scale)
encoder_pix_fmt = "p010le"
encoder_bit_depth = "10-bit"
# Auto-select encoder based on detected source bit depth if provided
if src_bit_depth is not None:
if src_bit_depth >= 10:
# Source is 10-bit or higher - use HEVC NVENC
encoder_name = "HEVC NVENC"
encoder_codec = "hevc_nvenc"
encoder_preset = "p7"
encoder_pix_fmt = "p010le"
encoder_bit_depth = "10-bit"
logger.info(f"Auto-selected HEVC NVENC for detected {src_bit_depth}-bit source")
else:
# Source is 8-bit - use AV1 NVENC
encoder_name = "AV1 NVENC"
encoder_codec = "av1_nvenc"
encoder_preset = "p7"
encoder_pix_fmt = "yuv420p"
encoder_bit_depth = "8-bit"
logger.info(f"Auto-selected AV1 NVENC for detected {src_bit_depth}-bit source")
# Debug: log audio_language received
logger.debug(f"audio_language parameter: {audio_language}")
# Build simple console summary
audio_summary_lines = []
for (index, channels, avg_bitrate, src_lang, meta_bitrate, title, codec_name) in streams:
# Normalize to 2ch or 6ch output
is_1080_class = scale_height >= 1080 or scale_width >= 1920
output_channels = 6 if is_1080_class and channels >= 6 else 2
codec, br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
if codec == "copy":
action = "COPY"
output_codec = codec_name
output_bitrate = f"{avg_bitrate}kbps"
else:
action = "ENC"
# Determine output codec based on encode choice
output_codec = "EAC3" if codec == "eac3" else "AAC"
output_bitrate = f"{br/1000:.0f}kbps"
# Show language change if audio_language is set
lang_info = f"{src_lang}{audio_language}" if audio_language else src_lang
# Include title in display if present
title_info = f" [{title}]" if title else ""
line = f" - Stream #{index}: {channels}ch→{output_channels}ch | {lang_info} | Detected: {codec_name} {avg_bitrate}kbps | Output: {output_codec} {output_bitrate} ({action}){title_info}"
audio_summary_lines.append(line)
cmd = ["ffmpeg","-y","-i",str(input_file)]
# Add subtitle inputs if present
if subtitle_files:
for sub_file in subtitle_files:
cmd.extend(["-i", str(sub_file)])
# In test mode, only encode first 15 minutes
if test_mode:
cmd.extend(["-t", "900"]) # 900 seconds = 15 minutes
# Only add scale filter if encoding (not copying)
if not no_encode:
cmd.extend([
"-vf",f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease"])
cmd.extend(["-map","0:v:0"]) # Map only first actual video stream (skips attached pictures)
# Map only selected audio streams
for index, _, _, _, _, _, _ in streams:
cmd.extend(["-map", f"0:{index}"])
# Add subtitle mapping if present
if subtitle_files:
for i, _ in enumerate(subtitle_files):
cmd.extend(["-map", f"{i+1}:s"])
else:
cmd.extend(["-map", "0:s?"])
# Video codec: copy if no_encode, otherwise use specified encoder
if no_encode:
cmd.extend(["-c:v", "copy"])
else:
cmd.extend([
"-c:v", encoder_codec, "-preset", encoder_preset, "-pix_fmt", encoder_pix_fmt])
if method=="CQ":
cmd += ["-cq", str(cq)]
else:
# Use bitrate config (fallback mode)
res_key = "1080" if scale_height >= 1080 or scale_width >= 1920 else "720"
vb = bitrate_config.get(f"bitrate_{res_key}", "900k")
maxrate = bitrate_config.get(f"maxrate_{res_key}", "1250k")
bufsize = bitrate_config.get(f"bufsize_{res_key}", "1800k")
cmd += ["-b:v", vb, "-maxrate", maxrate, "-bufsize", bufsize]
for i, (index, channels, avg_bitrate, src_lang, meta_bitrate, title, codec_name) in enumerate(streams):
# Normalize to 2ch or 6ch output
is_1080_class = scale_height >= 1080 or scale_width >= 1920
output_channels = 6 if is_1080_class and channels >= 6 else 2
# If no_encode is True, always copy audio
if no_encode:
codec, br = "copy", avg_bitrate
else:
codec, br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
# Check if title should be stripped (for this stream or globally)
# Preserve any stream with "commentary" in the title, regardless of strip_all_titles
is_commentary = title and "commentary" in title.lower()
should_strip = strip_all_titles and not is_commentary
# Log title stripping decisions for debugging (debug level, not info)
logger.debug(f"Stream {index}: title='{title}', is_commentary={is_commentary}, strip_all_titles={strip_all_titles}, should_strip={should_strip}")
if strip_all_titles and is_commentary:
logger.debug(f"Stream {index}: ✓ Preserving title '{title}' (contains 'commentary')")
if codec == "copy":
# Preserve original audio
cmd += [f"-c:a:{i}", "copy"]
# Only add language metadata if explicitly provided
if audio_language:
cmd += [f"-metadata:s:a:{i}", f"language={audio_language}"]
# Strip title metadata if requested (but preserve commentary tracks)
if should_strip:
cmd += [f"-metadata:s:a:{i}", "title="]
else:
# Re-encode with target bitrate
# EAC3 for multichannel, AAC for stereo
if codec == "eac3":
# Enhanced AC-3 (5.1 surround)
cmd += [
f"-c:a:{i}", "eac3",
f"-b:a:{i}", str(br),
f"-ac:{i}", str(output_channels),
f"-channel_layout:a:{i}", "5.1"
]
else:
# AAC (stereo)
cmd += [
f"-c:a:{i}", "aac",
f"-b:a:{i}", str(br),
f"-ac:{i}", str(output_channels),
f"-channel_layout:a:{i}", "stereo"
]
# Only add language metadata if explicitly provided
if audio_language:
cmd += [f"-metadata:s:a:{i}", f"language={audio_language}"]
# Strip title metadata if requested (but preserve commentary tracks)
if should_strip:
cmd += [f"-metadata:s:a:{i}", "title="]
# Add subtitle codec and metadata if subtitles are present
if subtitle_files:
cmd += ["-c:s", "srt"]
for i in range(len(subtitle_files)):
cmd += ["-metadata:s:s:" + str(i), "language=eng"]
if unforce_subs:
cmd += ["-disposition:s:" + str(i), "-forced"]
else:
cmd += ["-c:s", "copy"]
# For embedded subtitles, still apply -disposition if unforce_subs is enabled
if unforce_subs:
# Apply to all embedded subtitle streams
cmd += ["-disposition:s", "-forced"]
cmd += [str(output_file)]
# Print detailed console output with VIDEO and AUDIO sections
print(f"\n🎬 Encoding: {output_file.name}")
# VIDEO SECTION
print(f"📹 VIDEO")
# Build resolution and bit depth info
detected_bit = f" {src_bit_depth}-bit" if src_bit_depth else ""
output_bit = f" {encoder_bit_depth}"
if scale_width != src_width or scale_height != src_height:
res_info = f"Detected: {src_width}x{src_height}{detected_bit} | Output: {scale_width}x{scale_height}{output_bit}"
else:
res_info = f"Detected: {src_width}x{src_height}{detected_bit} | Output: {scale_width}x{scale_height}{output_bit}"
cq_info = f"CQ {cq}" if method == "CQ" else f"VBR {bitrate_config.get('bitrate_1080', '900k')}"
test_str = " [TEST 15min]" if test_mode else ""
print(f" {res_info} | {encoder_name} preset {encoder_preset} | {cq_info}{test_str}")
# AUDIO SECTION
print(f"🔊 AUDIO")
for line in audio_summary_lines:
print(line)
logger.debug(f"Running {method} encode: {output_file.name}")
# Run FFmpeg with stderr/stdout captured (hide version/config info)
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
# Print progress section header
print(f"\n⏳ PROGRESS")
# Read output line by line but only print progress-related lines
ffmpeg_log = []
import re
for line in process.stdout:
ffmpeg_log.append(line.rstrip())
# Only print progress lines (frame= indicates encoding progress)
if "frame=" in line:
# Extract key metrics: time, bitrate, and elapsed
time_match = re.search(r'time=(\S+)', line)
bitrate_match = re.search(r'bitrate=(\S+)', line)
elapsed_match = re.search(r'elapsed=(\S+)', line)
time_str = time_match.group(1) if time_match else "00:00:00"
bitrate_str = bitrate_match.group(1) if bitrate_match else "0kbps"
elapsed_str = elapsed_match.group(1) if elapsed_match else "0:00:00"
# Print with carriage return to update same line (no newline, use \r to go back to start)
print(f"\r {time_str} | {bitrate_str} | elapsed={elapsed_str}", end='', flush=True)
print() # Newline after encoding completes
returncode = process.wait()
if returncode != 0:
# Log full FFmpeg output if there was an error
logger.error("FFmpeg output (full):")
for line in ffmpeg_log:
logger.error(line)
raise subprocess.CalledProcessError(returncode, cmd)
orig_size = input_file.stat().st_size
out_size = output_file.stat().st_size
reduction_ratio = out_size / orig_size
# Log comprehensive results
logger.info(f"\n📊 ENCODE RESULTS:")
logger.info(f" Original Size: {orig_size/1e6:.2f} MB")
logger.info(f" Encoded Size: {out_size/1e6:.2f} MB")
logger.info(f" Reduction: {reduction_ratio:.1%} of original ({(1-reduction_ratio):.1%} saved)")
logger.info(f" Resolution: {src_width}x{src_height}{scale_width}x{scale_height}")
logger.info(f" Audio Streams: {len(streams)} streams processed")
msg = f"📦 Original: {orig_size/1e6:.2f} MB → Encoded: {out_size/1e6:.2f} MB ({reduction_ratio:.1%} of original)"
print(msg)
return orig_size, out_size, reduction_ratio

136
core/ffmpeg_helper.py Normal file
View File

@ -0,0 +1,136 @@
# core/ffmpeg_helper.py
import json
import subprocess
from pathlib import Path
from typing import Tuple
from core.logger_helper import setup_logger
logger = setup_logger(Path(__file__).parent.parent / "logs")
# =============================
# STREAM ANALYSIS
# =============================
def get_audio_streams(input_file: Path):
"""Return a list of (index, channels, bitrate_kbps, lang)"""
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "a",
"-show_entries", "stream=index,channels,bit_rate,tags=language",
"-of", "json", str(input_file)
]
result = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(result.stdout or "{}")
streams = []
for s in data.get("streams", []):
index = s["index"]
channels = s.get("channels", 2)
bitrate = int(int(s.get("bit_rate", 128000)) / 1000)
lang = s.get("tags", {}).get("language", "und")
streams.append((index, channels, bitrate, lang))
return streams
# =============================
# AUDIO DECISION LOGIC
# =============================
def choose_audio_settings(channels: int, bitrate_kbps: int, audio_config: dict) -> Tuple[str, int]:
"""
Return (codec, target_bitrate)
Rules:
- If 128 kbps or lower use Opus
- Otherwise use AAC
- Use audio_config to bucket bitrates.
"""
if channels == 2:
if bitrate_kbps <= 80:
target_br = audio_config["stereo"]["low"]
elif bitrate_kbps <= 112:
target_br = audio_config["stereo"]["medium"]
else:
target_br = audio_config["stereo"]["high"]
else:
if bitrate_kbps <= 176:
target_br = audio_config["multi_channel"]["low"]
else:
target_br = audio_config["multi_channel"]["high"]
# Opus threshold: <=128 kbps
threshold = audio_config.get("use_opus_below_kbps", 128)
codec = "libopus" if target_br <= threshold * 1000 else "aac"
return codec, target_br
# =============================
# FFMPEG COMMAND BUILDER
# =============================
def build_ffmpeg_command(input_file: Path, output_file: Path,
cq: int, width: int, height: int,
filter_flags: str, audio_config: dict):
"""Builds FFmpeg command with smart audio logic."""
streams = get_audio_streams(input_file)
logger.info(f"🎛 Detected {len(streams)} audio stream(s). Building command...")
cmd = [
"ffmpeg", "-y", "-i", str(input_file),
"-vf", f"scale={width}:{height}:flags={filter_flags}:force_original_aspect_ratio=decrease",
"-map", "0:v", "-map", "0:a", "-map", "0:s?",
"-c:v", "av1_nvenc", "-preset", "p1", "-cq", str(cq),
"-pix_fmt", "p010le"
]
for i, (index, channels, bitrate, lang) in enumerate(streams):
codec, br = choose_audio_settings(channels, bitrate, audio_config)
cmd += [
f"-c:a:{i}", codec,
f"-b:a:{i}", str(br),
f"-ac:{i}", str(channels),
f"-metadata:s:a:{i}", f"language={lang}"
]
cmd += ["-c:s", "copy", str(output_file)]
return cmd, streams
# =============================
# ENCODE RUNNER
# =============================
def run_encode(input_file: Path, output_file: Path, cq: int,
width: int, height: int, filter_flags: str,
audio_config: dict):
"""Handles encode, fallback logic, and returns size stats."""
cmd, streams = build_ffmpeg_command(input_file, output_file, cq, width, height, filter_flags, audio_config)
logger.info(f"🎬 Running FFmpeg CQ encode → {output_file.name}")
subprocess.run(cmd, check=True)
# Size check
orig_size = input_file.stat().st_size
out_size = output_file.stat().st_size
ratio = out_size / orig_size
logger.info(f"📦 Size: {orig_size/1e6:.2f}MB → {out_size/1e6:.2f}MB ({ratio:.1%})")
# Fallback logic
if ratio >= 0.5:
logger.warning(f"⚠️ Reduction too low ({ratio:.0%}), retrying with bitrate mode...")
output_file.unlink(missing_ok=True)
vb, maxrate, bufsize = (
("1500k", "1750k", "2250k") if height >= 1080
else ("900k", "1250k", "1600k")
)
cmd = [
"ffmpeg", "-y", "-i", str(input_file),
"-vf", f"scale={width}:{height}:flags={filter_flags}:force_original_aspect_ratio=decrease",
"-map", "0:v", "-map", "0:a", "-map", "0:s?",
"-c:v", "av1_nvenc", "-preset", "p1",
"-b:v", vb, "-maxrate", maxrate, "-bufsize", bufsize,
"-pix_fmt", "p010le"
]
for i, (index, channels, bitrate, lang) in enumerate(streams):
codec, br = choose_audio_settings(channels, bitrate, audio_config)
cmd += [
f"-c:a:{i}", codec,
f"-b:a:{i}", str(br),
f"-ac:{i}", str(channels),
f"-metadata:s:a:{i}", f"language={lang}"
]
cmd += ["-c:s", "copy", str(output_file)]
subprocess.run(cmd, check=True)
return orig_size, out_size

View File

@ -1,226 +0,0 @@
#!/usr/bin/env python3
"""
Hardware Detection and Optimization Module
Detects available hardware (GPU, CPU) and recommends optimal encoding settings.
"""
import subprocess
import platform
import json
from pathlib import Path
from .logger_helper import setup_logger
logger = setup_logger(Path(__file__).parent.parent / "logs")
class HardwareInfo:
"""Detects and stores hardware capabilities."""
def __init__(self):
self.platform_name = platform.system() # Windows, Linux, Darwin
self.cpu_cores = self._get_cpu_cores()
self.gpu_type = self._detect_gpu()
self.available_encoders = self._check_ffmpeg_encoders()
self.recommended_encoder = self._recommend_encoder()
self.recommended_settings = self._get_recommended_settings()
def _get_cpu_cores(self):
"""Get number of CPU cores."""
import multiprocessing
return multiprocessing.cpu_count()
def _detect_gpu(self):
"""Detect GPU type (NVIDIA, AMD, Intel, None)."""
if self.platform_name == "Windows":
return self._detect_gpu_windows()
elif self.platform_name == "Linux":
return self._detect_gpu_linux()
else:
return None
def _detect_gpu_windows(self):
"""Detect GPU on Windows using DXDIAG or WMI."""
try:
# Try using wmic (Windows only)
result = subprocess.run(
["wmic", "path", "win32_videocontroller", "get", "name"],
capture_output=True,
text=True,
timeout=5
)
gpu_info = result.stdout.lower()
if "nvidia" in gpu_info or "geforce" in gpu_info or "quadro" in gpu_info:
return "nvidia"
elif "amd" in gpu_info or "radeon" in gpu_info:
return "amd"
elif "intel" in gpu_info:
return "intel"
except Exception as e:
logger.warning(f"Could not detect GPU via WMI: {e}")
return None
def _detect_gpu_linux(self):
"""Detect GPU on Linux using lspci."""
try:
result = subprocess.run(
["lspci"],
capture_output=True,
text=True,
timeout=5
)
gpu_info = result.stdout.lower()
if "nvidia" in gpu_info:
return "nvidia"
elif "amd" in gpu_info:
return "amd"
elif "intel" in gpu_info:
return "intel"
except Exception as e:
logger.warning(f"Could not detect GPU via lspci: {e}")
return None
def _check_ffmpeg_encoders(self):
"""Check available encoders in FFmpeg."""
encoders = {
"h264_nvenc": False,
"hevc_nvenc": False,
"h264_amf": False,
"hevc_amf": False,
"h264_qsv": False,
"hevc_qsv": False,
"libx264": False,
"libx265": False,
}
try:
result = subprocess.run(
["ffmpeg", "-encoders"],
capture_output=True,
text=True,
timeout=5
)
output = result.stdout.lower()
for encoder in encoders:
if encoder in output:
encoders[encoder] = True
except Exception as e:
logger.warning(f"Could not check FFmpeg encoders: {e}")
return encoders
def _recommend_encoder(self):
"""Recommend best encoder based on hardware."""
# Prefer NVIDIA NVENC > AMD AMF > Intel QSV > CPU
if self.gpu_type == "nvidia":
if self.available_encoders.get("hevc_nvenc"):
return "hevc_nvenc" # H.265 on NVIDIA is efficient
elif self.available_encoders.get("h264_nvenc"):
return "h264_nvenc"
if self.gpu_type == "amd":
if self.available_encoders.get("hevc_amf"):
return "hevc_amf"
elif self.available_encoders.get("h264_amf"):
return "h264_amf"
if self.gpu_type == "intel":
if self.available_encoders.get("hevc_qsv"):
return "hevc_qsv"
elif self.available_encoders.get("h264_qsv"):
return "h264_qsv"
# Fallback to CPU encoders
if self.available_encoders.get("libx265"):
return "libx265"
elif self.available_encoders.get("libx264"):
return "libx264"
return "libx264" # Default fallback
def _get_recommended_settings(self):
"""Get recommended encoding settings based on hardware."""
settings = {
"encoder": self.recommended_encoder,
"preset": self._get_preset(),
"threads": self._get_thread_count(),
"gpu_capable": self.gpu_type is not None,
}
return settings
def _get_preset(self):
"""Get recommended preset based on encoder."""
encoder = self.recommended_encoder
# GPU encoders don't use "preset" in the same way
if "nvenc" in encoder or "amf" in encoder or "qsv" in encoder:
# GPU presets (0-fast, 1-medium, 2-slow)
return 1 # medium (balanced)
else:
# CPU presets (ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow)
if self.cpu_cores <= 4:
return "faster"
elif self.cpu_cores <= 8:
return "fast"
else:
return "medium"
def _get_thread_count(self):
"""Get recommended thread count for encoding."""
# For CPU encoding, use most cores but leave some for system
if "nvenc" in self.recommended_encoder or "amf" in self.recommended_encoder or "qsv" in self.recommended_encoder:
return 0 # GPU handles it
else:
# Leave 1-2 cores for system
return max(self.cpu_cores - 2, 1)
def to_dict(self):
"""Return all hardware info as dictionary."""
return {
"platform": self.platform_name,
"cpu_cores": self.cpu_cores,
"gpu_type": self.gpu_type,
"recommended_encoder": self.recommended_encoder,
"available_encoders": {k: v for k, v in self.available_encoders.items() if v},
"recommended_settings": self.recommended_settings,
}
def print_summary(self):
"""Print hardware detection summary."""
print("\n" + "="*60)
print("HARDWARE DETECTION SUMMARY")
print("="*60)
print(f"Platform: {self.platform_name}")
print(f"CPU Cores: {self.cpu_cores}")
print(f"GPU Type: {self.gpu_type or 'None (CPU only)'}")
print(f"\nAvailable Encoders:")
for encoder, available in self.available_encoders.items():
status = "" if available else ""
print(f" {status} {encoder}")
print(f"\nRecommended:")
print(f" Encoder: {self.recommended_encoder}")
print(f" Preset: {self.recommended_settings.get('preset')}")
print(f" Threads: {self.recommended_settings.get('threads')}")
print("="*60 + "\n")
def detect_hardware():
"""Quick detection - returns HardwareInfo object."""
return HardwareInfo()
if __name__ == "__main__":
# Test the hardware detection
hw = detect_hardware()
hw.print_summary()
# Also save as JSON for reference
hw_json = hw.to_dict()
print("Full Hardware Info (JSON):")
print(json.dumps(hw_json, indent=2))

View File

@ -1,134 +1,35 @@
# core/logger_helper.py
import logging
import json
from logging.handlers import RotatingFileHandler
from pathlib import Path
from datetime import datetime
class JsonFormatter(logging.Formatter):
"""
Custom JSON log formatter for structured logging.
Outputs rich JSON objects with context for programmatic parsing and analysis.
"""
def format(self, record: logging.LogRecord) -> str:
log_object = {
"timestamp": datetime.utcfromtimestamp(record.created).strftime("%Y-%m-%dT%H:%M:%SZ"),
"level": record.levelname,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
# Include any extra fields added via logger.info("msg", extra={...})
# This allows passing structured context: logger.info("msg", extra={"file": "video.mkv", "size": 1024})
if hasattr(record, "extra") and isinstance(record.extra, dict):
log_object.update(record.extra)
# Include exception info if present (for error tracking)
if record.exc_info:
log_object["exception"] = self.formatException(record.exc_info)
return json.dumps(log_object, ensure_ascii=False)
def setup_logger(log_folder: Path, log_file_name: str = "conversion.log", level=logging.INFO) -> logging.Logger:
"""
Setup logger with structured JSON file output and disabled console output.
Output:
- File (logs/conversion.log): JSON format with full context for programmatic parsing
- Console: Disabled (all user output handled via print() for clean terminal UI)
Usage:
logger.info("Processing complete", extra={
"file": "video.mkv",
"size_mb": 1024,
"duration_sec": 3600
})
Sets up a logger that prints to console and writes to a rotating log file.
"""
log_folder.mkdir(parents=True, exist_ok=True)
log_file = log_folder / log_file_name
logger = logging.getLogger("conversion_logger")
logger.setLevel(level)
logger.propagate = False # Prevent double logging
logger.propagate = False # Prevent duplicate logging if root logger exists
# Formatters
text_formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s (%(module)s:%(lineno)d)",
datefmt="%Y-%m-%d %H:%M:%S"
)
json_formatter = JsonFormatter()
# Formatter with timestamp
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
# Console handler (disabled - use print() for user-facing output)
# This prevents duplicate/ugly output mixing with terminal UI
# Console handler
console_handler = logging.StreamHandler()
console_handler.setFormatter(text_formatter)
console_handler.setLevel(logging.CRITICAL + 1) # Effectively disable (above CRITICAL)
console_handler.setFormatter(formatter)
console_handler.setLevel(level)
# File handler (JSON logs)
file_handler = RotatingFileHandler(log_file, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8")
file_handler.setFormatter(json_formatter)
# File handler with rotation (max 5 MB per file, keep 3 backups)
file_handler = RotatingFileHandler(log_file, maxBytes=5*1024*1024, backupCount=3, encoding="utf-8")
file_handler.setFormatter(formatter)
file_handler.setLevel(level)
# Add handlers only once
# Add handlers
if not logger.handlers:
logger.addHandler(console_handler)
logger.addHandler(file_handler)
return logger
def setup_failure_logger(log_folder: Path) -> logging.Logger:
"""
Setup dedicated failure logger for encoding/processing failures.
Output:
- File (logs/failure.log): Simple text format with timestamp and failure message
- Use this for tracking files that failed processing for later analysis
Usage:
failure_logger.warning(f"{file.name} | CQ mode failed: size threshold not met (95%)")
"""
log_folder.mkdir(parents=True, exist_ok=True)
log_file = log_folder / "failure.log"
logger = logging.getLogger("failure_logger")
logger.setLevel(logging.WARNING)
# Prevent duplicate handlers
if logger.handlers:
return logger
# Simple text formatter for failure log
formatter = logging.Formatter(
"%(asctime)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
# File handler only
file_handler = RotatingFileHandler(log_file, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8")
file_handler.setFormatter(formatter)
file_handler.setLevel(logging.WARNING)
logger.addHandler(file_handler)
logger.propagate = False
return logger
def log_event(logger: logging.Logger, level: str, message: str, **context):
"""
Log a structured event with context fields.
Args:
logger: Logger instance
level: Log level ("debug", "info", "warning", "error")
message: Main message text
**context: Additional context fields (file, size, duration, etc)
Example:
log_event(logger, "info", "Encoding complete",
file="video.mkv", size_mb=1024, method="CQ", reduction_pct=45)
"""
log_func = getattr(logger, level.lower(), logger.info)
log_func(message, extra=context)

0
core/process_helper.py Normal file
View File

View File

@ -1,792 +0,0 @@
# core/process_manager.py
"""Main processing logic for batch transcoding."""
import csv
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from core.audio_handler import get_audio_streams
from core.encode_engine import run_ffmpeg
from core.logger_helper import setup_logger, setup_failure_logger
from core.video_handler import get_source_resolution, determine_target_resolution, get_source_bit_depth, has_forced_subtitles
logger = setup_logger(Path(__file__).parent.parent / "logs")
failure_logger = setup_failure_logger(Path(__file__).parent.parent / "logs")
def get_default_cq(folder: Path, config: dict, resolution: str, encoder: str = "hevc") -> int:
"""
Get the default CQ value for a given resolution, encoder, and folder type.
Args:
folder: Input folder path (used to detect TV/anime/movie type)
config: Configuration dictionary
resolution: Resolution string ("720", "1080", etc.)
encoder: Encoder type ("hevc" or "av1")
Returns:
Default CQ value for the given parameters
"""
# Determine content type from folder path
folder_lower = str(folder).lower()
is_tv = "\\tv\\" in folder_lower or "/tv/" in folder_lower
is_anime = "\\anime\\" in folder_lower or "/anime/" in folder_lower
# Build the config key
if is_tv:
key = f"tv_{resolution}"
elif is_anime:
key = f"anime_{resolution}"
else:
key = f"movie_{resolution}"
# Get CQ value from config
cq_config = config.get("encode", {}).get("cq", {}).get(encoder, {})
return cq_config.get(key, 28) # Default fallback to 28
def get_media_context(file: Path, root_folder: Path) -> dict:
"""
Extract media context from file path for structured logging.
Parses directory structure to identify show name, media type (TV/Movie),
season/episode numbers for grouping logs later.
Args:
file: File path to analyze
root_folder: Root processing folder to use as reference
Returns:
dict with keys: media_type, show_name, season (optional), episode (optional), video_filename
Examples:
P:\\tv\\Breaking Bad\\season01\\episode01.mkv
{"media_type": "tv", "show_name": "Breaking Bad", "season": "01", "episode": "01"}
P:\\movies\\Inception.mkv
{"media_type": "movie", "show_name": "Inception"}
"""
parts = file.parts
root_parts = root_folder.parts
context = {
"video_filename": file.name,
"media_type": None,
"show_name": None,
"season": None,
"episode": None
}
# Find where media type (tv/movie/anime) appears in path
path_lower = str(file).lower()
if "\\tv\\" in path_lower or "/tv/" in path_lower:
context["media_type"] = "tv"
elif "\\anime\\" in path_lower or "/anime/" in path_lower:
context["media_type"] = "anime"
elif "\\movies\\" in path_lower or "/movies/" in path_lower:
context["media_type"] = "movie"
else:
# Default to movie if path structure unclear
context["media_type"] = "other"
# Extract show name (directory immediately after media type)
try:
for i, part in enumerate(parts):
part_lower = part.lower()
if part_lower in ("tv", "anime", "movies"):
# Next part is show name
if i + 1 < len(parts):
context["show_name"] = parts[i + 1]
# For TV/anime, check if there's a season folder
if context["media_type"] in ("tv", "anime") and i + 2 < len(parts):
season_part = parts[i + 2].lower()
# Pattern: "season01", "s01", "season 1", etc.
import re
season_match = re.search(r's(?:eason)?\s*(\d+)', season_part)
if season_match:
context["season"] = season_match.group(1).zfill(2)
# Extract episode from filename
# Pattern: "e01", "episode01", "01", etc.
filename_lower = file.stem.lower()
ep_match = re.search(r'e(?:pisode)?\s*(\d+)', filename_lower)
if ep_match:
context["episode"] = ep_match.group(1).zfill(2)
break
except Exception as e:
logger.warning(f"Could not parse media context from {file}: {e}")
return context
def _cleanup_temp_files(temp_input: Path, temp_output: Path):
"""Helper function to clean up temporary input and output files."""
try:
if temp_input.exists():
temp_input.unlink()
logger.debug(f"Cleaned up temp input: {temp_input.name}")
except Exception as e:
logger.warning(f"Could not delete temp input {temp_input.name}: {e}")
try:
if temp_output.exists():
temp_output.unlink()
logger.debug(f"Cleaned up temp output: {temp_output.name}")
except Exception as e:
logger.warning(f"Could not delete temp output {temp_output.name}: {e}")
def should_skip_file(file: Path, no_encode: bool, unforce_subs: bool, force_process: bool, ignore_tags: list, travel_output_folder: Path) -> tuple:
"""
Determine if a file should be skipped from processing based on multiple criteria.
Skip conditions (in order):
1. If --no-encode + --unforce-subs: skip if file has no forced subtitles
2. If --force-process NOT set: skip if filename contains any ignore_tags (e.g., [EHX])
3. Travel mode always processes files (overrides ignore tags)
Args:
file: File path to check
no_encode: True if --no-encode flag is set
unforce_subs: True if --unforce-subs flag is set
force_process: True if --force-process flag is set (bypass ignore_tags)
ignore_tags: List of filename tags to skip (from config)
travel_output_folder: If set, travel mode is active (process all files)
Returns:
tuple: (should_skip: bool, reason: str or None)
"""
# Check for forced subtitles if using --no-encode + --unforce-subs
if no_encode and unforce_subs:
if not has_forced_subtitles(file):
return True, "no forced subtitles found (--no-encode + --unforce-subs)"
# Skip files with ignore tags (unless force_process is enabled)
# In travel mode, don't skip files based on tags
if not force_process and not travel_output_folder and any(tag.lower() in file.name.lower() for tag in ignore_tags):
return True, "matches ignore tags"
return False, None
def process_folder(folder: Path, cq: int, transcode_mode: str, resolution: str, config: dict, tracker_file: Path, test_mode: bool = False, audio_language: str = None, filter_audio: bool = None, audio_select: str = None, encoder: str = "hevc", strip_all_titles: bool = False, travel_output_folder: Path = None, unforce_subs: bool = False, no_encode: bool = False, force_process: bool = False, replace_file: bool = False, wait_seconds: int = 0):
"""
Process all video files in folder with appropriate encoding settings.
Args:
folder: Input folder path
cq: CQ override value
transcode_mode: "cq" or "bitrate"
resolution: Explicit resolution override ("480", "720", "1080", or None for smart)
config: Configuration dictionary
tracker_file: Path to CSV tracker file
test_mode: If True, only encode first file and skip final move/cleanup
audio_language: Optional language code to tag audio (e.g., 'eng', 'spa'). If None, no tagging applied.
filter_audio: If True, show interactive audio selection prompt. If None, use config setting.
audio_select: Pre-selected audio streams (comma-separated, e.g., "1,2"). Skips interactive prompt.
encoder: Video encoder to use - "hevc" for HEVC NVENC 10-bit (default) or "av1" for AV1 NVENC 8-bit.
strip_all_titles: If True, strip all title metadata from all audio tracks.
unforce_subs: If True, remove forced flag from all subtitle tracks.
no_encode: If True, skip encoding and copy video/audio streams as-is. Useful with --unforce-subs for re-muxing only.
force_process: If True, process files even if they match ignore_tags (e.g., already encoded files).
replace_file: If True, replace original file instead of creating suffix version. Requires no_encode=True.
wait_seconds: Seconds to wait after each successful file (for Plex detection). 0 = no wait.
travel_output_folder: If provided, move encoded files to this folder instead of original location.
"""
if not folder.exists():
print(f"❌ Folder not found: {folder}")
logger.error(f"Folder not found: {folder}")
return
audio_config = config["audio"]
bitrate_config = config["encode"]["fallback"]
filters_config = config["encode"]["filters"]
suffix = config["suffix"]
extensions = config["extensions"]
ignore_tags = config["ignore_tags"]
reduction_ratio_threshold = config["reduction_ratio_threshold"]
# Resolution logic: explicit arg takes precedence, else use smart defaults
explicit_resolution = resolution # Will be None if not specified
filter_flags = filters_config.get("default","lanczos")
folder_lower = str(folder).lower()
is_tv = "\\tv\\" in folder_lower or "/tv/" in folder_lower
is_anime = "\\anime\\" in folder_lower or "/anime/" in folder_lower
if is_tv:
filter_flags = filters_config.get("tv","bicubic")
elif is_anime:
filter_flags = filters_config.get("anime", filters_config.get("default","lanczos"))
processing_folder = Path(config["processing_folder"])
processing_folder.mkdir(parents=True, exist_ok=True)
# Determine if we're in smart mode (no explicit mode specified)
is_smart_mode = transcode_mode not in ["cq", "bitrate"] # Default/smart mode
is_forced_cq = transcode_mode == "cq"
is_forced_bitrate = transcode_mode == "bitrate"
# Track files for potential retry in smart mode
failed_cq_files = [] # List of (file_path, metadata) for CQ failures in smart mode
consecutive_failures = 0
max_consecutive = 3
# Phase 1: Process files with initial mode strategy
print(f"\n{'='*60}")
if is_smart_mode:
print("📋 MODE: Smart (Try CQ first, retry with Bitrate if needed)")
elif is_forced_cq:
print("📋 MODE: Forced CQ (skip failures, log them)")
else:
print("📋 MODE: Forced Bitrate (skip failures, log them)")
print(f"{'='*60}\n")
skipped_count = 0
for file in folder.rglob("*"):
# Skip hidden files/directories (starting with . or ._)
if file.name.startswith('.') or file.name.startswith('._'):
continue
if file.suffix.lower() not in extensions:
continue
# Check if file should be skipped
should_skip, skip_reason = should_skip_file(file, no_encode, unforce_subs, force_process, ignore_tags, travel_output_folder)
if should_skip:
logger.info(f"Skipping {file.name}: {skip_reason}")
print(f"⏭️ Skipping {file.name}: {skip_reason}")
skipped_count += 1
continue
if skipped_count > 0:
print(f"⏭️ Skipped {skipped_count} file(s)")
logger.info(f"Skipped {skipped_count} file(s)")
skipped_count = 0
# Extract media context for structured logging
media_context = get_media_context(file, folder)
print("="*60)
logger.info(f"Processing: {file.name}", extra=media_context)
print(f"📁 Processing: {file.name}")
temp_input = (processing_folder / file.name).resolve()
# Check if file already exists in processing folder
if temp_input.exists() and os.access(temp_input, os.R_OK):
source_size = file.stat().st_size
temp_size = temp_input.stat().st_size
# Verify it's complete (same size as source)
if source_size == temp_size:
print(f"✓ Found existing copy in processing folder (verified complete)")
logger.info(f"File already in processing: {file.name} ({temp_size/1e6:.2f} MB verified complete)")
else:
# File exists but incomplete - recopy
print(f"⚠️ Existing copy incomplete ({temp_size/1e6:.2f} MB vs {source_size/1e6:.2f} MB source). Re-copying...")
logger.warning(f"Incomplete copy detected for {file.name}. Re-copying.")
shutil.copy2(file, temp_input)
logger.info(f"Re-copied {file.name}{temp_input.name}")
else:
# File doesn't exist or not accessible - copy it
shutil.copy2(file, temp_input)
logger.info(f"Copied {file.name}{temp_input.name}")
# Verify file is accessible
for attempt in range(3):
if temp_input.exists() and os.access(temp_input, os.R_OK):
break
# Check for matching subtitle file
subtitle_file = None
if config.get("general", {}).get("subtitles", {}).get("enabled", True):
subtitle_exts = config.get("general", {}).get("subtitles", {}).get("extensions", ".vtt,.srt,.ass,.ssa,.sub").split(",")
# Look for subtitle with same base name (e.g., movie.vtt or movie.en.vtt)
for ext in subtitle_exts:
ext = ext.strip()
# Try exact match first (movie.vtt)
potential_sub = file.with_suffix(ext)
if potential_sub.exists():
subtitle_file = potential_sub
print(f"📝 Found subtitle: {subtitle_file.name}")
logger.info(f"Found subtitle file: {subtitle_file.name}")
break
# Try language prefix variants (movie.en.vtt, movie.eng.vtt, etc.)
# Look for files matching the pattern basename.*language*.ext
parent_dir = file.parent
base_name = file.stem
for item in parent_dir.glob(f"{base_name}.*{ext}"):
subtitle_file = item
print(f"📝 Found subtitle: {subtitle_file.name}")
logger.info(f"Found subtitle file: {subtitle_file.name}")
break
if subtitle_file:
break
try:
# Detect source resolution and determine target resolution
src_width, src_height = get_source_resolution(temp_input)
src_bit_depth = get_source_bit_depth(temp_input)
res_width, res_height, target_resolution = determine_target_resolution(
src_width, src_height, explicit_resolution
)
# Auto-select encoder based on detected source bit depth
if src_bit_depth >= 10:
# Source is 10-bit or higher - use HEVC NVENC
selected_encoder = "hevc"
else:
# Source is 8-bit - use AV1 NVENC
selected_encoder = "av1"
logger.info(f"Auto-selected {selected_encoder.upper()} encoder for detected {src_bit_depth}-bit source")
# Log resolution decision
if explicit_resolution:
logger.info(f"Using explicitly specified resolution: {res_width}x{res_height}")
else:
if src_height > 1080:
print(f"⚠️ Source {src_width}x{src_height} is above 1080p. Scaling down to 1080p.")
elif src_height <= 720:
print(f" Source {src_width}x{src_height} is 720p or lower. Preserving resolution.")
else:
print(f" Source {src_width}x{src_height} is at or below 1080p. Preserving resolution.")
# Set CQ based on content type, target resolution, and encoder
if is_anime:
cq_key = f"anime_{target_resolution}"
elif is_tv:
cq_key = f"tv_{target_resolution}"
else:
cq_key = f"movie_{target_resolution}"
# Look up CQ from encoder-specific section (using auto-selected encoder)
encoder_cq_config = config["encode"]["cq"].get(selected_encoder, {})
content_cq = encoder_cq_config.get(cq_key, 32)
file_cq = cq if cq is not None else content_cq
# Use the auto-selected encoder for the rest of processing
actual_encoder = selected_encoder
content_cq = encoder_cq_config.get(cq_key, 32)
file_cq = cq if cq is not None else content_cq
# Output file with suffix in processing folder (always .mkv container)
temp_output = (processing_folder / f"{file.stem}{suffix}.mkv").resolve()
# Determine which method to try first
if is_forced_bitrate:
method = "Bitrate"
elif is_forced_cq:
method = "CQ"
else: # Smart mode
method = "CQ" # Always try CQ first in smart mode
# Attempt encoding
try:
# Determine audio_filter config (CLI arg overrides config file)
# --filter-audio flag means: show interactive prompt
if filter_audio:
audio_filter_config = {"enabled": True, "interactive": True}
# If --audio-select provided, skip interactive and use pre-selected streams
if audio_select:
audio_filter_config["preselected"] = audio_select
elif audio_select:
# If --audio-select provided (without --filter-audio), use preselected streams
audio_filter_config = {"enabled": True, "preselected": audio_select}
else:
# Use config file setting (if present)
audio_filter_config = config.get("general", {}).get("audio_filter", {})
orig_size, out_size, reduction_ratio = run_ffmpeg(
temp_input, temp_output, file_cq, res_width, res_height, src_width, src_height,
filter_flags, audio_config, method, bitrate_config, actual_encoder, [subtitle_file] if subtitle_file else None, audio_language,
audio_filter_config, test_mode, strip_all_titles, src_bit_depth, unforce_subs, no_encode
)
# Check if encode met size target
# Skip size check if --no-encode is used (file size will be nearly identical)
encode_succeeded = True
if not no_encode:
if method == "CQ" and reduction_ratio >= reduction_ratio_threshold:
encode_succeeded = False
elif method == "Bitrate" and reduction_ratio >= reduction_ratio_threshold:
encode_succeeded = False
if not encode_succeeded:
# Size threshold not met
error_msg = f"Size threshold not met ({reduction_ratio:.1%})"
if test_mode:
# In test mode, stop immediately and keep temp files
print(f"❌ Test mode: {method} failed: {error_msg}")
print(f" Temp input preserved at: {temp_input}")
print(f" Temp output preserved at: {temp_output}")
logger.error(f"Test mode: {method} size threshold failed for {file.name}: {error_msg}")
raise RuntimeError(error_msg)
if is_smart_mode and method == "CQ":
# In smart mode CQ failure, mark for bitrate retry
print(f"⚠️ CQ failed size target ({reduction_ratio:.1%}). Will retry with Bitrate.")
failure_logger.warning(f"{file.name} | CQ failed size target ({reduction_ratio:.1%})")
failed_cq_files.append({
'file': file,
'temp_input': temp_input,
'temp_output': temp_output,
'src_width': src_width,
'src_height': src_height,
'res_width': res_width,
'res_height': res_height,
'target_resolution': target_resolution,
'file_cq': file_cq,
'is_tv': is_tv,
'subtitle_file': subtitle_file,
'src_bit_depth': src_bit_depth,
'encoder': actual_encoder,
'media_context': media_context
})
consecutive_failures += 1
if consecutive_failures >= max_consecutive:
print(f"\n⚠️ {max_consecutive} consecutive CQ failures. Moving to Phase 2: Bitrate retry.")
logger.warning(f"{max_consecutive} consecutive CQ failures. Moving to Phase 2.")
break # Move to Phase 2
continue
elif is_forced_cq or is_forced_bitrate:
# In forced mode, skip the file
print(f"{method} failed: {error_msg}")
failure_logger.warning(f"{file.name} | {method} failed: {error_msg}")
consecutive_failures += 1
if consecutive_failures >= max_consecutive:
print(f"\n{max_consecutive} consecutive failures in forced {method} mode. Stopping.")
logger.error(f"{max_consecutive} consecutive failures. Stopping process.")
_cleanup_temp_files(temp_input, temp_output)
break
_cleanup_temp_files(temp_input, temp_output)
continue
# Encoding succeeded - reset failure counter
consecutive_failures = 0
except subprocess.CalledProcessError as e:
# FFmpeg execution failed
error_msg = str(e).split('\n')[0][:100] # First 100 chars of error
if test_mode:
# In test mode, stop immediately on any error and keep temp files
print(f"❌ Test mode: Encode failed. Stopping script.")
print(f" Temp input preserved at: {temp_input}")
print(f" Temp output preserved at: {temp_output}")
logger.error(f"Test mode: Encode failed for {file.name}: {error_msg}")
raise
if is_smart_mode and method == "CQ":
# In smart mode, log and retry with bitrate
print(f"❌ CQ encode error. Will retry with Bitrate.")
failure_logger.warning(f"{file.name} | CQ error: {error_msg}")
failed_cq_files.append({
'file': file,
'temp_input': temp_input,
'temp_output': temp_output,
'src_width': src_width,
'src_height': src_height,
'res_width': res_width,
'res_height': res_height,
'target_resolution': target_resolution,
'file_cq': file_cq,
'is_tv': is_tv,
'subtitle_file': subtitle_file,
'media_context': media_context
})
consecutive_failures += 1
if consecutive_failures >= max_consecutive:
print(f"\n⚠️ {max_consecutive} consecutive CQ failures. Moving to Phase 2: Bitrate retry.")
logger.warning(f"{max_consecutive} consecutive CQ failures. Moving to Phase 2.")
break
continue
elif is_forced_cq or is_forced_bitrate:
# In forced mode, skip and log
if test_mode:
# In test mode, stop immediately and keep temp files
print(f"❌ Test mode: {method} encode failed. Stopping script.")
print(f" Temp input preserved at: {temp_input}")
print(f" Temp output preserved at: {temp_output}")
logger.error(f"Test mode: {method} encode failed for {file.name}: {error_msg}")
raise
print(f"{method} encode failed: {error_msg}")
failure_logger.warning(f"{file.name} | {method} error: {error_msg}")
consecutive_failures += 1
if consecutive_failures >= max_consecutive:
print(f"\n{max_consecutive} consecutive failures in forced {method} mode. Stopping.")
logger.error(f"{max_consecutive} consecutive failures. Stopping process.")
_cleanup_temp_files(temp_input, temp_output)
break
_cleanup_temp_files(temp_input, temp_output)
continue
# If we get here, encoding succeeded - save file and log
_save_successful_encoding(
file, temp_input, temp_output, orig_size, out_size,
reduction_ratio, method, src_width, src_height, res_width, res_height,
file_cq, tracker_file, folder, is_tv, suffix, config, test_mode, subtitle_file, travel_output_folder, replace_file, wait_seconds, media_context
)
# In test mode, stop after first successful file
if test_mode:
print(f"\n✅ TEST MODE: File processed. Encoded file is in temp folder for inspection.")
break
except Exception as e:
# Unexpected error
error_msg = str(e)[:100]
print(f"❌ Unexpected error: {error_msg}")
failure_logger.warning(f"{file.name} | Unexpected error: {error_msg}")
consecutive_failures += 1
logger.error(f"Unexpected error processing {file.name}: {e}")
_cleanup_temp_files(temp_input, temp_output)
if is_forced_cq or is_forced_bitrate:
if consecutive_failures >= max_consecutive:
print(f"\n{max_consecutive} consecutive failures. Stopping.")
break
else:
if consecutive_failures >= max_consecutive:
print(f"\n⚠️ {max_consecutive} consecutive failures. Moving to Phase 2.")
break
# Phase 2: Retry failed CQ files with Bitrate mode (smart mode only)
if is_smart_mode and failed_cq_files:
print(f"\n{'='*60}")
print(f"📋 PHASE 2: Retrying {len(failed_cq_files)} failed files with Bitrate mode")
print(f"{'='*60}\n")
consecutive_failures = 0
for file_data in failed_cq_files:
file = file_data['file']
temp_input = file_data['temp_input']
temp_output = file_data['temp_output']
try:
print(f"🔄 Retrying: {file.name} with Bitrate")
logger.info(f"Phase 2 Retry: {file.name} with Bitrate mode")
# Clean up old output if it exists
if temp_output.exists():
temp_output.unlink()
# Retry with bitrate
orig_size, out_size, reduction_ratio = run_ffmpeg(
temp_input, temp_output, file_data['file_cq'],
file_data['res_width'], file_data['res_height'],
file_data['src_width'], file_data['src_height'],
filter_flags, audio_config, "Bitrate", bitrate_config, file_data.get('encoder', encoder),
[file_data.get('subtitle_file')] if file_data.get('subtitle_file') else None, audio_language, None, test_mode, strip_all_titles,
file_data.get('src_bit_depth'), unforce_subs, no_encode
)
# Check if bitrate also failed
# Skip size check if --no-encode is used (file size will be nearly identical)
if not no_encode and reduction_ratio >= reduction_ratio_threshold:
print(f"⚠️ Bitrate also failed size target ({reduction_ratio:.1%}). Skipping.")
failure_logger.warning(f"{file.name} | Bitrate retry also failed ({reduction_ratio:.1%})")
consecutive_failures += 1
_cleanup_temp_files(temp_input, temp_output)
if consecutive_failures >= max_consecutive:
print(f"\n⚠️ {max_consecutive} consecutive Phase 2 failures. Stopping retries.")
break
continue
# Bitrate succeeded
consecutive_failures = 0
_save_successful_encoding(
file, temp_input, temp_output,
orig_size, out_size, reduction_ratio, "Bitrate",
file_data['src_width'], file_data['src_height'],
file_data['res_width'], file_data['res_height'],
file_data['file_cq'], tracker_file,
folder, file_data['is_tv'], suffix, config, False,
file_data.get('subtitle_file'), travel_output_folder, replace_file, wait_seconds,
file_data.get('media_context')
)
except subprocess.CalledProcessError as e:
error_msg = str(e).split('\n')[0][:100]
if test_mode:
# In test mode, stop immediately on any error and keep temp files
print(f"❌ Test mode: Bitrate retry failed. Stopping script.")
print(f" Temp input preserved at: {temp_input}")
print(f" Temp output preserved at: {temp_output}")
logger.error(f"Test mode: Bitrate retry failed for {file.name}: {error_msg}")
raise
print(f"❌ Bitrate retry failed: {error_msg}")
failure_logger.warning(f"{file.name} | Bitrate retry error: {error_msg}")
consecutive_failures += 1
logger.error(f"Bitrate retry failed for {file.name}: {e}")
_cleanup_temp_files(temp_input, temp_output)
if consecutive_failures >= max_consecutive:
print(f"\n⚠️ {max_consecutive} consecutive Phase 2 failures. Stopping retries.")
break
except Exception as e:
error_msg = str(e)[:100]
if test_mode:
# In test mode, stop immediately on any error and keep temp files
print(f"❌ Test mode: Unexpected error in Phase 2. Stopping script.")
print(f" Temp input preserved at: {temp_input}")
print(f" Temp output preserved at: {temp_output}")
logger.error(f"Test mode: Phase 2 error for {file.name}: {error_msg}")
raise
print(f"❌ Unexpected error in Phase 2: {error_msg}")
failure_logger.warning(f"{file.name} | Phase 2 error: {error_msg}")
consecutive_failures += 1
_cleanup_temp_files(temp_input, temp_output)
if consecutive_failures >= max_consecutive:
print(f"\n⚠️ {max_consecutive} consecutive Phase 2 failures. Stopping retries.")
break
print(f"\n{'='*60}")
print("✅ Batch processing complete")
logger.info("Batch processing complete")
def _save_successful_encoding(file, temp_input, temp_output, orig_size, out_size,
reduction_ratio, method, src_width, src_height, res_width, res_height,
file_cq, tracker_file, folder, is_tv, suffix, config=None, test_mode=False, subtitle_file=None, travel_output_folder=None, replace_file: bool = False, wait_seconds: int = 0, media_context: dict = None):
"""Helper function to save successfully encoded files with [EHX] tag and clean up subtitle files."""
if media_context is None:
media_context = {}
# In test mode, show ratio and skip file move/cleanup
if test_mode:
orig_size_mb = round(orig_size / 1e6, 2)
out_size_mb = round(out_size / 1e6, 2)
percentage = round(out_size_mb / orig_size_mb * 100, 1)
print(f"\n{'='*60}")
print(f"📊 TEST MODE RESULTS:")
print(f"{'='*60}")
print(f"Original: {orig_size_mb} MB")
print(f"Encoded: {out_size_mb} MB")
print(f"Ratio: {percentage}% ({reduction_ratio:.1%} reduction)")
print(f"Method: {method} (CQ={file_cq if method == 'CQ' else 'N/A'})")
print(f"{'='*60}")
print(f"📁 Encoded file location: {temp_output}")
logger.info(f"TEST MODE - File: {file.name} | Ratio: {percentage}% | Method: {method}", extra=media_context)
return
# Check if file is in a Featurettes folder - if so, remove suffix from destination filename
folder_parts = [p.lower() for p in file.parent.parts]
is_featurette = "featurettes" in folder_parts
if replace_file:
# Use original filename (no suffix)
dest_file = file.parent / file.name
elif is_featurette:
# Remove suffix from temp_output.name for Featurettes
output_name = temp_output.name
if suffix in output_name:
output_name = output_name.replace(suffix, "")
dest_file = file.parent / output_name
else:
dest_file = file.parent / temp_output.name
# If travel mode is active, use travel output folder instead
if travel_output_folder:
# Preserve relative directory structure from input folder
relative_path = file.parent.relative_to(folder)
travel_dest_dir = travel_output_folder / relative_path
travel_dest_dir.mkdir(parents=True, exist_ok=True)
dest_file = travel_dest_dir / temp_output.name
print(f"🧳 Travel mode: Moving to {dest_file}")
logger.info(f"Travel mode destination: {dest_file}", extra=media_context)
shutil.move(temp_output, dest_file)
print(f"🚚 Moved {temp_output.name}{dest_file.name}")
logger.info(f"Moved {temp_output.name}{dest_file.name}", extra=media_context)
# Classify file type based on folder (folder_parts already defined earlier)
if "tv" in folder_parts:
f_type = "tv"
tv_index = folder_parts.index("tv")
show = folder.parts[tv_index + 1] if len(folder.parts) > tv_index + 1 else "Unknown"
elif "anime" in folder_parts:
f_type = "anime"
anime_index = folder_parts.index("anime")
show = folder.parts[anime_index + 1] if len(folder.parts) > anime_index + 1 else "Unknown"
else:
f_type = "movie"
show = "N/A"
orig_size_mb = round(orig_size / 1e6, 2)
proc_size_mb = round(out_size / 1e6, 2)
percentage = round(proc_size_mb / orig_size_mb * 100, 1)
# Get audio stream count for tracking
try:
audio_streams = get_audio_streams(temp_input)
audio_stream_count = len(audio_streams)
except:
audio_stream_count = 0
# Format resolutions for tracking
src_resolution = f"{src_width}x{src_height}"
target_res = f"{res_width}x{res_height}"
cq_str = str(file_cq) if method == "CQ" else "N/A"
with open(tracker_file, "a", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow([
f_type, show, dest_file.name, orig_size_mb, proc_size_mb, percentage,
src_resolution, target_res, audio_stream_count, cq_str, method
])
# Enhanced logging with all conversion details
log_context = {**media_context, "method": method, "original_size_mb": orig_size_mb, "output_size_mb": proc_size_mb, "reduction_pct": 100 - percentage}
logger.info(f"✅ CONVERSION COMPLETE: {dest_file.name}", extra=log_context)
logger.info(f" Type: {f_type.upper()} | Show: {show}", extra=log_context)
logger.info(f" Size: {orig_size_mb}MB → {proc_size_mb}MB ({percentage}% of original, {100-percentage:.1f}% reduction)", extra=log_context)
logger.info(f" Method: {method} | Status: SUCCESS", extra=log_context)
print(f"📝 Logged conversion: {dest_file.name} ({percentage}%), method={method}")
try:
temp_input.unlink()
# Keep original file if in travel mode, replace mode, or if in Featurettes folder
if travel_output_folder:
logger.info(f"Travel mode: Kept original file {file.name}", extra=media_context)
elif replace_file:
logger.info(f"Replace mode: Original file has been replaced with processed version at {file.name}", extra=media_context)
elif not is_featurette:
file.unlink()
logger.info(f"Deleted original and processing copy for {file.name}")
else:
logger.info(f"Featurettes file preserved at origin: {file.name}")
# Clean up subtitle file if it was embedded
if subtitle_file and subtitle_file.exists():
try:
subtitle_file.unlink()
print(f"🗑️ Removed embedded subtitle: {subtitle_file.name}")
logger.info(f"Removed embedded subtitle: {subtitle_file.name}")
except Exception as e:
logger.warning(f"Could not delete subtitle file {subtitle_file.name}: {e}")
except Exception as e:
print(f"⚠️ Could not delete files: {e}")
logger.warning(f"Could not delete files: {e}")
# Wait if specified (for Plex detection)
if wait_seconds > 0:
import time
print(f"⏱️ Waiting {wait_seconds}s for Plex to detect changes...")
time.sleep(wait_seconds)

0
core/tracker_helper.py Normal file
View File

View File

@ -1,232 +0,0 @@
# core/video_handler.py
"""Video resolution detection and encoding logic."""
import json
import subprocess
from pathlib import Path
from core.logger_helper import setup_logger
logger = setup_logger(Path(__file__).parent.parent / "logs")
def get_source_resolution(input_file: Path) -> tuple:
"""
Get source video resolution (width, height).
Returns tuple: (width, height)
Skips attached pictures and cover art.
"""
try:
# First, get all video streams and their disposition to find the first non-attached pic
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v",
"-show_entries", "stream=width,height,disposition",
"-of", "default=noprint_wrappers=1",
str(input_file)
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
if result.stdout:
lines = result.stdout.strip().split("\n")
# Parse the output to find a non-attached picture video stream
width = None
height = None
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith("width="):
width_val = int(line.split("=")[1]) if "=" in line else None
# Look ahead for height and disposition
height_val = None
is_attached_pic = False
if i + 1 < len(lines):
next_line = lines[i + 1].strip()
if next_line.startswith("height="):
height_val = int(next_line.split("=")[1]) if "=" in next_line else None
if i + 2 < len(lines):
disp_line = lines[i + 2].strip()
if disp_line.startswith("disposition="):
# Check if attached_pic flag is set to 1
if "attached_pic=1" in disp_line:
is_attached_pic = True
# If this is a real video stream (not attached pic) and has valid dimensions, use it
if width_val and height_val and not is_attached_pic:
width = width_val
height = height_val
return (width, height)
i += 1
# Fallback: if no valid stream found, try simple v:0 selection
if not width or not height:
logger.debug("No non-attached-pic video stream found, trying fallback method")
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "default=noprint_wrappers=1:nokey=1:noprint_wrappers=1",
str(input_file)
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
if result.stdout:
lines = result.stdout.strip().split("\n")
width = int(lines[0]) if len(lines) > 0 and lines[0].strip() else 1920
height = int(lines[1]) if len(lines) > 1 and lines[1].strip() else 1080
return (width, height)
logger.warning(f"ffprobe returned no output for {input_file.name}. Defaulting to 1920x1080")
return (1920, 1080)
except Exception as e:
logger.warning(f"Failed to detect source resolution: {e}. Defaulting to 1920x1080")
return (1920, 1080)
def get_source_bit_depth(input_file: Path) -> int:
"""
Detect source video bit depth (8, 10, or 12).
Returns: 12, 10, or 8 (default)
Skips attached pictures and cover art.
"""
try:
# Get all video streams with pixel format and disposition
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v",
"-show_entries", "stream=pix_fmt,disposition",
"-of", "default=noprint_wrappers=1",
str(input_file)
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
if result.stdout:
lines = result.stdout.strip().split("\n")
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith("pix_fmt="):
pix_fmt = line.split("=")[1] if "=" in line else None
# Check next line for disposition
is_attached_pic = False
if i + 1 < len(lines):
disp_line = lines[i + 1].strip()
if disp_line.startswith("disposition="):
if "attached_pic=1" in disp_line:
is_attached_pic = True
# If not attached pic, analyze the pixel format
if pix_fmt and not is_attached_pic:
pix_fmt_lower = pix_fmt.lower()
# Check for 12-bit indicators first
if any(x in pix_fmt_lower for x in ["12le", "12be"]):
return 12
# Check for 10-bit indicators
elif any(x in pix_fmt_lower for x in ["10le", "10be", "p010", "yuv420p10"]):
return 10
else:
return 8
i += 1
# Fallback to simple method if no streams found
logger.debug(f"Could not detect bit depth for {input_file.name}. Defaulting to 8-bit")
return 8
except Exception as e:
logger.warning(f"Failed to detect source bit depth: {e}. Defaulting to 8-bit")
return 8
def determine_target_resolution(src_width: int, src_height: int, explicit_resolution: str = None) -> tuple:
"""
Determine target resolution based on source and explicit override.
Returns tuple: (res_width, res_height, target_resolution_label)
Logic:
If explicit_resolution specified: use it as a MAXIMUM (downscale only, never upscale)
- If source > max: scale down to max
- If source <= max: preserve source resolution
Else:
- If source > 1080p: scale to 1080p
- If source <= 1080p: preserve source resolution
"""
if explicit_resolution:
# User explicitly specified resolution as a maximum threshold
max_height = int(explicit_resolution)
if src_height > max_height:
# Source is larger than max - downscale to max
if max_height == 1080:
return (1920, 1080, "1080")
elif max_height == 720:
return (1280, 720, "720")
else: # 480
return (854, 480, "480")
else:
# Source is <= max - preserve source resolution (no upscaling)
if src_height <= 720:
return (src_width, src_height, "720")
else:
return (src_width, src_height, "1080")
else:
# No explicit resolution - use smart defaults
if src_height > 1080:
# Scale down anything above 1080p to 1080p
return (1920, 1080, "1080")
else:
# Preserve source resolution (480p, 720p, 1080p, etc.)
if src_height <= 720:
return (src_width, src_height, "720")
else:
return (src_width, src_height, "1080")
def has_forced_subtitles(input_file: Path) -> bool:
"""
Check if the input file has any subtitles with the forced flag set.
Returns True if at least one subtitle stream has forced=1 disposition.
"""
try:
# Method 1: Try JSON output (most reliable)
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "s",
"-show_entries", "stream=disposition",
"-of", "json",
str(input_file)
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
if result.stdout:
try:
data = json.loads(result.stdout)
for stream in data.get("streams", []):
disposition = stream.get("disposition", {})
if isinstance(disposition, dict) and disposition.get("forced") == 1:
logger.debug(f"Found forced subtitle stream in {input_file.name}")
return True
except json.JSONDecodeError:
logger.debug(f"Failed to parse JSON from ffprobe for {input_file.name}, trying fallback method")
# Method 2: Fallback to text search for "forced=1" or "(forced)"
cmd = [
"ffprobe", "-v", "info",
"-select_streams", "s",
str(input_file)
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
if result.stderr:
# Look for "(forced)" in the human-readable ffprobe output
if "(forced)" in result.stderr:
logger.debug(f"Found (forced) in ffprobe output for {input_file.name}")
return True
return False
except Exception as e:
logger.warning(f"Failed to check forced subtitles for {input_file.name}: {e}")
return False

View File

@ -1,832 +0,0 @@
#!/usr/bin/env python3
"""
GUI Path Manager for Batch Video Transcoder
Allows easy browsing of folders and appending to paths.txt with encoding options.
"""
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from pathlib import Path
import os
import subprocess
import re
import json
from core.config_helper import load_config_xml
from core.logger_helper import setup_logger
logger = setup_logger(Path(__file__).parent.parent / "logs")
class PathManagerGUI:
def __init__(self, root):
self.root = root
self.root.title("Batch Transcoder - Path Manager")
self.root.geometry("1100x700")
# Load config
config_path = Path(__file__).parent.parent / "config.xml"
self.config = load_config_xml(config_path)
self.path_mappings = self.config.get("path_mappings", {})
# Paths file
self.paths_file = Path(__file__).parent.parent / "paths.txt"
self.transcode_bat = Path(__file__).parent.parent / "transcode.bat"
# Current selected folder
self.selected_folder = None
self.current_category = None
self.recently_added = None # Track recently added folder for highlighting
self.status_timer = None # Track status message timer
self.added_folders = set() # Folders that are in paths.txt
# Cache for folder data - split per category
self.cache_dir = Path(__file__).parent.parent / ".cache"
self.cache_dir.mkdir(exist_ok=True)
self.folder_cache = {} # Only current category in memory: {folder_path: size}
self.scan_in_progress = False
self.scanned_categories = set() # Track which categories have been scanned
# Lazy loading
self.all_folders = [] # All folders for current category
self.loaded_items = 0 # How many items are currently loaded
self.items_per_batch = 100 # Load 100 items at a time
# Load existing paths
self._load_existing_paths()
# Build UI
self._build_ui()
# Handle window close
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
def _build_ui(self):
"""Build the GUI layout."""
# Top frame for category selection and transcode launcher
top_frame = ttk.Frame(self.root)
top_frame.pack(fill=tk.X, padx=10, pady=10)
left_top = ttk.Frame(top_frame)
left_top.pack(side=tk.LEFT, fill=tk.X, expand=True)
ttk.Label(left_top, text="Category:").pack(side=tk.LEFT, padx=5)
self.category_var = tk.StringVar(value="tv")
categories = ["tv", "anime", "movies"]
for cat in categories:
ttk.Radiobutton(
left_top, text=cat.upper(), variable=self.category_var,
value=cat, command=self._on_category_change
).pack(side=tk.LEFT, padx=5)
ttk.Button(left_top, text="Refresh", command=self._refresh_with_cache_clear).pack(side=tk.LEFT, padx=5)
# Right side of top frame - transcode launcher
right_top = ttk.Frame(top_frame)
right_top.pack(side=tk.RIGHT)
if self.transcode_bat.exists():
ttk.Button(
right_top, text="▶ Run transcode.bat", command=self._run_transcode
).pack(side=tk.RIGHT, padx=5)
# Main content frame
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Left side - folder tree
left_frame = ttk.LabelFrame(main_frame, text="Folders (sorted by size)")
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
# Treeview for folders with add button column
self.tree = ttk.Treeview(left_frame, columns=("size", "add", "remove"), height=20)
self.tree.column("#0", width=180)
self.tree.column("size", width=80)
self.tree.column("add", width=50)
self.tree.column("remove", width=50)
self.tree.heading("#0", text="Folder Name")
self.tree.heading("size", text="Size")
self.tree.heading("add", text="Add")
self.tree.heading("remove", text="Remove")
scrollbar = ttk.Scrollbar(left_frame, orient=tk.VERTICAL, command=self._on_scrollbar)
self.tree.configure(yscroll=scrollbar.set)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# Configure tags for folder status
self.tree.tag_configure("added", background="#90EE90") # Light green for added
self.tree.tag_configure("not_added", background="white") # White for not added
self.tree.tag_configure("recently_added", background="#FFD700") # Gold for recently added
self.tree.bind("<Double-1>", self._on_folder_expand)
self.tree.bind("<<TreeviewSelect>>", self._on_folder_select)
self.tree.bind("<Button-1>", self._on_tree_click)
# Right side - options and preview
right_frame = ttk.LabelFrame(main_frame, text="Encoding Options & Preview")
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(5, 0))
# Mode selection
mode_frame = ttk.LabelFrame(right_frame, text="Mode (--m)")
mode_frame.pack(fill=tk.X, padx=5, pady=5)
self.mode_var = tk.StringVar(value="default")
for mode in ["default", "cq", "bitrate"]:
ttk.Radiobutton(mode_frame, text=mode, variable=self.mode_var, value=mode,
command=self._update_preview).pack(anchor=tk.W, padx=5)
# Resolution selection
res_frame = ttk.LabelFrame(right_frame, text="Resolution (--r)")
res_frame.pack(fill=tk.X, padx=5, pady=5)
self.resolution_var = tk.StringVar(value="none")
for res in ["none", "480", "720", "1080"]:
label = "Auto" if res == "none" else res + "p"
ttk.Radiobutton(res_frame, text=label, variable=self.resolution_var, value=res,
command=self._update_preview).pack(anchor=tk.W, padx=5)
# CQ value
cq_frame = ttk.LabelFrame(right_frame, text="CQ Value (--cq, optional)")
cq_frame.pack(fill=tk.X, padx=5, pady=5)
self.cq_var = tk.StringVar(value="")
cq_entry = ttk.Entry(cq_frame, textvariable=self.cq_var, width=10)
cq_entry.pack(anchor=tk.W, padx=5, pady=3)
cq_entry.bind("<KeyRelease>", lambda e: self._update_preview())
# Preview frame
preview_frame = ttk.LabelFrame(right_frame, text="Command Preview")
preview_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.preview_text = tk.Text(preview_frame, height=8, width=40, wrap=tk.WORD, bg="lightgray")
self.preview_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.preview_text.config(state=tk.DISABLED)
# Bottom frame - action buttons and status
bottom_frame = ttk.Frame(self.root)
bottom_frame.pack(fill=tk.X, padx=10, pady=10)
button_frame = ttk.Frame(bottom_frame)
button_frame.pack(side=tk.LEFT)
ttk.Button(button_frame, text="View paths.txt", command=self._view_paths_file).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="Clear paths.txt", command=self._clear_paths_file).pack(side=tk.LEFT, padx=5)
# Status label (for silent feedback)
self.status_label = ttk.Label(bottom_frame, text="", foreground="green")
self.status_label.pack(side=tk.LEFT, padx=10)
# Load cache and populate initial category
self._load_cache()
self._refresh_folders(use_cache=True)
# Only scan once per category on first view
if self.current_category not in self.scanned_categories:
self.root.after(100, self._scan_folders_once)
def _on_category_change(self):
"""Handle category radio button change."""
self.current_category = self.category_var.get()
# Load cache for this category
self._load_cache()
# Show cached data first
self._refresh_folders(use_cache=True)
# Only scan once per category on first view
if self.current_category not in self.scanned_categories:
self.root.after(100, self._scan_folders_once)
def _load_cache(self):
"""Load folder cache for current category from disk (lazy)."""
category = self.category_var.get()
cache_file = self.cache_dir / f".cache_{category}.json"
self.folder_cache.clear()
# Don't fully load cache yet - just verify it exists
if not cache_file.exists():
logger.info(f"No cache file for {category}")
else:
logger.info(f"Cache file exists for {category}")
def _parse_cache_lazily(self, limit=None):
"""Parse cache file lazily and return folders."""
category = self.category_var.get()
cache_file = self.cache_dir / f".cache_{category}.json"
folders = []
if cache_file.exists():
try:
with open(cache_file, "r", encoding="utf-8") as f:
cache_dict = json.load(f)
# Convert to list and sort
for folder_path_str, size in cache_dict.items():
folder_path = Path(folder_path_str)
if folder_path.exists():
folders.append((folder_path.name, folder_path, size))
# Early exit if limit reached
if limit and len(folders) >= limit:
break
# Sort by size descending (only what we loaded)
folders.sort(key=lambda x: x[2], reverse=True)
except Exception as e:
logger.error(f"Failed to parse cache: {e}")
return folders
def _save_cache(self):
"""Save current category's folder cache to disk."""
category = self.category_var.get()
cache_file = self.cache_dir / f".cache_{category}.json"
try:
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(self.folder_cache, f, indent=2)
except Exception as e:
logger.error(f"Failed to save {category} cache: {e}")
def _refresh_with_cache_clear(self):
"""Refresh and clear cache to force full scan."""
category = self.category_var.get()
cache_file = self.cache_dir / f".cache_{category}.json"
# Delete cache file for this category
if cache_file.exists():
cache_file.unlink()
self.folder_cache.clear()
self.scanned_categories.discard(category) # Reset so it will scan again
self._refresh_folders(use_cache=False)
def _scan_folders_once(self):
"""Scan folders once per category on first load."""
if self.scan_in_progress:
return
category = self.category_var.get()
if category in self.scanned_categories:
return # Already scanned this category
self.scan_in_progress = True
try:
category_mapping = {
"tv": "P:\\tv",
"anime": "P:\\anime",
"movies": "P:\\movies"
}
base_key = category_mapping.get(category)
if not base_key or base_key not in self.path_mappings:
return
base_path = Path(base_key)
if not base_path.exists():
return
# Scan folders and update cache
new_cache = {}
for entry in os.scandir(base_path):
if entry.is_dir(follow_symlinks=False):
size = self._get_folder_size(Path(entry))
new_cache[str(Path(entry))] = size
# Update cache and save
self.folder_cache = new_cache
self._save_cache()
self.scanned_categories.add(category)
# Update UI if still on same category
if self.category_var.get() == category:
self._refresh_folders(use_cache=True)
finally:
self.scan_in_progress = False
def _scan_folders_background(self):
"""Scan folders in background and update cache."""
if self.scan_in_progress:
return
self.scan_in_progress = True
try:
category = self.category_var.get()
category_mapping = {
"tv": "P:\\tv",
"anime": "P:\\anime",
"movies": "P:\\movies"
}
base_key = category_mapping.get(category)
if not base_key or base_key not in self.path_mappings:
return
base_path = Path(base_key)
if not base_path.exists():
return
# Scan folders and update cache
new_cache = {}
for entry in os.scandir(base_path):
if entry.is_dir(follow_symlinks=False):
size = self._get_folder_size(Path(entry))
new_cache[str(Path(entry))] = size
# Update cache and save
self.folder_cache[category] = new_cache
self._save_cache()
# Update UI if still on same category
if self.category_var.get() == category:
self._refresh_folders(use_cache=True)
finally:
self.scan_in_progress = False
# Schedule next continuous scan
self.background_scan_timer = self.root.after(
self.background_scan_interval,
self._continuous_background_scan
)
# Schedule next continuous scan
self.background_scan_timer = self.root.after(
self.background_scan_interval,
self._continuous_background_scan
)
def _load_existing_paths(self):
"""Load existing paths from paths.txt and extract folder paths."""
self.added_folders.clear()
if not self.paths_file.exists():
return
try:
with open(self.paths_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
# Extract the path (last argument in the command)
# Format: --m mode --r res --cq val "path" or just "path"
# Find all quoted strings
matches = re.findall(r'"([^"]*)"', line)
if matches:
# Last quoted string is the path
path = matches[-1]
self.added_folders.add(path)
except Exception as e:
logger.error(f"Failed to load existing paths: {e}")
def _get_folder_size(self, path: Path) -> int:
"""Calculate total size of folder in bytes."""
total = 0
try:
for entry in os.scandir(path):
if entry.is_file(follow_symlinks=False):
total += entry.stat().st_size
elif entry.is_dir(follow_symlinks=False):
total += self._get_folder_size(Path(entry))
except PermissionError:
pass
return total
def _format_size(self, bytes_size: int) -> str:
"""Format bytes to human readable size."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if bytes_size < 1024:
return f"{bytes_size:.1f} {unit}"
bytes_size /= 1024
return f"{bytes_size:.1f} PB"
def _refresh_folders(self, use_cache=False):
"""Refresh the folder tree from cache or disk."""
# Clear existing items
for item in self.tree.get_children():
self.tree.delete(item)
self.all_folders = []
self.loaded_items = 0
category = self.category_var.get()
# Map category to path mapping key
category_mapping = {
"tv": "P:\\tv",
"anime": "P:\\anime",
"movies": "P:\\movies"
}
base_key = category_mapping.get(category)
if not base_key or base_key not in self.path_mappings:
messagebox.showwarning("Info", f"No mapping found for {category}")
return
base_path = Path(base_key)
# Check if path exists
if not base_path.exists():
messagebox.showerror("Error", f"Path not found: {base_path}")
return
# Get folders from cache or disk
if use_cache:
# Parse cache lazily - only load what we need initially
folders = self._parse_cache_lazily(limit=None) # Get all but parse efficiently
else:
# Scan from disk
folders = []
try:
for entry in os.scandir(base_path):
if entry.is_dir(follow_symlinks=False):
size = self._get_folder_size(Path(entry))
folders.append((entry.name, Path(entry), size))
except PermissionError:
messagebox.showerror("Error", f"Permission denied accessing {base_path}")
return
# Update cache with fresh scan
cache_dict = {str(f[1]): f[2] for f in folders}
self.folder_cache = cache_dict
self._save_cache()
# Sort by size descending
folders.sort(key=lambda x: x[2], reverse=True)
# Store all folders and load first batch only
self.all_folders = folders
self._load_more_items()
def _on_folder_expand(self, event):
"""Handle folder double-click to show contents."""
selection = self.tree.selection()
if not selection:
return
item = selection[0]
tags = self.tree.item(item, "tags")
if not tags:
return
folder_path = Path(tags[0])
# Check if already expanded
if self.tree.get_children(item):
# Toggle: remove children
for child in self.tree.get_children(item):
self.tree.delete(child)
else:
# Add file/folder contents
try:
entries = []
for entry in os.scandir(folder_path):
if entry.is_file():
size = entry.stat().st_size
entries.append((entry.name, "File", size))
elif entry.is_dir():
size = self._get_folder_size(Path(entry))
entries.append((entry.name, "Folder", size))
# Sort by size descending
entries.sort(key=lambda x: x[2], reverse=True)
for name, type_str, size in entries:
size_str = self._format_size(size)
self.tree.insert(item, "end", text=f"[{type_str}] {name}", values=(size_str,))
except PermissionError:
messagebox.showerror("Error", f"Permission denied accessing {folder_path}")
def _on_folder_select(self, event):
"""Handle folder selection to update preview."""
selection = self.tree.selection()
if not selection:
return
item = selection[0]
tags = self.tree.item(item, "tags")
if tags:
self.selected_folder = tags[0]
self._update_preview()
def _on_tree_click(self, event):
"""Handle click on '+' or '-' button in add column."""
item = self.tree.identify("item", event.x, event.y)
column = self.tree.identify_column(event.x) # Only takes x coordinate
# Column #2 is the "add" column (columns are #0=name, #1=size, #2=add, #3=remove)
if item and column == "#2":
tags = self.tree.item(item, "tags")
if tags:
folder_path = tags[0]
values = self.tree.item(item, "values")
if len(values) > 1:
button_text = values[1] # Get button text
if "[+]" in button_text:
# Immediately update UI for snappy response
size_val = values[0]
self.tree.item(item, values=(size_val, "", "[-]"), tags=(folder_path, "added"))
# Add to paths.txt asynchronously
self.selected_folder = folder_path
self.root.after(0, self._add_to_paths_file_async, folder_path)
# Column #3 is the "remove" column
elif item and column == "#3":
tags = self.tree.item(item, "tags")
if tags:
folder_path = tags[0]
# Immediately update UI for snappy response
values = self.tree.item(item, "values")
size_val = values[0]
self.tree.item(item, values=(size_val, "[+]", ""), tags=(folder_path, "not_added"))
# Remove from paths.txt asynchronously
self.root.after(0, self._remove_from_paths_file_async, folder_path)
def _add_to_paths_file_async(self, folder_path):
"""Add to paths.txt without blocking UI."""
self.selected_folder = folder_path
self._add_to_paths_file()
# Silently reload in background
self._load_existing_paths()
def _remove_from_paths_file_async(self, folder_path):
"""Remove from paths.txt without blocking UI."""
self._remove_from_paths_file(folder_path)
def _update_preview(self):
"""Update the command preview."""
if not self.selected_folder:
preview_text = "No folder selected"
else:
folder_path = self.selected_folder
# Build command
cmd_parts = ['py main.py']
# Add mode if not default
mode = self.mode_var.get()
if mode != "default":
cmd_parts.append(f'--m {mode}')
# Add resolution if specified
resolution = self.resolution_var.get()
if resolution != "none":
cmd_parts.append(f'--r {resolution}')
# Add CQ if specified
cq = self.cq_var.get().strip()
if cq:
cmd_parts.append(f'--cq {cq}')
# Add path
cmd_parts.append(f'"{folder_path}"')
preview_text = " ".join(cmd_parts)
self.preview_text.config(state=tk.NORMAL)
self.preview_text.delete("1.0", tk.END)
self.preview_text.insert("1.0", preview_text)
self.preview_text.config(state=tk.DISABLED)
def _add_to_paths_file(self):
"""Append the current command to paths.txt."""
if not self.selected_folder:
messagebox.showwarning("Warning", "Please select a folder first")
return
folder_path = self.selected_folder
# Check if already in file
if folder_path in self.added_folders:
self._show_status(f"Already added: {Path(folder_path).name}")
return
# Build command line - start fresh
cmd_parts = []
# Add mode if not default
mode = self.mode_var.get()
if mode != "default":
cmd_parts.append(f'--m {mode}')
# Add resolution if specified
resolution = self.resolution_var.get()
if resolution != "none":
cmd_parts.append(f'--r {resolution}')
# Add CQ if specified
cq = self.cq_var.get().strip()
if cq:
cmd_parts.append(f'--cq {cq}')
# Add folder path
cmd_parts.append(f'"{folder_path}"')
line = " ".join(cmd_parts)
# Append to paths.txt
try:
# Check if file exists and has content
if self.paths_file.exists() and self.paths_file.stat().st_size > 0:
# Read last character to check if it ends with newline
with open(self.paths_file, "rb") as f:
f.seek(-1, 2) # Seek to last byte
last_char = f.read(1)
needs_newline = last_char != b'\n'
else:
needs_newline = False
# Write to file
with open(self.paths_file, "a", encoding="utf-8") as f:
if needs_newline:
f.write("\n")
f.write(line + "\n")
# Add to tracked set
self.added_folders.add(folder_path)
# Silent success - show status label instead of popup
self.recently_added = folder_path
self._show_status(f"✓ Added: {Path(folder_path).name}")
logger.info(f"Added to paths.txt: {line}")
# Clear timer if exists
if self.status_timer:
self.root.after_cancel(self.status_timer)
# Clear status after 3 seconds
self.status_timer = self.root.after(3000, self._clear_status)
except Exception as e:
messagebox.showerror("Error", f"Failed to write to paths.txt: {e}")
logger.error(f"Failed to write to paths.txt: {e}")
def _remove_from_paths_file(self, folder_path):
"""Remove a folder from paths.txt."""
if not self.paths_file.exists():
messagebox.showwarning("Warning", "paths.txt does not exist")
return
try:
with open(self.paths_file, "r", encoding="utf-8") as f:
lines = f.readlines()
# Filter out lines containing this folder path
new_lines = []
found = False
for line in lines:
if f'"{folder_path}"' in line or f"'{folder_path}'" in line:
found = True
else:
new_lines.append(line)
if not found:
messagebox.showwarning("Warning", "Path not found in paths.txt")
return
# Write back
with open(self.paths_file, "w", encoding="utf-8") as f:
f.writelines(new_lines)
# Remove from tracked set
self.added_folders.discard(folder_path)
self._show_status(f"✓ Removed: {Path(folder_path).name}")
logger.info(f"Removed from paths.txt: {folder_path}")
# Clear timer if exists
if self.status_timer:
self.root.after_cancel(self.status_timer)
# Clear status after 3 seconds
self.status_timer = self.root.after(3000, self._clear_status)
except Exception as e:
messagebox.showerror("Error", f"Failed to remove from paths.txt: {e}")
logger.error(f"Failed to remove from paths.txt: {e}")
def _show_status(self, message):
"""Show status message in label."""
self.status_label.config(text=message, foreground="green")
def _clear_status(self):
"""Clear status message after delay."""
self.status_label.config(text="")
self.status_timer = None
def _run_transcode(self):
"""Launch transcode.bat in a new command window."""
if not self.transcode_bat.exists():
messagebox.showerror("Error", f"transcode.bat not found at {self.transcode_bat}")
return
try:
# Launch in new cmd window
subprocess.Popen(
['cmd', '/c', f'"{self.transcode_bat}"'],
cwd=str(self.transcode_bat.parent),
creationflags=subprocess.CREATE_NEW_CONSOLE
)
logger.info("Launched transcode.bat")
except Exception as e:
messagebox.showerror("Error", f"Failed to launch transcode.bat: {e}")
logger.error(f"Failed to launch transcode.bat: {e}")
def _view_paths_file(self):
"""Open paths.txt in a new window."""
if not self.paths_file.exists():
messagebox.showinfo("Info", "paths.txt does not exist yet")
return
try:
with open(self.paths_file, "r", encoding="utf-8") as f:
content = f.read()
# Create new window
view_window = tk.Toplevel(self.root)
view_window.title("paths.txt")
view_window.geometry("800x400")
text_widget = tk.Text(view_window, wrap=tk.WORD)
text_widget.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
text_widget.insert("1.0", content)
# Add close button
ttk.Button(view_window, text="Close", command=view_window.destroy).pack(pady=5)
except Exception as e:
messagebox.showerror("Error", f"Failed to read paths.txt: {e}")
def _clear_paths_file(self):
"""Clear the paths.txt file."""
if not self.paths_file.exists():
messagebox.showinfo("Info", "paths.txt does not exist")
return
if messagebox.askyesno("Confirm", "Are you sure you want to clear paths.txt?"):
try:
self.paths_file.write_text("", encoding="utf-8")
messagebox.showinfo("Success", "paths.txt has been cleared")
logger.info("paths.txt cleared")
except Exception as e:
messagebox.showerror("Error", f"Failed to clear paths.txt: {e}")
def _on_closing(self):
"""Handle window closing - cleanup timers."""
self.root.destroy()
def _on_scrollbar(self, *args):
"""Handle scrollbar movement - load more items when scrolling."""
self.tree.yview(*args)
# Check if we need to load more items
if self.all_folders and self.loaded_items < len(self.all_folders):
# Get scroll position
first_visible = self.tree.yview()[0]
last_visible = self.tree.yview()[1]
# If we're past 70% scrolled, load more
if last_visible > 0.7:
self._load_more_items()
def _load_more_items(self):
"""Load next batch of items into tree."""
start = self.loaded_items
end = min(start + self.items_per_batch, len(self.all_folders))
for i in range(start, end):
folder_name, folder_path, size = self.all_folders[i]
size_str = self._format_size(size)
folder_path_str = str(folder_path)
# Determine button and tag
if folder_path_str in self.added_folders:
add_btn = ""
remove_btn = "[-]"
tag = "added"
else:
add_btn = "[+]"
remove_btn = ""
tag = "not_added"
self.tree.insert("", "end", text=folder_name, values=(size_str, add_btn, remove_btn),
tags=(folder_path_str, tag))
self.loaded_items = end
def main():
root = tk.Tk()
app = PathManagerGUI(root)
root.mainloop()
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,100 +0,0 @@
2025-12-31 12:42:36 | Pacific Rim (2013) x265 AAC 7.1 Bluray-1080p Tigole.mkv | CQ failed: Size threshold not met (71.6%)
2025-12-31 12:42:54 | Behind the Scenes - Drift Space.mkv | CQ failed: Size threshold not met (122.5%)
2025-12-31 13:02:27 | Behind the Scenes - Drift Space.mkv | CQ failed: Size threshold not met (122.5%)
2025-12-31 13:03:26 | Behind the Scenes - The Digital Artistry of Pacific Rim.mkv | CQ failed: Size threshold not met (180.9%)
2025-12-31 13:04:07 | Behind the Scenes - The Shatterdome.mkv | CQ failed: Size threshold not met (174.6%)
2025-12-31 13:40:45 | The Keyboard Cowboys - A Look Back at Hackers.mkv | CQ failed: Size threshold not met (135.1%)
2025-12-31 13:40:54 | Trailer.mkv | CQ failed: Size threshold not met (124.7%)
2025-12-31 14:02:32 | The Making of The Truman Show.mkv | CQ failed: Size threshold not met (117.3%)
2025-12-31 14:02:45 | The Visual Effects of The Truman Show.mkv | CQ failed: Size threshold not met (106.3%)
2025-12-31 14:02:51 | Product Placement.mkv | CQ failed: Size threshold not met (106.8%)
2025-12-31 14:24:35 | A Shot in the Dark.mkv | CQ failed: Size threshold not met (110.1%)
2025-12-31 14:24:52 | Chad and Keanu - Through Wick and Thin.mkv | CQ failed: Size threshold not met (119.8%)
2025-12-31 14:25:08 | In Honor of the Dead.mkv | CQ failed: Size threshold not met (123.7%)
2025-12-31 14:44:59 | The Lord of the Rings - The Return of the King (2003) x265 EAC3 5.1 Bluray-1080p GalaxyRG265.mkv | CQ failed: Size threshold not met (79.0%)
2025-12-31 15:09:01 | A Shot in the Dark.mkv | CQ failed: Size threshold not met (110.1%)
2025-12-31 15:09:19 | Chad and Keanu - Through Wick and Thin.mkv | CQ failed: Size threshold not met (119.8%)
2025-12-31 15:09:41 | In Honor of the Dead.mkv | CQ failed: Size threshold not met (123.7%)
2025-12-31 15:13:45 | Starship Troopers (1997) x265 AAC 5.1 Bluray-1080p Tigole.mkv | CQ error: Command '['ffmpeg', '-y', '-i', 'processing\\Starship Troopers (1997) x265 AAC 5.1 Bluray-1080p Tigo
2025-12-31 15:31:08 | Death From Above.mkv | CQ failed: Size threshold not met (90.5%)
2025-12-31 15:31:19 | Deleted Scenes.mkv | CQ failed: Size threshold not met (75.2%)
2025-12-31 15:31:57 | FX Comparisons.mkv | CQ failed: Size threshold not met (86.5%)
2025-12-31 15:51:25 | Behind the Scenes.mkv | CQ failed: Size threshold not met (124.6%)
2025-12-31 15:51:51 | Bikes, Blades, Bridges, and Bits.mkv | CQ failed: Size threshold not met (133.2%)
2025-12-31 15:52:30 | Check Your Sights.mkv | CQ failed: Size threshold not met (130.9%)
2025-12-31 16:11:32 | A Museum Tour with Sir Jonathan Wick.mkv | CQ failed: Size threshold not met (118.6%)
2025-12-31 16:11:51 | As Above, So Below - The Underworld of John Wick.mkv | CQ failed: Size threshold not met (125.2%)
2025-12-31 16:12:09 | Car Fu Ride-Along.mkv | CQ failed: Size threshold not met (173.8%)
2025-12-31 16:40:05 | TAYLOR SWIFT THE ERAS TOUR (2023) x265 EAC3 5.1 WEBRip-1080p GalaxyRG265.mkv | CQ failed: Size threshold not met (83.5%)
2025-12-31 18:10:24 | Interview with director Joe Dante.mkv | CQ failed: Size threshold not met (97.8%)
2025-12-31 19:15:56 | Supernatural - S03E01 - The Magnificent Seven x265 AC3 Bluray-1080p HiQVE.mkv | CQ failed: Size threshold not met (88.5%)
2026-01-01 01:25:05 | Supernatural - S07E17 - The Born-Again Identity x265 AC3 Bluray-1080p HiQVE.mkv | CQ error: Command '['ffmpeg', '-y', '-i', 'C:\\Users\\Tyler\\Documents\\GitHub\\conversion_project\\processing
2026-01-01 13:17:15 | The Office (US) - S08E04 - Garden Party x265 AAC Bluray-1080p Silence.mkv | CQ failed: Size threshold not met (122.2%)
2026-01-01 13:22:48 | The Office (US) - S08E04 - Garden Party x265 AAC Bluray-1080p Silence.mkv | CQ failed: Size threshold not met (101.3%)
2026-01-01 20:53:58 | ._Superman (2025) x265 EAC3 7.1 Bluray-1080p Ghost.mkv | CQ error: Command '['ffmpeg', '-y', '-i', 'C:\\Users\\Tyler\\Documents\\GitHub\\conversion_project\\processing
2026-01-01 20:55:56 | [sam] Vanitas no Carte - 02 [BD 1080p FLAC] [8822B4BC].mkv | Unexpected error: too many values to unpack (expected 5)
2026-01-01 20:56:12 | [sam] Vanitas no Carte - 03 [BD 1080p FLAC] [BDE63D2B].mkv | Unexpected error: too many values to unpack (expected 5)
2026-01-01 21:00:10 | [sam] Vanitas no Carte - 02 [BD 1080p FLAC] [8822B4BC].mkv | Unexpected error: too many values to unpack (expected 5)
2026-01-01 22:51:03 | ._Superman (2025) x265 EAC3 7.1 Bluray-1080p Ghost.mkv | CQ error: Command '['ffmpeg', '-y', '-i', 'C:\\Users\\Tyler\\Documents\\GitHub\\conversion_project\\processing
2026-01-01 22:51:21 | A New Era DC Takes Off.mkv | CQ failed: Size threshold not met (81.8%)
2026-01-01 22:51:37 | Adventures in the Making of “Superman”.mkv | Unexpected error: 'NoneType' object has no attribute 'split'
2026-01-01 22:53:54 | ._Superman (2025) x265 EAC3 7.1 Bluray-1080p Ghost.mkv | CQ error: Command '['ffmpeg', '-y', '-i', 'C:\\Users\\Tyler\\Documents\\GitHub\\conversion_project\\processing
2026-01-01 22:54:13 | A New Era DC Takes Off.mkv | CQ failed: Size threshold not met (81.8%)
2026-01-01 22:57:40 | ._Superman (2025) x265 EAC3 7.1 Bluray-1080p Ghost.mkv | CQ error: Command '['ffmpeg', '-y', '-i', 'C:\\Users\\Tyler\\Documents\\GitHub\\conversion_project\\processing
2026-01-01 22:58:01 | A New Era DC Takes Off.mkv | Unexpected error: name 'suffix' is not defined
2026-01-08 10:37:48 | According to Plan.mkv | CQ failed: Size threshold not met (94.3%)
2026-01-08 10:37:56 | Bloopers of the Caribbean.mkv | CQ failed: Size threshold not met (122.9%)
2026-01-08 10:39:01 | Captain Jack - From Head to Toe.mkv | CQ failed: Size threshold not met (110.9%)
2026-01-08 11:46:19 | Anatomy of a Scene - The Maelstrom.mkv | CQ failed: Size threshold not met (202.4%)
2026-01-08 11:46:59 | Bloopers of the Caribbean.mkv | CQ failed: Size threshold not met (107.0%)
2026-01-08 11:50:26 | Deleted & Extended Scenes.mkv | CQ failed: Size threshold not met (116.3%)
2026-01-08 13:38:12 | An Epic at Sea.mkv | CQ failed: Size threshold not met (85.1%)
2026-01-08 13:38:27 | Becoming Barbossa.mkv | CQ failed: Size threshold not met (93.0%)
2026-01-08 13:38:47 | Becoming Captain Jack.mkv | CQ failed: Size threshold not met (95.2%)
2026-01-08 13:56:34 | Bloopers of the Caribbean.mkv | CQ failed: Size threshold not met (103.5%)
2026-01-08 14:04:01 | Dead Men Tell No Tales - The Making of a New Adventure.mkv | CQ failed: Size threshold not met (156.6%)
2026-01-08 14:22:53 | Bloopers of the Caribbean.mkv | CQ failed: Size threshold not met (116.2%)
2026-01-08 14:24:16 | Deleted and Extended Scenes.mkv | CQ failed: Size threshold not met (115.3%)
2026-01-08 14:26:55 | Easter Eggs.mkv | CQ failed: Size threshold not met (227.9%)
2026-01-08 16:15:19 | Trailer [kr].mkv | CQ failed: Size threshold not met (106.3%)
2026-01-08 18:49:04 | The Ultimate Villain.mkv | CQ failed: Size threshold not met (85.9%)
2026-01-10 09:27:28 | 2.5.Dimensional.Seduction.2024.S01E01.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold.mkv | Unexpected error: name 'subtitle_file' is not defined
2026-01-10 09:39:03 | 2.5.Dimensional.Seduction.2024.S01E01.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold.mkv | Unexpected error: name 'subtitle_file' is not defined
2026-01-10 09:44:37 | 2.5.Dimensional.Seduction.2024.S01E01.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold.mkv | Unexpected error: name 'subtitle_file' is not defined
2026-01-10 23:31:01 | Animation.mkv | CQ failed: Size threshold not met (142.4%)
2026-01-10 23:31:13 | Art Design.mkv | CQ failed: Size threshold not met (136.3%)
2026-01-10 23:31:30 | Deleted Scenes.mkv | CQ failed: Size threshold not met (138.0%)
2026-01-13 22:28:50 | Ted Lasso (2020) - S00E01 - An American Coach in London (1080p YT WEB-DL x265 Ghost).mkv | CQ failed: Size threshold not met (180.6%)
2026-01-13 22:28:58 | Ted Lasso (2020) - S00E05 - SAG Awards Ted Lasso Team on the Greatest Ensembles of All Time (1080p YT WEB-DL x265 Ghost).mkv | CQ failed: Size threshold not met (102.1%)
2026-01-13 22:29:08 | Ted Lasso (2020) - S00E02 - Behind-the-Scenes with Coach Ted Lasso (1080p YT WEB-DL x265 Ghost).mkv | CQ failed: Size threshold not met (144.3%)
2026-01-13 22:31:55 | Ted Lasso (2020) - S01E01 - Pilot (1080p BluRay x265 Ghost).mkv | CQ failed: Size threshold not met (94.1%)
2026-01-13 23:15:08 | Season 1 - Extra Time with Coach Lasso - NBC Sports.mkv | CQ failed: Size threshold not met (169.4%)
2026-01-13 23:15:32 | Season 1 - Honest Trailer - Screen Junkies.mkv | CQ failed: Size threshold not met (174.9%)
2026-01-13 23:16:05 | Season 1 - How Fake Crowds Were Made For “Ted Lasso” - Insider.mkv | CQ failed: Size threshold not met (223.0%)
2026-01-14 00:02:36 | Season 2 - A Conversation with Hannah Waddingham & Juno Temple - Apple TV.mkv | CQ failed: Size threshold not met (140.3%)
2026-01-14 00:03:13 | Season 2 - Interview with Jason Sudeikis - The Tonight Show.mkv | CQ failed: Size threshold not met (297.6%)
2026-01-14 00:04:01 | Season 2 - Meet Brett Goldstein & Brendan Hunt - Cover Shoot - Entertainment Weekly.mkv | CQ failed: Size threshold not met (133.2%)
2026-01-16 20:19:34 | Chaos Dragon - Sekiryuu Seneki 2.mkv | Unexpected error: 'WindowsPath' object is not iterable
2026-01-17 11:10:21 | Ted Lasso (2020) - S00E01 - An American Coach in London (1080p YT WEB-DL x265 Ghost).mkv | CQ failed: Size threshold not met (229.5%)
2026-01-17 11:10:53 | Ted Lasso (2020) - S00E02 - Behind-the-Scenes with Coach Ted Lasso (1080p YT WEB-DL x265 Ghost).mkv | CQ failed: Size threshold not met (179.0%)
2026-01-17 11:11:42 | Ted Lasso (2020) - S00E03 - The Return of Coach Lasso (1080p YT WEB-DL x265 Ghost).mkv | CQ failed: Size threshold not met (132.8%)
2026-01-17 12:40:02 | Sherlock - S00E04 - Many Happy Returns x265 AAC WEBRip-1080p KITE-METeam.mkv | CQ failed: Size threshold not met (162.2%)
2026-01-26 11:55:13 | The Office (US) - S00E07 - The Pod Caster x264 AAC WEBDL-720p MULVAcoded.mkv | CQ failed: Size threshold not met (132.3%)
2026-01-26 15:00:18 | The Office (US) - S00E11 - Webisodes - The Podcast x264 AAC WEBDL-720p MULVAcoded.mkv | CQ failed: Size threshold not met (124.9%)
2026-01-26 20:54:10 | The Office (US) - S00E08 - Threat Level Midnight - The Movie x264 AAC WEBDL-720p MULVAcoded.mkv | CQ failed: Size threshold not met (119.7%)
2026-01-26 21:55:34 | The Office (US) - S00E09 - Recap Special x264 AAC WEBDL-720p MULVAcoded.mkv | CQ failed: Size threshold not met (124.8%)
2026-01-26 22:21:59 | The Office (US) - S00E13 - The Office Retrospective x264 AAC HDTV-720p MULVAcoded.mkv | CQ failed: Size threshold not met (125.8%)
2026-02-02 09:28:26 | Click Click, Bang Bang - Making of 2 Guns.mkv | CQ failed: Size threshold not met (136.0%)
2026-02-08 17:28:35 | Brothers in Blue.mkv | CQ failed: Size threshold not met (174.7%)
2026-02-08 17:29:21 | Camera Test.mkv | CQ failed: Size threshold not met (124.2%)
2026-02-08 17:31:19 | Deleted and Alternate Scenes.mkv | CQ failed: Size threshold not met (113.2%)
2026-02-19 14:48:03 | Season 1 & 2 Bloopers.mkv | CQ failed: Size threshold not met (98.5%)
2026-02-21 11:24:07 | Dimension 20 - S27E04 - Poppy Persona Non Grata.mp4 | Unexpected error: too many values to unpack (expected 7)
2026-02-21 11:27:17 | Dimension 20 - S27E04 - Poppy Persona Non Grata.mp4 | Unexpected error: too many values to unpack (expected 7)
2026-02-21 11:27:21 | Dimension 20 - S27E05 - A Hugi Minute.mp4 | Unexpected error: too many values to unpack (expected 7)
2026-02-21 15:06:34 | Decoding Die Hard.mkv | CQ failed: Size threshold not met (120.5%)
2026-02-21 15:06:58 | Easter Egg - Die Semi-Hard.mkv | CQ failed: Size threshold not met (109.6%)
2026-02-21 15:07:12 | Gallery.mkv | CQ failed: Size threshold not met (163.3%)
2026-02-22 10:07:41 | Taskmaster - S01E01 - Melon Buffet h265 AAC WEBRip-1080p EHX.mkv | CQ error: Command '['ffmpeg', '-y', '-i', 'C:\\Users\\Tyler\\Documents\\GitHub\\conversion_project\\processing
2026-02-22 10:07:51 | Taskmaster - S10E01 - God's Haemorrhoid h265 AAC WEBRip-1080p EHX.mkv | CQ error: Command '['ffmpeg', '-y', '-i', "C:\\Users\\Tyler\\Documents\\GitHub\\conversion_project\\processing
2026-02-22 10:08:00 | Taskmaster - S10E02 - A Documentary About a Despot h265 AAC WEBRip-1080p EHX.mkv | CQ error: Command '['ffmpeg', '-y', '-i', 'C:\\Users\\Tyler\\Documents\\GitHub\\conversion_project\\processing

660
main.py
View File

@ -1,223 +1,507 @@
#!/usr/bin/env python3
"""
AV1 Batch Video Transcoder
Main entry point for batch video encoding with intelligent audio and resolution handling.
"""
import argparse
import csv
import json
import os
import shutil
import subprocess
from pathlib import Path
from functools import lru_cache
from concurrent.futures import ThreadPoolExecutor, as_completed
from core.config_helper import load_config_xml
from core.logger_helper import setup_logger
from core.process_manager import process_folder, get_default_cq
# =============================
# PATH NORMALIZATION
# =============================
def normalize_input_path(input_path: str, path_mappings: dict) -> Path:
"""
Normalize input path from various formats to Windows path.
Supports:
- Windows paths: "P:\\tv\\show" or "P:/tv/show"
- Linux paths: "/mnt/plex/tv/show" (maps to Windows equivalent if mapping exists)
- Mixed separators: "P:/tv\\show"
Args:
input_path: Path string from user input
path_mappings: Dict mapping Windows paths to Linux paths from config
Returns:
Path object pointing to the actual local folder
"""
# First, try to map Linux paths to Windows paths (reverse mapping)
# If user provides "/mnt/plex/tv", find the mapping and convert to "P:\\tv"
if isinstance(path_mappings, list):
# New format: list of dicts
for mapping in path_mappings:
if isinstance(mapping, dict):
win_path = mapping.get("from")
linux_path = mapping.get("to")
if linux_path and input_path.lower().startswith(linux_path.lower()):
# Found a matching Linux path, convert to Windows
relative = input_path[len(linux_path):].lstrip("/").lstrip("\\")
result = Path(win_path) / relative if relative else Path(win_path)
logger.info(f"Path mapping: {input_path} -> {result}")
print(f" Mapped Linux path {input_path} to {result}")
return result
else:
# Old format: dict (for backwards compatibility)
for win_path, linux_path in path_mappings.items():
if input_path.lower().startswith(linux_path.lower()):
# Found a matching Linux path, convert to Windows
relative = input_path[len(linux_path):].lstrip("/").lstrip("\\")
result = Path(win_path) / relative if relative else Path(win_path)
logger.info(f"Path mapping: {input_path} -> {result}")
print(f" Mapped Linux path {input_path} to {result}")
return result
# No mapping found, use path as-is (normalize separators to Windows)
# Convert forward slashes to backslashes for Windows
normalized = input_path.replace("/", "\\")
result = Path(normalized)
logger.info(f"Using path as-is: {result}")
return result
# =============================
# Setup
# Setup logger
# =============================
LOG_FOLDER = Path(__file__).parent / "logs"
logger = setup_logger(LOG_FOLDER)
# =============================
# Tracker CSV
# =============================
TRACKER_FILE = Path(__file__).parent / "conversion_tracker.csv"
if not TRACKER_FILE.exists():
with open(TRACKER_FILE, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow([
"type", "show", "filename", "original_size_MB", "processed_size_MB", "percentage",
"source_resolution", "target_resolution", "audio_streams", "cq_value", "method"
"type","show","filename","original_size_MB","processed_size_MB","percentage","method"
])
# =============================
# FFPROBE CACHING
# =============================
@lru_cache(maxsize=256)
def get_audio_streams_cached(input_file_str: str):
"""Cached ffprobe call to avoid redundant queries"""
input_file = Path(input_file_str)
cmd = [
"ffprobe","-v","error","-select_streams","a",
"-show_entries","stream=index,channels,duration,bit_rate,tags=language",
"-of","json", str(input_file)
]
result = subprocess.run(cmd, capture_output=True, text=True)
return json.loads(result.stdout)
# =============================
# AUDIO BUCKET LOGIC
# =============================
def choose_audio_bitrate(channels: int, bitrate_kbps: int, audio_config: dict) -> int:
if channels == 2:
if bitrate_kbps < 100:
return audio_config["stereo"]["low"]
elif bitrate_kbps < 130:
return audio_config["stereo"]["medium"]
else:
return audio_config["stereo"]["high"]
else:
if bitrate_kbps < 390:
return audio_config["multi_channel"]["low"]
elif bitrate_kbps < 515:
return audio_config["multi_channel"]["medium"]
else:
return audio_config["multi_channel"]["high"]
# =============================
# PATH NORMALIZATION
# =============================
def normalize_path_for_service(local_path: str, path_mappings: dict) -> str:
for win_path, linux_path in path_mappings.items():
if local_path.lower().startswith(win_path.lower()):
return local_path.replace(win_path, linux_path).replace("\\", "/")
return local_path.replace("\\", "/")
# =============================
# AUDIO STREAMS DETECTION
# =============================
def get_audio_streams(input_file: Path):
cmd = [
"ffprobe","-v","error","-select_streams","a",
"-show_entries","stream=index,channels,duration,bit_rate,tags=language",
"-of","json", str(input_file)
]
result = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(result.stdout)
streams = []
for s in data.get("streams", []):
index = s["index"]
channels = s.get("channels", 2)
src_lang = s.get("tags", {}).get("language", "und")
bit_rate_meta = int(s.get("bit_rate", 0)) if s.get("bit_rate") else 0
try:
duration = float(s.get("duration", 0))
if duration and bit_rate_meta == 0:
fmt_cmd = [
"ffprobe","-v","error","-show_entries","format=size,duration",
"-of","json", str(input_file)
]
fmt_result = subprocess.run(fmt_cmd, capture_output=True, text=True)
fmt_data = json.loads(fmt_result.stdout)
size_bytes = int(fmt_data.get("format", {}).get("size", 0))
total_duration = float(fmt_data.get("format", {}).get("duration", duration))
n_streams = len(data.get("streams", []))
avg_bitrate_kbps = int((size_bytes*8/n_streams)/total_duration/1000)
elif duration and bit_rate_meta:
avg_bitrate_kbps = int(bit_rate_meta / 1000)
else:
avg_bitrate_kbps = 128
except Exception:
avg_bitrate_kbps = 128
streams.append((index, channels, avg_bitrate_kbps, src_lang, int(bit_rate_meta / 1000)))
return streams
# =============================
# OUTPUT VALIDATION
# =============================
def validate_output(input_file: Path, output_file: Path, expected_width: int, expected_height: int) -> bool:
"""Validate that output file has correct resolution and audio tracks"""
try:
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "json", str(output_file)
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
data = json.loads(result.stdout)
if not data.get("streams"):
logger.warning(f"❌ Validation failed: No video stream in {output_file.name}")
return False
width = data["streams"][0].get("width", 0)
height = data["streams"][0].get("height", 0)
# Allow small variance for scaling
if abs(width - expected_width) > 10 or abs(height - expected_height) > 10:
logger.warning(f"❌ Validation failed: Resolution {width}x{height}, expected ~{expected_width}x{expected_height}")
return False
logger.info(f"✅ Validation passed: {output_file.name} ({width}x{height})")
return True
except Exception as e:
logger.warning(f"⚠️ Validation skipped (probe error): {e}")
return True # Don't fail on validation errors
# =============================
# FFmpeg ENCODE (GPU + CPU fallback, per-resolution CPU preset)
# =============================
def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, scale_height: int,
filter_flags: str, audio_config: dict, method: str, crf_cpu: int, verbose: bool = False):
streams = get_audio_streams(input_file)
encoder_name = "av1_nvenc"
pix_fmt = "p010le"
header = (
f"\n🧩 ENCODE SETTINGS\n"
f" • Resolution: {scale_width}x{scale_height}\n"
f" • Scale Filter: {filter_flags}\n"
f" • CQ: {cq if method=='CQ' else 'N/A'}\n"
f" • CPU CRF: {crf_cpu}\n"
f" • Video Encoder: {encoder_name} (preset p1, pix_fmt {pix_fmt})\n"
f" • Audio Streams:"
)
logger.info(header)
print(header)
for (index, channels, avg_bitrate, src_lang, meta_bitrate) in streams:
output_channels = 2 if scale_height <= 720 else (6 if channels >= 6 else 2)
br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config)
line = (
f" - Stream #{index}: {channels}ch→{output_channels}ch, src={src_lang}, "
f"avg_bitrate={avg_bitrate}kbps, metadata={meta_bitrate}kbps, bucket_target={br/1000:.1f}kbps"
)
print(line)
logger.info(line)
cmd = [
"ffmpeg", "-y", "-i", str(input_file),
"-vf", f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease",
"-map", "0:v", "-map", "0:a", "-map", "0:s?",
"-c:v", encoder_name, "-preset", "p1", "-pix_fmt", pix_fmt
]
# Video quality
if method == "CQ":
cmd += ["-cq", str(cq)]
else:
if scale_height >= 1080:
vb, maxrate, bufsize = "1500k", "1750k", "2250k"
else:
vb, maxrate, bufsize = "900k", "1250k", "1600k"
cmd += ["-b:v", vb, "-maxrate", maxrate, "-bufsize", bufsize]
# Audio streams
for i, (index, channels, avg_bitrate, src_lang, meta_bitrate) in enumerate(streams):
# Determine output channels: 720p -> 2ch, 1080p -> 6ch if input>=6 else 2ch
output_channels = 2 if scale_height <= 720 else (6 if channels >= 6 else 2)
# Choose bitrate based on OUTPUT channels, not input
br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config)
cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", str(br), f"-ac:{i}", str(output_channels)]
cmd += ["-c:s", "copy", str(output_file)]
print(f"\n🎬 Running {method} encode: {output_file.name}")
logger.info(f"Running {method} encode: {output_file.name}")
if verbose:
logger.info(f"FFmpeg command: {' '.join(cmd)}")
# Try GPU encoder first
try:
if verbose:
subprocess.run(cmd, check=True)
else:
subprocess.run(cmd, check=True, capture_output=True)
except subprocess.CalledProcessError as e:
print(f"❌ FFmpeg failed with GPU encoder on {input_file.name}: {e}")
logger.error(f"GPU encode failed for {input_file.name}. Command: {' '.join(cmd)}")
# CPU fallback
cmd_cpu = cmd.copy()
idx = cmd_cpu.index(encoder_name)
cmd_cpu[idx] = "libsvtav1"
# CPU preset based on resolution
cpu_preset = "8" if scale_height <= 720 else "6" # faster for 720p, slower for 1080p
preset_idx = cmd_cpu.index("p1")
cmd_cpu[preset_idx] = cpu_preset
# Replace -cq with -crf
if "-cq" in cmd_cpu:
cq_idx = cmd_cpu.index("-cq")
cmd_cpu[cq_idx] = "-crf"
cmd_cpu[cq_idx + 1] = str(crf_cpu)
try:
if verbose:
subprocess.run(cmd_cpu, check=True)
else:
subprocess.run(cmd_cpu, check=True, capture_output=True)
print("✅ CPU fallback succeeded")
logger.info("CPU fallback succeeded")
except subprocess.CalledProcessError as e_cpu:
print(f"❌ CPU fallback also failed for {input_file.name}: {e_cpu}")
logger.error(f"CPU fallback failed for {input_file.name}. Command: {' '.join(cmd_cpu)}")
raise e_cpu
orig_size = input_file.stat().st_size
out_size = output_file.stat().st_size
reduction_ratio = out_size / orig_size
msg = f"📦 Original: {orig_size/1e6:.2f} MB → Encoded: {out_size/1e6:.2f} MB ({reduction_ratio:.1%} of original)"
print(msg)
logger.info(msg)
return orig_size, out_size, reduction_ratio
# =============================
# PROCESS FOLDER
# =============================
def process_folder(folder: Path, cq: int, resolution: str, config: dict, dry_run: bool = False,
verbose: bool = False, backup: bool = False, cleanup: bool = False, parallel: int = 1):
if not folder.exists():
print(f"❌ Folder not found: {folder}")
logger.error(f"Folder not found: {folder}")
return
audio_config = config["audio"]
filters_config = config["encode"]["filters"]
suffix = config["suffix"]
extensions = config["extensions"]
ignore_tags = config["ignore_tags"]
reduction_ratio_threshold = config["reduction_ratio_threshold"]
res_height = 1080 if resolution == "1080" else 720
res_width = 1920 if resolution == "1080" else 1280
# Determine type and resolution keys
folder_lower = str(folder).lower()
if "\\tv\\" in folder_lower or "/tv/" in folder_lower:
type_key = "tv"
filter_flags = filters_config.get("tv", "bicubic")
else:
type_key = "movie"
filter_flags = filters_config.get("default", "lanczos")
res_key = "1080" if resolution == "1080" else "720"
# Get CQ and CRF from config
cq_default = config["encode"]["cq"].get(f"{type_key}_{res_key}", 32)
crf_cpu = config["encode"]["crf"].get(f"{type_key}_{res_key}", 32)
if cq is None:
cq = cq_default
processing_folder = Path(config["processing_folder"])
processing_folder.mkdir(parents=True, exist_ok=True)
# Cleanup old processing folder if requested
if cleanup and processing_folder.exists():
print(f"🧹 Cleaning up old processing folder: {processing_folder}")
logger.info(f"Cleaning up old processing folder: {processing_folder}")
shutil.rmtree(processing_folder, ignore_errors=True)
processing_folder.mkdir(parents=True, exist_ok=True)
# Backup folder setup
backup_folder = None
if backup:
backup_folder = folder.parent / f"{folder.name}_backup"
backup_folder.mkdir(parents=True, exist_ok=True)
print(f"💾 Backup enabled: {backup_folder}")
logger.info(f"Backup folder: {backup_folder}")
# Dry-run message
if dry_run:
print("🔍 DRY-RUN MODE: No files will be encoded or deleted")
logger.info("DRY-RUN MODE: No files will be encoded or deleted")
# Track if we switch to bitrate mode
use_bitrate = False
# Collect all files to process first
files_to_process = []
for file in folder.rglob("*"):
if file.suffix.lower() not in extensions:
continue
if any(tag.lower() in file.name.lower() for tag in ignore_tags):
print(f"⏭️ Skipping: {file.name}")
logger.info(f"Skipping: {file.name}")
continue
files_to_process.append(file)
if not files_to_process:
print("❌ No files found to process")
logger.info("No files found to process")
return
print(f"📋 Found {len(files_to_process)} file(s) to process")
# Define the encoding task
def encode_file(file: Path):
"""Encodes a single file - used for parallel processing"""
try:
print("="*60)
logger.info(f"Processing: {file.name}")
print(f"📁 Processing: {file.name}")
temp_input = processing_folder / file.name
shutil.copy2(file, temp_input)
logger.info(f"Copied {file.name}{temp_input.name}")
temp_output = processing_folder / f"{file.stem}{suffix}{file.suffix}"
method = "Bitrate" if use_bitrate else "CQ"
if dry_run:
print(f"🔍 [DRY-RUN] Would encode: {temp_output}")
logger.info(f"[DRY-RUN] Would encode: {temp_output}")
return None
try:
orig_size, out_size, reduction_ratio = run_ffmpeg(
temp_input, temp_output, cq, res_width, res_height, filter_flags,
audio_config, method, crf_cpu, verbose
)
except subprocess.CalledProcessError as e:
print(f"❌ FFmpeg failed: {e}")
logger.error(f"FFmpeg failed: {e}")
temp_input.unlink(missing_ok=True)
temp_output.unlink(missing_ok=True)
return None
# Validate output
if not validate_output(temp_input, temp_output, res_width, res_height):
print(f"⚠️ Validation failed for {temp_output.name}, keeping original")
logger.warning(f"Validation failed for {temp_output.name}")
temp_input.unlink(missing_ok=True)
temp_output.unlink(missing_ok=True)
return None
# Handle fallback if CQ/Bitrate didn't reach target
if method == "CQ" and reduction_ratio >= reduction_ratio_threshold:
print(f"⚠️ CQ encode did not achieve target size ({reduction_ratio:.1%} >= {reduction_ratio_threshold:.1%}). Retrying with Bitrate.")
logger.warning(f"CQ encode failed target ({reduction_ratio:.1%}). Retrying with Bitrate.")
try:
temp_output.unlink(missing_ok=True)
orig_size, out_size, reduction_ratio = run_ffmpeg(
temp_input, temp_output, cq, res_width, res_height, filter_flags,
audio_config, "Bitrate", crf_cpu, verbose
)
if reduction_ratio >= reduction_ratio_threshold:
print("❌ Bitrate encode also failed target.")
logger.error("Bitrate encode failed target.")
temp_input.unlink(missing_ok=True)
temp_output.unlink(missing_ok=True)
return None
except subprocess.CalledProcessError as e:
print(f"❌ Bitrate retry failed: {e}")
logger.error(f"Bitrate retry failed: {e}")
temp_input.unlink(missing_ok=True)
temp_output.unlink(missing_ok=True)
return None
elif reduction_ratio >= reduction_ratio_threshold:
print("❌ Encode failed target. Stopping.")
logger.error("Encode failed target.")
temp_input.unlink(missing_ok=True)
temp_output.unlink(missing_ok=True)
return None
# Move final file back to original folder
dest_file = file.parent / temp_output.name
if not dry_run:
shutil.move(temp_output, dest_file)
print(f"🚚 Moved {temp_output.name}{dest_file.name}")
logger.info(f"Moved {temp_output.name}{dest_file.name}")
# Backup original if requested
if backup and not dry_run:
backup_dest = backup_folder / file.name
shutil.copy2(file, backup_dest)
logger.info(f"Backed up original to {backup_dest}")
# Determine folder type and show
folder_parts = [p.lower() for p in folder.parts]
if "tv" in folder_parts:
f_type = "tv"
tv_index = folder_parts.index("tv")
show = folder.parts[tv_index + 1] if len(folder.parts) > tv_index + 1 else "Unknown"
elif "anime" in folder_parts:
f_type = "anime"
anime_index = folder_parts.index("anime")
show = folder.parts[anime_index + 1] if len(folder.parts) > anime_index + 1 else "Unknown"
else:
f_type = "movie"
show = "N/A"
orig_size_mb = round(orig_size / 1e6, 2)
proc_size_mb = round(out_size / 1e6, 2)
percentage = round(proc_size_mb / orig_size_mb * 100, 1)
# Log conversion in tracker CSV (skip in dry-run)
if not dry_run:
with open(TRACKER_FILE, "a", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow([f_type, show, dest_file.name, orig_size_mb, proc_size_mb, percentage, method])
logger.info(f"Tracked conversion: {dest_file.name}, {orig_size_mb}MB → {proc_size_mb}MB ({percentage}%), method={method}")
print(f"📝 Logged conversion: {dest_file.name} ({percentage}%), method={method}")
# Delete temporary and original files
if not dry_run:
try:
temp_input.unlink()
file.unlink()
logger.info(f"Deleted original and processing copy for {file.name}")
except Exception as e:
print(f"⚠️ Could not delete files: {e}")
logger.warning(f"Could not delete files: {e}")
return {"file": file.name, "orig": orig_size_mb, "proc": proc_size_mb, "pct": percentage}
except Exception as e:
logger.error(f"Unexpected error processing {file.name}: {e}", exc_info=True)
return None
# Process files sequentially or in parallel
if parallel > 1:
with ThreadPoolExecutor(max_workers=parallel) as executor:
futures = [executor.submit(encode_file, f) for f in files_to_process]
for future in as_completed(futures):
result = future.result()
else:
for file in files_to_process:
encode_file(file)
if dry_run:
print("🔍 DRY-RUN COMPLETE: No actual changes made")
else:
print("✅ Processing complete!")
# =============================
# MAIN
# =============================
def main():
parser = argparse.ArgumentParser(
description="Batch AV1 encode videos with intelligent audio and resolution handling",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s "C:\\Videos\\Movies" # Smart mode (preserve resolution, 4K->1080p)
%(prog)s "C:\\Videos\\TV" --r 720 --m bitrate # Force 720p, bitrate mode
%(prog)s "C:\\Videos\\Anime" --cq 28 --r 1080 # Force 1080p, CQ=28
%(prog)s "C:\\Videos\\Low-Res" --r 480 # Force 480p for low-res content
"""
)
parser.add_argument("folder", help="Input folder containing video files")
parser.add_argument("--cq", type=int, help="Override default CQ value")
parser.add_argument(
"--m", "--mode", dest="transcode_mode", default="cq",
choices=["cq", "bitrate"],
help="Encode mode: CQ (constant quality) or Bitrate mode"
)
parser.add_argument(
"--encoder", dest="encoder", default="hevc",
choices=["hevc", "av1"],
help="Video encoder: 'hevc' for HEVC NVENC 10-bit (default), 'av1' for AV1 NVENC 8-bit. Auto-selected based on source bit depth if not specified"
)
parser.add_argument(
"--r", "--resolution", dest="resolution", default=None,
choices=["480", "720", "1080"],
help="Target resolution (acts as max, downscales if source is larger). If not specified: 4K→1080p, else preserve source"
)
parser.add_argument(
"--test", dest="test_mode", default=False, action="store_true",
help="Test mode: encode only first file, show ratio, don't move or delete (default: False)"
)
parser.add_argument(
"--language", dest="audio_language", default=None,
help="Tag audio streams with language code (e.g., eng, spa, fra). If not set, audio language is unchanged"
)
parser.add_argument(
"--filter-audio", dest="filter_audio", default=None, action="store_true",
help="Interactive audio selection: show audio streams and let user choose which to keep (overrides config setting)"
)
parser.add_argument(
"--audio-select", dest="audio_select", default=None,
help="Pre-select audio streams to keep (comma-separated, e.g., 1,2). Skips interactive prompt"
)
parser.add_argument(
"--keep-all-titles", dest="strip_all_titles", default=True, action="store_false",
help="Preserve title metadata on audio tracks (default: titles are stripped)"
)
parser.add_argument(
"--unforce-subs", dest="unforce_subs", default=False, action="store_true",
help="Remove forced flag from all subtitle tracks"
)
parser.add_argument(
"--no-encode", dest="no_encode", default=False, action="store_true",
help="Skip encoding: copy video/audio streams as-is. Useful with --unforce-subs to only re-mux subtitles"
)
parser.add_argument(
"--force-process", dest="force_process", default=False, action="store_true",
help="Process files even if they contain ignore tags (e.g., already encoded files with suffix)"
)
parser.add_argument(
"--replace", dest="replace_file", default=False, action="store_true",
help="Replace original file instead of creating suffix version. Requires --no-encode"
)
parser.add_argument(
"--wait", "-w", dest="wait_seconds", type=int, nargs='?', const=-1, default=None,
help="Wait after each file (default: 30s with --no-encode, 0s otherwise). Gives Plex time to detect changes"
)
parser.add_argument(
"--travel", dest="travel_mode", default=False, action="store_true",
help="Travel mode: force 720p resolution and CQ+2, requires --output flag"
)
parser.add_argument(
"--output", dest="output_folder", default=None,
help="Output folder for travel mode (creates subfolder based on input folder name)"
)
parser = argparse.ArgumentParser(description="Batch encode videos with logging and tracker")
parser.add_argument("folder", help="Path to folder containing videos")
parser.add_argument("--cq", type=int, help="Override default CQ")
parser.add_argument("--r", "--resolution", dest="resolution", default="1080", choices=["720","1080"], help="Target resolution")
parser.add_argument("--dry-run", action="store_true", help="Preview files without encoding")
parser.add_argument("--verbose", "-v", action="store_true", help="Show FFmpeg output")
parser.add_argument("--backup", action="store_true", help="Backup original files before encoding")
parser.add_argument("--cleanup", action="store_true", help="Clean old processing folder on startup")
parser.add_argument("--parallel", type=int, default=1, metavar="N", help="Encode N files in parallel (experimental)")
parser.add_argument("--ratio", type=float, help="Reduction ratio threshold (default 0.5 from config)")
args = parser.parse_args()
# Load configuration
config_path = Path(__file__).parent / "config.xml"
config = load_config_xml(config_path)
# Normalize input path (handle Linux paths, mixed separators, etc.)
folder = normalize_input_path(args.folder, config.get("path_mappings", {}))
# Override reduction ratio if provided
if args.ratio:
config["reduction_ratio_threshold"] = args.ratio
# Verify folder exists
if not folder.exists():
print(f"❌ Folder not found: {folder}")
logger.error(f"Folder not found: {folder}")
return
process_folder(Path(args.folder), args.cq, args.resolution, config,
dry_run=args.dry_run, verbose=args.verbose, backup=args.backup,
cleanup=args.cleanup, parallel=args.parallel)
# Handle travel mode
travel_output_folder = None
if args.travel_mode:
if not args.output_folder:
print("❌ --travel flag requires --output folder to be specified")
logger.error("--travel flag used without --output folder")
return
# Parse output folder and create subfolder based on input folder name
output_base = Path(args.output_folder)
input_folder_name = folder.name
travel_output_folder = output_base / input_folder_name
# Create the output folder structure
travel_output_folder.mkdir(parents=True, exist_ok=True)
print(f"✅ Travel mode: Output folder set to {travel_output_folder}")
logger.info(f"Travel mode enabled: {folder} -> {travel_output_folder}")
# Set resolution to 720 in travel mode
args.resolution = "720"
# Get default CQ for 720p and add 2
default_cq = get_default_cq(folder, config, "720", args.encoder)
args.cq = default_cq + 2
print(f"✅ Travel mode: Resolution=720p, CQ={args.cq} (default {default_cq} + 2)")
logger.info(f"Travel mode: CQ set to {args.cq}")
# Validate --replace flag requires --no-encode
if args.replace_file and not args.no_encode:
print("❌ --replace requires --no-encode flag")
logger.error("--replace flag used without --no-encode")
return
# Set wait time default: 30s if --no-encode and --wait used, 0 otherwise
# -1 means --wait was used without a value (use intelligent default)
if args.wait_seconds is None:
args.wait_seconds = 0 # No --wait flag provided
elif args.wait_seconds == -1:
args.wait_seconds = 30 if args.no_encode else 0 # --wait used without value
# Process folder
process_folder(folder, args.cq, args.transcode_mode, args.resolution, config, TRACKER_FILE, args.test_mode, args.audio_language, args.filter_audio, args.audio_select, args.encoder, args.strip_all_titles, travel_output_folder, args.unforce_subs, args.no_encode, args.force_process, args.replace_file, args.wait_seconds)
if __name__ == "__main__":
main()

View File

@ -1,322 +0,0 @@
{
"P:\\tv\\Hero Inside (2023)": 7372329680,
"P:\\tv\\Below Deck": 47516712212,
"P:\\tv\\The Penguin": 4459075060,
"P:\\tv\\Banshee (2013)": 25030541772,
"P:\\tv\\Dungeons & Dragons": 6660128393,
"P:\\tv\\Made For Love (2021)": 2211136772,
"P:\\tv\\Sirens (2025)": 4246622090,
"P:\\tv\\Landman (2024)": 35220290035,
"P:\\tv\\Last Man Standing": 49393251846,
"P:\\tv\\Alien - Earth (2025)": 2926145405,
"P:\\tv\\The Big Door Prize": 2314902686,
"P:\\tv\\Government Cheese (2025)": 15970704500,
"P:\\tv\\In the Dark (2019)": 2555891397,
"P:\\tv\\Tulsa King": 41351406080,
"P:\\tv\\Dopesick": 2571994785,
"P:\\tv\\Taylor (2025)": 2621206209,
"P:\\tv\\Star Trek Lower Decks": 33090597113,
"P:\\tv\\Face Off (2011)": 83155672195,
"P:\\tv\\Catch-22": 7113496871,
"P:\\tv\\Canada's Drag Race": 103586850759,
"P:\\tv\\Over the Garden Wall": 2937573633,
"P:\\tv\\The Traitors (US) (2023)": 48149750078,
"P:\\tv\\1923": 22125507023,
"P:\\tv\\Loki": 20082144632,
"P:\\tv\\House of the Dragon": 23959073249,
"P:\\tv\\The Trunk (2024)": 16810949304,
"P:\\tv\\The Chosen (2019)": 54241850899,
"P:\\tv\\Lucky Hank": 7336222432,
"P:\\tv\\Station Eleven": 2708694925,
"P:\\tv\\Kitchen Nightmares UK": 11563663098,
"P:\\tv\\The Good Lord Bird (2020)": 5619421375,
"P:\\tv\\Wolf Pack": 6844099384,
"P:\\tv\\Below Deck Mediterranean": 39902249615,
"P:\\tv\\The Old Man (2022)": 26139845941,
"P:\\tv\\Schitt's Creek": 9325109901,
"P:\\tv\\Mr. & Mrs. Smith (2024)": 5316681916,
"P:\\tv\\Percy Jackson and the Olympians": 3558450335,
"P:\\tv\\Firefly (2002)": 7517428895,
"P:\\tv\\Ballers": 13002096756,
"P:\\tv\\Bupkis": 13034439710,
"P:\\tv\\The Offer": 9070667475,
"P:\\tv\\Life After People (2009)": 45628647899,
"P:\\tv\\The Lord of the Rings - The Rings of Power": 12834498889,
"P:\\tv\\Paradise (2025)": 8024209737,
"P:\\tv\\Nobody Wants This": 11516933757,
"P:\\tv\\Shrinking (2023)": 17293593983,
"P:\\tv\\Hawkeye": 13524278345,
"P:\\tv\\Home Economics": 14315967074,
"P:\\tv\\Time Bandits (2024)": 6997478287,
"P:\\tv\\Lessons in Chemistry (2023)": 5485801173,
"P:\\tv\\1883": 4514294832,
"P:\\tv\\Love, Death & Robots (2019)": 8204860116,
"P:\\tv\\The Legend of Vox Machina": 25197294503,
"P:\\tv\\Harry Potter - Wizards of Baking (2024)": 23545641052,
"P:\\tv\\The Bachelor": 40368931577,
"P:\\tv\\American Horror Story": 142468660014,
"P:\\tv\\Yellowstone (2018)": 89724605866,
"P:\\tv\\St. Denis Medical (2024)": 18704263469,
"P:\\tv\\Cobra Kai": 39761471967,
"P:\\tv\\Power (2014)": 20414619656,
"P:\\tv\\The Originals (2013)": 72912846985,
"P:\\tv\\The Edge of Sleep": 1358235145,
"P:\\tv\\3 Body Problem": 11369334730,
"P:\\tv\\New Girl": 40676856398,
"P:\\tv\\Assembly Required (2021)": 5737519036,
"P:\\tv\\30 Rock (2006)": 81412969909,
"P:\\tv\\Rupauls Drag Race UK vs The World": 35504142221,
"P:\\tv\\Daredevil - Born Again (2025)": 7647367391,
"P:\\tv\\Brooklyn Nine Nine": 45722673163,
"P:\\tv\\Taskmaster - Champion of Champions": 2700754514,
"P:\\tv\\Kim's Convenience": 30475634673,
"P:\\tv\\The Office (US)": 161867626607,
"P:\\tv\\Stranger Things (2016)": 66712664909,
"P:\\tv\\Rupaul's Drag Race Vegas Revue": 2532474468,
"P:\\tv\\The Umbrella Academy": 55348092191,
"P:\\tv\\Secret Celebrity RuPaul's Drag Race": 4211193920,
"P:\\tv\\Andor (2022)": 25679584728,
"P:\\tv\\The Bondsman (2025)": 3112664353,
"P:\\tv\\Ghosts (2021)": 4574333812,
"P:\\tv\\Interior Chinatown": 3167640001,
"P:\\tv\\Selfie": 5013734266,
"P:\\tv\\Supernatural": 209274293691,
"P:\\tv\\Superman and Lois": 44881535930,
"P:\\tv\\Black Sails (2014)": 11356486450,
"P:\\tv\\Taskmaster (CA) (2022)": 2431664380,
"P:\\tv\\The Last of Us": 30545352719,
"P:\\tv\\Halo": 6961206915,
"P:\\tv\\Home Improvement 1991": 48878774505,
"P:\\tv\\Detroiters (2017)": 33750584701,
"P:\\tv\\Wildemount Wildlings (2025)": 3348907992,
"P:\\tv\\Terminator Zero": 3384699699,
"P:\\tv\\Um, Actually": 12360993522,
"P:\\tv\\The Rain (2018)": 2941174698,
"P:\\tv\\Harley Quinn": 20857796821,
"P:\\tv\\Lawmen - Bass Reeves (2023)": 5363156538,
"P:\\tv\\Parks and Recreation": 37277190974,
"P:\\tv\\Mythic Quest": 16965795814,
"P:\\tv\\Invincible (2021)": 19742824176,
"P:\\tv\\The Bear (2022)": 43665628138,
"P:\\tv\\Jentry Chau vs. the Underworld (2024)": 1406237358,
"P:\\tv\\Countdown (2025)": 8935252687,
"P:\\tv\\The Great British Bake Off": 78,
"P:\\tv\\Smartypants": 15959708127,
"P:\\tv\\Scenes from a Marriage (US)": 12493986505,
"P:\\tv\\The Franchise (2024)": 2981270395,
"P:\\tv\\Chad Powers (2025)": 2474659236,
"P:\\tv\\Doctor Who (2005)": 5820708419,
"P:\\tv\\Bad Monkey": 7767595411,
"P:\\tv\\Swimming with Sharks": 4426141798,
"P:\\tv\\English Teacher": 7603165476,
"P:\\tv\\Resident Alien (2021)": 17522605407,
"P:\\tv\\Krypton (2018)": 10875524680,
"P:\\tv\\Vikings (2013)": 194095449878,
"P:\\tv\\Arcane (2021)": 19588567847,
"P:\\tv\\Ludwig (2024)": 2670615425,
"P:\\tv\\Canada's Drag Race vs The World": 7844155647,
"P:\\tv\\BattleBots (2015)": 69,
"P:\\tv\\Abbott Elementary (2021)": 24595462535,
"P:\\tv\\Billy the Kid": 44803721006,
"P:\\tv\\Quantum Leap 2022": 8902776416,
"P:\\tv\\Obi-Wan Kenobi": 13867986923,
"P:\\tv\\Matlock (2024)": 34470939613,
"P:\\tv\\The Fall of Diddy (2025)": 2431035593,
"P:\\tv\\Kaos": 5164057710,
"P:\\tv\\Shifting Gears (2025)": 12649531141,
"P:\\tv\\Saving Hope": 33116225358,
"P:\\tv\\Gen V (2023)": 16871757804,
"P:\\tv\\Below Deck Sailing Yacht": 12706704039,
"P:\\tv\\Monarch Legacy of Monsters": 18371826949,
"P:\\tv\\High Potential": 24339309461,
"P:\\tv\\Band of Brothers (2001)": 15129362120,
"P:\\tv\\Quantum Leap (1989)": 39284023472,
"P:\\tv\\Harley and the Davidsons": 76,
"P:\\tv\\Rupaul's Drag Race All Stars": 60579323023,
"P:\\tv\\Amazing Stories (2020)": 4281304451,
"P:\\tv\\Murder She Wrote": 12095973826,
"P:\\tv\\Kitchen Nightmares US": 56092851597,
"P:\\tv\\Game Changer": 38317757866,
"P:\\tv\\Taskmaster AU": 20527610746,
"P:\\tv\\Fallout": 19686023936,
"P:\\tv\\Young Sheldon": 21714069112,
"P:\\tv\\Vice Principals (2016)": 18406955713,
"P:\\tv\\Adventuring Academy": 62196997373,
"P:\\tv\\Solar Opposites": 1138214210,
"P:\\tv\\Pok\u00e9mon Concierge (2023)": 1134616527,
"P:\\tv\\Better Call Saul": 31152560439,
"P:\\tv\\Counterpart": 4875616955,
"P:\\tv\\The Paper (2025)": 8102218176,
"P:\\tv\\Chuck": 32193192829,
"P:\\tv\\The Bachelorette": 9927266246,
"P:\\tv\\Wandavision": 10099450034,
"P:\\tv\\Pantheon": 13397374449,
"P:\\tv\\The Gilded Age": 90505242840,
"P:\\tv\\Gastronauts": 9365810750,
"P:\\tv\\American Gods (2017)": 43921706762,
"P:\\tv\\The IT Crowd (2006)": 9239572772,
"P:\\tv\\Winning Time - The Rise of the Lakers Dynasty (2022)": 37911197652,
"P:\\tv\\Monet's Slumber Party": 8253206091,
"P:\\tv\\Walker": 5492500161,
"P:\\tv\\Stargirl": 9507100884,
"P:\\tv\\House of Guinness (2025)": 5444928896,
"P:\\tv\\Father Brown": 18896564477,
"P:\\tv\\Silo (2023)": 12897630564,
"P:\\tv\\Your Honor (2020)": 25879839349,
"P:\\tv\\Welcome to Wrexham": 66664948104,
"P:\\tv\\Royal Pains (2009)": 1247586112,
"P:\\tv\\The Continental (2023)": 1920206807,
"P:\\tv\\Citadel": 2339699246,
"P:\\tv\\The 10th Kingdom (2000)": 14174589505,
"P:\\tv\\Parlor Room": 12022280605,
"P:\\tv\\Its Always Sunny in Philadelphia": 84650830434,
"P:\\tv\\Star Wars - Skeleton Crew (2024)": 2940779001,
"P:\\tv\\Rupaul's Drag Race": 57149739065,
"P:\\tv\\Only Murders in the Building (2021)": 2379838148,
"P:\\tv\\Running Man": 10279755878,
"P:\\tv\\Shetland": 18537045340,
"P:\\tv\\Adults (2025)": 6845585714,
"P:\\tv\\iCarly (2021)": 19966043984,
"P:\\tv\\Villainous (2017)": 1961793524,
"P:\\tv\\The Terminal List - Dark Wolf (2025)": 9939046560,
"P:\\tv\\Ted Lasso (2020)": 52046307136,
"P:\\tv\\Murderbot (2025)": 18338040970,
"P:\\tv\\RuPaul's Drag Race Down Under": 27454793482,
"P:\\tv\\Gravity Falls": 31900305156,
"P:\\tv\\The Santa Clauses (2022)": 6400385164,
"P:\\tv\\Marvel's The Punisher (2017)": 32242478897,
"P:\\tv\\Dracula (2020)": 2147285239,
"P:\\tv\\Extraordinary": 6934203888,
"P:\\tv\\Cyberpunk - Edgerunners (2022)": 11313875182,
"P:\\tv\\Rick and Morty": 31672318625,
"P:\\tv\\Welcome to Chippendales (2022)": 10423545837,
"P:\\tv\\Squid Game (2021)": 22082475135,
"P:\\tv\\MobLand (2025)": 6622179548,
"P:\\tv\\Taskmaster (NZ)": 71323320898,
"P:\\tv\\The Newsroom": 27756667258,
"P:\\tv\\The Pretender": 18425629462,
"P:\\tv\\Hazbin Hotel (2024)": 10906489515,
"P:\\tv\\Raised by wolves": 9720677524,
"P:\\tv\\Tomb Raider - The Legend of Lara Croft": 9341088252,
"P:\\tv\\Spartacus": 75639017886,
"P:\\tv\\Worst Cooks in America (2010)": 22063867049,
"P:\\tv\\Avenue 5": 12572813494,
"P:\\tv\\Man Down (2013)": 5077144151,
"P:\\tv\\Outlander": 27364180668,
"P:\\tv\\The Eternaut": 17178505929,
"P:\\tv\\Below Deck Down Under (2022)": 36006759742,
"P:\\tv\\Dirty Laundry": 27626331672,
"P:\\tv\\Chilling Adventures of Sabrina (2018)": 23147355371,
"P:\\tv\\The Studio (2025)": 11530554023,
"P:\\tv\\The Forsytes (2025)": 4034792830,
"P:\\tv\\Platonic (2023)": 17488146510,
"P:\\tv\\Love Island (US) (2019)": 20699120877,
"P:\\tv\\Dark Side of the Ring": 11863132534,
"P:\\tv\\The Day of the Jackal (2024)": 17787097381,
"P:\\tv\\Utopia (AU)": 8691287022,
"P:\\tv\\Sweetpea": 2706241673,
"P:\\tv\\Dateline NBC (1992)": 19267231607,
"P:\\tv\\Euphoria": 40925172559,
"P:\\tv\\The Consultant (2023)": 74,
"P:\\tv\\Titans (2018)": 31986198137,
"P:\\tv\\Taskmaster": 142193364333,
"P:\\tv\\Ink Master": 23329086486,
"P:\\tv\\Dimension 20": 557729281243,
"P:\\tv\\Continuum (2012)": 29352883496,
"P:\\tv\\South Park": 70261225261,
"P:\\tv\\Letterkenny": 63,
"P:\\tv\\Ghosts (2019)": 40703143881,
"P:\\tv\\Moon Knight": 10976093361,
"P:\\tv\\Twisted Metal (2023)": 12547412897,
"P:\\tv\\Extrapolations": 6690715385,
"P:\\tv\\Quiet On Set - The Dark Side Of Kids TV": 12191520028,
"P:\\tv\\Sh\u014dgun": 20899988683,
"P:\\tv\\Taboo (2017)": 19309841226,
"P:\\tv\\Ironheart (2025)": 3153557870,
"P:\\tv\\DOTA - Dragon's Blood (2021)": 12538510766,
"P:\\tv\\Knuckles": 2140786440,
"P:\\tv\\Shoresy": 9900029120,
"P:\\tv\\Impractical Jokers": 13357380400,
"P:\\tv\\One More Time (2024)": 6434473461,
"P:\\tv\\Crowd Control": 9644641207,
"P:\\tv\\Dimension 20's Adventuring Party": 12002285238,
"P:\\tv\\Special Ops Lioness": 9765393961,
"P:\\tv\\Ted (2024)": 3024624414,
"P:\\tv\\Mighty Nein (2025)": 6138965943,
"P:\\tv\\Citadel - Diana": 13304679453,
"P:\\tv\\Our Flag Means Death": 2107045664,
"P:\\tv\\Make Some Noise": 25555591381,
"P:\\tv\\Mayor of Kingstown (2021)": 65464041666,
"P:\\tv\\The Take": 6020370013,
"P:\\tv\\Agatha All Along": 3411637969,
"P:\\tv\\The Amazing Digital Circus (2023)": 4739070191,
"P:\\tv\\The Now": 836886747,
"P:\\tv\\Poppa\u2019s House": 13794748297,
"P:\\tv\\Married at First Sight (2014)": 30275711911,
"P:\\tv\\The Closer": 47449608535,
"P:\\tv\\Junior Taskmaster (2024)": 4133620030,
"P:\\tv\\WondLa": 1399628000,
"P:\\tv\\The Second Best Hospital in the Galaxy (2024)": 3636394169,
"P:\\tv\\Being Human (2011)": 66311454464,
"P:\\tv\\SCORPION": 54081802764,
"P:\\tv\\The Goes Wrong Show (2019)": 3676343887,
"P:\\tv\\See": 12316511887,
"P:\\tv\\Dirk Gently's Holistic Detective Agency (2016)": 11935610182,
"P:\\tv\\Tokyo Override (2024)": 3802255332,
"P:\\tv\\Peacemaker (2022)": 13199970800,
"P:\\tv\\The Falcon and The Winter Soldier (2021)": 11657055937,
"P:\\tv\\Fargo (2014)": 93247402537,
"P:\\tv\\Killer Cakes": 3673781461,
"P:\\tv\\The Mandalorian": 36487773789,
"P:\\tv\\Very Important People": 12237876110,
"P:\\tv\\Smiling Friends": 5633340834,
"P:\\tv\\Game Changers (2024)": 5880504271,
"P:\\tv\\Star Strek Strange New Worlds": 13781151928,
"P:\\tv\\Galavant": 12147863291,
"P:\\tv\\She-Hulk Attorney at Law": 10233633417,
"P:\\tv\\From Dusk Till Dawn - The Series (2014)": 5360771338,
"P:\\tv\\The Journal of the Mysterious Creatures (2019)": 92,
"P:\\tv\\Fallen (2024)": 4161867429,
"P:\\tv\\Severance": 15044806873,
"P:\\tv\\The Great (2020)": 22361386693,
"P:\\tv\\What If": 21312022582,
"P:\\tv\\Rupaul's Drag Race UK": 110914388896,
"P:\\tv\\Game Of Thrones": 119681469870,
"P:\\tv\\Belgravia - The Next Chapter": 8340040939,
"P:\\tv\\Hitmen (2020)": 12274410846,
"P:\\tv\\Haunted Hotel (2025)": 4735071992,
"P:\\tv\\The Book of Boba Fett": 12039417291,
"P:\\tv\\SAS Rogue Heroes (2022)": 10733559643,
"P:\\tv\\Dwight in Shining Armor": 75,
"P:\\tv\\Jury Duty": 8010062372,
"P:\\tv\\Son of Zorn (2016)": 6780978712,
"P:\\tv\\The Gentlemen (2024)": 5224500371,
"P:\\tv\\Schmigadoon!": 6206632733,
"P:\\tv\\The Drew Carey Show (1995)": 70,
"P:\\tv\\Fired on Mars (2023)": 3590992124,
"P:\\tv\\Black Bird (2022)": 5893929480,
"P:\\tv\\Billions": 31141419259,
"P:\\tv\\Reacher (2022)": 17521873037,
"P:\\tv\\The Morning Show": 94311701751,
"P:\\tv\\Secret Level": 2810124465,
"P:\\tv\\The Boys": 68010010167,
"P:\\tv\\Gordon Ramsay's Food Stars (2023)": 6344621632,
"P:\\tv\\Death and Other Details": 17844763765,
"P:\\tv\\Modern Family": 82788065200,
"P:\\tv\\Married... with Children (1987)": 64228823786,
"P:\\tv\\BattleBots": 61,
"P:\\tv\\Silicon Valley (2014)": 63657428121,
"P:\\tv\\Tires (2024)": 5375794389,
"P:\\tv\\Creature Commandos (2024)": 2331424358,
"P:\\tv\\Goosebumps (2023)": 8257419062,
"P:\\tv\\The Fall of the House of Usher (2023)": 16454192941,
"P:\\tv\\Passion for punchlines": 75514795,
"P:\\tv\\The Queen's Gambit": 4100494817,
"P:\\tv\\Suits LA (2025)": 22274831381,
"P:\\tv\\Dune - Prophecy": 3330003290,
"P:\\tv\\Unstable": 5444623642,
"P:\\tv\\The Split": 7970767632,
"P:\\tv\\Barry": 31934844666,
"P:\\tv\\The Dragon Dentist": 11317084093,
"P:\\tv\\Kevin Can F-k Himself": 11614889793
}

File diff suppressed because it is too large Load Diff

View File

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<config>
<general>
<processing_folder>processing</processing_folder>
<suffix> -EHX</suffix>
<extensions>.mkv,.mp4</extensions>
<ignore_tags>ehx,megusta</ignore_tags>
<reduction_ratio_threshold>0.5</reduction_ratio_threshold>
</general>
<path_mappings>
<map from="P:\tv" to="/mnt/plex/tv" />
<map from="P:\anime" to="/mnt/plex/anime" />
</path_mappings>
<encode>
<encoder default="nvenc">
<av1_nvenc preset="p7" bit_depth="8" pix_fmt="yuv420p" />
<hevc_nvenc preset="slow" bit_depth="10" pix_fmt="yuv420p10le" />
</encoder>
<cq>
<tv_1080>28</tv_1080>
<tv_720>32</tv_720>
<movie_1080>32</movie_1080>
<movie_720>34</movie_720>
</cq>
<fallback>
<bitrate_1080>1500k</bitrate_1080>
<maxrate_1080>1750k</maxrate_1080>
<bufsize_1080>2750k</bufsize_1080>
<bitrate_720>900k</bitrate_720>
<maxrate_720>1250k</maxrate_720>
<bufsize_720>1800k</bufsize_720>
</fallback>
<filters>
<default>lanczos</default>
<tv>bicubic</tv>
</filters>
</encode>
<audio>
<stereo>
<low>64000</low>
<medium>128000</medium>
<high>160000</high>
</stereo>
<multi_channel>
<low>384000</low>
<medium>512000</medium>
<high>640000</high>
</multi_channel>
</audio>
</config>

View File

@ -1,839 +0,0 @@
#!/usr/bin/env python3
"""
GUI Path Manager for Batch Video Transcoder
Allows easy browsing of folders and appending to paths.txt with encoding options.
"""
import sys
from pathlib import Path
# Add parent directory to path so we can import core modules
sys.path.insert(0, str(Path(__file__).parent.parent))
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import os
import subprocess
import re
import json
from core.config_helper import load_config_xml
from core.logger_helper import setup_logger
logger = setup_logger(Path(__file__).parent.parent / "logs")
class PathManagerGUI:
def __init__(self, root):
self.root = root
self.root.title("Batch Transcoder - Path Manager")
self.root.geometry("1100x700")
# Load config (from parent directory)
config_path = Path(__file__).parent.parent / "config.xml"
self.config = load_config_xml(config_path)
# Convert path_mappings from list to dict for easier lookup
path_mappings_list = self.config.get("path_mappings", [])
self.path_mappings = {m["from"]: m["to"] for m in path_mappings_list} if isinstance(path_mappings_list, list) else path_mappings_list
# Paths file (in root directory)
self.paths_file = Path(__file__).parent.parent / "paths.txt"
self.transcode_bat = Path(__file__).parent.parent / "transcode.bat"
# Current selected folder
self.selected_folder = None
self.current_category = None
self.recently_added = None # Track recently added folder for highlighting
self.status_timer = None # Track status message timer
self.added_folders = set() # Folders that are in paths.txt
# Cache for folder data - split per category
self.cache_dir = Path(__file__).parent.parent / ".cache"
self.cache_dir.mkdir(exist_ok=True)
self.folder_cache = {} # Only current category in memory: {folder_path: size}
self.scan_in_progress = False
self.scanned_categories = set() # Track which categories have been scanned
# Lazy loading
self.all_folders = [] # All folders for current category
self.loaded_items = 0 # How many items are currently loaded
self.items_per_batch = 100 # Load 100 items at a time
# Load existing paths
self._load_existing_paths()
# Build UI
self._build_ui()
# Handle window close
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
def _build_ui(self):
"""Build the GUI layout."""
# Top frame for category selection and transcode launcher
top_frame = ttk.Frame(self.root)
top_frame.pack(fill=tk.X, padx=10, pady=10)
left_top = ttk.Frame(top_frame)
left_top.pack(side=tk.LEFT, fill=tk.X, expand=True)
ttk.Label(left_top, text="Category:").pack(side=tk.LEFT, padx=5)
self.category_var = tk.StringVar(value="tv")
categories = ["tv", "anime", "movies"]
for cat in categories:
ttk.Radiobutton(
left_top, text=cat.upper(), variable=self.category_var,
value=cat, command=self._on_category_change
).pack(side=tk.LEFT, padx=5)
ttk.Button(left_top, text="Refresh", command=self._refresh_with_cache_clear).pack(side=tk.LEFT, padx=5)
# Right side of top frame - transcode launcher
right_top = ttk.Frame(top_frame)
right_top.pack(side=tk.RIGHT)
if self.transcode_bat.exists():
ttk.Button(
right_top, text="▶ Run transcode.bat", command=self._run_transcode
).pack(side=tk.RIGHT, padx=5)
# Main content frame
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Left side - folder tree
left_frame = ttk.LabelFrame(main_frame, text="Folders (sorted by size)")
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
# Treeview for folders with add button column
self.tree = ttk.Treeview(left_frame, columns=("size", "add", "remove"), height=20)
self.tree.column("#0", width=180)
self.tree.column("size", width=80)
self.tree.column("add", width=50)
self.tree.column("remove", width=50)
self.tree.heading("#0", text="Folder Name")
self.tree.heading("size", text="Size")
self.tree.heading("add", text="Add")
self.tree.heading("remove", text="Remove")
scrollbar = ttk.Scrollbar(left_frame, orient=tk.VERTICAL, command=self._on_scrollbar)
self.tree.configure(yscroll=scrollbar.set)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# Configure tags for folder status
self.tree.tag_configure("added", background="#90EE90") # Light green for added
self.tree.tag_configure("not_added", background="white") # White for not added
self.tree.tag_configure("recently_added", background="#FFD700") # Gold for recently added
self.tree.bind("<Double-1>", self._on_folder_expand)
self.tree.bind("<<TreeviewSelect>>", self._on_folder_select)
self.tree.bind("<Button-1>", self._on_tree_click)
# Right side - options and preview
right_frame = ttk.LabelFrame(main_frame, text="Encoding Options & Preview")
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(5, 0))
# Mode selection
mode_frame = ttk.LabelFrame(right_frame, text="Mode (--m)")
mode_frame.pack(fill=tk.X, padx=5, pady=5)
self.mode_var = tk.StringVar(value="default")
for mode in ["default", "cq", "bitrate"]:
ttk.Radiobutton(mode_frame, text=mode, variable=self.mode_var, value=mode,
command=self._update_preview).pack(anchor=tk.W, padx=5)
# Resolution selection
res_frame = ttk.LabelFrame(right_frame, text="Resolution (--r)")
res_frame.pack(fill=tk.X, padx=5, pady=5)
self.resolution_var = tk.StringVar(value="none")
for res in ["none", "480", "720", "1080"]:
label = "Auto" if res == "none" else res + "p"
ttk.Radiobutton(res_frame, text=label, variable=self.resolution_var, value=res,
command=self._update_preview).pack(anchor=tk.W, padx=5)
# CQ value
cq_frame = ttk.LabelFrame(right_frame, text="CQ Value (--cq, optional)")
cq_frame.pack(fill=tk.X, padx=5, pady=5)
self.cq_var = tk.StringVar(value="")
cq_entry = ttk.Entry(cq_frame, textvariable=self.cq_var, width=10)
cq_entry.pack(anchor=tk.W, padx=5, pady=3)
cq_entry.bind("<KeyRelease>", lambda e: self._update_preview())
# Preview frame
preview_frame = ttk.LabelFrame(right_frame, text="Command Preview")
preview_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.preview_text = tk.Text(preview_frame, height=8, width=40, wrap=tk.WORD, bg="lightgray")
self.preview_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.preview_text.config(state=tk.DISABLED)
# Bottom frame - action buttons and status
bottom_frame = ttk.Frame(self.root)
bottom_frame.pack(fill=tk.X, padx=10, pady=10)
button_frame = ttk.Frame(bottom_frame)
button_frame.pack(side=tk.LEFT)
ttk.Button(button_frame, text="View paths.txt", command=self._view_paths_file).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="Clear paths.txt", command=self._clear_paths_file).pack(side=tk.LEFT, padx=5)
# Status label (for silent feedback)
self.status_label = ttk.Label(bottom_frame, text="", foreground="green")
self.status_label.pack(side=tk.LEFT, padx=10)
# Load cache and populate initial category
self._load_cache()
self._refresh_folders(use_cache=True)
# Only scan once per category on first view
if self.current_category not in self.scanned_categories:
self.root.after(100, self._scan_folders_once)
def _on_category_change(self):
"""Handle category radio button change."""
self.current_category = self.category_var.get()
# Load cache for this category
self._load_cache()
# Show cached data first
self._refresh_folders(use_cache=True)
# Only scan once per category on first view
if self.current_category not in self.scanned_categories:
self.root.after(100, self._scan_folders_once)
def _load_cache(self):
"""Load folder cache for current category from disk (lazy)."""
category = self.category_var.get()
cache_file = self.cache_dir / f".cache_{category}.json"
self.folder_cache.clear()
# Don't fully load cache yet - just verify it exists
if not cache_file.exists():
logger.info(f"No cache file for {category}")
else:
logger.info(f"Cache file exists for {category}")
def _parse_cache_lazily(self, limit=None):
"""Parse cache file lazily and return folders."""
category = self.category_var.get()
cache_file = self.cache_dir / f".cache_{category}.json"
folders = []
if cache_file.exists():
try:
with open(cache_file, "r", encoding="utf-8") as f:
cache_dict = json.load(f)
# Convert to list and sort
for folder_path_str, size in cache_dict.items():
folder_path = Path(folder_path_str)
if folder_path.exists():
folders.append((folder_path.name, folder_path, size))
# Early exit if limit reached
if limit and len(folders) >= limit:
break
# Sort by size descending (only what we loaded)
folders.sort(key=lambda x: x[2], reverse=True)
except Exception as e:
logger.error(f"Failed to parse cache: {e}")
return folders
def _save_cache(self):
"""Save current category's folder cache to disk."""
category = self.category_var.get()
cache_file = self.cache_dir / f".cache_{category}.json"
try:
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(self.folder_cache, f, indent=2)
except Exception as e:
logger.error(f"Failed to save {category} cache: {e}")
def _refresh_with_cache_clear(self):
"""Refresh and clear cache to force full scan."""
category = self.category_var.get()
cache_file = self.cache_dir / f".cache_{category}.json"
# Delete cache file for this category
if cache_file.exists():
cache_file.unlink()
self.folder_cache.clear()
self.scanned_categories.discard(category) # Reset so it will scan again
self._refresh_folders(use_cache=False)
def _scan_folders_once(self):
"""Scan folders once per category on first load."""
if self.scan_in_progress:
return
category = self.category_var.get()
if category in self.scanned_categories:
return # Already scanned this category
self.scan_in_progress = True
try:
category_mapping = {
"tv": "P:\\tv",
"anime": "P:\\anime",
"movies": "P:\\movies"
}
base_key = category_mapping.get(category)
if not base_key or base_key not in self.path_mappings:
return
base_path = Path(base_key)
if not base_path.exists():
return
# Scan folders and update cache
new_cache = {}
for entry in os.scandir(base_path):
if entry.is_dir(follow_symlinks=False):
size = self._get_folder_size(Path(entry))
new_cache[str(Path(entry))] = size
# Update cache and save
self.folder_cache = new_cache
self._save_cache()
self.scanned_categories.add(category)
# Update UI if still on same category
if self.category_var.get() == category:
self._refresh_folders(use_cache=True)
finally:
self.scan_in_progress = False
def _scan_folders_background(self):
"""Scan folders in background and update cache."""
if self.scan_in_progress:
return
self.scan_in_progress = True
try:
category = self.category_var.get()
category_mapping = {
"tv": "P:\\tv",
"anime": "P:\\anime",
"movies": "P:\\movies"
}
base_key = category_mapping.get(category)
if not base_key or base_key not in self.path_mappings:
return
base_path = Path(base_key)
if not base_path.exists():
return
# Scan folders and update cache
new_cache = {}
for entry in os.scandir(base_path):
if entry.is_dir(follow_symlinks=False):
size = self._get_folder_size(Path(entry))
new_cache[str(Path(entry))] = size
# Update cache and save
self.folder_cache[category] = new_cache
self._save_cache()
# Update UI if still on same category
if self.category_var.get() == category:
self._refresh_folders(use_cache=True)
finally:
self.scan_in_progress = False
# Schedule next continuous scan
self.background_scan_timer = self.root.after(
self.background_scan_interval,
self._continuous_background_scan
)
# Schedule next continuous scan
self.background_scan_timer = self.root.after(
self.background_scan_interval,
self._continuous_background_scan
)
def _load_existing_paths(self):
"""Load existing paths from paths.txt and extract folder paths."""
self.added_folders.clear()
if not self.paths_file.exists():
return
try:
with open(self.paths_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
# Extract the path (last argument in the command)
# Format: --m mode --r res --cq val "path" or just "path"
# Find all quoted strings
matches = re.findall(r'"([^"]*)"', line)
if matches:
# Last quoted string is the path
path = matches[-1]
self.added_folders.add(path)
except Exception as e:
logger.error(f"Failed to load existing paths: {e}")
def _get_folder_size(self, path: Path) -> int:
"""Calculate total size of folder in bytes."""
total = 0
try:
for entry in os.scandir(path):
if entry.is_file(follow_symlinks=False):
total += entry.stat().st_size
elif entry.is_dir(follow_symlinks=False):
total += self._get_folder_size(Path(entry))
except PermissionError:
pass
return total
def _format_size(self, bytes_size: int) -> str:
"""Format bytes to human readable size."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if bytes_size < 1024:
return f"{bytes_size:.1f} {unit}"
bytes_size /= 1024
return f"{bytes_size:.1f} PB"
def _refresh_folders(self, use_cache=False):
"""Refresh the folder tree from cache or disk."""
# Clear existing items
for item in self.tree.get_children():
self.tree.delete(item)
self.all_folders = []
self.loaded_items = 0
category = self.category_var.get()
# Map category to path mapping key
category_mapping = {
"tv": "P:\\tv",
"anime": "P:\\anime",
"movies": "P:\\movies"
}
base_key = category_mapping.get(category)
if not base_key or base_key not in self.path_mappings:
messagebox.showwarning("Info", f"No mapping found for {category}")
return
base_path = Path(base_key)
# Check if path exists
if not base_path.exists():
messagebox.showerror("Error", f"Path not found: {base_path}")
return
# Get folders from cache or disk
if use_cache:
# Parse cache lazily - only load what we need initially
folders = self._parse_cache_lazily(limit=None) # Get all but parse efficiently
else:
# Scan from disk
folders = []
try:
for entry in os.scandir(base_path):
if entry.is_dir(follow_symlinks=False):
size = self._get_folder_size(Path(entry))
folders.append((entry.name, Path(entry), size))
except PermissionError:
messagebox.showerror("Error", f"Permission denied accessing {base_path}")
return
# Update cache with fresh scan
cache_dict = {str(f[1]): f[2] for f in folders}
self.folder_cache = cache_dict
self._save_cache()
# Sort by size descending
folders.sort(key=lambda x: x[2], reverse=True)
# Store all folders and load first batch only
self.all_folders = folders
self._load_more_items()
def _on_folder_expand(self, event):
"""Handle folder double-click to show contents."""
selection = self.tree.selection()
if not selection:
return
item = selection[0]
tags = self.tree.item(item, "tags")
if not tags:
return
folder_path = Path(tags[0])
# Check if already expanded
if self.tree.get_children(item):
# Toggle: remove children
for child in self.tree.get_children(item):
self.tree.delete(child)
else:
# Add file/folder contents
try:
entries = []
for entry in os.scandir(folder_path):
if entry.is_file():
size = entry.stat().st_size
entries.append((entry.name, "File", size))
elif entry.is_dir():
size = self._get_folder_size(Path(entry))
entries.append((entry.name, "Folder", size))
# Sort by size descending
entries.sort(key=lambda x: x[2], reverse=True)
for name, type_str, size in entries:
size_str = self._format_size(size)
self.tree.insert(item, "end", text=f"[{type_str}] {name}", values=(size_str,))
except PermissionError:
messagebox.showerror("Error", f"Permission denied accessing {folder_path}")
def _on_folder_select(self, event):
"""Handle folder selection to update preview."""
selection = self.tree.selection()
if not selection:
return
item = selection[0]
tags = self.tree.item(item, "tags")
if tags:
self.selected_folder = tags[0]
self._update_preview()
def _on_tree_click(self, event):
"""Handle click on '+' or '-' button in add column."""
item = self.tree.identify("item", event.x, event.y)
column = self.tree.identify_column(event.x) # Only takes x coordinate
# Column #2 is the "add" column (columns are #0=name, #1=size, #2=add, #3=remove)
if item and column == "#2":
tags = self.tree.item(item, "tags")
if tags:
folder_path = tags[0]
values = self.tree.item(item, "values")
if len(values) > 1:
button_text = values[1] # Get button text
if "[+]" in button_text:
# Immediately update UI for snappy response
size_val = values[0]
self.tree.item(item, values=(size_val, "", "[-]"), tags=(folder_path, "added"))
# Add to paths.txt asynchronously
self.selected_folder = folder_path
self.root.after(0, self._add_to_paths_file_async, folder_path)
# Column #3 is the "remove" column
elif item and column == "#3":
tags = self.tree.item(item, "tags")
if tags:
folder_path = tags[0]
# Immediately update UI for snappy response
values = self.tree.item(item, "values")
size_val = values[0]
self.tree.item(item, values=(size_val, "[+]", ""), tags=(folder_path, "not_added"))
# Remove from paths.txt asynchronously
self.root.after(0, self._remove_from_paths_file_async, folder_path)
def _add_to_paths_file_async(self, folder_path):
"""Add to paths.txt without blocking UI."""
self.selected_folder = folder_path
self._add_to_paths_file()
# Silently reload in background
self._load_existing_paths()
def _remove_from_paths_file_async(self, folder_path):
"""Remove from paths.txt without blocking UI."""
self._remove_from_paths_file(folder_path)
def _update_preview(self):
"""Update the command preview."""
if not self.selected_folder:
preview_text = "No folder selected"
else:
folder_path = self.selected_folder
# Build command
cmd_parts = ['py main.py']
# Add mode if not default
mode = self.mode_var.get()
if mode != "default":
cmd_parts.append(f'--m {mode}')
# Add resolution if specified
resolution = self.resolution_var.get()
if resolution != "none":
cmd_parts.append(f'--r {resolution}')
# Add CQ if specified
cq = self.cq_var.get().strip()
if cq:
cmd_parts.append(f'--cq {cq}')
# Add path
cmd_parts.append(f'"{folder_path}"')
preview_text = " ".join(cmd_parts)
self.preview_text.config(state=tk.NORMAL)
self.preview_text.delete("1.0", tk.END)
self.preview_text.insert("1.0", preview_text)
self.preview_text.config(state=tk.DISABLED)
def _add_to_paths_file(self):
"""Append the current command to paths.txt."""
if not self.selected_folder:
messagebox.showwarning("Warning", "Please select a folder first")
return
folder_path = self.selected_folder
# Check if already in file
if folder_path in self.added_folders:
self._show_status(f"Already added: {Path(folder_path).name}")
return
# Build command line - start fresh
cmd_parts = []
# Add mode if not default
mode = self.mode_var.get()
if mode != "default":
cmd_parts.append(f'--m {mode}')
# Add resolution if specified
resolution = self.resolution_var.get()
if resolution != "none":
cmd_parts.append(f'--r {resolution}')
# Add CQ if specified
cq = self.cq_var.get().strip()
if cq:
cmd_parts.append(f'--cq {cq}')
# Add folder path
cmd_parts.append(f'"{folder_path}"')
line = " ".join(cmd_parts)
# Append to paths.txt
try:
# Check if file exists and has content
if self.paths_file.exists() and self.paths_file.stat().st_size > 0:
# Read last character to check if it ends with newline
with open(self.paths_file, "rb") as f:
f.seek(-1, 2) # Seek to last byte
last_char = f.read(1)
needs_newline = last_char != b'\n'
else:
needs_newline = False
# Write to file
with open(self.paths_file, "a", encoding="utf-8") as f:
if needs_newline:
f.write("\n")
f.write(line + "\n")
# Add to tracked set
self.added_folders.add(folder_path)
# Silent success - show status label instead of popup
self.recently_added = folder_path
self._show_status(f"✓ Added: {Path(folder_path).name}")
logger.info(f"Added to paths.txt: {line}")
# Clear timer if exists
if self.status_timer:
self.root.after_cancel(self.status_timer)
# Clear status after 3 seconds
self.status_timer = self.root.after(3000, self._clear_status)
except Exception as e:
messagebox.showerror("Error", f"Failed to write to paths.txt: {e}")
logger.error(f"Failed to write to paths.txt: {e}")
def _remove_from_paths_file(self, folder_path):
"""Remove a folder from paths.txt."""
if not self.paths_file.exists():
messagebox.showwarning("Warning", "paths.txt does not exist")
return
try:
with open(self.paths_file, "r", encoding="utf-8") as f:
lines = f.readlines()
# Filter out lines containing this folder path
new_lines = []
found = False
for line in lines:
if f'"{folder_path}"' in line or f"'{folder_path}'" in line:
found = True
else:
new_lines.append(line)
if not found:
messagebox.showwarning("Warning", "Path not found in paths.txt")
return
# Write back
with open(self.paths_file, "w", encoding="utf-8") as f:
f.writelines(new_lines)
# Remove from tracked set
self.added_folders.discard(folder_path)
self._show_status(f"✓ Removed: {Path(folder_path).name}")
logger.info(f"Removed from paths.txt: {folder_path}")
# Clear timer if exists
if self.status_timer:
self.root.after_cancel(self.status_timer)
# Clear status after 3 seconds
self.status_timer = self.root.after(3000, self._clear_status)
except Exception as e:
messagebox.showerror("Error", f"Failed to remove from paths.txt: {e}")
logger.error(f"Failed to remove from paths.txt: {e}")
def _show_status(self, message):
"""Show status message in label."""
self.status_label.config(text=message, foreground="green")
def _clear_status(self):
"""Clear status message after delay."""
self.status_label.config(text="")
self.status_timer = None
def _run_transcode(self):
"""Launch transcode.bat in a new command window."""
if not self.transcode_bat.exists():
messagebox.showerror("Error", f"transcode.bat not found at {self.transcode_bat}")
return
try:
# Launch in new cmd window
subprocess.Popen(
['cmd', '/c', f'"{self.transcode_bat}"'],
cwd=str(self.transcode_bat.parent),
creationflags=subprocess.CREATE_NEW_CONSOLE
)
logger.info("Launched transcode.bat")
except Exception as e:
messagebox.showerror("Error", f"Failed to launch transcode.bat: {e}")
logger.error(f"Failed to launch transcode.bat: {e}")
def _view_paths_file(self):
"""Open paths.txt in a new window."""
if not self.paths_file.exists():
messagebox.showinfo("Info", "paths.txt does not exist yet")
return
try:
with open(self.paths_file, "r", encoding="utf-8") as f:
content = f.read()
# Create new window
view_window = tk.Toplevel(self.root)
view_window.title("paths.txt")
view_window.geometry("800x400")
text_widget = tk.Text(view_window, wrap=tk.WORD)
text_widget.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
text_widget.insert("1.0", content)
# Add close button
ttk.Button(view_window, text="Close", command=view_window.destroy).pack(pady=5)
except Exception as e:
messagebox.showerror("Error", f"Failed to read paths.txt: {e}")
def _clear_paths_file(self):
"""Clear the paths.txt file."""
if not self.paths_file.exists():
messagebox.showinfo("Info", "paths.txt does not exist")
return
if messagebox.askyesno("Confirm", "Are you sure you want to clear paths.txt?"):
try:
self.paths_file.write_text("", encoding="utf-8")
messagebox.showinfo("Success", "paths.txt has been cleared")
logger.info("paths.txt cleared")
except Exception as e:
messagebox.showerror("Error", f"Failed to clear paths.txt: {e}")
def _on_closing(self):
"""Handle window closing - cleanup timers."""
self.root.destroy()
def _on_scrollbar(self, *args):
"""Handle scrollbar movement - load more items when scrolling."""
self.tree.yview(*args)
# Check if we need to load more items
if self.all_folders and self.loaded_items < len(self.all_folders):
# Get scroll position
first_visible = self.tree.yview()[0]
last_visible = self.tree.yview()[1]
# If we're past 70% scrolled, load more
if last_visible > 0.7:
self._load_more_items()
def _load_more_items(self):
"""Load next batch of items into tree."""
start = self.loaded_items
end = min(start + self.items_per_batch, len(self.all_folders))
for i in range(start, end):
folder_name, folder_path, size = self.all_folders[i]
size_str = self._format_size(size)
folder_path_str = str(folder_path)
# Determine button and tag
if folder_path_str in self.added_folders:
add_btn = ""
remove_btn = "[-]"
tag = "added"
else:
add_btn = "[+]"
remove_btn = ""
tag = "not_added"
self.tree.insert("", "end", text=folder_name, values=(size_str, add_btn, remove_btn),
tags=(folder_path_str, tag))
self.loaded_items = end
def main():
root = tk.Tk()
app = PathManagerGUI(root)
root.mainloop()
if __name__ == "__main__":
main()

View File

@ -1,3 +0,0 @@
{"timestamp": "2026-01-02T03:44:59Z", "level": "INFO", "message": "No cache file for tv", "module": "gui_path_manager", "funcName": "_load_cache", "line": 213}
{"timestamp": "2026-01-02T03:45:35Z", "level": "INFO", "message": "No cache file for tv", "module": "gui_path_manager", "funcName": "_load_cache", "line": 215}
{"timestamp": "2026-01-02T03:45:43Z", "level": "INFO", "message": "No cache file for movies", "module": "gui_path_manager", "funcName": "_load_cache", "line": 215}

View File

@ -1,4 +0,0 @@
--m cq --cq 28 "P:\movies\Pirates of the Caribbean - At World's End (2007)"
--m cq --cq 28 "P:\movies\Pirates of the Caribbean - The Curse of the Black Pearl (2003)"
--m cq --cq 28 "P:\movies\Pirates of the Caribbean - Dead Men Tell No Tales (2017)"
--m cq --cq 28 "P:\movies\Pirates of the Caribbean - On Stranger Tides (2011)"

View File

@ -1,7 +0,0 @@
"P:\movies\Nobody 2 (2025)" --strip-all-titles
"P:\movies\The French Dispatch (2021)" --strip-all-titles
"P:\movies\Let's Be Cops (2014)" --strip-all-titles
"P:\movies\The Secret World of Arrietty (2010)" --strip-all-titles
"P:\movies\Akira (1988)" --strip-all-titles
"P:\movies\Space Sweepers (2021)" --strip-all-titles
"P:\movies\John Carter (2012)" --strip-all-titles

View File

@ -1,47 +0,0 @@
@echo off
REM ====================================================================
REM Batch Transcode Queue Runner
REM Reads paths.txt and processes each line as a separate encode job
REM Each line should be a Python command with arguments, e.g.:
REM --r 720 --m bitrate "C:\Videos\TV Show"
REM --r 1080 --cq 28 "C:\Videos\Movies"
REM ====================================================================
setlocal enabledelayedexpansion
echo.
echo ====================================================================
echo Starting Batch Transcode Queue
echo ====================================================================
echo.
set "JOB_COUNT=0"
set "SUCCESS_COUNT=0"
set "FAILED_COUNT=0"
for /f "usebackq delims=" %%i in ("paths.txt") do (
set /a JOB_COUNT+=1
echo.
echo [Job !JOB_COUNT!] Processing: %%i
echo ======================================
py main.py %%i
if errorlevel 1 (
set /a FAILED_COUNT+=1
echo [Job !JOB_COUNT!] FAILED - Continuing to next item...
) else (
set /a SUCCESS_COUNT+=1
echo [Job !JOB_COUNT!] SUCCESS
)
)
echo.
echo ====================================================================
echo Batch Transcode Queue Complete
echo ====================================================================
echo Total Jobs: !JOB_COUNT!
echo Successful: !SUCCESS_COUNT!
echo Failed: !FAILED_COUNT!
echo ====================================================================
pause