diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebd2855f..41069272 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -102,6 +102,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add Trakt List option `Auto Refresh` to automatically update list from trakt.tv once each day
- Add Trakt List option `Generate Playlist` to automatically generate ETV Playlist from matched Trakt List items
- Read `country` field from movie NFO files and include in search index as `country`
+- Add *experimental* and *incomplete* `Remote Stream` library kind
+ - Remote Stream libraries have fallback metadata added like Other Video libraries (every folder is a tag)
+ - Remote Stream library items consist of YAML (`.yml`) files with the following fields
+ - `url`: the URL of the content that can be played directly by ffmpeg
+ - `script`: the process name and arguments for a command that will output content to stdout
+ - `duration`: when the content is "live" and does not have duration metadata, this must be provided to allow scheduling
+ - The remote stream definition (YAML file) may provide either a `url` or a `script`
+ - If both are provided, `url` will be used
### Changed
- Allow `Other Video` libraries and `Image` libraries to use the same folders
diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings b/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
index 660306c6..2138ffc3 100644
--- a/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
+++ b/ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
@@ -45,6 +45,7 @@
True
True
True
+ True
True
True
True
\ No newline at end of file
diff --git a/ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
index 8188c50f..ebd730fd 100644
--- a/ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
+++ b/ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
@@ -10,7 +10,8 @@ public record CollectionCardResultsViewModel(
List MusicVideoCards,
List OtherVideoCards,
List SongCards,
- List ImageCards)
+ List ImageCards,
+ List RemoteStreamCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}
diff --git a/ErsatzTV.Application/MediaCards/Mapper.cs b/ErsatzTV.Application/MediaCards/Mapper.cs
index edc98c52..3982f070 100644
--- a/ErsatzTV.Application/MediaCards/Mapper.cs
+++ b/ErsatzTV.Application/MediaCards/Mapper.cs
@@ -155,6 +155,15 @@ internal static class Mapper
string.Empty, // TODO: thumbnail?
imageMetadata.Image.State);
+ internal static RemoteStreamCardViewModel ProjectToViewModel(RemoteStreamMetadata remoteStreamMetadata) =>
+ new(
+ remoteStreamMetadata.RemoteStreamId,
+ remoteStreamMetadata.Title,
+ remoteStreamMetadata.OriginalTitle,
+ remoteStreamMetadata.SortTitle,
+ string.Empty, // TODO: thumbnail?
+ remoteStreamMetadata.RemoteStream.State);
+
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
@@ -199,7 +208,9 @@ internal static class Mapper
.ToList(),
collection.MediaItems.OfType().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
.ToList(),
- collection.MediaItems.OfType().Map(i => ProjectToViewModel(i.ImageMetadata.Head())).ToList())
+ collection.MediaItems.OfType().Map(i => ProjectToViewModel(i.ImageMetadata.Head())).ToList(),
+ collection.MediaItems.OfType().Map(i => ProjectToViewModel(i.RemoteStreamMetadata.Head()))
+ .ToList())
{ UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel(
diff --git a/ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs
index 3f09be71..fb55ea35 100644
--- a/ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs
+++ b/ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs
@@ -105,6 +105,12 @@ public class GetCollectionCardsHandler :
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Image).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
+ .Include(c => c.MediaItems)
+ .ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
+ .ThenInclude(ovm => ovm.Artwork)
+ .Include(c => c.MediaItems)
+ .ThenInclude(i => (i as RemoteStream).MediaVersions)
+ .ThenInclude(mv => mv.MediaFiles)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
diff --git a/ErsatzTV.Application/MediaCards/RemoteStreamCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/RemoteStreamCardResultsViewModel.cs
new file mode 100644
index 00000000..d856f424
--- /dev/null
+++ b/ErsatzTV.Application/MediaCards/RemoteStreamCardResultsViewModel.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core.Search;
+
+namespace ErsatzTV.Application.MediaCards;
+
+public record RemoteStreamCardResultsViewModel(int Count, List Cards, SearchPageMap PageMap);
diff --git a/ErsatzTV.Application/MediaCards/RemoteStreamCardViewModel.cs b/ErsatzTV.Application/MediaCards/RemoteStreamCardViewModel.cs
new file mode 100644
index 00000000..9b5e5f39
--- /dev/null
+++ b/ErsatzTV.Application/MediaCards/RemoteStreamCardViewModel.cs
@@ -0,0 +1,20 @@
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Application.MediaCards;
+
+public record RemoteStreamCardViewModel(
+ int RemoteStreamId,
+ string Title,
+ string Subtitle,
+ string SortTitle,
+ string Poster,
+ MediaItemState State) : MediaCardViewModel(
+ RemoteStreamId,
+ Title,
+ Subtitle,
+ SortTitle,
+ Poster,
+ State)
+{
+ public int CustomIndex { get; set; }
+}
diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
index 37c725d0..21b0adb8 100644
--- a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
+++ b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
@@ -12,4 +12,5 @@ public record AddItemsToCollection(
List MusicVideoIds,
List OtherVideoIds,
List SongIds,
- List ImageIds) : IRequest>;
+ List ImageIds,
+ List RemoteStreamIds) : IRequest>;
diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
index 1cf0ec36..09fabe2d 100644
--- a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
+++ b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
@@ -60,6 +60,7 @@ public class AddItemsToCollectionHandler :
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
+ .Append(request.RemoteStreamIds)
.ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs
index 1f71fc0a..6d1e88fc 100644
--- a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs
+++ b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs
@@ -12,4 +12,5 @@ public record AddItemsToPlaylist(
List MusicVideoIds,
List OtherVideoIds,
List SongIds,
- List ImageIds) : IRequest>;
+ List ImageIds,
+ List RemoteStreamIds) : IRequest>;
diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMediaItemToCollection.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMediaItemToCollection.cs
new file mode 100644
index 00000000..27badc05
--- /dev/null
+++ b/ErsatzTV.Application/MediaCollections/Commands/AddMediaItemToCollection.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core;
+
+namespace ErsatzTV.Application.MediaCollections;
+
+public record AddMediaItemToCollection(int CollectionId, int MediaItemId) : IRequest>;
diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMediaItemToCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMediaItemToCollectionHandler.cs
new file mode 100644
index 00000000..aa003d8b
--- /dev/null
+++ b/ErsatzTV.Application/MediaCollections/Commands/AddMediaItemToCollectionHandler.cs
@@ -0,0 +1,83 @@
+using System.Threading.Channels;
+using ErsatzTV.Application.Playouts;
+using ErsatzTV.Application.Search;
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Interfaces.Repositories;
+using ErsatzTV.Core.Scheduling;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Infrastructure.Extensions;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application.MediaCollections;
+
+public class AddMediaItemToCollectionHandler :
+ IRequestHandler>
+{
+ private readonly ChannelWriter _channel;
+ private readonly IDbContextFactory _dbContextFactory;
+ private readonly IMediaCollectionRepository _mediaCollectionRepository;
+ private readonly ChannelWriter _searchChannel;
+
+ public AddMediaItemToCollectionHandler(
+ IDbContextFactory dbContextFactory,
+ IMediaCollectionRepository mediaCollectionRepository,
+ ChannelWriter channel,
+ ChannelWriter searchChannel)
+ {
+ _dbContextFactory = dbContextFactory;
+ _mediaCollectionRepository = mediaCollectionRepository;
+ _channel = channel;
+ _searchChannel = searchChannel;
+ }
+
+ public async Task> Handle(
+ AddMediaItemToCollection request,
+ CancellationToken cancellationToken)
+ {
+ await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
+ Validation validation = await Validate(dbContext, request);
+ return await validation.Apply(parameters => ApplyAddMediaItemRequest(dbContext, parameters));
+ }
+
+ private async Task ApplyAddMediaItemRequest(TvContext dbContext, Parameters parameters)
+ {
+ parameters.Collection.MediaItems.Add(parameters.MediaItem);
+ if (await dbContext.SaveChangesAsync() > 0)
+ {
+ await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]));
+
+ // refresh all playouts that use this collection
+ foreach (int playoutId in await _mediaCollectionRepository
+ .PlayoutIdsUsingCollection(parameters.Collection.Id))
+ {
+ await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
+ }
+ }
+
+ return Unit.Default;
+ }
+
+ private static async Task> Validate(
+ TvContext dbContext,
+ AddMediaItemToCollection request) =>
+ (await CollectionMustExist(dbContext, request), await ValidateMediaItem(dbContext, request))
+ .Apply((collection, episode) => new Parameters(collection, episode));
+
+ private static Task> CollectionMustExist(
+ TvContext dbContext,
+ AddMediaItemToCollection request) =>
+ dbContext.Collections
+ .Include(c => c.MediaItems)
+ .SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
+ .Map(o => o.ToValidation("Collection does not exist."));
+
+ private static Task> ValidateMediaItem(
+ TvContext dbContext,
+ AddMediaItemToCollection request) =>
+ dbContext.MediaItems
+ .SelectOneAsync(m => m.Id, e => e.Id == request.MediaItemId)
+ .Map(o => o.ToValidation("MediaItem does not exist"));
+
+ private sealed record Parameters(Collection Collection, MediaItem MediaItem);
+}
diff --git a/ErsatzTV.Application/MediaItems/Mapper.cs b/ErsatzTV.Application/MediaItems/Mapper.cs
index 018dfa15..49f55b5e 100644
--- a/ErsatzTV.Application/MediaItems/Mapper.cs
+++ b/ErsatzTV.Application/MediaItems/Mapper.cs
@@ -32,6 +32,9 @@ internal static class Mapper
internal static NamedMediaItemViewModel ProjectToViewModel(Image image) =>
new(image.Id, image.ImageMetadata.HeadOrNone().Match(i => i.Title, () => "???"));
+ internal static RemoteStreamViewModel ProjectToViewModel(RemoteStream remoteStream) =>
+ new(remoteStream.Id, remoteStream.Url, remoteStream.Script);
+
private static string MovieTitle(Movie movie)
{
var title = "???";
diff --git a/ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs
index 4d284be9..0a402878 100644
--- a/ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs
+++ b/ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs
@@ -56,6 +56,12 @@ public class GetMediaItemInfoHandler : IRequestHandler mv.Chapters)
.Include(i => (i as Image).MediaVersions)
.ThenInclude(mv => mv.Streams)
+ .Include(i => (i as RemoteStream).RemoteStreamMetadata)
+ .ThenInclude(mv => mv.Subtitles)
+ .Include(i => (i as RemoteStream).MediaVersions)
+ .ThenInclude(mv => mv.Chapters)
+ .Include(i => (i as RemoteStream).MediaVersions)
+ .ThenInclude(mv => mv.Streams)
.Include(i => (i as Song).SongMetadata)
.ThenInclude(mv => mv.Subtitles)
.Include(i => (i as Song).MediaVersions)
diff --git a/ErsatzTV.Application/MediaItems/Queries/GetRemoteStreamById.cs b/ErsatzTV.Application/MediaItems/Queries/GetRemoteStreamById.cs
new file mode 100644
index 00000000..0ca058e7
--- /dev/null
+++ b/ErsatzTV.Application/MediaItems/Queries/GetRemoteStreamById.cs
@@ -0,0 +1,5 @@
+using ErsatzTV.Core;
+
+namespace ErsatzTV.Application.MediaItems;
+
+public record GetRemoteStreamById(int RemoteStreamId) : IRequest