repo creation with initial code after cloning public repo
This commit is contained in:
Generated
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
P:System.Threading.Tasks.Task`1.Result
|
||||||
|
M:System.Guid.op_Equality(System.Guid,System.Guid)
|
||||||
|
M:System.Guid.op_Inequality(System.Guid,System.Guid)
|
||||||
|
M:System.Guid.Equals(System.Object)
|
||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
# Jellyfin Contributors
|
||||||
|
|
||||||
|
- [1337joe](https://github.com/1337joe)
|
||||||
|
- [97carmine](https://github.com/97carmine)
|
||||||
|
- [Abbe98](https://github.com/Abbe98)
|
||||||
|
- [agrenott](https://github.com/agrenott)
|
||||||
|
- [alltilla](https://github.com/alltilla)
|
||||||
|
- [AndreCarvalho](https://github.com/AndreCarvalho)
|
||||||
|
- [anthonylavado](https://github.com/anthonylavado)
|
||||||
|
- [Artiume](https://github.com/Artiume)
|
||||||
|
- [AThomsen](https://github.com/AThomsen)
|
||||||
|
- [barongreenback](https://github.com/BaronGreenback)
|
||||||
|
- [barronpm](https://github.com/barronpm)
|
||||||
|
- [bilde2910](https://github.com/bilde2910)
|
||||||
|
- [bfayers](https://github.com/bfayers)
|
||||||
|
- [BnMcG](https://github.com/BnMcG)
|
||||||
|
- [Bond-009](https://github.com/Bond-009)
|
||||||
|
- [brianjmurrell](https://github.com/brianjmurrell)
|
||||||
|
- [bugfixin](https://github.com/bugfixin)
|
||||||
|
- [chaosinnovator](https://github.com/chaosinnovator)
|
||||||
|
- [ckcr4lyf](https://github.com/ckcr4lyf)
|
||||||
|
- [cocool97](https://github.com/cocool97)
|
||||||
|
- [ConfusedPolarBear](https://github.com/ConfusedPolarBear)
|
||||||
|
- [crankdoofus](https://github.com/crankdoofus)
|
||||||
|
- [crobibero](https://github.com/crobibero)
|
||||||
|
- [cromefire](https://github.com/cromefire)
|
||||||
|
- [cryptobank](https://github.com/cryptobank)
|
||||||
|
- [cvium](https://github.com/cvium)
|
||||||
|
- [dannymichel](https://github.com/dannymichel)
|
||||||
|
- [darioackermann](https://github.com/darioackermann)
|
||||||
|
- [DaveChild](https://github.com/DaveChild)
|
||||||
|
- [DavidFair](https://github.com/DavidFair)
|
||||||
|
- [Delgan](https://github.com/Delgan)
|
||||||
|
- [Derpipose](https://github.com/Derpipose)
|
||||||
|
- [dcrdev](https://github.com/dcrdev)
|
||||||
|
- [dhartung](https://github.com/dhartung)
|
||||||
|
- [dinki](https://github.com/dinki)
|
||||||
|
- [dkanada](https://github.com/dkanada)
|
||||||
|
- [dlahoti](https://github.com/dlahoti)
|
||||||
|
- [dmitrylyzo](https://github.com/dmitrylyzo)
|
||||||
|
- [DMouse10462](https://github.com/DMouse10462)
|
||||||
|
- [DrPandemic](https://github.com/DrPandemic)
|
||||||
|
- [eglia](https://github.com/eglia)
|
||||||
|
- [EgorBakanov](https://github.com/EgorBakanov)
|
||||||
|
- [EraYaN](https://github.com/EraYaN)
|
||||||
|
- [escabe](https://github.com/escabe)
|
||||||
|
- [excelite](https://github.com/excelite)
|
||||||
|
- [fasheng](https://github.com/fasheng)
|
||||||
|
- [ferferga](https://github.com/ferferga)
|
||||||
|
- [fhriley](https://github.com/fhriley)
|
||||||
|
- [flemse](https://github.com/flemse)
|
||||||
|
- [Froghut](https://github.com/Froghut)
|
||||||
|
- [fruhnow](https://github.com/fruhnow)
|
||||||
|
- [geilername](https://github.com/geilername)
|
||||||
|
- [GermanCoding](https://github.com/GermanCoding)
|
||||||
|
- [gnattu](https://github.com/gnattu)
|
||||||
|
- [GodTamIt](https://github.com/GodTamIt)
|
||||||
|
- [grafixeyehero](https://github.com/grafixeyehero)
|
||||||
|
- [h1nk](https://github.com/h1nk)
|
||||||
|
- [hawken93](https://github.com/hawken93)
|
||||||
|
- [HelloWorld017](https://github.com/HelloWorld017)
|
||||||
|
- [ikomhoog](https://github.com/ikomhoog)
|
||||||
|
- [iwalton3](https://github.com/iwalton3)
|
||||||
|
- [jftuga](https://github.com/jftuga)
|
||||||
|
- [jkhsjdhjs](https://github.com/jkhsjdhjs)
|
||||||
|
- [jmshrv](https://github.com/jmshrv)
|
||||||
|
- [joern-h](https://github.com/joern-h)
|
||||||
|
- [joshuaboniface](https://github.com/joshuaboniface)
|
||||||
|
- [JustAMan](https://github.com/JustAMan)
|
||||||
|
- [justinfenn](https://github.com/justinfenn)
|
||||||
|
- [JPVenson](https://github.com/JPVenson)
|
||||||
|
- [KerryRJ](https://github.com/KerryRJ)
|
||||||
|
- [Larvitar](https://github.com/Larvitar)
|
||||||
|
- [LeoVerto](https://github.com/LeoVerto)
|
||||||
|
- [Liggy](https://github.com/Liggy)
|
||||||
|
- [lmaonator](https://github.com/lmaonator)
|
||||||
|
- [LogicalPhallacy](https://github.com/LogicalPhallacy)
|
||||||
|
- [loli10K](https://github.com/loli10K)
|
||||||
|
- [lostmypillow](https://github.com/lostmypillow)
|
||||||
|
- [Lynxy](https://github.com/Lynxy)
|
||||||
|
- [ManfredRichthofen](https://github.com/ManfredRichthofen)
|
||||||
|
- [Marenz](https://github.com/Marenz)
|
||||||
|
- [marius-luca-87](https://github.com/marius-luca-87)
|
||||||
|
- [mark-monteiro](https://github.com/mark-monteiro)
|
||||||
|
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
|
||||||
|
- [Matt07211](https://github.com/Matt07211)
|
||||||
|
- [Maxr1998](https://github.com/Maxr1998)
|
||||||
|
- [mcarlton00](https://github.com/mcarlton00)
|
||||||
|
- [mitchfizz05](https://github.com/mitchfizz05)
|
||||||
|
- [mohd-akram](https://github.com/mohd-akram)
|
||||||
|
- [MrTimscampi](https://github.com/MrTimscampi)
|
||||||
|
- [n8225](https://github.com/n8225)
|
||||||
|
- [Nalsai](https://github.com/Nalsai)
|
||||||
|
- [Narfinger](https://github.com/Narfinger)
|
||||||
|
- [NathanPickard](https://github.com/NathanPickard)
|
||||||
|
- [neilsb](https://github.com/neilsb)
|
||||||
|
- [nevado](https://github.com/nevado)
|
||||||
|
- [Nickbert7](https://github.com/Nickbert7)
|
||||||
|
- [nicknsy](https://github.com/nicknsy)
|
||||||
|
- [nvllsvm](https://github.com/nvllsvm)
|
||||||
|
- [nyanmisaka](https://github.com/nyanmisaka)
|
||||||
|
- [OancaAndrei](https://github.com/OancaAndrei)
|
||||||
|
- [obradovichv](https://github.com/obradovichv)
|
||||||
|
- [oddstr13](https://github.com/oddstr13)
|
||||||
|
- [orryverducci](https://github.com/orryverducci)
|
||||||
|
- [petermcneil](https://github.com/petermcneil)
|
||||||
|
- [Phlogi](https://github.com/Phlogi)
|
||||||
|
- [pjeanjean](https://github.com/pjeanjean)
|
||||||
|
- [ploughpuff](https://github.com/ploughpuff)
|
||||||
|
- [pR0Ps](https://github.com/pR0Ps)
|
||||||
|
- [PrplHaz4](https://github.com/PrplHaz4)
|
||||||
|
- [RazeLighter777](https://github.com/RazeLighter777)
|
||||||
|
- [redSpoutnik](https://github.com/redSpoutnik)
|
||||||
|
- [ringmatter](https://github.com/ringmatter)
|
||||||
|
- [ryan-hartzell](https://github.com/ryan-hartzell)
|
||||||
|
- [s0urcelab](https://github.com/s0urcelab)
|
||||||
|
- [sachk](https://github.com/sachk)
|
||||||
|
- [sammyrc34](https://github.com/sammyrc34)
|
||||||
|
- [samuel9554](https://github.com/samuel9554)
|
||||||
|
- [SapientGuardian](https://github.com/SapientGuardian)
|
||||||
|
- [scheidleon](https://github.com/scheidleon)
|
||||||
|
- [sebPomme](https://github.com/sebPomme)
|
||||||
|
- [SegiH](https://github.com/SegiH)
|
||||||
|
- [SenorSmartyPants](https://github.com/SenorSmartyPants)
|
||||||
|
- [shemanaev](https://github.com/shemanaev)
|
||||||
|
- [skaro13](https://github.com/skaro13)
|
||||||
|
- [sl1288](https://github.com/sl1288)
|
||||||
|
- [Smith00101010](https://github.com/Smith00101010)
|
||||||
|
- [sorinyo2004](https://github.com/sorinyo2004)
|
||||||
|
- [sparky8251](https://github.com/sparky8251)
|
||||||
|
- [spookbits](https://github.com/spookbits)
|
||||||
|
- [ssenart](https://github.com/ssenart)
|
||||||
|
- [stanionascu](https://github.com/stanionascu)
|
||||||
|
- [stevehayles](https://github.com/stevehayles)
|
||||||
|
- [StollD](https://github.com/StollD)
|
||||||
|
- [SuperSandro2000](https://github.com/SuperSandro2000)
|
||||||
|
- [tbraeutigam](https://github.com/tbraeutigam)
|
||||||
|
- [teacupx](https://github.com/teacupx)
|
||||||
|
- [TelepathicWalrus](https://github.com/TelepathicWalrus)
|
||||||
|
- [Terror-Gene](https://github.com/Terror-Gene)
|
||||||
|
- [ThatNerdyPikachu](https://github.com/ThatNerdyPikachu)
|
||||||
|
- [ThibaultNocchi](https://github.com/ThibaultNocchi)
|
||||||
|
- [thornbill](https://github.com/thornbill)
|
||||||
|
- [ThreeFive-O](https://github.com/ThreeFive-O)
|
||||||
|
- [tjwalkr3](https://github.com/tjwalkr3)
|
||||||
|
- [TrisMcC](https://github.com/TrisMcC)
|
||||||
|
- [trumblejoe](https://github.com/trumblejoe)
|
||||||
|
- [TtheCreator](https://github.com/TtheCreator)
|
||||||
|
- [twinkybot](https://github.com/twinkybot)
|
||||||
|
- [Ullmie02](https://github.com/Ullmie02)
|
||||||
|
- [Unhelpful](https://github.com/Unhelpful)
|
||||||
|
- [viaregio](https://github.com/viaregio)
|
||||||
|
- [vitorsemeano](https://github.com/vitorsemeano)
|
||||||
|
- [voodoos](https://github.com/voodoos)
|
||||||
|
- [whooo](https://github.com/whooo)
|
||||||
|
- [WiiPlayer2](https://github.com/WiiPlayer2)
|
||||||
|
- [WillWill56](https://github.com/WillWill56)
|
||||||
|
- [wtayl0r](https://github.com/wtayl0r)
|
||||||
|
- [Wuerfelbecher](https://github.com/Wuerfelbecher)
|
||||||
|
- [Wunax](https://github.com/Wunax)
|
||||||
|
- [WWWesten](https://github.com/WWWesten)
|
||||||
|
- [WX9yMOXWId](https://github.com/WX9yMOXWId)
|
||||||
|
- [xosdy](https://github.com/xosdy)
|
||||||
|
- [XVicarious](https://github.com/XVicarious)
|
||||||
|
- [YouKnowBlom](https://github.com/YouKnowBlom)
|
||||||
|
- [ZachPhelan](https://github.com/ZachPhelan)
|
||||||
|
- [KristupasSavickas](https://github.com/KristupasSavickas)
|
||||||
|
- [Pusta](https://github.com/pusta)
|
||||||
|
- [nielsvanvelzen](https://github.com/nielsvanvelzen)
|
||||||
|
- [skyfrk](https://github.com/skyfrk)
|
||||||
|
- [ianjazz246](https://github.com/ianjazz246)
|
||||||
|
- [peterspenler](https://github.com/peterspenler)
|
||||||
|
- [MBR-0001](https://github.com/MBR-0001)
|
||||||
|
- [jonas-resch](https://github.com/jonas-resch)
|
||||||
|
- [vgambier](https://github.com/vgambier)
|
||||||
|
- [MinecraftPlaye](https://github.com/MinecraftPlaye)
|
||||||
|
- [RealGreenDragon](https://github.com/RealGreenDragon)
|
||||||
|
- [ipitio](https://github.com/ipitio)
|
||||||
|
- [TheTyrius](https://github.com/TheTyrius)
|
||||||
|
- [tallbl0nde](https://github.com/tallbl0nde)
|
||||||
|
- [sleepycatcoding](https://github.com/sleepycatcoding)
|
||||||
|
- [scampower3](https://github.com/scampower3)
|
||||||
|
- [Chris-Codes-It](https://github.com/Chris-Codes-It)
|
||||||
|
- [Pithaya](https://github.com/Pithaya)
|
||||||
|
- [Çağrı Sakaoğlu](https://github.com/ilovepilav)
|
||||||
|
- [Barasingha](https://github.com/MaVdbussche)
|
||||||
|
- [Gauvino](https://github.com/Gauvino)
|
||||||
|
- [felix920506](https://github.com/felix920506)
|
||||||
|
- [btopherjohnson](https://github.com/btopherjohnson)
|
||||||
|
- [GeorgeH005](https://github.com/GeorgeH005)
|
||||||
|
- [Vedant](https://github.com/viktory36/)
|
||||||
|
- [NotSaifA](https://github.com/NotSaifA)
|
||||||
|
- [HonestlyWhoKnows](https://github.com/honestlywhoknows)
|
||||||
|
- [TheMelmacian](https://github.com/TheMelmacian)
|
||||||
|
- [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode)
|
||||||
|
- [pret0rian8](https://github.com/pret0rian)
|
||||||
|
- [jaina heartles](https://github.com/heartles)
|
||||||
|
- [oxixes](https://github.com/oxixes)
|
||||||
|
- [elfalem](https://github.com/elfalem)
|
||||||
|
- [Kenneth Cochran](https://github.com/kennethcochran)
|
||||||
|
- [benedikt257](https://github.com/benedikt257)
|
||||||
|
- [revam](https://github.com/revam)
|
||||||
|
- [allesmi](https://github.com/allesmi)
|
||||||
|
- [ThunderClapLP](https://github.com/ThunderClapLP)
|
||||||
|
- [Shoham Peller](https://github.com/spellr)
|
||||||
|
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||||
|
- [TokerX](https://github.com/TokerX)
|
||||||
|
- [GeneMarks](https://github.com/GeneMarks)
|
||||||
|
- [Kirill Nikiforov](https://github.com/allmazz)
|
||||||
|
- [bjorntp](https://github.com/bjorntp)
|
||||||
|
- [martenumberto](https://github.com/martenumberto)
|
||||||
|
- [ZeusCraft10](https://github.com/ZeusCraft10)
|
||||||
|
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
|
||||||
|
|
||||||
|
# Emby Contributors
|
||||||
|
|
||||||
|
- [LukePulverenti](https://github.com/LukePulverenti)
|
||||||
|
- [ebr11](https://github.com/ebr11)
|
||||||
|
- [lalmanzar](https://github.com/lalmanzar)
|
||||||
|
- [schneifu](https://github.com/schneifu)
|
||||||
|
- [Mark2xv](https://github.com/Mark2xv)
|
||||||
|
- [ScottRapsey](https://github.com/ScottRapsey)
|
||||||
|
- [skynet600](https://github.com/skynet600)
|
||||||
|
- [Cheesegeezer](https://githum.com/Cheesegeezer)
|
||||||
|
- [Radeon](https://github.com/radeonorama)
|
||||||
|
- [gcw07](https://github.com/gcw07)
|
||||||
|
- [SivaramAdhiappan](https://github.com/shivaram1190)
|
||||||
|
- [CWatkinsNash](https://github.com/CWatkinsNash)
|
||||||
|
- [sfnetwork](https://github.com/sfnetwork)
|
||||||
|
- [Logos302](https://github.com/Logos302)
|
||||||
|
- [TheWorkz](https://github.com/TheWorkz)
|
||||||
|
- [mboehler](https://github.com/mboehler)
|
||||||
|
- [KaHooli](https://github.com/KaHooli)
|
||||||
|
- [xzener](https://github.com/xzener)
|
||||||
|
- [CBers](https://github.com/CBers)
|
||||||
|
- [Sagaia](https://github.com/Sagaia)
|
||||||
|
- [JHawk111](https://github.com/JHawk111)
|
||||||
|
- [David3663](https://github.com/david3663)
|
||||||
|
- [Smyken](https://github.com/Smyken)
|
||||||
|
- [doron1](https://github.com/doron1)
|
||||||
|
- [brainfryd](https://github.com/brainfryd)
|
||||||
|
- [DGMayor](http://github.com/DGMayor)
|
||||||
|
- [Jon-theHTPC](https://github.com/Jon-theHTPC)
|
||||||
|
- [aspdend](https://github.com/aspdend)
|
||||||
|
- [RedshirtMB](https://github.com/RedshirtMB)
|
||||||
|
- [thealienamongus](https://github.com/thealienamongus)
|
||||||
|
- [brocass](https://github.com/brocass)
|
||||||
|
- [pjrollo2000](https://github.com/pjrollo2000)
|
||||||
|
- [abobader](https://github.com/abobader)
|
||||||
|
- [milli260876](https://github.com/milli260876)
|
||||||
|
- [vileboy](https://github.com/vileboy)
|
||||||
|
- [starkadius](https://github.com/starkadius)
|
||||||
|
- [wraslor](https://github.com/wraslor)
|
||||||
|
- [mrwebsmith](https://github.com/mrwebsmith)
|
||||||
|
- [rickster53](https://github.com/rickster53)
|
||||||
|
- [Tharnax](https://github.com/Tharnax)
|
||||||
|
- [0sm0](https://github.com/0sm0)
|
||||||
|
- [swhitmore](https://github.com/swhitmore)
|
||||||
|
- [DigiTM](https://github.com/DigiTM)
|
||||||
|
- [crisliv / xliv](https://github.com/crisliv)
|
||||||
|
- [Yogi](https://github.com/yogi12)
|
||||||
|
- [madFloyd](https://github.com/madFloyd)
|
||||||
|
- [yardameus](https://github.com/yardameus)
|
||||||
|
- [rrb008](https://github.com/rrb008)
|
||||||
|
- [Toonguy](https://github.com/Toonguy)
|
||||||
|
- [Alwin Hummels](https://github.com/AlwinHummels)
|
||||||
|
- [trooper11](https://github.com/trooper11)
|
||||||
|
- [danlotfy](https://github.com/danlotfy)
|
||||||
|
- [jordy1955](https://github.com/jordy1955)
|
||||||
|
- [JoshFink](https://github.com/JoshFink)
|
||||||
|
- [Detector1](https://github.com/Detector1)
|
||||||
|
- [BlackIce013](https://github.com/blackice013)
|
||||||
|
- [mporcas](https://github.com/mporcas)
|
||||||
|
- [tikuf](https://github.com/tikuf/)
|
||||||
|
- [Tim Hobbs](https://github.com/timhobbs)
|
||||||
|
- [SvenVandenbrande](https://github.com/SvenVandenbrande)
|
||||||
|
- [olsh](https://github.com/olsh)
|
||||||
|
- [lbenini](https://github.com/lbenini)
|
||||||
|
- [gnuyent](https://github.com/gnuyent)
|
||||||
|
- [Matthew Jones](https://github.com/matthew-jones-uk)
|
||||||
|
- [Jakob Kukla](https://github.com/jakobkukla)
|
||||||
|
- [Utku Özdemir](https://github.com/utkuozdemir)
|
||||||
|
- [JPUC1143](https://github.com/Jpuc1143/)
|
||||||
|
- [0x25CBFC4F](https://github.com/0x25CBFC4F)
|
||||||
|
- [Robert Lützner](https://github.com/rluetzner)
|
||||||
|
- [Nathan McCrina](https://github.com/nfmccrina)
|
||||||
|
- [Martin Reuter](https://github.com/reuterma24)
|
||||||
|
- [Michael McElroy](https://github.com/mcmcelro)
|
||||||
|
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
|
||||||
|
- [DerMaddis](https://github.com/dermaddis)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project>
|
||||||
|
<!-- Sets defaults for all projects in the repo -->
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<WarningsNotAsErrors>NU1902;NU1903</WarningsNotAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AdditionalFiles Include="$(MSBuildThisFileDirectory)/BannedSymbols.txt" />
|
||||||
|
<AdditionalFiles Include="$(MSBuildThisFileDirectory)/stylecop.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Custom Analyzers -->
|
||||||
|
<ItemGroup Condition=" '$(MSBuildProjectName)' != 'Jellyfin.CodeAnalysis' AND '$(Configuration)' == 'Debug' ">
|
||||||
|
<ProjectReference Include="$(MSBuildThisFileDirectory)src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj" OutputItemType="Analyzer" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||||
|
<ItemGroup Label="Package Dependencies">
|
||||||
|
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
|
||||||
|
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||||
|
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||||
|
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||||
|
<PackageVersion Include="BDInfo" Version="0.8.0" />
|
||||||
|
<PackageVersion Include="BitFaster.Caching" Version="2.5.4" />
|
||||||
|
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
|
||||||
|
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
||||||
|
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
|
||||||
|
<PackageVersion Include="Diacritics" Version="4.1.4" />
|
||||||
|
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||||
|
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||||
|
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
|
||||||
|
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
||||||
|
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||||
|
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||||
|
<PackageVersion Include="Ignore" Version="0.2.1" />
|
||||||
|
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
|
||||||
|
<PackageVersion Include="libse" Version="4.0.12" />
|
||||||
|
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||||
|
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||||
|
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
|
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||||
|
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||||
|
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||||
|
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
||||||
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||||
|
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
|
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||||
|
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||||
|
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||||
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||||
|
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||||
|
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
|
||||||
|
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
|
||||||
|
<PackageVersion Include="SkiaSharp" Version="[3.116.1]" />
|
||||||
|
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="[3.116.1]" />
|
||||||
|
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="[3.116.1]" />
|
||||||
|
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||||
|
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||||
|
<PackageVersion Include="Svg.Skia" Version="3.4.1" />
|
||||||
|
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.9.0" />
|
||||||
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.3.2" />
|
||||||
|
<PackageVersion Include="System.Text.Json" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||||
|
<PackageVersion Include="z440.atl.core" Version="7.11.0" />
|
||||||
|
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
||||||
|
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||||
|
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class to determine if Album is multipart.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AlbumParser
|
||||||
|
{
|
||||||
|
private readonly NamingOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AlbumParser"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Naming options containing AlbumStackingPrefixes.</param>
|
||||||
|
public AlbumParser(NamingOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[-\.\(\)\s]+")]
|
||||||
|
private static partial Regex CleanRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Function that determines if album is multipart.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to file.</param>
|
||||||
|
/// <returns>True if album is multipart.</returns>
|
||||||
|
public bool IsMultiPart(string path)
|
||||||
|
{
|
||||||
|
var filename = Path.GetFileName(path);
|
||||||
|
if (filename.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move this logic into options object
|
||||||
|
// Even better, remove the prefixes and come up with regexes
|
||||||
|
// But Kodi documentation seems to be weak for audio
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
// Remove whitespace
|
||||||
|
filename = CleanRegex().Replace(filename, " ");
|
||||||
|
|
||||||
|
ReadOnlySpan<char> trimmedFilename = filename.AsSpan().TrimStart();
|
||||||
|
|
||||||
|
foreach (var prefix in _options.AlbumStackingPrefixes)
|
||||||
|
{
|
||||||
|
if (!trimmedFilename.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmp = trimmedFilename.Slice(prefix.Length).Trim();
|
||||||
|
|
||||||
|
if (int.TryParse(tmp.LeftPart(' '), CultureInfo.InvariantCulture, out _))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Static helper class to determine if file at path is audio file.
|
||||||
|
/// </summary>
|
||||||
|
public static class AudioFileParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Static helper method to determine if file at path is audio file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to file.</param>
|
||||||
|
/// <param name="options"><see cref="NamingOptions"/> containing AudioFileExtensions.</param>
|
||||||
|
/// <returns>True if file at path is audio file.</returns>
|
||||||
|
public static bool IsAudioFile(string path, NamingOptions options)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(path.AsSpan());
|
||||||
|
return options.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Emby.Naming.AudioBook
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single video file.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioBookFileInfo : IComparable<AudioBookFileInfo>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AudioBookFileInfo"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to audiobook file.</param>
|
||||||
|
/// <param name="container">File type.</param>
|
||||||
|
/// <param name="partNumber">Number of part this file represents.</param>
|
||||||
|
/// <param name="chapterNumber">Number of chapter this file represents.</param>
|
||||||
|
public AudioBookFileInfo(string path, string container, int? partNumber = default, int? chapterNumber = default)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
Container = container;
|
||||||
|
PartNumber = partNumber;
|
||||||
|
ChapterNumber = chapterNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the path.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The path.</value>
|
||||||
|
public string Path { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the container.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The container.</value>
|
||||||
|
public string Container { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the part number.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The part number.</value>
|
||||||
|
public int? PartNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the chapter number.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The chapter number.</value>
|
||||||
|
public int? ChapterNumber { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int CompareTo(AudioBookFileInfo? other)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(this, other))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReferenceEquals(null, other))
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chapterNumberComparison = Nullable.Compare(ChapterNumber, other.ChapterNumber);
|
||||||
|
if (chapterNumberComparison != 0)
|
||||||
|
{
|
||||||
|
return chapterNumberComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
var partNumberComparison = Nullable.Compare(PartNumber, other.PartNumber);
|
||||||
|
if (partNumberComparison != 0)
|
||||||
|
{
|
||||||
|
return partNumberComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Compare(Path, other.Path, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
|
||||||
|
namespace Emby.Naming.AudioBook
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parser class to extract part and/or chapter number from audiobook filename.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioBookFilePathParser
|
||||||
|
{
|
||||||
|
private readonly NamingOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AudioBookFilePathParser"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Naming options containing AudioBookPartsExpressions.</param>
|
||||||
|
public AudioBookFilePathParser(NamingOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Based on regex determines if filename includes part/chapter number.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to audiobook file.</param>
|
||||||
|
/// <returns>Returns <see cref="AudioBookFilePathParser"/> object.</returns>
|
||||||
|
public AudioBookFilePathParserResult Parse(string path)
|
||||||
|
{
|
||||||
|
AudioBookFilePathParserResult result = default;
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(path);
|
||||||
|
foreach (var expression in _options.AudioBookPartsExpressions)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(fileName, expression, RegexOptions.IgnoreCase);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
if (!result.ChapterNumber.HasValue)
|
||||||
|
{
|
||||||
|
var value = match.Groups["chapter"];
|
||||||
|
if (value.Success)
|
||||||
|
{
|
||||||
|
if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
|
||||||
|
{
|
||||||
|
result.ChapterNumber = intValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.PartNumber.HasValue)
|
||||||
|
{
|
||||||
|
var value = match.Groups["part"];
|
||||||
|
if (value.Success)
|
||||||
|
{
|
||||||
|
if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
|
||||||
|
{
|
||||||
|
result.PartNumber = intValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Emby.Naming.AudioBook
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data object for passing result of audiobook part/chapter extraction.
|
||||||
|
/// </summary>
|
||||||
|
public record struct AudioBookFilePathParserResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional number of path extracted from audiobook filename.
|
||||||
|
/// </summary>
|
||||||
|
public int? PartNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional number of chapter extracted from audiobook filename.
|
||||||
|
/// </summary>
|
||||||
|
public int? ChapterNumber { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Emby.Naming.AudioBook
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a complete video, including all parts and subtitles.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioBookInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AudioBookInfo" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Name of audiobook.</param>
|
||||||
|
/// <param name="year">Year of audiobook release.</param>
|
||||||
|
/// <param name="files">List of files composing the actual audiobook.</param>
|
||||||
|
/// <param name="extras">List of extra files.</param>
|
||||||
|
/// <param name="alternateVersions">Alternative version of files.</param>
|
||||||
|
public AudioBookInfo(string name, int? year, IReadOnlyList<AudioBookFileInfo> files, IReadOnlyList<AudioBookFileInfo> extras, IReadOnlyList<AudioBookFileInfo> alternateVersions)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Year = year;
|
||||||
|
Files = files;
|
||||||
|
Extras = extras;
|
||||||
|
AlternateVersions = alternateVersions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The name.</value>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the year.
|
||||||
|
/// </summary>
|
||||||
|
public int? Year { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the files.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The files.</value>
|
||||||
|
public IReadOnlyList<AudioBookFileInfo> Files { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the extras.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The extras.</value>
|
||||||
|
public IReadOnlyList<AudioBookFileInfo> Extras { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the alternate versions.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The alternate versions.</value>
|
||||||
|
public IReadOnlyList<AudioBookFileInfo> AlternateVersions { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using Emby.Naming.Video;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
|
||||||
|
namespace Emby.Naming.AudioBook
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Class used to resolve Name, Year, alternative files and extras from stack of files.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioBookListResolver
|
||||||
|
{
|
||||||
|
private readonly NamingOptions _options;
|
||||||
|
private readonly AudioBookResolver _audioBookResolver;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AudioBookListResolver"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Naming options passed along to <see cref="AudioBookResolver"/> and <see cref="AudioBookNameParser"/>.</param>
|
||||||
|
public AudioBookListResolver(NamingOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_audioBookResolver = new AudioBookResolver(_options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves Name, Year and differentiate alternative files and extras from regular audiobook files.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="files">List of files related to audiobook.</param>
|
||||||
|
/// <returns>Returns IEnumerable of <see cref="AudioBookInfo"/>.</returns>
|
||||||
|
public IEnumerable<AudioBookInfo> Resolve(IEnumerable<FileSystemMetadata> files)
|
||||||
|
{
|
||||||
|
// File with empty fullname will be sorted out here.
|
||||||
|
var audiobookFileInfos = files
|
||||||
|
.Select(i => _audioBookResolver.Resolve(i.FullName))
|
||||||
|
.OfType<AudioBookFileInfo>();
|
||||||
|
|
||||||
|
var stackResult = StackResolver.ResolveAudioBooks(audiobookFileInfos);
|
||||||
|
|
||||||
|
foreach (var stack in stackResult)
|
||||||
|
{
|
||||||
|
var stackFiles = stack.Files
|
||||||
|
.Select(i => _audioBookResolver.Resolve(i))
|
||||||
|
.OfType<AudioBookFileInfo>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
stackFiles.Sort();
|
||||||
|
|
||||||
|
var nameParserResult = new AudioBookNameParser(_options).Parse(stack.Name);
|
||||||
|
|
||||||
|
FindExtraAndAlternativeFiles(ref stackFiles, out var extras, out var alternativeVersions, nameParserResult);
|
||||||
|
|
||||||
|
var info = new AudioBookInfo(
|
||||||
|
nameParserResult.Name,
|
||||||
|
nameParserResult.Year,
|
||||||
|
stackFiles,
|
||||||
|
extras,
|
||||||
|
alternativeVersions);
|
||||||
|
|
||||||
|
yield return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FindExtraAndAlternativeFiles(ref List<AudioBookFileInfo> stackFiles, out List<AudioBookFileInfo> extras, out List<AudioBookFileInfo> alternativeVersions, AudioBookNameParserResult nameParserResult)
|
||||||
|
{
|
||||||
|
extras = new List<AudioBookFileInfo>();
|
||||||
|
alternativeVersions = new List<AudioBookFileInfo>();
|
||||||
|
|
||||||
|
var haveChaptersOrPages = stackFiles.Any(x => x.ChapterNumber is not null || x.PartNumber is not null);
|
||||||
|
var groupedBy = stackFiles.GroupBy(file => new { file.ChapterNumber, file.PartNumber });
|
||||||
|
var nameWithReplacedDots = nameParserResult.Name.Replace(' ', '.');
|
||||||
|
|
||||||
|
foreach (var group in groupedBy)
|
||||||
|
{
|
||||||
|
if (group.Key.ChapterNumber is null && group.Key.PartNumber is null)
|
||||||
|
{
|
||||||
|
if (group.Count() > 1 || haveChaptersOrPages)
|
||||||
|
{
|
||||||
|
List<AudioBookFileInfo>? ex = null;
|
||||||
|
List<AudioBookFileInfo>? alt = null;
|
||||||
|
|
||||||
|
foreach (var audioFile in group)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileNameWithoutExtension(audioFile.Path.AsSpan());
|
||||||
|
if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
(alt ??= new()).Add(audioFile);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
(ex ??= new()).Add(audioFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ex is not null)
|
||||||
|
{
|
||||||
|
var extra = ex
|
||||||
|
.OrderBy(x => x.Container)
|
||||||
|
.ThenBy(x => x.Path)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
stackFiles = stackFiles.Except(extra).ToList();
|
||||||
|
extras.AddRange(extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alt is not null)
|
||||||
|
{
|
||||||
|
var alternatives = alt
|
||||||
|
.OrderBy(x => x.Container)
|
||||||
|
.ThenBy(x => x.Path)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var main = FindMainAudioBookFile(alternatives, nameParserResult.Name);
|
||||||
|
alternatives.Remove(main);
|
||||||
|
stackFiles = stackFiles.Except(alternatives).ToList();
|
||||||
|
alternativeVersions.AddRange(alternatives);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (group.Count() > 1)
|
||||||
|
{
|
||||||
|
var alternatives = group
|
||||||
|
.OrderBy(x => x.Container)
|
||||||
|
.ThenBy(x => x.Path)
|
||||||
|
.Skip(1)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
stackFiles = stackFiles.Except(alternatives).ToList();
|
||||||
|
alternativeVersions.AddRange(alternatives);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AudioBookFileInfo FindMainAudioBookFile(List<AudioBookFileInfo> files, string name)
|
||||||
|
{
|
||||||
|
var main = files.Find(x => Path.GetFileNameWithoutExtension(x.Path).Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
main ??= files.FirstOrDefault(x => Path.GetFileNameWithoutExtension(x.Path).Equals("audiobook", StringComparison.OrdinalIgnoreCase));
|
||||||
|
main ??= files.OrderBy(x => x.Container)
|
||||||
|
.ThenBy(x => x.Path)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
return main;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
|
||||||
|
namespace Emby.Naming.AudioBook
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class to retrieve name and year from audiobook previously retrieved name.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioBookNameParser
|
||||||
|
{
|
||||||
|
private readonly NamingOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AudioBookNameParser"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Naming options containing AudioBookNamesExpressions.</param>
|
||||||
|
public AudioBookNameParser(NamingOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse name and year from previously determined name of audiobook.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Name of the audiobook.</param>
|
||||||
|
/// <returns>Returns <see cref="AudioBookNameParserResult"/> object.</returns>
|
||||||
|
public AudioBookNameParserResult Parse(string name)
|
||||||
|
{
|
||||||
|
AudioBookNameParserResult result = default;
|
||||||
|
foreach (var expression in _options.AudioBookNamesExpressions)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(name, expression, RegexOptions.IgnoreCase);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
if (result.Name is null)
|
||||||
|
{
|
||||||
|
var value = match.Groups["name"];
|
||||||
|
if (value.Success)
|
||||||
|
{
|
||||||
|
result.Name = value.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.Year.HasValue)
|
||||||
|
{
|
||||||
|
var value = match.Groups["year"];
|
||||||
|
if (value.Success)
|
||||||
|
{
|
||||||
|
if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
|
||||||
|
{
|
||||||
|
result.Year = intValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(result.Name))
|
||||||
|
{
|
||||||
|
result.Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Emby.Naming.AudioBook
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data object used to pass result of name and year parsing.
|
||||||
|
/// </summary>
|
||||||
|
public struct AudioBookNameParserResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets name of audiobook.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional year of release.
|
||||||
|
/// </summary>
|
||||||
|
public int? Year { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.AudioBook
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve specifics (path, container, partNumber, chapterNumber) about audiobook file.
|
||||||
|
/// </summary>
|
||||||
|
public class AudioBookResolver
|
||||||
|
{
|
||||||
|
private readonly NamingOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AudioBookResolver"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options"><see cref="NamingOptions"/> containing AudioFileExtensions and also used to pass to AudioBookFilePathParser.</param>
|
||||||
|
public AudioBookResolver(NamingOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve specifics (path, container, partNumber, chapterNumber) about audiobook file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to audiobook file.</param>
|
||||||
|
/// <returns>Returns <see cref="AudioBookResolver"/> object.</returns>
|
||||||
|
public AudioBookFileInfo? Resolve(string path)
|
||||||
|
{
|
||||||
|
if (path.Length == 0 || Path.GetFileNameWithoutExtension(path).Length == 0)
|
||||||
|
{
|
||||||
|
// Return null to indicate this path will not be used, instead of stopping whole process with exception
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(path);
|
||||||
|
|
||||||
|
// Check supported extensions
|
||||||
|
if (!_options.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = extension.TrimStart('.');
|
||||||
|
|
||||||
|
var parsingResult = new AudioBookFilePathParser(_options).Parse(path);
|
||||||
|
|
||||||
|
return new AudioBookFileInfo(
|
||||||
|
path,
|
||||||
|
container,
|
||||||
|
chapterNumber: parsingResult.ChapterNumber,
|
||||||
|
partNumber: parsingResult.PartNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Book
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class to retrieve basic metadata from a book filename.
|
||||||
|
/// </summary>
|
||||||
|
public static class BookFileNameParser
|
||||||
|
{
|
||||||
|
private const string NameMatchGroup = "name";
|
||||||
|
private const string IndexMatchGroup = "index";
|
||||||
|
private const string YearMatchGroup = "year";
|
||||||
|
private const string SeriesNameMatchGroup = "seriesName";
|
||||||
|
|
||||||
|
private static readonly Regex[] _nameMatches =
|
||||||
|
[
|
||||||
|
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required
|
||||||
|
new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||||
|
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||||
|
new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||||
|
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
|
||||||
|
// last resort matches the whole string as the name
|
||||||
|
new Regex(@"(?<name>.*)")
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a filename name to retrieve the book name, series name, index, and year.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Book filename to parse for information.</param>
|
||||||
|
/// <returns>Returns <see cref="BookFileNameParserResult"/> object.</returns>
|
||||||
|
public static BookFileNameParserResult Parse(string? name)
|
||||||
|
{
|
||||||
|
var result = new BookFileNameParserResult();
|
||||||
|
|
||||||
|
if (name == null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var regex in _nameMatches)
|
||||||
|
{
|
||||||
|
var match = regex.Match(name);
|
||||||
|
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
|
||||||
|
{
|
||||||
|
result.Name = nameGroup.Value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index))
|
||||||
|
{
|
||||||
|
result.Index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups.TryGetValue(YearMatchGroup, out Group? yearGroup) && yearGroup.Success && int.TryParse(yearGroup.Value, out var year))
|
||||||
|
{
|
||||||
|
result.Year = year;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups.TryGetValue(SeriesNameMatchGroup, out Group? seriesGroup) && seriesGroup.Success)
|
||||||
|
{
|
||||||
|
result.SeriesName = seriesGroup.Value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Book
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data object used to pass metadata parsed from a book filename.
|
||||||
|
/// </summary>
|
||||||
|
public class BookFileNameParserResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="BookFileNameParserResult"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public BookFileNameParserResult()
|
||||||
|
{
|
||||||
|
Name = null;
|
||||||
|
Index = null;
|
||||||
|
Year = null;
|
||||||
|
SeriesName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the book.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the book index.
|
||||||
|
/// </summary>
|
||||||
|
public int? Index { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the publication year.
|
||||||
|
/// </summary>
|
||||||
|
public int? Year { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the series name.
|
||||||
|
/// </summary>
|
||||||
|
public string? SeriesName { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Common
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Regular expressions for parsing TV Episodes.
|
||||||
|
/// </summary>
|
||||||
|
public class EpisodeExpression
|
||||||
|
{
|
||||||
|
private string _expression;
|
||||||
|
private Regex? _regex;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="EpisodeExpression"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="expression">Regular expressions.</param>
|
||||||
|
/// <param name="byDate">True if date is expected.</param>
|
||||||
|
public EpisodeExpression(string expression, bool byDate = false)
|
||||||
|
{
|
||||||
|
_expression = expression;
|
||||||
|
IsByDate = byDate;
|
||||||
|
DateTimeFormats = Array.Empty<string>();
|
||||||
|
SupportsAbsoluteEpisodeNumbers = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets raw expressions string.
|
||||||
|
/// </summary>
|
||||||
|
public string Expression
|
||||||
|
{
|
||||||
|
get => _expression;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_expression = value;
|
||||||
|
_regex = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether gets or sets property indicating if date can be find in expression.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsByDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether gets or sets property indicating if expression is optimistic.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsOptimistic { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether gets or sets property indicating if expression is named.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsNamed { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether gets or sets property indicating if expression supports episodes with absolute numbers.
|
||||||
|
/// </summary>
|
||||||
|
public bool SupportsAbsoluteEpisodeNumbers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional list of date formats used for date parsing.
|
||||||
|
/// </summary>
|
||||||
|
public string[] DateTimeFormats { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a <see cref="Regex"/> expressions objects (creates it if null).
|
||||||
|
/// </summary>
|
||||||
|
public Regex Regex => _regex ??= new Regex(Expression, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Emby.Naming.Common
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Type of audiovisual media.
|
||||||
|
/// </summary>
|
||||||
|
public enum MediaType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The audio.
|
||||||
|
/// </summary>
|
||||||
|
Audio = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The photo.
|
||||||
|
/// </summary>
|
||||||
|
Photo = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The video.
|
||||||
|
/// </summary>
|
||||||
|
Video = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
#pragma warning disable CA1819
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Emby.Naming.Video;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
|
// ReSharper disable StringLiteralTypo
|
||||||
|
|
||||||
|
namespace Emby.Naming.Common
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Big ugly class containing lot of different naming options that should be split and injected instead of passes everywhere.
|
||||||
|
/// </summary>
|
||||||
|
public class NamingOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="NamingOptions"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public NamingOptions()
|
||||||
|
{
|
||||||
|
VideoFileExtensions =
|
||||||
|
[
|
||||||
|
".001",
|
||||||
|
".3g2",
|
||||||
|
".3gp",
|
||||||
|
".amv",
|
||||||
|
".asf",
|
||||||
|
".asx",
|
||||||
|
".avi",
|
||||||
|
".bin",
|
||||||
|
".bivx",
|
||||||
|
".divx",
|
||||||
|
".dv",
|
||||||
|
".dvr-ms",
|
||||||
|
".f4v",
|
||||||
|
".fli",
|
||||||
|
".flv",
|
||||||
|
".ifo",
|
||||||
|
".img",
|
||||||
|
".iso",
|
||||||
|
".m2t",
|
||||||
|
".m2ts",
|
||||||
|
".m2v",
|
||||||
|
".m4v",
|
||||||
|
".mkv",
|
||||||
|
".mk3d",
|
||||||
|
".mov",
|
||||||
|
".mp4",
|
||||||
|
".mpe",
|
||||||
|
".mpeg",
|
||||||
|
".mpg",
|
||||||
|
".mts",
|
||||||
|
".mxf",
|
||||||
|
".nrg",
|
||||||
|
".nsv",
|
||||||
|
".nuv",
|
||||||
|
".ogg",
|
||||||
|
".ogm",
|
||||||
|
".ogv",
|
||||||
|
".pva",
|
||||||
|
".qt",
|
||||||
|
".rec",
|
||||||
|
".rm",
|
||||||
|
".rmvb",
|
||||||
|
".strm",
|
||||||
|
".svq3",
|
||||||
|
".tp",
|
||||||
|
".ts",
|
||||||
|
".ty",
|
||||||
|
".viv",
|
||||||
|
".vob",
|
||||||
|
".vp3",
|
||||||
|
".webm",
|
||||||
|
".wmv",
|
||||||
|
".wtv",
|
||||||
|
".xvid"
|
||||||
|
];
|
||||||
|
|
||||||
|
VideoFlagDelimiters =
|
||||||
|
[
|
||||||
|
'(',
|
||||||
|
')',
|
||||||
|
'-',
|
||||||
|
'.',
|
||||||
|
'_',
|
||||||
|
'[',
|
||||||
|
']'
|
||||||
|
];
|
||||||
|
|
||||||
|
StubFileExtensions =
|
||||||
|
[
|
||||||
|
".disc"
|
||||||
|
];
|
||||||
|
|
||||||
|
StubTypes =
|
||||||
|
[
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "dvd",
|
||||||
|
token: "dvd"),
|
||||||
|
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "hddvd",
|
||||||
|
token: "hddvd"),
|
||||||
|
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "bluray",
|
||||||
|
token: "bluray"),
|
||||||
|
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "bluray",
|
||||||
|
token: "brrip"),
|
||||||
|
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "bluray",
|
||||||
|
token: "bd25"),
|
||||||
|
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "bluray",
|
||||||
|
token: "bd50"),
|
||||||
|
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "vhs",
|
||||||
|
token: "vhs"),
|
||||||
|
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "tv",
|
||||||
|
token: "HDTV"),
|
||||||
|
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "tv",
|
||||||
|
token: "PDTV"),
|
||||||
|
|
||||||
|
new StubTypeRule(
|
||||||
|
stubType: "tv",
|
||||||
|
token: "DSR")
|
||||||
|
];
|
||||||
|
|
||||||
|
VideoFileStackingRules =
|
||||||
|
[
|
||||||
|
new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[0-9]+)[\)\]]?(?:\.[^.]+)?$", true),
|
||||||
|
new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[a-d])[\)\]]?(?:\.[^.]+)?$", false)
|
||||||
|
];
|
||||||
|
|
||||||
|
CleanDateTimes =
|
||||||
|
[
|
||||||
|
@"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*",
|
||||||
|
@"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*"
|
||||||
|
];
|
||||||
|
|
||||||
|
CleanStrings =
|
||||||
|
[
|
||||||
|
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
|
||||||
|
@"^(?<cleaned>.+?)(\[.*\])",
|
||||||
|
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
|
||||||
|
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
|
||||||
|
@"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$",
|
||||||
|
@"^\s*(?<cleaned>.+?)(([-._ ](trailer|sample))|-(scene|clip|behindthescenes|deleted|deletedscene|featurette|short|interview|other|extra))$"
|
||||||
|
];
|
||||||
|
|
||||||
|
SubtitleFileExtensions =
|
||||||
|
[
|
||||||
|
".ass",
|
||||||
|
".mks",
|
||||||
|
".sami",
|
||||||
|
".smi",
|
||||||
|
".srt",
|
||||||
|
".ssa",
|
||||||
|
".sub",
|
||||||
|
".sup",
|
||||||
|
".vtt",
|
||||||
|
];
|
||||||
|
|
||||||
|
LyricFileExtensions =
|
||||||
|
[
|
||||||
|
".lrc",
|
||||||
|
".elrc",
|
||||||
|
".txt"
|
||||||
|
];
|
||||||
|
|
||||||
|
AlbumStackingPrefixes =
|
||||||
|
[
|
||||||
|
"cd",
|
||||||
|
"digital media",
|
||||||
|
"disc",
|
||||||
|
"disk",
|
||||||
|
"vol",
|
||||||
|
"volume",
|
||||||
|
"part",
|
||||||
|
"act"
|
||||||
|
];
|
||||||
|
|
||||||
|
ArtistSubfolders =
|
||||||
|
[
|
||||||
|
"albums",
|
||||||
|
"broadcasts",
|
||||||
|
"bootlegs",
|
||||||
|
"compilations",
|
||||||
|
"dj-mixes",
|
||||||
|
"eps",
|
||||||
|
"live",
|
||||||
|
"mixtapes",
|
||||||
|
"others",
|
||||||
|
"remixes",
|
||||||
|
"singles",
|
||||||
|
"soundtracks",
|
||||||
|
"spokenwords",
|
||||||
|
"streets"
|
||||||
|
];
|
||||||
|
|
||||||
|
AudioFileExtensions =
|
||||||
|
[
|
||||||
|
".669",
|
||||||
|
".3gp",
|
||||||
|
".aa",
|
||||||
|
".aac",
|
||||||
|
".aax",
|
||||||
|
".ac3",
|
||||||
|
".act",
|
||||||
|
".adp",
|
||||||
|
".adplug",
|
||||||
|
".adx",
|
||||||
|
".afc",
|
||||||
|
".amf",
|
||||||
|
".aif",
|
||||||
|
".aiff",
|
||||||
|
".alac",
|
||||||
|
".amr",
|
||||||
|
".ape",
|
||||||
|
".ast",
|
||||||
|
".au",
|
||||||
|
".awb",
|
||||||
|
".cda",
|
||||||
|
".cue",
|
||||||
|
".dmf",
|
||||||
|
".dsf",
|
||||||
|
".dsm",
|
||||||
|
".dsp",
|
||||||
|
".dts",
|
||||||
|
".dvf",
|
||||||
|
".eac3",
|
||||||
|
".ec3",
|
||||||
|
".far",
|
||||||
|
".flac",
|
||||||
|
".gdm",
|
||||||
|
".gsm",
|
||||||
|
".gym",
|
||||||
|
".hps",
|
||||||
|
".imf",
|
||||||
|
".it",
|
||||||
|
".m15",
|
||||||
|
".m4a",
|
||||||
|
".m4b",
|
||||||
|
".mac",
|
||||||
|
".med",
|
||||||
|
".mka",
|
||||||
|
".mmf",
|
||||||
|
".mod",
|
||||||
|
".mogg",
|
||||||
|
".mp2",
|
||||||
|
".mp3",
|
||||||
|
".mpa",
|
||||||
|
".mpc",
|
||||||
|
".mpp",
|
||||||
|
".mp+",
|
||||||
|
".msv",
|
||||||
|
".nmf",
|
||||||
|
".nsf",
|
||||||
|
".nsv",
|
||||||
|
".oga",
|
||||||
|
".ogg",
|
||||||
|
".okt",
|
||||||
|
".opus",
|
||||||
|
".pls",
|
||||||
|
".ra",
|
||||||
|
".rf64",
|
||||||
|
".rm",
|
||||||
|
".s3m",
|
||||||
|
".sfx",
|
||||||
|
".shn",
|
||||||
|
".sid",
|
||||||
|
".stm",
|
||||||
|
".strm",
|
||||||
|
".ult",
|
||||||
|
".uni",
|
||||||
|
".vox",
|
||||||
|
".wav",
|
||||||
|
".wma",
|
||||||
|
".wv",
|
||||||
|
".xm",
|
||||||
|
".xsp",
|
||||||
|
".ymf"
|
||||||
|
];
|
||||||
|
|
||||||
|
MediaFlagDelimiters =
|
||||||
|
[
|
||||||
|
'.'
|
||||||
|
];
|
||||||
|
|
||||||
|
MediaForcedFlags =
|
||||||
|
[
|
||||||
|
"foreign",
|
||||||
|
"forced"
|
||||||
|
];
|
||||||
|
|
||||||
|
MediaDefaultFlags =
|
||||||
|
[
|
||||||
|
"default"
|
||||||
|
];
|
||||||
|
|
||||||
|
MediaHearingImpairedFlags =
|
||||||
|
[
|
||||||
|
"cc",
|
||||||
|
"hi",
|
||||||
|
"sdh"
|
||||||
|
];
|
||||||
|
|
||||||
|
EpisodeExpressions =
|
||||||
|
[
|
||||||
|
// *** Begin Kodi Standard Naming
|
||||||
|
// <!-- foo.s01.e01, foo.s01_e01, S01E02 foo, S01 - E02 -->
|
||||||
|
new EpisodeExpression(@".*(\\|\/)(?<seriesname>((?[][ ._-]*[Ee]([0-9]+))[^\\\/])*)?[Ss](?<seasonnumber>[0-9]+)[][ ._-]*[Ee](?<epnumber>[0-9]+)([^\\/]*)$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
// <!-- foo.ep01, foo.EP_01 -->
|
||||||
|
new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"),
|
||||||
|
// <!-- foo.E01., foo.e01. -->
|
||||||
|
new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"),
|
||||||
|
new EpisodeExpression("(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true)
|
||||||
|
{
|
||||||
|
DateTimeFormats =
|
||||||
|
[
|
||||||
|
"yyyy.MM.dd",
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
"yyyy_MM_dd",
|
||||||
|
"yyyy MM dd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new EpisodeExpression("(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true)
|
||||||
|
{
|
||||||
|
DateTimeFormats =
|
||||||
|
[
|
||||||
|
"dd.MM.yyyy",
|
||||||
|
"dd-MM-yyyy",
|
||||||
|
"dd_MM_yyyy",
|
||||||
|
"dd MM yyyy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// This isn't a Kodi naming rule, but the expression below causes false episode numbers for
|
||||||
|
// Title Season X Episode X naming schemes.
|
||||||
|
// "Series Season X Episode X - Title.avi", "Series S03 E09.avi", "s3 e9 - Title.avi"
|
||||||
|
new EpisodeExpression(@".*[\\\/]((?<seriesname>[^\\/]+?)\s)?[Ss](?:eason)?\s*(?<seasonnumber>[0-9]+)\s+[Ee](?:pisode)?\s*(?<epnumber>[0-9]+).*$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Not a Kodi rule as well, but the expression below also causes false positives,
|
||||||
|
// so we make sure this one gets tested first.
|
||||||
|
// "Foo Bar 889"
|
||||||
|
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
new EpisodeExpression(@"[\\\/\._ \[\(-]([0-9]+)x([0-9]+(?:(?:[a-i]|\.[1-9])(?![0-9]))?)([^\\\/]*)$")
|
||||||
|
{
|
||||||
|
SupportsAbsoluteEpisodeNumbers = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Not a Kodi rule as well, but below rule also causes false positives for triple-digit episode names
|
||||||
|
// [bar] Foo - 1 [baz] special case of below expression to prevent false positives with digits in the series name
|
||||||
|
new EpisodeExpression(@".*[\\\/]?.*?(\[.*?\])+.*?(?<seriesname>[-\w\s]+?)[\s_]*-[\s_]*(?<epnumber>[0-9]+).*$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// /server/anything_102.mp4
|
||||||
|
// /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv
|
||||||
|
// /server/anything_1996.11.14.mp4
|
||||||
|
new EpisodeExpression(@"[\\/._ -](?<seriesname>(?![0-9]+[0-9][0-9])([^\\\/_])*)[\\\/._ -](?<seasonnumber>[0-9]+)(?<epnumber>[0-9][0-9](?:(?:[a-i]|\.[1-9])(?![0-9]))?)([._ -][^\\\/]*)$")
|
||||||
|
{
|
||||||
|
IsOptimistic = true,
|
||||||
|
IsNamed = true,
|
||||||
|
SupportsAbsoluteEpisodeNumbers = false
|
||||||
|
},
|
||||||
|
new EpisodeExpression(@"[\/._ -]p(?:ar)?t[_. -]()([ivx]+|[0-9]+)([._ -][^\/]*)$")
|
||||||
|
{
|
||||||
|
SupportsAbsoluteEpisodeNumbers = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// *** End Kodi Standard Naming
|
||||||
|
|
||||||
|
// "Episode 16", "Episode 16 - Title"
|
||||||
|
new EpisodeExpression(@"[Ee]pisode (?<epnumber>[0-9]+)(-(?<endingepnumber>[0-9]+))?[^\\\/]*$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
new EpisodeExpression(@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]+)[xX](?<epnumber>[0-9]+)[^\\\/]*$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
new EpisodeExpression(@".*(\\|\/)[sS](?<seasonnumber>[0-9]+)[x,X]?[eE](?<epnumber>[0-9]+)[^\\\/]*$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
new EpisodeExpression(@".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]+))[^\\\/]*$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
new EpisodeExpression(@".*(\\|\/)(?<seriesname>[^\\\/]*)[sS](?<seasonnumber>[0-9]{1,4})[xX\.]?[eE](?<epnumber>[0-9]+)[^\\\/]*$")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// "01.avi"
|
||||||
|
new EpisodeExpression(@".*[\\\/](?<epnumber>[0-9]+)(-(?<endingepnumber>[0-9]+))*\.\w+$")
|
||||||
|
{
|
||||||
|
IsOptimistic = true,
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// "1-12 episode title"
|
||||||
|
new EpisodeExpression("([0-9]+)-([0-9]+)"),
|
||||||
|
|
||||||
|
// "01 - blah.avi", "01-blah.avi"
|
||||||
|
new EpisodeExpression(@".*(\\|\/)(?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*\s?-\s?[^\\\/]*$")
|
||||||
|
{
|
||||||
|
IsOptimistic = true,
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// "01.blah.avi"
|
||||||
|
new EpisodeExpression(@".*(\\|\/)(?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*\.[^\\\/]+$")
|
||||||
|
{
|
||||||
|
IsOptimistic = true,
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// "blah - 01.avi", "blah 2 - 01.avi", "blah - 01 blah.avi", "blah 2 - 01 blah", "blah - 01 - blah.avi", "blah 2 - 01 - blah"
|
||||||
|
new EpisodeExpression(@".*[\\\/][^\\\/]* - (?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*[^\\\/]*$")
|
||||||
|
{
|
||||||
|
IsOptimistic = true,
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// "01 episode title.avi"
|
||||||
|
new EpisodeExpression(@"[Ss]eason[\._ ](?<seasonnumber>[0-9]+)[\\\/](?<epnumber>[0-9]{1,3})([^\\\/]*)$")
|
||||||
|
{
|
||||||
|
IsOptimistic = true,
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Series and season only expression
|
||||||
|
// "the show/season 1", "the show/s01"
|
||||||
|
new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)\/[Ss](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Series and season only expression
|
||||||
|
// "the show S01", "the show season 1"
|
||||||
|
new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)[\. _\-]+[sS](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Anime style expression
|
||||||
|
// "[Group][Series Name][21][1080p][FLAC][HASH]"
|
||||||
|
// "[Group] Series Name [04][BDRIP]"
|
||||||
|
new EpisodeExpression(@"(?:\[(?:[^\]]+)\]\s*)?(?<seriesname>\[[^\]]+\]|[^[\]]+)\s*\[(?<epnumber>[0-9]+)\]")
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
VideoExtraRules =
|
||||||
|
[
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Trailer,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"trailers",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.ThemeVideo,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"backdrops",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.ThemeSong,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"theme-music",
|
||||||
|
MediaType.Audio),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.BehindTheScenes,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"behind the scenes",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.DeletedScene,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"deleted scenes",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Interview,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"interviews",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Scene,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"scenes",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Sample,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"samples",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Short,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"shorts",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Featurette,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"featurettes",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Unknown,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"extras",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Unknown,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"extra",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Unknown,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"other",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Clip,
|
||||||
|
ExtraRuleType.DirectoryName,
|
||||||
|
"clips",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Trailer,
|
||||||
|
ExtraRuleType.Filename,
|
||||||
|
"trailer",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Sample,
|
||||||
|
ExtraRuleType.Filename,
|
||||||
|
"sample",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.ThemeSong,
|
||||||
|
ExtraRuleType.Filename,
|
||||||
|
"theme",
|
||||||
|
MediaType.Audio),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Trailer,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-trailer",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Trailer,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
".trailer",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Trailer,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"_trailer",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Trailer,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"- trailer",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Sample,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-sample",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Sample,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
".sample",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Sample,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"_sample",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Sample,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"- sample",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Scene,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-scene",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Clip,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-clip",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Interview,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-interview",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.BehindTheScenes,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-behindthescenes",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.DeletedScene,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-deleted",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.DeletedScene,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-deletedscene",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Featurette,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-featurette",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Short,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-short",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Unknown,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-extra",
|
||||||
|
MediaType.Video),
|
||||||
|
|
||||||
|
new ExtraRule(
|
||||||
|
ExtraType.Unknown,
|
||||||
|
ExtraRuleType.Suffix,
|
||||||
|
"-other",
|
||||||
|
MediaType.Video)
|
||||||
|
];
|
||||||
|
|
||||||
|
AllExtrasTypesFolderNames = VideoExtraRules
|
||||||
|
.Where(i => i.RuleType == ExtraRuleType.DirectoryName)
|
||||||
|
.ToDictionary(i => i.Token, i => i.ExtraType, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
Format3DRules =
|
||||||
|
[
|
||||||
|
// Kodi rules:
|
||||||
|
new Format3DRule(
|
||||||
|
precedingToken: "3d",
|
||||||
|
token: "hsbs"),
|
||||||
|
|
||||||
|
new Format3DRule(
|
||||||
|
precedingToken: "3d",
|
||||||
|
token: "sbs"),
|
||||||
|
|
||||||
|
new Format3DRule(
|
||||||
|
precedingToken: "3d",
|
||||||
|
token: "htab"),
|
||||||
|
|
||||||
|
new Format3DRule(
|
||||||
|
precedingToken: "3d",
|
||||||
|
token: "tab"),
|
||||||
|
|
||||||
|
// Media Browser rules:
|
||||||
|
new Format3DRule("fsbs"),
|
||||||
|
new Format3DRule("hsbs"),
|
||||||
|
new Format3DRule("sbs"),
|
||||||
|
new Format3DRule("ftab"),
|
||||||
|
new Format3DRule("htab"),
|
||||||
|
new Format3DRule("tab"),
|
||||||
|
new Format3DRule("sbs3d"),
|
||||||
|
new Format3DRule("mvc")
|
||||||
|
];
|
||||||
|
|
||||||
|
AudioBookPartsExpressions =
|
||||||
|
[
|
||||||
|
// Detect specified chapters, like CH 01
|
||||||
|
@"ch(?:apter)?[\s_-]?(?<chapter>[0-9]+)",
|
||||||
|
// Detect specified parts, like Part 02
|
||||||
|
@"p(?:ar)?t[\s_-]?(?<part>[0-9]+)",
|
||||||
|
// Chapter is often beginning of filename
|
||||||
|
"^(?<chapter>[0-9]+)",
|
||||||
|
// Part if often ending of filename
|
||||||
|
"(?<!ch(?:apter) )(?<part>[0-9]+)$",
|
||||||
|
// Sometimes named as 0001_005 (chapter_part)
|
||||||
|
"(?<chapter>[0-9]+)_(?<part>[0-9]+)",
|
||||||
|
// Some audiobooks are ripped from cd's, and will be named by disk number.
|
||||||
|
@"dis(?:c|k)[\s_-]?(?<chapter>[0-9]+)"
|
||||||
|
];
|
||||||
|
|
||||||
|
AudioBookNamesExpressions =
|
||||||
|
[
|
||||||
|
// Detect year usually in brackets after name Batman (2020)
|
||||||
|
@"^(?<name>.+?)\s*\(\s*(?<year>[0-9]{4})\s*\)\s*$",
|
||||||
|
@"^\s*(?<name>[^ ].*?)\s*$"
|
||||||
|
];
|
||||||
|
|
||||||
|
MultipleEpisodeExpressions = new[]
|
||||||
|
{
|
||||||
|
@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[eExX](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
|
||||||
|
@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[xX][eE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
|
||||||
|
@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )?[xXeE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
|
||||||
|
@".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})(-[xE]?[eE]?(?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
|
||||||
|
@".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3}))((-| - )[0-9]{1,4}[xXeE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
|
||||||
|
@".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3}))((-| - )[0-9]{1,4}[xX][eE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
|
||||||
|
@".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3}))((-| - )?[xXeE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
|
||||||
|
@".*(\\|\/)(?<seriesname>((?![sS]?[0-9]{1,4}[xX][0-9]{1,3})[^\\\/])*)?([sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3}))(-[xX]?[eE]?(?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
|
||||||
|
@".*(\\|\/)(?<seriesname>[^\\\/]*)[sS](?<seasonnumber>[0-9]{1,4})[xX\.]?[eE](?<epnumber>[0-9]{1,3})((-| - )?[xXeE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$",
|
||||||
|
@".*(\\|\/)(?<seriesname>[^\\\/]*)[sS](?<seasonnumber>[0-9]{1,4})[xX\.]?[eE](?<epnumber>[0-9]{1,3})(-[xX]?[eE]?(?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$"
|
||||||
|
}.Select(i => new EpisodeExpression(i)
|
||||||
|
{
|
||||||
|
IsNamed = true
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
Compile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the folder name to extra types mapping.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, ExtraType> AllExtrasTypesFolderNames { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of audio file extensions.
|
||||||
|
/// </summary>
|
||||||
|
public string[] AudioFileExtensions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of external media flag delimiters.
|
||||||
|
/// </summary>
|
||||||
|
public char[] MediaFlagDelimiters { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of external media forced flags.
|
||||||
|
/// </summary>
|
||||||
|
public string[] MediaForcedFlags { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of external media default flags.
|
||||||
|
/// </summary>
|
||||||
|
public string[] MediaDefaultFlags { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of external media hearing impaired flags.
|
||||||
|
/// </summary>
|
||||||
|
public string[] MediaHearingImpairedFlags { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of album stacking prefixes.
|
||||||
|
/// </summary>
|
||||||
|
public string[] AlbumStackingPrefixes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of artist subfolders.
|
||||||
|
/// </summary>
|
||||||
|
public string[] ArtistSubfolders { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of subtitle file extensions.
|
||||||
|
/// </summary>
|
||||||
|
public string[] SubtitleFileExtensions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of lyric file extensions.
|
||||||
|
/// </summary>
|
||||||
|
public string[] LyricFileExtensions { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of episode regular expressions.
|
||||||
|
/// </summary>
|
||||||
|
public EpisodeExpression[] EpisodeExpressions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of video file extensions.
|
||||||
|
/// </summary>
|
||||||
|
public string[] VideoFileExtensions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of video stub file extensions.
|
||||||
|
/// </summary>
|
||||||
|
public string[] StubFileExtensions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of raw audiobook parts regular expressions strings.
|
||||||
|
/// </summary>
|
||||||
|
public string[] AudioBookPartsExpressions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of raw audiobook names regular expressions strings.
|
||||||
|
/// </summary>
|
||||||
|
public string[] AudioBookNamesExpressions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of stub type rules.
|
||||||
|
/// </summary>
|
||||||
|
public StubTypeRule[] StubTypes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of video flag delimiters.
|
||||||
|
/// </summary>
|
||||||
|
public char[] VideoFlagDelimiters { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of 3D Format rules.
|
||||||
|
/// </summary>
|
||||||
|
public Format3DRule[] Format3DRules { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the file stacking rules.
|
||||||
|
/// </summary>
|
||||||
|
public FileStackRule[] VideoFileStackingRules { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of raw clean DateTimes regular expressions strings.
|
||||||
|
/// </summary>
|
||||||
|
public string[] CleanDateTimes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of raw clean strings regular expressions strings.
|
||||||
|
/// </summary>
|
||||||
|
public string[] CleanStrings { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of multi-episode regular expressions.
|
||||||
|
/// </summary>
|
||||||
|
public EpisodeExpression[] MultipleEpisodeExpressions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets list of extra rules for videos.
|
||||||
|
/// </summary>
|
||||||
|
public ExtraRule[] VideoExtraRules { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets list of clean datetime regular expressions.
|
||||||
|
/// </summary>
|
||||||
|
public Regex[] CleanDateTimeRegexes { get; private set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets list of clean string regular expressions.
|
||||||
|
/// </summary>
|
||||||
|
public Regex[] CleanStringRegexes { get; private set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles raw regex strings into regexes.
|
||||||
|
/// </summary>
|
||||||
|
public void Compile()
|
||||||
|
{
|
||||||
|
CleanDateTimeRegexes = CleanDateTimes.Select(Compile).ToArray();
|
||||||
|
CleanStringRegexes = CleanStrings.Select(Compile).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Regex Compile(string exp)
|
||||||
|
{
|
||||||
|
return new Regex(exp, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<ProjectGuid>{E5AF7B26-2239-4CE0-B477-0AA2018EDAA2}</ProjectGuid>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||||
|
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||||
|
<IncludeSymbols>true</IncludeSymbols>
|
||||||
|
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
<CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition=" '$(Stability)'=='Unstable'">
|
||||||
|
<!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
|
||||||
|
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="../SharedVersion.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../MediaBrowser.Common/MediaBrowser.Common.csproj" />
|
||||||
|
<ProjectReference Include="../MediaBrowser.Model/MediaBrowser.Model.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
|
<PackageId>Jellyfin.Naming</PackageId>
|
||||||
|
<VersionPrefix>10.12.0</VersionPrefix>
|
||||||
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Code Analyzers -->
|
||||||
|
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
<PackageReference Include="IDisposableAnalyzers">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Model.Dlna;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
|
|
||||||
|
namespace Emby.Naming.ExternalFiles
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// External media file parser class.
|
||||||
|
/// </summary>
|
||||||
|
public class ExternalPathParser
|
||||||
|
{
|
||||||
|
private readonly NamingOptions _namingOptions;
|
||||||
|
private readonly DlnaProfileType _type;
|
||||||
|
private readonly ILocalizationManager _localizationManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ExternalPathParser"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="localizationManager">The localization manager.</param>
|
||||||
|
/// <param name="namingOptions">The <see cref="NamingOptions"/> object containing FileExtensions, MediaDefaultFlags, MediaForcedFlags and MediaFlagDelimiters.</param>
|
||||||
|
/// <param name="type">The <see cref="DlnaProfileType"/> of the parsed file.</param>
|
||||||
|
public ExternalPathParser(NamingOptions namingOptions, ILocalizationManager localizationManager, DlnaProfileType type)
|
||||||
|
{
|
||||||
|
_localizationManager = localizationManager;
|
||||||
|
_namingOptions = namingOptions;
|
||||||
|
_type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse filename and extract information.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to file.</param>
|
||||||
|
/// <param name="extraString">Part of the filename only containing the extra information.</param>
|
||||||
|
/// <returns>Returns null or an <see cref="ExternalPathParserResult"/> object if parsing is successful.</returns>
|
||||||
|
public ExternalPathParserResult? ParseFile(string path, string? extraString)
|
||||||
|
{
|
||||||
|
if (path.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(path.AsSpan());
|
||||||
|
if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
&& !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
&& !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathInfo = new ExternalPathParserResult(path);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(extraString))
|
||||||
|
{
|
||||||
|
return pathInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var separator in _namingOptions.MediaFlagDelimiters)
|
||||||
|
{
|
||||||
|
var languageString = extraString;
|
||||||
|
var titleString = string.Empty;
|
||||||
|
const int SeparatorLength = 1;
|
||||||
|
|
||||||
|
while (languageString.Length > 0)
|
||||||
|
{
|
||||||
|
int lastSeparator = languageString.LastIndexOf(separator);
|
||||||
|
|
||||||
|
if (lastSeparator == -1)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string currentSlice = languageString[lastSeparator..];
|
||||||
|
string currentSliceWithoutSeparator = currentSlice[SeparatorLength..];
|
||||||
|
|
||||||
|
if (_namingOptions.MediaDefaultFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
pathInfo.IsDefault = true;
|
||||||
|
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
languageString = languageString[..lastSeparator];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_namingOptions.MediaForcedFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
pathInfo.IsForced = true;
|
||||||
|
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
languageString = languageString[..lastSeparator];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to translate to three character code
|
||||||
|
var culture = _localizationManager.FindLanguageInfo(currentSliceWithoutSeparator);
|
||||||
|
|
||||||
|
if (culture is not null && pathInfo.Language is null)
|
||||||
|
{
|
||||||
|
pathInfo.Language = culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase)
|
||||||
|
? culture.Name
|
||||||
|
: culture.ThreeLetterISOLanguageName;
|
||||||
|
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
else if (culture is not null && pathInfo.Language == "hin")
|
||||||
|
{
|
||||||
|
// Hindi language code "hi" collides with a hearing impaired flag - use as Hindi only if no other language is set
|
||||||
|
pathInfo.IsHearingImpaired = true;
|
||||||
|
pathInfo.Language = culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase)
|
||||||
|
? culture.Name
|
||||||
|
: culture.ThreeLetterISOLanguageName;
|
||||||
|
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Equals(s, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
pathInfo.IsHearingImpaired = true;
|
||||||
|
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
titleString = currentSlice + titleString;
|
||||||
|
}
|
||||||
|
|
||||||
|
languageString = languageString[..lastSeparator];
|
||||||
|
}
|
||||||
|
|
||||||
|
pathInfo.Title = titleString.Length >= SeparatorLength ? titleString[SeparatorLength..] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
namespace Emby.Naming.ExternalFiles
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Class holding information about external files.
|
||||||
|
/// </summary>
|
||||||
|
public class ExternalPathParserResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ExternalPathParserResult"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to file.</param>
|
||||||
|
/// <param name="isDefault">Is default.</param>
|
||||||
|
/// <param name="isForced">Is forced.</param>
|
||||||
|
/// <param name="isHearingImpaired">For the hearing impaired.</param>
|
||||||
|
public ExternalPathParserResult(string path, bool isDefault = false, bool isForced = false, bool isHearingImpaired = false)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
IsDefault = isDefault;
|
||||||
|
IsForced = isForced;
|
||||||
|
IsHearingImpaired = isHearingImpaired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the path.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The path.</value>
|
||||||
|
public string Path { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the language.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The language.</value>
|
||||||
|
public string? Language { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the title.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The title.</value>
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether this instance is default.
|
||||||
|
/// </summary>
|
||||||
|
/// <value><c>true</c> if this instance is default; otherwise, <c>false</c>.</value>
|
||||||
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether this instance is forced.
|
||||||
|
/// </summary>
|
||||||
|
/// <value><c>true</c> if this instance is forced; otherwise, <c>false</c>.</value>
|
||||||
|
public bool IsForced { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether this instance is for the hearing impaired.
|
||||||
|
/// </summary>
|
||||||
|
/// <value><c>true</c> if this instance is for the hearing impaired; otherwise, <c>false</c>.</value>
|
||||||
|
public bool IsHearingImpaired { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Resources;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
// General Information about an assembly is controlled through the following
|
||||||
|
// set of attributes. Change these attribute values to modify the information
|
||||||
|
// associated with an assembly.
|
||||||
|
[assembly: AssemblyTitle("Emby.Naming")]
|
||||||
|
[assembly: AssemblyDescription("")]
|
||||||
|
[assembly: AssemblyConfiguration("")]
|
||||||
|
[assembly: AssemblyCompany("Jellyfin Project")]
|
||||||
|
[assembly: AssemblyProduct("Jellyfin Server")]
|
||||||
|
[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")]
|
||||||
|
[assembly: AssemblyTrademark("")]
|
||||||
|
[assembly: AssemblyCulture("")]
|
||||||
|
[assembly: NeutralResourcesLanguage("en")]
|
||||||
|
|
||||||
|
// Setting ComVisible to false makes the types in this assembly not visible
|
||||||
|
// to COM components. If you need to access a type in this assembly from
|
||||||
|
// COM, set the ComVisible attribute to true on that type.
|
||||||
|
[assembly: ComVisible(false)]
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Holder object for Episode information.
|
||||||
|
/// </summary>
|
||||||
|
public class EpisodeInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="EpisodeInfo"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to the file.</param>
|
||||||
|
public EpisodeInfo(string path)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the path.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The path.</value>
|
||||||
|
public string Path { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the container.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The container.</value>
|
||||||
|
public string? Container { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the series.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The name of the series.</value>
|
||||||
|
public string? SeriesName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the format3 d.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The format3 d.</value>
|
||||||
|
public string? Format3D { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether [is3 d].
|
||||||
|
/// </summary>
|
||||||
|
/// <value><c>true</c> if [is3 d]; otherwise, <c>false</c>.</value>
|
||||||
|
public bool Is3D { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether this instance is stub.
|
||||||
|
/// </summary>
|
||||||
|
/// <value><c>true</c> if this instance is stub; otherwise, <c>false</c>.</value>
|
||||||
|
public bool IsStub { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the stub.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The type of the stub.</value>
|
||||||
|
public string? StubType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional season number.
|
||||||
|
/// </summary>
|
||||||
|
public int? SeasonNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional episode number.
|
||||||
|
/// </summary>
|
||||||
|
public int? EpisodeNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional ending episode number. For multi-episode files 1-13.
|
||||||
|
/// </summary>
|
||||||
|
public int? EndingEpisodeNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional year of release.
|
||||||
|
/// </summary>
|
||||||
|
public int? Year { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional year of release.
|
||||||
|
/// </summary>
|
||||||
|
public int? Month { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional day of release.
|
||||||
|
/// </summary>
|
||||||
|
public int? Day { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether by date expression was used.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsByDate { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
|
||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Used to parse information about episode from path.
|
||||||
|
/// </summary>
|
||||||
|
public class EpisodePathParser
|
||||||
|
{
|
||||||
|
private readonly NamingOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="EpisodePathParser"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options"><see cref="NamingOptions"/> object containing EpisodeExpressions and MultipleEpisodeExpressions.</param>
|
||||||
|
public EpisodePathParser(NamingOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses information about episode from path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path.</param>
|
||||||
|
/// <param name="isDirectory">Is path for a directory or file.</param>
|
||||||
|
/// <param name="isNamed">Do we want to use IsNamed expressions.</param>
|
||||||
|
/// <param name="isOptimistic">Do we want to use Optimistic expressions.</param>
|
||||||
|
/// <param name="supportsAbsoluteNumbers">Do we want to use expressions supporting absolute episode numbers.</param>
|
||||||
|
/// <param name="fillExtendedInfo">Should we attempt to retrieve extended information.</param>
|
||||||
|
/// <returns>Returns <see cref="EpisodePathParserResult"/> object.</returns>
|
||||||
|
public EpisodePathParserResult Parse(
|
||||||
|
string path,
|
||||||
|
bool isDirectory,
|
||||||
|
bool? isNamed = null,
|
||||||
|
bool? isOptimistic = null,
|
||||||
|
bool? supportsAbsoluteNumbers = null,
|
||||||
|
bool fillExtendedInfo = true)
|
||||||
|
{
|
||||||
|
// Added to be able to use regex patterns which require a file extension.
|
||||||
|
// There were no failed tests without this block, but to be safe, we can keep it until
|
||||||
|
// the regex which require file extensions are modified so that they don't need them.
|
||||||
|
if (isDirectory)
|
||||||
|
{
|
||||||
|
path += ".mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
EpisodePathParserResult? result = null;
|
||||||
|
|
||||||
|
foreach (var expression in _options.EpisodeExpressions)
|
||||||
|
{
|
||||||
|
if (supportsAbsoluteNumbers.HasValue
|
||||||
|
&& expression.SupportsAbsoluteEpisodeNumbers != supportsAbsoluteNumbers.Value)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNamed.HasValue && expression.IsNamed != isNamed.Value)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOptimistic.HasValue && expression.IsOptimistic != isOptimistic.Value)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentResult = Parse(path, expression);
|
||||||
|
if (currentResult.Success)
|
||||||
|
{
|
||||||
|
result = currentResult;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result is not null && fillExtendedInfo)
|
||||||
|
{
|
||||||
|
FillAdditional(path, result);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(result.SeriesName))
|
||||||
|
{
|
||||||
|
result.SeriesName = result.SeriesName
|
||||||
|
.Trim()
|
||||||
|
.Trim('_', '.', '-')
|
||||||
|
.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result ?? new EpisodePathParserResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EpisodePathParserResult Parse(string name, EpisodeExpression expression)
|
||||||
|
{
|
||||||
|
var result = new EpisodePathParserResult();
|
||||||
|
|
||||||
|
// This is a hack to handle wmc naming
|
||||||
|
if (expression.IsByDate)
|
||||||
|
{
|
||||||
|
name = name.Replace('_', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = expression.Regex.Match(name);
|
||||||
|
|
||||||
|
// (Full)(Season)(Episode)(Extension)
|
||||||
|
if (match.Success && match.Groups.Count >= 3)
|
||||||
|
{
|
||||||
|
if (expression.IsByDate)
|
||||||
|
{
|
||||||
|
DateTime date;
|
||||||
|
if (expression.DateTimeFormats.Length > 0)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParseExact(
|
||||||
|
match.Groups[0].ValueSpan,
|
||||||
|
expression.DateTimeFormats,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None,
|
||||||
|
out date))
|
||||||
|
{
|
||||||
|
result.Year = date.Year;
|
||||||
|
result.Month = date.Month;
|
||||||
|
result.Day = date.Day;
|
||||||
|
result.Success = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date))
|
||||||
|
{
|
||||||
|
result.Year = date.Year;
|
||||||
|
result.Month = date.Month;
|
||||||
|
result.Day = date.Day;
|
||||||
|
result.Success = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Only consider success if date successfully parsed?
|
||||||
|
result.Success = true;
|
||||||
|
}
|
||||||
|
else if (expression.IsNamed)
|
||||||
|
{
|
||||||
|
if (int.TryParse(match.Groups["seasonnumber"].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
|
||||||
|
{
|
||||||
|
result.SeasonNumber = num;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (int.TryParse(match.Groups["epnumber"].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
|
||||||
|
{
|
||||||
|
result.EpisodeNumber = num;
|
||||||
|
}
|
||||||
|
|
||||||
|
var endingNumberGroup = match.Groups["endingepnumber"];
|
||||||
|
if (endingNumberGroup.Success)
|
||||||
|
{
|
||||||
|
// Will only set EndingEpisodeNumber if the captured number is not followed by additional numbers
|
||||||
|
// or a 'p' or 'i' as what you would get with a pixel resolution specification.
|
||||||
|
// It avoids erroneous parsing of something like "series-s09e14-1080p.mkv" as a multi-episode from E14 to E108
|
||||||
|
int nextIndex = endingNumberGroup.Index + endingNumberGroup.Length;
|
||||||
|
if (nextIndex >= name.Length
|
||||||
|
|| !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
|
||||||
|
{
|
||||||
|
result.EndingEpisodeNumber = num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.SeriesName = match.Groups["seriesname"].Value;
|
||||||
|
result.Success = result.EpisodeNumber.HasValue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (int.TryParse(match.Groups[1].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
|
||||||
|
{
|
||||||
|
result.SeasonNumber = num;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
|
||||||
|
{
|
||||||
|
result.EpisodeNumber = num;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = result.EpisodeNumber.HasValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate match when the season is 200 through 1927 or above 2500
|
||||||
|
// because it is an error unless the TV show is intentionally using false season numbers.
|
||||||
|
// It avoids erroneous parsing of something like "Series Special (1920x1080).mkv" as being season 1920 episode 1080.
|
||||||
|
if ((result.SeasonNumber >= 200 && result.SeasonNumber < 1928)
|
||||||
|
|| result.SeasonNumber > 2500)
|
||||||
|
{
|
||||||
|
result.Success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.IsByDate = expression.IsByDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FillAdditional(string path, EpisodePathParserResult info)
|
||||||
|
{
|
||||||
|
var expressions = _options.MultipleEpisodeExpressions.Where(i => i.IsNamed).ToList();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(info.SeriesName))
|
||||||
|
{
|
||||||
|
expressions.InsertRange(0, _options.EpisodeExpressions.Where(i => i.IsNamed));
|
||||||
|
}
|
||||||
|
|
||||||
|
FillAdditional(path, info, expressions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FillAdditional(string path, EpisodePathParserResult info, IEnumerable<EpisodeExpression> expressions)
|
||||||
|
{
|
||||||
|
foreach (var i in expressions)
|
||||||
|
{
|
||||||
|
var result = Parse(path, i);
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(info.SeriesName))
|
||||||
|
{
|
||||||
|
info.SeriesName = result.SeriesName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue)
|
||||||
|
{
|
||||||
|
info.EndingEpisodeNumber = result.EndingEpisodeNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(info.SeriesName)
|
||||||
|
&& (!info.EpisodeNumber.HasValue || info.EndingEpisodeNumber.HasValue))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Holder object for <see cref="EpisodePathParser"/> result.
|
||||||
|
/// </summary>
|
||||||
|
public class EpisodePathParserResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional season number.
|
||||||
|
/// </summary>
|
||||||
|
public int? SeasonNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional episode number.
|
||||||
|
/// </summary>
|
||||||
|
public int? EpisodeNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional ending episode number. For multi-episode files 1-13.
|
||||||
|
/// </summary>
|
||||||
|
public int? EndingEpisodeNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the series.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The name of the series.</value>
|
||||||
|
public string? SeriesName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether parsing was successful.
|
||||||
|
/// </summary>
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether by date expression was used.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsByDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional year of release.
|
||||||
|
/// </summary>
|
||||||
|
public int? Year { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional year of release.
|
||||||
|
/// </summary>
|
||||||
|
public int? Month { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets optional day of release.
|
||||||
|
/// </summary>
|
||||||
|
public int? Day { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using Emby.Naming.Video;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Used to resolve information about episode from path.
|
||||||
|
/// </summary>
|
||||||
|
public class EpisodeResolver
|
||||||
|
{
|
||||||
|
private readonly NamingOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="EpisodeResolver"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions and passed to <see cref="StubResolver"/>, <see cref="Format3DParser"/> and <see cref="EpisodePathParser"/>.</param>
|
||||||
|
public EpisodeResolver(NamingOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve information about episode from path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path.</param>
|
||||||
|
/// <param name="isDirectory">Is path for a directory or file.</param>
|
||||||
|
/// <param name="isNamed">Do we want to use IsNamed expressions.</param>
|
||||||
|
/// <param name="isOptimistic">Do we want to use Optimistic expressions.</param>
|
||||||
|
/// <param name="supportsAbsoluteNumbers">Do we want to use expressions supporting absolute episode numbers.</param>
|
||||||
|
/// <param name="fillExtendedInfo">Should we attempt to retrieve extended information.</param>
|
||||||
|
/// <returns>Returns null or <see cref="EpisodeInfo"/> object if successful.</returns>
|
||||||
|
public EpisodeInfo? Resolve(
|
||||||
|
string path,
|
||||||
|
bool isDirectory,
|
||||||
|
bool? isNamed = null,
|
||||||
|
bool? isOptimistic = null,
|
||||||
|
bool? supportsAbsoluteNumbers = null,
|
||||||
|
bool fillExtendedInfo = true)
|
||||||
|
{
|
||||||
|
bool isStub = false;
|
||||||
|
string? container = null;
|
||||||
|
string? stubType = null;
|
||||||
|
|
||||||
|
if (!isDirectory)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(path);
|
||||||
|
// Check supported extensions
|
||||||
|
if (!_options.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// It's not supported. Check stub extensions
|
||||||
|
if (!StubResolver.TryResolveFile(path, _options, out stubType))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isStub = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
container = extension.TrimStart('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
var format3DResult = Format3DParser.Parse(path, _options);
|
||||||
|
|
||||||
|
var parsingResult = new EpisodePathParser(_options)
|
||||||
|
.Parse(path, isDirectory, isNamed, isOptimistic, supportsAbsoluteNumbers, fillExtendedInfo);
|
||||||
|
|
||||||
|
if (!parsingResult.Success && !isStub)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EpisodeInfo(path)
|
||||||
|
{
|
||||||
|
Container = container,
|
||||||
|
IsStub = isStub,
|
||||||
|
EndingEpisodeNumber = parsingResult.EndingEpisodeNumber,
|
||||||
|
EpisodeNumber = parsingResult.EpisodeNumber,
|
||||||
|
SeasonNumber = parsingResult.SeasonNumber,
|
||||||
|
SeriesName = parsingResult.SeriesName,
|
||||||
|
StubType = stubType,
|
||||||
|
Is3D = format3DResult.Is3D,
|
||||||
|
Format3D = format3DResult.Format3D,
|
||||||
|
IsByDate = parsingResult.IsByDate,
|
||||||
|
Day = parsingResult.Day,
|
||||||
|
Month = parsingResult.Month,
|
||||||
|
Year = parsingResult.Year
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Class to parse season paths.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class SeasonPathParser
|
||||||
|
{
|
||||||
|
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex ProcessPre();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex ProcessPost();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
|
||||||
|
private static partial Regex SeasonPrefix();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse season number from path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to season.</param>
|
||||||
|
/// <param name="parentPath">Folder name of the parent.</param>
|
||||||
|
/// <param name="supportSpecialAliases">Support special aliases when parsing.</param>
|
||||||
|
/// <param name="supportNumericSeasonFolders">Support numeric season folders when parsing.</param>
|
||||||
|
/// <returns>Returns <see cref="SeasonPathParserResult"/> object.</returns>
|
||||||
|
public static SeasonPathParserResult Parse(string path, string? parentPath, bool supportSpecialAliases, bool supportNumericSeasonFolders)
|
||||||
|
{
|
||||||
|
var result = new SeasonPathParserResult();
|
||||||
|
var parentFolderName = parentPath is null ? null : new DirectoryInfo(parentPath).Name;
|
||||||
|
|
||||||
|
var (seasonNumber, isSeasonFolder) = GetSeasonNumberFromPath(path, parentFolderName, supportSpecialAliases, supportNumericSeasonFolders);
|
||||||
|
|
||||||
|
result.SeasonNumber = seasonNumber;
|
||||||
|
|
||||||
|
if (result.SeasonNumber.HasValue)
|
||||||
|
{
|
||||||
|
result.Success = true;
|
||||||
|
result.IsSeasonFolder = isSeasonFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the season number from path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path.</param>
|
||||||
|
/// <param name="parentFolderName">The parent folder name.</param>
|
||||||
|
/// <param name="supportSpecialAliases">if set to <c>true</c> [support special aliases].</param>
|
||||||
|
/// <param name="supportNumericSeasonFolders">if set to <c>true</c> [support numeric season folders].</param>
|
||||||
|
/// <returns>System.Nullable{System.Int32}.</returns>
|
||||||
|
private static (int? SeasonNumber, bool IsSeasonFolder) GetSeasonNumberFromPath(
|
||||||
|
string path,
|
||||||
|
string? parentFolderName,
|
||||||
|
bool supportSpecialAliases,
|
||||||
|
bool supportNumericSeasonFolders)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(path);
|
||||||
|
|
||||||
|
var seasonPrefixMatch = SeasonPrefix().Match(fileName);
|
||||||
|
if (seasonPrefixMatch.Success &&
|
||||||
|
int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||||
|
{
|
||||||
|
return (val, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
string filename = CleanNameRegex.Replace(fileName, string.Empty);
|
||||||
|
|
||||||
|
if (parentFolderName is not null)
|
||||||
|
{
|
||||||
|
var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
|
||||||
|
filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportSpecialAliases &&
|
||||||
|
(filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return (0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportNumericSeasonFolders &&
|
||||||
|
int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
|
||||||
|
{
|
||||||
|
return (val, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var preMatch = ProcessPre().Match(filename);
|
||||||
|
if (preMatch.Success)
|
||||||
|
{
|
||||||
|
return CheckMatch(preMatch);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var postMatch = ProcessPost().Match(filename);
|
||||||
|
return CheckMatch(postMatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int? SeasonNumber, bool IsSeasonFolder) CheckMatch(Match match)
|
||||||
|
{
|
||||||
|
var numberString = match.Groups["seasonnumber"];
|
||||||
|
if (numberString.Success)
|
||||||
|
{
|
||||||
|
if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber))
|
||||||
|
{
|
||||||
|
return (seasonNumber, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the season number from the second half of the Season folder name (everything after "Season", or "Staffel").
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path.</param>
|
||||||
|
/// <returns>System.Nullable{System.Int32}.</returns>
|
||||||
|
private static (int? SeasonNumber, bool IsSeasonFolder) GetSeasonNumberFromPathSubstring(ReadOnlySpan<char> path)
|
||||||
|
{
|
||||||
|
var numericStart = -1;
|
||||||
|
var length = 0;
|
||||||
|
|
||||||
|
var hasOpenParenthesis = false;
|
||||||
|
var isSeasonFolder = true;
|
||||||
|
|
||||||
|
// Find out where the numbers start, and then keep going until they end
|
||||||
|
for (var i = 0; i < path.Length; i++)
|
||||||
|
{
|
||||||
|
if (char.IsNumber(path[i]))
|
||||||
|
{
|
||||||
|
if (!hasOpenParenthesis)
|
||||||
|
{
|
||||||
|
if (numericStart == -1)
|
||||||
|
{
|
||||||
|
numericStart = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
length++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (numericStart != -1)
|
||||||
|
{
|
||||||
|
// There's other stuff after the season number, e.g. episode number
|
||||||
|
isSeasonFolder = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentChar = path[i];
|
||||||
|
if (currentChar == '(')
|
||||||
|
{
|
||||||
|
hasOpenParenthesis = true;
|
||||||
|
}
|
||||||
|
else if (currentChar == ')')
|
||||||
|
{
|
||||||
|
hasOpenParenthesis = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericStart == -1)
|
||||||
|
{
|
||||||
|
return (null, isSeasonFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int.Parse(path.Slice(numericStart, length), provider: CultureInfo.InvariantCulture), isSeasonFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data object to pass result of <see cref="SeasonPathParser"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class SeasonPathParserResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the season number.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The season number.</value>
|
||||||
|
public int? SeasonNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether this <see cref="SeasonPathParserResult" /> is success.
|
||||||
|
/// </summary>
|
||||||
|
/// <value><c>true</c> if success; otherwise, <c>false</c>.</value>
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether "Is season folder".
|
||||||
|
/// Seems redundant and barely used.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSeasonFolder { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Holder object for Series information.
|
||||||
|
/// </summary>
|
||||||
|
public class SeriesInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SeriesInfo"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to the file.</param>
|
||||||
|
public SeriesInfo(string path)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the path.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The path.</value>
|
||||||
|
public string Path { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the series.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The name of the series.</value>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Emby.Naming.Common;
|
||||||
|
|
||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Used to parse information about series from paths containing more information that only the series name.
|
||||||
|
/// Uses the same regular expressions as the EpisodePathParser but have different success criteria.
|
||||||
|
/// </summary>
|
||||||
|
public static class SeriesPathParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses information about series from path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options"><see cref="NamingOptions"/> object containing EpisodeExpressions and MultipleEpisodeExpressions.</param>
|
||||||
|
/// <param name="path">Path.</param>
|
||||||
|
/// <returns>Returns <see cref="SeriesPathParserResult"/> object.</returns>
|
||||||
|
public static SeriesPathParserResult Parse(NamingOptions options, string path)
|
||||||
|
{
|
||||||
|
SeriesPathParserResult? result = null;
|
||||||
|
|
||||||
|
foreach (var expression in options.EpisodeExpressions)
|
||||||
|
{
|
||||||
|
var currentResult = Parse(path, expression);
|
||||||
|
if (currentResult.Success)
|
||||||
|
{
|
||||||
|
result = currentResult;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(result.SeriesName))
|
||||||
|
{
|
||||||
|
result.SeriesName = result.SeriesName.Trim(' ', '_', '.', '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result ?? new SeriesPathParserResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SeriesPathParserResult Parse(string name, EpisodeExpression expression)
|
||||||
|
{
|
||||||
|
var result = new SeriesPathParserResult();
|
||||||
|
|
||||||
|
var match = expression.Regex.Match(name);
|
||||||
|
|
||||||
|
if (match.Success && match.Groups.Count >= 3)
|
||||||
|
{
|
||||||
|
if (expression.IsNamed)
|
||||||
|
{
|
||||||
|
result.SeriesName = match.Groups["seriesname"].Value;
|
||||||
|
result.Success = !string.IsNullOrEmpty(result.SeriesName) && !match.Groups["seasonnumber"].ValueSpan.IsEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Holder object for <see cref="SeriesPathParser"/> result.
|
||||||
|
/// </summary>
|
||||||
|
public class SeriesPathParserResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the series.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The name of the series.</value>
|
||||||
|
public string? SeriesName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether parsing was successful.
|
||||||
|
/// </summary>
|
||||||
|
public bool Success { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
|
||||||
|
namespace Emby.Naming.TV
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Used to resolve information about series from path.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class SeriesResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Regex that matches strings of at least 2 characters separated by a dot or underscore.
|
||||||
|
/// Used for removing separators between words, i.e turns "The_show" into "The show" while
|
||||||
|
/// preserving names like "S.H.O.W".
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")]
|
||||||
|
private static partial Regex SeriesNameRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regex that matches titles with year in parentheses. Captures the title (which may be
|
||||||
|
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")]
|
||||||
|
private static partial Regex TitleWithYearRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve information about series from path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options"><see cref="NamingOptions"/> object passed to <see cref="SeriesPathParser"/>.</param>
|
||||||
|
/// <param name="path">Path to series.</param>
|
||||||
|
/// <returns>SeriesInfo.</returns>
|
||||||
|
public static SeriesInfo Resolve(NamingOptions options, string path)
|
||||||
|
{
|
||||||
|
string seriesName = Path.GetFileName(path);
|
||||||
|
|
||||||
|
// First check if the filename matches a title with year pattern (handles numeric titles)
|
||||||
|
if (!string.IsNullOrEmpty(seriesName))
|
||||||
|
{
|
||||||
|
var titleWithYearMatch = TitleWithYearRegex().Match(seriesName);
|
||||||
|
if (titleWithYearMatch.Success)
|
||||||
|
{
|
||||||
|
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
|
||||||
|
return new SeriesInfo(path)
|
||||||
|
{
|
||||||
|
Name = seriesName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SeriesPathParserResult result = SeriesPathParser.Parse(options, path);
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(result.SeriesName))
|
||||||
|
{
|
||||||
|
seriesName = result.SeriesName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(seriesName))
|
||||||
|
{
|
||||||
|
seriesName = SeriesNameRegex().Replace(seriesName, "${a} ${b}").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SeriesInfo(path)
|
||||||
|
{
|
||||||
|
Name = seriesName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
|
namespace Emby.Naming.TV;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class for TV metadata parsing.
|
||||||
|
/// </summary>
|
||||||
|
public static class TvParserHelpers
|
||||||
|
{
|
||||||
|
private static readonly string[] _continuingState = ["Pilot", "Returning Series", "Returning"];
|
||||||
|
private static readonly string[] _endedState = ["Cancelled", "Canceled"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to parse a string into <see cref="SeriesStatus"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="status">The status string.</param>
|
||||||
|
/// <param name="enumValue">The <see cref="SeriesStatus"/>.</param>
|
||||||
|
/// <returns>Returns true if parsing was successful.</returns>
|
||||||
|
public static bool TryParseSeriesStatus(string status, out SeriesStatus? enumValue)
|
||||||
|
{
|
||||||
|
if (Enum.TryParse(status, true, out SeriesStatus seriesStatus))
|
||||||
|
{
|
||||||
|
enumValue = seriesStatus;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_continuingState.Contains(status, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
enumValue = SeriesStatus.Continuing;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_endedState.Contains(status, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
enumValue = SeriesStatus.Ended;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
enumValue = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see href="http://kodi.wiki/view/Advancedsettings.xml#video" />.
|
||||||
|
/// </summary>
|
||||||
|
public static class CleanDateTimeParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to clean the name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Name of video.</param>
|
||||||
|
/// <param name="cleanDateTimeRegexes">Optional list of regexes to clean the name.</param>
|
||||||
|
/// <returns>Returns <see cref="CleanDateTimeResult"/> object.</returns>
|
||||||
|
public static CleanDateTimeResult Clean(string name, IReadOnlyList<Regex> cleanDateTimeRegexes)
|
||||||
|
{
|
||||||
|
CleanDateTimeResult result = new CleanDateTimeResult(name);
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var len = cleanDateTimeRegexes.Count;
|
||||||
|
for (int i = 0; i < len; i++)
|
||||||
|
{
|
||||||
|
if (TryClean(name, cleanDateTimeRegexes[i], ref result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryClean(string name, Regex expression, ref CleanDateTimeResult result)
|
||||||
|
{
|
||||||
|
var match = expression.Match(name);
|
||||||
|
|
||||||
|
if (match.Success
|
||||||
|
&& match.Groups.Count == 5
|
||||||
|
&& match.Groups[1].Success
|
||||||
|
&& match.Groups[2].Success
|
||||||
|
&& int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
|
||||||
|
{
|
||||||
|
result = new CleanDateTimeResult(match.Groups[1].Value.TrimEnd(), year);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Holder structure for name and year.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct CleanDateTimeResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CleanDateTimeResult"/> struct.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Name of video.</param>
|
||||||
|
/// <param name="year">Year of release.</param>
|
||||||
|
public CleanDateTimeResult(string name, int? year = null)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Year = year;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The name.</value>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the year.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The year.</value>
|
||||||
|
public int? Year { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <see href="http://kodi.wiki/view/Advancedsettings.xml#video" />.
|
||||||
|
/// </summary>
|
||||||
|
public static class CleanStringParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to extract clean name with regular expressions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Name of file.</param>
|
||||||
|
/// <param name="expressions">List of regex to parse name and year from.</param>
|
||||||
|
/// <param name="newName">Parsing result string.</param>
|
||||||
|
/// <returns>True if parsing was successful.</returns>
|
||||||
|
public static bool TryClean([NotNullWhen(true)] string? name, IReadOnlyList<Regex> expressions, out string newName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
newName = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iteratively apply the regexps to clean the string.
|
||||||
|
bool cleaned = false;
|
||||||
|
for (int i = 0; i < expressions.Count; i++)
|
||||||
|
{
|
||||||
|
if (TryClean(name, expressions[i], out newName))
|
||||||
|
{
|
||||||
|
cleaned = true;
|
||||||
|
name = newName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newName = cleaned ? name : string.Empty;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryClean(string name, Regex expression, out string newName)
|
||||||
|
{
|
||||||
|
var match = expression.Match(name);
|
||||||
|
if (match.Success && match.Groups.TryGetValue("cleaned", out var cleaned))
|
||||||
|
{
|
||||||
|
newName = cleaned.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
newName = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Holder object for passing results from ExtraResolver.
|
||||||
|
/// </summary>
|
||||||
|
public class ExtraResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the extra.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The type of the extra.</value>
|
||||||
|
public ExtraType? ExtraType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the rule.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The rule.</value>
|
||||||
|
public ExtraRule? Rule { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaType = Emby.Naming.Common.MediaType;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A rule used to match a file path with an <see cref="MediaBrowser.Model.Entities.ExtraType"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class ExtraRule
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ExtraRule"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="extraType">Type of extra.</param>
|
||||||
|
/// <param name="ruleType">Type of rule.</param>
|
||||||
|
/// <param name="token">Token.</param>
|
||||||
|
/// <param name="mediaType">Media type.</param>
|
||||||
|
public ExtraRule(ExtraType extraType, ExtraRuleType ruleType, string token, MediaType mediaType)
|
||||||
|
{
|
||||||
|
Token = token;
|
||||||
|
ExtraType = extraType;
|
||||||
|
RuleType = ruleType;
|
||||||
|
MediaType = mediaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the token to use for matching against the file path.
|
||||||
|
/// </summary>
|
||||||
|
public string Token { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the extra to return when matched.
|
||||||
|
/// </summary>
|
||||||
|
public ExtraType ExtraType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the rule.
|
||||||
|
/// </summary>
|
||||||
|
public ExtraRuleType RuleType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the media to return when matched.
|
||||||
|
/// </summary>
|
||||||
|
public MediaType MediaType { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Emby.Naming.Audio;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve if file is extra for video.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExtraRuleResolver
|
||||||
|
{
|
||||||
|
private static readonly char[] _digits = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to resolve if file is extra.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to file.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
|
||||||
|
/// <returns>Returns <see cref="ExtraResult"/> object.</returns>
|
||||||
|
public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions, string? libraryRoot = "")
|
||||||
|
{
|
||||||
|
ExtraResult result = new ExtraResult();
|
||||||
|
|
||||||
|
bool isAudioFile = AudioFileParser.IsAudioFile(path, namingOptions);
|
||||||
|
bool isVideoFile = VideoResolver.IsVideoFile(path, namingOptions);
|
||||||
|
|
||||||
|
ReadOnlySpan<char> pathSpan = path.AsSpan();
|
||||||
|
ReadOnlySpan<char> fileName = Path.GetFileName(pathSpan);
|
||||||
|
ReadOnlySpan<char> fileNameWithoutExtension = Path.GetFileNameWithoutExtension(pathSpan);
|
||||||
|
// Trim the digits from the end of the filename so we can recognize things like -trailer2
|
||||||
|
ReadOnlySpan<char> trimmedFileNameWithoutExtension = fileNameWithoutExtension.TrimEnd(_digits);
|
||||||
|
ReadOnlySpan<char> directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan));
|
||||||
|
string fullDirectory = Path.GetDirectoryName(pathSpan).ToString();
|
||||||
|
|
||||||
|
foreach (ExtraRule rule in namingOptions.VideoExtraRules)
|
||||||
|
{
|
||||||
|
if ((rule.MediaType == MediaType.Audio && !isAudioFile)
|
||||||
|
|| (rule.MediaType == MediaType.Video && !isVideoFile))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isMatch = rule.RuleType switch
|
||||||
|
{
|
||||||
|
ExtraRuleType.Filename => fileNameWithoutExtension.Equals(rule.Token, StringComparison.OrdinalIgnoreCase),
|
||||||
|
ExtraRuleType.Suffix => trimmedFileNameWithoutExtension.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase),
|
||||||
|
ExtraRuleType.Regex => Regex.IsMatch(fileName, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||||
|
ExtraRuleType.DirectoryName => directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(fullDirectory, libraryRoot, StringComparison.OrdinalIgnoreCase),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isMatch)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ExtraType = rule.ExtraType;
|
||||||
|
result.Rule = rule;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extra rules type to determine against what <see cref="ExtraRule.Token"/> should be matched.
|
||||||
|
/// </summary>
|
||||||
|
public enum ExtraRuleType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Match <see cref="ExtraRule.Token"/> against a suffix in the file name.
|
||||||
|
/// </summary>
|
||||||
|
Suffix = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Match <see cref="ExtraRule.Token"/> against the file name, excluding the file extension.
|
||||||
|
/// </summary>
|
||||||
|
Filename = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Match <see cref="ExtraRule.Token"/> against the file name, including the file extension.
|
||||||
|
/// </summary>
|
||||||
|
Regex = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Match <see cref="ExtraRule.Token"/> against the name of the directory containing the file.
|
||||||
|
/// </summary>
|
||||||
|
DirectoryName = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Object holding list of files paths with additional information.
|
||||||
|
/// </summary>
|
||||||
|
public class FileStack
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="FileStack"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The stack name.</param>
|
||||||
|
/// <param name="isDirectory">Whether the stack files are directories.</param>
|
||||||
|
/// <param name="files">The stack files.</param>
|
||||||
|
public FileStack(string name, bool isDirectory, IReadOnlyList<string> files)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
IsDirectoryStack = isDirectory;
|
||||||
|
Files = files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of file stack.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of paths in stack.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> Files { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether stack is directory stack.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDirectoryStack { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper function to determine if path is in the stack.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="file">Path of desired file.</param>
|
||||||
|
/// <param name="isDirectory">Requested type of stack.</param>
|
||||||
|
/// <returns>True if file is in the stack.</returns>
|
||||||
|
public bool ContainsFile(string file, bool isDirectory)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(file))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsDirectoryStack == isDirectory && Files.Contains(file, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regex based rule for file stacking (eg. disc1, disc2).
|
||||||
|
/// </summary>
|
||||||
|
public class FileStackRule
|
||||||
|
{
|
||||||
|
private readonly Regex _tokenRegex;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="FileStackRule"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">Token.</param>
|
||||||
|
/// <param name="isNumerical">Whether the file stack rule uses numerical or alphabetical numbering.</param>
|
||||||
|
public FileStackRule(string token, bool isNumerical)
|
||||||
|
{
|
||||||
|
_tokenRegex = new Regex(token, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
IsNumerical = isNumerical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the rule uses numerical or alphabetical numbering.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsNumerical { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Match the input against the rule regex.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">The input.</param>
|
||||||
|
/// <param name="result">The part type and number or <c>null</c>.</param>
|
||||||
|
/// <returns>A value indicating whether the input matched the rule.</returns>
|
||||||
|
public bool Match(string input, [NotNullWhen(true)] out (string StackName, string PartType, string PartNumber)? result)
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
var match = _tokenRegex.Match(input);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var partType = match.Groups["parttype"].Success ? match.Groups["parttype"].Value : "unknown";
|
||||||
|
result = (match.Groups["filename"].Value, partType, match.Groups["number"].Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parse 3D format related flags.
|
||||||
|
/// </summary>
|
||||||
|
public static class Format3DParser
|
||||||
|
{
|
||||||
|
// Static default result to save on allocation costs.
|
||||||
|
private static readonly Format3DResult _defaultResult = new(false, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse 3D format related flags.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to file.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <returns>Returns <see cref="Format3DResult"/> object.</returns>
|
||||||
|
public static Format3DResult Parse(ReadOnlySpan<char> path, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
int oldLen = namingOptions.VideoFlagDelimiters.Length;
|
||||||
|
Span<char> delimiters = stackalloc char[oldLen + 1];
|
||||||
|
namingOptions.VideoFlagDelimiters.AsSpan().CopyTo(delimiters);
|
||||||
|
delimiters[oldLen] = ' ';
|
||||||
|
|
||||||
|
return Parse(path, delimiters, namingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Format3DResult Parse(ReadOnlySpan<char> path, ReadOnlySpan<char> delimiters, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
foreach (var rule in namingOptions.Format3DRules)
|
||||||
|
{
|
||||||
|
var result = Parse(path, rule, delimiters);
|
||||||
|
|
||||||
|
if (result.Is3D)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _defaultResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Format3DResult Parse(ReadOnlySpan<char> path, Format3DRule rule, ReadOnlySpan<char> delimiters)
|
||||||
|
{
|
||||||
|
bool is3D = false;
|
||||||
|
string? format3D = null;
|
||||||
|
|
||||||
|
// If there's no preceding token we just consider it found
|
||||||
|
var foundPrefix = string.IsNullOrEmpty(rule.PrecedingToken);
|
||||||
|
while (path.Length > 0)
|
||||||
|
{
|
||||||
|
var index = path.IndexOfAny(delimiters);
|
||||||
|
if (index == -1)
|
||||||
|
{
|
||||||
|
index = path.Length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSlice = path[..index];
|
||||||
|
path = path[(index + 1)..];
|
||||||
|
|
||||||
|
if (!foundPrefix)
|
||||||
|
{
|
||||||
|
foundPrefix = currentSlice.Equals(rule.PrecedingToken, StringComparison.OrdinalIgnoreCase);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
is3D = foundPrefix && currentSlice.Equals(rule.Token, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (is3D)
|
||||||
|
{
|
||||||
|
format3D = rule.Token;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return is3D ? new Format3DResult(true, format3D) : _defaultResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Helper object to return data from <see cref="Format3DParser"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class Format3DResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Format3DResult"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="is3D">A value indicating whether the parsed string contains 3D tokens.</param>
|
||||||
|
/// <param name="format3D">The 3D format. Value might be null if [is3D] is <c>false</c>.</param>
|
||||||
|
public Format3DResult(bool is3D, string? format3D)
|
||||||
|
{
|
||||||
|
Is3D = is3D;
|
||||||
|
Format3D = format3D;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether [is3 d].
|
||||||
|
/// </summary>
|
||||||
|
/// <value><c>true</c> if [is3 d]; otherwise, <c>false</c>.</value>
|
||||||
|
public bool Is3D { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the format3 d.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The format3 d.</value>
|
||||||
|
public string? Format3D { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data holder class for 3D format rule.
|
||||||
|
/// </summary>
|
||||||
|
public class Format3DRule
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="Format3DRule"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">Token.</param>
|
||||||
|
/// <param name="precedingToken">Token present before current token.</param>
|
||||||
|
public Format3DRule(string token, string? precedingToken = null)
|
||||||
|
{
|
||||||
|
Token = token;
|
||||||
|
PrecedingToken = precedingToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the token.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The token.</value>
|
||||||
|
public string Token { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the preceding token.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The preceding token.</value>
|
||||||
|
public string? PrecedingToken { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Emby.Naming.AudioBook;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve <see cref="FileStack"/> from list of paths.
|
||||||
|
/// </summary>
|
||||||
|
public static class StackResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves only directories from paths.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="files">List of paths.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <returns>Enumerable <see cref="FileStack"/> of directories.</returns>
|
||||||
|
public static IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true }), namingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves only files from paths.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="files">List of paths.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <returns>Enumerable <see cref="FileStack"/> of files.</returns>
|
||||||
|
public static IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false }), namingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves audiobooks from paths.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="files">List of paths.</param>
|
||||||
|
/// <returns>Enumerable <see cref="FileStack"/> of directories.</returns>
|
||||||
|
public static IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files)
|
||||||
|
{
|
||||||
|
var groupedDirectoryFiles = files.GroupBy(file => Path.GetDirectoryName(file.Path));
|
||||||
|
|
||||||
|
foreach (var directory in groupedDirectoryFiles)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(directory.Key))
|
||||||
|
{
|
||||||
|
foreach (var file in directory)
|
||||||
|
{
|
||||||
|
var stack = new FileStack(Path.GetFileNameWithoutExtension(file.Path), false, new[] { file.Path });
|
||||||
|
yield return stack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var stack = new FileStack(Path.GetFileName(directory.Key), false, directory.Select(f => f.Path).ToArray());
|
||||||
|
yield return stack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves videos from paths.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="files">List of paths.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <returns>Enumerable <see cref="FileStack"/> of videos.</returns>
|
||||||
|
public static IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
var potentialFiles = files
|
||||||
|
.Where(i => i.IsDirectory || VideoResolver.IsVideoFile(i.FullName, namingOptions) || VideoResolver.IsStubFile(i.FullName, namingOptions))
|
||||||
|
.OrderBy(i => i.FullName);
|
||||||
|
|
||||||
|
var potentialStacks = new Dictionary<string, StackMetadata>();
|
||||||
|
foreach (var file in potentialFiles)
|
||||||
|
{
|
||||||
|
var name = file.Name;
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
name = Path.GetFileName(file.FullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < namingOptions.VideoFileStackingRules.Length; i++)
|
||||||
|
{
|
||||||
|
var rule = namingOptions.VideoFileStackingRules[i];
|
||||||
|
if (!rule.Match(name, out var stackParsingResult))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stackName = stackParsingResult.Value.StackName;
|
||||||
|
var partNumber = stackParsingResult.Value.PartNumber;
|
||||||
|
var partType = stackParsingResult.Value.PartType;
|
||||||
|
|
||||||
|
if (!potentialStacks.TryGetValue(stackName, out var stackResult))
|
||||||
|
{
|
||||||
|
stackResult = new StackMetadata(file.IsDirectory, rule.IsNumerical, partType);
|
||||||
|
potentialStacks[stackName] = stackResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stackResult.Parts.Count > 0)
|
||||||
|
{
|
||||||
|
if (stackResult.IsDirectory != file.IsDirectory
|
||||||
|
|| !string.Equals(partType, stackResult.PartType, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stackResult.ContainsPart(partNumber))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.IsNumerical != stackResult.IsNumerical)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stackResult.Parts.Add(partNumber, file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (fileName, stack) in potentialStacks)
|
||||||
|
{
|
||||||
|
if (stack.Parts.Count < 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return new FileStack(fileName, stack.IsDirectory, stack.Parts.Select(kv => kv.Value.FullName).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StackMetadata
|
||||||
|
{
|
||||||
|
public StackMetadata(bool isDirectory, bool isNumerical, string partType)
|
||||||
|
{
|
||||||
|
Parts = new Dictionary<string, FileSystemMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
IsDirectory = isDirectory;
|
||||||
|
IsNumerical = isNumerical;
|
||||||
|
PartType = partType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, FileSystemMetadata> Parts { get; }
|
||||||
|
|
||||||
|
public bool IsDirectory { get; }
|
||||||
|
|
||||||
|
public bool IsNumerical { get; }
|
||||||
|
|
||||||
|
public string PartType { get; }
|
||||||
|
|
||||||
|
public bool ContainsPart(string partNumber) => Parts.ContainsKey(partNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve if file is stub (.disc).
|
||||||
|
/// </summary>
|
||||||
|
public static class StubResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to resolve if file is stub (.disc).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to file.</param>
|
||||||
|
/// <param name="options">NamingOptions containing StubFileExtensions and StubTypes.</param>
|
||||||
|
/// <param name="stubType">Stub type.</param>
|
||||||
|
/// <returns>True if file is a stub.</returns>
|
||||||
|
public static bool TryResolveFile(string path, NamingOptions options, out string? stubType)
|
||||||
|
{
|
||||||
|
stubType = default;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(path.AsSpan());
|
||||||
|
|
||||||
|
if (!options.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = Path.GetExtension(Path.GetFileNameWithoutExtension(path.AsSpan())).TrimStart('.');
|
||||||
|
|
||||||
|
foreach (var rule in options.StubTypes)
|
||||||
|
{
|
||||||
|
if (token.Equals(rule.Token, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
stubType = rule.StubType;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data class holding information about Stub type rule.
|
||||||
|
/// </summary>
|
||||||
|
public class StubTypeRule
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="StubTypeRule"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">Token.</param>
|
||||||
|
/// <param name="stubType">Stub type.</param>
|
||||||
|
public StubTypeRule(string token, string stubType)
|
||||||
|
{
|
||||||
|
Token = token;
|
||||||
|
StubType = stubType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the token.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The token.</value>
|
||||||
|
public string Token { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the stub.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The type of the stub.</value>
|
||||||
|
public string StubType { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single video file.
|
||||||
|
/// </summary>
|
||||||
|
public class VideoFileInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="VideoFileInfo"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Name of file.</param>
|
||||||
|
/// <param name="path">Path to the file.</param>
|
||||||
|
/// <param name="container">Container type.</param>
|
||||||
|
/// <param name="year">Year of release.</param>
|
||||||
|
/// <param name="extraType">Extra type.</param>
|
||||||
|
/// <param name="extraRule">Extra rule.</param>
|
||||||
|
/// <param name="format3D">Format 3D.</param>
|
||||||
|
/// <param name="is3D">Is 3D.</param>
|
||||||
|
/// <param name="isStub">Is Stub.</param>
|
||||||
|
/// <param name="stubType">Stub type.</param>
|
||||||
|
/// <param name="isDirectory">Is directory.</param>
|
||||||
|
public VideoFileInfo(string name, string path, string? container, int? year = default, ExtraType? extraType = default, ExtraRule? extraRule = default, string? format3D = default, bool is3D = default, bool isStub = default, string? stubType = default, bool isDirectory = default)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
Container = container;
|
||||||
|
Name = name;
|
||||||
|
Year = year;
|
||||||
|
ExtraType = extraType;
|
||||||
|
ExtraRule = extraRule;
|
||||||
|
Format3D = format3D;
|
||||||
|
Is3D = is3D;
|
||||||
|
IsStub = isStub;
|
||||||
|
StubType = stubType;
|
||||||
|
IsDirectory = isDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the path.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The path.</value>
|
||||||
|
public string Path { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the container.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The container.</value>
|
||||||
|
public string? Container { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The name.</value>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the year.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The year.</value>
|
||||||
|
public int? Year { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the extra, e.g. trailer, theme song, behind the scenes, etc.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The type of the extra.</value>
|
||||||
|
public ExtraType? ExtraType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the extra rule.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The extra rule.</value>
|
||||||
|
public ExtraRule? ExtraRule { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the format3 d.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The format3 d.</value>
|
||||||
|
public string? Format3D { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether [is3 d].
|
||||||
|
/// </summary>
|
||||||
|
/// <value><c>true</c> if [is3 d]; otherwise, <c>false</c>.</value>
|
||||||
|
public bool Is3D { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether this instance is stub.
|
||||||
|
/// </summary>
|
||||||
|
/// <value><c>true</c> if this instance is stub; otherwise, <c>false</c>.</value>
|
||||||
|
public bool IsStub { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the stub.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The type of the stub.</value>
|
||||||
|
public string? StubType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether this instance is a directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The type.</value>
|
||||||
|
public bool IsDirectory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the file name without extension.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The file name without extension.</value>
|
||||||
|
public ReadOnlySpan<char> FileNameWithoutExtension => !IsDirectory
|
||||||
|
? System.IO.Path.GetFileNameWithoutExtension(Path.AsSpan())
|
||||||
|
: System.IO.Path.GetFileName(Path.AsSpan());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return "VideoFileInfo(Name: '" + Name + "')";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a complete video, including all parts and subtitles.
|
||||||
|
/// </summary>
|
||||||
|
public class VideoInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="VideoInfo" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The name.</param>
|
||||||
|
public VideoInfo(string? name)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
|
||||||
|
Files = Array.Empty<VideoFileInfo>();
|
||||||
|
AlternateVersions = Array.Empty<VideoFileInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The name.</value>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the year.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The year.</value>
|
||||||
|
public int? Year { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the files.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The files.</value>
|
||||||
|
public IReadOnlyList<VideoFileInfo> Files { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the alternate versions.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The alternate versions.</value>
|
||||||
|
public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the extra type.
|
||||||
|
/// </summary>
|
||||||
|
public ExtraType? ExtraType { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves alternative versions and extras from list of video files.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class VideoListResolver
|
||||||
|
{
|
||||||
|
[GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex ResolutionRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\[([^]]*)\]")]
|
||||||
|
private static partial Regex CheckMultiVersionRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves alternative versions and extras from list of video files.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="videoInfos">List of related video files.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
|
||||||
|
/// <param name="parseName">Whether to parse the name or use the filename.</param>
|
||||||
|
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
|
||||||
|
/// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
|
||||||
|
public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "")
|
||||||
|
{
|
||||||
|
// Filter out all extras, otherwise they could cause stacks to not be resolved
|
||||||
|
// See the unit test TestStackedWithTrailer
|
||||||
|
var nonExtras = videoInfos
|
||||||
|
.Where(i => i.ExtraType is null)
|
||||||
|
.Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory });
|
||||||
|
|
||||||
|
var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList();
|
||||||
|
|
||||||
|
var remainingFiles = new List<VideoFileInfo>();
|
||||||
|
var standaloneMedia = new List<VideoFileInfo>();
|
||||||
|
|
||||||
|
for (var i = 0; i < videoInfos.Count; i++)
|
||||||
|
{
|
||||||
|
var current = videoInfos[i];
|
||||||
|
if (stackResult.Any(s => s.ContainsFile(current.Path, current.IsDirectory)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.ExtraType is null)
|
||||||
|
{
|
||||||
|
standaloneMedia.Add(current);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
remainingFiles.Add(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new List<VideoInfo>();
|
||||||
|
|
||||||
|
foreach (var stack in stackResult)
|
||||||
|
{
|
||||||
|
var info = new VideoInfo(stack.Name)
|
||||||
|
{
|
||||||
|
Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName, libraryRoot))
|
||||||
|
.OfType<VideoFileInfo>()
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
info.Year = info.Files[0].Year;
|
||||||
|
list.Add(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var media in standaloneMedia)
|
||||||
|
{
|
||||||
|
var info = new VideoInfo(media.Name) { Files = new[] { media } };
|
||||||
|
|
||||||
|
info.Year = info.Files[0].Year;
|
||||||
|
list.Add(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportMultiVersion)
|
||||||
|
{
|
||||||
|
list = GetVideosGroupedByVersion(list, namingOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever files are left, just add them
|
||||||
|
list.AddRange(remainingFiles.Select(i => new VideoInfo(i.Name)
|
||||||
|
{
|
||||||
|
Files = new[] { i },
|
||||||
|
Year = i.Year,
|
||||||
|
ExtraType = i.ExtraType
|
||||||
|
}));
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
if (videos.Count == 0)
|
||||||
|
{
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
var folderName = Path.GetFileName(Path.GetDirectoryName(videos[0].Files[0].Path.AsSpan()));
|
||||||
|
|
||||||
|
if (folderName.Length <= 1 || !HaveSameYear(videos))
|
||||||
|
{
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the above [if]
|
||||||
|
VideoInfo? primary = null;
|
||||||
|
for (var i = 0; i < videos.Count; i++)
|
||||||
|
{
|
||||||
|
var video = videos[i];
|
||||||
|
if (video.ExtraType is not null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions))
|
||||||
|
{
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderName.Equals(video.Files[0].FileNameWithoutExtension, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
primary = video;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videos.Count > 1)
|
||||||
|
{
|
||||||
|
var groups = videos
|
||||||
|
.Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x))
|
||||||
|
.Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value))
|
||||||
|
.GroupBy(x => x.resolutionMatch.Success)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
videos.Clear();
|
||||||
|
|
||||||
|
StringComparer comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (group.Key)
|
||||||
|
{
|
||||||
|
videos.InsertRange(0, group
|
||||||
|
.OrderByDescending(x => x.resolutionMatch.Value, comparer)
|
||||||
|
.ThenBy(x => x.filename, comparer)
|
||||||
|
.Select(x => x.value));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
videos.AddRange(group.OrderBy(x => x.filename, comparer).Select(x => x.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
primary ??= videos[0];
|
||||||
|
videos.Remove(primary);
|
||||||
|
|
||||||
|
var list = new List<VideoInfo>
|
||||||
|
{
|
||||||
|
primary
|
||||||
|
};
|
||||||
|
|
||||||
|
list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray();
|
||||||
|
list[0].Name = folderName.ToString();
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos)
|
||||||
|
{
|
||||||
|
if (videos.Count == 1)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstYear = videos[0].Year ?? -1;
|
||||||
|
for (var i = 1; i < videos.Count; i++)
|
||||||
|
{
|
||||||
|
if ((videos[i].Year ?? -1) != firstYear)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the folder name before cleaning as we don't care about cleaning that part
|
||||||
|
if (folderName.Length <= testFilename.Length)
|
||||||
|
{
|
||||||
|
testFilename = testFilename[folderName.Length..].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// There are no span overloads for regex unfortunately
|
||||||
|
if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName))
|
||||||
|
{
|
||||||
|
testFilename = cleanName.AsSpan().Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CleanStringParser should have removed common keywords etc.
|
||||||
|
return testFilename.IsEmpty
|
||||||
|
|| testFilename[0] == '-'
|
||||||
|
|| CheckMultiVersionRegex().IsMatch(testFilename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.IO;
|
||||||
|
using Emby.Naming.Common;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
|
||||||
|
namespace Emby.Naming.Video
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves <see cref="VideoFileInfo"/> from file path.
|
||||||
|
/// </summary>
|
||||||
|
public static class VideoResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <param name="parseName">Whether to parse the name or use the filename.</param>
|
||||||
|
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
|
||||||
|
/// <returns>VideoFileInfo.</returns>
|
||||||
|
public static VideoFileInfo? ResolveDirectory(string? path, NamingOptions namingOptions, bool parseName = true, string? libraryRoot = "")
|
||||||
|
{
|
||||||
|
return Resolve(path, true, namingOptions, parseName, libraryRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
|
||||||
|
/// <returns>VideoFileInfo.</returns>
|
||||||
|
public static VideoFileInfo? ResolveFile(string? path, NamingOptions namingOptions, string? libraryRoot = "")
|
||||||
|
{
|
||||||
|
return Resolve(path, false, namingOptions, libraryRoot: libraryRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the specified path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path.</param>
|
||||||
|
/// <param name="isDirectory">if set to <c>true</c> [is folder].</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <param name="parseName">Whether or not the name should be parsed for info.</param>
|
||||||
|
/// <param name="libraryRoot">Top-level folder for the containing library.</param>
|
||||||
|
/// <returns>VideoFileInfo.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><c>path</c> is <c>null</c>.</exception>
|
||||||
|
public static VideoFileInfo? Resolve(string? path, bool isDirectory, NamingOptions namingOptions, bool parseName = true, string? libraryRoot = "")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isStub = false;
|
||||||
|
ReadOnlySpan<char> container = ReadOnlySpan<char>.Empty;
|
||||||
|
string? stubType = null;
|
||||||
|
|
||||||
|
if (!isDirectory)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(path.AsSpan());
|
||||||
|
|
||||||
|
// Check supported extensions
|
||||||
|
if (!namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// It's not supported. Check stub extensions
|
||||||
|
if (!StubResolver.TryResolveFile(path, namingOptions, out stubType))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isStub = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
container = extension.TrimStart('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
var format3DResult = Format3DParser.Parse(path, namingOptions);
|
||||||
|
|
||||||
|
var extraResult = ExtraRuleResolver.GetExtraInfo(path, namingOptions, libraryRoot);
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
|
||||||
|
int? year = null;
|
||||||
|
|
||||||
|
if (parseName)
|
||||||
|
{
|
||||||
|
var cleanDateTimeResult = CleanDateTime(name, namingOptions);
|
||||||
|
name = cleanDateTimeResult.Name;
|
||||||
|
year = cleanDateTimeResult.Year;
|
||||||
|
|
||||||
|
if (TryCleanString(name, namingOptions, out var newName))
|
||||||
|
{
|
||||||
|
name = newName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VideoFileInfo(
|
||||||
|
path: path,
|
||||||
|
container: container.IsEmpty ? null : container.ToString(),
|
||||||
|
isStub: isStub,
|
||||||
|
name: name,
|
||||||
|
year: year,
|
||||||
|
stubType: stubType,
|
||||||
|
is3D: format3DResult.Is3D,
|
||||||
|
format3D: format3DResult.Format3D,
|
||||||
|
extraType: extraResult.ExtraType,
|
||||||
|
isDirectory: isDirectory,
|
||||||
|
extraRule: extraResult.Rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if path is video file based on extension.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to file.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <returns>True if is video file.</returns>
|
||||||
|
public static bool IsVideoFile(string path, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(path.AsSpan());
|
||||||
|
return namingOptions.VideoFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if path is video file stub based on extension.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to file.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <returns>True if is video file stub.</returns>
|
||||||
|
public static bool IsStubFile(string path, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(path.AsSpan());
|
||||||
|
return namingOptions.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to clean name of clutter.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Raw name.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <param name="newName">Clean name.</param>
|
||||||
|
/// <returns>True if cleaning of name was successful.</returns>
|
||||||
|
public static bool TryCleanString([NotNullWhen(true)] string? name, NamingOptions namingOptions, out string newName)
|
||||||
|
{
|
||||||
|
return CleanStringParser.TryClean(name, namingOptions.CleanStringRegexes, out newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get name and year from raw name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Raw name.</param>
|
||||||
|
/// <param name="namingOptions">The naming options.</param>
|
||||||
|
/// <returns>Returns <see cref="CleanDateTimeResult"/> with name and optional year.</returns>
|
||||||
|
public static CleanDateTimeResult CleanDateTime(string name, NamingOptions namingOptions)
|
||||||
|
{
|
||||||
|
return CleanDateTimeParser.Clean(name, namingOptions.CleanDateTimeRegexes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// <autogenerated />
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v10.0", FrameworkDisplayName = ".NET 10.0")]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.TargetFramework = net10.0
|
||||||
|
build_property.TargetFramework = net10.0
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion =
|
||||||
|
build_property.TargetPlatformMinVersion =
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = Emby.Naming
|
||||||
|
build_property.ProjectDir = /srv/common_drive/Projects/pgsql-jellyfin/Emby.Naming/
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||||
|
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
|
||||||
|
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
||||||
|
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
||||||
|
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">/home/wjones/.nuget/packages/</NuGetPackageRoot>
|
||||||
|
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">/home/wjones/.nuget/packages/</NuGetPackageFolders>
|
||||||
|
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
||||||
|
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<SourceRoot Include="/home/wjones/.nuget/packages/" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.entityframeworkcore/10.0.3/buildTransitive/net10.0/Microsoft.EntityFrameworkCore.props" Condition="Exists('$(NuGetPackageRoot)microsoft.entityframeworkcore/10.0.3/buildTransitive/net10.0/Microsoft.EntityFrameworkCore.props')" />
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.props" Condition="Exists('$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.props')" />
|
||||||
|
</ImportGroup>
|
||||||
|
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<PkgStyleCop_Analyzers_Unstable Condition=" '$(PkgStyleCop_Analyzers_Unstable)' == '' ">/home/wjones/.nuget/packages/stylecop.analyzers.unstable/1.2.0.556</PkgStyleCop_Analyzers_Unstable>
|
||||||
|
<PkgSmartAnalyzers_MultithreadingAnalyzer Condition=" '$(PkgSmartAnalyzers_MultithreadingAnalyzer)' == '' ">/home/wjones/.nuget/packages/smartanalyzers.multithreadinganalyzer/1.1.31</PkgSmartAnalyzers_MultithreadingAnalyzer>
|
||||||
|
<PkgSerilogAnalyzer Condition=" '$(PkgSerilogAnalyzer)' == '' ">/home/wjones/.nuget/packages/seriloganalyzer/0.15.0</PkgSerilogAnalyzer>
|
||||||
|
<PkgMicrosoft_CodeAnalysis_BannedApiAnalyzers Condition=" '$(PkgMicrosoft_CodeAnalysis_BannedApiAnalyzers)' == '' ">/home/wjones/.nuget/packages/microsoft.codeanalysis.bannedapianalyzers/4.14.0</PkgMicrosoft_CodeAnalysis_BannedApiAnalyzers>
|
||||||
|
<PkgIDisposableAnalyzers Condition=" '$(PkgIDisposableAnalyzers)' == '' ">/home/wjones/.nuget/packages/idisposableanalyzers/4.0.8</PkgIDisposableAnalyzers>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||||
|
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.extensions.options/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Options.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.options/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Options.targets')" />
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Logging.Abstractions.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Logging.Abstractions.targets')" />
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.targets')" />
|
||||||
|
</ImportGroup>
|
||||||
|
</Project>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"dgSpecHash": "nPaubMzeXPg=",
|
||||||
|
"success": true,
|
||||||
|
"projectFilePath": "/srv/common_drive/Projects/pgsql-jellyfin/Emby.Naming/Emby.Naming.csproj",
|
||||||
|
"expectedPackageFiles": [
|
||||||
|
"/home/wjones/.nuget/packages/diacritics/4.1.4/diacritics.4.1.4.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/icu4n/60.1.0-alpha.356/icu4n.60.1.0-alpha.356.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/icu4n.transliterator/60.1.0-alpha.356/icu4n.transliterator.60.1.0-alpha.356.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/idisposableanalyzers/4.0.8/idisposableanalyzers.4.0.8.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/j2n/2.0.0/j2n.2.0.0.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.codeanalysis.bannedapianalyzers/4.14.0/microsoft.codeanalysis.bannedapianalyzers.4.14.0.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore/10.0.3/microsoft.entityframeworkcore.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.abstractions/10.0.3/microsoft.entityframeworkcore.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.analyzers/10.0.3/microsoft.entityframeworkcore.analyzers.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.relational/10.0.3/microsoft.entityframeworkcore.relational.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.caching.abstractions/10.0.3/microsoft.extensions.caching.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.caching.memory/10.0.3/microsoft.extensions.caching.memory.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.configuration.abstractions/10.0.3/microsoft.extensions.configuration.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.dependencyinjection/10.0.3/microsoft.extensions.dependencyinjection.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/10.0.3/microsoft.extensions.dependencyinjection.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.logging/10.0.3/microsoft.extensions.logging.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.logging.abstractions/10.0.3/microsoft.extensions.logging.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.options/10.0.3/microsoft.extensions.options.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.primitives/10.0.3/microsoft.extensions.primitives.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/polly/8.6.5/polly.8.6.5.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/polly.core/8.6.5/polly.core.8.6.5.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/seriloganalyzer/0.15.0/seriloganalyzer.0.15.0.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/smartanalyzers.multithreadinganalyzer/1.1.31/smartanalyzers.multithreadinganalyzer.1.1.31.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/stylecop.analyzers/1.2.0-beta.556/stylecop.analyzers.1.2.0-beta.556.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/stylecop.analyzers.unstable/1.2.0.556/stylecop.analyzers.unstable.1.2.0.556.nupkg.sha512"
|
||||||
|
],
|
||||||
|
"logs": []
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
17714532054800000
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
17715044167700000
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<ProjectGuid>{89AB4548-770D-41FD-A891-8DAFF44F452C}</ProjectGuid>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
|
||||||
|
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\SharedVersion.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="TagLibSharp" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Code Analyzers -->
|
||||||
|
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
<PackageReference Include="IDisposableAnalyzers">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Controller.Drawing;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Drawing;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TagLib;
|
||||||
|
using TagLib.IFD;
|
||||||
|
using TagLib.IFD.Entries;
|
||||||
|
using TagLib.IFD.Tags;
|
||||||
|
|
||||||
|
namespace Emby.Photos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata provider for photos.
|
||||||
|
/// </summary>
|
||||||
|
public class PhotoProvider : ICustomMetadataProvider<Photo>, IForcedProvider, IHasItemChangeMonitor
|
||||||
|
{
|
||||||
|
private readonly ILogger<PhotoProvider> _logger;
|
||||||
|
private readonly IImageProcessor _imageProcessor;
|
||||||
|
|
||||||
|
// Other extensions might cause taglib to hang
|
||||||
|
private readonly string[] _includeExtensions = [".jpg", ".jpeg", ".png", ".tiff", ".cr2", ".webp", ".avif"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PhotoProvider" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="imageProcessor">The image processor.</param>
|
||||||
|
public PhotoProvider(ILogger<PhotoProvider> logger, IImageProcessor imageProcessor)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_imageProcessor = imageProcessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Embedded Information";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
|
||||||
|
{
|
||||||
|
if (item.IsFileProtocol)
|
||||||
|
{
|
||||||
|
var file = directoryService.GetFile(item.Path);
|
||||||
|
return file is not null && item.HasChanged(file.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ItemUpdateType> FetchAsync(Photo item, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
item.SetImagePath(ImageType.Primary, item.Path);
|
||||||
|
|
||||||
|
// Examples: https://github.com/mono/taglib-sharp/blob/a5f6949a53d09ce63ee7495580d6802921a21f14/tests/fixtures/TagLib.Tests.Images/NullOrientationTest.cs
|
||||||
|
if (_includeExtensions.Contains(Path.GetExtension(item.Path.AsSpan()), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var file = TagLib.File.Create(item.Path);
|
||||||
|
if (file.GetTag(TagTypes.TiffIFD) is IFDTag tag)
|
||||||
|
{
|
||||||
|
var structure = tag.Structure;
|
||||||
|
if (structure?.GetEntry(0, (ushort)IFDEntryTag.ExifIFD) is SubIFDEntry exif)
|
||||||
|
{
|
||||||
|
var exifStructure = exif.Structure;
|
||||||
|
if (exifStructure is not null)
|
||||||
|
{
|
||||||
|
if (exifStructure.GetEntry(0, (ushort)ExifEntryTag.ApertureValue) is RationalIFDEntry apertureEntry)
|
||||||
|
{
|
||||||
|
item.Aperture = (double)apertureEntry.Value.Numerator / apertureEntry.Value.Denominator;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exifStructure.GetEntry(0, (ushort)ExifEntryTag.ShutterSpeedValue) is RationalIFDEntry shutterSpeedEntry)
|
||||||
|
{
|
||||||
|
item.ShutterSpeed = (double)shutterSpeedEntry.Value.Numerator / shutterSpeedEntry.Value.Denominator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file is TagLib.Image.File image)
|
||||||
|
{
|
||||||
|
item.CameraMake = image.ImageTag.Make;
|
||||||
|
item.CameraModel = image.ImageTag.Model;
|
||||||
|
|
||||||
|
item.Width = image.Properties.PhotoWidth;
|
||||||
|
item.Height = image.Properties.PhotoHeight;
|
||||||
|
|
||||||
|
item.CommunityRating = image.ImageTag.Rating;
|
||||||
|
|
||||||
|
item.Overview = image.ImageTag.Comment;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(image.ImageTag.Title)
|
||||||
|
&& !item.LockedFields.Contains(MetadataField.Name))
|
||||||
|
{
|
||||||
|
item.Name = image.ImageTag.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateTaken = image.ImageTag.DateTime;
|
||||||
|
if (dateTaken.HasValue)
|
||||||
|
{
|
||||||
|
item.DateCreated = dateTaken.Value.ToUniversalTime();
|
||||||
|
item.PremiereDate = dateTaken.Value;
|
||||||
|
item.ProductionYear = dateTaken.Value.Year;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Genres = image.ImageTag.Genres;
|
||||||
|
item.Tags = image.ImageTag.Keywords;
|
||||||
|
item.Software = image.ImageTag.Software;
|
||||||
|
|
||||||
|
if (image.ImageTag.Orientation == TagLib.Image.ImageOrientation.None)
|
||||||
|
{
|
||||||
|
item.Orientation = null;
|
||||||
|
}
|
||||||
|
else if (Enum.TryParse(image.ImageTag.Orientation.ToString(), true, out ImageOrientation orientation))
|
||||||
|
{
|
||||||
|
item.Orientation = orientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.ExposureTime = image.ImageTag.ExposureTime;
|
||||||
|
item.FocalLength = image.ImageTag.FocalLength;
|
||||||
|
|
||||||
|
item.Latitude = image.ImageTag.Latitude;
|
||||||
|
item.Longitude = image.ImageTag.Longitude;
|
||||||
|
item.Altitude = image.ImageTag.Altitude;
|
||||||
|
|
||||||
|
if (image.ImageTag.ISOSpeedRatings.HasValue)
|
||||||
|
{
|
||||||
|
item.IsoSpeedRating = Convert.ToInt32(image.ImageTag.ISOSpeedRatings.Value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
item.IsoSpeedRating = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Image Provider - Error reading image tag for {0}", item.Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Width <= 0 || item.Height <= 0)
|
||||||
|
{
|
||||||
|
var img = item.GetImageInfo(ImageType.Primary, 0);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var size = _imageProcessor.GetImageDimensions(item, img);
|
||||||
|
|
||||||
|
if (size.Width > 0 && size.Height > 0)
|
||||||
|
{
|
||||||
|
item.Width = size.Width;
|
||||||
|
item.Height = size.Height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// format not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemUpdateType Result = ItemUpdateType.ImageUpdate | ItemUpdateType.MetadataImport;
|
||||||
|
return Task.FromResult(Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Resources;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
// General Information about an assembly is controlled through the following
|
||||||
|
// set of attributes. Change these attribute values to modify the information
|
||||||
|
// associated with an assembly.
|
||||||
|
[assembly: AssemblyTitle("Emby.Photos")]
|
||||||
|
[assembly: AssemblyDescription("")]
|
||||||
|
[assembly: AssemblyConfiguration("")]
|
||||||
|
[assembly: AssemblyCompany("Jellyfin Project")]
|
||||||
|
[assembly: AssemblyProduct("Jellyfin Server")]
|
||||||
|
[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")]
|
||||||
|
[assembly: AssemblyTrademark("")]
|
||||||
|
[assembly: AssemblyCulture("")]
|
||||||
|
[assembly: NeutralResourcesLanguage("en")]
|
||||||
|
|
||||||
|
// Setting ComVisible to false makes the types in this assembly not visible
|
||||||
|
// to COM components. If you need to access a type in this assembly from
|
||||||
|
// COM, set the ComVisible attribute to true on that type.
|
||||||
|
[assembly: ComVisible(false)]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// <autogenerated />
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v10.0", FrameworkDisplayName = ".NET 10.0")]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
is_global = true
|
||||||
|
build_property.TargetFramework = net10.0
|
||||||
|
build_property.TargetFramework = net10.0
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetFrameworkVersion = v10.0
|
||||||
|
build_property.TargetPlatformMinVersion =
|
||||||
|
build_property.TargetPlatformMinVersion =
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.ProjectTypeGuids =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.InvariantGlobalization =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.PlatformNeutralAssembly =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
|
build_property.RootNamespace = Emby.Photos
|
||||||
|
build_property.ProjectDir = /srv/common_drive/Projects/pgsql-jellyfin/Emby.Photos/
|
||||||
|
build_property.EnableComHosting =
|
||||||
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
|
build_property.EffectiveAnalysisLevelStyle = 10.0
|
||||||
|
build_property.EnableCodeStyleSeverity =
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||||
|
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
|
||||||
|
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
||||||
|
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
||||||
|
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">/home/wjones/.nuget/packages/</NuGetPackageRoot>
|
||||||
|
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">/home/wjones/.nuget/packages/</NuGetPackageFolders>
|
||||||
|
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
||||||
|
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<SourceRoot Include="/home/wjones/.nuget/packages/" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.entityframeworkcore/10.0.3/buildTransitive/net10.0/Microsoft.EntityFrameworkCore.props" Condition="Exists('$(NuGetPackageRoot)microsoft.entityframeworkcore/10.0.3/buildTransitive/net10.0/Microsoft.EntityFrameworkCore.props')" />
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.props" Condition="Exists('$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.props')" />
|
||||||
|
</ImportGroup>
|
||||||
|
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<PkgStyleCop_Analyzers_Unstable Condition=" '$(PkgStyleCop_Analyzers_Unstable)' == '' ">/home/wjones/.nuget/packages/stylecop.analyzers.unstable/1.2.0.556</PkgStyleCop_Analyzers_Unstable>
|
||||||
|
<PkgSmartAnalyzers_MultithreadingAnalyzer Condition=" '$(PkgSmartAnalyzers_MultithreadingAnalyzer)' == '' ">/home/wjones/.nuget/packages/smartanalyzers.multithreadinganalyzer/1.1.31</PkgSmartAnalyzers_MultithreadingAnalyzer>
|
||||||
|
<PkgSerilogAnalyzer Condition=" '$(PkgSerilogAnalyzer)' == '' ">/home/wjones/.nuget/packages/seriloganalyzer/0.15.0</PkgSerilogAnalyzer>
|
||||||
|
<PkgMicrosoft_CodeAnalysis_BannedApiAnalyzers Condition=" '$(PkgMicrosoft_CodeAnalysis_BannedApiAnalyzers)' == '' ">/home/wjones/.nuget/packages/microsoft.codeanalysis.bannedapianalyzers/4.14.0</PkgMicrosoft_CodeAnalysis_BannedApiAnalyzers>
|
||||||
|
<PkgIDisposableAnalyzers Condition=" '$(PkgIDisposableAnalyzers)' == '' ">/home/wjones/.nuget/packages/idisposableanalyzers/4.0.8</PkgIDisposableAnalyzers>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||||
|
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.extensions.options/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Options.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.options/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Options.targets')" />
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Logging.Abstractions.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Logging.Abstractions.targets')" />
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.extensions.configuration.binder/10.0.3/buildTransitive/netstandard2.0/Microsoft.Extensions.Configuration.Binder.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.configuration.binder/10.0.3/buildTransitive/netstandard2.0/Microsoft.Extensions.Configuration.Binder.targets')" />
|
||||||
|
<Import Project="$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.targets')" />
|
||||||
|
</ImportGroup>
|
||||||
|
</Project>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"dgSpecHash": "KcJoyPeqtMs=",
|
||||||
|
"success": true,
|
||||||
|
"projectFilePath": "/srv/common_drive/Projects/pgsql-jellyfin/Emby.Photos/Emby.Photos.csproj",
|
||||||
|
"expectedPackageFiles": [
|
||||||
|
"/home/wjones/.nuget/packages/bitfaster.caching/2.5.4/bitfaster.caching.2.5.4.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/diacritics/4.1.4/diacritics.4.1.4.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/icu4n/60.1.0-alpha.356/icu4n.60.1.0-alpha.356.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/icu4n.transliterator/60.1.0-alpha.356/icu4n.transliterator.60.1.0-alpha.356.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/idisposableanalyzers/4.0.8/idisposableanalyzers.4.0.8.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/j2n/2.0.0/j2n.2.0.0.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.codeanalysis.bannedapianalyzers/4.14.0/microsoft.codeanalysis.bannedapianalyzers.4.14.0.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore/10.0.3/microsoft.entityframeworkcore.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.abstractions/10.0.3/microsoft.entityframeworkcore.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.analyzers/10.0.3/microsoft.entityframeworkcore.analyzers.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.relational/10.0.3/microsoft.entityframeworkcore.relational.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.caching.abstractions/10.0.3/microsoft.extensions.caching.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.caching.memory/10.0.3/microsoft.extensions.caching.memory.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.configuration/10.0.3/microsoft.extensions.configuration.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.configuration.abstractions/10.0.3/microsoft.extensions.configuration.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.configuration.binder/10.0.3/microsoft.extensions.configuration.binder.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.dependencyinjection/10.0.3/microsoft.extensions.dependencyinjection.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/10.0.3/microsoft.extensions.dependencyinjection.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.logging/10.0.3/microsoft.extensions.logging.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.logging.abstractions/10.0.3/microsoft.extensions.logging.abstractions.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.options/10.0.3/microsoft.extensions.options.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/microsoft.extensions.primitives/10.0.3/microsoft.extensions.primitives.10.0.3.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/nebml/1.1.0.5/nebml.1.1.0.5.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/polly/8.6.5/polly.8.6.5.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/polly.core/8.6.5/polly.core.8.6.5.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/seriloganalyzer/0.15.0/seriloganalyzer.0.15.0.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/smartanalyzers.multithreadinganalyzer/1.1.31/smartanalyzers.multithreadinganalyzer.1.1.31.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/stylecop.analyzers/1.2.0-beta.556/stylecop.analyzers.1.2.0-beta.556.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/stylecop.analyzers.unstable/1.2.0.556/stylecop.analyzers.unstable.1.2.0.556.nupkg.sha512",
|
||||||
|
"/home/wjones/.nuget/packages/taglibsharp/2.3.0/taglibsharp.2.3.0.nupkg.sha512"
|
||||||
|
],
|
||||||
|
"logs": []
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
17714532053700000
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
17715044201300000
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.AppBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provides a base class to hold common application paths used by both the UI and Server.
|
||||||
|
/// This can be subclassed to add application-specific paths.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class BaseApplicationPaths : IApplicationPaths
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="BaseApplicationPaths"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="programDataPath">The program data path.</param>
|
||||||
|
/// <param name="logDirectoryPath">The log directory path.</param>
|
||||||
|
/// <param name="configurationDirectoryPath">The configuration directory path.</param>
|
||||||
|
/// <param name="cacheDirectoryPath">The cache directory path.</param>
|
||||||
|
/// <param name="webDirectoryPath">The web directory path.</param>
|
||||||
|
protected BaseApplicationPaths(
|
||||||
|
string programDataPath,
|
||||||
|
string logDirectoryPath,
|
||||||
|
string configurationDirectoryPath,
|
||||||
|
string cacheDirectoryPath,
|
||||||
|
string webDirectoryPath)
|
||||||
|
{
|
||||||
|
ProgramDataPath = programDataPath;
|
||||||
|
LogDirectoryPath = logDirectoryPath;
|
||||||
|
ConfigurationDirectoryPath = configurationDirectoryPath;
|
||||||
|
CachePath = cacheDirectoryPath;
|
||||||
|
WebPath = webDirectoryPath;
|
||||||
|
DataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ProgramDataPath { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string WebPath { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ProgramSystemPath { get; } = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string DataPath { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string VirtualDataPath => "%AppDataPath%";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ImageCachePath => Path.Combine(CachePath, "images");
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string PluginsPath => Path.Combine(ProgramDataPath, "plugins");
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string PluginConfigurationsPath => Path.Combine(PluginsPath, "configurations");
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string LogDirectoryPath { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ConfigurationDirectoryPath { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string SystemConfigurationFilePath => Path.Combine(ConfigurationDirectoryPath, "system.xml");
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string CachePath { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string TempDirectory => Path.Join(Path.GetTempPath(), "jellyfin");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string TrickplayPath => Path.Combine(DataPath, "trickplay");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string BackupPath => Path.Combine(DataPath, "backups");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public virtual void MakeSanityCheckOrThrow()
|
||||||
|
{
|
||||||
|
CreateAndCheckMarker(ConfigurationDirectoryPath, "config");
|
||||||
|
CreateAndCheckMarker(LogDirectoryPath, "log");
|
||||||
|
CreateAndCheckMarker(PluginsPath, "plugin");
|
||||||
|
CreateAndCheckMarker(ProgramDataPath, "data");
|
||||||
|
CreateAndCheckMarker(CachePath, "cache");
|
||||||
|
CreateAndCheckMarker(DataPath, "data");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void CreateAndCheckMarker(string path, string markerName, bool recursive = false)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
|
||||||
|
CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetMarkers(string path, bool recursive = false)
|
||||||
|
{
|
||||||
|
return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
|
||||||
|
{
|
||||||
|
string? otherMarkers = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Error while checking for marker files, assume none exist and keep going
|
||||||
|
// TODO: add some logging
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherMarkers is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var markerPath = Path.Combine(path, markerName);
|
||||||
|
if (!File.Exists(markerPath))
|
||||||
|
{
|
||||||
|
FileHelper.CreateEmpty(markerPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Common.Events;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using MediaBrowser.Model.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.AppBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Class BaseConfigurationManager.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class BaseConfigurationManager : IConfigurationManager
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, object> _configurations = new();
|
||||||
|
private readonly Lock _configurationSyncLock = new();
|
||||||
|
|
||||||
|
private ConfigurationStore[] _configurationStores = Array.Empty<ConfigurationStore>();
|
||||||
|
private IConfigurationFactory[] _configurationFactories = Array.Empty<IConfigurationFactory>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The _configuration.
|
||||||
|
/// </summary>
|
||||||
|
private BaseApplicationConfiguration? _configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="BaseConfigurationManager" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="applicationPaths">The application paths.</param>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="xmlSerializer">The XML serializer.</param>
|
||||||
|
protected BaseConfigurationManager(
|
||||||
|
IApplicationPaths applicationPaths,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IXmlSerializer xmlSerializer)
|
||||||
|
{
|
||||||
|
CommonApplicationPaths = applicationPaths;
|
||||||
|
XmlSerializer = xmlSerializer;
|
||||||
|
Logger = loggerFactory.CreateLogger<BaseConfigurationManager>();
|
||||||
|
|
||||||
|
UpdateCachePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when [configuration updated].
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<EventArgs>? ConfigurationUpdated;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when [configuration updating].
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<ConfigurationUpdateEventArgs>? NamedConfigurationUpdating;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when [named configuration updated].
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<ConfigurationUpdateEventArgs>? NamedConfigurationUpdated;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type of the configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The type of the configuration.</value>
|
||||||
|
protected abstract Type ConfigurationType { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the logger.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The logger.</value>
|
||||||
|
protected ILogger<BaseConfigurationManager> Logger { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the XML serializer.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The XML serializer.</value>
|
||||||
|
protected IXmlSerializer XmlSerializer { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the application paths.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The application paths.</value>
|
||||||
|
public IApplicationPaths CommonApplicationPaths { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the system configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The configuration.</value>
|
||||||
|
public BaseApplicationConfiguration CommonConfiguration
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_configuration is not null)
|
||||||
|
{
|
||||||
|
return _configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_configurationSyncLock)
|
||||||
|
{
|
||||||
|
if (_configuration is not null)
|
||||||
|
{
|
||||||
|
return _configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _configuration = (BaseApplicationConfiguration)ConfigurationHelper.GetXmlConfiguration(ConfigurationType, CommonApplicationPaths.SystemConfigurationFilePath, XmlSerializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected set
|
||||||
|
{
|
||||||
|
_configuration = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manually pre-loads a factory so that it is available pre system initialisation.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Class to register.</typeparam>
|
||||||
|
public virtual void RegisterConfiguration<T>()
|
||||||
|
where T : IConfigurationFactory
|
||||||
|
{
|
||||||
|
IConfigurationFactory factory = Activator.CreateInstance<T>();
|
||||||
|
|
||||||
|
if (_configurationFactories is null)
|
||||||
|
{
|
||||||
|
_configurationFactories = [factory];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_configurationFactories = [.._configurationFactories, factory];
|
||||||
|
}
|
||||||
|
|
||||||
|
_configurationStores = _configurationFactories
|
||||||
|
.SelectMany(i => i.GetConfigurations())
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds parts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="factories">The configuration factories.</param>
|
||||||
|
public virtual void AddParts(IEnumerable<IConfigurationFactory> factories)
|
||||||
|
{
|
||||||
|
_configurationFactories = factories.ToArray();
|
||||||
|
|
||||||
|
_configurationStores = _configurationFactories
|
||||||
|
.SelectMany(i => i.GetConfigurations())
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the configuration.
|
||||||
|
/// </summary>
|
||||||
|
public void SaveConfiguration()
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Saving system configuration");
|
||||||
|
var path = CommonApplicationPaths.SystemConfigurationFilePath;
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Path can't be a root directory."));
|
||||||
|
|
||||||
|
lock (_configurationSyncLock)
|
||||||
|
{
|
||||||
|
XmlSerializer.SerializeToFile(CommonConfiguration, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnConfigurationUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when [configuration updated].
|
||||||
|
/// </summary>
|
||||||
|
protected virtual void OnConfigurationUpdated()
|
||||||
|
{
|
||||||
|
UpdateCachePath();
|
||||||
|
|
||||||
|
EventHelper.QueueEventIfNotNull(ConfigurationUpdated, this, EventArgs.Empty, Logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newConfiguration">The new configuration.</param>
|
||||||
|
/// <exception cref="ArgumentNullException"><c>newConfiguration</c> is <c>null</c>.</exception>
|
||||||
|
public virtual void ReplaceConfiguration(BaseApplicationConfiguration newConfiguration)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newConfiguration);
|
||||||
|
|
||||||
|
ValidateCachePath(newConfiguration);
|
||||||
|
|
||||||
|
CommonConfiguration = newConfiguration;
|
||||||
|
SaveConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the items by name path.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateCachePath()
|
||||||
|
{
|
||||||
|
string cachePath;
|
||||||
|
|
||||||
|
// If the configuration file has no entry (i.e. not set in UI)
|
||||||
|
if (string.IsNullOrWhiteSpace(CommonConfiguration.CachePath))
|
||||||
|
{
|
||||||
|
// If the current live configuration has no entry (i.e. not set on CLI/envvars, during startup)
|
||||||
|
if (string.IsNullOrWhiteSpace(((BaseApplicationPaths)CommonApplicationPaths).CachePath))
|
||||||
|
{
|
||||||
|
// Set cachePath to a default value under ProgramDataPath
|
||||||
|
cachePath = Path.Combine(((BaseApplicationPaths)CommonApplicationPaths).ProgramDataPath, "cache");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Set cachePath to the existing live value; will require restart if UI value is removed (but not replaced)
|
||||||
|
// TODO: Figure out how to re-grab this from the CLI/envvars while running
|
||||||
|
cachePath = ((BaseApplicationPaths)CommonApplicationPaths).CachePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Set cachePath to the new UI-set value
|
||||||
|
cachePath = CommonConfiguration.CachePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogInformation("Setting cache path: {Path}", cachePath);
|
||||||
|
((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath;
|
||||||
|
CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the cache path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newConfig">The new configuration.</param>
|
||||||
|
/// <exception cref="DirectoryNotFoundException">The new cache path doesn't exist.</exception>
|
||||||
|
private void ValidateCachePath(BaseApplicationConfiguration newConfig)
|
||||||
|
{
|
||||||
|
var newPath = newConfig.CachePath;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(newPath)
|
||||||
|
&& !string.Equals(CommonConfiguration.CachePath ?? string.Empty, newPath, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// Validate
|
||||||
|
if (!Directory.Exists(newPath))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException(
|
||||||
|
string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0} does not exist.",
|
||||||
|
newPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureWriteAccess(newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures that we have write access to the path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path.</param>
|
||||||
|
protected void EnsureWriteAccess(string path)
|
||||||
|
{
|
||||||
|
var file = Path.Combine(path, Guid.NewGuid().ToString());
|
||||||
|
File.WriteAllText(file, string.Empty);
|
||||||
|
File.Delete(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetConfigurationFile(string key)
|
||||||
|
{
|
||||||
|
return Path.Combine(CommonApplicationPaths.ConfigurationDirectoryPath, key.ToLowerInvariant() + ".xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public object GetConfiguration(string key)
|
||||||
|
{
|
||||||
|
return _configurations.GetOrAdd(
|
||||||
|
key,
|
||||||
|
static (k, configurationManager) =>
|
||||||
|
{
|
||||||
|
var file = configurationManager.GetConfigurationFile(k);
|
||||||
|
|
||||||
|
var configurationInfo = Array.Find(
|
||||||
|
configurationManager._configurationStores,
|
||||||
|
i => string.Equals(i.Key, k, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (configurationInfo is null)
|
||||||
|
{
|
||||||
|
throw new ResourceNotFoundException("Configuration with key " + k + " not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var configurationType = configurationInfo.ConfigurationType;
|
||||||
|
|
||||||
|
lock (configurationManager._configurationSyncLock)
|
||||||
|
{
|
||||||
|
return configurationManager.LoadConfiguration(file, configurationType);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private object LoadConfiguration(string path, Type configurationType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return XmlSerializer.DeserializeFromFile(configurationType, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not IOException)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error loading configuration file: {Path}", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Activator.CreateInstance(configurationType)
|
||||||
|
?? throw new InvalidOperationException("Configuration type can't be Nullable<T>.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SaveConfiguration(string key, object configuration)
|
||||||
|
{
|
||||||
|
var configurationStore = GetConfigurationStore(key);
|
||||||
|
var configurationType = configurationStore.ConfigurationType;
|
||||||
|
|
||||||
|
if (configuration.GetType() != configurationType)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Expected configuration type is " + configurationType.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configurationStore is IValidatingConfiguration validatingStore)
|
||||||
|
{
|
||||||
|
var currentConfiguration = GetConfiguration(key);
|
||||||
|
|
||||||
|
validatingStore.Validate(currentConfiguration, configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
NamedConfigurationUpdating?.Invoke(this, new ConfigurationUpdateEventArgs(key, configuration));
|
||||||
|
|
||||||
|
_configurations.AddOrUpdate(key, configuration, (_, _) => configuration);
|
||||||
|
|
||||||
|
var path = GetConfigurationFile(key);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Path can't be a root directory."));
|
||||||
|
|
||||||
|
lock (_configurationSyncLock)
|
||||||
|
{
|
||||||
|
XmlSerializer.SerializeToFile(configuration, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnNamedConfigurationUpdated(key, configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event handler for when a named configuration has been updated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">The key of the configuration.</param>
|
||||||
|
/// <param name="configuration">The old configuration.</param>
|
||||||
|
protected virtual void OnNamedConfigurationUpdated(string key, object configuration)
|
||||||
|
{
|
||||||
|
NamedConfigurationUpdated?.Invoke(this, new ConfigurationUpdateEventArgs(key, configuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ConfigurationStore[] GetConfigurationStores()
|
||||||
|
{
|
||||||
|
return _configurationStores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Type GetConfigurationType(string key)
|
||||||
|
{
|
||||||
|
return GetConfigurationStore(key)
|
||||||
|
.ConfigurationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConfigurationStore GetConfigurationStore(string key)
|
||||||
|
{
|
||||||
|
return _configurationStores
|
||||||
|
.First(i => string.Equals(i.Key, key, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using MediaBrowser.Model.Serialization;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.AppBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Class ConfigurationHelper.
|
||||||
|
/// </summary>
|
||||||
|
public static class ConfigurationHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads an xml configuration file from the file system
|
||||||
|
/// It will immediately re-serialize and save if new serialization data is available due to property changes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The type.</param>
|
||||||
|
/// <param name="path">The path.</param>
|
||||||
|
/// <param name="xmlSerializer">The XML serializer.</param>
|
||||||
|
/// <returns>System.Object.</returns>
|
||||||
|
public static object GetXmlConfiguration(Type type, string path, IXmlSerializer xmlSerializer)
|
||||||
|
{
|
||||||
|
object configuration;
|
||||||
|
|
||||||
|
byte[]? buffer = null;
|
||||||
|
|
||||||
|
// Use try/catch to avoid the extra file system lookup using File.Exists
|
||||||
|
try
|
||||||
|
{
|
||||||
|
buffer = File.ReadAllBytes(path);
|
||||||
|
|
||||||
|
configuration = xmlSerializer.DeserializeFromBytes(type, buffer);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Note: CreateInstance returns null for Nullable<T>, e.g. CreateInstance(typeof(int?)) returns null.
|
||||||
|
configuration = Activator.CreateInstance(type)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(buffer?.Length ?? 0);
|
||||||
|
xmlSerializer.SerializeToStream(configuration, stream);
|
||||||
|
|
||||||
|
// Take the object we just got and serialize it back to bytes
|
||||||
|
Span<byte> newBytes = stream.GetBuffer().AsSpan(0, (int)stream.Length);
|
||||||
|
|
||||||
|
// If the file didn't exist before, or if something has changed, re-save
|
||||||
|
if (buffer is null || !newBytes.SequenceEqual(buffer))
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path));
|
||||||
|
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
// Save it after load in case we got new items
|
||||||
|
using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||||
|
{
|
||||||
|
fs.Write(newBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Model.Branding;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Branding
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A configuration factory for <see cref="BrandingOptions"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class BrandingConfigurationFactory : IConfigurationFactory
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<ConfigurationStore> GetConfigurations()
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new ConfigurationStore
|
||||||
|
{
|
||||||
|
ConfigurationType = typeof(BrandingOptions),
|
||||||
|
Key = "branding"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Controller.Chapters;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
using MediaBrowser.Model.MediaInfo;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Chapters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The chapter manager.
|
||||||
|
/// </summary>
|
||||||
|
public class ChapterManager : IChapterManager
|
||||||
|
{
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
|
private readonly ILogger<ChapterManager> _logger;
|
||||||
|
private readonly IMediaEncoder _encoder;
|
||||||
|
private readonly IChapterRepository _chapterRepository;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IPathManager _pathManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The first chapter ticks.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly long _firstChapterTicks = TimeSpan.FromSeconds(15).Ticks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ChapterManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The <see cref="ILogger{ChapterManager}"/>.</param>
|
||||||
|
/// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
|
||||||
|
/// <param name="encoder">The <see cref="IMediaEncoder"/>.</param>
|
||||||
|
/// <param name="chapterRepository">The <see cref="IChapterRepository"/>.</param>
|
||||||
|
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
|
||||||
|
/// <param name="pathManager">The <see cref="IPathManager"/>.</param>
|
||||||
|
public ChapterManager(
|
||||||
|
ILogger<ChapterManager> logger,
|
||||||
|
IFileSystem fileSystem,
|
||||||
|
IMediaEncoder encoder,
|
||||||
|
IChapterRepository chapterRepository,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IPathManager pathManager)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_fileSystem = fileSystem;
|
||||||
|
_encoder = encoder;
|
||||||
|
_chapterRepository = chapterRepository;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_pathManager = pathManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether [is eligible for chapter image extraction] [the specified video].
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="video">The video.</param>
|
||||||
|
/// <param name="libraryOptions">The library options for the video.</param>
|
||||||
|
/// <returns><c>true</c> if [is eligible for chapter image extraction] [the specified video]; otherwise, <c>false</c>.</returns>
|
||||||
|
private bool IsEligibleForChapterImageExtraction(Video video, LibraryOptions libraryOptions)
|
||||||
|
{
|
||||||
|
if (video.IsPlaceHolder)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryOptions is null || !libraryOptions.EnableChapterImageExtraction)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.IsShortcut)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video.IsCompleteMedia)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't extract images if there are no video streams
|
||||||
|
return video.DefaultVideoStreamIndex.HasValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetAverageDurationBetweenChapters(IReadOnlyList<ChapterInfo> chapters)
|
||||||
|
{
|
||||||
|
if (chapters.Count < 2)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
long sum = 0;
|
||||||
|
for (int i = 1; i < chapters.Count; i++)
|
||||||
|
{
|
||||||
|
sum += chapters[i].StartPositionTicks - chapters[i - 1].StartPositionTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum / chapters.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (chapters.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var libraryOptions = _libraryManager.GetLibraryOptions(video);
|
||||||
|
|
||||||
|
if (!IsEligibleForChapterImageExtraction(video, libraryOptions))
|
||||||
|
{
|
||||||
|
extractImages = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var averageChapterDuration = GetAverageDurationBetweenChapters(chapters);
|
||||||
|
var threshold = TimeSpan.FromSeconds(1).Ticks;
|
||||||
|
if (averageChapterDuration < threshold)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold);
|
||||||
|
extractImages = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var success = true;
|
||||||
|
var changesMade = false;
|
||||||
|
|
||||||
|
var runtimeTicks = video.RunTimeTicks ?? 0;
|
||||||
|
|
||||||
|
var currentImages = GetSavedChapterImages(video, directoryService);
|
||||||
|
|
||||||
|
foreach (var chapter in chapters)
|
||||||
|
{
|
||||||
|
if (chapter.StartPositionTicks >= runtimeTicks)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Stopping chapter extraction for {0} because a chapter was found with a position greater than the runtime.", video.Name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = _pathManager.GetChapterImagePath(video, chapter.StartPositionTicks);
|
||||||
|
|
||||||
|
if (!currentImages.Contains(path, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (extractImages)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Add some time for the first chapter to make sure we don't end up with a black image
|
||||||
|
var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(_firstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks);
|
||||||
|
|
||||||
|
var inputPath = video.Path;
|
||||||
|
var directoryPath = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(directoryPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = video.Container;
|
||||||
|
var mediaSource = new MediaSourceInfo
|
||||||
|
{
|
||||||
|
VideoType = video.VideoType,
|
||||||
|
IsoType = video.IsoType,
|
||||||
|
Protocol = video.PathProtocol ?? MediaProtocol.File,
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogInformation("Extracting chapter image for {Name} at {Path}", video.Name, inputPath);
|
||||||
|
var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
|
||||||
|
File.Copy(tempFile, path, true);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_fileSystem.DeleteFile(tempFile);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting temporary chapter image encoding file {Path}", tempFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
chapter.ImagePath = path;
|
||||||
|
chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
|
||||||
|
changesMade = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error extracting chapter images for {0}", string.Join(',', video.Path));
|
||||||
|
success = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(chapter.ImagePath))
|
||||||
|
{
|
||||||
|
chapter.ImagePath = null;
|
||||||
|
changesMade = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!string.Equals(path, chapter.ImagePath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
chapter.ImagePath = path;
|
||||||
|
chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
|
||||||
|
changesMade = true;
|
||||||
|
}
|
||||||
|
else if (libraryOptions?.EnableChapterImageExtraction != true)
|
||||||
|
{
|
||||||
|
// We have an image for the current chapter but the user has disabled chapter image extraction -> delete this chapter's image
|
||||||
|
chapter.ImagePath = null;
|
||||||
|
changesMade = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveChapters && changesMade)
|
||||||
|
{
|
||||||
|
SaveChapters(video, chapters);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteDeadImages(currentImages, chapters);
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
|
||||||
|
{
|
||||||
|
// Remove any chapters that are outside of the runtime of the video
|
||||||
|
var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList();
|
||||||
|
_chapterRepository.SaveChapters(video.Id, validChapters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ChapterInfo? GetChapter(Guid baseItemId, int index)
|
||||||
|
{
|
||||||
|
return _chapterRepository.GetChapter(baseItemId, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<ChapterInfo> GetChapters(Guid baseItemId)
|
||||||
|
{
|
||||||
|
return _chapterRepository.GetChapters(baseItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _chapterRepository.DeleteChaptersAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<string> GetSavedChapterImages(Video video, IDirectoryService directoryService)
|
||||||
|
{
|
||||||
|
var path = _pathManager.GetChapterImageFolderPath(video);
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return directoryService.GetFilePaths(path);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteDeadImages(IEnumerable<string> images, IEnumerable<ChapterInfo> chapters)
|
||||||
|
{
|
||||||
|
var existingImages = chapters.Select(i => i.ImagePath).Where(i => !string.IsNullOrEmpty(i));
|
||||||
|
var deadImages = images
|
||||||
|
.Except(existingImages, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i.AsSpan()), StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var image in deadImages)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Deleting dead chapter image {Path}", image);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_fileSystem.DeleteFile(image!);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting {Path}.", image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Emby.Server.Implementations.Images;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.Drawing;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Collections
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A collection image provider.
|
||||||
|
/// </summary>
|
||||||
|
public class CollectionImageProvider : BaseDynamicImageProvider<BoxSet>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CollectionImageProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileSystem">The filesystem.</param>
|
||||||
|
/// <param name="providerManager">The provider manager.</param>
|
||||||
|
/// <param name="applicationPaths">The application paths.</param>
|
||||||
|
/// <param name="imageProcessor">The image processor.</param>
|
||||||
|
public CollectionImageProvider(
|
||||||
|
IFileSystem fileSystem,
|
||||||
|
IProviderManager providerManager,
|
||||||
|
IApplicationPaths applicationPaths,
|
||||||
|
IImageProcessor imageProcessor)
|
||||||
|
: base(fileSystem, providerManager, applicationPaths, imageProcessor)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override bool Supports(BaseItem item)
|
||||||
|
{
|
||||||
|
// Right now this is the only way to prevent this image from getting created ahead of internet image providers
|
||||||
|
if (!item.IsLocked)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.Supports(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item)
|
||||||
|
{
|
||||||
|
var playlist = (BoxSet)item;
|
||||||
|
|
||||||
|
return playlist.Children.Concat(playlist.GetLinkedChildren())
|
||||||
|
.Select(i =>
|
||||||
|
{
|
||||||
|
var subItem = i;
|
||||||
|
|
||||||
|
var episode = subItem as Episode;
|
||||||
|
|
||||||
|
var series = episode?.Series;
|
||||||
|
if (series is not null && series.HasImage(ImageType.Primary))
|
||||||
|
{
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subItem.HasImage(ImageType.Primary))
|
||||||
|
{
|
||||||
|
return subItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = subItem.GetOwner() ?? subItem.GetParent();
|
||||||
|
|
||||||
|
if (parent is not null && parent.HasImage(ImageType.Primary))
|
||||||
|
{
|
||||||
|
if (parent is MusicAlbum)
|
||||||
|
{
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.Where(i => i is not null)
|
||||||
|
.GroupBy(x => x!.Id) // We removed the null values
|
||||||
|
.Select(x => x.First())
|
||||||
|
.ToList()!; // Again... the list doesn't contain any null values
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string CreateImage(BaseItem item, IReadOnlyCollection<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex)
|
||||||
|
{
|
||||||
|
return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.Collections;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Collections
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The collection manager.
|
||||||
|
/// </summary>
|
||||||
|
public class CollectionManager : ICollectionManager
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
|
private readonly ILibraryMonitor _iLibraryMonitor;
|
||||||
|
private readonly ILogger<CollectionManager> _logger;
|
||||||
|
private readonly IProviderManager _providerManager;
|
||||||
|
private readonly ILocalizationManager _localizationManager;
|
||||||
|
private readonly IApplicationPaths _appPaths;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CollectionManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The library manager.</param>
|
||||||
|
/// <param name="appPaths">The application paths.</param>
|
||||||
|
/// <param name="localizationManager">The localization manager.</param>
|
||||||
|
/// <param name="fileSystem">The filesystem.</param>
|
||||||
|
/// <param name="iLibraryMonitor">The library monitor.</param>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="providerManager">The provider manager.</param>
|
||||||
|
public CollectionManager(
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IApplicationPaths appPaths,
|
||||||
|
ILocalizationManager localizationManager,
|
||||||
|
IFileSystem fileSystem,
|
||||||
|
ILibraryMonitor iLibraryMonitor,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IProviderManager providerManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_fileSystem = fileSystem;
|
||||||
|
_iLibraryMonitor = iLibraryMonitor;
|
||||||
|
_logger = loggerFactory.CreateLogger<CollectionManager>();
|
||||||
|
_providerManager = providerManager;
|
||||||
|
_localizationManager = localizationManager;
|
||||||
|
_appPaths = appPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler<CollectionCreatedEventArgs>? CollectionCreated;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler<CollectionModifiedEventArgs>? ItemsAddedToCollection;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler<CollectionModifiedEventArgs>? ItemsRemovedFromCollection;
|
||||||
|
|
||||||
|
private IEnumerable<Folder> FindFolders(string path)
|
||||||
|
{
|
||||||
|
return _libraryManager
|
||||||
|
.RootFolder
|
||||||
|
.Children
|
||||||
|
.OfType<Folder>()
|
||||||
|
.Where(i => _fileSystem.AreEqual(path, i.Path) || _fileSystem.ContainsSubPath(i.Path, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<Folder?> EnsureLibraryFolder(string path, bool createIfNeeded)
|
||||||
|
{
|
||||||
|
var existingFolder = FindFolders(path).FirstOrDefault();
|
||||||
|
if (existingFolder is not null)
|
||||||
|
{
|
||||||
|
return existingFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createIfNeeded)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
|
||||||
|
var libraryOptions = new LibraryOptions
|
||||||
|
{
|
||||||
|
PathInfos = [new MediaPathInfo(path)],
|
||||||
|
EnableRealtimeMonitor = false,
|
||||||
|
SaveLocalMetadata = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var name = _localizationManager.GetLocalizedString("Collections");
|
||||||
|
|
||||||
|
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_libraryManager.RootFolder.Children = null;
|
||||||
|
|
||||||
|
return FindFolders(path).First();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string GetCollectionsFolderPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(_appPaths.DataPath, "collections");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<Folder?> GetCollectionsFolder(bool createIfNeeded)
|
||||||
|
{
|
||||||
|
return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<BoxSet> GetCollections(User user)
|
||||||
|
{
|
||||||
|
var folder = GetCollectionsFolder(false).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
return folder is null
|
||||||
|
? Enumerable.Empty<BoxSet>()
|
||||||
|
: folder.GetChildren(user, true).OfType<BoxSet>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<BoxSet> CreateCollectionAsync(CollectionCreationOptions options)
|
||||||
|
{
|
||||||
|
var name = options.Name;
|
||||||
|
|
||||||
|
// Need to use the [boxset] suffix
|
||||||
|
// If internet metadata is not found, or if xml saving is off there will be no collection.xml
|
||||||
|
// This could cause it to get re-resolved as a plain folder
|
||||||
|
var folderName = _fileSystem.GetValidFilename(name) + " [boxset]";
|
||||||
|
|
||||||
|
var parentFolder = await GetCollectionsFolder(true).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (parentFolder is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(nameof(parentFolder));
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = Path.Combine(parentFolder.Path, folderName);
|
||||||
|
|
||||||
|
_iLibraryMonitor.ReportFileSystemChangeBeginning(path);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = Directory.CreateDirectory(path);
|
||||||
|
var collection = new BoxSet
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Path = path,
|
||||||
|
IsLocked = options.IsLocked,
|
||||||
|
ProviderIds = options.ProviderIds,
|
||||||
|
DateCreated = info.CreationTimeUtc,
|
||||||
|
DateModified = info.LastWriteTimeUtc
|
||||||
|
};
|
||||||
|
|
||||||
|
parentFolder.AddChild(collection);
|
||||||
|
|
||||||
|
if (options.ItemIdList.Count > 0)
|
||||||
|
{
|
||||||
|
await AddToCollectionAsync(
|
||||||
|
collection.Id,
|
||||||
|
options.ItemIdList.Select(x => new Guid(x)),
|
||||||
|
false,
|
||||||
|
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||||
|
{
|
||||||
|
// The initial adding of items is going to create a local metadata file
|
||||||
|
// This will cause internet metadata to be skipped as a result
|
||||||
|
MetadataRefreshMode = MetadataRefreshMode.FullRefresh
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_providerManager.QueueRefresh(collection.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionCreated?.Invoke(this, new CollectionCreatedEventArgs
|
||||||
|
{
|
||||||
|
Collection = collection,
|
||||||
|
Options = options
|
||||||
|
});
|
||||||
|
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Refresh handled internally
|
||||||
|
_iLibraryMonitor.ReportFileSystemChangeComplete(path, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds)
|
||||||
|
=> AddToCollectionAsync(collectionId, itemIds, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem)));
|
||||||
|
|
||||||
|
private async Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> ids, bool fireEvent, MetadataRefreshOptions refreshOptions)
|
||||||
|
{
|
||||||
|
if (_libraryManager.GetItemById(collectionId) is not BoxSet collection)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("No collection exists with the supplied collectionId " + collectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BaseItem>? itemList = null;
|
||||||
|
|
||||||
|
var linkedChildrenList = collection.GetLinkedChildren();
|
||||||
|
var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList();
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
var item = _libraryManager.GetItemById(id);
|
||||||
|
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("No item exists with the supplied Id " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentLinkedChildrenIds.Contains(id))
|
||||||
|
{
|
||||||
|
(itemList ??= new()).Add(item);
|
||||||
|
|
||||||
|
linkedChildrenList.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemList is not null)
|
||||||
|
{
|
||||||
|
var originalLen = collection.LinkedChildren.Length;
|
||||||
|
var newItemCount = itemList.Count;
|
||||||
|
LinkedChild[] newChildren = new LinkedChild[originalLen + newItemCount];
|
||||||
|
collection.LinkedChildren.CopyTo(newChildren, 0);
|
||||||
|
for (int i = 0; i < newItemCount; i++)
|
||||||
|
{
|
||||||
|
newChildren[originalLen + i] = LinkedChild.Create(itemList[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.LinkedChildren = newChildren;
|
||||||
|
collection.UpdateRatingToItems(linkedChildrenList);
|
||||||
|
|
||||||
|
await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
refreshOptions.ForceSave = true;
|
||||||
|
_providerManager.QueueRefresh(collection.Id, refreshOptions, RefreshPriority.High);
|
||||||
|
|
||||||
|
if (fireEvent)
|
||||||
|
{
|
||||||
|
ItemsAddedToCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RemoveFromCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds)
|
||||||
|
{
|
||||||
|
if (_libraryManager.GetItemById(collectionId) is not BoxSet collection)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("No collection exists with the supplied Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new List<LinkedChild>();
|
||||||
|
var itemList = new List<BaseItem>();
|
||||||
|
|
||||||
|
foreach (var guidId in itemIds)
|
||||||
|
{
|
||||||
|
var childItem = _libraryManager.GetItemById(guidId);
|
||||||
|
|
||||||
|
var child = collection.LinkedChildren.FirstOrDefault(i => (i.ItemId.HasValue && i.ItemId.Value.Equals(guidId)) || (childItem is not null && string.Equals(childItem.Path, i.Path, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
if (child is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No collection title exists with the supplied Id");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(child);
|
||||||
|
|
||||||
|
if (childItem is not null)
|
||||||
|
{
|
||||||
|
itemList.Add(childItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.Count > 0)
|
||||||
|
{
|
||||||
|
collection.LinkedChildren = collection.LinkedChildren.Except(list).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
_providerManager.QueueRefresh(
|
||||||
|
collection.Id,
|
||||||
|
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||||
|
{
|
||||||
|
ForceSave = true
|
||||||
|
},
|
||||||
|
RefreshPriority.High);
|
||||||
|
|
||||||
|
ItemsRemovedFromCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<BaseItem> CollapseItemsWithinBoxSets(IEnumerable<BaseItem> items, User user)
|
||||||
|
{
|
||||||
|
var results = new Dictionary<Guid, BaseItem>();
|
||||||
|
|
||||||
|
var allBoxSets = GetCollections(user).ToList();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item is ISupportsBoxSetGrouping)
|
||||||
|
{
|
||||||
|
var itemId = item.Id;
|
||||||
|
|
||||||
|
var itemIsInBoxSet = false;
|
||||||
|
foreach (var boxSet in allBoxSets)
|
||||||
|
{
|
||||||
|
if (!boxSet.ContainsLinkedChildByItemId(itemId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
itemIsInBoxSet = true;
|
||||||
|
|
||||||
|
results.TryAdd(boxSet.Id, boxSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip any item that is in a box set
|
||||||
|
if (itemIsInBoxSet)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var alreadyInResults = false;
|
||||||
|
|
||||||
|
// this is kind of a performance hack because only Video has alternate versions that should be in a box set?
|
||||||
|
if (item is Video video)
|
||||||
|
{
|
||||||
|
foreach (var childId in video.GetLocalAlternateVersionIds())
|
||||||
|
{
|
||||||
|
if (!results.ContainsKey(childId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
alreadyInResults = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alreadyInResults)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[item.Id] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.Values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using Emby.Server.Implementations.AppBase;
|
||||||
|
using Jellyfin.Data.Events;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using MediaBrowser.Model.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Class ServerConfigurationManager.
|
||||||
|
/// </summary>
|
||||||
|
public class ServerConfigurationManager : BaseConfigurationManager, IServerConfigurationManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ServerConfigurationManager" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="applicationPaths">The application paths.</param>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="xmlSerializer">The XML serializer.</param>
|
||||||
|
public ServerConfigurationManager(
|
||||||
|
IApplicationPaths applicationPaths,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IXmlSerializer xmlSerializer)
|
||||||
|
: base(applicationPaths, loggerFactory, xmlSerializer)
|
||||||
|
{
|
||||||
|
UpdateMetadataPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration updating event.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<GenericEventArgs<ServerConfiguration>>? ConfigurationUpdating;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type of the configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The type of the configuration.</value>
|
||||||
|
protected override Type ConfigurationType => typeof(ServerConfiguration);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the application paths.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The application paths.</value>
|
||||||
|
public IServerApplicationPaths ApplicationPaths => (IServerApplicationPaths)CommonApplicationPaths;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The configuration.</value>
|
||||||
|
public ServerConfiguration Configuration => (ServerConfiguration)CommonConfiguration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when [configuration updated].
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnConfigurationUpdated()
|
||||||
|
{
|
||||||
|
UpdateMetadataPath();
|
||||||
|
|
||||||
|
base.OnConfigurationUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the metadata path.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="UnauthorizedAccessException">If the directory does not exist, and the caller does not have the required permission to create it.</exception>
|
||||||
|
/// <exception cref="NotSupportedException">If there is a custom path transcoding path specified, but it is invalid.</exception>
|
||||||
|
/// <exception cref="IOException">If the directory does not exist, and it also could not be created.</exception>
|
||||||
|
private void UpdateMetadataPath()
|
||||||
|
{
|
||||||
|
((ServerApplicationPaths)ApplicationPaths).InternalMetadataPath = string.IsNullOrWhiteSpace(Configuration.MetadataPath)
|
||||||
|
? ApplicationPaths.DefaultInternalMetadataPath
|
||||||
|
: Configuration.MetadataPath;
|
||||||
|
Directory.CreateDirectory(ApplicationPaths.InternalMetadataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newConfiguration">The new configuration.</param>
|
||||||
|
/// <exception cref="DirectoryNotFoundException">If the configuration path doesn't exist.</exception>
|
||||||
|
public override void ReplaceConfiguration(BaseApplicationConfiguration newConfiguration)
|
||||||
|
{
|
||||||
|
var newConfig = (ServerConfiguration)newConfiguration;
|
||||||
|
|
||||||
|
ValidateMetadataPath(newConfig);
|
||||||
|
|
||||||
|
ConfigurationUpdating?.Invoke(this, new GenericEventArgs<ServerConfiguration>(newConfig));
|
||||||
|
|
||||||
|
base.ReplaceConfiguration(newConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the metadata path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newConfig">The new configuration.</param>
|
||||||
|
/// <exception cref="DirectoryNotFoundException">The new config path doesn't exist.</exception>
|
||||||
|
private void ValidateMetadataPath(ServerConfiguration newConfig)
|
||||||
|
{
|
||||||
|
var newPath = newConfig.MetadataPath;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(newPath)
|
||||||
|
&& !string.Equals(Configuration.MetadataPath, newPath, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(newPath))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException(
|
||||||
|
string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0} does not exist.",
|
||||||
|
newPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureWriteAccess(newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Static class containing the default configuration options for the web server.
|
||||||
|
/// </summary>
|
||||||
|
public static class ConfigurationOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a new copy of the default configuration options.
|
||||||
|
/// </summary>
|
||||||
|
public static Dictionary<string, string?> DefaultConfiguration => new()
|
||||||
|
{
|
||||||
|
{ HostWebClientKey, bool.TrueString },
|
||||||
|
{ DefaultRedirectKey, "web/" },
|
||||||
|
{ FfmpegProbeSizeKey, "1G" },
|
||||||
|
{ FfmpegAnalyzeDurationKey, "200M" },
|
||||||
|
{ BindToUnixSocketKey, bool.FalseString },
|
||||||
|
{ SqliteCacheSizeKey, "20000" },
|
||||||
|
{ FfmpegSkipValidationKey, bool.FalseString },
|
||||||
|
{ FfmpegImgExtractPerfTradeoffKey, bool.FalseString },
|
||||||
|
{ DetectNetworkChangeKey, bool.TrueString }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using MediaBrowser.Model.Cryptography;
|
||||||
|
using static MediaBrowser.Model.Cryptography.Constants;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Cryptography
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Class providing abstractions over cryptographic functions.
|
||||||
|
/// </summary>
|
||||||
|
public class CryptographyProvider : ICryptoProvider
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DefaultHashMethod => "PBKDF2-SHA512";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public PasswordHash CreatePasswordHash(ReadOnlySpan<char> password)
|
||||||
|
{
|
||||||
|
byte[] salt = GenerateSalt();
|
||||||
|
return new PasswordHash(
|
||||||
|
DefaultHashMethod,
|
||||||
|
Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
DefaultIterations,
|
||||||
|
HashAlgorithmName.SHA512,
|
||||||
|
DefaultOutputLength),
|
||||||
|
salt,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "iterations", DefaultIterations.ToString(CultureInfo.InvariantCulture) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool Verify(PasswordHash hash, ReadOnlySpan<char> password)
|
||||||
|
{
|
||||||
|
if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var iterations = GetIterationsParameter(hash);
|
||||||
|
return hash.Hash.SequenceEqual(
|
||||||
|
Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
password,
|
||||||
|
hash.Salt,
|
||||||
|
iterations,
|
||||||
|
HashAlgorithmName.SHA1,
|
||||||
|
32));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var iterations = GetIterationsParameter(hash);
|
||||||
|
return hash.Hash.SequenceEqual(
|
||||||
|
Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
password,
|
||||||
|
hash.Salt,
|
||||||
|
iterations,
|
||||||
|
HashAlgorithmName.SHA512,
|
||||||
|
DefaultOutputLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotSupportedException($"Can't verify hash with id: {hash.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts and validates the iterations parameter from a password hash.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hash">The password hash containing parameters.</param>
|
||||||
|
/// <returns>The number of iterations.</returns>
|
||||||
|
/// <exception cref="FormatException">Thrown when iterations parameter is missing or invalid.</exception>
|
||||||
|
private static int GetIterationsParameter(PasswordHash hash)
|
||||||
|
{
|
||||||
|
if (!hash.Parameters.TryGetValue("iterations", out var iterationsStr))
|
||||||
|
{
|
||||||
|
throw new FormatException($"Password hash with id '{hash.Id}' is missing required 'iterations' parameter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(iterationsStr, CultureInfo.InvariantCulture, out var iterations))
|
||||||
|
{
|
||||||
|
throw new FormatException($"Password hash with id '{hash.Id}' has invalid 'iterations' parameter: '{iterationsStr}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return iterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public byte[] GenerateSalt()
|
||||||
|
=> GenerateSalt(DefaultSaltLength);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public byte[] GenerateSalt(int length)
|
||||||
|
{
|
||||||
|
var salt = new byte[length];
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
rng.GetNonZeroBytes(salt);
|
||||||
|
return salt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Data;
|
||||||
|
|
||||||
|
public class CleanDatabaseScheduledTask : ILibraryPostScanTask
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ILogger<CleanDatabaseScheduledTask> _logger;
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
|
private readonly IPathManager _pathManager;
|
||||||
|
|
||||||
|
public CleanDatabaseScheduledTask(
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
ILogger<CleanDatabaseScheduledTask> logger,
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
|
IPathManager pathManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_logger = logger;
|
||||||
|
_dbProvider = dbProvider;
|
||||||
|
_pathManager = pathManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await CleanDeadItems(cancellationToken, progress).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CleanDeadItems(CancellationToken cancellationToken, IProgress<double> progress)
|
||||||
|
{
|
||||||
|
var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery
|
||||||
|
{
|
||||||
|
HasDeadParentId = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var numComplete = 0;
|
||||||
|
var numItems = itemIds.Count + 1;
|
||||||
|
|
||||||
|
_logger.LogDebug("Cleaning {Number} items with dead parents", numItems);
|
||||||
|
|
||||||
|
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||||
|
|
||||||
|
foreach (var itemId in itemIds)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
|
if (item is not null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Cleaning item {Item} type: {Type} path: {Path}", item.Name, item.GetType().Name, item.Path ?? string.Empty);
|
||||||
|
|
||||||
|
foreach (var mediaSource in item.GetMediaSources(false))
|
||||||
|
{
|
||||||
|
// Delete extracted data
|
||||||
|
var mediaSourceItem = _libraryManager.GetItemById(mediaSource.Id);
|
||||||
|
if (mediaSourceItem is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extractedDataFolders = _pathManager.GetExtractedDataPaths(mediaSourceItem);
|
||||||
|
foreach (var folder in extractedDataFolders)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(folder))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(folder, true);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to remove {Folder}: {Exception}", folder, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete item
|
||||||
|
_libraryManager.DeleteItem(item, new DeleteOptions
|
||||||
|
{
|
||||||
|
DeleteFileLocation = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
numComplete++;
|
||||||
|
double percent = numComplete;
|
||||||
|
percent /= numItems;
|
||||||
|
subProgress.Report(percent * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||||
|
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (context.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (transaction.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
subProgress.Report(50);
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
subProgress.Report(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Report(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Emby.Server.Implementations.Playlists;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.LiveTv;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
|
using MediaBrowser.Controller.Playlists;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Data;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class ItemTypeLookup : IItemTypeLookup
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<string> MusicGenreTypes { get; } = [
|
||||||
|
typeof(Audio).FullName!,
|
||||||
|
typeof(MusicVideo).FullName!,
|
||||||
|
typeof(MusicAlbum).FullName!,
|
||||||
|
typeof(MusicArtist).FullName!,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyDictionary<BaseItemKind, string> BaseItemKindNames { get; } = new Dictionary<BaseItemKind, string>()
|
||||||
|
{
|
||||||
|
{ BaseItemKind.AggregateFolder, typeof(AggregateFolder).FullName! },
|
||||||
|
{ BaseItemKind.Audio, typeof(Audio).FullName! },
|
||||||
|
{ BaseItemKind.AudioBook, typeof(AudioBook).FullName! },
|
||||||
|
{ BaseItemKind.BasePluginFolder, typeof(BasePluginFolder).FullName! },
|
||||||
|
{ BaseItemKind.Book, typeof(Book).FullName! },
|
||||||
|
{ BaseItemKind.BoxSet, typeof(BoxSet).FullName! },
|
||||||
|
{ BaseItemKind.Channel, typeof(Channel).FullName! },
|
||||||
|
{ BaseItemKind.CollectionFolder, typeof(CollectionFolder).FullName! },
|
||||||
|
{ BaseItemKind.Episode, typeof(Episode).FullName! },
|
||||||
|
{ BaseItemKind.Folder, typeof(Folder).FullName! },
|
||||||
|
{ BaseItemKind.Genre, typeof(Genre).FullName! },
|
||||||
|
{ BaseItemKind.Movie, typeof(Movie).FullName! },
|
||||||
|
{ BaseItemKind.LiveTvChannel, typeof(LiveTvChannel).FullName! },
|
||||||
|
{ BaseItemKind.LiveTvProgram, typeof(LiveTvProgram).FullName! },
|
||||||
|
{ BaseItemKind.MusicAlbum, typeof(MusicAlbum).FullName! },
|
||||||
|
{ BaseItemKind.MusicArtist, typeof(MusicArtist).FullName! },
|
||||||
|
{ BaseItemKind.MusicGenre, typeof(MusicGenre).FullName! },
|
||||||
|
{ BaseItemKind.MusicVideo, typeof(MusicVideo).FullName! },
|
||||||
|
{ BaseItemKind.Person, typeof(Person).FullName! },
|
||||||
|
{ BaseItemKind.Photo, typeof(Photo).FullName! },
|
||||||
|
{ BaseItemKind.PhotoAlbum, typeof(PhotoAlbum).FullName! },
|
||||||
|
{ BaseItemKind.Playlist, typeof(Playlist).FullName! },
|
||||||
|
{ BaseItemKind.PlaylistsFolder, typeof(PlaylistsFolder).FullName! },
|
||||||
|
{ BaseItemKind.Season, typeof(Season).FullName! },
|
||||||
|
{ BaseItemKind.Series, typeof(Series).FullName! },
|
||||||
|
{ BaseItemKind.Studio, typeof(Studio).FullName! },
|
||||||
|
{ BaseItemKind.Trailer, typeof(Trailer).FullName! },
|
||||||
|
{ BaseItemKind.TvChannel, typeof(LiveTvChannel).FullName! },
|
||||||
|
{ BaseItemKind.TvProgram, typeof(LiveTvProgram).FullName! },
|
||||||
|
{ BaseItemKind.UserRootFolder, typeof(UserRootFolder).FullName! },
|
||||||
|
{ BaseItemKind.UserView, typeof(UserView).FullName! },
|
||||||
|
{ BaseItemKind.Video, typeof(Video).FullName! },
|
||||||
|
{ BaseItemKind.Year, typeof(Year).FullName! }
|
||||||
|
}.ToFrozenDictionary();
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Data
|
||||||
|
{
|
||||||
|
public static class SqliteExtensions
|
||||||
|
{
|
||||||
|
private const string DatetimeFormatUtc = "yyyy-MM-dd HH:mm:ss.FFFFFFFK";
|
||||||
|
private const string DatetimeFormatLocal = "yyyy-MM-dd HH:mm:ss.FFFFFFF";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An array of ISO-8601 DateTime formats that we support parsing.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] _datetimeFormats = new string[]
|
||||||
|
{
|
||||||
|
"THHmmssK",
|
||||||
|
"THHmmK",
|
||||||
|
"HH:mm:ss.FFFFFFFK",
|
||||||
|
"HH:mm:ssK",
|
||||||
|
"HH:mmK",
|
||||||
|
DatetimeFormatUtc,
|
||||||
|
"yyyy-MM-dd HH:mm:ssK",
|
||||||
|
"yyyy-MM-dd HH:mmK",
|
||||||
|
"yyyy-MM-ddTHH:mm:ss.FFFFFFFK",
|
||||||
|
"yyyy-MM-ddTHH:mmK",
|
||||||
|
"yyyy-MM-ddTHH:mm:ssK",
|
||||||
|
"yyyyMMddHHmmssK",
|
||||||
|
"yyyyMMddHHmmK",
|
||||||
|
"yyyyMMddTHHmmssFFFFFFFK",
|
||||||
|
"THHmmss",
|
||||||
|
"THHmm",
|
||||||
|
"HH:mm:ss.FFFFFFF",
|
||||||
|
"HH:mm:ss",
|
||||||
|
"HH:mm",
|
||||||
|
DatetimeFormatLocal,
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
"yyyy-MM-dd HH:mm",
|
||||||
|
"yyyy-MM-ddTHH:mm:ss.FFFFFFF",
|
||||||
|
"yyyy-MM-ddTHH:mm",
|
||||||
|
"yyyy-MM-ddTHH:mm:ss",
|
||||||
|
"yyyyMMddHHmmss",
|
||||||
|
"yyyyMMddHHmm",
|
||||||
|
"yyyyMMddTHHmmssFFFFFFF",
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
"yyyyMMdd",
|
||||||
|
"yy-MM-dd"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IEnumerable<SqliteDataReader> Query(this SqliteConnection sqliteConnection, string commandText)
|
||||||
|
{
|
||||||
|
if (sqliteConnection.State != ConnectionState.Open)
|
||||||
|
{
|
||||||
|
sqliteConnection.Open();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var command = sqliteConnection.CreateCommand();
|
||||||
|
command.CommandText = commandText;
|
||||||
|
using (var reader = command.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
yield return reader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Execute(this SqliteConnection sqliteConnection, string commandText)
|
||||||
|
{
|
||||||
|
using var command = sqliteConnection.CreateCommand();
|
||||||
|
command.CommandText = commandText;
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToDateTimeParamValue(this DateTime dateValue)
|
||||||
|
{
|
||||||
|
var kind = DateTimeKind.Utc;
|
||||||
|
|
||||||
|
return (dateValue.Kind == DateTimeKind.Unspecified)
|
||||||
|
? DateTime.SpecifyKind(dateValue, kind).ToString(
|
||||||
|
GetDateTimeKindFormat(kind),
|
||||||
|
CultureInfo.InvariantCulture)
|
||||||
|
: dateValue.ToString(
|
||||||
|
GetDateTimeKindFormat(dateValue.Kind),
|
||||||
|
CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDateTimeKindFormat(DateTimeKind kind)
|
||||||
|
=> (kind == DateTimeKind.Utc) ? DatetimeFormatUtc : DatetimeFormatLocal;
|
||||||
|
|
||||||
|
public static bool TryReadDateTime(this SqliteDataReader reader, int index, out DateTime result)
|
||||||
|
{
|
||||||
|
if (reader.IsDBNull(index))
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateText = reader.GetString(index);
|
||||||
|
|
||||||
|
if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AdjustToUniversal, out var dateTimeResult))
|
||||||
|
{
|
||||||
|
// If the resulting DateTimeKind is Unspecified it is actually Utc.
|
||||||
|
// This is required downstream for the Json serializer.
|
||||||
|
if (dateTimeResult.Kind == DateTimeKind.Unspecified)
|
||||||
|
{
|
||||||
|
dateTimeResult = DateTime.SpecifyKind(dateTimeResult, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = dateTimeResult;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetGuid(this SqliteDataReader reader, int index, out Guid result)
|
||||||
|
{
|
||||||
|
if (reader.IsDBNull(index))
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = reader.GetGuid(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
result = Guid.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetString(this SqliteDataReader reader, int index, out string result)
|
||||||
|
{
|
||||||
|
result = string.Empty;
|
||||||
|
|
||||||
|
if (reader.IsDBNull(index))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = reader.GetString(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetBoolean(this SqliteDataReader reader, int index, out bool result)
|
||||||
|
{
|
||||||
|
if (reader.IsDBNull(index))
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = reader.GetBoolean(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetInt32(this SqliteDataReader reader, int index, out int result)
|
||||||
|
{
|
||||||
|
if (reader.IsDBNull(index))
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = reader.GetInt32(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetInt64(this SqliteDataReader reader, int index, out long result)
|
||||||
|
{
|
||||||
|
if (reader.IsDBNull(index))
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = reader.GetInt64(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetSingle(this SqliteDataReader reader, int index, out float result)
|
||||||
|
{
|
||||||
|
if (reader.IsDBNull(index))
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = reader.GetFloat(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetDouble(this SqliteDataReader reader, int index, out double result)
|
||||||
|
{
|
||||||
|
if (reader.IsDBNull(index))
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = reader.GetDouble(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TryBind(this SqliteCommand statement, string name, Guid value)
|
||||||
|
{
|
||||||
|
statement.TryBind(name, value, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TryBind(this SqliteCommand statement, string name, object? value, bool isBlob = false)
|
||||||
|
{
|
||||||
|
var preparedValue = value ?? DBNull.Value;
|
||||||
|
if (statement.Parameters.Contains(name))
|
||||||
|
{
|
||||||
|
statement.Parameters[name].Value = preparedValue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Blobs aren't always detected automatically
|
||||||
|
if (isBlob)
|
||||||
|
{
|
||||||
|
statement.Parameters.Add(new SqliteParameter(name, SqliteType.Blob) { Value = value });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
statement.Parameters.AddWithValue(name, preparedValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void TryBindNull(this SqliteCommand statement, string name)
|
||||||
|
{
|
||||||
|
statement.TryBind(name, DBNull.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<SqliteDataReader> ExecuteQuery(this SqliteCommand command)
|
||||||
|
{
|
||||||
|
using (var reader = command.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
yield return reader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int SelectScalarInt(this SqliteCommand command)
|
||||||
|
{
|
||||||
|
var result = command.ExecuteScalar();
|
||||||
|
// Can't be null since the method is used to retrieve Count
|
||||||
|
return Convert.ToInt32(result!, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SqliteCommand PrepareStatement(this SqliteConnection sqliteConnection, string sql)
|
||||||
|
{
|
||||||
|
var command = sqliteConnection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Data
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Class TypeMapper.
|
||||||
|
/// </summary>
|
||||||
|
public class TypeMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This holds all the types in the running assemblies
|
||||||
|
/// so that we can de-serialize properly when we don't have strong types.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="typeName">Name of the type.</param>
|
||||||
|
/// <returns>Type.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><c>typeName</c> is null.</exception>
|
||||||
|
public Type? GetType(string typeName)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(typeName);
|
||||||
|
|
||||||
|
return _typeMap.GetOrAdd(typeName, k => AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.Select(a => a.GetType(k))
|
||||||
|
.FirstOrDefault(t => t is not null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Devices
|
||||||
|
{
|
||||||
|
public class DeviceId
|
||||||
|
{
|
||||||
|
private readonly IApplicationPaths _appPaths;
|
||||||
|
private readonly ILogger<DeviceId> _logger;
|
||||||
|
private readonly Lock _syncLock = new();
|
||||||
|
|
||||||
|
private string? _id;
|
||||||
|
|
||||||
|
public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
|
||||||
|
{
|
||||||
|
_appPaths = appPaths;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Value => _id ??= GetDeviceId();
|
||||||
|
|
||||||
|
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
|
||||||
|
|
||||||
|
private string? GetCachedId()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_syncLock)
|
||||||
|
{
|
||||||
|
var value = File.ReadAllText(CachePath, Encoding.UTF8);
|
||||||
|
|
||||||
|
if (Guid.TryParse(value, out _))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogError("Invalid value found in device id file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error reading file");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveId(string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = CachePath;
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Path can't be a root directory."));
|
||||||
|
|
||||||
|
lock (_syncLock)
|
||||||
|
{
|
||||||
|
File.WriteAllText(path, id, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error writing to file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetNewId()
|
||||||
|
{
|
||||||
|
return Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetDeviceId()
|
||||||
|
{
|
||||||
|
var id = GetCachedId();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
id = GetNewId();
|
||||||
|
SaveId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<ProjectGuid>{E383961B-9356-4D5D-8233-9A1079D03055}</ProjectGuid>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Emby.Naming\Emby.Naming.csproj" />
|
||||||
|
<ProjectReference Include="..\Jellyfin.Api\Jellyfin.Api.csproj" />
|
||||||
|
<ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" />
|
||||||
|
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
|
||||||
|
<ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
|
||||||
|
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
|
||||||
|
<ProjectReference Include="..\MediaBrowser.Providers\MediaBrowser.Providers.csproj" />
|
||||||
|
<ProjectReference Include="..\MediaBrowser.XbmcMetadata\MediaBrowser.XbmcMetadata.csproj" />
|
||||||
|
<ProjectReference Include="..\MediaBrowser.LocalMetadata\MediaBrowser.LocalMetadata.csproj" />
|
||||||
|
<ProjectReference Include="..\Emby.Photos\Emby.Photos.csproj" />
|
||||||
|
<ProjectReference Include="..\src\Jellyfin.Drawing\Jellyfin.Drawing.csproj" />
|
||||||
|
<ProjectReference Include="..\MediaBrowser.MediaEncoding\MediaBrowser.MediaEncoding.csproj" />
|
||||||
|
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BitFaster.Caching" />
|
||||||
|
<PackageReference Include="DiscUtils.Udf" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
|
<PackageReference Include="prometheus-net.DotNetRuntime" />
|
||||||
|
<PackageReference Include="DotNet.Glob" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\SharedVersion.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
<CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Code Analyzers -->
|
||||||
|
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
|
<!-- TODO: Add IDisposableAnalyzers -->
|
||||||
|
<!-- <PackageReference Include="IDisposableAnalyzers">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
|
</PackageReference> -->
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
|
||||||
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Ignore" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Localization\iso6392.txt" />
|
||||||
|
<EmbeddedResource Include="Localization\countries.json" />
|
||||||
|
<EmbeddedResource Include="Localization\Core\*.json" />
|
||||||
|
<EmbeddedResource Include="Localization\Ratings\*.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Events;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
using MediaBrowser.Controller.Channels;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Session;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.EntryPoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="IHostedService"/> responsible for notifying users when libraries are updated.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IServerConfigurationManager _configurationManager;
|
||||||
|
private readonly IProviderManager _providerManager;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly ILogger<LibraryChangedNotifier> _logger;
|
||||||
|
|
||||||
|
private readonly Lock _libraryChangedSyncLock = new();
|
||||||
|
private readonly List<Folder> _foldersAddedTo = new();
|
||||||
|
private readonly List<Folder> _foldersRemovedFrom = new();
|
||||||
|
private readonly List<BaseItem> _itemsAdded = new();
|
||||||
|
private readonly List<BaseItem> _itemsRemoved = new();
|
||||||
|
private readonly List<BaseItem> _itemsUpdated = new();
|
||||||
|
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
|
||||||
|
|
||||||
|
private Timer? _libraryUpdateTimer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="LibraryChangedNotifier"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
|
||||||
|
/// <param name="configurationManager">The <see cref="IServerConfigurationManager"/>.</param>
|
||||||
|
/// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
|
||||||
|
/// <param name="userManager">The <see cref="IUserManager"/>.</param>
|
||||||
|
/// <param name="logger">The <see cref="ILogger"/>.</param>
|
||||||
|
/// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
|
||||||
|
public LibraryChangedNotifier(
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IServerConfigurationManager configurationManager,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IUserManager userManager,
|
||||||
|
ILogger<LibraryChangedNotifier> logger,
|
||||||
|
IProviderManager providerManager)
|
||||||
|
{
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_configurationManager = configurationManager;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_logger = logger;
|
||||||
|
_providerManager = providerManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_libraryManager.ItemAdded += OnLibraryItemAdded;
|
||||||
|
_libraryManager.ItemUpdated += OnLibraryItemUpdated;
|
||||||
|
_libraryManager.ItemRemoved += OnLibraryItemRemoved;
|
||||||
|
|
||||||
|
_providerManager.RefreshCompleted += OnProviderRefreshCompleted;
|
||||||
|
_providerManager.RefreshStarted += OnProviderRefreshStarted;
|
||||||
|
_providerManager.RefreshProgress += OnProviderRefreshProgress;
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_libraryManager.ItemAdded -= OnLibraryItemAdded;
|
||||||
|
_libraryManager.ItemUpdated -= OnLibraryItemUpdated;
|
||||||
|
_libraryManager.ItemRemoved -= OnLibraryItemRemoved;
|
||||||
|
|
||||||
|
_providerManager.RefreshCompleted -= OnProviderRefreshCompleted;
|
||||||
|
_providerManager.RefreshStarted -= OnProviderRefreshStarted;
|
||||||
|
_providerManager.RefreshProgress -= OnProviderRefreshProgress;
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProviderRefreshProgress(object? sender, GenericEventArgs<Tuple<BaseItem, double>> e)
|
||||||
|
{
|
||||||
|
var item = e.Argument.Item1;
|
||||||
|
|
||||||
|
if (!EnableRefreshMessage(item))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress = e.Argument.Item2;
|
||||||
|
|
||||||
|
if (_lastProgressMessageTimes.TryGetValue(item.Id, out var lastMessageSendTime))
|
||||||
|
{
|
||||||
|
if (progress > 0 && progress < 100 && (DateTime.UtcNow - lastMessageSendTime).TotalMilliseconds < 1000)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastProgressMessageTimes.AddOrUpdate(item.Id, _ => DateTime.UtcNow, (_, _) => DateTime.UtcNow);
|
||||||
|
|
||||||
|
var dict = new Dictionary<string, string>();
|
||||||
|
dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||||
|
dict["Progress"] = progress.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, dict, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
var collectionFolders = _libraryManager.GetCollectionFolders(item);
|
||||||
|
|
||||||
|
foreach (var collectionFolder in collectionFolders)
|
||||||
|
{
|
||||||
|
var collectionFolderDict = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["ItemId"] = collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture),
|
||||||
|
["Progress"] = (collectionFolder.GetRefreshProgress() ?? 0).ToString(CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, collectionFolderDict, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProviderRefreshStarted(object? sender, GenericEventArgs<BaseItem> e)
|
||||||
|
=> OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0)));
|
||||||
|
|
||||||
|
private void OnProviderRefreshCompleted(object? sender, GenericEventArgs<BaseItem> e)
|
||||||
|
{
|
||||||
|
OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100)));
|
||||||
|
|
||||||
|
_lastProgressMessageTimes.TryRemove(e.Argument.Id, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool EnableRefreshMessage(BaseItem item)
|
||||||
|
=> item is Folder { IsRoot: false, IsTopParent: true }
|
||||||
|
and not (AggregateFolder or UserRootFolder or UserView or Channel);
|
||||||
|
|
||||||
|
private void OnLibraryItemAdded(object? sender, ItemChangeEventArgs e)
|
||||||
|
=> OnLibraryChange(e.Item, e.Parent, _itemsAdded, _foldersAddedTo);
|
||||||
|
|
||||||
|
private void OnLibraryItemUpdated(object? sender, ItemChangeEventArgs e)
|
||||||
|
=> OnLibraryChange(e.Item, e.Parent, _itemsUpdated, null);
|
||||||
|
|
||||||
|
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
|
||||||
|
=> OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom);
|
||||||
|
|
||||||
|
private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList)
|
||||||
|
{
|
||||||
|
if (!FilterItem(item))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_libraryChangedSyncLock)
|
||||||
|
{
|
||||||
|
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
|
||||||
|
|
||||||
|
if (_libraryUpdateTimer is null)
|
||||||
|
{
|
||||||
|
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foldersList is not null && parent is Folder folder)
|
||||||
|
{
|
||||||
|
foldersList.Add(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsList.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void LibraryUpdateTimerCallback(object? state)
|
||||||
|
{
|
||||||
|
List<Folder> foldersAddedTo;
|
||||||
|
List<Folder> foldersRemovedFrom;
|
||||||
|
List<BaseItem> itemsUpdated;
|
||||||
|
List<BaseItem> itemsAdded;
|
||||||
|
List<BaseItem> itemsRemoved;
|
||||||
|
lock (_libraryChangedSyncLock)
|
||||||
|
{
|
||||||
|
// Remove dupes in case some were saved multiple times
|
||||||
|
foldersAddedTo = _foldersAddedTo
|
||||||
|
.DistinctBy(x => x.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foldersRemovedFrom = _foldersRemovedFrom
|
||||||
|
.DistinctBy(x => x.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
itemsUpdated = _itemsUpdated
|
||||||
|
.Where(i => !_itemsAdded.Contains(i))
|
||||||
|
.DistinctBy(x => x.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
itemsAdded = _itemsAdded.ToList();
|
||||||
|
itemsRemoved = _itemsRemoved.ToList();
|
||||||
|
|
||||||
|
if (_libraryUpdateTimer is not null)
|
||||||
|
{
|
||||||
|
_libraryUpdateTimer.Dispose();
|
||||||
|
_libraryUpdateTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_itemsAdded.Clear();
|
||||||
|
_itemsRemoved.Clear();
|
||||||
|
_itemsUpdated.Clear();
|
||||||
|
_foldersAddedTo.Clear();
|
||||||
|
_foldersRemovedFrom.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendChangeNotifications(
|
||||||
|
List<BaseItem> itemsAdded,
|
||||||
|
List<BaseItem> itemsUpdated,
|
||||||
|
List<BaseItem> itemsRemoved,
|
||||||
|
List<Folder> foldersAddedTo,
|
||||||
|
List<Folder> foldersRemovedFrom,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userIds = _sessionManager.Sessions
|
||||||
|
.Select(i => i.UserId)
|
||||||
|
.Where(i => !i.IsEmpty())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var userId in userIds)
|
||||||
|
{
|
||||||
|
LibraryUpdateInfo info;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
info = GetLibraryUpdateInfo(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, userId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in GetLibraryUpdateInfo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.IsEmpty)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _sessionManager.SendMessageToUserSessions(
|
||||||
|
new List<Guid> { userId },
|
||||||
|
SessionMessageType.LibraryChanged,
|
||||||
|
info,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error sending LibraryChanged message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LibraryUpdateInfo GetLibraryUpdateInfo(
|
||||||
|
List<BaseItem> itemsAdded,
|
||||||
|
List<BaseItem> itemsUpdated,
|
||||||
|
List<BaseItem> itemsRemoved,
|
||||||
|
List<Folder> foldersAddedTo,
|
||||||
|
List<Folder> foldersRemovedFrom,
|
||||||
|
Guid userId)
|
||||||
|
{
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
|
||||||
|
var newAndRemoved = new List<BaseItem>();
|
||||||
|
newAndRemoved.AddRange(foldersAddedTo);
|
||||||
|
newAndRemoved.AddRange(foldersRemovedFrom);
|
||||||
|
|
||||||
|
var allUserRootChildren = _libraryManager.GetUserRootFolder()
|
||||||
|
.GetChildren(user, true)
|
||||||
|
.OfType<Folder>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new LibraryUpdateInfo
|
||||||
|
{
|
||||||
|
ItemsAdded = itemsAdded.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user))
|
||||||
|
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray(),
|
||||||
|
ItemsUpdated = itemsUpdated.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user))
|
||||||
|
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray(),
|
||||||
|
ItemsRemoved = itemsRemoved.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user, true))
|
||||||
|
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray(),
|
||||||
|
FoldersAddedTo = foldersAddedTo.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user))
|
||||||
|
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray(),
|
||||||
|
FoldersRemovedFrom = foldersRemovedFrom.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user))
|
||||||
|
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray(),
|
||||||
|
CollectionFolders = GetTopParentIds(newAndRemoved, allUserRootChildren).ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FilterItem(BaseItem item)
|
||||||
|
{
|
||||||
|
if (!item.IsFolder && !item.HasPathProtocol)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item is IItemByName && item is not MusicArtist)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.SourceType == SourceType.Library;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetTopParentIds(List<BaseItem> items, List<Folder> allUserRootChildren)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
// If the physical root changed, return the user root
|
||||||
|
if (item is AggregateFolder)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var folder in allUserRootChildren)
|
||||||
|
{
|
||||||
|
list.Add(folder.Id.ToString("N", CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.Distinct(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private T[] TranslatePhysicalItemToUserLibrary<T>(T item, User user, bool includeIfNotFound = false)
|
||||||
|
where T : BaseItem
|
||||||
|
{
|
||||||
|
// If the physical root changed, return the user root
|
||||||
|
if (item is AggregateFolder)
|
||||||
|
{
|
||||||
|
return _libraryManager.GetUserRootFolder() is T t ? new[] { t } : Array.Empty<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return it only if it's in the user's library
|
||||||
|
if (includeIfNotFound || item.IsVisibleStandalone(user))
|
||||||
|
{
|
||||||
|
return new[] { item };
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_libraryUpdateTimer?.Dispose();
|
||||||
|
_libraryUpdateTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user