repo creation with initial code after cloning public repo

This commit is contained in:
2026-02-19 07:36:25 -05:00
commit 460884fea3
2860 changed files with 650825 additions and 0 deletions
+4
View File
@@ -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>
+4
View File
@@ -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
View File
@@ -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)
+27
View File
@@ -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>
+90
View File
@@ -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>
+70
View File
@@ -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;
}
}
}
+25
View File
@@ -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; }
}
}
+56
View File
@@ -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);
}
}
}
+75
View File
@@ -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; }
}
}
+70
View File
@@ -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);
}
}
+23
View File
@@ -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
}
}
+913
View File
@@ -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>((?![Ss]([0-9]+)[][ ._-]*[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);
}
}
}
+59
View File
@@ -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; }
}
}
+21
View File
@@ -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)]
+94
View File
@@ -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; }
}
}
+242
View File
@@ -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;
}
}
}
}
}
+54
View File
@@ -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; }
}
}
+93
View File
@@ -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
};
}
}
}
+174
View File
@@ -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);
}
}
}
+26
View File
@@ -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; }
}
}
+29
View File
@@ -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; }
}
}
+60
View File
@@ -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;
}
}
}
+19
View File
@@ -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; }
}
}
+71
View File
@@ -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
};
}
}
}
+44
View File
@@ -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;
}
}
+55
View File
@@ -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;
}
}
}
+31
View File
@@ -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; }
}
}
+55
View File
@@ -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;
}
}
}
+22
View File
@@ -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; }
}
}
+46
View File
@@ -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; }
}
}
+69
View File
@@ -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;
}
}
}
+28
View File
@@ -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
}
}
+56
View File
@@ -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);
}
}
}
+48
View File
@@ -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;
}
}
+81
View File
@@ -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;
}
}
}
+31
View File
@@ -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; }
}
}
+31
View File
@@ -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; }
}
}
+156
View File
@@ -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);
}
}
}
+50
View File
@@ -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;
}
}
}
+31
View File
@@ -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; }
}
}
+120
View File
@@ -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 + "')";
}
}
}
+53
View File
@@ -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; }
}
}
+223
View File
@@ -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);
}
}
}
+160
View File
@@ -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 =
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
+34
View File
@@ -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
+42
View File
@@ -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>
+175
View File
@@ -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);
}
}
+21
View File
@@ -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 =
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
+39
View File
@@ -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