Merge branch 'refs/heads/master' into item-authorization-3

# Conflicts:
#	Jellyfin.Api/Controllers/PlaylistsController.cs
pull/11171/head
Cody Robibero 2 months ago
commit a1e67fa61a

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.3",
"version": "8.0.4",
"commands": [
"dotnet-ef"
]

@ -105,7 +105,7 @@ jobs:
body="${body//$'\r'/'%0D'}"
echo ::set-output name=body::$body
- name: Find difference comment
uses: peter-evans/find-comment@d5fe37641ad8451bdd80312415672ba26c86575e # v3.0.0
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}

@ -132,7 +132,7 @@ jobs:
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
with:
python-version: '3.12'
cache: 'pip'

@ -16,7 +16,7 @@
<PackageVersion Include="Diacritics" Version="3.3.27" />
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
<PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.2.3" />
<PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.3.1" />
<PackageVersion Include="FsCheck.Xunit" Version="2.16.6" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.1" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
@ -25,15 +25,15 @@
<PackageVersion Include="libse" Version="3.6.13" />
<PackageVersion Include="LrcParser" Version="2023.524.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.3" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
@ -42,8 +42,8 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.3" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.3" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.4" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.4" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
@ -73,7 +73,7 @@
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.7" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="1.0.0.17" />
<PackageVersion Include="Svg.Skia" Version="1.0.0.18" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageVersion Include="System.Globalization" Version="4.3.0" />

@ -537,6 +537,12 @@ namespace Emby.Naming.Common
"extras",
MediaType.Video),
new ExtraRule(
ExtraType.Unknown,
ExtraRuleType.DirectoryName,
"extra",
MediaType.Video),
new ExtraRule(
ExtraType.Unknown,
ExtraRuleType.DirectoryName,

@ -102,7 +102,7 @@ namespace Emby.Server.Implementations.Collections
var name = _localizationManager.GetLocalizedString("Collections");
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.BoxSets, libraryOptions, true).ConfigureAwait(false);
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
return FindFolders(path).First();
}

@ -903,10 +903,7 @@ namespace Emby.Server.Implementations.Dto
if (item is Audio audio)
{
dto.Album = audio.Album;
if (audio.ExtraType.HasValue)
{
dto.ExtraType = audio.ExtraType.Value.ToString();
}
dto.ExtraType = audio.ExtraType;
var albumParent = audio.AlbumEntity;
@ -1058,10 +1055,7 @@ namespace Emby.Server.Implementations.Dto
dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
}
if (video.ExtraType.HasValue)
{
dto.ExtraType = video.ExtraType.Value.ToString();
}
dto.ExtraType = video.ExtraType;
}
if (options.ContainsField(ItemFields.MediaStreams))

@ -48,7 +48,7 @@ namespace Emby.Server.Implementations.HttpServer
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
using var connection = new WebSocketConnection(
var connection = new WebSocketConnection(
_loggerFactory.CreateLogger<WebSocketConnection>(),
webSocket,
authorizationInfo,
@ -56,17 +56,19 @@ namespace Emby.Server.Implementations.HttpServer
{
OnReceive = ProcessWebSocketMessageReceived
};
var tasks = new Task[_webSocketListeners.Length];
for (var i = 0; i < _webSocketListeners.Length; ++i)
await using (connection.ConfigureAwait(false))
{
tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection, context);
}
var tasks = new Task[_webSocketListeners.Length];
for (var i = 0; i < _webSocketListeners.Length; ++i)
{
tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection, context);
}
await Task.WhenAll(tasks).ConfigureAwait(false);
await Task.WhenAll(tasks).ConfigureAwait(false);
await connection.ReceiveAsync().ConfigureAwait(false);
_logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
await connection.ReceiveAsync().ConfigureAwait(false);
_logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
}
}
catch (Exception ex) // Otherwise ASP.Net will ignore the exception
{

@ -2703,7 +2703,12 @@ namespace Emby.Server.Implementations.Library
extra = itemById;
}
extra.ExtraType = extraType;
// Only update extra type if it is more specific then the currently known extra type
if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown)
{
extra.ExtraType = extraType;
}
extra.ParentId = Guid.Empty;
extra.OwnerId = owner.Id;
return extra;

@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library
if (user is not null)
{
SetDefaultAudioAndSubtitleStreamIndexes(item, source, user);
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
if (item.MediaType == MediaType.Audio)
{
@ -296,7 +296,7 @@ namespace Emby.Server.Implementations.Library
catch (Exception ex)
{
_logger.LogError(ex, "Error getting media sources");
return Enumerable.Empty<MediaSourceInfo>();
return [];
}
}
@ -339,7 +339,7 @@ namespace Emby.Server.Implementations.Library
{
foreach (var source in sources)
{
SetDefaultAudioAndSubtitleStreamIndexes(item, source, user);
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
if (item.MediaType == MediaType.Audio)
{
@ -360,7 +360,7 @@ namespace Emby.Server.Implementations.Library
{
if (string.IsNullOrEmpty(language))
{
return Array.Empty<string>();
return [];
}
var culture = _localizationManager.FindLanguageInfo(language);
@ -369,14 +369,15 @@ namespace Emby.Server.Implementations.Library
return culture.ThreeLetterISOLanguageNames;
}
return new string[] { language };
return [language];
}
private void SetDefaultSubtitleStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
{
if (userData.SubtitleStreamIndex.HasValue
&& user.RememberSubtitleSelections
&& user.SubtitleMode != SubtitlePlaybackMode.None && allowRememberingSelection)
&& user.SubtitleMode != SubtitlePlaybackMode.None
&& allowRememberingSelection)
{
var index = userData.SubtitleStreamIndex.Value;
// Make sure the saved index is still valid
@ -390,7 +391,7 @@ namespace Emby.Server.Implementations.Library
var preferredSubs = NormalizeLanguage(user.SubtitleLanguagePreference);
var defaultAudioIndex = source.DefaultAudioStreamIndex;
var audioLangage = defaultAudioIndex is null
var audioLanguage = defaultAudioIndex is null
? null
: source.MediaStreams.Where(i => i.Type == MediaStreamType.Audio && i.Index == defaultAudioIndex).Select(i => i.Language).FirstOrDefault();
@ -398,9 +399,9 @@ namespace Emby.Server.Implementations.Library
source.MediaStreams,
preferredSubs,
user.SubtitleMode,
audioLangage);
audioLanguage);
MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLangage);
MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage);
}
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
@ -421,7 +422,7 @@ namespace Emby.Server.Implementations.Library
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack);
}
public void SetDefaultAudioAndSubtitleStreamIndexes(BaseItem item, MediaSourceInfo source, User user)
public void SetDefaultAudioAndSubtitleStreamIndices(BaseItem item, MediaSourceInfo source, User user)
{
// Item would only be null if the app didn't supply ItemId as part of the live stream open request
var mediaType = item?.MediaType ?? MediaType.Video;
@ -526,7 +527,7 @@ namespace Emby.Server.Implementations.Library
var item = request.ItemId.IsEmpty()
? null
: _libraryManager.GetItemById(request.ItemId);
SetDefaultAudioAndSubtitleStreamIndexes(item, clone, user);
SetDefaultAudioAndSubtitleStreamIndices(item, clone, user);
}
return new Tuple<LiveStreamResponse, IDirectStreamProvider>(new LiveStreamResponse(clone), liveStream as IDirectStreamProvider);

