Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/MediaBrowser.Common/Media/AddChapterRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace MediaBrowser.Media;

public class AddChapterRequest : UpdateMediaRequest
{
[JsonPropertyName("thumbnail"), Range(0, double.MaxValue)]
public double? Thumbnail { get; init; }

[Range(0, double.MaxValue)]
public required double Duration { get; init; }

[Range(0, double.MaxValue)]
public required double Start { get; init; }
}
9 changes: 4 additions & 5 deletions src/MediaBrowser.Common/Media/Import/ImportController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,11 @@ public async Task<ActionResult<MediaReadModel>> Import(string name, [FromBody] I
thumbnail: request.Thumbnail);
await context.Media.AddAsync(media);

await nfo.Save(media, Path.Combine(mediaConfig.MediaDirectory, $"{hash}.nfo"));
await nfo.Save(media, mediaConfig.MediaFileLocation(media, ".nfo"));

if (request.Thumbnail != null && ffprobe.Value.mime.StartsWith("video/", StringComparison.InvariantCulture))
{
var thumbnailLocation = Path.Combine(mediaConfig.MediaDirectory, $"{hash}.jpg");
var thumbnailLocation = mediaConfig.MediaFileLocation(media, ".jpg");
if (!await ffmpeg.TryExtractThumbnail(file.Path,
outputPath: thumbnailLocation,
at: TimeSpan.FromSeconds(request.Thumbnail.Value)))
Expand All @@ -131,13 +131,12 @@ public async Task<ActionResult<MediaReadModel>> Import(string name, [FromBody] I
}

System.IO.File.Copy(thumbnailLocation,
destFileName: Path.Combine(mediaConfig.MediaDirectory, $"{hash}-fanart.jpg"), true);
destFileName: mediaConfig.MediaFileLocation(media, "-fanart.jpg"), true);
}

await context.SaveChangesAsync();

var newFilePath = Path.Combine(mediaConfig.MediaDirectory,
$"{hash}.{mediaConfig.GetExtensionFromMime(ffprobe.Value.mime)}");
var newFilePath = mediaConfig.MediaFileLocation(media);

System.IO.File.Move(file.Path, newFilePath);

Expand Down
7 changes: 5 additions & 2 deletions src/MediaBrowser.Common/Media/Import/Nfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public MediaEntity Read(string rawXml)
throw new ParseNfoException(1, "Invalid or missing media ID");
}

var parentId = Guid.TryParse(xmlDoc.SelectSingleNode("//parentId")?.InnerText, out var parentIdValue) ? parentIdValue : (Guid?)null;

var hash = xmlDoc.SelectSingleNode("//md5")?.InnerText;
if (string.IsNullOrWhiteSpace(hash) || hash.Length != 32)
{
Expand Down Expand Up @@ -99,7 +101,7 @@ public MediaEntity Read(string rawXml)
};

var media = MediaEntity.Create(
mediaId: mediaId,
mediaId: mediaId, parentId: parentId,
fileInfo: fileInfo,
ffprobe: ffprobeResponse,
request: request,
Expand All @@ -113,7 +115,8 @@ public MediaEntity Read(string rawXml)
height: int.TryParse(xmlDoc.SelectSingleNode("//height")?.InnerText ?? string.Empty, out var height) ? height : null,
width: int.TryParse(xmlDoc.SelectSingleNode("//width")?.InnerText ?? string.Empty, out var width) ? width : null,
ctimeMs: long.TryParse(xmlDoc.SelectSingleNode("//ctime")?.InnerText ?? string.Empty, out var ctimeMs) ? ctimeMs : null,
mtimeMs: long.TryParse(xmlDoc.SelectSingleNode("//mtimeMs")?.InnerText ?? string.Empty, out var mtimeMs) ? mtimeMs : null);
mtimeMs: long.TryParse(xmlDoc.SelectSingleNode("//mtimeMs")?.InnerText ?? string.Empty, out var mtimeMs) ? mtimeMs : null,
start: double.TryParse(xmlDoc.SelectSingleNode("//start")?.InnerText ?? string.Empty, out var start) ? start : null);

return media;
}
Expand Down
2 changes: 2 additions & 0 deletions src/MediaBrowser.Common/Media/Import/Nfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
{{/each}}
</writers>
<stats>
<parentId>{{parentId}}</parentId>
<start>{{start}}</start>
<ctime>{{ctimeMs}}</ctime>
<duration>{{duration}}</duration>
<ffprobe>{{ffprobe}}</ffprobe>
Expand Down
64 changes: 59 additions & 5 deletions src/MediaBrowser.Common/Media/Media.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public partial class MediaEntity

[Column("mime"), JsonPropertyName("mime")]
public required string Mime { get; init; }
public bool IsImage() => Mime.StartsWith("image/", StringComparison.InvariantCulture);

