Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da19d5e4b9 |
@ -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
@ -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)": 35968103808,
|
|
||||||
"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": 106759553819,
|
|
||||||
"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": 40713122628,
|
|
||||||
"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)": 19403375683,
|
|
||||||
"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)": 13556293879,
|
|
||||||
"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": 24845798484,
|
|
||||||
"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": 20302317773,
|
|
||||||
"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": 59664530796,
|
|
||||||
"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)": 27887501056,
|
|
||||||
"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": 559394428110,
|
|
||||||
"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": 10192565178,
|
|
||||||
"P:\\tv\\Impractical Jokers": 13357380400,
|
|
||||||
"P:\\tv\\One More Time (2024)": 6434473461,
|
|
||||||
"P:\\tv\\Crowd Control": 9644641207,
|
|
||||||
"P:\\tv\\Dimension 20's Adventuring Party": 12563974792,
|
|
||||||
"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": 26524123873,
|
|
||||||
"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": 14563355278,
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
51
.vscode/launch.json
vendored
51
.vscode/launch.json
vendored
@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
422
ARCHITECTURE.md
422
ARCHITECTURE.md
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
167
README.md
@ -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)
|
|
||||||
@ -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
|
|
||||||
96
config.xml
96
config.xml
@ -9,33 +9,14 @@
|
|||||||
<processing_folder>processing</processing_folder>
|
<processing_folder>processing</processing_folder>
|
||||||
|
|
||||||
<!-- File suffix added to encoded outputs -->
|
<!-- File suffix added to encoded outputs -->
|
||||||
<suffix> - [EHX]</suffix>
|
<suffix> -EHX</suffix>
|
||||||
|
|
||||||
<!-- Allowed input extensions -->
|
<!-- Allowed input extensions -->
|
||||||
<extensions>.mkv,.mp4</extensions>
|
<extensions>.mkv,.mp4</extensions>
|
||||||
|
|
||||||
<!-- File name tags to skip/ignore -->
|
<!-- Reduction ratio threshold: if output >= this ratio of input, retry/fail -->
|
||||||
<ignore_tags>ehx,._</ignore_tags> <!-- ehx = encoded tag, ._ = macOS metadata files -->
|
<!-- Default 0.5 = 50% (generic). Can override with ratio flag -->
|
||||||
|
<reduction_ratio_threshold>0.65</reduction_ratio_threshold>
|
||||||
<!-- Reduction ratio threshold: output must be <= this % of original or encoding fails -->
|
|
||||||
<reduction_ratio_threshold>0.85</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>
|
|
||||||
</general>
|
</general>
|
||||||
|
|
||||||
<!-- =============================
|
<!-- =============================
|
||||||
@ -44,32 +25,39 @@
|
|||||||
<path_mappings>
|
<path_mappings>
|
||||||
<map from="P:\tv" to="/mnt/plex/tv" />
|
<map from="P:\tv" to="/mnt/plex/tv" />
|
||||||
<map from="P:\anime" to="/mnt/plex/anime" />
|
<map from="P:\anime" to="/mnt/plex/anime" />
|
||||||
<map from="P:\movies" to="/mnt/plex/movies" />
|
|
||||||
</path_mappings>
|
</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 SETTINGS
|
||||||
============================= -->
|
============================= -->
|
||||||
<encode>
|
<encode>
|
||||||
<!-- CQ defaults (per resolution / content type / encoder) -->
|
<!-- CQ defaults (per resolution / content type) -->
|
||||||
<cq>
|
<cq>
|
||||||
<av1>
|
<tv_1080>28</tv_1080>
|
||||||
<tv_1080>32</tv_1080>
|
<tv_720>32</tv_720>
|
||||||
<tv_720>30</tv_720>
|
<movie_1080>32</movie_1080>
|
||||||
<anime_1080>32</anime_1080>
|
<movie_720>34</movie_720>
|
||||||
<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>
|
|
||||||
</cq>
|
</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 bitrate-based mode -->
|
||||||
<fallback>
|
<fallback>
|
||||||
@ -77,9 +65,9 @@
|
|||||||
<maxrate_1080>1750k</maxrate_1080>
|
<maxrate_1080>1750k</maxrate_1080>
|
||||||
<bufsize_1080>2750k</bufsize_1080>
|
<bufsize_1080>2750k</bufsize_1080>
|
||||||
|
|
||||||
<bitrate_720>1200k</bitrate_720>
|
<bitrate_720>900k</bitrate_720>
|
||||||
<maxrate_720>1450k</maxrate_720>
|
<maxrate_720>1250k</maxrate_720>
|
||||||
<bufsize_720>2200k</bufsize_720>
|
<bufsize_720>1800k</bufsize_720>
|
||||||
</fallback>
|
</fallback>
|
||||||
|
|
||||||
<!-- Scale filter defaults -->
|
<!-- Scale filter defaults -->
|
||||||
@ -94,14 +82,26 @@
|
|||||||
============================= -->
|
============================= -->
|
||||||
<audio>
|
<audio>
|
||||||
<stereo>
|
<stereo>
|
||||||
<low>128000</low>
|
<low>96000</low>
|
||||||
<medium>160000</medium>
|
<medium>128000</medium>
|
||||||
<high>192000</high>
|
<high>160000</high>
|
||||||
</stereo>
|
</stereo>
|
||||||
<multi_channel>
|
<multi_channel>
|
||||||
<low>384000</low>
|
<low>384000</low>
|
||||||
<medium>448000</medium>
|
<medium>512000</medium>
|
||||||
|
<high>640000</high>
|
||||||
</multi_channel>
|
</multi_channel>
|
||||||
|
<codec_rules>
|
||||||
|
<use_opus_below_kbps>128</use_opus_below_kbps>
|
||||||
|
</codec_rules>
|
||||||
</audio>
|
</audio>
|
||||||
|
|
||||||
|
<!-- =============================
|
||||||
|
IGNORE LIST (filenames to skip)
|
||||||
|
============================= -->
|
||||||
|
<ignore_tags>
|
||||||
|
<tag>ehx</tag>
|
||||||
|
<tag>megusta</tag>
|
||||||
|
</ignore_tags>
|
||||||
|
|
||||||
</config>
|
</config>
|
||||||
|
|||||||
@ -24,627 +24,3 @@ anime,You are Ms. Servant (2024),You are Ms. Servant - S01E10 - You and the Forb
|
|||||||
anime,You are Ms. Servant (2024),You are Ms. Servant - S01E11 - Your Prayers Are Gods' Prayers x264 AAC WEBDL-1080p VARYG -EHX.mkv,1454.6,310.35,21.3,CQ
|
anime,You are Ms. Servant (2024),You are Ms. Servant - S01E11 - Your Prayers Are Gods' Prayers x264 AAC WEBDL-1080p VARYG -EHX.mkv,1454.6,310.35,21.3,CQ
|
||||||
anime,You are Ms. Servant (2024),You are Ms. Servant - S01E12 - The Joyous Tidings You All Bring x264 AAC WEBDL-1080p VARYG -EHX.mkv,1444.42,329.28,22.8,CQ
|
anime,You are Ms. Servant (2024),You are Ms. Servant - S01E12 - The Joyous Tidings You All Bring x264 AAC WEBDL-1080p VARYG -EHX.mkv,1444.42,329.28,22.8,CQ
|
||||||
tv,Vikings (2013),Vikings - S03E08 - To the Gates! x265 AAC Bluray-1080p Silence -EHX.mkv,1812.11,609.28,33.6,CQ
|
tv,Vikings (2013),Vikings - S03E08 - To the Gates! x265 AAC Bluray-1080p Silence -EHX.mkv,1812.11,609.28,33.6,CQ
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E10 - When Will Met Sylvia h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2449.05,913.68,37.3,CQ
|
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E04 - Divorce Party h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2629.36,889.78,33.8,CQ
|
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E09 - Slumber Party h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2647.45,851.84,32.2,CQ
|
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E03 - Partner’s Retreat h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2612.89,933.07,35.7,CQ
|
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E02 - Gandalf the Lizard h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2230.54,908.05,40.7,CQ
|
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E06 - The Big Two Six h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2248.51,766.53,34.1,CQ
|
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E05 - My Wife’s Boyfriend h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2641.22,1076.82,40.8,CQ
|
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E07 - Let the River Run h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2495.78,766.88,30.7,CQ
|
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E01 - Pilot h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2523.21,774.51,30.7,CQ
|
|
||||||
tv,Platonic (2023),Platonic (2023) - S01E08 - San Diego h264 EAC3 Atmos WEBDL-1080p NTb -EHX.mkv,2356.7,947.11,40.2,CQ
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E01 - Pilot (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1354.14,698.34,51.6,CQ
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E02 - Homesick (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1283.35,664.56,51.8,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E03 - Secrets (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1289.79,669.48,51.9,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E04 - Birds of a Feather (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1302.82,670.23,51.4,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E05 - Love Language (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1337.54,695.82,52.0,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E06 - Sexy Beast (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1339.22,691.87,51.7,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E07 - The Green Glow (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1343.16,698.56,52.0,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E08 - End of the World As We Know It (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1314.51,681.05,51.8,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E09 - Welcome Aliens (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1334.42,696.31,52.2,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S01E10 - Heroes of Patience (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1325.94,689.15,52.0,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E01 - Old Friends (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1606.27,693.54,43.2,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E02 - The Wire (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1531.15,661.26,43.2,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E03 - Girls' Night (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1536.63,668.52,43.5,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E04 - Radio Harry (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1535.92,664.56,43.3,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E05 - Family Day (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1532.64,661.16,43.1,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E06 - An Alien in New York (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1537.03,668.16,43.5,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E07 - Escape from New York (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1523.81,662.61,43.5,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E08 - Alien Dinner Party (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1536.43,668.82,43.5,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E09 - Autopsy (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1532.42,665.9,43.5,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E10 - The Ghost of Bobby Smallwood (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1530.38,664.43,43.4,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E11 - The Weight (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1526.21,662.06,43.4,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E12 - The Alien Within (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1533.2,663.78,43.3,Bitrate
|
|
||||||
tv,Resident Alien,"Resident Alien (2021) - S02E13 - Harry, a Parent (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv",1534.45,664.7,43.3,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E14 - Cat and Mouse (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1534.71,666.94,43.5,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E15 - Best of Enemies (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1535.56,667.63,43.5,Bitrate
|
|
||||||
tv,Resident Alien,Resident Alien (2021) - S02E16 - I Believe in Aliens (1080p AMZN WEB-DL x265 t3nzin) -EHX.mkv,1535.13,663.17,43.2,Bitrate
|
|
||||||
movie,N/A,Pirates of the Caribbean - The Curse of the Black Pearl (2003) x265 AAC 5.1 Bluray-1080p Tigole -EHX.mkv,8626.32,4135.01,47.9,CQ
|
|
||||||
movie,N/A,Mr. & Mrs. Smith (2005) x265 AAC 5.1 Bluray-1080p Tigole -EHX.mkv,7011.2,2725.53,38.9,CQ
|
|
||||||
movie,N/A,Pirates of the Caribbean - At World's End (2007) x265 EAC3 5.1 Bluray-1080p EDGE2020 -EHX.mkv,7563.73,4117.62,54.4,CQ
|
|
||||||
movie,N/A,Pirates of the Caribbean - Dead Man's Chest (2006) x265 EAC3 5.1 Bluray-1080p EDGE2020 -EHX.mkv,6451.81,3785.47,58.7,CQ
|
|
||||||
movie,N/A,Pirates of the Caribbean - Dead Men Tell No Tales (2017) x265 EAC3 7.1 Bluray-1080p EDGE2020 -EHX.mkv,5859.92,3171.93,54.1,CQ
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S16E03 - The Mother of All Balls h265 AC3 WEBDL-2160p NTb -EHX.mkv,6322.47,1144.2,18.1,CQ
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S16E14 - Booked and Blessed h265 AC3 WEBDL-2160p NTb -EHX.mkv,5901.1,927.85,15.7,CQ
|
|
||||||
tv,Rupaul's Drag Race,RuPauls Drag Race S16E15 720p CRAV WEB-DL DD5 1 H 264-NTb[TGx] -EHX.mkv,1601.72,528.09,33.0,CQ
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E01.1080p.WEB.h264-EDITH[EZTVx.to] -EHX.mkv,2903.19,527.44,18.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E02.1080p.WEB.h264-EDITH[EZTVx.to] -EHX.mkv,2844.23,525.08,18.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E04.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,3100.74,525.09,16.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E05.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,3148.67,526.17,16.7,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s16e06.1080p.web.h264-hotdogwater[EZTVx.to] -EHX.mkv,2843.79,526.47,18.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E07.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,3108.9,527.94,17.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E08.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,3206.51,526.13,16.4,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E09.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,3064.79,524.95,17.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E10.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,2911.36,525.03,18.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E11.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,2976.04,525.23,17.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E12.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,3010.96,526.66,17.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E13.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,3038.41,527.13,17.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S16E16.1080p.WEB.H264-BUSSY[EZTVx.to] -EHX.mkv,3238.49,517.3,16.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E00.Meet.The.Queens.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,851.07,379.53,44.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s13e01.720p.web.h264-secretos -EHX.mkv,1337.44,536.54,40.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E02.Condragulations.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,1248.46,545.65,43.7,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s13e03.720p.web.h264-secretos -EHX.mkv,1262.79,521.13,41.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E04.RuPaulmark.Channel.REPACK.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,1202.71,547.68,45.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E05.The.Bag.Ball.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,1292.04,542.09,42.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E06.Disco-Mentary.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,1296.75,542.46,41.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E07.Bossy.Rossy.Ruboot.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,1211.33,542.93,44.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E08.Social.Media.The.Unverified.Rusical.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,1229.9,542.28,44.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E09.Snatch.Game.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,1287.32,543.59,42.2,CQ
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E10.Freaky.Friday.Queens.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,1212.27,543.43,44.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s13e11.repack.720p.web.h264-secretos -EHX.mkv,1278.62,531.51,41.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S13E12.Nice.Girls.Roast.720p.AMZN.WEB-DL.DDP2.0.H.264-SLAG -EHX.mkv,1177.92,543.94,46.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s13e13.720p.web.h264-secretos -EHX.mkv,1234.73,530.43,43.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s13e14.720p.web.h264-secretos -EHX.mkv,1243.26,537.96,43.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s13e15.720p.web.h264-secretos -EHX.mkv,1718.56,690.29,40.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s13e16.720p.web.h264-secretos -EHX.mkv,1372.28,539.73,39.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E01.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1352.3,529.35,39.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E02.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1348.53,531.66,39.4,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E03.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1328.2,522.57,39.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E04.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1334.36,520.36,39.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E12.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1368.73,672.27,49.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E10.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1389.64,678.87,48.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E08.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1346.01,679.89,50.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E13.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1301.07,683.05,52.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E06.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1366.78,672.66,49.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E14.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1340.32,686.03,51.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E07.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1358.23,678.47,50.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E05.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1345.05,670.99,49.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E11.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1356.79,677.34,49.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S12E09.720p.WEB.x264-SECRETOS[eztv] -EHX.mkv,1352.33,674.36,49.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E03 - Monopulence! h264 EAC3 WEBDL-1080p SPAMnEGGS -EHX.mkv,4083.05,657.29,16.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E05 - RDR Live! h264 EAC3 WEBDL-1080p spamneggs -EHX.mkv,4184.02,666.73,15.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E06 - Let's Get Sea Sickening Ball h264 EAC3 WEBDL-1080p FLUX -EHX.mkv,4438.22,676.65,15.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E07 - Snatch Game h264 EAC3 WEBDL-720p RAWR -EHX.mkv,2702.69,663.75,24.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E01 - Squirrel Games x265 AAC HDTV-1080p MeGusta -EHX.mkv,1478.08,708.03,47.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E02 - Drag Queens Got Talent x265 EAC3 HDTV-1080p MeGusta -EHX.mkv,1289.08,644.18,50.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,"RuPaul's Drag Race - S17E04 - Bitch, I'm a Drag Queen! x265 EAC3 HDTV-1080p MeGusta -EHX.mkv",1484.51,649.51,43.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E08 - The Wicked Wiz of Oz - The Rusical! h264 EAC3 WEBDL-1080p RAWR -EHX.mkv,4319.97,643.56,14.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E09 - Heavens to Betsey! h264 EAC3 WEBDL-1080p spamneggs -EHX.mkv,4242.08,647.63,15.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E10 - The Villains Roast h264 EAC3 WEBDL-1080p spamneggs -EHX.mkv,4449.87,647.18,14.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E11 - Ross Mathews vs The Ducks h264 EAC3 WEBDL-1080p SPAMnEGGS -EHX.mkv,3728.17,644.84,17.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,"RuPaul's Drag Race - S17E12 - Charisma, Uniquiness, Nerve and Talent Monologues h264 EAC3 WEBDL-1080p RAWR -EHX.mkv",4281.08,647.53,15.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E13 - Drag Baby Mamas h264 EAC3 WEBDL-1080p RAWR -EHX.mkv,4164.02,649.46,15.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E14 - How's Your Headliner h264 EAC3 WEBDL-1080p spamneggs -EHX.mkv,3988.77,644.43,16.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E15 - LalapaRuza Smackdown Reunited h264 EAC3 WEBDL-1080p spamneggs -EHX.mkv,4234.63,646.0,15.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPaul's Drag Race - S17E16 - Grand Finale h264 AAC WEBDL-1080p EDITH -EHX.mkv,2948.27,633.86,21.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e01.720p.web.h264-secretos -EHX.mkv,1308.99,651.03,49.7,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e02.720p.web.h264-secretos -EHX.mkv,1293.71,650.03,50.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e03.720p.web.h264-secretos -EHX.mkv,1293.33,646.69,50.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e04.720p.web.h264-secretos -EHX.mkv,1288.48,650.09,50.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e05.720p.web.h264-secretos[eztv.re] -EHX.mkv,1228.27,648.93,52.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e06.720p.web.h264-secretos -EHX.mkv,1234.26,647.31,52.4,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e07.720p.web.h264-secretos -EHX.mkv,1212.49,645.16,53.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e08.720p.web.h264-secretos -EHX.mkv,1227.08,646.99,52.7,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e09.720p.web.h264-secretos -EHX.mkv,1249.42,646.81,51.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e10.720p.web.h264-secretos -EHX.mkv,1282.44,651.84,50.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e11.720p.web.h264-secretos -EHX.mkv,1237.95,651.01,52.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e12.720p.web.h264-secretos -EHX.mkv,1245.94,649.49,52.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e13.720p.web.h264-secretos -EHX.mkv,1228.06,647.11,52.7,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e14.720p.web.h264-secretos[eztv.re] -EHX.mkv,1198.42,650.76,54.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e15.repack.720p.web.h264-secretos -EHX.mkv,1348.76,648.98,48.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s14e16.720p.web.h264-secretos -EHX.mkv,1339.12,640.99,47.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S15E01.720p.WEB.h264-BAE[eztv.re] -EHX.mkv,1277.15,636.45,49.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,RuPauls.Drag.Race.S15E02.720p.WEB.h264-BAE[eztv.re] -EHX.mkv,1343.72,653.83,48.7,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e03.720p.web.h264-spamneggs -EHX.mkv,1058.49,430.22,40.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e04.720p.web.h264-spamneggs -EHX.mkv,1084.14,437.29,40.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e05.720p.web.h264-spamneggs -EHX.mkv,1051.17,428.48,40.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e06.720p.web.h264-spamneggs -EHX.mkv,1040.01,429.12,41.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e07.720p.web.h264-spamneggs -EHX.mkv,1059.12,432.86,40.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e08.720p.web.h264-spamneggs -EHX.mkv,1125.51,460.2,40.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e09.720p.web.h264-spamneggs -EHX.mkv,1054.13,430.07,40.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e10.720p.web.h264-spamneggs -EHX.mkv,1056.83,432.25,40.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e11.720p.web.h264-spamneggs -EHX.mkv,1561.8,642.93,41.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e12.720p.web.h264-spamneggs -EHX.mkv,1558.99,643.5,41.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e13.1080p.web.h264-spamneggs -EHX.mkv,3249.86,643.88,19.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e14.1080p.web.h264-spamneggs -EHX.mkv,3019.26,641.73,21.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e15.1080p.web.h264-spamneggs -EHX.mkv,4225.9,857.1,20.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race,rupauls.drag.race.s15e16.1080p.web.h264-spamneggs -EHX.mkv,3297.57,636.17,19.3,Bitrate
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E12 - Decision Day Is Near x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3201.45,550.02,17.2,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E10 - Retreat and Defeat x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3406.69,599.06,17.6,CQ
|
|
||||||
tv,Married at First Sight (2014),"Married at First Sight - S19E01 - I Do, Deep in the Heart of Austin x264 EAC3 WEBDL-1080p EDITH -EHX.mkv",3605.94,623.46,17.3,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E07 - This Is Not a Game x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3550.73,694.2,19.6,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E06 - Home Sweet Home x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3619.65,628.61,17.4,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E02 - Don't Mess with My Texas Wedding x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3603.61,686.78,19.1,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E14 - Reunion Special x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,2303.86,492.61,21.4,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E03 - Catching Flights and Feelings x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3079.99,517.5,16.8,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E04 - Falling for You x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3598.39,731.15,20.3,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E11 - I'm Done x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3385.24,640.41,18.9,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E05 - Trouble in Paradise x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3281.39,645.37,19.7,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E09 - Two Truths and a Lie x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3464.32,679.07,19.6,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E08 - Anniversary Adventures x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3636.51,631.28,17.4,CQ
|
|
||||||
tv,Married at First Sight (2014),Married at First Sight - S19E13 - Happily Ever After x264 EAC3 WEBDL-1080p EDITH -EHX.mkv,3154.44,584.92,18.5,CQ
|
|
||||||
movie,N/A,xXx Return of Xander Cage 2017 (2160p x265 10bit S84 Joy) -EHX.mkv,5594.97,2780.39,49.7,CQ
|
|
||||||
tv,Rupaul's Drag Race All Stars,"RuPaul's Drag Race All Stars - S10E01 - Winner Winner, Chicken Dinner x265 EAC3 HDTV-1080p MeGusta -EHX.mkv",1581.02,722.5,45.7,CQ
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E02 - Murder On The Dance Floor x265 EAC3 HDTV-1080p MeGusta -EHX.mkv,1057.74,664.36,62.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E03 - Hoop Queens Makeover x265 AAC HDTV-1080p MeGusta -EHX.mkv,1229.84,659.35,53.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E04 - The Eight Ball (aka The Magic 8 Ball) x264 EAC3 WEBDL-1080p JFF -EHX.mkv,4051.89,694.35,17.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E05 - Rappin' Roast h264 EAC3 WEBDL-1080p EDITH -EHX.mkv,4697.34,663.57,14.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E06 - Starrbooty - The Rebooty h264 EAC3 WEBDL-1080p EDITH -EHX.mkv,4651.68,660.24,14.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E07 - Wicked Good h264 EAC3 WEBDL-1080p RAWR -EHX.mkv,4963.29,703.05,14.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E08 - Stagecooch h264 EAC3 WEBDL-1080p RAWR -EHX.mkv,4695.82,662.88,14.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E09 - The Golden Bitchelor h264 EAC3 WEBDL-1080p RAWR -EHX.mkv,4694.04,666.31,14.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E10 - Tournament of All Stars Snatch Game h264 EAC3 WEBDL-1080p EDITH -EHX.mkv,4603.66,665.81,14.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E11 - Tournament of All Stars Talent Invitational h264 EAC3 WEBDL-1080p EDITH -EHX.mkv,4702.9,663.54,14.1,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPaul's Drag Race All Stars - S10E12 - Tournament of All Stars Smackdown for the Crown h264 EAC3 WEBDL-1080p EDITH -EHX.mkv,4658.3,667.76,14.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E01.1080p.WEB.h264-EDITH[EZTVx.to] -EHX.mkv,5544.35,755.65,13.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E02.1080p.WEB.h264-EDITH[EZTVx.to] -EHX.mkv,4649.46,661.06,14.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E03.1080p.WEB.h264-EDITH[EZTVx.to] -EHX.mkv,4624.67,666.62,14.4,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E04.1080p.HEVC.x265-MeGusta[EZTVx.to] -EHX.mkv,1450.48,666.38,45.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E05.1080p.WEB.h264-EDITH[EZTVx.to] -EHX.mkv,4667.45,667.21,14.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E06.REPACK.1080p.HEVC.x265-MeGusta[EZTVx.to] -EHX.mkv,1329.06,664.59,50.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E07.1080p.HEVC.x265-MeGusta[EZTVx.to] -EHX.mkv,1216.5,664.62,54.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E08.1080p.HEVC.x265-MeGusta[EZTVx.to] -EHX.mkv,1366.13,666.69,48.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E09.1080p.HEVC.x265-MeGusta[EZTVx.to] -EHX.mkv,1581.67,667.37,42.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E10.1080p.HEVC.x265-MeGusta[EZTVx.to] -EHX.mkv,1527.25,663.21,43.4,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E11.1080p.HEVC.x265-MeGusta[EZTVx.to] -EHX.mkv,1030.54,669.17,64.9,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S09E12.1080p.HEVC.x265-MeGusta[EZTVx.to] -EHX.mkv,1812.7,663.79,36.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s05e01.720p.web.h264-secretos[eztv.io] -EHX.mkv,1378.24,656.64,47.6,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s05e02.720p.web.h264-secretos -EHX.mkv,1352.5,653.27,48.3,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s05e03.720p.web.h264-secretos[eztv.io] -EHX.mkv,1344.59,655.61,48.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s05e05.720p.web.h264-secretos[eztv.io] -EHX.mkv,1360.77,656.13,48.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s05e06.720p.web.h264-secretos[eztv.io] -EHX.mkv,1346.34,657.51,48.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s05e07.720p.web.h264-secretos[eztv.io] -EHX.mkv,1327.95,650.28,49.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,RuPauls.Drag.Race.All.Stars.S05E08.720p.HEVC.x265-MeGusta[eztv.io] -EHX.mkv,1137.62,669.41,58.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s01e02.720p.web.h264-secretos[eztv.io] -EHX.mkv,894.51,447.39,50.0,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s01e03.720p.web.h264-secretos[eztv.io] -EHX.mkv,897.39,446.39,49.7,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s01e04.720p.web.h264-secretos[eztv.io] -EHX.mkv,898.65,447.89,49.8,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s01e05.720p.web.h264-secretos[eztv.io] -EHX.mkv,866.64,446.61,51.5,Bitrate
|
|
||||||
tv,Rupaul's Drag Race All Stars,rupauls.drag.race.all.stars.s01e06.720p.web.h264-secretos[eztv.io] -EHX.mkv,895.37,449.6,50.2,Bitrate
|
|
||||||
tv,Rupaul's Drag Race UK,RuPaul's Drag Race UK - S02E01 - Royalty Returns x264 AAC WEBDL-1080p secretos -EHX.mkv,2896.8,721.6,24.9,Bitrate
|
|
||||||
tv,Canada's Drag Race,Canadas.Drag.Race.S02E01.720p.HEVC.x265-MeGusta[eztv.re] -EHX.mkv,1103.09,642.81,58.3,Bitrate
|
|
||||||
tv,Canada's Drag Race,Canadas.Drag.Race.S02E02.720p.HEVC.x265-MeGusta[eztv.re] -EHX.mkv,1066.51,641.85,60.2,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S02E01 - Drag Pop x265 EAC3 HDTV-1080p MeGusta -EHX.mkv,1555.58,743.93,47.8,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S02E02 - The Hole x265 EAC3 HDTV-1080p MeGusta -EHX.mkv,1118.96,630.31,56.3,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S02E03 - Glamorous Drag Queen Ball Challenge x265 EAC3 HDTV-1080p MeGusta -EHX.mkv,1434.62,631.35,44.0,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S02E04 - Reading Battles h264 EAC3 WEBDL-1080p BUSSY -EHX.mkv,4186.46,632.51,15.1,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S02E05 - Snatch Game - The Rusical h264 EAC3 WEBDL-1080p BUSSY -EHX.mkv,4170.4,631.93,15.2,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S02E06 - Crown Me x265 EAC3 HDTV-1080p MeGusta -EHX.mkv,1510.19,629.87,41.7,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,"Canada's Drag Race - Canada vs. The World - S01E01 - Bonjour, Hi x264 AAC WEBDL-1080p SLAG -EHX.mkv",3001.88,746.06,24.9,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S01E02 - Snatch Summit x264 AAC WEBDL-1080p SLAG -EHX.mkv,2576.92,641.38,24.9,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S01E03 - The Weather Ball x264 AAC WEBDL-1080p SLAG -EHX.mkv,2564.82,636.05,24.8,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S01E04 - Comedy Queens x264 AAC WEBDL-1080p SLAG -EHX.mkv,2577.81,639.31,24.8,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S01E05 - Spy Queens x264 AAC WEBDL-1080p SLAG -EHX.mkv,2576.93,641.31,24.9,Bitrate
|
|
||||||
tv,Canada's Drag Race vs The World,Canada's Drag Race - Canada vs. The World - S01E06 - Grand Finale x264 AAC WEBDL-1080p SLAG -EHX.mkv,2575.57,640.14,24.9,Bitrate
|
|
||||||
movie,N/A,2025-12-29 21-53-23 -EHX.mp4,343.3,47.9,14.0,CQ
|
|
||||||
movie,N/A,How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole -EHX.mkv,5626.25,3268.49,58.1,CQ
|
|
||||||
movie,N/A,Oppenheimer (2023) x265 AAC 5.1 Bluray-1080p Tigole -EHX.mkv,9344.87,3040.3,32.5,1920x1080,1920x1080,1,32,CQ
|
|
||||||
movie,N/A,Pacific Rim (2013) x265 AAC 7.1 Bluray-1080p Tigole -EHX.mkv,5624.98,4029.53,71.6,1920x1080,1920x1080,2,32,CQ
|
|
||||||
movie,N/A,"Planes, Trains and Automobiles (1987) x265 AAC 5.1 Bluray-1080p afm72 -EHX.mkv",4489.88,2513.95,56.0,1920x1080,1920x1080,1,32,CQ
|
|
||||||
movie,N/A,Hackers (1995) x265 AAC 5.1 Bluray-1080p Tigole -EHX.mkv,5601.42,2346.49,41.9,1920x804,1920x804,2,32,CQ
|
|
||||||
movie,N/A,Bullet Train (2022) x265 AAC 5.1 Bluray-1080p Tigole -EHX.mkv,5857.37,2444.68,41.7,1920x804,1920x804,2,32,CQ
|
|
||||||
movie,N/A,The Truman Show (1998) x265 AAC 5.1 Bluray-1080p Silence -EHX.mkv,5152.45,2971.12,57.7,1918x1080,1918x1080,1,32,CQ
|
|
||||||
movie,N/A,John Wick - Chapter 4 (2023) x265 AAC 7.1 Bluray-1080p Tigole -EHX.mkv,8144.41,3288.02,40.4,1920x804,1920x804,1,32,CQ
|
|
||||||
movie,N/A,F1 (2025) x265 EAC3 7.1 Bluray-1080p SAMPA -EHX.mkv,7923.16,4044.9,51.1,1920x1080,1920x1080,1,32,CQ
|
|
||||||
movie,N/A,Starship Troopers (1997) x265 AAC 5.1 Bluray-1080p Tigole - [EHX].mkv,6522.97,4495.6,68.9,1920x1040,1920x1040,4,32,CQ
|
|
||||||
movie,N/A,John Wick - Chapter 3 - Parabellum (2019) x265 AAC 7.1 Bluray-1080p Tigole - [EHX].mkv,6600.85,2604.84,39.5,1920x800,1920x800,1,32,CQ
|
|
||||||
movie,N/A,John Wick - Chapter 2 (2017) x265 AAC 7.1 Bluray-1080p Tigole - [EHX].mkv,5563.3,2021.69,36.3,1920x800,1920x800,2,32,CQ
|
|
||||||
movie,N/A,Belle (2021) h264 AC3 5.1 WEBDL-1080p CMRG - [EHX].mkv,6192.36,1967.79,31.8,1912x796,1912x796,1,32,CQ
|
|
||||||
movie,N/A,Ferris Bueller's Day Off (1986) x265 AAC 5.1 Bluray-1080p r00t - [EHX].mkv,5225.63,3147.46,60.2,1920x816,1920x816,3,32,CQ
|
|
||||||
movie,N/A,Getting the Class Together - The Cast of Ferris Bueller’s Day Off - [EHX].mkv,284.54,141.45,49.7,720x480,720x480,1,34,CQ
|
|
||||||
movie,N/A,The Making of Ferris Bueller's Day Off - [EHX].mkv,159.01,89.99,56.6,720x480,720x480,1,34,CQ
|
|
||||||
movie,N/A,The World According to Ben Stein - [EHX].mkv,111.41,44.35,39.8,720x480,720x480,1,34,CQ
|
|
||||||
movie,N/A,Vintage Ferris Bueller - The Lost Tapes - [EHX].mkv,105.03,57.2,54.5,720x480,720x480,1,34,CQ
|
|
||||||
movie,N/A,Who is Ferris Bueller - [EHX].mkv,94.64,53.06,56.1,720x480,720x480,1,34,CQ
|
|
||||||
movie,N/A,The.Baker.2022.1080p.WEB-DL.DDP5.1.H.264-EniaHD - [EHX].mkv,5497.79,816.56,14.9,1920x802,1280x720,3,34,CQ
|
|
||||||
movie,N/A,The Losers (2010) h264 EAC3 5.1 WEBDL-1080p PiRaTeS - [EHX].mkv,5151.11,2964.76,57.6,1920x1080,1920x1080,1,32,CQ
|
|
||||||
movie,N/A,Violent Night (2022) x265 AAC 7.1 Bluray-1080p Tigole - [EHX].mkv,5106.24,1906.91,37.3,1920x804,1920x804,2,32,CQ
|
|
||||||
movie,N/A,Scott Pilgrim vs. the World (2010) x265 AAC 5.1 Bluray-1080p afm72 - [EHX].mkv,5104.23,2890.73,56.6,1920x1040,1920x1040,5,32,CQ
|
|
||||||
movie,N/A,Small Soldiers (1998) x265 AAC 5.1 Bluray-1080p FreetheFish - [EHX].mkv,4607.73,2738.43,59.4,1920x816,1920x816,2,32,CQ
|
|
||||||
movie,N/A,Bloopers - [EHX].mkv,61.58,19.48,31.6,704x328,704x328,2,34,CQ
|
|
||||||
movie,N/A,Deleted Scenes - [EHX].mkv,77.84,26.06,33.5,704x328,704x328,2,34,CQ
|
|
||||||
movie,N/A,German Theatrical Trailer - [EHX].mkv,22.08,13.79,62.5,704x568,704x568,2,34,CQ
|
|
||||||
movie,N/A,Introduction from director Joe Dante - [EHX].mkv,4.76,2.76,58.0,1560x1008,1560x1008,2,32,CQ
|
|
||||||
movie,N/A,Making Of - [EHX].mkv,141.53,67.99,48.0,696x560,696x560,2,34,CQ
|
|
||||||
movie,N/A,Theatrical Trailer - [EHX].mkv,19.06,8.33,43.7,720x408,720x408,2,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E01 - The Magnificent Seven x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1194.05,574.4,48.1,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E02 - The Kids Are Alright x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1258.77,589.56,46.8,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E03 - Bad Day at Black Rock x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1211.76,542.14,44.7,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E04 - Sin City x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1218.99,528.76,43.4,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E05 - Bedtime Stories x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1216.02,604.5,49.7,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E06 - Red Sky at Morning x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1251.74,572.19,45.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E07 - Fresh Blood x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1178.69,487.12,41.3,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E08 - A Very Supernatural Christmas x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1255.23,568.17,45.3,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E09 - Malleus Maleficarum x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1118.33,411.8,36.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E10 - Dream a Little Dream of Me x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1199.51,508.34,42.4,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E11 - Mystery Spot x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1210.49,541.26,44.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E12 - Jus in Bello x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1284.1,564.19,43.9,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E13 - Ghostfacers! x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1286.68,733.41,57.0,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E14 - Long-Distance Call x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1198.65,460.6,38.4,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E15 - Time is on My Side x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1199.16,425.7,35.5,1920x1072,1920x1072,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S03E16 - No Rest For the Wicked x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1212.69,560.74,46.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E01 - Black x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1067.26,439.71,41.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E02 - Reichenbach x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1090.87,451.58,41.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E03 - Soul Survivor x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1052.5,427.81,40.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E04 - Paper Moon x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,929.6,376.72,40.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E05 - Fan Fiction x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1024.01,417.75,40.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E06 - Ask Jeeves x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1070.47,448.26,41.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S10E07 - Girls, Girls, Girls x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1081.52,444.61,41.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E08 - Hibbing 911 x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1066.74,430.87,40.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E09 - The Things We Left Behind x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1065.36,422.05,39.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E10 - The Hunter Games x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1072.45,458.86,42.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E11 - There's No Place Like Home x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1114.36,442.83,39.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E12 - About a Boy x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1092.59,456.92,41.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E13 - Halt & Catch Fire x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1077.61,460.74,42.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E14 - The Executioner's Song x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1011.17,394.25,39.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E15 - The Things They Carried x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1086.58,469.19,43.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E16 - Paint It Black x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,953.74,362.53,38.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E17 - Inside Man x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1102.15,427.09,38.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E18 - Book of the Damned x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1147.45,473.33,41.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E19 - The Werther Project x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1140.94,477.81,41.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E20 - Angel Heart x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1104.08,424.57,38.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E21 - Dark Dynasty x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1048.57,388.53,37.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E22 - The Prisoner x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1100.73,455.3,41.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S10E23 - Brother's Keeper x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1140.72,457.58,40.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E01 - In My Time of Dying x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1274.11,594.17,46.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E02 - Everybody Loves a Clown x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1226.97,661.37,53.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E03 - Bloodlust x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1265.74,589.0,46.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E04 - Children Shouldn't Play With Dead Things x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1220.96,573.52,47.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E05 - Simon Said x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1217.35,674.01,55.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E06 - No Exit x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1276.57,716.27,56.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E07 - The Usual Suspects x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1269.09,629.48,49.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E08 - Crossroad Blues x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1245.31,629.48,50.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E09 - Croatoan x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1268.56,626.75,49.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E10 - Hunted x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1275.97,618.85,48.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E11 - Playthings x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1193.53,557.27,46.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E12 - Nightshifter x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1224.62,590.71,48.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E13 - Houses of the Holy x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1216.84,550.81,45.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E14 - Born Under a Bad Sign x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1269.84,572.68,45.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E15 - Tall Tales x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1152.21,521.45,45.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E16 - Roadkill x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1110.03,497.54,44.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E17 - Heart x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1235.78,626.8,50.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E18 - Hollywood Babylon x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1230.73,666.65,54.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E19 - Folsom Prison Blues x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1243.85,719.79,57.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E20 - What Is and What Should Never Be x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1300.63,616.77,47.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E21 - All Hell Breaks Loose (1) x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1249.53,689.24,55.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S02E22 - All Hell Breaks Loose (2) x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1258.44,666.0,52.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E01 - Lost and Found x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1099.63,465.54,42.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E02 - The Rising Son x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1082.08,506.4,46.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E03 - Patience x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,909.89,354.25,38.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E04 - The Big Empty x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1102.86,407.58,37.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E05 - Advanced Thanatology x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1012.31,403.77,39.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E06 - Tombstone x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,965.9,381.78,39.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E07 - War of the Worlds x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1011.59,366.43,36.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E08 - The Scorpion and the Frog x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,954.26,379.04,39.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E09 - The Bad Place x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1006.11,415.23,41.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E10 - Wayward Sisters x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1134.42,475.13,41.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E11 - Breakdown x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1039.16,373.16,35.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E12 - Various & Sundry Villains x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1135.33,462.15,40.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E13 - Devil's Bargain x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1016.28,417.09,41.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E14 - Good Intentions x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1101.76,477.59,43.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E15 - A Most Holy Man x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,942.74,337.79,35.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E16 - ScoobyNatural x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,966.55,462.13,47.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E17 - The Thing x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,990.31,380.88,38.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E18 - Bring 'Em Back Alive x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1091.07,496.7,45.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E19 - Funeralia x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1009.92,383.02,37.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E20 - Unfinished Business x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1063.08,395.92,37.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E21 - Beat the Devil x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,995.78,430.0,43.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E22 - Exodus x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1107.29,594.17,53.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S13E23 - Let the Good Times Roll x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1061.47,469.9,44.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E01 - Keep Calm and Carry On x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1011.62,464.83,45.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E02 - Mamma Mia x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1007.38,405.25,40.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E03 - The Foundry x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1118.48,425.87,38.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E04 - American Nightmare x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1016.94,473.16,46.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E05 - The One You've Been Waiting For x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1068.6,431.55,40.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E06 - Celebrating The Life Of Asa Fox x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1079.77,437.23,40.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E07 - Rock Never Dies x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1137.78,468.17,41.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E08 - LOTUS x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1028.23,437.09,42.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E09 - First Blood x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,996.51,405.17,40.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E10 - Lily Sunder Has Some Regrets x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1112.98,434.89,39.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E11 - Regarding Dean x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1161.83,502.78,43.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E12 - Stuck in the Middle (With You) x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1070.71,395.17,36.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E13 - Family Feud x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1030.23,404.72,39.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E14 - The Raid x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1063.59,411.96,38.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E15 - Somewhere Between Heaven and Hell x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1101.52,453.63,41.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E16 - Ladies Drink Free x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1204.16,500.78,41.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E17 - The British Invasion x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,989.83,368.13,37.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E18 - The Memory Remains x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1001.29,386.17,38.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E19 - The Future x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,998.94,354.12,35.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E20 - Twigs and Twine and Tasha Banes x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1043.67,414.27,39.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E21 - There's Something About Mary x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,992.35,360.39,36.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E22 - Who We Are x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1091.96,413.3,37.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S12E23 - All Along the Watchtower x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,986.84,415.3,42.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E01 - I Think I'm Gonna Like It Here x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1169.67,510.7,43.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E02 - Devil May Care x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1115.33,495.59,44.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E03 - I'm No Angel x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1105.25,457.97,41.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E04 - Slumber Party x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1103.58,449.27,40.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E05 - Dog Dean Afternoon x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1071.81,443.95,41.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E06 - Heaven Can't Wait x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,982.71,382.99,39.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E07 - Bad Boys x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,976.85,389.93,39.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E08 - Rock and a Hard Place x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1025.92,410.37,40.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E09 - Holy Terror x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,986.23,369.38,37.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E10 - Road Trip x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1092.43,423.97,38.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E11 - First Born x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1121.0,427.51,38.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E12 - Sharp Teeth x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1098.16,431.16,39.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E13 - The Purge x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1115.15,455.98,40.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E14 - Captives x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1015.03,364.67,35.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E15 - #THINMAN x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1090.01,407.17,37.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E16 - Blade Runners x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1054.37,422.14,40.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E17 - Mother's Little Helper x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,969.34,368.19,38.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E18 - Meta Fiction x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,995.09,377.65,38.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E19 - Alex Annie Alexis Ann x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1088.62,417.88,38.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E20 - Bloodlines x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1065.41,419.02,39.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E21 - King of the Damned x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1105.87,427.1,38.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E22 - Stairway to Heaven x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1083.4,437.15,40.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S09E23 - Do You Believe in Miracles x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1149.25,460.99,40.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E01 - Meet the New Boss x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1272.4,516.99,40.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S07E02 - Hello, Cruel World x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1237.25,538.63,43.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E03 - The Girl Next Door x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1238.58,550.56,44.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E04 - Defending Your Life x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1242.46,498.66,40.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S07E05 - Shut Up, Dr. Phil x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1235.03,556.46,45.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E06 - Slash Fiction x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1253.2,536.57,42.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E07 - The Mentalists x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1276.06,588.42,46.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S07E08 - Season Seven, Time for a Wedding! x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1295.19,583.0,45.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E09 - How to Win Friends and Influence Monsters x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1192.28,508.04,42.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E10 - Death's Door x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1250.57,512.81,41.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E11 - Adventures in Babysitting x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1224.81,479.53,39.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E12 - Time After Time x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1230.21,462.75,37.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E13 - The Slice Girls x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1203.35,461.29,38.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E14 - Plucky Pennywhistle's Magical Menagerie x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1257.37,643.97,51.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E15 - Repo Man x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1145.45,447.79,39.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E16 - Out With the Old x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1243.23,531.93,42.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E17 - The Born-Again Identity x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1200.72,465.67,38.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S07E18 - Party On, Garth x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1206.39,476.78,39.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E19 - Of Grave Importance x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1155.55,406.48,35.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E20 - The Girl with the Dungeons and Dragons Tattoo x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1254.13,550.42,43.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E21 - Reading is Fundamental x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1190.18,421.4,35.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E22 - There Will Be Blood x265 AC3 Bluray-1080p HiQVE.mkv,1220.64,453.02,37.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S07E23 - Survival of the Fittest x265 AC3 Bluray-1080p HiQVE.mkv,1181.16,467.72,39.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Fargo (2014),Fargo (2014) - S02E04 - Fear and Trembling (1080p BluRay x265 Silence) - [EHX].mkv,2063.29,1517.94,73.6,1920x1080,1920x1080,1,28,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E01 - Exile on Main Street x265 AC3 Bluray-1080p HiQVE - [EHX] - [EHX].mkv,1216.1,461.66,38.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E02 - Two and a Half Men x265 AC3 Bluray-1080p HiQVE - [EHX] - [EHX].mkv,1222.62,505.86,41.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E03 - The Third Man x265 AC3 Bluray-1080p HiQVE - [EHX] - [EHX].mkv,1204.98,485.43,40.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E01 - Who Threw Pretzels at a Couple Having Sex? - [EHX] - [EHX].mkv,1541.33,681.76,44.2,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E02 - Who Got High and Reenacted a Concert Using Eggs? - [EHX] - [EHX].mkv,1758.32,816.99,46.5,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E03 - Who Came Out to Their High School Girlfriend Via Jesus Christ? - [EHX].mkv,1790.83,1009.79,56.4,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E04 - Who Got Stung in the Crotch by a Jellyfish? - [EHX].mkv,1675.86,849.95,50.7,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E05 - Who Watched a Woman Pump Breast Milk While Snorting Cocaine? - [EHX].mkv,1566.21,700.88,44.8,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E06 - Who Was Found Naked in a Hallway by a Drug Dealer? - [EHX].mkv,1481.71,645.05,43.5,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E07 - Who Is an Honorary Member at a Sex Club? - [EHX].mkv,1668.77,732.43,43.9,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E08 - Who Went to a War Criminal's Birthday? - [EHX].mkv,1600.26,672.57,42.0,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E09 - Who Blamed Their Sex Noises on a Videogame? - [EHX].mkv,1434.91,657.23,45.8,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E10 - Who Bugged Someone's Car to Catch Them Cheating? - [EHX].mkv,1631.16,697.22,42.7,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E11 - Who Sent a Mean Email to a Famous Comedian as a Middle Schooler? - [EHX].mkv,1628.6,824.63,50.6,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Dirty Laundry,Dirty Laundry - S03E12 - Who Has an Active Warrant Out For Their Arrest? - [EHX].mkv,1638.37,718.68,43.9,1920x1080,1920x1080,1,32,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E04 - Weekend at Bobby's x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1226.57,492.18,40.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E05 - Live Free or Twihard x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1214.86,496.07,40.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E06 - You Can't Handle the Truth x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1173.67,443.16,37.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E07 - Family Matters x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1099.59,380.26,34.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E08 - All Dogs Go to Heaven x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1227.33,532.03,43.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E09 - Clap Your Hands If You Believe x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1212.57,546.56,45.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E10 - Caged Heat x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1154.72,420.73,36.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E11 - Appointment in Samarra x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1241.18,468.26,37.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E12 - Like A Virgin x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1260.23,528.11,41.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E13 - Unforgiven x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1243.94,616.35,49.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E14 - Mannequin 3 - The Reckoning x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1192.86,467.22,39.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E15 - The French Mistake x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1286.57,580.32,45.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E16 - And Then There Were None x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1235.2,511.66,41.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E17 - My Heart Will Go On x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1219.94,547.37,44.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E18 - Frontierland x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1217.06,476.93,39.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E19 - Mommy Dearest x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1236.08,498.65,40.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E20 - The Man Who Would Be King x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1195.44,487.82,40.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E21 - Let It Bleed x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1258.11,502.94,40.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S06E22 - The Man Who Knew Too Much x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1257.75,561.37,44.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E01 - Sympathy for the Devil x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1286.7,512.54,39.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S05E02 - Good God, Y'All! x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1290.8,640.05,49.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E05 - Fallen Idols x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1224.68,489.8,40.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E06 - I Believe the Children Are Our Future x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1147.5,417.19,36.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E07 - The Curious Case of Dean Winchester x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1092.35,397.79,36.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E08 - Changing Channels x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1220.88,621.4,50.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E09 - The Real Ghostbusters x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1172.84,520.0,44.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E10 - Abandon All Hope x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1093.89,444.87,40.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S05E11 - Sam, Interrupted x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1155.62,467.1,40.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E12 - Swap Meat x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1222.52,506.77,41.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E13 - The Song Remains the Same x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1116.16,417.32,37.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E14 - My Bloody Valentine x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1209.17,474.82,39.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E15 - Dead Men Don't Wear Plaid x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1170.22,485.67,41.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E16 - Dark Side of the Moon x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1148.56,450.47,39.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E17 - 99 Problems x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1147.16,458.51,40.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E18 - Point of No Return x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1138.18,450.57,39.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E19 - Hammer of the Gods x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1126.81,451.45,40.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E20 - The Devil You Know x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1100.01,387.27,35.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E21 - Two Minutes To Midnight x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1059.37,405.11,38.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S05E22 - Swan Song x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1164.41,506.82,43.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E01 - We Need To Talk About Kevin x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1216.44,508.67,41.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S08E02 - What's Up, Tiger Mommy x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1163.27,516.16,44.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E03 - Heartache x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1156.34,439.41,38.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E04 - Bitten x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1272.57,691.04,54.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E05 - Blood Brother x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1110.07,476.2,42.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E06 - Southern Comfort x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1232.93,544.38,44.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E07 - A Little Slice of Kevin x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1142.88,529.87,46.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E08 - Hunteri Heroici x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1245.13,559.72,45.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E09 - Citizen Fang x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1160.86,453.76,39.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E10 - Torn and Frayed x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1192.44,458.52,38.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E11 - LARP and the Real Girl x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1193.99,551.28,46.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E12 - As Time Goes By x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1201.86,495.68,41.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E13 - Everybody Hates Hitler x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1180.93,466.07,39.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E14 - Trial and Error x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1199.3,474.89,39.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E15 - Man's Best Friend with Benefits x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1168.65,440.69,37.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E16 - Remember the Titans x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1068.49,409.04,38.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S08E17 - Goodbye, Stranger x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1068.26,423.48,39.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E18 - Freaks and Geeks x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1083.2,456.58,42.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E19 - Taxi Driver x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1119.6,522.02,46.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E20 - Pac-Man Fever x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1150.82,446.7,38.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E21 - The Great Escapist x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1182.49,470.16,39.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E22 - Clip Show x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1128.71,529.95,47.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S08E23 - Sacrifice x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1123.91,486.99,43.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E01 - Stranger in a Strange Land x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1077.42,392.43,36.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E02 - Gods and Monsters x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1056.56,340.4,32.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E03 - The Scar x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1139.18,418.79,36.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E04 - Mint Condition x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1237.48,519.9,42.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E05 - Nightmare Logic x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1186.46,443.25,37.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E06 - Optimism x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1161.41,436.32,37.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E07 - Unhuman Nature x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1144.54,432.86,37.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E08 - Byzantium x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1201.5,399.35,33.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E09 - The Spear x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1114.74,405.12,36.3,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E10 - Nihilism x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1096.07,372.62,34.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E11 - Damaged Goods x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1144.87,422.42,36.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E12 - Prophet and Loss x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,998.45,330.98,33.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E13 - Lebanon x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1169.02,408.11,34.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E14 - Ouroboros x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1085.86,376.39,34.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E15 - Peace of Mind x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1209.11,476.01,39.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E16 - Don't Go in the Woods x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,998.67,353.21,35.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E17 - Game Night x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1171.18,478.27,40.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E18 - Absence x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1149.74,496.79,43.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E19 - Jack in the Box x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1119.57,345.82,30.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S14E20 - Moriah x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1194.42,467.3,39.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,"Supernatural - S11E01 - Out of the Darkness, Into the Fire x265 AC3 Bluray-1080p HiQVE - [EHX].mkv",1081.05,491.08,45.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E02 - Form and Void x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1150.4,441.72,38.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E03 - The Bad Seed x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1000.66,354.67,35.4,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E04 - Baby x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1221.59,650.84,53.3,1920x1080,1920x1080,2,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E05 - Thin Lizzie x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1111.38,412.77,37.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E06 - Our Little World x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1121.15,399.25,35.6,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E07 - Plush x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1115.48,444.02,39.8,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E08 - Just My Imagination x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1003.32,400.98,40.0,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E09 - O Brother Where Art Thou x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1056.77,442.89,41.9,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E10 - The Devil in the Details x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1057.23,456.3,43.2,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E11 - Into the Mystic x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1115.26,457.89,41.1,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E12 - Don’t You Forget about Me x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1054.6,449.94,42.7,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Supernatural,Supernatural - S11E13 - Love Hurts x265 AC3 Bluray-1080p HiQVE - [EHX].mkv,1017.88,371.56,36.5,1920x1080,1920x1080,1,34,CQ
|
|
||||||
tv,Extrapolations,Extrapolations.S01E08.2070.Ecocide.1080p.ATVP.WEB-DL.DDP5.1.H.264-EniaHD - [EHX].mkv,4485.35,821.95,18.3,1920x872,1920x872,2,30,CQ
|
|
||||||
tv,Extrapolations,Extrapolations.S01E07.2068.The.Going-Away.Party.1080p.ATVP.WEB-DL.DDP5.1.H.264-EniaHD - [EHX].mkv,3977.53,517.86,13.0,1920x872,1920x872,2,30,CQ
|
|
||||||
tv,Extrapolations,Extrapolations.S01E04.2059.Face.of.God.1080p.ATVP.WEB-DL.DDP5.1.H.264-EniaHD - [EHX].mkv,4348.62,655.87,15.1,1920x872,1920x872,2,30,CQ
|
|
||||||
tv,Extrapolations,Extrapolations.S01E05.2059.Part.II.Nightbirds.1080p.ATVP.WEB-DL.DDP5.1.H.264-EniaHD - [EHX].mkv,4358.04,875.13,20.1,1920x872,1920x872,2,30,CQ
|
|
||||||
tv,Extrapolations,Extrapolations.S01E02.2046.Whale.Fall.1080p.ATVP.WEB-DL.DDP5.1.H.264-EniaHD - [EHX].mkv,4506.01,919.87,20.4,1920x872,1920x872,2,30,CQ
|
|
||||||
tv,Extrapolations,Extrapolations.S01E06.2066.Lola.1080p.ATVP.WEB-DL.DDP5.1.H.264-EniaHD - [EHX].mkv,5080.34,886.64,17.5,1920x872,1920x872,2,30,CQ
|
|
||||||
tv,Extrapolations,Extrapolations.S01E03.2047.The.Fifth.Question.1080p.ATVP.WEB-DL.DDP5.1.H.264-EniaHD - [EHX].mkv,4775.68,964.11,20.2,1920x872,1920x872,2,30,CQ
|
|
||||||
tv,Extrapolations,Extrapolations.S01E01.2037.A.Raven.Story.1080p.ATVP.WEB-DL.DDP5.1.H.264-EniaHD - [EHX].mkv,4308.18,1049.28,24.4,1920x872,1920x872,2,30,CQ
|
|
||||||
movie,N/A,Superman (2025) x265 EAC3 7.1 Bluray-1080p Ghost - [EHX].mkv,8364.57,4374.49,52.3,1920x1080,1920x1080,4,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 02 [BD 1080p FLAC] [8822B4BC] - [EHX].mkv,1662.26,248.98,15.0,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 03 [BD 1080p FLAC] [BDE63D2B] - [EHX].mkv,1540.47,251.65,16.3,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 04 [BD 1080p FLAC] [4B388837] - [EHX].mkv,1841.37,303.34,16.5,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 05 [BD 1080p FLAC] [03D15E74] - [EHX].mkv,1533.1,279.44,18.2,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 06 [BD 1080p FLAC] [8498E1EE] - [EHX].mkv,1851.33,343.69,18.6,1920x1080,1920x1080,2,32,CQ
|
|
||||||
movie,N/A,xXx - Return of Xander Cage (2017) x264 TrueHD Atmos 7.1 Bluray-1080p DDR - [EHX].mkv,14066.19,3230.54,23.0,1920x800,1920x800,3,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 07 [BD 1080p FLAC] [B24C2A72] - [EHX].mkv,1252.42,256.39,20.5,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 08 [BD 1080p FLAC] [133E6216] - [EHX].mkv,1340.99,214.87,16.0,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 09 [BD 1080p FLAC] [90B63B29] - [EHX].mkv,1431.18,246.63,17.2,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 10 [BD 1080p FLAC] [9EB21FD3] - [EHX].mkv,1478.88,280.75,19.0,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 11 [BD 1080p FLAC] [39EC1E6A] - [EHX].mkv,1271.25,245.56,19.3,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - 12 [BD 1080p FLAC] [41A14681] - [EHX].mkv,1557.74,243.59,15.6,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E01 - Mémoire 1 - Vanitas ―In the Event of Rusty Hopes― x265 FLAC Bluray-1080p sam - [EHX].mkv,2254.26,369.91,16.4,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E13 - Mémoire 13 - Forêt d'argent ―A Chance Encounter― x265 FLAC Bluray-1080p sam - [EHX].mkv,1769.7,384.47,21.7,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E14 - Mémoire 14 - Château de sorciére ―The Witch and the Young Man― x265 FLAC Bluray-1080p sam - [EHX].mkv,1526.89,251.6,16.5,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E15 - Mémoire 15 - Oiseau et ciel ―The d'Apchiers' Vampire― x265 FLAC Bluray-1080p sam - [EHX].mkv,1659.94,288.39,17.4,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E16 - Mémoire 16 - Chasse aux vampires ―The Beast― x265 FLAC Bluray-1080p sam - [EHX].mkv,1893.39,312.18,16.5,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E17 - Mémoire 17 - Vengeance ―Hands Upon a Nightmare― x265 FLAC Bluray-1080p sam - [EHX].mkv,2111.03,365.15,17.3,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E18 - Mémoire 18 - Avec toi ―Just the Two of Us― x265 FLAC Bluray-1080p sam - [EHX].mkv,1620.1,286.11,17.7,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E19 - Mémoire 19 - Canorus ―Snow Flower― x265 FLAC Bluray-1080p sam - [EHX].mkv,1876.92,395.46,21.1,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E20 - Mémoire 20 - Mal d'amour ―The Incurable Disease― x265 FLAC Bluray-1080p sam - [EHX].mkv,1336.24,234.84,17.6,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E21 - Mémoire 21 - Un autre ―Scar― x265 FLAC Bluray-1080p sam - [EHX].mkv,1359.08,229.23,16.9,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E22 - Mémoire 22 - Rencontre ―Blue Night― x265 FLAC Bluray-1080p sam - [EHX].mkv,1448.32,223.05,15.4,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E23 - Mémoire 23 - Pleuvoir ―Tears like Rain― x265 FLAC Bluray-1080p sam - [EHX].mkv,2601.41,396.97,15.3,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),The Case Study of Vanitas - S01E24 - Mémoire 24 - Après la pluie ―His Wish― x265 FLAC Bluray-1080p sam - [EHX].mkv,1991.11,303.87,15.3,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - NCED 1 [BD 1080p FLAC] [F919673C] - [EHX].mkv,115.5,10.38,9.0,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,The Case Study of Vanitas (2021),[sam] Vanitas no Carte - NCOP 1 [BD 1080p FLAC] [ACE65BAF] - [EHX].mkv,99.75,21.82,21.9,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E01 - Demons and Humans x264 AAC WEBDL-1080p VARYG - [EHX].mkv,2230.88,482.25,21.6,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E02 - The Demon's Daughter x264 AAC WEBDL-1080p VARYG - [EHX].mkv,975.24,165.89,17.0,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E03 - The Devourer (Part 1) x264 AAC WEBDL-1080p VARYG - [EHX].mkv,992.22,169.73,17.1,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E04 - The Devourer (Part 2) x264 AAC WEBDL-1080p VARYG - [EHX].mkv,991.38,174.69,17.6,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E05 - The Garden of Happiness (Part 1) x264 AAC WEBDL-1080p VARYG - [EHX].mkv,990.18,199.98,20.2,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E06 - The Garden of Happiness (Part 2) x264 AAC WEBDL-1080p VARYG - [EHX].mkv,988.98,203.36,20.6,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E07 - The Haunting of Kudanzaka x264 AAC WEBDL-1080p VARYG - [EHX].mkv,989.27,168.54,17.0,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E08 - Blossoming Dreams of the Kanzashi (Part 1) x264 AAC WEBDL-1080p VARYG - [EHX].mkv,989.56,208.83,21.1,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E09 - Blossoming Dreams of the Kanzashi (Part 2) x264 AAC WEBDL-1080p VARYG - [EHX].mkv,988.53,204.37,20.7,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E10 - Harlot in the Rain x264 AAC WEBDL-1080p VARYG - [EHX].mkv,990.33,202.56,20.5,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E11 - Drunken Dreams of Lingering Snow (Part 1) x264 AAC WEBDL-1080p VARYG - [EHX].mkv,992.92,172.67,17.4,1920x1080,1920x1080,2,32,CQ
|
|
||||||
anime,Sword of the Demon Hunter - Kijin Gentosho (2025),Sword of the Demon Hunter - Kijin Gentosho - S01E12 - Drunken Dreams of Lingering Snow (Part 2) x264 AAC WEBDL-1080p VARYG - [EHX].mkv,989.34,199.49,20.2,1920x1080,1920x1080,2,32,CQ
|
|
||||||
movie,N/A,A New Era꞉ DC Takes Off.mkv,151.36,57.92,38.3,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,Adventures in the Making of “Superman”.mkv,1885.82,608.37,32.3,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,Breaking News꞉ The Daily Planet Returns.mkv,174.45,70.86,40.6,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,Icons Forever꞉ Superman’s Enduring Legacy.mkv,196.44,73.29,37.3,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,Krypto Short꞉ School Bus Scuffle.mkv,169.9,55.95,32.9,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,Kryptunes꞉ The Music of “Superman”.mkv,209.9,75.31,35.9,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,Lex Luthor꞉ The Mind of a Master Villain.mkv,172.32,62.11,36.0,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,Pawns to Pixels꞉ Krypto Is Born.mkv,185.04,69.84,37.7,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,The Justice Gang.mkv,338.06,114.74,33.9,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,The Ultimate Villain.mkv,164.36,74.57,45.4,1920x1080,1280x720,1,34,CQ
|
|
||||||
movie,N/A,2026-01-03 09-23-11 - [EHX].mkv,845.34,133.54,15.8,3840x2160,1920x1080,1,32,CQ
|
|
||||||
movie,N/A,Pirates of the Caribbean - Dead Man's Chest (2006) (1080p BluRay x265 10bit Tigole) - [EHX].mkv,9134.59,4091.91,44.8,1920x806,1920x806,2,27,CQ
|
|
||||||
movie,N/A,Pirates of the Caribbean - Dead Man's Chest (2006) (1080p BluRay x265 10bit Tigole) - Copy - [EHX].mkv,9134.59,3632.85,39.8,1920x806,1920x806,0,28,CQ
|
|
||||||
movie,N/A,Pirates of the Caribbean - At World's End (2007) (1080p BluRay x265 10bit Tigole) - [EHX].mkv,9795.92,3764.59,38.4,1920x800,1920x800,1,28,CQ
|
|
||||||
movie,N/A,Pirates of the Caribbean - The Curse of the Black Pearl (2003) (1080p BluRay x265 10bit Tigole) - [EHX].mkv,8626.32,3549.92,41.2,1920x800,1920x800,4,28,CQ
|
|
||||||
movie,N/A,Pirates of the Caribbean - Dead Men Tell No Tales (2017) (1080p BluRay x265 10bit Tigole) - [EHX].mkv,8415.25,2889.53,34.3,1920x800,1920x800,1,28,CQ
|
|
||||||
movie,N/A,Deleted Scenes.mkv,67.72,51.41,75.9,1920x800,1920x800,1,28,CQ
|
|
||||||
movie,N/A,Gallery - Jerry Bruckheimer Photo Diary.mkv,33.16,25.72,77.6,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Pirates of the Caribbean - On Stranger Tides (2011) (1080p BluRay x265 10bit Tigole) - [EHX].mkv,7070.29,2401.09,34.0,1920x800,1920x800,2,28,CQ
|
|
||||||
tv,Dimension 20,Dimension 20 - S27E01 - Welcome to the Wastes - [EHX].mkv,3929.25,1665.15,42.4,1920x1080,1920x1080,1,32,CQ
|
|
||||||
movie,N/A,Superman (2025) x265 EAC3 7.1 Bluray-1080p Ghost - Copy - [EHX].mkv,8364.57,3891.43,46.5,1920x1080,1920x1080,4,28,CQ
|
|
||||||
movie,N/A,The Roundup (2022) x265 AAC 5.1 Bluray-1080p Tigole - [EHX].mkv,5702.94,2383.35,41.8,1920x804,1920x804,2,28,CQ
|
|
||||||
movie,N/A,Wolf Children (2012) x264 AC3 5.1 Bluray-1080p RH - [EHX].mkv,4453.91,1929.93,43.3,1920x1080,1920x1080,3,32,CQ
|
|
||||||
movie,N/A,The Intern (2015) x265 AAC 5.1 Bluray-1080p Tigole - [EHX].mkv,5020.75,2681.95,53.4,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,The.Suicide.Squad.2021.1080p.HMAX.WEB-DL.DDP5.1.Atmos.X.264-EVO - [EHX].mkv,5220.52,3193.39,61.2,1920x1012,1920x1012,1,32,CQ
|
|
||||||
movie,N/A,Venom - The Last Dance (2024) x265 EAC3 5.1 Bluray-1080p Radarr - [EHX].mkv,5722.93,1798.15,31.4,1920x804,1920x804,1,28,CQ
|
|
||||||
movie,N/A,Meet the Fockers (2004) 1080p 10bit Bluray x265 HEVC [Org DD 5.1 Hindi + DD 5.1 English] MSubs ~ TombDoc - [EHX].mkv,4453.37,1639.66,36.8,1920x1080,1280x720,2,30,CQ
|
|
||||||
movie,N/A,Meet the Parents (2000) 1080p 10bit Bluray x265 HEVC [Org DD 5.1 Hindi + DD 5.1 English] MSubs ~ TombDoc - [EHX].mkv,4190.09,1167.67,27.9,1920x1080,1280x720,2,30,CQ
|
|
||||||
movie,N/A,Little Fockers (2010) 1080p 10bit Bluray x265 HEVC [Org DD 5.1 Hindi + DD 5.1 English] MSubs ~ TombDoc - [EHX].mkv,3753.92,934.35,24.9,1920x1040,1280x720,2,30,CQ
|
|
||||||
movie,N/A,Premium Rush (2012) x265 AAC 5.1 Bluray-1080p afm72 - [EHX].mkv,4184.15,2174.93,52.0,1920x800,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Captain America - Brave New World (2025) x265 EAC3 7.1 Bluray-1080p Silence - [EHX].mkv,5999.46,2240.25,37.3,1920x800,1920x800,3,28,CQ
|
|
||||||
movie,N/A,A New Era꞉ DC Takes Off.mkv,151.36,112.09,74.1,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Adventures in the Making of “Superman”.mkv,1885.82,1191.9,63.2,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Lex Luthor꞉ The Mind of a Master Villain.mkv,172.32,122.45,71.1,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Kryptunes꞉ The Music of “Superman”.mkv,209.9,155.56,74.1,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Breaking News꞉ The Daily Planet Returns.mkv,174.45,139.55,80.0,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Icons Forever꞉ Superman’s Enduring Legacy.mkv,196.44,146.85,74.8,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,The Justice Gang.mkv,338.06,219.86,65.0,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Pawns to Pixels꞉ Krypto Is Born.mkv,185.04,137.35,74.2,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Krypto Short꞉ School Bus Scuffle.mkv,169.9,113.54,66.8,1920x1080,1920x1080,1,28,CQ
|
|
||||||
movie,N/A,Ponyo (2008) [1080p x265 HEVC 10bit BluRay Dual Audio AAC 5.1] [Prof] - [EHX].mkv,5677.51,2589.26,45.6,1920x1080,1920x1080,2,28,CQ
|
|
||||||
movie,N/A,Castle in the Sky (1986) x265 AAC 5.1 Bluray-1080p Prof - [EHX].mkv,5698.25,2878.22,50.5,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E01.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,902.7,271.03,30.0,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E02.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1062.31,225.84,21.3,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E03.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1071.07,253.31,23.7,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E04.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,916.31,261.55,28.5,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E05.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1109.18,270.18,24.4,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E06.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,930.07,266.65,28.7,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E07.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1020.44,245.17,24.0,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E08.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,964.19,241.3,25.0,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E09.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1370.19,231.42,16.9,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E10.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1961.91,323.2,16.5,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E11.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1812.19,282.68,15.6,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E12.REPACK2.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1750.2,325.31,18.6,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E13.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1903.86,399.95,21.0,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E14.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1636.72,332.96,20.3,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E15.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1374.76,256.48,18.7,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E16.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1775.36,406.08,22.9,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E17.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1666.95,419.35,25.2,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E18.REPACK.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1909.93,304.69,16.0,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E19.REPACK.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1276.32,406.7,31.9,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E20.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1241.33,307.68,24.8,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E21.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1413.57,287.12,20.3,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E22.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1278.65,358.63,28.0,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E23.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1112.42,275.71,24.8,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,2.5 Dimensional Seduction (2024),2.5.Dimensional.Seduction.2024.S01E24.REPACK.BDRip-1080p.x265.FLAC.EAC3.Dual.Audio-Freehold - [EHX].mkv,1138.06,223.54,19.6,1920x1080,1920x1080,2,28,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E01 - An Unfamiliar Forest x264 Opus Bluray-1080p Netaro - [EHX].mkv,1551.71,253.39,16.3,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E02 - Curry is Justice! x264 Opus Bluray-1080p Netaro - [EHX].mkv,1601.88,256.06,16.0,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E03 - A Small House in a Big Forest x264 Opus Bluray-1080p Netaro - [EHX].mkv,1564.89,265.71,17.0,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E04 - Adventurers (Kenichi and His Eight Allies) x264 Opus Bluray-1080p Netaro - [EHX].mkv,1730.43,267.56,15.5,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E05 - The Moon Over an Old Castle x264 Opus Bluray-1080p Netaro - [EHX].mkv,1593.87,264.18,16.6,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E06 - The Book Loving x264 Opus Bluray-1080p Netaro - [EHX].mkv,1596.12,297.05,18.6,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E07 - Creep Forth! x264 Opus Bluray-1080p Netaro - [EHX].mkv,1629.39,261.02,16.0,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E08 - The Timid Mage x264 Opus Bluray-1080p Netaro - [EHX].mkv,1438.94,242.13,16.8,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),"The Daily Life of a Middle-Aged Online Shopper in Another World - S01E09 - So It's a Spider, So What x264 Opus Bluray-1080p Netaro - [EHX].mkv",1557.73,265.76,17.1,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E10 - The Story of Canals x264 Opus Bluray-1080p Netaro - [EHX].mkv,1562.25,261.33,16.7,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E11 - Shock! x264 Opus Bluray-1080p Netaro - [EHX].mkv,1624.52,279.0,17.2,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),"The Daily Life of a Middle-Aged Online Shopper in Another World - S01E12 - The Unreasonable, Mischievous Princess x264 Opus Bluray-1080p Netaro - [EHX].mkv",1491.54,264.92,17.8,1920x1080,1920x1080,1,32,CQ
|
|
||||||
anime,The Daily Life of a Middle-Aged Online Shopper in Another World (2025),The Daily Life of a Middle-Aged Online Shopper in Another World - S01E13 - To Sa-Kura x264 Opus Bluray-1080p Netaro - [EHX].mkv,1607.51,269.68,16.8,1920x1080,1920x1080,1,32,CQ
|
|
||||||
|
|||||||
|
Can't render this file because it has a wrong number of fields in line 14.
|
@ -1,441 +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 ""
|
|
||||||
for line in stderr_output.split("\n"):
|
|
||||||
# Match "Stream #0:X(YYY)" where X is stream number, YYY is language
|
|
||||||
match = re.search(r"Stream #0:(\d+)\((\w{3})\)", line)
|
|
||||||
if match:
|
|
||||||
stream_idx = int(match.group(1))
|
|
||||||
lang_code = match.group(2)
|
|
||||||
language_map[stream_idx] = lang_code
|
|
||||||
|
|
||||||
# Get audio stream details via JSON with tags
|
|
||||||
cmd = [
|
|
||||||
"ffprobe","-v","error","-select_streams","a",
|
|
||||||
"-show_entries","stream=index,channels,bit_rate,tags",
|
|
||||||
"-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)
|
|
||||||
|
|
||||||
# Get language from our parsed map, default to "und"
|
|
||||||
src_lang = language_map.get(index, "und")
|
|
||||||
|
|
||||||
# Get title from tags or from our parsed map
|
|
||||||
title = ""
|
|
||||||
if "tags" in s and "title" in s["tags"]:
|
|
||||||
title = s["tags"]["title"]
|
|
||||||
elif index in title_map:
|
|
||||||
title = title_map[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.info(f"Stream {index}: Using fallback bitrate {calculated_bitrate_kbps} kbps")
|
|
||||||
|
|
||||||
streams.append((index, channels, calculated_bitrate_kbps, src_lang, int(bit_rate_meta / 1000) if bit_rate_meta else 0, title))
|
|
||||||
|
|
||||||
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
|
|
||||||
logger.info(f"Stereo audio {bitrate_kbps}kbps ≤ {high_br/1000:.0f}k threshold - copying 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
|
|
||||||
logger.info(f"Stereo audio {bitrate_kbps}kbps ≤ {medium_br/1000:.0f}k threshold - copying 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 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} | 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, strip_title) tuples
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Same list with strip_title field updated based on user selection
|
|
||||||
"""
|
|
||||||
streams_with_titles = [(s[0], s[5]) for s in filtered_streams if s[5]]
|
|
||||||
|
|
||||||
if not streams_with_titles:
|
|
||||||
return [s + (False,) if len(s) == 6 else s for s in 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("Enter stream numbers to STRIP titles (comma-separated, or leave blank to keep all)")
|
|
||||||
print("Example: \"1,3\" will strip titles from streams #1 and #3")
|
|
||||||
print("-"*80)
|
|
||||||
|
|
||||||
strip_input = input("➜ Strip titles from: ").strip()
|
|
||||||
|
|
||||||
strip_indices = set()
|
|
||||||
if strip_input:
|
|
||||||
try:
|
|
||||||
for part in strip_input.split(","):
|
|
||||||
idx = int(part.strip())
|
|
||||||
strip_indices.add(idx)
|
|
||||||
except ValueError:
|
|
||||||
print("❌ Invalid input. Keeping all titles.\n")
|
|
||||||
logger.warning("Invalid title stripping input")
|
|
||||||
|
|
||||||
# Add strip_title field to each stream
|
|
||||||
result = []
|
|
||||||
for s in filtered_streams:
|
|
||||||
should_strip = s[0] in strip_indices
|
|
||||||
result.append(s + (should_strip,))
|
|
||||||
|
|
||||||
if strip_indices:
|
|
||||||
print(f"✅ Will strip titles from stream(s): {sorted(list(strip_indices))}\n")
|
|
||||||
logger.info(f"User selected to strip titles from streams: {sorted(list(strip_indices))}")
|
|
||||||
else:
|
|
||||||
print("✅ Keeping all titles\n")
|
|
||||||
|
|
||||||
return result
|
|
||||||
@ -8,7 +8,6 @@ DEFAULT_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<processing_folder>processing</processing_folder>
|
<processing_folder>processing</processing_folder>
|
||||||
<suffix> -EHX</suffix>
|
<suffix> -EHX</suffix>
|
||||||
<extensions>.mkv,.mp4</extensions>
|
<extensions>.mkv,.mp4</extensions>
|
||||||
<ignore_tags>ehx,megusta</ignore_tags>
|
|
||||||
<reduction_ratio_threshold>0.5</reduction_ratio_threshold>
|
<reduction_ratio_threshold>0.5</reduction_ratio_threshold>
|
||||||
</general>
|
</general>
|
||||||
<path_mappings>
|
<path_mappings>
|
||||||
@ -25,10 +24,10 @@ DEFAULT_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<fallback>
|
<fallback>
|
||||||
<bitrate_1080>1500k</bitrate_1080>
|
<bitrate_1080>1500k</bitrate_1080>
|
||||||
<maxrate_1080>1750k</maxrate_1080>
|
<maxrate_1080>1750k</maxrate_1080>
|
||||||
<bufsize_1080>2750k</bufsize_1080>
|
<bufsize_1080>2250k</bufsize_1080>
|
||||||
<bitrate_720>900k</bitrate_720>
|
<bitrate_720>900k</bitrate_720>
|
||||||
<maxrate_720>1250k</maxrate_720>
|
<maxrate_720>1250k</maxrate_720>
|
||||||
<bufsize_720>1800k</bufsize_720>
|
<bufsize_720>1600k</bufsize_720>
|
||||||
</fallback>
|
</fallback>
|
||||||
<filters>
|
<filters>
|
||||||
<default>lanczos</default>
|
<default>lanczos</default>
|
||||||
@ -38,15 +37,18 @@ DEFAULT_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
<audio>
|
<audio>
|
||||||
<stereo>
|
<stereo>
|
||||||
<low>64000</low>
|
<low>64000</low>
|
||||||
<medium>128000</medium>
|
<medium>96000</medium>
|
||||||
<high>160000</high>
|
<high>128000</high>
|
||||||
</stereo>
|
</stereo>
|
||||||
<multi_channel>
|
<multi_channel>
|
||||||
<low>384000</low>
|
<low>160000</low>
|
||||||
<medium>512000</medium>
|
<high>192000</high>
|
||||||
<high>640000</high>
|
|
||||||
</multi_channel>
|
</multi_channel>
|
||||||
</audio>
|
</audio>
|
||||||
|
<ignore_tags>
|
||||||
|
<tag>ehx</tag>
|
||||||
|
<tag>megusta</tag>
|
||||||
|
</ignore_tags>
|
||||||
</config>
|
</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_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"]
|
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_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
|
reduction_ratio_threshold = float(reduction_ratio_elem.text) if reduction_ratio_elem is not None else 0.5
|
||||||
|
|
||||||
# --- Path Mappings ---
|
# --- Path Mappings ---
|
||||||
path_mappings = []
|
path_mappings = {}
|
||||||
for m in root.findall("path_mappings/map"):
|
for m in root.findall("path_mappings/map"):
|
||||||
f = m.attrib.get("from")
|
f = m.attrib.get("from")
|
||||||
t = m.attrib.get("to")
|
t = m.attrib.get("to")
|
||||||
if f and t:
|
if f and t:
|
||||||
path_mappings.append({"from": f, "to": t})
|
path_mappings[f] = t
|
||||||
|
|
||||||
# --- Encode ---
|
# --- Encode ---
|
||||||
encode_elem = root.find("encode")
|
encode_elem = root.find("encode")
|
||||||
@ -91,80 +90,49 @@ def load_config_xml(path: Path) -> dict:
|
|||||||
if encode_elem is not None:
|
if encode_elem is not None:
|
||||||
cq_elem = encode_elem.find("cq")
|
cq_elem = encode_elem.find("cq")
|
||||||
if cq_elem is not None:
|
if cq_elem is not None:
|
||||||
# Check if CQ has encoder-specific sub-elements (av1, hevc)
|
for child in cq_elem:
|
||||||
encoder_elems = list(cq_elem)
|
if child.text:
|
||||||
if encoder_elems and encoder_elems[0].tag in ["av1", "hevc"]:
|
cq[child.tag] = int(child.text)
|
||||||
# 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())
|
|
||||||
|
|
||||||
fallback_elem = encode_elem.find("fallback")
|
fallback_elem = encode_elem.find("fallback")
|
||||||
if fallback_elem is not None:
|
if fallback_elem is not None:
|
||||||
for child in fallback_elem:
|
for child in fallback_elem:
|
||||||
if child.text and child.text.strip():
|
if child.text:
|
||||||
fallback[child.tag] = child.text.strip()
|
fallback[child.tag] = child.text
|
||||||
|
|
||||||
filters_elem = encode_elem.find("filters")
|
filters_elem = encode_elem.find("filters")
|
||||||
if filters_elem is not None:
|
if filters_elem is not None:
|
||||||
for child in filters_elem:
|
for child in filters_elem:
|
||||||
if child.text and child.text.strip():
|
if child.text:
|
||||||
filters[child.tag] = child.text.strip()
|
filters[child.tag] = child.text
|
||||||
|
|
||||||
# --- Audio ---
|
# --- Audio ---
|
||||||
audio = {"stereo": {}, "multi_channel": {}}
|
audio = {"stereo": {}, "multi_channel": {}}
|
||||||
stereo_elem = root.find("audio/stereo")
|
stereo_elem = root.find("audio/stereo")
|
||||||
if stereo_elem is not None:
|
if stereo_elem is not None:
|
||||||
for child in stereo_elem:
|
for child in stereo_elem:
|
||||||
if child.text and child.text.strip():
|
if child.text:
|
||||||
audio["stereo"][child.tag] = int(child.text.strip())
|
audio["stereo"][child.tag] = int(child.text)
|
||||||
|
|
||||||
multi_elem = root.find("audio/multi_channel")
|
multi_elem = root.find("audio/multi_channel")
|
||||||
if multi_elem is not None:
|
if multi_elem is not None:
|
||||||
for child in multi_elem:
|
for child in multi_elem:
|
||||||
if child.text and child.text.strip():
|
if child.text:
|
||||||
audio["multi_channel"][child.tag] = int(child.text.strip())
|
audio["multi_channel"][child.tag] = int(child.text)
|
||||||
|
|
||||||
# --- Services (Sonarr/Radarr) ---
|
# --- Ignore Tags ---
|
||||||
services = {"sonarr": {}, "radarr": {}}
|
ignore_tags = []
|
||||||
sonarr_elem = root.find("services/sonarr")
|
for tag_elem in root.findall("ignore_tags/tag"):
|
||||||
if sonarr_elem is not None:
|
if tag_elem.text:
|
||||||
url_elem = sonarr_elem.find("url")
|
ignore_tags.append(tag_elem.text)
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processing_folder": processing_folder,
|
"processing_folder": processing_folder,
|
||||||
"suffix": suffix,
|
"suffix": suffix,
|
||||||
"extensions": [ext.lower() for ext in extensions],
|
"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,
|
"path_mappings": path_mappings,
|
||||||
"encode": {"cq": cq, "fallback": fallback, "filters": filters},
|
"encode": {"cq": cq, "fallback": fallback, "filters": filters},
|
||||||
"audio": audio,
|
"audio": audio,
|
||||||
"services": services
|
"ignore_tags": ignore_tags,
|
||||||
|
"reduction_ratio_threshold": reduction_ratio_threshold
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,229 +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):
|
|
||||||
"""
|
|
||||||
Run FFmpeg encode with comprehensive logging.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
strip_all_titles: If True, strip all title metadata from all audio tracks
|
|
||||||
|
|
||||||
Returns tuple: (orig_size, out_size, 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]
|
|
||||||
# Add strip_title field (False by default for pre-selected)
|
|
||||||
streams = [s + (False,) for s in streams]
|
|
||||||
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.")
|
|
||||||
streams = [s + (False,) for s in 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)
|
|
||||||
# Add strip_title field (False by default for automatic filtering)
|
|
||||||
streams = [s + (False,) for s in streams]
|
|
||||||
else:
|
|
||||||
# No filtering - add strip_title field as False
|
|
||||||
streams = [s + (False,) for s in streams]
|
|
||||||
|
|
||||||
# Log comprehensive encode settings
|
|
||||||
header = f"\n🧩 ENCODE SETTINGS"
|
|
||||||
logger.info(header)
|
|
||||||
print(" ")
|
|
||||||
|
|
||||||
# Determine encoder display name and settings
|
|
||||||
if encoder == "av1":
|
|
||||||
encoder_name = "AV1 NVENC"
|
|
||||||
encoder_codec = "av1_nvenc"
|
|
||||||
encoder_preset = "p7"
|
|
||||||
encoder_pix_fmt = "yuv420p"
|
|
||||||
encoder_bit_depth = "8-bit"
|
|
||||||
else: # default hevc = HEVC NVENC
|
|
||||||
encoder_name = "HEVC NVENC"
|
|
||||||
encoder_codec = "hevc_nvenc"
|
|
||||||
encoder_preset = "slow"
|
|
||||||
encoder_pix_fmt = "p010le"
|
|
||||||
encoder_bit_depth = "10-bit"
|
|
||||||
|
|
||||||
logger.info(f" Video:")
|
|
||||||
logger.info(f" • Source Resolution: {src_width}x{src_height}")
|
|
||||||
logger.info(f" • Target Resolution: {scale_width}x{scale_height}")
|
|
||||||
logger.info(f" • Encoder: {encoder_name} (preset {encoder_preset}, {encoder_bit_depth}, pix_fmt {encoder_pix_fmt})")
|
|
||||||
logger.info(f" • Scale Filter: {filter_flags}")
|
|
||||||
logger.info(f" • Encode Method: {method}")
|
|
||||||
if method == "CQ":
|
|
||||||
logger.info(f" • CQ Value: {cq}")
|
|
||||||
else:
|
|
||||||
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")
|
|
||||||
logger.info(f" • Bitrate: {vb}, Max: {maxrate}")
|
|
||||||
|
|
||||||
logger.info(f" Audio Streams ({len(streams)} detected):")
|
|
||||||
print(" ")
|
|
||||||
|
|
||||||
for (index, channels, avg_bitrate, src_lang, meta_bitrate, title, strip_title) 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 (preserve)"
|
|
||||||
bitrate_display = f"{avg_bitrate}kbps"
|
|
||||||
else:
|
|
||||||
action = "ENCODE"
|
|
||||||
bitrate_display = f"{br/1000:.0f}kbps"
|
|
||||||
|
|
||||||
# Include title in display if present
|
|
||||||
title_info = f" | Title: {title}" if title else ""
|
|
||||||
line = f" - Stream #{index}: {channels}ch→{output_channels}ch | Lang: {src_lang} | Detected: {avg_bitrate}kbps | Action: {action} | Target: {bitrate_display}{title_info}"
|
|
||||||
print(line)
|
|
||||||
logger.info(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
|
|
||||||
|
|
||||||
cmd.extend([
|
|
||||||
"-vf",f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease",
|
|
||||||
"-map","0:v"])
|
|
||||||
|
|
||||||
# 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?"])
|
|
||||||
|
|
||||||
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, strip_title) 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
|
|
||||||
codec, br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
|
|
||||||
|
|
||||||
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)
|
|
||||||
should_strip = strip_title or (strip_all_titles and not (title and "commentary" in title.lower()))
|
|
||||||
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)
|
|
||||||
should_strip = strip_title or (strip_all_titles and not (title and "commentary" in title.lower()))
|
|
||||||
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"]
|
|
||||||
else:
|
|
||||||
cmd += ["-c:s", "copy"]
|
|
||||||
|
|
||||||
cmd += [str(output_file)]
|
|
||||||
|
|
||||||
print(f"\n🎬 Running {method} encode: {output_file.name}")
|
|
||||||
logger.info(f"Running {method} encode: {output_file.name}")
|
|
||||||
|
|
||||||
subprocess.run(cmd, check=True)
|
|
||||||
|
|
||||||
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
136
core/ffmpeg_helper.py
Normal 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
|
||||||
@ -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))
|
|
||||||
@ -1,96 +1,35 @@
|
|||||||
|
# core/logger_helper.py
|
||||||
import logging
|
import logging
|
||||||
import json
|
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class JsonFormatter(logging.Formatter):
|
|
||||||
"""
|
|
||||||
Custom JSON log formatter for structured logging.
|
|
||||||
"""
|
|
||||||
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,
|
|
||||||
"funcName": record.funcName,
|
|
||||||
"line": record.lineno,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Include any extra fields added via logger.info("msg", extra={...})
|
|
||||||
if hasattr(record, "extra") and isinstance(record.extra, dict):
|
|
||||||
log_object.update(record.extra)
|
|
||||||
|
|
||||||
# Include exception info if present
|
|
||||||
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:
|
def setup_logger(log_folder: Path, log_file_name: str = "conversion.log", level=logging.INFO) -> logging.Logger:
|
||||||
"""
|
"""
|
||||||
Sets up a logger that prints to console and writes to a rotating JSON log file.
|
Sets up a logger that prints to console and writes to a rotating log file.
|
||||||
"""
|
"""
|
||||||
log_folder.mkdir(parents=True, exist_ok=True)
|
log_folder.mkdir(parents=True, exist_ok=True)
|
||||||
log_file = log_folder / log_file_name
|
log_file = log_folder / log_file_name
|
||||||
|
|
||||||
logger = logging.getLogger("conversion_logger")
|
logger = logging.getLogger("conversion_logger")
|
||||||
logger.setLevel(level)
|
logger.setLevel(level)
|
||||||
logger.propagate = False # Prevent double logging
|
logger.propagate = False # Prevent duplicate logging if root logger exists
|
||||||
|
|
||||||
# Formatters
|
# Formatter with timestamp
|
||||||
text_formatter = logging.Formatter(
|
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
||||||
"%(asctime)s [%(levelname)s] %(message)s (%(module)s:%(lineno)d)",
|
|
||||||
datefmt="%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
json_formatter = JsonFormatter()
|
|
||||||
|
|
||||||
# Console handler (human-readable)
|
# Console handler
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
console_handler.setFormatter(text_formatter)
|
console_handler.setFormatter(formatter)
|
||||||
console_handler.setLevel(level)
|
console_handler.setLevel(level)
|
||||||
|
|
||||||
# File handler (JSON logs)
|
# 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 = RotatingFileHandler(log_file, maxBytes=5*1024*1024, backupCount=3, encoding="utf-8")
|
||||||
file_handler.setFormatter(json_formatter)
|
file_handler.setFormatter(formatter)
|
||||||
file_handler.setLevel(level)
|
file_handler.setLevel(level)
|
||||||
|
|
||||||
# Add handlers only once
|
# Add handlers
|
||||||
if not logger.handlers:
|
if not logger.handlers:
|
||||||
logger.addHandler(console_handler)
|
logger.addHandler(console_handler)
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
def setup_failure_logger(log_folder: Path) -> logging.Logger:
|
|
||||||
"""
|
|
||||||
Setup a dedicated logger for encoding failures.
|
|
||||||
Returns a logger that writes to logs/failure.log
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|||||||
0
core/process_helper.py
Normal file
0
core/process_helper.py
Normal file
@ -1,564 +0,0 @@
|
|||||||
# core/process_manager.py
|
|
||||||
"""Main processing logic for batch transcoding."""
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import os
|
|
||||||
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, get_source_bit_depth, determine_target_resolution
|
|
||||||
|
|
||||||
logger = setup_logger(Path(__file__).parent.parent / "logs")
|
|
||||||
failure_logger = setup_failure_logger(Path(__file__).parent.parent / "logs")
|
|
||||||
|
|
||||||
|
|
||||||
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 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):
|
|
||||||
"""
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
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 encoding mode
|
|
||||||
is_smart_mode = transcode_mode == "compression" # Try CQ first, then bitrate fallback
|
|
||||||
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 compression 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: Compression (Try CQ first, retry with Bitrate if needed)")
|
|
||||||
elif is_forced_cq:
|
|
||||||
print("📋 MODE: CQ (constant quality, skip failures, log them)")
|
|
||||||
else:
|
|
||||||
print("📋 MODE: Bitrate (bitrate mode only, skip failures, log them)")
|
|
||||||
print(f"{'='*60}\n")
|
|
||||||
|
|
||||||
skipped_count = 0
|
|
||||||
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):
|
|
||||||
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
|
|
||||||
|
|
||||||
print("="*60)
|
|
||||||
logger.info(f"Processing: {file.name}")
|
|
||||||
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 files (supports multiple)
|
|
||||||
subtitle_files = []
|
|
||||||
if config.get("general", {}).get("subtitles", {}).get("enabled", True):
|
|
||||||
subtitle_exts = config.get("general", {}).get("subtitles", {}).get("extensions", ".vtt,.srt,.ass,.ssa,.sub").split(",")
|
|
||||||
parent_dir = file.parent
|
|
||||||
base_name = file.stem
|
|
||||||
found_subs = set() # Track found subtitles to avoid duplicates
|
|
||||||
|
|
||||||
# Look for subtitle files 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() and str(potential_sub) not in found_subs:
|
|
||||||
subtitle_files.append(potential_sub)
|
|
||||||
found_subs.add(str(potential_sub))
|
|
||||||
print(f"📝 Found subtitle: {potential_sub.name}")
|
|
||||||
logger.info(f"Found subtitle file: {potential_sub.name}")
|
|
||||||
|
|
||||||
# Try language prefix variants (movie.en.vtt, movie.eng.vtt, movie.en.forced.srt, etc.)
|
|
||||||
# Look for all files matching the pattern basename.*ext
|
|
||||||
for item in sorted(parent_dir.glob(f"{base_name}.*{ext}")):
|
|
||||||
if str(item) not in found_subs:
|
|
||||||
subtitle_files.append(item)
|
|
||||||
found_subs.add(str(item))
|
|
||||||
print(f"📝 Found subtitle: {item.name}")
|
|
||||||
logger.info(f"Found subtitle file: {item.name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Detect source resolution and determine target resolution
|
|
||||||
src_width, src_height = get_source_resolution(temp_input)
|
|
||||||
res_width, res_height, target_resolution = determine_target_resolution(
|
|
||||||
src_width, src_height, explicit_resolution
|
|
||||||
)
|
|
||||||
|
|
||||||
# Auto-select encoder based on source bit depth if not explicitly specified
|
|
||||||
# (explicit encoder arg is passed in, so if user didn't specify, it's still the default)
|
|
||||||
# We need to check if encoder came from CLI or is the default
|
|
||||||
# For now, we'll always auto-detect and only skip if encoder was explicitly set
|
|
||||||
# Since we can't distinguish in the current flow, we'll add a parameter to track this
|
|
||||||
selected_encoder = encoder # Start with what was passed (may be default)
|
|
||||||
|
|
||||||
# Check source bit depth for auto-selection
|
|
||||||
source_bit_depth = get_source_bit_depth(temp_input)
|
|
||||||
|
|
||||||
# Auto-select encoder based on source bit depth
|
|
||||||
# 10-bit or higher (including 12-bit) → HEVC (supports up to 10-bit)
|
|
||||||
# 8-bit → AV1 (more efficient for 8-bit)
|
|
||||||
if source_bit_depth >= 10:
|
|
||||||
selected_encoder = "hevc"
|
|
||||||
encoder_note = "auto-selected (10+ bit source)"
|
|
||||||
else:
|
|
||||||
selected_encoder = "av1"
|
|
||||||
encoder_note = "auto-selected (8-bit source)"
|
|
||||||
|
|
||||||
print(f"ℹ️ Encoder: {selected_encoder} ({encoder_note})")
|
|
||||||
logger.info(f"Selected encoder: {selected_encoder} - Source bit depth: {source_bit_depth}-bit")
|
|
||||||
|
|
||||||
# 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.")
|
|
||||||
logger.info(f"Source {src_width}x{src_height} detected. Scaling to 1080p.")
|
|
||||||
elif src_height <= 720:
|
|
||||||
print(f"ℹ️ Source {src_width}x{src_height} is 720p or lower. Preserving resolution.")
|
|
||||||
logger.info(f"Source {src_width}x{src_height} (<=720p). Preserving source resolution.")
|
|
||||||
else:
|
|
||||||
print(f"ℹ️ Source {src_width}x{src_height} is at or below 1080p. Preserving resolution.")
|
|
||||||
logger.info(f"Source {src_width}x{src_height} (<=1080p). Preserving source 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
|
|
||||||
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
|
|
||||||
|
|
||||||
# Always output as .mkv (AV1 video codec) with [EHX] suffix
|
|
||||||
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
|
|
||||||
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, selected_encoder, subtitle_files, audio_language,
|
|
||||||
audio_filter_config, test_mode, strip_all_titles
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if encode met size target
|
|
||||||
encode_succeeded = True
|
|
||||||
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
|
|
||||||
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_files': subtitle_files
|
|
||||||
})
|
|
||||||
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
|
|
||||||
error_msg = f"Size threshold not met ({reduction_ratio:.1%})"
|
|
||||||
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 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_files': subtitle_files
|
|
||||||
})
|
|
||||||
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
|
|
||||||
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_files
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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, selected_encoder,
|
|
||||||
file_data.get('subtitle_files'), audio_language, None, test_mode, strip_all_titles
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if bitrate also failed
|
|
||||||
if 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_files')
|
|
||||||
)
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
error_msg = str(e).split('\n')[0][:100]
|
|
||||||
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]
|
|
||||||
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_files=None):
|
|
||||||
"""Helper function to save successfully encoded files with [EHX] tag and clean up subtitle files."""
|
|
||||||
|
|
||||||
# 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}")
|
|
||||||
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 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
|
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
# 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
|
|
||||||
logger.info(f"\n✅ CONVERSION COMPLETE: {dest_file.name}")
|
|
||||||
logger.info(f" Type: {f_type.upper()} | Show: {show}")
|
|
||||||
logger.info(f" Size: {orig_size_mb}MB → {proc_size_mb}MB ({percentage}% of original, {100-percentage:.1f}% reduction)")
|
|
||||||
logger.info(f" Method: {method} | Status: SUCCESS")
|
|
||||||
print(f"📝 Logged conversion: {dest_file.name} ({percentage}%), method={method}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
temp_input.unlink()
|
|
||||||
|
|
||||||
# Only delete original file if NOT in Featurettes folder (Featurettes are re-encoded in place)
|
|
||||||
if 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 files if they exist
|
|
||||||
if subtitle_files:
|
|
||||||
for sub_file in subtitle_files:
|
|
||||||
if sub_file.exists():
|
|
||||||
try:
|
|
||||||
sub_file.unlink()
|
|
||||||
print(f"🗑️ Removed subtitle: {sub_file.name}")
|
|
||||||
logger.info(f"Removed subtitle: {sub_file.name}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not delete subtitle file {sub_file.name}: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ Could not delete files: {e}")
|
|
||||||
logger.warning(f"Could not delete files: {e}")
|
|
||||||
0
core/tracker_helper.py
Normal file
0
core/tracker_helper.py
Normal file
@ -1,108 +0,0 @@
|
|||||||
# core/video_handler.py
|
|
||||||
"""Video resolution detection and encoding logic."""
|
|
||||||
|
|
||||||
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)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
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
|
|
||||||
logger.info(f"Source resolution detected: {width}x{height}")
|
|
||||||
return (width, height)
|
|
||||||
else:
|
|
||||||
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)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cmd = [
|
|
||||||
"ffprobe", "-v", "error",
|
|
||||||
"-select_streams", "v:0",
|
|
||||||
"-show_entries", "stream=pix_fmt",
|
|
||||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
||||||
str(input_file)
|
|
||||||
]
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='ignore', check=False)
|
|
||||||
if result.stdout:
|
|
||||||
pix_fmt = result.stdout.strip().lower()
|
|
||||||
# Check for 12-bit indicators first
|
|
||||||
if any(x in pix_fmt for x in ["12le", "12be"]):
|
|
||||||
logger.info(f"Source bit depth detected: 12-bit ({pix_fmt})")
|
|
||||||
return 12
|
|
||||||
# Check for 10-bit indicators
|
|
||||||
elif any(x in pix_fmt for x in ["10le", "10be", "p010", "yuv420p10"]):
|
|
||||||
logger.info(f"Source bit depth detected: 10-bit ({pix_fmt})")
|
|
||||||
return 10
|
|
||||||
else:
|
|
||||||
logger.info(f"Source bit depth detected: 8-bit ({pix_fmt})")
|
|
||||||
return 8
|
|
||||||
else:
|
|
||||||
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
|
|
||||||
Else:
|
|
||||||
- If source > 1080p: scale to 1080p
|
|
||||||
- If source <= 1080p: preserve source resolution
|
|
||||||
"""
|
|
||||||
if explicit_resolution:
|
|
||||||
# User explicitly specified resolution - always use it
|
|
||||||
if explicit_resolution == "1080":
|
|
||||||
return (1920, 1080, "1080")
|
|
||||||
elif explicit_resolution == "720":
|
|
||||||
return (1280, 720, "720")
|
|
||||||
else: # 480
|
|
||||||
return (854, 480, "480")
|
|
||||||
else:
|
|
||||||
# No explicit resolution - use smart defaults
|
|
||||||
if src_height > 1080:
|
|
||||||
# Scale down anything above 1080p to 1080p
|
|
||||||
logger.info(f"Source {src_width}x{src_height} detected. Scaling to 1080p.")
|
|
||||||
return (1920, 1080, "1080")
|
|
||||||
else:
|
|
||||||
# Preserve source resolution (480p, 720p, 1080p, etc.)
|
|
||||||
if src_height <= 720:
|
|
||||||
logger.info(f"Source {src_width}x{src_height} (<=720p). Preserving source resolution.")
|
|
||||||
return (src_width, src_height, "720")
|
|
||||||
else:
|
|
||||||
logger.info(f"Source {src_width}x{src_height} (<=1080p). Preserving source resolution.")
|
|
||||||
return (src_width, src_height, "1080")
|
|
||||||
@ -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()
|
|
||||||
25729
logs/conversion.log
25729
logs/conversion.log
File diff suppressed because it is too large
Load Diff
@ -1,63 +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
|
|
||||||
596
main.py
596
main.py
@ -1,155 +1,507 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
|
||||||
AV1 Batch Video Transcoder
|
|
||||||
Main entry point for batch video encoding with intelligent audio and resolution handling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
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.config_helper import load_config_xml
|
||||||
from core.logger_helper import setup_logger
|
from core.logger_helper import setup_logger
|
||||||
from core.process_manager import process_folder
|
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
# PATH NORMALIZATION
|
# Setup logger
|
||||||
# =============================
|
|
||||||
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
|
|
||||||
# =============================
|
# =============================
|
||||||
LOG_FOLDER = Path(__file__).parent / "logs"
|
LOG_FOLDER = Path(__file__).parent / "logs"
|
||||||
logger = setup_logger(LOG_FOLDER)
|
logger = setup_logger(LOG_FOLDER)
|
||||||
|
|
||||||
|
# =============================
|
||||||
|
# Tracker CSV
|
||||||
|
# =============================
|
||||||
TRACKER_FILE = Path(__file__).parent / "conversion_tracker.csv"
|
TRACKER_FILE = Path(__file__).parent / "conversion_tracker.csv"
|
||||||
if not TRACKER_FILE.exists():
|
if not TRACKER_FILE.exists():
|
||||||
with open(TRACKER_FILE, "w", newline="", encoding="utf-8") as f:
|
with open(TRACKER_FILE, "w", newline="", encoding="utf-8") as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
"type", "show", "filename", "original_size_MB", "processed_size_MB", "percentage",
|
"type","show","filename","original_size_MB","processed_size_MB","percentage","method"
|
||||||
"source_resolution", "target_resolution", "audio_streams", "cq_value", "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
|
# MAIN
|
||||||
# =============================
|
# =============================
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(description="Batch encode videos with logging and tracker")
|
||||||
description="Batch AV1 encode videos with intelligent audio and resolution handling",
|
parser.add_argument("folder", help="Path to folder containing videos")
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
parser.add_argument("--cq", type=int, help="Override default CQ")
|
||||||
epilog="""
|
parser.add_argument("--r", "--resolution", dest="resolution", default="1080", choices=["720","1080"], help="Target resolution")
|
||||||
Examples:
|
parser.add_argument("--dry-run", action="store_true", help="Preview files without encoding")
|
||||||
%(prog)s "C:\\Videos\\Movies" # CQ mode (preserve resolution, 4K->1080p)
|
parser.add_argument("--verbose", "-v", action="store_true", help="Show FFmpeg output")
|
||||||
%(prog)s "C:\\Videos\\TV" --r 720 --m bitrate # Force 720p, bitrate mode
|
parser.add_argument("--backup", action="store_true", help="Backup original files before encoding")
|
||||||
%(prog)s "C:\\Videos\\Anime" --cq 28 --r 1080 # Force 1080p, CQ=28
|
parser.add_argument("--cleanup", action="store_true", help="Clean old processing folder on startup")
|
||||||
%(prog)s "C:\\Videos\\Low-Res" --m compression --r 480 # Force 480p, try CQ then bitrate for compression
|
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)")
|
||||||
)
|
|
||||||
|
|
||||||
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", "compression"],
|
|
||||||
help="Encode mode: 'cq' (constant quality only), 'bitrate' (bitrate only), or 'compression' (try CQ then bitrate fallback)"
|
|
||||||
)
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--r", "--resolution", dest="resolution", default=None,
|
|
||||||
choices=["480", "720", "1080"],
|
|
||||||
help="Force target resolution (if not specified: 4K->1080p, else preserve)"
|
|
||||||
)
|
|
||||||
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. Requires --filter-audio"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--strip-all-titles", dest="strip_all_titles", default=False, action="store_true",
|
|
||||||
help="Strip title metadata from all audio tracks (default: False)"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Load configuration
|
|
||||||
config_path = Path(__file__).parent / "config.xml"
|
config_path = Path(__file__).parent / "config.xml"
|
||||||
config = load_config_xml(config_path)
|
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", {}))
|
|
||||||
|
|
||||||
# Verify folder exists
|
|
||||||
if not folder.exists():
|
|
||||||
print(f"❌ Folder not found: {folder}")
|
|
||||||
logger.error(f"Folder not found: {folder}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Process folder
|
# Override reduction ratio if provided
|
||||||
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)
|
if args.ratio:
|
||||||
|
config["reduction_ratio_threshold"] = args.ratio
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
3201
path_manager/cache/.folder_cache.json
vendored
3201
path_manager/cache/.folder_cache.json
vendored
File diff suppressed because it is too large
Load Diff
@ -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>
|
|
||||||
@ -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()
|
|
||||||
@ -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}
|
|
||||||
@ -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)"
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
"P:\movies\Ponyo (2008)"
|
|
||||||
"P:\movies\Castle in the Sky (1986)"
|
|
||||||
"P:\movies\The Secret Life of Walter Mitty (2013)"
|
|
||||||
"P:\movies\Let's Be Cops (2014)"
|
|
||||||
"P:\movies\Deadpool & Wolverine (2024)"
|
|
||||||
"P:\movies\The Secret World of Arrietty (2010)"
|
|
||||||
"P:\movies\The Irregular at Magic High School - The Girl Who Summons the Stars (2017)"
|
|
||||||
"P:\movies\The French Dispatch (2021)"
|
|
||||||
"P:\movies\John Carter (2012)"
|
|
||||||
@ -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
|
|
||||||
Loading…
x
Reference in New Issue
Block a user