@ -124,16 +124,16 @@ namespace Emby.Server.Implementations.Library
}
else if (mode == SubtitlePlaybackMode.Always)
{
// always load the most suitable full subtitles
// Always load the most suitable full subtitles
filteredStreams = sortedStreams.Where(s => !s.IsForced).ToList();
}
else if (mode == SubtitlePlaybackMode.OnlyForced)
{
// always load the most suitable full subtitles
// Always load the most suitable full subtitles
filteredStreams = sortedStreams.Where(s => s.IsForced).ToList();
}
// load forced subs if we have found no suitable full subtitles
// Load forced subs if we have found no suitable full subtitles
var iterStreams = filteredStreams is null || filteredStreams.Count == 0
? sortedStreams.Where(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
: filteredStreams;

@ -1,7 +1,5 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.IO;
using System.Linq;
@ -11,7 +9,6 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.LocalMetadata.Savers;
using MediaBrowser.Model.Entities;
namespace Emby.Server.Implementations.Library.Resolvers
{
@ -20,11 +17,11 @@ namespace Emby.Server.Implementations.Library.Resolvers
/// </summary>
public class PlaylistResolver : GenericFolderResolver<Playlist>
{
private CollectionType?[] _musicPlaylistCollectionTypes =
{
private readonly CollectionType?[] _musicPlaylistCollectionTypes =
[
null,
CollectionType.music
};
];
/// <inheritdoc/>
protected override Playlist Resolve(ItemResolveArgs args)

@ -125,5 +125,7 @@
"TaskDownloadMissingSubtitles": "Спампаваць адсутныя субтытры",
"TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных спісаў прайгравання HLS. Гэта задача можа працаваць у працягу доўгага часу.",
"TaskRefreshTrickplayImages": "Стварыце выявы Trickplay",
"TaskRefreshTrickplayImagesDescription": "Стварае прагляд відэаролікаў для Trickplay у падключаных бібліятэках."
"TaskRefreshTrickplayImagesDescription": "Стварае прагляд відэаролікаў для Trickplay у падключаных бібліятэках.",
"TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і спісы прайгравання",
"TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і спісаў прайгравання, якія больш не існуюць."
}

@ -126,5 +126,7 @@
"External": "Extern",
"HearingImpaired": "Discapacitat auditiva",
"TaskRefreshTrickplayImages": "Generar miniatures de línia de temps",
"TaskRefreshTrickplayImagesDescription": "Crear miniatures de línia de temps per vídeos en les biblioteques habilitades."
"TaskRefreshTrickplayImagesDescription": "Crear miniatures de línia de temps per vídeos en les biblioteques habilitades.",
"TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.",
"TaskCleanCollectionsAndPlaylists": "Neteja col·leccions i llistes de reproducció"
}

@ -126,5 +126,7 @@
"External": "Externí",
"HearingImpaired": "Sluchově postižení",
"TaskRefreshTrickplayImages": "Generovat obrázky pro Trickplay",
"TaskRefreshTrickplayImagesDescription": "Obrázky Trickplay se používají k zobrazení náhledů u videí v knihovnách, kde je to povoleno."
"TaskRefreshTrickplayImagesDescription": "Obrázky Trickplay se používají k zobrazení náhledů u videí v knihovnách, kde je to povoleno.",
"TaskCleanCollectionsAndPlaylists": "Pročistit kolekce a seznamy přehrávání",
"TaskCleanCollectionsAndPlaylistsDescription": "Odstraní neexistující položky z kolekcí a seznamů přehrávání."
}

@ -126,5 +126,7 @@
"External": "Extern",
"HearingImpaired": "Hörgeschädigt",
"TaskRefreshTrickplayImages": "Trickplay-Bilder generieren",
"TaskRefreshTrickplayImagesDescription": "Erstellt eine Trickplay-Vorschau für Videos in aktivierten Bibliotheken."
"TaskRefreshTrickplayImagesDescription": "Erstellt eine Trickplay-Vorschau für Videos in aktivierten Bibliotheken.",
"TaskCleanCollectionsAndPlaylists": "Sammlungen und Playlisten aufräumen",
"TaskCleanCollectionsAndPlaylistsDescription": "Lösche nicht mehr vorhandene Einträge aus den Sammlungen und Playlisten."
}

@ -126,5 +126,7 @@
"External": "External",
"HearingImpaired": "Hearing Impaired",
"TaskRefreshTrickplayImages": "Generate Trickplay Images",
"TaskRefreshTrickplayImagesDescription": "Creates trickplay previews for videos in enabled libraries."
"TaskRefreshTrickplayImagesDescription": "Creates trickplay previews for videos in enabled libraries.",
"TaskCleanCollectionsAndPlaylists": "Clean up collections and playlists",
"TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist."
}

@ -125,5 +125,7 @@
"TaskOptimizeDatabase": "Optimize database",
"TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance.",
"TaskKeyframeExtractor": "Keyframe Extractor",
"TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time."
"TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.",
"TaskCleanCollectionsAndPlaylists": "Clean up collections and playlists",
"TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist."
}

@ -30,7 +30,7 @@
"ItemAddedWithName": "{0} se ha añadido a la biblioteca",
"ItemRemovedWithName": "{0} ha sido eliminado de la biblioteca",
"LabelIpAddressValue": "Dirección IP: {0}",
"LabelRunningTimeValue": "Tiempo de funcionamiento: {0}",
"LabelRunningTimeValue": "Duración: {0}",
"Latest": "Últimas",
"MessageApplicationUpdated": "Se ha actualizado el servidor Jellyfin",
"MessageApplicationUpdatedTo": "Se ha actualizado el servidor Jellyfin a la versión {0}",

@ -126,5 +126,7 @@
"External": "Externe",
"HearingImpaired": "Malentendants",
"TaskRefreshTrickplayImages": "Générer des images Trickplay",
"TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées."
"TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées.",
"TaskCleanCollectionsAndPlaylists": "Nettoyer les collections et les listes de lecture",
"TaskCleanCollectionsAndPlaylistsDescription": "Supprime les éléments des collections et des listes de lecture qui n'existent plus."
}

@ -126,5 +126,7 @@
"External": "Išorinis",
"HearingImpaired": "Su klausos sutrikimais",
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
"TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose."
"TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.",
"TaskCleanCollectionsAndPlaylists": "Sutvarko duomenis jūsų kolekcijose ir grojaraščiuose.",
"TaskCleanCollectionsAndPlaylistsDescription": "Pašalina nebeegzistuojančius elementus iš kolekcijų ir grojaraščių."
}

@ -126,5 +126,7 @@
"External": "Extern",
"HearingImpaired": "Slechthorend",
"TaskRefreshTrickplayImages": "Trickplay-afbeeldingen genereren",
"TaskRefreshTrickplayImagesDescription": "Creëert trickplay-voorvertoningen voor video's in bibliotheken waarvoor dit is ingeschakeld."
"TaskRefreshTrickplayImagesDescription": "Creëert trickplay-voorvertoningen voor video's in bibliotheken waarvoor dit is ingeschakeld.",
"TaskCleanCollectionsAndPlaylists": "Collecties en afspeellijsten opruimen",
"TaskCleanCollectionsAndPlaylistsDescription": "Verwijdert niet langer bestaande items uit collecties en afspeellijsten."
}

@ -126,5 +126,7 @@
"TaskKeyframeExtractor": "Ekstraktor klatek kluczowych",
"HearingImpaired": "Niedosłyszący",
"TaskRefreshTrickplayImages": "Generuj obrazy trickplay",
"TaskRefreshTrickplayImagesDescription": "Tworzy podglądy trickplay dla filmów we włączonych bibliotekach."
"TaskRefreshTrickplayImagesDescription": "Tworzy podglądy trickplay dla filmów we włączonych bibliotekach.",
"TaskCleanCollectionsAndPlaylistsDescription": "Usuwa elementy z kolekcji i list odtwarzania, które już nie istnieją.",
"TaskCleanCollectionsAndPlaylists": "Oczyść kolekcje i listy odtwarzania"
}

@ -126,5 +126,7 @@
"External": "Externo",
"HearingImpaired": "Surdo",
"TaskRefreshTrickplayImages": "Gerar imagens de truques",
"TaskRefreshTrickplayImagesDescription": "Cria vizualizações de truques para videos nas librarias ativas."
"TaskRefreshTrickplayImagesDescription": "Cria vizualizações de truques para videos nas librarias ativas.",
"TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
"TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução"
}

@ -125,5 +125,7 @@
"TaskKeyframeExtractor": "Extrator de quadro-chave",
"TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
"TaskRefreshTrickplayImages": "Gerar miniaturas de vídeo",
"TaskRefreshTrickplayImagesDescription": "Cria miniaturas de vídeo para vídeos nas bibliotecas definidas."
"TaskRefreshTrickplayImagesDescription": "Cria miniaturas de vídeo para vídeos nas bibliotecas definidas.",
"TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
"TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução"
}

@ -126,5 +126,7 @@
"External": "Внешние",
"HearingImpaired": "Для слабослышащих",
"TaskRefreshTrickplayImages": "Сгенерировать изображения для Trickplay",
"TaskRefreshTrickplayImagesDescription": "Создает предпросмотры для Trickplay для видео в библиотеках, где эта функция включена."
"TaskRefreshTrickplayImagesDescription": "Создает предпросмотры для Trickplay для видео в библиотеках, где эта функция включена.",
"TaskCleanCollectionsAndPlaylists": "Очистка коллекций и списков воспроизведения",
"TaskCleanCollectionsAndPlaylistsDescription": "Удаляет элементы из коллекций и списков воспроизведения, которые больше не существуют."
}

