| | | 1 | | using System; |
| | | 2 | | using System.Globalization; |
| | | 3 | | using System.Linq; |
| | | 4 | | using System.Net; |
| | | 5 | | using System.Security.Claims; |
| | | 6 | | using System.Text.Json; |
| | | 7 | | using System.Threading; |
| | | 8 | | using System.Threading.Tasks; |
| | | 9 | | using Jellyfin.Api.Extensions; |
| | | 10 | | using Jellyfin.Data; |
| | | 11 | | using Jellyfin.Data.Enums; |
| | | 12 | | using Jellyfin.Database.Implementations.Entities; |
| | | 13 | | using Jellyfin.Database.Implementations.Enums; |
| | | 14 | | using Jellyfin.Extensions; |
| | | 15 | | using MediaBrowser.Common.Extensions; |
| | | 16 | | using MediaBrowser.Common.Net; |
| | | 17 | | using MediaBrowser.Controller; |
| | | 18 | | using MediaBrowser.Controller.Configuration; |
| | | 19 | | using MediaBrowser.Controller.Devices; |
| | | 20 | | using MediaBrowser.Controller.Entities; |
| | | 21 | | using MediaBrowser.Controller.Entities.Audio; |
| | | 22 | | using MediaBrowser.Controller.Library; |
| | | 23 | | using MediaBrowser.Controller.MediaEncoding; |
| | | 24 | | using MediaBrowser.Model.Dlna; |
| | | 25 | | using MediaBrowser.Model.Dto; |
| | | 26 | | using MediaBrowser.Model.Entities; |
| | | 27 | | using MediaBrowser.Model.MediaInfo; |
| | | 28 | | using MediaBrowser.Model.Session; |
| | | 29 | | using Microsoft.AspNetCore.Http; |
| | | 30 | | using Microsoft.AspNetCore.Http.HttpResults; |
| | | 31 | | using Microsoft.Extensions.Logging; |
| | | 32 | | |
| | | 33 | | namespace Jellyfin.Api.Helpers; |
| | | 34 | | |
| | | 35 | | /// <summary> |
| | | 36 | | /// Media info helper. |
| | | 37 | | /// </summary> |
| | | 38 | | public class MediaInfoHelper |
| | | 39 | | { |
| | | 40 | | private readonly IUserManager _userManager; |
| | | 41 | | private readonly ILibraryManager _libraryManager; |
| | | 42 | | private readonly IMediaSourceManager _mediaSourceManager; |
| | | 43 | | private readonly IMediaEncoder _mediaEncoder; |
| | | 44 | | private readonly IServerConfigurationManager _serverConfigurationManager; |
| | | 45 | | private readonly ILogger<MediaInfoHelper> _logger; |
| | | 46 | | private readonly INetworkManager _networkManager; |
| | | 47 | | private readonly IDeviceManager _deviceManager; |
| | | 48 | | private readonly IServerApplicationHost _appHost; |
| | | 49 | | |
| | | 50 | | /// <summary> |
| | | 51 | | /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class. |
| | | 52 | | /// </summary> |
| | | 53 | | /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> |
| | | 54 | | /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> |
| | | 55 | | /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> |
| | | 56 | | /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> |
| | | 57 | | /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</p |
| | | 58 | | /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param> |
| | | 59 | | /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> |
| | | 60 | | /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> |
| | | 61 | | /// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param> |
| | | 62 | | public MediaInfoHelper( |
| | | 63 | | IUserManager userManager, |
| | | 64 | | ILibraryManager libraryManager, |
| | | 65 | | IMediaSourceManager mediaSourceManager, |
| | | 66 | | IMediaEncoder mediaEncoder, |
| | | 67 | | IServerConfigurationManager serverConfigurationManager, |
| | | 68 | | ILogger<MediaInfoHelper> logger, |
| | | 69 | | INetworkManager networkManager, |
| | | 70 | | IDeviceManager deviceManager, |
| | | 71 | | IServerApplicationHost appHost) |
| | | 72 | | { |
| | 21 | 73 | | _userManager = userManager; |
| | 21 | 74 | | _libraryManager = libraryManager; |
| | 21 | 75 | | _mediaSourceManager = mediaSourceManager; |
| | 21 | 76 | | _mediaEncoder = mediaEncoder; |
| | 21 | 77 | | _serverConfigurationManager = serverConfigurationManager; |
| | 21 | 78 | | _logger = logger; |
| | 21 | 79 | | _networkManager = networkManager; |
| | 21 | 80 | | _deviceManager = deviceManager; |
| | 21 | 81 | | _appHost = appHost; |
| | 21 | 82 | | } |
| | | 83 | | |
| | | 84 | | /// <summary> |
| | | 85 | | /// Get playback info. |
| | | 86 | | /// </summary> |
| | | 87 | | /// <param name="item">The item.</param> |
| | | 88 | | /// <param name="user">The user.</param> |
| | | 89 | | /// <param name="request">The current <see cref="HttpRequest"/>.</param> |
| | | 90 | | /// <param name="mediaSourceId">Media source id.</param> |
| | | 91 | | /// <param name="liveStreamId">Live stream id.</param> |
| | | 92 | | /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> |
| | | 93 | | public async Task<PlaybackInfoResponse> GetPlaybackInfo( |
| | | 94 | | BaseItem item, |
| | | 95 | | User? user, |
| | | 96 | | HttpRequest request, |
| | | 97 | | string? mediaSourceId = null, |
| | | 98 | | string? liveStreamId = null) |
| | | 99 | | { |
| | 4 | 100 | | var result = new PlaybackInfoResponse(); |
| | | 101 | | |
| | 4 | 102 | | var mediaSources = await ResolvePlaybackMediaSources(item, user, mediaSourceId, liveStreamId).ConfigureAwait(fal |
| | | 103 | | |
| | 4 | 104 | | if (mediaSources.Length == 0) |
| | | 105 | | { |
| | 0 | 106 | | result.MediaSources = Array.Empty<MediaSourceInfo>(); |
| | | 107 | | |
| | 0 | 108 | | result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream; |
| | | 109 | | } |
| | | 110 | | else |
| | | 111 | | { |
| | | 112 | | // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we s |
| | | 113 | | // Should we move this directly into MediaSourceManager? |
| | 4 | 114 | | var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(me |
| | 4 | 115 | | if (mediaSourcesClone is not null) |
| | | 116 | | { |
| | | 117 | | // Carry over the default audio index source. |
| | | 118 | | // This field is not intended to be exposed to API clients, but it is used internally by the server |
| | 16 | 119 | | for (int i = 0; i < mediaSourcesClone.Length && i < mediaSources.Length; i++) |
| | | 120 | | { |
| | 4 | 121 | | mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource; |
| | | 122 | | } |
| | | 123 | | |
| | 16 | 124 | | foreach (var mediaSource in mediaSourcesClone) |
| | | 125 | | { |
| | 4 | 126 | | RewritePublishedLiveStreamPath(mediaSource, request); |
| | | 127 | | } |
| | | 128 | | |
| | 4 | 129 | | result.MediaSources = mediaSourcesClone; |
| | | 130 | | } |
| | | 131 | | |
| | 4 | 132 | | result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); |
| | | 133 | | } |
| | | 134 | | |
| | 4 | 135 | | return result; |
| | 4 | 136 | | } |
| | | 137 | | |
| | | 138 | | private async Task<MediaSourceInfo[]> ResolvePlaybackMediaSources(BaseItem item, User? user, string? mediaSourceId, |
| | | 139 | | { |
| | 4 | 140 | | if (!string.IsNullOrWhiteSpace(liveStreamId)) |
| | | 141 | | { |
| | 3 | 142 | | var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwa |
| | | 143 | | |
| | 3 | 144 | | return new[] { mediaSource }; |
| | | 145 | | } |
| | | 146 | | |
| | | 147 | | // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? |
| | 1 | 148 | | var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationTok |
| | | 149 | | |
| | 1 | 150 | | if (string.IsNullOrWhiteSpace(mediaSourceId)) |
| | | 151 | | { |
| | 1 | 152 | | return mediaSourcesList.ToArray(); |
| | | 153 | | } |
| | | 154 | | |
| | 0 | 155 | | return mediaSourcesList |
| | 0 | 156 | | .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) |
| | 0 | 157 | | .ToArray(); |
| | 4 | 158 | | } |
| | | 159 | | |
| | | 160 | | /// <summary> |
| | | 161 | | /// SetDeviceSpecificData. |
| | | 162 | | /// </summary> |
| | | 163 | | /// <param name="item">Item to set data for.</param> |
| | | 164 | | /// <param name="mediaSource">Media source info.</param> |
| | | 165 | | /// <param name="profile">Device profile.</param> |
| | | 166 | | /// <param name="claimsPrincipal">Current claims principal.</param> |
| | | 167 | | /// <param name="maxBitrate">Max bitrate.</param> |
| | | 168 | | /// <param name="startTimeTicks">Start time ticks.</param> |
| | | 169 | | /// <param name="mediaSourceId">Media source id.</param> |
| | | 170 | | /// <param name="audioStreamIndex">Audio stream index.</param> |
| | | 171 | | /// <param name="subtitleStreamIndex">Subtitle stream index.</param> |
| | | 172 | | /// <param name="maxAudioChannels">Max audio channels.</param> |
| | | 173 | | /// <param name="playSessionId">Play session id.</param> |
| | | 174 | | /// <param name="userId">User id.</param> |
| | | 175 | | /// <param name="enableDirectPlay">Enable direct play.</param> |
| | | 176 | | /// <param name="enableDirectStream">Enable direct stream.</param> |
| | | 177 | | /// <param name="enableTranscoding">Enable transcoding.</param> |
| | | 178 | | /// <param name="allowVideoStreamCopy">Allow video stream copy.</param> |
| | | 179 | | /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param> |
| | | 180 | | /// <param name="alwaysBurnInSubtitleWhenTranscoding">Always burn-in subtitle when transcoding.</param> |
| | | 181 | | /// <param name="ipAddress">Requesting IP address.</param> |
| | | 182 | | public void SetDeviceSpecificData( |
| | | 183 | | BaseItem item, |
| | | 184 | | MediaSourceInfo mediaSource, |
| | | 185 | | DeviceProfile profile, |
| | | 186 | | ClaimsPrincipal claimsPrincipal, |
| | | 187 | | int? maxBitrate, |
| | | 188 | | long startTimeTicks, |
| | | 189 | | string mediaSourceId, |
| | | 190 | | int? audioStreamIndex, |
| | | 191 | | int? subtitleStreamIndex, |
| | | 192 | | int? maxAudioChannels, |
| | | 193 | | string playSessionId, |
| | | 194 | | Guid userId, |
| | | 195 | | bool enableDirectPlay, |
| | | 196 | | bool enableDirectStream, |
| | | 197 | | bool enableTranscoding, |
| | | 198 | | bool allowVideoStreamCopy, |
| | | 199 | | bool allowAudioStreamCopy, |
| | | 200 | | bool alwaysBurnInSubtitleWhenTranscoding, |
| | | 201 | | IPAddress ipAddress) |
| | | 202 | | { |
| | 0 | 203 | | var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); |
| | | 204 | | |
| | 0 | 205 | | var options = new MediaOptions |
| | 0 | 206 | | { |
| | 0 | 207 | | MediaSources = new[] { mediaSource }, |
| | 0 | 208 | | Context = EncodingContext.Streaming, |
| | 0 | 209 | | DeviceId = claimsPrincipal.GetDeviceId(), |
| | 0 | 210 | | ItemId = item.Id, |
| | 0 | 211 | | Profile = profile, |
| | 0 | 212 | | MaxAudioChannels = maxAudioChannels, |
| | 0 | 213 | | AllowAudioStreamCopy = allowAudioStreamCopy, |
| | 0 | 214 | | AllowVideoStreamCopy = allowVideoStreamCopy, |
| | 0 | 215 | | AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding, |
| | 0 | 216 | | }; |
| | | 217 | | |
| | 0 | 218 | | if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) |
| | | 219 | | { |
| | 0 | 220 | | options.MediaSourceId = mediaSourceId; |
| | 0 | 221 | | options.AudioStreamIndex = audioStreamIndex; |
| | 0 | 222 | | options.SubtitleStreamIndex = subtitleStreamIndex; |
| | | 223 | | } |
| | | 224 | | |
| | 0 | 225 | | var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException(); |
| | | 226 | | |
| | 0 | 227 | | if (!enableDirectPlay) |
| | | 228 | | { |
| | 0 | 229 | | mediaSource.SupportsDirectPlay = false; |
| | | 230 | | } |
| | | 231 | | |
| | 0 | 232 | | if (!enableDirectStream || !allowVideoStreamCopy) |
| | | 233 | | { |
| | 0 | 234 | | mediaSource.SupportsDirectStream = false; |
| | | 235 | | } |
| | | 236 | | |
| | 0 | 237 | | if (!enableTranscoding) |
| | | 238 | | { |
| | 0 | 239 | | mediaSource.SupportsTranscoding = false; |
| | | 240 | | } |
| | | 241 | | |
| | 0 | 242 | | if (item is Audio) |
| | | 243 | | { |
| | 0 | 244 | | _logger.LogInformation( |
| | 0 | 245 | | "User policy for {0}. EnableAudioPlaybackTranscoding: {1}", |
| | 0 | 246 | | user.Username, |
| | 0 | 247 | | user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); |
| | | 248 | | } |
| | | 249 | | else |
| | | 250 | | { |
| | 0 | 251 | | _logger.LogInformation( |
| | 0 | 252 | | "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybac |
| | 0 | 253 | | user.Username, |
| | 0 | 254 | | user.HasPermission(PermissionKind.EnablePlaybackRemuxing), |
| | 0 | 255 | | user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), |
| | 0 | 256 | | user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); |
| | | 257 | | } |
| | | 258 | | |
| | 0 | 259 | | options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress); |
| | | 260 | | |
| | 0 | 261 | | if (!options.ForceDirectStream) |
| | | 262 | | { |
| | | 263 | | // direct-stream http streaming is currently broken |
| | 0 | 264 | | options.EnableDirectStream = false; |
| | | 265 | | } |
| | | 266 | | |
| | | 267 | | // Beginning of Playback Determination |
| | 0 | 268 | | var streamInfo = item.MediaType == MediaType.Audio |
| | 0 | 269 | | ? streamBuilder.GetOptimalAudioStream(options) |
| | 0 | 270 | | : streamBuilder.GetOptimalVideoStream(options); |
| | | 271 | | |
| | 0 | 272 | | if (streamInfo is not null) |
| | | 273 | | { |
| | 0 | 274 | | streamInfo.PlaySessionId = playSessionId; |
| | 0 | 275 | | streamInfo.StartPositionTicks = startTimeTicks; |
| | | 276 | | |
| | 0 | 277 | | mediaSource.SupportsDirectPlay = streamInfo.PlayMethod == PlayMethod.DirectPlay; |
| | | 278 | | |
| | | 279 | | // Players do not handle this being set according to PlayMethod |
| | 0 | 280 | | mediaSource.SupportsDirectStream = |
| | 0 | 281 | | options.EnableDirectStream |
| | 0 | 282 | | ? streamInfo.PlayMethod == PlayMethod.DirectPlay || streamInfo.PlayMethod == PlayMethod.DirectStream |
| | 0 | 283 | | : streamInfo.PlayMethod == PlayMethod.DirectPlay; |
| | | 284 | | |
| | 0 | 285 | | mediaSource.SupportsTranscoding = |
| | 0 | 286 | | streamInfo.PlayMethod == PlayMethod.DirectStream |
| | 0 | 287 | | || mediaSource.TranscodingContainer is not null |
| | 0 | 288 | | || profile.TranscodingProfiles.Any(i => i.Type == streamInfo.MediaType && i.Context == options.Context); |
| | | 289 | | |
| | 0 | 290 | | if (item is Audio) |
| | | 291 | | { |
| | 0 | 292 | | if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) |
| | | 293 | | { |
| | 0 | 294 | | mediaSource.SupportsTranscoding = false; |
| | | 295 | | } |
| | | 296 | | } |
| | 0 | 297 | | else if (item is Video) |
| | | 298 | | { |
| | 0 | 299 | | if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) |
| | 0 | 300 | | && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) |
| | 0 | 301 | | && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) |
| | | 302 | | { |
| | 0 | 303 | | mediaSource.SupportsTranscoding = false; |
| | | 304 | | } |
| | | 305 | | } |
| | | 306 | | |
| | 0 | 307 | | if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) |
| | | 308 | | { |
| | 0 | 309 | | mediaSource.SupportsDirectPlay = false; |
| | 0 | 310 | | mediaSource.SupportsDirectStream = false; |
| | | 311 | | |
| | 0 | 312 | | mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), "&allowVideoStreamCopy=f |
| | 0 | 313 | | mediaSource.TranscodingContainer = streamInfo.Container; |
| | 0 | 314 | | mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; |
| | 0 | 315 | | if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding) |
| | | 316 | | { |
| | 0 | 317 | | mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true"; |
| | | 318 | | } |
| | | 319 | | } |
| | | 320 | | else |
| | | 321 | | { |
| | 0 | 322 | | if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStr |
| | | 323 | | { |
| | 0 | 324 | | streamInfo.PlayMethod = PlayMethod.Transcode; |
| | 0 | 325 | | mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), null); |
| | | 326 | | |
| | 0 | 327 | | if (!allowVideoStreamCopy) |
| | | 328 | | { |
| | 0 | 329 | | mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; |
| | | 330 | | } |
| | | 331 | | |
| | 0 | 332 | | if (!allowAudioStreamCopy) |
| | | 333 | | { |
| | 0 | 334 | | mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; |
| | | 335 | | } |
| | | 336 | | |
| | 0 | 337 | | if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding) |
| | | 338 | | { |
| | 0 | 339 | | mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true"; |
| | | 340 | | } |
| | | 341 | | } |
| | | 342 | | } |
| | | 343 | | |
| | | 344 | | // Do this after the above so that StartPositionTicks is set |
| | | 345 | | // The token must not be null |
| | 0 | 346 | | SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, claimsPrincipal.GetToken()!); |
| | 0 | 347 | | mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; |
| | | 348 | | } |
| | | 349 | | |
| | 0 | 350 | | foreach (var attachment in mediaSource.MediaAttachments) |
| | | 351 | | { |
| | 0 | 352 | | attachment.DeliveryUrl = string.Format( |
| | 0 | 353 | | CultureInfo.InvariantCulture, |
| | 0 | 354 | | "/Videos/{0}/{1}/Attachments/{2}", |
| | 0 | 355 | | item.Id, |
| | 0 | 356 | | mediaSource.Id, |
| | 0 | 357 | | attachment.Index); |
| | | 358 | | } |
| | 0 | 359 | | } |
| | | 360 | | |
| | | 361 | | /// <summary> |
| | | 362 | | /// Sort media source. |
| | | 363 | | /// </summary> |
| | | 364 | | /// <param name="result">Playback info response.</param> |
| | | 365 | | /// <param name="maxBitrate">Max bitrate.</param> |
| | | 366 | | /// <param name="preferredItemId">The id of the queried item, whose own media source must stay the default.</param> |
| | | 367 | | public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default) |
| | | 368 | | { |
| | 3 | 369 | | var originalList = result.MediaSources.ToList(); |
| | | 370 | | |
| | | 371 | | // The queried item's source carries the user's resume state for that version, so it must stay the |
| | | 372 | | // default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version. |
| | 3 | 373 | | var preferredId = preferredItemId.IsEmpty() |
| | 3 | 374 | | ? null |
| | 3 | 375 | | : preferredItemId.ToString("N", CultureInfo.InvariantCulture); |
| | | 376 | | |
| | 3 | 377 | | result.MediaSources = result.MediaSources |
| | 3 | 378 | | .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.Ordinal |
| | 3 | 379 | | .ThenBy(i => |
| | 3 | 380 | | { |
| | 3 | 381 | | // Nothing beats direct playing a file |
| | 3 | 382 | | if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) |
| | 3 | 383 | | { |
| | 3 | 384 | | return 0; |
| | 3 | 385 | | } |
| | 3 | 386 | | |
| | 3 | 387 | | return 1; |
| | 3 | 388 | | }) |
| | 3 | 389 | | .ThenBy(i => |
| | 3 | 390 | | { |
| | 3 | 391 | | // Let's assume direct streaming a file is just as desirable as direct playing a remote url |
| | 3 | 392 | | if (i.SupportsDirectPlay || i.SupportsDirectStream) |
| | 3 | 393 | | { |
| | 3 | 394 | | return 0; |
| | 3 | 395 | | } |
| | 3 | 396 | | |
| | 3 | 397 | | return 1; |
| | 3 | 398 | | }) |
| | 3 | 399 | | .ThenBy(i => |
| | 3 | 400 | | { |
| | 3 | 401 | | return i.Protocol switch |
| | 3 | 402 | | { |
| | 3 | 403 | | MediaProtocol.File => 0, |
| | 3 | 404 | | _ => 1, |
| | 3 | 405 | | }; |
| | 3 | 406 | | }) |
| | 3 | 407 | | .ThenBy(i => |
| | 3 | 408 | | { |
| | 3 | 409 | | if (maxBitrate.HasValue && i.Bitrate.HasValue) |
| | 3 | 410 | | { |
| | 3 | 411 | | return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; |
| | 3 | 412 | | } |
| | 3 | 413 | | |
| | 3 | 414 | | return 1; |
| | 3 | 415 | | }) |
| | 3 | 416 | | .ThenBy(originalList.IndexOf) |
| | 3 | 417 | | .ToArray(); |
| | 3 | 418 | | } |
| | | 419 | | |
| | | 420 | | /// <summary> |
| | | 421 | | /// Open media source. |
| | | 422 | | /// </summary> |
| | | 423 | | /// <param name="httpContext">Http Context.</param> |
| | | 424 | | /// <param name="request">Live stream request.</param> |
| | | 425 | | /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns> |
| | | 426 | | public async Task<LiveStreamResponse> OpenMediaSource(HttpContext httpContext, LiveStreamRequest request) |
| | | 427 | | { |
| | 11 | 428 | | var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); |
| | | 429 | | |
| | 11 | 430 | | RewritePublishedLiveStreamPath(result.MediaSource, httpContext.Request); |
| | | 431 | | |
| | 11 | 432 | | var profile = request.DeviceProfile; |
| | 11 | 433 | | if (profile is null) |
| | | 434 | | { |
| | 11 | 435 | | var clientCapabilities = _deviceManager.GetCapabilities(httpContext.User.GetDeviceId()); |
| | 11 | 436 | | if (clientCapabilities is not null) |
| | | 437 | | { |
| | 0 | 438 | | profile = clientCapabilities.DeviceProfile; |
| | | 439 | | } |
| | | 440 | | } |
| | | 441 | | |
| | 11 | 442 | | if (profile is not null) |
| | | 443 | | { |
| | 0 | 444 | | var item = _libraryManager.GetItemById<BaseItem>(request.ItemId) |
| | 0 | 445 | | ?? throw new ResourceNotFoundException(); |
| | | 446 | | |
| | 0 | 447 | | SetDeviceSpecificData( |
| | 0 | 448 | | item, |
| | 0 | 449 | | result.MediaSource, |
| | 0 | 450 | | profile, |
| | 0 | 451 | | httpContext.User, |
| | 0 | 452 | | request.MaxStreamingBitrate, |
| | 0 | 453 | | request.StartTimeTicks ?? 0, |
| | 0 | 454 | | result.MediaSource.Id, |
| | 0 | 455 | | request.AudioStreamIndex, |
| | 0 | 456 | | request.SubtitleStreamIndex, |
| | 0 | 457 | | request.MaxAudioChannels, |
| | 0 | 458 | | request.PlaySessionId, |
| | 0 | 459 | | request.UserId, |
| | 0 | 460 | | request.EnableDirectPlay, |
| | 0 | 461 | | request.EnableDirectStream, |
| | 0 | 462 | | true, |
| | 0 | 463 | | true, |
| | 0 | 464 | | true, |
| | 0 | 465 | | request.AlwaysBurnInSubtitleWhenTranscoding, |
| | 0 | 466 | | httpContext.GetNormalizedRemoteIP()); |
| | | 467 | | } |
| | | 468 | | else |
| | | 469 | | { |
| | 11 | 470 | | if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) |
| | | 471 | | { |
| | 0 | 472 | | result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; |
| | | 473 | | } |
| | | 474 | | } |
| | | 475 | | |
| | | 476 | | // here was a check if (result.MediaSource is not null) but Rider said it will never be null |
| | 11 | 477 | | NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video); |
| | | 478 | | |
| | 11 | 479 | | return result; |
| | 11 | 480 | | } |
| | | 481 | | |
| | | 482 | | /// <summary> |
| | | 483 | | /// Normalize media source container. |
| | | 484 | | /// </summary> |
| | | 485 | | /// <param name="mediaSource">Media source.</param> |
| | | 486 | | /// <param name="profile">Device profile.</param> |
| | | 487 | | /// <param name="type">Dlna profile type.</param> |
| | | 488 | | public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) |
| | | 489 | | { |
| | 11 | 490 | | mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profi |
| | 11 | 491 | | } |
| | | 492 | | |
| | | 493 | | private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) |
| | | 494 | | { |
| | 0 | 495 | | var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken); |
| | 0 | 496 | | mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex; |
| | | 497 | | |
| | 0 | 498 | | mediaSource.TranscodeReasons = info.TranscodeReasons; |
| | | 499 | | |
| | 0 | 500 | | foreach (var profile in profiles) |
| | | 501 | | { |
| | 0 | 502 | | foreach (var stream in mediaSource.MediaStreams) |
| | | 503 | | { |
| | 0 | 504 | | if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index) |
| | | 505 | | { |
| | 0 | 506 | | stream.DeliveryMethod = profile.DeliveryMethod; |
| | | 507 | | |
| | 0 | 508 | | if (profile.DeliveryMethod == SubtitleDeliveryMethod.External) |
| | | 509 | | { |
| | 0 | 510 | | stream.DeliveryUrl = profile.Url.TrimStart('-'); |
| | 0 | 511 | | stream.IsExternalUrl = profile.IsExternalUrl; |
| | | 512 | | } |
| | | 513 | | } |
| | | 514 | | } |
| | | 515 | | } |
| | 0 | 516 | | } |
| | | 517 | | |
| | | 518 | | private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress) |
| | | 519 | | { |
| | 0 | 520 | | var maxBitrate = clientMaxBitrate; |
| | 0 | 521 | | var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; |
| | | 522 | | |
| | 0 | 523 | | if (remoteClientMaxBitrate <= 0) |
| | | 524 | | { |
| | 0 | 525 | | remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit; |
| | | 526 | | } |
| | | 527 | | |
| | 0 | 528 | | if (remoteClientMaxBitrate > 0) |
| | | 529 | | { |
| | 0 | 530 | | var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress); |
| | | 531 | | |
| | 0 | 532 | | _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIP: {1}, IsInLocalNetwork: {2}", remoteClientMa |
| | 0 | 533 | | if (!isInLocalNetwork) |
| | | 534 | | { |
| | 0 | 535 | | maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); |
| | | 536 | | } |
| | | 537 | | } |
| | | 538 | | |
| | 0 | 539 | | return maxBitrate; |
| | | 540 | | } |
| | | 541 | | |
| | | 542 | | /// <summary> |
| | | 543 | | /// Rewrites a Live TV media source's <see cref="MediaSourceInfo.Path"/> to the request-appropriate published |
| | | 544 | | /// URL when it points at a Jellyfin-hosted live stream buffer, so response copies never leak server-local |
| | | 545 | | /// addresses. Only opened live streams are eligible. The shared instance held by |
| | | 546 | | /// <see cref="IMediaSourceManager"/> is never touched by this method. |
| | | 547 | | /// </summary> |
| | | 548 | | /// <param name="mediaSource">The media source clone to rewrite in place.</param> |
| | | 549 | | /// <param name="request">The current <see cref="HttpRequest"/>.</param> |
| | | 550 | | private void RewritePublishedLiveStreamPath(MediaSourceInfo mediaSource, HttpRequest request) |
| | | 551 | | { |
| | | 552 | | // Opened live streams always carry a LiveStreamId; this excludes pre-open and plugin/remote sources. |
| | 15 | 553 | | if (string.IsNullOrEmpty(mediaSource.LiveStreamId)) |
| | | 554 | | { |
| | 7 | 555 | | return; |
| | | 556 | | } |
| | | 557 | | |
| | 8 | 558 | | var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl; |
| | 8 | 559 | | var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.P |
| | | 560 | | |
| | 8 | 561 | | if (publishedPath is not null) |
| | | 562 | | { |
| | 8 | 563 | | mediaSource.Path = publishedPath; |
| | 8 | 564 | | return; |
| | | 565 | | } |
| | | 566 | | |
| | 0 | 567 | | if (mediaSource.Path is not null && mediaSource.Path.Contains("/LiveTv/LiveStreamFiles/", StringComparison.Ordin |
| | | 568 | | { |
| | 0 | 569 | | _logger.LogDebug("Not rewriting live stream path for media source {MediaSourceId}: the local path did not re |
| | | 570 | | } |
| | 0 | 571 | | } |
| | | 572 | | |
| | | 573 | | /// <summary> |
| | | 574 | | /// Resolves a Jellyfin-hosted Live TV buffer path to its request-appropriate published equivalent. |
| | | 575 | | /// Returns null when the path isn't a Jellyfin-hosted <c>/LiveTv/LiveStreamFiles/</c> HTTP URL. |
| | | 576 | | /// </summary> |
| | | 577 | | /// <param name="smartApiUrl">The request-appropriate base URL, as returned by <see cref="IServerApplicationHost.Get |
| | | 578 | | /// <param name="localPath">The media source's local (LAN-access) path, as built from <see cref="IServerApplicationH |
| | | 579 | | /// <param name="protocol">The media source's protocol.</param> |
| | | 580 | | /// <param name="baseUrl">The server's configured BaseUrl, if any.</param> |
| | | 581 | | /// <returns>The published path, or null if the local path should be left unchanged.</returns> |
| | | 582 | | internal static string? GetPublishedLiveStreamPath( |
| | | 583 | | string smartApiUrl, |
| | | 584 | | string? localPath, |
| | | 585 | | MediaProtocol protocol, |
| | | 586 | | string baseUrl) |
| | | 587 | | { |
| | 21 | 588 | | if (protocol != MediaProtocol.Http |
| | 21 | 589 | | || !Uri.TryCreate(localPath, UriKind.Absolute, out var localUri)) |
| | | 590 | | { |
| | 2 | 591 | | return null; |
| | | 592 | | } |
| | | 593 | | |
| | 19 | 594 | | var relativePath = localUri.PathAndQuery; |
| | 19 | 595 | | if (!string.IsNullOrEmpty(baseUrl)) |
| | | 596 | | { |
| | 7 | 597 | | var basePrefix = baseUrl + "/"; |
| | 7 | 598 | | if (!relativePath.StartsWith(basePrefix, StringComparison.OrdinalIgnoreCase)) |
| | | 599 | | { |
| | 1 | 600 | | return null; |
| | | 601 | | } |
| | | 602 | | |
| | 6 | 603 | | relativePath = relativePath[baseUrl.Length..]; |
| | | 604 | | } |
| | | 605 | | |
| | 18 | 606 | | if (!relativePath.StartsWith("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) |
| | | 607 | | { |
| | 0 | 608 | | return null; |
| | | 609 | | } |
| | | 610 | | |
| | 18 | 611 | | var prefix = smartApiUrl.TrimEnd('/'); |
| | 18 | 612 | | if (!string.IsNullOrEmpty(baseUrl)) |
| | | 613 | | { |
| | 6 | 614 | | var includesBaseUrl = Uri.TryCreate(prefix, UriKind.Absolute, out var publishedUri) |
| | 6 | 615 | | && Uri.UnescapeDataString(publishedUri.AbsolutePath) |
| | 6 | 616 | | .TrimEnd('/') |
| | 6 | 617 | | .EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase); |
| | | 618 | | |
| | 6 | 619 | | if (!includesBaseUrl) |
| | | 620 | | { |
| | 2 | 621 | | prefix += baseUrl; |
| | | 622 | | } |
| | | 623 | | } |
| | | 624 | | |
| | 18 | 625 | | return prefix + relativePath; |
| | | 626 | | } |
| | | 627 | | } |