[Column("size"), JsonPropertyName("size")]
public required long? Size { get; init; }
Expand All @@ -33,6 +34,9 @@ public partial class MediaEntity
[Column("duration"), JsonPropertyName("duration")]
public required double? Duration { get; set; }

[Column("start"), JsonPropertyName("start")]
public double? Start { get; set; }

[Column("md5"), JsonPropertyName("md5"), MaxLength(32)]
public required string Md5 { get; init; }

Expand Down Expand Up @@ -63,6 +67,9 @@ public partial class MediaEntity
[Column("thumbnail"), JsonPropertyName("thumbnail")]
public double? Thumbnail { get; set; }

[Column("parent_id"), JsonPropertyName("parentId")]
public Guid? ParentId { get; init; }

[JsonPropertyName("cast"), UnorderedEquality]
public ICollection<CastEntity> Cast { get; set; } = [];

Expand Down Expand Up @@ -106,10 +113,12 @@ public partial class MediaEntity
Writers = Writers.Select(it => it.Name).ToList(),
Url = $"/api/Media/{Id}/file",
Thumbnail = Thumbnail,
ThumbnailUrl = Mime.StartsWith("image/", StringComparison.InvariantCulture) || File.Exists(System.IO.Path.Combine(config.MediaDirectory, $"{Md5}.jpg"))
ThumbnailUrl = IsImage() || File.Exists(config.MediaFileLocation(this, ".jpg"))
? $"/api/Media/{Id}/file/thumbnail" : null,
FanartThumbnailUrl = Mime.StartsWith("image/", StringComparison.InvariantCulture) || File.Exists(System.IO.Path.Combine(config.MediaDirectory, $"{Md5}-fanart.jpg"))
? $"/api/Media/{Id}/file/thumbnail-fanart" : null
FanartThumbnailUrl = IsImage() || File.Exists(config.MediaFileLocation(this, "-fanart.jpg"))
? $"/api/Media/{Id}/file/thumbnail-fanart" : null,
Start = Start,
ParentId = ParentId
};