@ -126,5 +126,7 @@
"External": "Externé",
"HearingImpaired": "Sluchovo postihnutí",
"TaskRefreshTrickplayImages": "Generovanie obrázkov Trickplay",
"TaskRefreshTrickplayImagesDescription": "Vytvára trickplay náhľady pre videá v povolených knižniciach."
"TaskRefreshTrickplayImagesDescription": "Vytvára trickplay náhľady pre videá v povolených knižniciach.",
"TaskCleanCollectionsAndPlaylists": "Vyčistiť kolekcie a playlisty",
"TaskCleanCollectionsAndPlaylistsDescription": "Odstráni položky z kolekcií a playlistov, ktoré už neexistujú."
}

@ -126,5 +126,7 @@
"External": "Harici",
"HearingImpaired": "Duyma Engelli",
"TaskRefreshTrickplayImages": "Trickplay Görselleri Oluştur",
"TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için trickplay önizlemeleri oluşturur."
"TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için trickplay önizlemeleri oluşturur.",
"TaskCleanCollectionsAndPlaylistsDescription": "Artık var olmayan koleksiyon ve çalma listelerindeki ögeleri kaldırır.",
"TaskCleanCollectionsAndPlaylists": "Koleksiyonları ve çalma listelerini temizleyin"
}

@ -125,5 +125,7 @@
"External": "Зовнішній",
"HearingImpaired": "З порушеннями слуху",
"TaskRefreshTrickplayImagesDescription": "Створює trickplay-зображення для відео у ввімкнених медіатеках.",
"TaskRefreshTrickplayImages": "Створити Trickplay-зображення"
"TaskRefreshTrickplayImages": "Створити Trickplay-зображення",
"TaskCleanCollectionsAndPlaylists": "Очистити колекції і списки відтворення",
"TaskCleanCollectionsAndPlaylistsDescription": "Видаляє елементи з колекцій і списків відтворення, які більше не існують."
}

@ -126,5 +126,7 @@
"External": "外部",
"HearingImpaired": "听力障碍",
"TaskRefreshTrickplayImages": "生成时间轴缩略图",
"TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成时间轴缩略图。"
"TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成时间轴缩略图。",
"TaskCleanCollectionsAndPlaylists": "清理合集和播放列表",
"TaskCleanCollectionsAndPlaylistsDescription": "清理合集和播放列表中已不存在的项目。"
}

@ -22,6 +22,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Playlists;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PlaylistsNET.Content;
@ -59,6 +60,11 @@ namespace Emby.Server.Implementations.Playlists
_appConfig = appConfig;
}
public Playlist GetPlaylistForUser(Guid playlistId, Guid userId)
{
return GetPlaylists(userId).Where(p => p.Id.Equals(playlistId)).FirstOrDefault();
}
public IEnumerable<Playlist> GetPlaylists(Guid userId)
{
var user = _userManager.GetUserById(userId);
@ -66,61 +72,56 @@ namespace Emby.Server.Implementations.Playlists
return GetPlaylistsFolder(userId).GetChildren(user, true).OfType<Playlist>();
}
public async Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest options)
public async Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest request)
{
var name = options.Name;
var name = request.Name;
var folderName = _fileSystem.GetValidFilename(name);
var parentFolder = GetPlaylistsFolder(options.UserId);
var parentFolder = GetPlaylistsFolder(request.UserId);
if (parentFolder is null)
{
throw new ArgumentException(nameof(parentFolder));
}
if (options.MediaType is null || options.MediaType == MediaType.Unknown)
if (request.MediaType is null || request.MediaType == MediaType.Unknown)
{
foreach (var itemId in options.ItemIdList)
foreach (var itemId in request.ItemIdList)
{
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
throw new ArgumentException("No item exists with the supplied Id");
}
var item = _libraryManager.GetItemById(itemId) ?? throw new ArgumentException("No item exists with the supplied Id");
if (item.MediaType != MediaType.Unknown)
{
options.MediaType = item.MediaType;
request.MediaType = item.MediaType;
}
else if (item is MusicArtist || item is MusicAlbum || item is MusicGenre)
{
options.MediaType = MediaType.Audio;
request.MediaType = MediaType.Audio;
}
else if (item is Genre)
{
options.MediaType = MediaType.Video;
request.MediaType = MediaType.Video;
}
else
{
if (item is Folder folder)
{
options.MediaType = folder.GetRecursiveChildren(i => !i.IsFolder && i.SupportsAddingToPlaylist)
request.MediaType = folder.GetRecursiveChildren(i => !i.IsFolder && i.SupportsAddingToPlaylist)
.Select(i => i.MediaType)
.FirstOrDefault(i => i != MediaType.Unknown);
}
}
if (options.MediaType is not null && options.MediaType != MediaType.Unknown)
if (request.MediaType is not null && request.MediaType != MediaType.Unknown)
{
break;
}
}
}
if (options.MediaType is null || options.MediaType == MediaType.Unknown)
if (request.MediaType is null || request.MediaType == MediaType.Unknown)
{
options.MediaType = MediaType.Audio;
request.MediaType = MediaType.Audio;
}
var user = _userManager.GetUserById(options.UserId);
var user = _userManager.GetUserById(request.UserId);
var path = Path.Combine(parentFolder.Path, folderName);
path = GetTargetPath(path);
@ -133,19 +134,20 @@ namespace Emby.Server.Implementations.Playlists
{
Name = name,
Path = path,
OwnerUserId = options.UserId,
Shares = options.Shares ?? Array.Empty<Share>()
OwnerUserId = request.UserId,
Shares = request.Users ?? [],
OpenAccess = request.Public ?? false
};
playlist.SetMediaType(options.MediaType);
playlist.SetMediaType(request.MediaType);
parentFolder.AddChild(playlist);
await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None)
.ConfigureAwait(false);
if (options.ItemIdList.Count > 0)
if (request.ItemIdList.Count > 0)
{
await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false)
await AddToPlaylistInternal(playlist.Id, request.ItemIdList, user, new DtoOptions(false)
{
EnableImages = true
}).ConfigureAwait(false);
@ -160,7 +162,7 @@ namespace Emby.Server.Implementations.Playlists
}
}
private string GetTargetPath(string path)
private static string GetTargetPath(string path)
{
while (Directory.Exists(path))
{
@ -170,14 +172,14 @@ namespace Emby.Server.Implementations.Playlists
return path;
}
private List<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, MediaType playlistMediaType, User user, DtoOptions options)
private IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, MediaType playlistMediaType, User user, DtoOptions options)
{
var items = itemIds.Select(i => _libraryManager.GetItemById(i)).Where(i => i is not null);
var items = itemIds.Select(_libraryManager.GetItemById).Where(i => i is not null);
return Playlist.GetPlaylistItems(playlistMediaType, items, user, options);
}
public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
{
var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId);
@ -231,13 +233,8 @@ namespace Emby.Server.Implementations.Playlists
// Update the playlist in the repository
playlist.LinkedChildren = newLinkedChildren;
await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
// Update the playlist on disk
if (playlist.IsFile)
{
SavePlaylistFile(playlist);
}
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
// Refresh playlist metadata
_providerManager.QueueRefresh(
@ -249,7 +246,7 @@ namespace Emby.Server.Implementations.Playlists
RefreshPriority.High);
}
public async Task RemoveFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds)
public async Task RemoveItemFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds)
{
if (_libraryManager.GetItemById(playlistId) is not Playlist playlist)
{
@ -266,12 +263,7 @@ namespace Emby.Server.Implementations.Playlists
.Select(i => i.Item1)
.ToArray();
await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
if (playlist.IsFile)
{
SavePlaylistFile(playlist);
}
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
_providerManager.QueueRefresh(
playlist.Id,
@ -313,14 +305,9 @@ namespace Emby.Server.Implementations.Playlists
newList.Insert(newIndex, item);
}
playlist.LinkedChildren = newList.ToArray();
playlist.LinkedChildren = [.. newList];
await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
if (playlist.IsFile)
{
SavePlaylistFile(playlist);
}
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
}
/// <inheritdoc />
@ -430,8 +417,11 @@ namespace Emby.Server.Implementations.Playlists
}
else if (extension.Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
{
var playlist = new M3uPlaylist();
playlist.IsExtended = true;
var playlist = new M3uPlaylist
{
IsExtended = true
};
foreach (var child in item.GetLinkedChildren())
{
var entry = new M3uPlaylistEntry()
@ -481,7 +471,7 @@ namespace Emby.Server.Implementations.Playlists
}
}
private string NormalizeItemPath(string playlistPath, string itemPath)
private static string NormalizeItemPath(string playlistPath, string itemPath)
{
return MakeRelativePath(Path.GetDirectoryName(playlistPath), itemPath);
}
@ -537,16 +527,11 @@ namespace Emby.Server.Implementations.Playlists
{
// Update owner if shared
var rankedShares = playlist.Shares.OrderByDescending(x => x.CanEdit).ToArray();
if (rankedShares.Length > 0 && Guid.TryParse(rankedShares[0].UserId, out var guid))
if (rankedShares.Length > 0)
{
playlist.OwnerUserId = guid;
playlist.OwnerUserId = rankedShares[0].UserId;
playlist.Shares = rankedShares.Skip(1).ToArray();
await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
if (playlist.IsFile)
{
SavePlaylistFile(playlist);
}
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
}
else if (!playlist.OpenAccess)
{
@ -563,5 +548,76 @@ namespace Emby.Server.Implementations.Playlists
}
}
}
public async Task UpdatePlaylist(PlaylistUpdateRequest request)
{
var playlist = GetPlaylistForUser(request.Id, request.UserId);
if (request.Ids is not null)
{
playlist.LinkedChildren = [];
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
var user = _userManager.GetUserById(request.UserId);
await AddToPlaylistInternal(request.Id, request.Ids, user, new DtoOptions(false)
{
EnableImages = true
}).ConfigureAwait(false);
playlist = GetPlaylistForUser(request.Id, request.UserId);
}
if (request.Name is not null)
{
playlist.Name = request.Name;
}
if (request.Users is not null)
{
playlist.Shares = request.Users;
}
if (request.Public is not null)
{
playlist.OpenAccess = request.Public.Value;
}
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
}
public async Task AddUserToShares(PlaylistUserUpdateRequest request)
{
var userId = request.UserId;
var playlist = GetPlaylistForUser(request.Id, userId);
var shares = playlist.Shares.ToList();
var existingUserShare = shares.FirstOrDefault(s => s.UserId.Equals(userId));
if (existingUserShare is not null)
{
shares.Remove(existingUserShare);
}
shares.Add(new PlaylistUserPermissions(userId, request.CanEdit ?? false));
playlist.Shares = shares;
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
}
public async Task RemoveUserFromShares(Guid playlistId, Guid userId, PlaylistUserPermissions share)
{
var playlist = GetPlaylistForUser(playlistId, userId);
var shares = playlist.Shares.ToList();
shares.Remove(share);
playlist.Shares = shares;
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
}
private async Task UpdatePlaylistInternal(Playlist playlist)
{
await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
if (playlist.IsFile)
{
SavePlaylistFile(playlist);
}
}
}
}

