diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index e016d7e51f..29cc6558c1 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -280,6 +280,13 @@ namespace Emby.Naming.Common "default" }; + MediaHearingImpairedFlags = new[] + { + "cc", + "hi", + "sdh" + }; + EpisodeExpressions = new[] { // *** Begin Kodi Standard Naming @@ -727,6 +734,11 @@ namespace Emby.Naming.Common /// public string[] MediaDefaultFlags { get; set; } + /// + /// Gets or sets list of external media hearing impaired flags. + /// + public string[] MediaHearingImpairedFlags { get; set; } + /// /// Gets or sets list of album stacking prefixes. /// diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 3bde3a1cf9..1fa4fa5371 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -99,6 +99,18 @@ namespace Emby.Naming.ExternalFiles pathInfo.Language = culture.ThreeLetterISOLanguageName; extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); } + else if (culture != 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.ThreeLetterISOLanguageName; + extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); + } + else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase))) + { + pathInfo.IsHearingImpaired = true; + extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); + } else { titleString = currentSlice + titleString; diff --git a/Emby.Naming/ExternalFiles/ExternalPathParserResult.cs b/Emby.Naming/ExternalFiles/ExternalPathParserResult.cs index 1cc773a2e1..b0d9e7a9fd 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParserResult.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParserResult.cs @@ -11,11 +11,13 @@ namespace Emby.Naming.ExternalFiles /// Path to file. /// Is default. /// Is forced. - public ExternalPathParserResult(string path, bool isDefault = false, bool isForced = false) + /// For the hearing impaired. + public ExternalPathParserResult(string path, bool isDefault = false, bool isForced = false, bool isHearingImpaired = false) { Path = path; IsDefault = isDefault; IsForced = isForced; + IsHearingImpaired = isHearingImpaired; } /// @@ -47,5 +49,11 @@ namespace Emby.Naming.ExternalFiles /// /// true if this instance is forced; otherwise, false. public bool IsForced { get; set; } + + /// + /// Gets or sets a value indicating whether this instance is for the hearing impaired. + /// + /// true if this instance is for the hearing impaired; otherwise, false. + public bool IsHearingImpaired { get; set; } } } diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 9c9fa73830..cdc0aec8d4 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -178,7 +178,8 @@ namespace Emby.Server.Implementations.Data "RpuPresentFlag", "ElPresentFlag", "BlPresentFlag", - "DvBlSignalCompatibilityId" + "DvBlSignalCompatibilityId", + "IsHearingImpaired" }; private static readonly string _mediaStreamSaveColumnsInsertQuery = @@ -349,7 +350,8 @@ namespace Emby.Server.Implementations.Data public void Initialize(SqliteUserDataRepository userDataRepo, IUserManager userManager) { const string CreateMediaStreamsTableCommand - = "create table if not exists mediastreams (ItemId GUID, StreamIndex INT, StreamType TEXT, Codec TEXT, Language TEXT, ChannelLayout TEXT, Profile TEXT, AspectRatio TEXT, Path TEXT, IsInterlaced BIT, BitRate INT NULL, Channels INT NULL, SampleRate INT NULL, IsDefault BIT, IsForced BIT, IsExternal BIT, Height INT NULL, Width INT NULL, AverageFrameRate FLOAT NULL, RealFrameRate FLOAT NULL, Level FLOAT NULL, PixelFormat TEXT, BitDepth INT NULL, IsAnamorphic BIT NULL, RefFrames INT NULL, CodecTag TEXT NULL, Comment TEXT NULL, NalLengthSize TEXT NULL, IsAvc BIT NULL, Title TEXT NULL, TimeBase TEXT NULL, CodecTimeBase TEXT NULL, ColorPrimaries TEXT NULL, ColorSpace TEXT NULL, ColorTransfer TEXT NULL, DvVersionMajor INT NULL, DvVersionMinor INT NULL, DvProfile INT NULL, DvLevel INT NULL, RpuPresentFlag INT NULL, ElPresentFlag INT NULL, BlPresentFlag INT NULL, DvBlSignalCompatibilityId INT NULL, PRIMARY KEY (ItemId, StreamIndex))"; + = "create table if not exists mediastreams (ItemId GUID, StreamIndex INT, StreamType TEXT, Codec TEXT, Language TEXT, ChannelLayout TEXT, Profile TEXT, AspectRatio TEXT, Path TEXT, IsInterlaced BIT, BitRate INT NULL, Channels INT NULL, SampleRate INT NULL, IsDefault BIT, IsForced BIT, IsExternal BIT, Height INT NULL, Width INT NULL, AverageFrameRate FLOAT NULL, RealFrameRate FLOAT NULL, Level FLOAT NULL, PixelFormat TEXT, BitDepth INT NULL, IsAnamorphic BIT NULL, RefFrames INT NULL, CodecTag TEXT NULL, Comment TEXT NULL, NalLengthSize TEXT NULL, IsAvc BIT NULL, Title TEXT NULL, TimeBase TEXT NULL, CodecTimeBase TEXT NULL, ColorPrimaries TEXT NULL, ColorSpace TEXT NULL, ColorTransfer TEXT NULL, DvVersionMajor INT NULL, DvVersionMinor INT NULL, DvProfile INT NULL, DvLevel INT NULL, RpuPresentFlag INT NULL, ElPresentFlag INT NULL, BlPresentFlag INT NULL, DvBlSignalCompatibilityId INT NULL, IsHearingImpaired BIT NULL, PRIMARY KEY (ItemId, StreamIndex))"; + const string CreateMediaAttachmentsTableCommand = "create table if not exists mediaattachments (ItemId GUID, AttachmentIndex INT, Codec TEXT, CodecTag TEXT NULL, Comment TEXT NULL, Filename TEXT NULL, MIMEType TEXT NULL, PRIMARY KEY (ItemId, AttachmentIndex))"; @@ -572,6 +574,8 @@ namespace Emby.Server.Implementations.Data AddColumn(db, "MediaStreams", "ElPresentFlag", "INT", existingColumnNames); AddColumn(db, "MediaStreams", "BlPresentFlag", "INT", existingColumnNames); AddColumn(db, "MediaStreams", "DvBlSignalCompatibilityId", "INT", existingColumnNames); + + AddColumn(db, "MediaStreams", "IsHearingImpaired", "TEXT", existingColumnNames); }, TransactionMode); @@ -5836,6 +5840,8 @@ AND Type = @InternalPersonType)"); statement.TryBind("@ElPresentFlag" + index, stream.ElPresentFlag); statement.TryBind("@BlPresentFlag" + index, stream.BlPresentFlag); statement.TryBind("@DvBlSignalCompatibilityId" + index, stream.DvBlSignalCompatibilityId); + + statement.TryBind("@IsHearingImpaired" + index, stream.IsHearingImpaired); } statement.Reset(); @@ -6047,12 +6053,15 @@ AND Type = @InternalPersonType)"); item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId; } + item.IsHearingImpaired = reader.GetBoolean(43); + if (item.Type == MediaStreamType.Subtitle) { item.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); item.LocalizedDefault = _localization.GetLocalizedString("Default"); item.LocalizedForced = _localization.GetLocalizedString("Forced"); item.LocalizedExternal = _localization.GetLocalizedString("External"); + item.LocalizedHearingImpaired = _localization.GetLocalizedString("Hearing Impaired"); } return item; diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index d8c33d51bd..15088384cc 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -28,6 +28,7 @@ "HeaderLiveTV": "Live TV", "HeaderNextUp": "Next Up", "HeaderRecordingGroups": "Recording Groups", + "HearingImpaired": "Hearing Impaired", "HomeVideos": "Home Videos", "Inherit": "Inherit", "ItemAddedWithName": "{0} was added to the library", diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index b33b45ab2f..66e52ca57e 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -730,6 +730,7 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedDefault = _localization.GetLocalizedString("Default"); stream.LocalizedForced = _localization.GetLocalizedString("Forced"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); + stream.LocalizedHearingImpaired = _localization.GetLocalizedString("Hearing Impaired"); if (string.IsNullOrEmpty(stream.Title)) { @@ -955,6 +956,11 @@ namespace MediaBrowser.MediaEncoding.Probing { stream.IsForced = true; } + + if (disposition.GetValueOrDefault("hearing_impaired") == 1) + { + stream.IsHearingImpaired = true; + } } NormalizeStreamTitle(stream); diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index 90a60cf470..344ebaf808 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -221,6 +221,8 @@ namespace MediaBrowser.Model.Entities public string LocalizedExternal { get; set; } + public string LocalizedHearingImpaired { get; set; } + public string DisplayTitle { get @@ -345,6 +347,11 @@ namespace MediaBrowser.Model.Entities attributes.Add(string.IsNullOrEmpty(LocalizedUndefined) ? "Und" : LocalizedUndefined); } + if (IsHearingImpaired) + { + attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired); + } + if (IsDefault) { attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault); @@ -453,6 +460,12 @@ namespace MediaBrowser.Model.Entities /// true if this instance is forced; otherwise, false. public bool IsForced { get; set; } + /// + /// Gets or sets a value indicating whether this instance is for the hearing impaired. + /// + /// true if this instance is for the hearing impaired; otherwise, false. + public bool IsHearingImpaired { get; set; } + /// /// Gets or sets the height. /// diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index d55cc44914..1bc2edfd88 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -120,6 +120,7 @@ namespace MediaBrowser.Providers.MediaInfo mediaStream.Index = startIndex++; mediaStream.IsDefault = pathInfo.IsDefault || mediaStream.IsDefault; mediaStream.IsForced = pathInfo.IsForced || mediaStream.IsForced; + mediaStream.IsHearingImpaired = pathInfo.IsHearingImpaired || mediaStream.IsHearingImpaired; mediaStreams.Add(MergeMetadata(mediaStream, pathInfo)); } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 13cfe885f8..bbe1246ca7 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -65,6 +65,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.True(res.VideoStream.IsDefault); Assert.False(res.VideoStream.IsExternal); Assert.False(res.VideoStream.IsForced); + Assert.False(res.VideoStream.IsHearingImpaired); Assert.False(res.VideoStream.IsInterlaced); Assert.False(res.VideoStream.IsTextSubtitleStream); Assert.Equal(13d, res.VideoStream.Level); @@ -142,16 +143,19 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[3].Type); Assert.Equal("DVDSUB", res.MediaStreams[3].Codec); Assert.Null(res.MediaStreams[3].Title); + Assert.False(res.MediaStreams[3].IsHearingImpaired); Assert.Equal("eng", res.MediaStreams[4].Language); Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[4].Type); Assert.Equal("mov_text", res.MediaStreams[4].Codec); Assert.Null(res.MediaStreams[4].Title); + Assert.True(res.MediaStreams[4].IsHearingImpaired); Assert.Equal("eng", res.MediaStreams[5].Language); Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[5].Type); Assert.Equal("mov_text", res.MediaStreams[5].Codec); Assert.Equal("Commentary", res.MediaStreams[5].Title); + Assert.False(res.MediaStreams[5].IsHearingImpaired); } [Fact] diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json index 77e3def76d..9a7a4ba373 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json @@ -206,7 +206,7 @@ "lyrics": 0, "karaoke": 0, "forced": 0, - "hearing_impaired": 0, + "hearing_impaired": 1, "visual_impaired": 0, "clean_effects": 0, "attached_pic": 0, diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs index 80c38affe1..d39a22e30b 100644 --- a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs @@ -82,6 +82,19 @@ namespace Jellyfin.Model.Tests.Entities Codec = null }); + data.Add( + "Title - EN - Hearing Impaired - Default - Forced - SRT", + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = "Title", + Language = "EN", + IsForced = true, + IsDefault = true, + IsHearingImpaired = true, + Codec = "SRT" + }); + data.Add( "Title - AAC - Default - External", new MediaStream diff --git a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs index b396b54400..97949adffa 100644 --- a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs @@ -17,12 +17,15 @@ public class ExternalPathParserTests { var englishCultureDto = new CultureDto("English", "English", "en", new[] { "eng" }); var frenchCultureDto = new CultureDto("French", "French", "fr", new[] { "fre", "fra" }); + var hindiCultureDto = new CultureDto("Hindi", "Hindi", "hi", new[] { "hin" }); var localizationManager = new Mock(MockBehavior.Loose); localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase))) .Returns(englishCultureDto); localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"fr.*", RegexOptions.IgnoreCase))) .Returns(frenchCultureDto); + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"hi.*", RegexOptions.IgnoreCase))) + .Returns(hindiCultureDto); _audioPathParser = new ExternalPathParser(new NamingOptions(), localizationManager.Object, DlnaProfileType.Audio); _subtitlePathParser = new ExternalPathParser(new NamingOptions(), localizationManager.Object, DlnaProfileType.Subtitle); @@ -89,6 +92,7 @@ public class ExternalPathParserTests [InlineData(".DEFAULT.FORCED", null, null, true, true)] [InlineData(".en", null, "eng")] [InlineData(".EN", null, "eng")] + [InlineData(".hi", null, "hin")] [InlineData(".fr.en", "fr", "eng")] [InlineData(".en.fr", "en", "fre")] [InlineData(".title.en.fr", "title.en", "fre")] @@ -96,7 +100,11 @@ public class ExternalPathParserTests [InlineData(".Title.with.Separator", "Title.with.Separator", null)] [InlineData(".title.en.default.forced", "title", "eng", true, true)] [InlineData(".forced.default.en.title", "title", "eng", true, true)] - public void ParseFile_ExtraTokens_ParseToValues(string tokens, string? title, string? language, bool isDefault = false, bool isForced = false) + [InlineData(".sdh.en.title", "title", "eng", false, false, true)] + [InlineData(".en.cc.title", "title", "eng", false, false, true)] + [InlineData(".hi.en.title", "title", "eng", false, false, true)] + [InlineData(".en.hi.title", "title", "eng", false, false, true)] + public void ParseFile_ExtraTokens_ParseToValues(string tokens, string? title, string? language, bool isDefault = false, bool isForced = false, bool isHearingImpaired = false) { var path = "My.Video" + tokens + ".srt"; @@ -107,5 +115,6 @@ public class ExternalPathParserTests Assert.Equal(language, actual.Language); Assert.Equal(isDefault, actual.IsDefault); Assert.Equal(isForced, actual.IsForced); + Assert.Equal(isHearingImpaired, actual.IsHearingImpaired); } } diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index 57674bb7f4..6ee4b8ef22 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -227,7 +227,7 @@ public class MediaInfoResolverTests }); // filename has metadata - file = "My.Video.Title1.default.forced.en.srt"; + file = "My.Video.Title1.default.forced.sdh.en.srt"; data.Add( file, new[] @@ -236,7 +236,7 @@ public class MediaInfoResolverTests }, new[] { - CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title1", 0, true, true) + CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title1", 0, true, true, true) }); // single stream with metadata @@ -245,15 +245,15 @@ public class MediaInfoResolverTests file, new[] { - CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true) + CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true, true) }, new[] { - CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true) + CreateMediaStream(VideoDirectoryPath + "/" + file, "eng", "Title", 0, true, true, true) }); // stream wins for title/language, filename wins for flags when conflicting - file = "My.Video.Title2.default.forced.en.srt"; + file = "My.Video.Title2.default.forced.sdh.en.srt"; data.Add( file, new[] @@ -262,7 +262,7 @@ public class MediaInfoResolverTests }, new[] { - CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0, true, true) + CreateMediaStream(VideoDirectoryPath + "/" + file, "fra", "Metadata", 0, true, true, true) }); // multiple stream with metadata - filename flags ignored but other data filled in when missing from stream @@ -324,6 +324,7 @@ public class MediaInfoResolverTests Assert.Equal(expected.Path, actual.Path); Assert.Equal(expected.IsDefault, actual.IsDefault); Assert.Equal(expected.IsForced, actual.IsForced); + Assert.Equal(expected.IsHearingImpaired, actual.IsHearingImpaired); Assert.Equal(expected.Language, actual.Language); Assert.Equal(expected.Title, actual.Title); } @@ -396,7 +397,7 @@ public class MediaInfoResolverTests } } - private static MediaStream CreateMediaStream(string path, string? language, string? title, int index, bool isForced = false, bool isDefault = false) + private static MediaStream CreateMediaStream(string path, string? language, string? title, int index, bool isForced = false, bool isDefault = false, bool isHearingImpaired = false) { return new MediaStream { @@ -405,6 +406,7 @@ public class MediaInfoResolverTests Path = path, IsDefault = isDefault, IsForced = isForced, + IsHearingImpaired = isHearingImpaired, Language = language, Title = title };