public static MediaEntity Create(
Expand All @@ -125,7 +134,8 @@ public static MediaEntity Create(
Guid? mediaId = null,
double? thumbnail = null,
int? height = null, int? width = null,
long? ctimeMs = null, long? mtimeMs = null)
long? ctimeMs = null, long? mtimeMs = null,
Guid? parentId = null, double? start = null, double? duration = null)
{
var castEntities = new List<CastEntity>();
var directorEntities = new List<DirectorEntity>();
Expand All @@ -149,14 +159,16 @@ public static MediaEntity Create(
Size = fileInfo.Length,
Width = width ?? ffprobe.Streams?.Select(it => it.Width).OfType<int>().Cast<int?>().FirstOrDefault(),
Height = height ?? ffprobe.Streams?.Select(it => it.Height).OfType<int>().Cast<int?>().FirstOrDefault(),
Duration = double.Parse(ffprobe.Format?.Duration ?? "0", CultureInfo.InvariantCulture),
Duration = duration ?? double.Parse(ffprobe.Format?.Duration ?? "0", CultureInfo.InvariantCulture),
Md5 = hash,
Rating = request.Rating,
UserStarRating = request.UserStarRating,
Published = createdOn.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
MtimeMs = mtimeMs ?? Convert.ToInt64((fileInfo.LastWriteTimeUtc - DateTime.UnixEpoch).TotalMilliseconds),
Ffprobe = ffprobe,
Thumbnail = thumbnail,
Start = start,
ParentId = parentId,

Cast = castEntities,
Directors = directorEntities,
Expand Down Expand Up @@ -208,6 +220,48 @@ public void Update(UpdateMediaRequest request)
.Where(name => !string.IsNullOrWhiteSpace(name)).Distinct()
.Select(name => new WriterEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList();
}

public MediaEntity CreateChapter(AddChapterRequest request) => new()
{
Id = Guid.CreateVersion7(),
CtimeMs = CtimeMs,
CreatedOn = CreatedOn,
UpdatedOn = UpdatedOn,
Path = Path,
Title = request.Title,
OriginalTitle = request.OriginalTitle,
Description = request.Description,
Mime = Mime,
Size = Size,
Width = Width,
Height = Height,
Duration = request.Duration,
Md5 = Md5,
Rating = request.Rating,
UserStarRating = request.UserStarRating,
Published = Published,
MtimeMs = MtimeMs,
Ffprobe = Ffprobe,
Thumbnail = request.Thumbnail,
Start = request.Start,
ParentId = Id,

Cast = request.Cast
.Where(name => !string.IsNullOrWhiteSpace(name)).Distinct()
.Select(name => new CastEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList(),
Directors = request.Directors
.Where(name => !string.IsNullOrWhiteSpace(name)).Distinct()
.Select(name => new DirectorEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList(),
Genres = request.Genres
.Where(name => !string.IsNullOrWhiteSpace(name)).Distinct()
.Select(name => new GenreEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList(),
Producers = request.Producers
.Where(name => !string.IsNullOrWhiteSpace(name)).Distinct()
.Select(name => new ProducerEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList(),
Writers = request.Writers
.Where(name => !string.IsNullOrWhiteSpace(name)).Distinct()
.Select(name => new WriterEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList()
};
}

public class MediaEntityConfiguration(DbType type) : IEntityTypeConfiguration<MediaEntity>
Expand Down
8 changes: 8 additions & 0 deletions src/MediaBrowser.Common/Media/MediaConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ public string GetExtensionFromMime(string mime)
return ext;
}

/// <summary>
/// Creates a file location for a media file based on its MD5 hash and optional parent ID.
/// If the media has a parent ID, it appends it to the file name to allow for multiple files with the same hash (e.g., different chapters).
/// The extension is determined from the media's MIME type, or can be overridden with the optional extension parameter.
/// </summary>
public string MediaFileLocation(MediaEntity media, string? extension = null) =>
Path.Combine(MediaDirectory, $"{media.Md5}{(media.ParentId == null || extension == null ? "" : $".{media.Id}")}{extension ?? $".{GetExtensionFromMime(media.Mime)}"}");

public string MediaDirectory { get; } = configuration["media:mediaDirectory"]!;
public string ProducersDirectory { get; } = configuration["media:producersDirectory"]!;
public string WritersDirectory { get; } = configuration["media:writersDirectory"]!;
Expand Down
87 changes: 68 additions & 19 deletions src/MediaBrowser.Common/Media/MediaController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,62 @@ public async Task<ActionResult<MediaReadModel>> Update(Guid id, [FromBody] Updat

media.Update(request);

await nfo.Save(media, Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}.nfo"));
await nfo.Save(media, mediaConfig.MediaFileLocation(media, ".nfo"));

await context.SaveChangesAsync();

return media.ToReadModel(mediaConfig);
}

[HttpPost("{id:guid}/chapters")]
public async Task<ActionResult<MediaReadModel>> AddChapter(Guid id, [FromBody] AddChapterRequest request)
{
var media = await context.MediaJoined.Where(m => m.Id == id && m.ParentId == null).FirstOrDefaultAsync();
if (media == null || media.IsImage())
{
return NotFound();
}

if (request.Cast
.Concat(request.Directors)
.Concat(request.Genres)
.Concat(request.Producers)
.Concat(request.Writers)
.Any(it => !IsNameValid(it)))
{
return StatusCode(StatusCodes.Status417ExpectationFailed);
}

if (request.Start + request.Duration > media.Duration)
{
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
}

var chapter = media.CreateChapter(request);

await nfo.Save(chapter, mediaConfig.MediaFileLocation(chapter, ".nfo"));

await context.AddAsync(chapter);

await context.SaveChangesAsync();

if (request.Thumbnail != null && chapter.Mime.StartsWith("video/", StringComparison.InvariantCulture))
{
var thumbnailLocation = mediaConfig.MediaFileLocation(chapter, ".jpg");
if (!await ffmpeg.TryExtractThumbnail(mediaConfig.MediaFileLocation(chapter),
outputPath: thumbnailLocation,
at: TimeSpan.FromSeconds(request.Thumbnail.Value)))
{
return StatusCode(StatusCodes.Status406NotAcceptable);
}

System.IO.File.Copy(thumbnailLocation,
destFileName: mediaConfig.MediaFileLocation(chapter, "-fanart.jpg"), true);
}

return chapter.ToReadModel(mediaConfig);
}

[HttpGet("search")]
public async Task<SearchResponse> Search([FromQuery] SearchRequest request)
{
Expand All @@ -62,13 +111,13 @@ public async Task<SearchResponse> Search([FromQuery] SearchRequest request)

[HttpGet("{id:guid}/file")]
public Task<ActionResult> Stream(Guid id) =>
ReadFile(id, media => $".{mediaConfig.GetExtensionFromMime(media.Mime)}", media => media.Mime, true);
ReadFile(id, media => mediaConfig.MediaFileLocation(media), etag: true);

[HttpGet("{id:guid}/file/thumbnail-fanart")]
public Task<ActionResult> StreamFanartThumbnail(Guid id) =>
ReadFile(id,
media => media.Mime.StartsWith("image/", StringComparison.InvariantCulture) ? $".{mediaConfig.GetExtensionFromMime(media.Mime)}" : "-fanart.jpg",
media => media.Mime.StartsWith("image/", StringComparison.InvariantCulture) ? media.Mime : "image/jpeg");
media => media.IsImage() ? mediaConfig.MediaFileLocation(media) : mediaConfig.MediaFileLocation(media, "-fanart.jpg"),
media => media.IsImage() ? media.Mime : "image/jpeg");

[HttpPost("{id:guid}/file/thumbnail-fanart")]
public Task<ActionResult> UpdateFanartThumbnailWithTimestamp(Guid id, [FromBody] UpdateThumbnailRequest request) =>
Expand All @@ -77,8 +126,8 @@ public Task<ActionResult> UpdateFanartThumbnailWithTimestamp(Guid id, [FromBody]
[HttpGet("{id:guid}/file/thumbnail")]
public Task<ActionResult> StreamThumbnail(Guid id) =>
ReadFile(id,
media => media.Mime.StartsWith("image/", StringComparison.InvariantCulture) ? $".{mediaConfig.GetExtensionFromMime(media.Mime)}" : ".jpg",
media => media.Mime.StartsWith("image/", StringComparison.InvariantCulture) ? media.Mime : "image/jpeg");
media => media.IsImage() ? mediaConfig.MediaFileLocation(media) : mediaConfig.MediaFileLocation(media, ".jpg"),
media => media.IsImage() ? media.Mime : "image/jpeg");

[HttpPost("{id:guid}/file/thumbnail")]
public Task<ActionResult> UpdateThumbnailWithTimestamp(Guid id, [FromBody] UpdateThumbnailRequest request) =>
Expand All @@ -93,13 +142,12 @@ public async Task<ActionResult> UpdateThumbnailWithFile(Guid id, [FromForm] Uplo
return NotFound();
}

if (media.Mime.StartsWith("image/", StringComparison.InvariantCulture))
if (media.IsImage())
{
return StatusCode(StatusCodes.Status406NotAcceptable);
}

var extension = request.IsPrimary ? ".jpg" : "-fanart.jpg";
var thumbnailLocation = Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}{extension}");
var thumbnailLocation = mediaConfig.MediaFileLocation(media, request.IsPrimary ? ".jpg" : "-fanart.jpg");

var (result, success) = await UpdateThumbnail(request.Thumbnail, thumbnailLocation);

Expand All @@ -109,24 +157,23 @@ public async Task<ActionResult> UpdateThumbnailWithFile(Guid id, [FromForm] Uplo
}

media.Thumbnail = null;
await nfo.Save(media, Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}.nfo"));
await nfo.Save(media, mediaConfig.MediaFileLocation(media, ".nfo"));
await context.SaveChangesAsync();

return result;
}

async Task<ActionResult> ReadFile(Guid id,
Func<MediaEntity, string> extension,
Func<MediaEntity, string> mime,
bool etag = false)
Func<MediaEntity, string> filePathFactory,
Func<MediaEntity, string>? mimeFactory = null, bool etag = false)
{
var media = await context.Media.Where(m => m.Id == id).FirstOrDefaultAsync();
if (media == null)
{
return NotFound();
}

var filePath = Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}{extension(media)}");
var filePath = filePathFactory(media);
if (!System.IO.File.Exists(filePath))
{
return NotFound();
Expand All @@ -149,7 +196,9 @@ async Task<ActionResult> ReadFile(Guid id,
}
Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R");

return File(new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read), mime(media),
mimeFactory ??= it => it.Mime;

return File(new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read), mimeFactory(media),
enableRangeProcessing: true);
}

Expand Down Expand Up @@ -187,18 +236,18 @@ async Task<ActionResult> UpdateThumbnailWithTimestamp(Guid id, string extension,
return NotFound();
}

if (!media.Mime.StartsWith("video/", StringComparison.InvariantCulture))
if (media.IsImage())
{
return StatusCode(StatusCodes.Status406NotAcceptable);
}

var filePath = Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}.{mediaConfig.GetExtensionFromMime(media.Mime)}");
var filePath = mediaConfig.MediaFileLocation(media);
if (!System.IO.File.Exists(filePath))
{
return NotFound();
}

var thumbnailLocation = Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}{extension}");
var thumbnailLocation = mediaConfig.MediaFileLocation(media, extension);
if (!await ffmpeg.TryExtractThumbnail(filePath,
outputPath: thumbnailLocation,
at: TimeSpan.FromSeconds(at)))
Expand All @@ -210,7 +259,7 @@ async Task<ActionResult> UpdateThumbnailWithTimestamp(Guid id, string extension,
if (isPrimary)
{
media.Thumbnail = at;
await nfo.Save(media, Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}.nfo"));
await nfo.Save(media, mediaConfig.MediaFileLocation(media, ".nfo"));
await context.SaveChangesAsync();
}

Expand Down
Loading
Loading