@ -510,7 +510,11 @@ public class LibraryController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden)
{
var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList();
var items = _libraryManager.GetUserRootFolder().Children
.Concat(_libraryManager.RootFolder.VirtualChildren)
.Where(i => _libraryManager.GetLibraryOptions(i).Enabled)
.OrderBy(i => i.SortName)
.ToList();
if (isHidden.HasValue)
{

@ -92,12 +92,232 @@ public class PlaylistsController : BaseJellyfinApiController
Name = name ?? createPlaylistRequest?.Name,
ItemIdList = ids,
UserId = userId.Value,
MediaType = mediaType ?? createPlaylistRequest?.MediaType
MediaType = mediaType ?? createPlaylistRequest?.MediaType,
Users = createPlaylistRequest?.Users.ToArray() ?? [],
Public = createPlaylistRequest?.IsPublic
}).ConfigureAwait(false);
return result;
}
/// <summary>
/// Updates a playlist.
/// </summary>
/// <param name="playlistId">The playlist id.</param>
/// <param name="updatePlaylistRequest">The <see cref="UpdatePlaylistDto"/> id.</param>
/// <response code="204">Playlist updated.</response>
/// <response code="403">Access forbidden.</response>
/// <response code="404">Playlist not found.</response>
/// <returns>
/// A <see cref="Task" /> that represents the asynchronous operation to update a playlist.
/// The task result contains an <see cref="OkResult"/> indicating success.
/// </returns>
[HttpPost("{playlistId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdatePlaylist(
[FromRoute, Required] Guid playlistId,
[FromBody, Required] UpdatePlaylistDto updatePlaylistRequest)
{
var callingUserId = User.GetUserId();
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
if (playlist is null)
{
return NotFound("Playlist not found");
}
var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
if (!isPermitted)
{
return Forbid();
}
await _playlistManager.UpdatePlaylist(new PlaylistUpdateRequest
{
UserId = callingUserId,
Id = playlistId,
Name = updatePlaylistRequest.Name,
Ids = updatePlaylistRequest.Ids,
Users = updatePlaylistRequest.Users,
Public = updatePlaylistRequest.IsPublic
}).ConfigureAwait(false);
return NoContent();
}
/// <summary>
/// Get a playlist's users.
/// </summary>
/// <param name="playlistId">The playlist id.</param>
/// <response code="200">Found shares.</response>
/// <response code="403">Access forbidden.</response>
/// <response code="404">Playlist not found.</response>
/// <returns>
/// A list of <see cref="PlaylistUserPermissions"/> objects.
/// </returns>
[HttpGet("{playlistId}/Users")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<IReadOnlyList<PlaylistUserPermissions>> GetPlaylistUsers(
[FromRoute, Required] Guid playlistId)
{
var userId = User.GetUserId();
var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId);
if (playlist is null)
{
return NotFound("Playlist not found");
}
var isPermitted = playlist.OwnerUserId.Equals(userId);
return isPermitted ? playlist.Shares.ToList() : Forbid();
}
/// <summary>
/// Get a playlist user.
/// </summary>
/// <param name="playlistId">The playlist id.</param>
/// <param name="userId">The user id.</param>
/// <response code="200">User permission found.</response>
/// <response code="403">Access forbidden.</response>
/// <response code="404">Playlist not found.</response>
/// <returns>
/// <see cref="PlaylistUserPermissions"/>.
/// </returns>
[HttpGet("{playlistId}/Users/{userId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<PlaylistUserPermissions?> GetPlaylistUser(
[FromRoute, Required] Guid playlistId,
[FromRoute, Required] Guid userId)
{
var callingUserId = User.GetUserId();
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
if (playlist is null)
{
return NotFound("Playlist not found");
}
var userPermission = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId));
var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId))
|| userId.Equals(callingUserId);
if (!isPermitted)
{
return Forbid();
}
if (userPermission is not null)
{
return userPermission;
}
return NotFound("User permissions not found");
}
/// <summary>
/// Modify a user of a playlist's users.
/// </summary>
/// <param name="playlistId">The playlist id.</param>
/// <param name="userId">The user id.</param>
/// <param name="updatePlaylistUserRequest">The <see cref="UpdatePlaylistUserDto"/>.</param>
/// <response code="204">User's permissions modified.</response>
/// <response code="403">Access forbidden.</response>
/// <response code="404">Playlist not found.</response>
/// <returns>
/// A <see cref="Task" /> that represents the asynchronous operation to modify an user's playlist permissions.
/// The task result contains an <see cref="OkResult"/> indicating success.
/// </returns>
[HttpPost("{playlistId}/Users/{userId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdatePlaylistUser(
[FromRoute, Required] Guid playlistId,
[FromRoute, Required] Guid userId,
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow), Required] UpdatePlaylistUserDto updatePlaylistUserRequest)
{
var callingUserId = User.GetUserId();
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
if (playlist is null)
{
return NotFound("Playlist not found");
}
var isPermitted = playlist.OwnerUserId.Equals(callingUserId);
if (!isPermitted)
{
return Forbid();
}
await _playlistManager.AddUserToShares(new PlaylistUserUpdateRequest
{
Id = playlistId,
UserId = userId,
CanEdit = updatePlaylistUserRequest.CanEdit
}).ConfigureAwait(false);
return NoContent();
}
/// <summary>
/// Remove a user from a playlist's users.
/// </summary>
/// <param name="playlistId">The playlist id.</param>
/// <param name="userId">The user id.</param>
/// <response code="204">User permissions removed from playlist.</response>
/// <response code="401">Unauthorized access.</response>
/// <response code="404">No playlist or user permissions found.</response>
/// <returns>
/// A <see cref="Task" /> that represents the asynchronous operation to delete a user from a playlist's shares.
/// The task result contains an <see cref="OkResult"/> indicating success.
/// </returns>
[HttpDelete("{playlistId}/Users/{userId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveUserFromPlaylist(
[FromRoute, Required] Guid playlistId,
[FromRoute, Required] Guid userId)
{
var callingUserId = User.GetUserId();
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
if (playlist is null)
{
return NotFound("Playlist not found");
}
var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
if (!isPermitted)
{
return Forbid();
}
var share = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId));
if (share is null)
{
return NotFound("User permissions not found");
}
await _playlistManager.RemoveUserFromShares(playlistId, callingUserId, share).ConfigureAwait(false);
return NoContent();
}
/// <summary>
/// Adds items to a playlist.
/// </summary>
@ -105,16 +325,34 @@ public class PlaylistsController : BaseJellyfinApiController
/// <param name="ids">Item id, comma delimited.</param>
/// <param name="userId">The userId.</param>
/// <response code="204">Items added to playlist.</response>
/// <response code="403">Access forbidden.</response>
/// <response code="404">Playlist not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> AddToPlaylist(
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddItemToPlaylist(
[FromRoute, Required] Guid playlistId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
[FromQuery] Guid? userId)
{
userId = RequestHelpers.GetUserId(User, userId);
await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false);
var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value);
if (playlist is null)
{
return NotFound("Playlist not found");
}
var isPermitted = playlist.OwnerUserId.Equals(userId.Value)
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(userId.Value));
if (!isPermitted)
{
return Forbid();
}
await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false);
return NoContent();
}
@ -125,14 +363,34 @@ public class PlaylistsController : BaseJellyfinApiController
/// <param name="itemId">The item id.</param>
/// <param name="newIndex">The new index.</param>
/// <response code="204">Item moved to new index.</response>
/// <response code="403">Access forbidden.</response>
/// <response code="404">Playlist not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> MoveItem(
[FromRoute, Required] string playlistId,
[FromRoute, Required] string itemId,
[FromRoute, Required] int newIndex)
{
var callingUserId = User.GetUserId();
var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId);
if (playlist is null)
{
return NotFound("Playlist not found");
}
var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
if (!isPermitted)
{
return Forbid();
}
await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false);
return NoContent();
}
@ -143,14 +401,34 @@ public class PlaylistsController : BaseJellyfinApiController
/// <param name="playlistId">The playlist id.</param>
/// <param name="entryIds">The item ids, comma delimited.</param>
/// <response code="204">Items removed.</response>
/// <response code="403">Access forbidden.</response>
/// <response code="404">Playlist not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpDelete("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> RemoveFromPlaylist(
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveItemFromPlaylist(
[FromRoute, Required] string playlistId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds)
{
await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
var callingUserId = User.GetUserId();
var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId);
if (playlist is null)
{
return NotFound("Playlist not found");
}
var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
if (!isPermitted)
{
return Forbid();
}
await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
return NoContent();
}
@ -167,10 +445,12 @@ public class PlaylistsController : BaseJellyfinApiController
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Original playlist returned.</response>
/// <response code="404">Access forbidden.</response>
/// <response code="404">Playlist not found.</response>
/// <returns>The original playlist items.</returns>
[HttpGet("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems(
[FromRoute, Required] Guid playlistId,
@ -184,6 +464,21 @@ public class PlaylistsController : BaseJellyfinApiController
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
userId = RequestHelpers.GetUserId(User, userId);
var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value);
if (playlist is null)
{
return NotFound("Playlist not found");
}
var isPermitted = playlist.OpenAccess
|| playlist.OwnerUserId.Equals(userId.Value)
|| playlist.Shares.Any(s => s.UserId.Equals(userId.Value));
if (!isPermitted)
{
return Forbid();
}
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);

