| | 1 | | using System; |
| | 2 | | using System.Globalization; |
| | 3 | | using System.IO; |
| | 4 | | using System.Threading; |
| | 5 | | using System.Threading.Tasks; |
| | 6 | | using Jellyfin.Api.Models.StreamingDtos; |
| | 7 | | using MediaBrowser.Controller.MediaEncoding; |
| | 8 | | using MediaBrowser.Controller.Streaming; |
| | 9 | | using MediaBrowser.Model.IO; |
| | 10 | | using Microsoft.Extensions.Logging; |
| | 11 | |
|
| | 12 | | namespace Jellyfin.Api.Helpers; |
| | 13 | |
|
| | 14 | | /// <summary> |
| | 15 | | /// The hls helpers. |
| | 16 | | /// </summary> |
| | 17 | | public static class HlsHelpers |
| | 18 | | { |
| | 19 | | /// <summary> |
| | 20 | | /// Waits for a minimum number of segments to be available. |
| | 21 | | /// </summary> |
| | 22 | | /// <param name="playlist">The playlist string.</param> |
| | 23 | | /// <param name="segmentCount">The segment count.</param> |
| | 24 | | /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> |
| | 25 | | /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> |
| | 26 | | /// <returns>A <see cref="Task"/> indicating the waiting process.</returns> |
| | 27 | | public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, Cancellation |
| | 28 | | { |
| | 29 | | logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist); |
| | 30 | |
|
| | 31 | | while (!cancellationToken.IsCancellationRequested) |
| | 32 | | { |
| | 33 | | try |
| | 34 | | { |
| | 35 | | // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written |
| | 36 | | var fileStream = new FileStream( |
| | 37 | | playlist, |
| | 38 | | FileMode.Open, |
| | 39 | | FileAccess.Read, |
| | 40 | | FileShare.ReadWrite, |
| | 41 | | IODefaults.FileStreamBufferSize, |
| | 42 | | FileOptions.Asynchronous | FileOptions.SequentialScan); |
| | 43 | | await using (fileStream.ConfigureAwait(false)) |
| | 44 | | { |
| | 45 | | using var reader = new StreamReader(fileStream); |
| | 46 | | var count = 0; |
| | 47 | |
|
| | 48 | | while (!reader.EndOfStream) |
| | 49 | | { |
| | 50 | | var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); |
| | 51 | | if (line is null) |
| | 52 | | { |
| | 53 | | // Nothing currently in buffer. |
| | 54 | | break; |
| | 55 | | } |
| | 56 | |
|
| | 57 | | if (line.Contains("#EXTINF:", StringComparison.OrdinalIgnoreCase)) |
| | 58 | | { |
| | 59 | | count++; |
| | 60 | | if (count >= segmentCount) |
| | 61 | | { |
| | 62 | | logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist); |
| | 63 | | return; |
| | 64 | | } |
| | 65 | | } |
| | 66 | | } |
| | 67 | | } |
| | 68 | |
|
| | 69 | | await Task.Delay(100, cancellationToken).ConfigureAwait(false); |
| | 70 | | } |
| | 71 | | catch (IOException) |
| | 72 | | { |
| | 73 | | // May get an error if the file is locked |
| | 74 | | } |
| | 75 | |
|
| | 76 | | await Task.Delay(50, cancellationToken).ConfigureAwait(false); |
| | 77 | | } |
| | 78 | | } |
| | 79 | |
|
| | 80 | | /// <summary> |
| | 81 | | /// Gets the #EXT-X-MAP string. |
| | 82 | | /// </summary> |
| | 83 | | /// <param name="outputPath">The output path of the file.</param> |
| | 84 | | /// <param name="state">The <see cref="StreamState"/>.</param> |
| | 85 | | /// <param name="isOsDepends">Get a normal string or depends on OS.</param> |
| | 86 | | /// <returns>The string text of #EXT-X-MAP.</returns> |
| | 87 | | public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends) |
| | 88 | | { |
| 0 | 89 | | var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) |
| 0 | 90 | | var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); |
| 0 | 91 | | var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); |
| 0 | 92 | | var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); |
| | 93 | |
|
| | 94 | | // on Linux/Unix |
| | 95 | | // #EXT-X-MAP:URI="prefix-1.mp4" |
| 0 | 96 | | var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension; |
| 0 | 97 | | if (!isOsDepends) |
| | 98 | | { |
| 0 | 99 | | return fmp4InitFileName; |
| | 100 | | } |
| | 101 | |
|
| 0 | 102 | | if (OperatingSystem.IsWindows()) |
| | 103 | | { |
| | 104 | | // on Windows |
| | 105 | | // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" |
| 0 | 106 | | fmp4InitFileName = outputPrefix + "-1" + outputExtension; |
| | 107 | | } |
| | 108 | |
|
| 0 | 109 | | return fmp4InitFileName; |
| | 110 | | } |
| | 111 | |
|
| | 112 | | /// <summary> |
| | 113 | | /// Gets the hls playlist text. |
| | 114 | | /// </summary> |
| | 115 | | /// <param name="path">The path to the playlist file.</param> |
| | 116 | | /// <param name="state">The <see cref="StreamState"/>.</param> |
| | 117 | | /// <returns>The playlist text as a string.</returns> |
| | 118 | | public static string GetLivePlaylistText(string path, StreamState state) |
| | 119 | | { |
| 0 | 120 | | var text = File.ReadAllText(path); |
| | 121 | |
|
| 0 | 122 | | var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); |
| 0 | 123 | | if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) |
| | 124 | | { |
| 0 | 125 | | var fmp4InitFileName = GetFmp4InitFileName(path, state, true); |
| 0 | 126 | | var baseUrlParam = string.Format( |
| 0 | 127 | | CultureInfo.InvariantCulture, |
| 0 | 128 | | "hls/{0}/", |
| 0 | 129 | | Path.GetFileNameWithoutExtension(path)); |
| 0 | 130 | | var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false); |
| | 131 | |
|
| | 132 | | // Replace fMP4 init file URI. |
| 0 | 133 | | text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture); |
| | 134 | | } |
| | 135 | |
|
| 0 | 136 | | return text; |
| | 137 | | } |
| | 138 | | } |