@ -173,7 +173,7 @@ public class SubtitleController : BaseJellyfinApiController
/// <response code="200">File returned.</response>
/// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
[HttpGet("Providers/Subtitles/Subtitles/{subtitleId}")]
[Authorize]
[Authorize(Policy = Policies.SubtitleManagement)]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Application.Octet)]
[ProducesFile("text/*")]

@ -192,7 +192,7 @@ public static class HlsCodecStringHelpers
/// <returns>The AV1 codec string.</returns>
public static string GetAv1String(string? profile, int level, bool tierFlag, int bitDepth)
{
// https://aomedia.org/av1/specification/annex-a/
// https://aomediacodec.github.io/av1-isobmff/#codecsparam
// FORMAT: [codecTag].[profile].[level][tier].[bitDepth]
StringBuilder result = new StringBuilder("av01", 13);
@ -214,8 +214,7 @@ public static class HlsCodecStringHelpers
result.Append(".0");
}
if (level <= 0
|| level > 31)
if (level is <= 0 or > 31)
{
// Default to the maximum defined level 6.3
level = 19;
@ -230,7 +229,8 @@ public static class HlsCodecStringHelpers
}
result.Append('.')
.Append(level)
// Needed to pad it double digits; otherwise, browsers will reject the stream.
.AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", level)
.Append(tierFlag ? 'H' : 'M');
string bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture);

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Text.Json.Serialization;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json.Converters;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Api.Models.PlaylistDtos;
@ -14,13 +15,13 @@ public class CreatePlaylistDto
/// <summary>
/// Gets or sets the name of the new playlist.
/// </summary>
public string? Name { get; set; }
public required string Name { get; set; }
/// <summary>
/// Gets or sets item ids to add to the playlist.
/// </summary>
[JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>();
public IReadOnlyList<Guid> Ids { get; set; } = [];
/// <summary>
/// Gets or sets the user id.
@ -31,4 +32,14 @@ public class CreatePlaylistDto
/// Gets or sets the media type.
/// </summary>
public MediaType? MediaType { get; set; }
/// <summary>
/// Gets or sets the playlist users.
/// </summary>
public IReadOnlyList<PlaylistUserPermissions> Users { get; set; } = [];
/// <summary>
/// Gets or sets a value indicating whether the playlist is public.
/// </summary>
public bool IsPublic { get; set; } = true;
}

@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Jellyfin.Extensions.Json.Converters;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Api.Models.PlaylistDtos;
/// <summary>
/// Update existing playlist dto. Fields set to `null` will not be updated and keep their current values.
/// </summary>
public class UpdatePlaylistDto
{
/// <summary>
/// Gets or sets the name of the new playlist.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets item ids of the playlist.
/// </summary>
[JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
public IReadOnlyList<Guid>? Ids { get; set; }
/// <summary>
/// Gets or sets the playlist users.
/// </summary>
public IReadOnlyList<PlaylistUserPermissions>? Users { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the playlist is public.
/// </summary>
public bool? IsPublic { get; set; }
}

@ -0,0 +1,12 @@
namespace Jellyfin.Api.Models.PlaylistDtos;
/// <summary>
/// Update existing playlist user dto. Fields set to `null` will not be updated and keep their current values.
/// </summary>
public class UpdatePlaylistUserDto
{
/// <summary>
/// Gets or sets a value indicating whether the user can edit the playlist.
/// </summary>
public bool? CanEdit { get; set; }
}

@ -54,12 +54,12 @@ internal class FixPlaylistOwner : IMigrationRoutine
foreach (var playlist in playlists)
{
var shares = playlist.Shares;
if (shares.Length > 0)
if (shares.Count > 0)
{
var firstEditShare = shares.First(x => x.CanEdit);
if (firstEditShare is not null && Guid.TryParse(firstEditShare.UserId, out var guid))
if (firstEditShare is not null)
{
playlist.OwnerUserId = guid;
playlist.OwnerUserId = firstEditShare.UserId;
playlist.Shares = shares.Where(x => x != firstEditShare).ToArray();
playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
_playlistManager.SavePlaylistFile(playlist);

@ -833,7 +833,7 @@ namespace MediaBrowser.Controller.Entities
return CanDelete() && IsAuthorizedToDelete(user, allCollectionFolders);
}
public bool CanDelete(User user)
public virtual bool CanDelete(User user)
{
var allCollectionFolders = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();

@ -138,7 +138,7 @@ namespace MediaBrowser.Controller.Library
MediaProtocol GetPathProtocol(string path);
void SetDefaultAudioAndSubtitleStreamIndexes(BaseItem item, MediaSourceInfo source, User user);
void SetDefaultAudioAndSubtitleStreamIndices(BaseItem item, MediaSourceInfo source, User user);
Task AddMediaInfoWithProbe(MediaSourceInfo mediaSource, bool isAudio, string cacheKey, bool addProbeDelay, bool isLiveStream, CancellationToken cancellationToken);
}

@ -107,7 +107,6 @@ namespace MediaBrowser.Controller.MediaEncoding
{ "wmav2", 2 },
{ "libmp3lame", 2 },
{ "libfdk_aac", 6 },
{ "aac_at", 6 },
{ "ac3", 6 },
{ "eac3", 6 },
{ "dca", 6 },
@ -752,6 +751,15 @@ namespace MediaBrowser.Controller.MediaEncoding
return "dca";
}
if (string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase))
{
// The ffmpeg upstream breaks the AudioToolbox ALAC encoder in version 6.1 but fixes it in version 7.0.
// Since ALAC is lossless in quality and the AudioToolbox encoder is not faster,
// its only benefit is a smaller file size.
// To prevent problems, use the ffmpeg native encoder instead.
return "alac";
}
return codec.ToLowerInvariant();
}
@ -1333,7 +1341,7 @@ namespace MediaBrowser.Controller.MediaEncoding
return ".ts";
}
public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec)
private string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec)
{
if (state.OutputVideoBitrate is null)
{
@ -1402,7 +1410,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
// The `maxrate` and `bufsize` options can potentially lead to performance regression
// and even encoder hangs, especially when the value is very high.
return FormattableString.Invariant($" -b:v {bitrate}");
return FormattableString.Invariant($" -b:v {bitrate} -qmin -1 -qmax -1");
}
return FormattableString.Invariant($" -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}");
@ -5119,11 +5127,6 @@ namespace MediaBrowser.Controller.MediaEncoding
/* Make main filters for video stream */
var mainFilters = new List<string>();
// INPUT videotoolbox/memory surface(vram/uma)
// this will pass-through automatically if in/out format matches.
mainFilters.Add("format=nv12|p010le|videotoolbox_vld");
mainFilters.Add("hwupload=derive_device=videotoolbox");
// hw deint
if (doDeintH2645)
{
@ -5179,6 +5182,21 @@ namespace MediaBrowser.Controller.MediaEncoding
overlayFilters.Add("overlay_videotoolbox=eof_action=pass:repeatlast=0");
}
var needFiltering = mainFilters.Any(f => !string.IsNullOrEmpty(f)) ||
subFilters.Any(f => !string.IsNullOrEmpty(f)) ||
overlayFilters.Any(f => !string.IsNullOrEmpty(f));
// This is a workaround for ffmpeg's hwupload implementation
// For VideoToolbox encoders, a hwupload without a valid filter actually consuming its frame
// will cause the encoder to produce incorrect frames.
if (needFiltering)
{
// INPUT videotoolbox/memory surface(vram/uma)
// this will pass-through automatically if in/out format matches.
mainFilters.Insert(0, "format=nv12|p010le|videotoolbox_vld");
mainFilters.Insert(0, "hwupload=derive_device=videotoolbox");
}
return (mainFilters, subFilters, overlayFilters);
}

@ -33,7 +33,7 @@ namespace MediaBrowser.Controller.Net
SingleWriter = false
});
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly object _activeConnectionsLock = new();
/// <summary>
/// The _active connections.
@ -126,15 +126,10 @@ namespace MediaBrowser.Controller.Net
InitialDelayMs = dueTimeMs
};
_lock.Wait();
try
lock (_activeConnectionsLock)
{
_activeConnections.Add((message.Connection, cancellationTokenSource, state));
}
finally
{
_lock.Release();
}
}
protected void SendData(bool force)
@ -153,8 +148,7 @@ namespace MediaBrowser.Controller.Net
(IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State)[] tuples;
var now = DateTime.UtcNow;
await _lock.WaitAsync().ConfigureAwait(false);
try
lock (_activeConnectionsLock)
{
if (_activeConnections.Count == 0)
{
@ -174,10 +168,6 @@ namespace MediaBrowser.Controller.Net
})
.ToArray();
}
finally
{
_lock.Release();
}
if (tuples.Length == 0)
{
@ -240,8 +230,7 @@ namespace MediaBrowser.Controller.Net
/// <param name="message">The message.</param>
private void Stop(WebSocketMessageInfo message)
{
_lock.Wait();
try
lock (_activeConnectionsLock)
{
var connection = _activeConnections.FirstOrDefault(c => c.Connection == message.Connection);
@ -250,10 +239,6 @@ namespace MediaBrowser.Controller.Net
DisposeConnection(connection);
}
}
finally
{
_lock.Release();
}
}
/// <summary>
@ -283,15 +268,10 @@ namespace MediaBrowser.Controller.Net
Logger.LogError(ex, "Error disposing websocket");
}
_lock.Wait();
try
lock (_activeConnectionsLock)
{
_activeConnections.Remove(connection);
}
finally
{
_lock.Release();
}
}
protected virtual async ValueTask DisposeAsyncCore()
@ -306,18 +286,13 @@ namespace MediaBrowser.Controller.Net
Logger.LogError(ex, "Disposing the message consumer failed");
}
await _lock.WaitAsync().ConfigureAwait(false);
try
lock (_activeConnectionsLock)
{
foreach (var connection in _activeConnections.ToArray())
{
DisposeConnection(connection);
}
}
finally
{
_lock.Release();
}
}
/// <inheritdoc />

@ -4,12 +4,35 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Playlists;
namespace MediaBrowser.Controller.Playlists
{
public interface IPlaylistManager
{
/// <summary>
/// Gets the playlist.
/// </summary>
/// <param name="playlistId">The playlist identifier.</param>
/// <param name="userId">The user identifier.</param>
/// <returns>Playlist.</returns>
Playlist GetPlaylistForUser(Guid playlistId, Guid userId);
/// <summary>
/// Creates the playlist.
/// </summary>
/// <param name="request">The <see cref="PlaylistCreationRequest"/>.</param>
/// <returns>The created playlist.</returns>
Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest request);
/// <summary>
/// Updates a playlist.
/// </summary>
/// <param name="request">The <see cref="PlaylistUpdateRequest"/>.</param>
/// <returns>Task.</returns>
Task UpdatePlaylist(PlaylistUpdateRequest request);
/// <summary>
/// Gets the playlists.
/// </summary>
@ -18,11 +41,20 @@ namespace MediaBrowser.Controller.Playlists
IEnumerable<Playlist> GetPlaylists(Guid userId);
/// <summary>
/// Creates the playlist.
/// Adds a share to the playlist.
/// </summary>
/// <param name="request">The <see cref="PlaylistUserUpdateRequest"/>.</param>
/// <returns>Task.</returns>
Task AddUserToShares(PlaylistUserUpdateRequest request);
/// <summary>
/// Removes a share from the playlist.
/// </summary>
/// <param name="options">The options.</param>
/// <returns>Task&lt;Playlist&gt;.</returns>
Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest options);
/// <param name="playlistId">The playlist identifier.</param>
/// <param name="userId">The user identifier.</param>
/// <param name="share">The share.</param>
/// <returns>Task.</returns>
Task RemoveUserFromShares(Guid playlistId, Guid userId, PlaylistUserPermissions share);
/// <summary>
/// Adds to playlist.
@ -31,7 +63,7 @@ namespace MediaBrowser.Controller.Playlists
/// <param name="itemIds">The item ids.</param>
/// <param name="userId">The user identifier.</param>
/// <returns>Task.</returns>
Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
/// <summary>
/// Removes from playlist.
@ -39,7 +71,7 @@ namespace MediaBrowser.Controller.Playlists
/// <param name="playlistId">The playlist identifier.</param>
/// <param name="entryIds">The entry ids.</param>
/// <returns>Task.</returns>
Task RemoveFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds);
Task RemoveItemFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds);
/// <summary>
/// Gets the playlists folder.

@ -16,24 +16,23 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Playlists
{
public class Playlist : Folder, IHasShares
{
public static readonly IReadOnlyList<string> SupportedExtensions = new[]
{
public static readonly IReadOnlyList<string> SupportedExtensions =
[
".m3u",
".m3u8",
".pls",
".wpl",
".zpl"
};
];
public Playlist()
{
Shares = Array.Empty<Share>();
Shares = [];
OpenAccess = false;
}
@ -41,7 +40,7 @@ namespace MediaBrowser.Controller.Playlists
public bool OpenAccess { get; set; }
public Share[] Shares { get; set; }
public IReadOnlyList<PlaylistUserPermissions> Shares { get; set; }
[JsonIgnore]
public bool IsFile => IsPlaylistFile(Path);
@ -130,7 +129,7 @@ namespace MediaBrowser.Controller.Playlists
protected override List<BaseItem> LoadChildren()
{
// Save a trip to the database
return new List<BaseItem>();
return [];
}
protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
@ -145,7 +144,7 @@ namespace MediaBrowser.Controller.Playlists
protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService)
{
return new List<BaseItem>();
return [];
}
public override IEnumerable<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query)
@ -167,7 +166,7 @@ namespace MediaBrowser.Controller.Playlists
return base.GetChildren(user, true, query);
}
public static List<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> inputItems, User user, DtoOptions options)
public static IReadOnlyList<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> inputItems, User user, DtoOptions options)
{
if (user is not null)
{
@ -192,9 +191,9 @@ namespace MediaBrowser.Controller.Playlists
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
Recursive = true,
IncludeItemTypes = new[] { BaseItemKind.Audio },
GenreIds = new[] { musicGenre.Id },
OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) },
IncludeItemTypes = [BaseItemKind.Audio],
GenreIds = [musicGenre.Id],
OrderBy = [(ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending)],
DtoOptions = options
});
}
@ -204,9 +203,9 @@ namespace MediaBrowser.Controller.Playlists
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
Recursive = true,
IncludeItemTypes = new[] { BaseItemKind.Audio },
ArtistIds = new[] { musicArtist.Id },
OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) },
IncludeItemTypes = [BaseItemKind.Audio],
ArtistIds = [musicArtist.Id],
OrderBy = [(ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending)],
DtoOptions = options
});
}
@ -217,8 +216,8 @@ namespace MediaBrowser.Controller.Playlists
{
Recursive = true,
IsFolder = false,
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
MediaTypes = new[] { mediaType },
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)],
MediaTypes = [mediaType],
EnableTotalRecordCount = false,
DtoOptions = options
};
@ -226,7 +225,7 @@ namespace MediaBrowser.Controller.Playlists
return folder.GetItemList(query);
}
return new[] { item };
return [item];
}
public override bool IsVisible(User user)
@ -248,12 +247,17 @@ namespace MediaBrowser.Controller.Playlists
}
var shares = Shares;
if (shares.Length == 0)
if (shares.Count == 0)
{
return false;
}
return shares.Any(share => Guid.TryParse(share.UserId, out var id) && id.Equals(userId));
return shares.Any(s => s.UserId.Equals(userId));
}
public override bool CanDelete(User user)
{
return user.HasPermission(PermissionKind.IsAdministrator) || user.Id.Equals(OwnerUserId);
}
public override bool IsVisibleStandalone(User user)

@ -134,6 +134,7 @@ namespace MediaBrowser.Controller.Session
/// <value>The now playing item.</value>
public BaseItemDto NowPlayingItem { get; set; }
[JsonIgnore]
public BaseItem FullNowPlayingItem { get; set; }
public BaseItemDto NowViewingItem { get; set; }

@ -32,6 +32,7 @@ namespace MediaBrowser.LocalMetadata.Images
"folder",
"poster",
"cover",
"jacket",
"default"
};

@ -519,7 +519,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
private void FetchFromSharesNode(XmlReader reader, IHasShares item)
{
var list = new List<Share>();
var list = new List<PlaylistUserPermissions>();
reader.MoveToContent();
reader.Read();
@ -565,7 +565,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
}
}
item.Shares = list.ToArray();
item.Shares = [.. list];
}
/// <summary>
@ -830,12 +830,12 @@ namespace MediaBrowser.LocalMetadata.Parsers
/// </summary>
/// <param name="reader">The xml reader.</param>
/// <returns>The share.</returns>
protected Share? GetShare(XmlReader reader)
protected PlaylistUserPermissions? GetShare(XmlReader reader)
{
var item = new Share();
reader.MoveToContent();
reader.Read();
string? userId = null;
var canEdit = false;
// Loop through each element
while (!reader.EOF && reader.ReadState == ReadState.Interactive)
@ -845,10 +845,10 @@ namespace MediaBrowser.LocalMetadata.Parsers
switch (reader.Name)
{
case "UserId":
item.UserId = reader.ReadNormalizedString();
userId = reader.ReadNormalizedString();
break;
case "CanEdit":
item.CanEdit = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase);
canEdit = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase);
break;
default:
reader.Skip();
@ -862,9 +862,9 @@ namespace MediaBrowser.LocalMetadata.Parsers
}
// This is valid
if (!string.IsNullOrWhiteSpace(item.UserId))
if (!string.IsNullOrWhiteSpace(userId) && Guid.TryParse(userId, out var guid))
{
return item;
return new PlaylistUserPermissions(guid, canEdit);
}
return null;

@ -420,19 +420,16 @@ namespace MediaBrowser.LocalMetadata.Savers
foreach (var share in item.Shares)
{
if (share.UserId is not null)
{
await writer.WriteStartElementAsync(null, "Share", null).ConfigureAwait(false);
await writer.WriteStartElementAsync(null, "Share", null).ConfigureAwait(false);
await writer.WriteElementStringAsync(null, "UserId", null, share.UserId).ConfigureAwait(false);
await writer.WriteElementStringAsync(
null,
"CanEdit",
null,
share.CanEdit.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()).ConfigureAwait(false);
await writer.WriteElementStringAsync(null, "UserId", null, share.UserId.ToString()).ConfigureAwait(false);
await writer.WriteElementStringAsync(
null,
"CanEdit",
null,
share.CanEdit.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()).ConfigureAwait(false);
await writer.WriteEndElementAsync().ConfigureAwait(false);
}
await writer.WriteEndElementAsync().ConfigureAwait(false);
}
await writer.WriteEndElementAsync().ConfigureAwait(false);

@ -69,6 +69,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
"aac_at",
"libfdk_aac",
"ac3",
"alac",
"dca",
"libmp3lame",
"libopus",

@ -463,6 +463,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
extraArgs += " -user_agent " + userAgent;
}
if (request.MediaSource.Protocol == MediaProtocol.Rtsp)
{
extraArgs += " -rtsp_transport tcp+udp -rtsp_flags prefer_tcp";
}
return extraArgs;
}

@ -65,7 +65,7 @@ namespace MediaBrowser.Model.Dto
public DateTime? DateLastMediaAdded { get; set; }
public string ExtraType { get; set; }
public ExtraType? ExtraType { get; set; }
public int? AirsBeforeSeasonNumber { get; set; }

@ -1,16 +1,49 @@
#pragma warning disable CS1591
#pragma warning disable SA1300 // Lowercase required for backwards compat.
namespace MediaBrowser.Model.Entities
namespace MediaBrowser.Model.Entities;
/// <summary>
/// The collection type options.
/// </summary>
public enum CollectionTypeOptions
{
public enum CollectionTypeOptions
{
Movies = 0,
TvShows = 1,
Music = 2,
MusicVideos = 3,
HomeVideos = 4,
BoxSets = 5,
Books = 6,
Mixed = 7
}
/// <summary>
/// Movies.
/// </summary>
movies = 0,
/// <summary>
/// TV Shows.
/// </summary>
tvshows = 1,
/// <summary>
/// Music.
/// </summary>
music = 2,
/// <summary>
/// Music Videos.
/// </summary>
musicvideos = 3,
/// <summary>
/// Home Videos (and Photos).
/// </summary>
homevideos = 4,
/// <summary>
/// Box Sets.
/// </summary>
boxsets = 5,
/// <summary>
/// Books.
/// </summary>
books = 6,
/// <summary>
/// Mixed Movies and TV Shows.
/// </summary>
mixed = 7
}

@ -1,4 +1,6 @@
namespace MediaBrowser.Model.Entities;
using System.Collections.Generic;
namespace MediaBrowser.Model.Entities;
/// <summary>
/// Interface for access to shares.
@ -8,5 +10,5 @@ public interface IHasShares
/// <summary>
/// Gets or sets the shares.
/// </summary>
Share[] Shares { get; set; }
IReadOnlyList<PlaylistUserPermissions> Shares { get; set; }
}

@ -730,6 +730,8 @@ namespace MediaBrowser.Model.Entities
1 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10),
4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG),
2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR),
// While not in Dolby Spec, Profile 8 CCid 6 media are possible to create, and since CCid 6 stems from Bluray (Profile 7 originally) an HDR10 base layer is guaranteed to exist.
6 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10),
// There is no other case to handle here as per Dolby Spec. Default case included for completeness and linting purposes
_ => (VideoRange.SDR, VideoRangeType.SDR)
},

@ -0,0 +1,30 @@
using System;
namespace MediaBrowser.Model.Entities;
/// <summary>
/// Class to hold data on user permissions for playlists.
/// </summary>
public class PlaylistUserPermissions
{
/// <summary>
/// Initializes a new instance of the <see cref="PlaylistUserPermissions"/> class.
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="canEdit">Edit permission.</param>
public PlaylistUserPermissions(Guid userId, bool canEdit = false)
{
UserId = userId;
CanEdit = canEdit;
}
/// <summary>
/// Gets or sets the user id.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user has edit permissions.
/// </summary>
public bool CanEdit { get; set; }
}

@ -1,17 +0,0 @@
namespace MediaBrowser.Model.Entities;
/// <summary>
/// Class to hold data on sharing permissions.
/// </summary>
public class Share
{
/// <summary>
/// Gets or sets the user id.
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user has edit permissions.
/// </summary>
public bool CanEdit { get; set; }
}

@ -37,7 +37,6 @@ namespace MediaBrowser.Model.Entities
/// Gets or sets the type of the collection.
/// </summary>
/// <value>The type of the collection.</value>
[JsonConverter(typeof(JsonLowerCaseConverter<CollectionTypeOptions?>))]
public CollectionTypeOptions? CollectionType { get; set; }
public LibraryOptions LibraryOptions { get; set; }

@ -18,7 +18,7 @@ public class PlaylistCreationRequest
/// <summary>
/// Gets or sets the list of items.
/// </summary>
public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>();
public IReadOnlyList<Guid> ItemIdList { get; set; } = [];
/// <summary>
/// Gets or sets the media type.
@ -31,7 +31,12 @@ public class PlaylistCreationRequest
public Guid UserId { get; set; }
/// <summary>
/// Gets or sets the shares.
/// Gets or sets the user permissions.
/// </summary>
public Share[]? Shares { get; set; }
public IReadOnlyList<PlaylistUserPermissions> Users { get; set; } = [];
/// <summary>
/// Gets or sets a value indicating whether the playlist is public.
/// </summary>
public bool? Public { get; set; } = true;
}

@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Model.Playlists;
/// <summary>
/// A playlist update request.
/// </summary>
public class PlaylistUpdateRequest
{
/// <summary>
/// Gets or sets the id of the playlist.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the id of the user updating the playlist.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// Gets or sets the name of the playlist.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets item ids to add to the playlist.
/// </summary>
public IReadOnlyList<Guid>? Ids { get; set; }
/// <summary>
/// Gets or sets the playlist users.
/// </summary>
public IReadOnlyList<PlaylistUserPermissions>? Users { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the playlist is public.
/// </summary>
public bool? Public { get; set; }
}

@ -0,0 +1,24 @@
using System;
namespace MediaBrowser.Model.Playlists;
/// <summary>
/// A playlist user update request.
/// </summary>
public class PlaylistUserUpdateRequest
{
/// <summary>
/// Gets or sets the id of the playlist.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the id of the updated user.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user can edit the playlist.
/// </summary>
public bool? CanEdit { get; set; }
}

@ -1106,7 +1106,8 @@ namespace MediaBrowser.Providers.Manager
var musicArtists = albums
.Select(i => i.MusicArtist)
.Where(i => i is not null);
.Where(i => i is not null)
.Distinct();
var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new Progress<double>(), options, true, cancellationToken));

@ -554,9 +554,13 @@ public class SkiaEncoder : IImageEncoder
/// <inheritdoc />
public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{
var splashBuilder = new SplashscreenBuilder(this);
var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
splashBuilder.GenerateSplash(posters, backdrops, outputPath);
// Only generate the splash screen if we have at least one poster and at least one backdrop/thumbnail.
if (posters.Count > 0 && backdrops.Count > 0)
{
var splashBuilder = new SplashscreenBuilder(this);
var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
splashBuilder.GenerateSplash(posters, backdrops, outputPath);
}
}
/// <inheritdoc />

@ -1,25 +0,0 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Jellyfin.Extensions.Json.Converters
{
/// <summary>
/// Converts an object to a lowercase string.
/// </summary>
/// <typeparam name="T">The object type.</typeparam>
public class JsonLowerCaseConverter<T> : JsonConverter<T>
{
/// <inheritdoc />
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return JsonSerializer.Deserialize<T>(ref reader, options);
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
writer.WriteStringValue(value?.ToString()?.ToLowerInvariant());
}
}
}

@ -159,7 +159,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
{
Locations = [customPath],
Name = "Recorded Movies",
CollectionType = CollectionTypeOptions.Movies
CollectionType = CollectionTypeOptions.movies
};
}
@ -172,7 +172,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
{
Locations = [customPath],
Name = "Recorded Shows",
CollectionType = CollectionTypeOptions.TvShows
CollectionType = CollectionTypeOptions.tvshows
};
}
}

@ -78,28 +78,36 @@ public sealed class AutoDiscoveryHost : BackgroundService
private async Task ListenForAutoDiscoveryMessage(IPAddress address, CancellationToken cancellationToken)
{
using var udpClient = new UdpClient(new IPEndPoint(address, PortNumber));
udpClient.MulticastLoopback = false;
while (!cancellationToken.IsCancellationRequested)
try
{
try
using var udpClient = new UdpClient(new IPEndPoint(address, PortNumber));
udpClient.MulticastLoopback = false;
while (!cancellationToken.IsCancellationRequested)
{
var result = await udpClient.ReceiveAsync(cancellationToken).ConfigureAwait(false);
var text = Encoding.UTF8.GetString(result.Buffer);
if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase))
try
{
await RespondToV2Message(udpClient, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false);
var result = await udpClient.ReceiveAsync(cancellationToken).ConfigureAwait(false);
var text = Encoding.UTF8.GetString(result.Buffer);
if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase))
{
await RespondToV2Message(udpClient, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false);
}
}
catch (SocketException ex)
{
_logger.LogError(ex, "Failed to receive data from socket");
}
}
catch (SocketException ex)
{
_logger.LogError(ex, "Failed to receive data from socket");
}
catch (OperationCanceledException)
{
_logger.LogDebug("Broadcast socket operation cancelled");
}
}
catch (OperationCanceledException)
{
_logger.LogDebug("Broadcast socket operation cancelled");
}
catch (Exception ex)
{
// Exception in this function will prevent the background service from restarting in-process.
_logger.LogError(ex, "Unable to bind to {Address}:{Port}", address, PortNumber);
}
}

@ -1,71 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Jellyfin.Extensions.Json.Converters;
using MediaBrowser.Model.Entities;
using Xunit;
namespace Jellyfin.Extensions.Tests.Json.Converters
{
public class JsonLowerCaseConverterTests
{
private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions()
{
Converters =
{
new JsonStringEnumConverter()
}
};
[Theory]
[InlineData(null, "{\"CollectionType\":null}")]
[InlineData(CollectionTypeOptions.Movies, "{\"CollectionType\":\"movies\"}")]
[InlineData(CollectionTypeOptions.MusicVideos, "{\"CollectionType\":\"musicvideos\"}")]
public void Serialize_CollectionTypeOptions_Correct(CollectionTypeOptions? collectionType, string expected)
{
Assert.Equal(expected, JsonSerializer.Serialize(new TestContainer(collectionType), _jsonOptions));
}
[Theory]
[InlineData("{\"CollectionType\":null}", null)]
[InlineData("{\"CollectionType\":\"movies\"}", CollectionTypeOptions.Movies)]
[InlineData("{\"CollectionType\":\"musicvideos\"}", CollectionTypeOptions.MusicVideos)]
public void Deserialize_CollectionTypeOptions_Correct(string json, CollectionTypeOptions? result)
{
var res = JsonSerializer.Deserialize<TestContainer>(json, _jsonOptions);
Assert.NotNull(res);
Assert.Equal(result, res!.CollectionType);
}
[Theory]
[InlineData(null)]
[InlineData(CollectionTypeOptions.Movies)]
[InlineData(CollectionTypeOptions.MusicVideos)]
public void RoundTrip_CollectionTypeOptions_Correct(CollectionTypeOptions? value)
{
var res = JsonSerializer.Deserialize<TestContainer>(JsonSerializer.Serialize(new TestContainer(value), _jsonOptions), _jsonOptions);
Assert.NotNull(res);
Assert.Equal(value, res!.CollectionType);
}
[Theory]
[InlineData("{\"CollectionType\":null}")]
[InlineData("{\"CollectionType\":\"movies\"}")]
[InlineData("{\"CollectionType\":\"musicvideos\"}")]
public void RoundTrip_String_Correct(string json)
{
var res = JsonSerializer.Serialize(JsonSerializer.Deserialize<TestContainer>(json, _jsonOptions), _jsonOptions);
Assert.Equal(json, res);
}
private sealed class TestContainer
{
public TestContainer(CollectionTypeOptions? collectionType)
{
CollectionType = collectionType;
}
[JsonConverter(typeof(JsonLowerCaseConverter<CollectionTypeOptions?>))]
public CollectionTypeOptions? CollectionType { get; set; }
}
}
}
Loading…
Cancel
Save