Browse Source

add basic remote stream library (#2175)

* initial remote stream library support; scanning seems to work ok

* flood schedule remote streams kind of works

* switch remote stream definitions to yaml files

* implement remote stream script playback

* update changelog
pull/2176/head
Jason Dove 1 year ago committed by GitHub
parent
commit
5c43ae47b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
  3. 3
      ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
  4. 13
      ErsatzTV.Application/MediaCards/Mapper.cs
  5. 6
      ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs
  6. 5
      ErsatzTV.Application/MediaCards/RemoteStreamCardResultsViewModel.cs
  7. 20
      ErsatzTV.Application/MediaCards/RemoteStreamCardViewModel.cs
  8. 3
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
  9. 1
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  10. 3
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs
  11. 5
      ErsatzTV.Application/MediaCollections/Commands/AddMediaItemToCollection.cs
  12. 83
      ErsatzTV.Application/MediaCollections/Commands/AddMediaItemToCollectionHandler.cs
  13. 3
      ErsatzTV.Application/MediaItems/Mapper.cs
  14. 6
      ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs
  15. 5
      ErsatzTV.Application/MediaItems/Queries/GetRemoteStreamById.cs
  16. 19
      ErsatzTV.Application/MediaItems/Queries/GetRemoteStreamByIdHandler.cs
  17. 3
      ErsatzTV.Application/MediaItems/RemoteStreamViewModel.cs
  18. 2
      ErsatzTV.Application/Playouts/Mapper.cs
  19. 4
      ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs
  20. 2
      ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayoutHandler.cs
  21. 17
      ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs
  22. 5
      ErsatzTV.Application/Search/Queries/QuerySearchIndexRemoteStreams.cs
  23. 33
      ErsatzTV.Application/Search/Queries/QuerySearchIndexRemoteStreamsHandler.cs
  24. 3
      ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs
  25. 18
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  26. 36
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  27. 1
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  28. 3
      ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs
  29. 14
      ErsatzTV.Core/Domain/MediaItem/RemoteStream.cs
  30. 7
      ErsatzTV.Core/Domain/Metadata/RemoteStreamMetadata.cs
  31. 1
      ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs
  32. 1
      ErsatzTV.Core/Extensions/MediaItemExtensions.cs
  33. 1
      ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs
  34. 1
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  35. 18
      ErsatzTV.Core/Interfaces/Repositories/IRemoteStreamRepository.cs
  36. 76
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  37. 29
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  38. 7
      ErsatzTV.Core/Scheduling/MediaItemsForCollection.cs
  39. 9
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  40. 8
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
  41. 20
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs
  42. 12
      ErsatzTV.Core/Streaming/YamlRemoteStreamDefinition.cs
  43. 16
      ErsatzTV.FFmpeg/OutputOption/TimeLimitOutputOption.cs
  44. 6126
      ErsatzTV.Infrastructure.MySql/Migrations/20250720030144_Add_RemoteStream.Designer.cs
  45. 328
      ErsatzTV.Infrastructure.MySql/Migrations/20250720030144_Add_RemoteStream.cs
  46. 6126
      ErsatzTV.Infrastructure.MySql/Migrations/20250720030313_Add_LocalLibraryRemoteStream.Designer.cs
  47. 27
      ErsatzTV.Infrastructure.MySql/Migrations/20250720030313_Add_LocalLibraryRemoteStream.cs
  48. 6138
      ErsatzTV.Infrastructure.MySql/Migrations/20250720135059_Add_RemoteStreamFields.Designer.cs
  49. 62
      ErsatzTV.Infrastructure.MySql/Migrations/20250720135059_Add_RemoteStreamFields.cs
  50. 185
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  51. 5963
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250720030204_Add_RemoteStream.Designer.cs
  52. 323
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250720030204_Add_RemoteStream.cs
  53. 5963
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250720030246_Add_LocalLibraryRemoteStream.Designer.cs
  54. 27
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250720030246_Add_LocalLibraryRemoteStream.cs
  55. 5975
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250720135252_Add_RemoteStreamFields.Designer.cs
  56. 59
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250720135252_Add_RemoteStreamFields.cs
  57. 183
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  58. 22
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/RemoteStreamConfiguration.cs
  59. 41
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/RemoteStreamMetadataConfiguration.cs
  60. 67
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  61. 6
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  62. 172
      ErsatzTV.Infrastructure/Data/Repositories/RemoteStreamRepository.cs
  63. 20
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  64. 2
      ErsatzTV.Infrastructure/Data/TvContext.cs
  65. 4
      ErsatzTV.Infrastructure/Health/Checks/FileNotFoundHealthCheck.cs
  66. 24
      ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs
  67. 81
      ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs
  68. 11
      ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  69. 2
      ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  70. 15
      ErsatzTV.Scanner/Core/Interfaces/Metadata/IRemoteStreamFolderScanner.cs
  71. 5
      ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs
  72. 135
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  73. 375
      ErsatzTV.Scanner/Core/Metadata/RemoteStreamFolderScanner.cs
  74. 13
      ErsatzTV.Scanner/Program.cs
  75. 55
      ErsatzTV/Controllers/InternalController.cs
  76. 43
      ErsatzTV/Pages/CollectionItems.razor
  77. 18
      ErsatzTV/Pages/Libraries.razor
  78. 1
      ErsatzTV/Pages/LocalLibraries.razor
  79. 8
      ErsatzTV/Pages/LocalLibraryEditor.razor
  80. 24
      ErsatzTV/Pages/MultiSelectBase.cs
  81. 132
      ErsatzTV/Pages/RemoteStreamList.razor
  82. 82
      ErsatzTV/Pages/Search.razor
  83. 64
      ErsatzTV/Pages/Trash.razor
  84. 1
      ErsatzTV/Services/RunOnce/DatabaseCleanerService.cs
  85. 1
      ErsatzTV/Shared/MainLayout.razor
  86. 1
      ErsatzTV/Startup.cs

8
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 `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 - 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` - 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 ### Changed
- Allow `Other Video` libraries and `Image` libraries to use the same folders - Allow `Other Video` libraries and `Image` libraries to use the same folders

1
ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings

@ -45,6 +45,7 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=subtitles_005Ccommands/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=subtitles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=television_005Cqueries/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=television_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=templates_005Cqueries/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=templates_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=troubleshooting_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=validators/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=validators/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

3
ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs

@ -10,7 +10,8 @@ public record CollectionCardResultsViewModel(
List<MusicVideoCardViewModel> MusicVideoCards, List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards, List<OtherVideoCardViewModel> OtherVideoCards,
List<SongCardViewModel> SongCards, List<SongCardViewModel> SongCards,
List<ImageCardViewModel> ImageCards) List<ImageCardViewModel> ImageCards,
List<RemoteStreamCardViewModel> RemoteStreamCards)
{ {
public bool UseCustomPlaybackOrder { get; set; } public bool UseCustomPlaybackOrder { get; set; }
} }

13
ErsatzTV.Application/MediaCards/Mapper.cs

@ -155,6 +155,15 @@ internal static class Mapper
string.Empty, // TODO: thumbnail? string.Empty, // TODO: thumbnail?
imageMetadata.Image.State); 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) => internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new( new(
artistMetadata.ArtistId, artistMetadata.ArtistId,
@ -199,7 +208,9 @@ internal static class Mapper
.ToList(), .ToList(),
collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head())) collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
.ToList(), .ToList(),
collection.MediaItems.OfType<Image>().Map(i => ProjectToViewModel(i.ImageMetadata.Head())).ToList()) collection.MediaItems.OfType<Image>().Map(i => ProjectToViewModel(i.ImageMetadata.Head())).ToList(),
collection.MediaItems.OfType<RemoteStream>().Map(i => ProjectToViewModel(i.RemoteStreamMetadata.Head()))
.ToList())
{ UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder }; { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel( internal static ActorCardViewModel ProjectToViewModel(

6
ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs

@ -105,6 +105,12 @@ public class GetCollectionCardsHandler :
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as Image).MediaVersions) .ThenInclude(i => (i as Image).MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .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) .SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection"))) .Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby)); .MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));

5
ErsatzTV.Application/MediaCards/RemoteStreamCardResultsViewModel.cs

@ -0,0 +1,5 @@
using ErsatzTV.Core.Search;
namespace ErsatzTV.Application.MediaCards;
public record RemoteStreamCardResultsViewModel(int Count, List<RemoteStreamCardViewModel> Cards, SearchPageMap PageMap);

20
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; }
}

3
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs

@ -12,4 +12,5 @@ public record AddItemsToCollection(
List<int> MusicVideoIds, List<int> MusicVideoIds,
List<int> OtherVideoIds, List<int> OtherVideoIds,
List<int> SongIds, List<int> SongIds,
List<int> ImageIds) : IRequest<Either<BaseError, Unit>>; List<int> ImageIds,
List<int> RemoteStreamIds) : IRequest<Either<BaseError, Unit>>;

1
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs

@ -60,6 +60,7 @@ public class AddItemsToCollectionHandler :
.Append(request.OtherVideoIds) .Append(request.OtherVideoIds)
.Append(request.SongIds) .Append(request.SongIds)
.Append(request.ImageIds) .Append(request.ImageIds)
.Append(request.RemoteStreamIds)
.ToList(); .ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList(); var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();

3
ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs

@ -12,4 +12,5 @@ public record AddItemsToPlaylist(
List<int> MusicVideoIds, List<int> MusicVideoIds,
List<int> OtherVideoIds, List<int> OtherVideoIds,
List<int> SongIds, List<int> SongIds,
List<int> ImageIds) : IRequest<Either<BaseError, Unit>>; List<int> ImageIds,
List<int> RemoteStreamIds) : IRequest<Either<BaseError, Unit>>;

5
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<Either<BaseError, Unit>>;

83
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<AddMediaItemToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _searchChannel;
public AddMediaItemToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel,
ChannelWriter<ISearchIndexBackgroundServiceRequest> searchChannel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchChannel = searchChannel;
}
public async Task<Either<BaseError, Unit>> Handle(
AddMediaItemToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddMediaItemRequest(dbContext, parameters));
}
private async Task<Unit> 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<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
AddMediaItemToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateMediaItem(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext,
AddMediaItemToCollection request) =>
dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private static Task<Validation<BaseError, MediaItem>> ValidateMediaItem(
TvContext dbContext,
AddMediaItemToCollection request) =>
dbContext.MediaItems
.SelectOneAsync(m => m.Id, e => e.Id == request.MediaItemId)
.Map(o => o.ToValidation<BaseError>("MediaItem does not exist"));
private sealed record Parameters(Collection Collection, MediaItem MediaItem);
}

3
ErsatzTV.Application/MediaItems/Mapper.cs

@ -32,6 +32,9 @@ internal static class Mapper
internal static NamedMediaItemViewModel ProjectToViewModel(Image image) => internal static NamedMediaItemViewModel ProjectToViewModel(Image image) =>
new(image.Id, image.ImageMetadata.HeadOrNone().Match(i => i.Title, () => "???")); 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) private static string MovieTitle(Movie movie)
{ {
var title = "???"; var title = "???";

6
ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs

@ -56,6 +56,12 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(i => (i as Image).MediaVersions) .Include(i => (i as Image).MediaVersions)
.ThenInclude(mv => mv.Streams) .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) .Include(i => (i as Song).SongMetadata)
.ThenInclude(mv => mv.Subtitles) .ThenInclude(mv => mv.Subtitles)
.Include(i => (i as Song).MediaVersions) .Include(i => (i as Song).MediaVersions)

5
ErsatzTV.Application/MediaItems/Queries/GetRemoteStreamById.cs

@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaItems;
public record GetRemoteStreamById(int RemoteStreamId) : IRequest<Option<RemoteStreamViewModel>>;

19
ErsatzTV.Application/MediaItems/Queries/GetRemoteStreamByIdHandler.cs

@ -0,0 +1,19 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaItems;
public class GetRemoteStreamByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetRemoteStreamById, Option<RemoteStreamViewModel>>
{
public async Task<Option<RemoteStreamViewModel>> Handle(
GetRemoteStreamById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.RemoteStreams
.SelectOneAsync(rs => rs.Id, rs => rs.Id == request.RemoteStreamId)
.MapT(Mapper.ProjectToViewModel);
}
}

3
ErsatzTV.Application/MediaItems/RemoteStreamViewModel.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaItems;
public record RemoteStreamViewModel(int Id, string Url, string Script);

2
ErsatzTV.Application/Playouts/Mapper.cs

@ -75,6 +75,8 @@ internal static class Mapper
.IfNone("[unknown song]"); .IfNone("[unknown song]");
case Image i: case Image i:
return i.ImageMetadata.HeadOrNone().Map(im => im.Title ?? string.Empty).IfNone("[unknown image]"); return i.ImageMetadata.HeadOrNone().Map(im => im.Title ?? string.Empty).IfNone("[unknown image]");
case RemoteStream rs:
return rs.RemoteStreamMetadata.HeadOrNone().Map(im => im.Title ?? string.Empty).IfNone("[unknown remote stream]");
default: default:
return string.Empty; return string.Empty;
} }

4
ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs

@ -61,6 +61,10 @@ public class GetFuturePlayoutItemsByIdHandler : IRequestHandler<GetFuturePlayout
.ThenInclude(mi => (mi as Image).ImageMetadata) .ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).MediaVersions) .ThenInclude(mi => (mi as Image).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
.Filter(i => i.PlayoutId == request.PlayoutId) .Filter(i => i.PlayoutId == request.PlayoutId)
.Filter(i => i.Finish >= now) .Filter(i => i.Finish >= now)
.Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None) .Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None)

2
ErsatzTV.Application/Scheduling/Commands/PreviewBlockPlayoutHandler.cs

@ -81,6 +81,8 @@ public class PreviewBlockPlayoutHandler(
.Include(mi => (mi as Song).MediaVersions) .Include(mi => (mi as Song).MediaVersions)
.Include(mi => (mi as Image).ImageMetadata) .Include(mi => (mi as Image).ImageMetadata)
.Include(mi => (mi as Image).MediaVersions) .Include(mi => (mi as Image).MediaVersions)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Include(mi => (mi as RemoteStream).MediaVersions)
.SelectOneAsync(mi => mi.Id, mi => mi.Id == playoutItem.MediaItemId); .SelectOneAsync(mi => mi.Id, mi => mi.Id == playoutItem.MediaItemId);
foreach (MediaItem mediaItem in maybeMediaItem) foreach (MediaItem mediaItem in maybeMediaItem)

17
ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs

@ -4,17 +4,9 @@ using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search; namespace ErsatzTV.Application.Search;
public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel> public class QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex searchIndex)
: IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
{ {
private readonly IClient _client;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex searchIndex)
{
_client = client;
_searchIndex = searchIndex;
}
public async Task<SearchResultAllItemsViewModel> Handle( public async Task<SearchResultAllItemsViewModel> Handle(
QuerySearchIndexAllItems request, QuerySearchIndexAllItems request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
@ -27,9 +19,10 @@ public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexA
await GetIds(LuceneSearchIndex.MusicVideoType, request.Query), await GetIds(LuceneSearchIndex.MusicVideoType, request.Query),
await GetIds(LuceneSearchIndex.OtherVideoType, request.Query), await GetIds(LuceneSearchIndex.OtherVideoType, request.Query),
await GetIds(LuceneSearchIndex.SongType, request.Query), await GetIds(LuceneSearchIndex.SongType, request.Query),
await GetIds(LuceneSearchIndex.ImageType, request.Query)); await GetIds(LuceneSearchIndex.ImageType, request.Query),
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query));
private async Task<List<int>> GetIds(string type, string query) => private async Task<List<int>> GetIds(string type, string query) =>
(await _searchIndex.Search(_client, $"type:{type} AND ({query})", string.Empty, 0, 0)).Items.Map(i => i.Id) (await searchIndex.Search(client, $"type:{type} AND ({query})", string.Empty, 0, 0)).Items.Map(i => i.Id)
.ToList(); .ToList();
} }

5
ErsatzTV.Application/Search/Queries/QuerySearchIndexRemoteStreams.cs

@ -0,0 +1,5 @@
using ErsatzTV.Application.MediaCards;
namespace ErsatzTV.Application.Search;
public record QuerySearchIndexRemoteStreams(string Query, int PageNumber, int PageSize) : IRequest<RemoteStreamCardResultsViewModel>;

33
ErsatzTV.Application/Search/Queries/QuerySearchIndexRemoteStreamsHandler.cs

@ -0,0 +1,33 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexRemoteStreamsHandler(
IClient client,
ISearchIndex searchIndex,
IRemoteStreamRepository imageRepository)
: IRequestHandler<QuerySearchIndexRemoteStreams, RemoteStreamCardResultsViewModel>
{
public async Task<RemoteStreamCardResultsViewModel> Handle(
QuerySearchIndexRemoteStreams request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<RemoteStreamCardViewModel> items = await imageRepository
.GetRemoteStreamsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new RemoteStreamCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}

3
ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs

@ -9,4 +9,5 @@ public record SearchResultAllItemsViewModel(
List<int> MusicVideoIds, List<int> MusicVideoIds,
List<int> OtherVideoIds, List<int> OtherVideoIds,
List<int> SongIds, List<int> SongIds,
List<int> ImageIds); List<int> ImageIds,
List<int> RemoteStreamIds);

18
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -162,6 +162,15 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata) .ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(i => i.Watermark) .Include(i => i.Watermark)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Include(i => i.Watermark)
.ForChannelAndTime(channel.Id, now) .ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem())) .Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(item => ValidatePlayoutItemPath(dbContext, item)); .BindT(item => ValidatePlayoutItemPath(dbContext, item));
@ -313,7 +322,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItemWithPath.PlayoutItem.SubtitleMode ?? channel.SubtitleMode, playoutItemWithPath.PlayoutItem.SubtitleMode ?? channel.SubtitleMode,
start, start,
finish, finish,
request.StartAtZero ? start : now, effectiveNow,
playoutItemWatermark, playoutItemWatermark,
maybeGlobalWatermark, maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDisplay, channel.FFmpegProfile.VaapiDisplay,
@ -594,6 +603,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
// check filesystem first // check filesystem first
if (_localFileSystem.FileExists(path)) if (_localFileSystem.FileExists(path))
{ {
if (playoutItem.MediaItem is RemoteStream remoteStream)
{
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
? remoteStream.Url
: $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}";
}
return new PlayoutItemWithPath(playoutItem, path); return new PlayoutItemWithPath(playoutItem, path);
} }

36
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -32,11 +33,20 @@ public class PrepareTroubleshootingPlaybackHandler(
{ {
public async Task<Either<BaseError, Command>> Handle(PrepareTroubleshootingPlayback request, CancellationToken cancellationToken) public async Task<Either<BaseError, Command>> Handle(PrepareTroubleshootingPlayback request, CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); try
Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate(dbContext, request); {
return await validation.Match( await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4), Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate(dbContext, request);
error => Task.FromResult<Either<BaseError, Command>>(error.Join())); return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4),
error => Task.FromResult<Either<BaseError, Command>>(error.Join()));
}
catch (Exception ex)
{
entityLocker.UnlockTroubleshootingPlayback();
logger.LogError(ex, "Error while preparing troubleshooting playback");
return BaseError.New(ex.Message);
}
} }
private async Task<Either<BaseError, Command>> GetProcess( private async Task<Either<BaseError, Command>> GetProcess(
@ -78,6 +88,10 @@ public class PrepareTroubleshootingPlaybackHandler(
DateTimeOffset now = DateTimeOffset.Now; DateTimeOffset now = DateTimeOffset.Now;
var duration = TimeSpan.FromSeconds(Math.Min(version.Duration.TotalSeconds, 30)); var duration = TimeSpan.FromSeconds(Math.Min(version.Duration.TotalSeconds, 30));
if (duration <= TimeSpan.Zero)
{
duration = TimeSpan.FromSeconds(30);
}
Command process = await ffmpegProcessService.ForPlayoutItem( Command process = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath, ffmpegPath,
@ -182,6 +196,11 @@ public class PrepareTroubleshootingPlaybackHandler(
.Include(mi => (mi as Image).MediaVersions) .Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Image).ImageMetadata) .Include(mi => (mi as Image).ImageMetadata)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.SelectOneAsync(mi => mi.Id, mi => mi.Id == request.MediaItemId) .SelectOneAsync(mi => mi.Id, mi => mi.Id == request.MediaItemId)
.Map(o => o.ToValidation<BaseError>(new UnableToLocatePlayoutItem())); .Map(o => o.ToValidation<BaseError>(new UnableToLocatePlayoutItem()));
} }
@ -213,6 +232,13 @@ public class PrepareTroubleshootingPlaybackHandler(
// check filesystem first // check filesystem first
if (localFileSystem.FileExists(path)) if (localFileSystem.FileExists(path))
{ {
if (mediaItem is RemoteStream remoteStream)
{
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
? remoteStream.Url
: $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}";
}
return path; return path;
} }

1
ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs

@ -40,6 +40,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
public Task<List<OtherVideo>> GetOtherVideo(int id) => throw new NotSupportedException(); public Task<List<OtherVideo>> GetOtherVideo(int id) => throw new NotSupportedException();
public Task<List<Song>> GetSong(int id) => throw new NotSupportedException(); public Task<List<Song>> GetSong(int id) => throw new NotSupportedException();
public Task<List<Image>> GetImage(int id) => throw new NotSupportedException(); public Task<List<Image>> GetImage(int id) => throw new NotSupportedException();
public Task<List<RemoteStream>> GetRemoteStream(int id) => throw new NotSupportedException();
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) => public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
throw new NotSupportedException(); throw new NotSupportedException();

3
ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs

@ -7,5 +7,6 @@ public enum LibraryMediaKind
MusicVideos = 3, MusicVideos = 3,
OtherVideos = 4, OtherVideos = 4,
Songs = 5, Songs = 5,
Images = 6 Images = 6,
RemoteStreams = 7
} }

14
ErsatzTV.Core/Domain/MediaItem/RemoteStream.cs

@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.Domain;
[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix")]
public class RemoteStream : MediaItem
{
public string Url { get; set; }
public string Script { get; set; }
public TimeSpan? Duration { get; set; }
public string FallbackQuery { get; set; }
public List<RemoteStreamMetadata> RemoteStreamMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}

7
ErsatzTV.Core/Domain/Metadata/RemoteStreamMetadata.cs

@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public class RemoteStreamMetadata : Metadata
{
public int RemoteStreamId { get; set; }
public RemoteStream RemoteStream { get; set; }
}

1
ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs

@ -16,6 +16,7 @@ public enum ProgramScheduleItemCollectionType
OtherVideo = 40, OtherVideo = 40,
Song = 50, Song = 50,
Image = 60, Image = 60,
RemoteStream = 70,
FakeCollection = 100 FakeCollection = 100
} }

1
ErsatzTV.Core/Extensions/MediaItemExtensions.cs

@ -32,6 +32,7 @@ public static class MediaItemExtensions
OtherVideo ov => ov.MediaVersions.Head(), OtherVideo ov => ov.MediaVersions.Head(),
Song s => s.MediaVersions.Head(), Song s => s.MediaVersions.Head(),
Image i => i.MediaVersions.Head(), Image i => i.MediaVersions.Head(),
RemoteStream rs => rs.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) _ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
}; };

1
ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs

@ -13,4 +13,5 @@ public interface IFallbackMetadataProvider
Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo); Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo);
Option<SongMetadata> GetFallbackMetadata(Song song); Option<SongMetadata> GetFallbackMetadata(Song song);
Option<ImageMetadata> GetFallbackMetadata(Image image); Option<ImageMetadata> GetFallbackMetadata(Image image);
Option<RemoteStreamMetadata> GetFallbackMetadata(RemoteStream remoteStream);
} }

1
ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs

@ -24,6 +24,7 @@ public interface IMediaCollectionRepository
Task<List<OtherVideo>> GetOtherVideo(int id); Task<List<OtherVideo>> GetOtherVideo(int id);
Task<List<Song>> GetSong(int id); Task<List<Song>> GetSong(int id);
Task<List<Image>> GetImage(int id); Task<List<Image>> GetImage(int id);
Task<List<RemoteStream>> GetRemoteStream(int id);
Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id); Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id);
Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId); Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId);
Task<List<int>> PlayoutIdsUsingCollection(int collectionId); Task<List<int>> PlayoutIdsUsingCollection(int collectionId);

18
ErsatzTV.Core/Interfaces/Repositories/IRemoteStreamRepository.cs

@ -0,0 +1,18 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IRemoteStreamRepository
{
Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> GetOrAdd(
LibraryPath libraryPath,
LibraryFolder libraryFolder,
string path);
Task<IEnumerable<string>> FindRemoteStreamPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddTag(RemoteStreamMetadata metadata, Tag tag);
Task<List<RemoteStreamMetadata>> GetRemoteStreamsForCards(List<int> ids);
Task UpdateDefinition(RemoteStream remoteStream);
}

76
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -6,12 +6,9 @@ using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Core.Metadata;
public partial class FallbackMetadataProvider : IFallbackMetadataProvider public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadataProvider
{ {
private static readonly Regex SeasonPattern = SeasonNumber(); private static readonly Regex SeasonPattern = SeasonNumber();
private readonly IClient _client;
public FallbackMetadataProvider(IClient client) => _client = client;
public Option<int> GetSeasonNumberForFolder(string folder) public Option<int> GetSeasonNumberForFolder(string folder)
{ {
@ -173,6 +170,25 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
return GetImageMetadata(path, metadata); return GetImageMetadata(path, metadata);
} }
public Option<RemoteStreamMetadata> GetFallbackMetadata(RemoteStream remoteStream)
{
string path = remoteStream.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileNameWithoutExtension(path);
var metadata = new RemoteStreamMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
RemoteStream = remoteStream,
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = []
};
return GetRemoteStreamMetadata(path, metadata);
}
[GeneratedRegex(@"s(?:eason)?\s?(\d+)(?![e\d])", RegexOptions.IgnoreCase)] [GeneratedRegex(@"s(?:eason)?\s?(\d+)(?![e\d])", RegexOptions.IgnoreCase)]
private static partial Regex SeasonNumber(); private static partial Regex SeasonNumber();
@ -235,7 +251,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
} }
catch (Exception ex) catch (Exception ex)
{ {
_client.Notify(ex); client.Notify(ex);
} }
return result; return result;
@ -258,7 +274,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
} }
catch (Exception ex) catch (Exception ex)
{ {
_client.Notify(ex); client.Notify(ex);
} }
return metadata; return metadata;
@ -284,7 +300,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
} }
catch (Exception ex) catch (Exception ex)
{ {
_client.Notify(ex); client.Notify(ex);
return None; return None;
} }
} }
@ -322,7 +338,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
} }
catch (Exception ex) catch (Exception ex)
{ {
_client.Notify(ex); client.Notify(ex);
return None; return None;
} }
} }
@ -360,7 +376,45 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
} }
catch (Exception ex) catch (Exception ex)
{ {
_client.Notify(ex); client.Notify(ex);
return None;
}
}
private Option<RemoteStreamMetadata> GetRemoteStreamMetadata(string path, RemoteStreamMetadata metadata)
{
try
{
string folder = Path.GetDirectoryName(path);
if (folder == null)
{
return None;
}
string libraryPath = metadata.RemoteStream.LibraryPath.Path;
string parent = Optional(Directory.GetParent(libraryPath)).Match(
di => di.FullName,
() => libraryPath);
string diff = Path.GetRelativePath(parent, folder);
var tags = diff.Split(Path.DirectorySeparatorChar)
.Map(t => new Tag { Name = t })
.ToList();
metadata.Artwork = [];
metadata.Actors = [];
metadata.Genres = [];
metadata.Tags = tags;
metadata.Studios = [];
metadata.DateUpdated = DateTime.UtcNow;
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
return metadata;
}
catch (Exception ex)
{
client.Notify(ex);
return None; return None;
} }
} }
@ -398,7 +452,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
} }
catch (Exception ex) catch (Exception ex)
{ {
_client.Notify(ex); client.Notify(ex);
return None; return None;
} }
} }
@ -420,7 +474,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
} }
catch (Exception ex) catch (Exception ex)
{ {
_client.Notify(ex); client.Notify(ex);
} }
return metadata; return metadata;

29
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -5,17 +5,8 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Core.Metadata;
public class LocalFileSystem : ILocalFileSystem public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) : ILocalFileSystem
{ {
private readonly IClient _client;
private readonly ILogger<LocalFileSystem> _logger;
public LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger)
{
_client = client;
_logger = logger;
}
public Unit EnsureFolderExists(string folder) public Unit EnsureFolderExists(string folder)
{ {
try try
@ -27,7 +18,7 @@ public class LocalFileSystem : ILocalFileSystem
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to ensure folder exists at {Folder}", folder); logger.LogWarning(ex, "Failed to ensure folder exists at {Folder}", folder);
} }
return Unit.Default; return Unit.Default;
@ -58,12 +49,12 @@ public class LocalFileSystem : ILocalFileSystem
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
_logger.LogWarning("Unauthorized access exception listing subdirectories of folder {Folder}", folder); logger.LogWarning("Unauthorized access exception listing subdirectories of folder {Folder}", folder);
} }
catch (Exception ex) catch (Exception ex)
{ {
// do nothing // do nothing
_client.Notify(ex); client.Notify(ex);
} }
} }
@ -81,12 +72,12 @@ public class LocalFileSystem : ILocalFileSystem
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
_logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder); logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder);
} }
catch (Exception ex) catch (Exception ex)
{ {
// do nothing // do nothing
_client.Notify(ex); client.Notify(ex);
} }
} }
@ -104,12 +95,12 @@ public class LocalFileSystem : ILocalFileSystem
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
_logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder); logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder);
} }
catch (Exception ex) catch (Exception ex)
{ {
// do nothing // do nothing
_client.Notify(ex); client.Notify(ex);
} }
} }
@ -138,7 +129,7 @@ public class LocalFileSystem : ILocalFileSystem
} }
catch (Exception ex) catch (Exception ex)
{ {
_client.Notify(ex); client.Notify(ex);
return BaseError.New(ex.ToString()); return BaseError.New(ex.ToString());
} }
} }
@ -159,7 +150,7 @@ public class LocalFileSystem : ILocalFileSystem
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to empty folder at {Folder}", folder); logger.LogWarning(ex, "Failed to empty folder at {Folder}", folder);
} }
return Unit.Default; return Unit.Default;

7
ErsatzTV.Core/Scheduling/MediaItemsForCollection.cs

@ -82,6 +82,13 @@ public static class MediaItemsForCollection
result.AddRange(await mediaCollectionRepository.GetImage(mediaItemId)); result.AddRange(await mediaCollectionRepository.GetImage(mediaItemId));
} }
break;
case ProgramScheduleItemCollectionType.RemoteStream:
foreach (int mediaItemId in Optional(collectionKey.MediaItemId))
{
result.AddRange(await mediaCollectionRepository.GetRemoteStream(mediaItemId));
}
break; break;
default: default:
throw new ArgumentOutOfRangeException(nameof(collectionKey)); throw new ArgumentOutOfRangeException(nameof(collectionKey));

9
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -860,6 +860,9 @@ public class PlayoutBuilder : IPlayoutBuilder
Song s => await s.MediaVersions.Map(v => v.Duration).HeadOrNone() Song s => await s.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, .IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
Image => false, Image => false,
RemoteStream rs => await rs.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero
&& (!rs.Duration.HasValue || rs.Duration.Value == TimeSpan.Zero),
_ => true _ => true
}; };
@ -1194,7 +1197,11 @@ public class PlayoutBuilder : IPlayoutBuilder
case Image i: case Image i:
return i.ImageMetadata.HeadOrNone().Match( return i.ImageMetadata.HeadOrNone().Match(
sm => sm.Title ?? string.Empty, sm => sm.Title ?? string.Empty,
() => "[unknown song]"); () => "[unknown image]");
case RemoteStream rs:
return rs.RemoteStreamMetadata.HeadOrNone().Match(
sm => sm.Title ?? string.Empty,
() => "[unknown remote stream]");
default: default:
return string.Empty; return string.Empty;
} }

8
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -207,6 +207,14 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
} }
MediaVersion version = mediaItem.GetHeadVersion(); MediaVersion version = mediaItem.GetHeadVersion();
if (mediaItem is RemoteStream remoteStream)
{
return version.Duration == TimeSpan.Zero && remoteStream.Duration.HasValue
? remoteStream.Duration.Value
: version.Duration;
}
return version.Duration; return version.Duration;
} }

20
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs

@ -47,6 +47,20 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
} }
TimeSpan itemDuration = DurationForMediaItem(mediaItem); TimeSpan itemDuration = DurationForMediaItem(mediaItem);
// never block scheduling when there is only one schedule item (with fixed start and flood)
DateTimeOffset peekScheduleItemStart =
scheduleItem.Id != peekScheduleItem.Id && peekScheduleItem.StartType == StartType.Fixed
? GetStartTimeAfter(nextState with { InFlood = false }, peekScheduleItem)
: DateTimeOffset.MaxValue;
if (itemDuration == TimeSpan.Zero && mediaItem is RemoteStream)
{
itemDuration = itemStartTime != peekScheduleItemStart && peekScheduleItemStart < hardStop
? peekScheduleItemStart - itemStartTime
: hardStop - itemStartTime;
}
List<MediaChapter> itemChapters = ChaptersForMediaItem(mediaItem); List<MediaChapter> itemChapters = ChaptersForMediaItem(mediaItem);
var playoutItem = new PlayoutItem var playoutItem = new PlayoutItem
@ -69,12 +83,6 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
SubtitleMode = scheduleItem.SubtitleMode SubtitleMode = scheduleItem.SubtitleMode
}; };
// never block scheduling when there is only one schedule item (with fixed start and flood)
DateTimeOffset peekScheduleItemStart =
scheduleItem.Id != peekScheduleItem.Id && peekScheduleItem.StartType == StartType.Fixed
? GetStartTimeAfter(nextState with { InFlood = false }, peekScheduleItem)
: DateTimeOffset.MaxValue;
var enumeratorStates = new Dictionary<CollectionKey, CollectionEnumeratorState>(); var enumeratorStates = new Dictionary<CollectionKey, CollectionEnumeratorState>();
foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators) foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{ {

12
ErsatzTV.Core/Streaming/YamlRemoteStreamDefinition.cs

@ -0,0 +1,12 @@
using YamlDotNet.Serialization;
namespace ErsatzTV.Core.Streaming;
public class YamlRemoteStreamDefinition
{
public string Url { get; set; }
public string Script { get; set; }
public string Duration { get; set; }
[YamlMember(Alias = "fallback_query", ApplyNamingConventions = false)]
public string FallbackQuery { get; set; }
}

16
ErsatzTV.FFmpeg/OutputOption/TimeLimitOutputOption.cs

@ -2,16 +2,12 @@
namespace ErsatzTV.FFmpeg.OutputOption; namespace ErsatzTV.FFmpeg.OutputOption;
public class TimeLimitOutputOption : IPipelineStep public class TimeLimitOutputOption(TimeSpan finish) : IPipelineStep
{ {
private readonly TimeSpan _finish; public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public TimeLimitOutputOption(TimeSpan finish) => _finish = finish; public string[] InputOptions(InputFile inputFile) => [];
public string[] FilterOptions => [];
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>(); public string[] OutputOptions => ["-t", $"{((int)finish.TotalHours):00}:{finish:mm}:{finish:ss\\.fffffff}"];
public string[] GlobalOptions => Array.Empty<string>();
public string[] InputOptions(InputFile inputFile) => Array.Empty<string>();
public string[] FilterOptions => Array.Empty<string>();
public string[] OutputOptions => new[] { "-t", $"{_finish:c}" };
public FrameState NextState(FrameState currentState) => currentState; public FrameState NextState(FrameState currentState) => currentState;
} }

6126
ErsatzTV.Infrastructure.MySql/Migrations/20250720030144_Add_RemoteStream.Designer.cs generated

File diff suppressed because it is too large Load Diff

328
ErsatzTV.Infrastructure.MySql/Migrations/20250720030144_Add_RemoteStream.cs

@ -0,0 +1,328 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_RemoteStream : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Tag",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Subtitle",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Studio",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "MetadataGuid",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamId",
table: "MediaVersion",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Genre",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Artwork",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Actor",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "RemoteStream",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RemoteStream", x => x.Id);
table.ForeignKey(
name: "FK_RemoteStream_MediaItem_Id",
column: x => x.Id,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RemoteStreamMetadata",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
RemoteStreamId = table.Column<int>(type: "int", nullable: false),
MetadataKind = table.Column<int>(type: "int", nullable: false),
Title = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
OriginalTitle = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SortTitle = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Year = table.Column<int>(type: "int", nullable: true),
ReleaseDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
DateAdded = table.Column<DateTime>(type: "datetime(6)", nullable: false),
DateUpdated = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RemoteStreamMetadata", x => x.Id);
table.ForeignKey(
name: "FK_RemoteStreamMetadata_RemoteStream_RemoteStreamId",
column: x => x.RemoteStreamId,
principalTable: "RemoteStream",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Tag_RemoteStreamMetadataId",
table: "Tag",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Subtitle_RemoteStreamMetadataId",
table: "Subtitle",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Studio_RemoteStreamMetadataId",
table: "Studio",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_RemoteStreamMetadataId",
table: "MetadataGuid",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MediaVersion_RemoteStreamId",
table: "MediaVersion",
column: "RemoteStreamId");
migrationBuilder.CreateIndex(
name: "IX_Genre_RemoteStreamMetadataId",
table: "Genre",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Artwork_RemoteStreamMetadataId",
table: "Artwork",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Actor_RemoteStreamMetadataId",
table: "Actor",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_RemoteStreamMetadata_RemoteStreamId",
table: "RemoteStreamMetadata",
column: "RemoteStreamId");
migrationBuilder.AddForeignKey(
name: "FK_Actor_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Actor",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Artwork_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Artwork",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Genre_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Genre",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MediaVersion_RemoteStream_RemoteStreamId",
table: "MediaVersion",
column: "RemoteStreamId",
principalTable: "RemoteStream",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MetadataGuid_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "MetadataGuid",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Studio_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Studio",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Subtitle_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Subtitle",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tag_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Tag",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actor_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Actor");
migrationBuilder.DropForeignKey(
name: "FK_Artwork_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Artwork");
migrationBuilder.DropForeignKey(
name: "FK_Genre_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Genre");
migrationBuilder.DropForeignKey(
name: "FK_MediaVersion_RemoteStream_RemoteStreamId",
table: "MediaVersion");
migrationBuilder.DropForeignKey(
name: "FK_MetadataGuid_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "MetadataGuid");
migrationBuilder.DropForeignKey(
name: "FK_Studio_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Studio");
migrationBuilder.DropForeignKey(
name: "FK_Subtitle_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Subtitle");
migrationBuilder.DropForeignKey(
name: "FK_Tag_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Tag");
migrationBuilder.DropTable(
name: "RemoteStreamMetadata");
migrationBuilder.DropTable(
name: "RemoteStream");
migrationBuilder.DropIndex(
name: "IX_Tag_RemoteStreamMetadataId",
table: "Tag");
migrationBuilder.DropIndex(
name: "IX_Subtitle_RemoteStreamMetadataId",
table: "Subtitle");
migrationBuilder.DropIndex(
name: "IX_Studio_RemoteStreamMetadataId",
table: "Studio");
migrationBuilder.DropIndex(
name: "IX_MetadataGuid_RemoteStreamMetadataId",
table: "MetadataGuid");
migrationBuilder.DropIndex(
name: "IX_MediaVersion_RemoteStreamId",
table: "MediaVersion");
migrationBuilder.DropIndex(
name: "IX_Genre_RemoteStreamMetadataId",
table: "Genre");
migrationBuilder.DropIndex(
name: "IX_Artwork_RemoteStreamMetadataId",
table: "Artwork");
migrationBuilder.DropIndex(
name: "IX_Actor_RemoteStreamMetadataId",
table: "Actor");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Tag");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Subtitle");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Studio");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "MetadataGuid");
migrationBuilder.DropColumn(
name: "RemoteStreamId",
table: "MediaVersion");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Genre");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Artwork");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Actor");
}
}
}

6126
ErsatzTV.Infrastructure.MySql/Migrations/20250720030313_Add_LocalLibraryRemoteStream.Designer.cs generated

File diff suppressed because it is too large Load Diff

27
ErsatzTV.Infrastructure.MySql/Migrations/20250720030313_Add_LocalLibraryRemoteStream.cs

@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_LocalLibraryRemoteStream : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// create local images library
migrationBuilder.Sql(
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
SELECT 'Remote Streams', 7, Id FROM
(SELECT LMS.Id FROM LocalMediaSource LMS LIMIT 1) AS A");
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_id())");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6138
ErsatzTV.Infrastructure.MySql/Migrations/20250720135059_Add_RemoteStreamFields.Designer.cs generated

File diff suppressed because it is too large Load Diff

62
ErsatzTV.Infrastructure.MySql/Migrations/20250720135059_Add_RemoteStreamFields.cs

@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_RemoteStreamFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<TimeSpan>(
name: "Duration",
table: "RemoteStream",
type: "time(6)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FallbackQuery",
table: "RemoteStream",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "Script",
table: "RemoteStream",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "Url",
table: "RemoteStream",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Duration",
table: "RemoteStream");
migrationBuilder.DropColumn(
name: "FallbackQuery",
table: "RemoteStream");
migrationBuilder.DropColumn(
name: "Script",
table: "RemoteStream");
migrationBuilder.DropColumn(
name: "Url",
table: "RemoteStream");
}
}
}

185
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -57,6 +57,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<string>("Role") b.Property<string>("Role")
.HasColumnType("longtext"); .HasColumnType("longtext");
@ -86,6 +89,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -199,6 +204,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Path") b.Property<string>("Path")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("int"); .HasColumnType("int");
@ -227,6 +235,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -775,6 +785,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("int"); .HasColumnType("int");
@ -798,6 +811,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -1266,6 +1281,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("RFrameRate") b.Property<string>("RFrameRate")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<int?>("RemoteStreamId")
.HasColumnType("int");
b.Property<string>("SampleAspectRatio") b.Property<string>("SampleAspectRatio")
.HasColumnType("longtext"); .HasColumnType("longtext");
@ -1290,6 +1308,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoId"); b.HasIndex("OtherVideoId");
b.HasIndex("RemoteStreamId");
b.HasIndex("SongId"); b.HasIndex("SongId");
b.ToTable("MediaVersion", (string)null); b.ToTable("MediaVersion", (string)null);
@ -1324,6 +1344,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("int"); .HasColumnType("int");
@ -1347,6 +1370,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -2171,6 +2196,48 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.UseTptMappingStrategy(); b.UseTptMappingStrategy();
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStreamMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DateUpdated")
.HasColumnType("datetime(6)");
b.Property<int>("MetadataKind")
.HasColumnType("int");
b.Property<string>("OriginalTitle")
.HasColumnType("longtext");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("datetime(6)");
b.Property<int>("RemoteStreamId")
.HasColumnType("int");
b.Property<string>("SortTitle")
.HasColumnType("longtext");
b.Property<string>("Title")
.HasColumnType("longtext");
b.Property<int?>("Year")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RemoteStreamId");
b.ToTable("RemoteStreamMetadata", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -2864,6 +2931,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("int"); .HasColumnType("int");
@ -2887,6 +2957,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -2969,6 +3041,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Path") b.Property<string>("Path")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<bool>("SDH") b.Property<bool>("SDH")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
@ -3006,6 +3081,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -3050,6 +3127,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("int"); .HasColumnType("int");
@ -3073,6 +3153,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -3388,6 +3470,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("OtherVideo", (string)null); b.ToTable("OtherVideo", (string)null);
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStream", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<TimeSpan?>("Duration")
.HasColumnType("time(6)");
b.Property<string>("FallbackQuery")
.HasColumnType("longtext");
b.Property<string>("Script")
.HasColumnType("longtext");
b.Property<string>("Url")
.HasColumnType("longtext");
b.ToTable("RemoteStream", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{ {
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -3730,6 +3831,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Actors")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Actors") .WithMany("Actors")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -3796,6 +3902,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Artwork")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Artwork") .WithMany("Artwork")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -3986,6 +4097,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Genres")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres") .WithMany("Genres")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -4163,6 +4279,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoId") .HasForeignKey("OtherVideoId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStream", null)
.WithMany("MediaVersions")
.HasForeignKey("RemoteStreamId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Song", null) b.HasOne("ErsatzTV.Core.Domain.Song", null)
.WithMany("MediaVersions") .WithMany("MediaVersions")
.HasForeignKey("SongId") .HasForeignKey("SongId")
@ -4201,6 +4322,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Guids")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Guids") .WithMany("Guids")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -4697,6 +4823,17 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Watermark"); b.Navigation("Watermark");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStreamMetadata", b =>
{
b.HasOne("ErsatzTV.Core.Domain.RemoteStream", "RemoteStream")
.WithMany("RemoteStreamMetadata")
.HasForeignKey("RemoteStreamId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RemoteStream");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Block", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Block", b =>
{ {
b.HasOne("ErsatzTV.Core.Domain.Scheduling.BlockGroup", "BlockGroup") b.HasOne("ErsatzTV.Core.Domain.Scheduling.BlockGroup", "BlockGroup")
@ -4986,6 +5123,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Studios")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios") .WithMany("Studios")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -5042,6 +5184,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Subtitles") .WithMany("Subtitles")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -5090,6 +5237,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Tags")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Tags") .WithMany("Tags")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -5294,6 +5446,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStream", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.RemoteStream", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{ {
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@ -5737,6 +5898,23 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("ProgramScheduleAlternates"); b.Navigation("ProgramScheduleAlternates");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStreamMetadata", b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Block", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Block", b =>
{ {
b.Navigation("Items"); b.Navigation("Items");
@ -5903,6 +6081,13 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("OtherVideoMetadata"); b.Navigation("OtherVideoMetadata");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStream", b =>
{
b.Navigation("MediaVersions");
b.Navigation("RemoteStreamMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{ {
b.Navigation("Episodes"); b.Navigation("Episodes");

5963
ErsatzTV.Infrastructure.Sqlite/Migrations/20250720030204_Add_RemoteStream.Designer.cs generated

File diff suppressed because it is too large Load Diff

323
ErsatzTV.Infrastructure.Sqlite/Migrations/20250720030204_Add_RemoteStream.cs

@ -0,0 +1,323 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_RemoteStream : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Tag",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Subtitle",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Studio",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "MetadataGuid",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamId",
table: "MediaVersion",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Genre",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Artwork",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RemoteStreamMetadataId",
table: "Actor",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "RemoteStream",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_RemoteStream", x => x.Id);
table.ForeignKey(
name: "FK_RemoteStream_MediaItem_Id",
column: x => x.Id,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RemoteStreamMetadata",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RemoteStreamId = table.Column<int>(type: "INTEGER", nullable: false),
MetadataKind = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", nullable: true),
OriginalTitle = table.Column<string>(type: "TEXT", nullable: true),
SortTitle = table.Column<string>(type: "TEXT", nullable: true),
Year = table.Column<int>(type: "INTEGER", nullable: true),
ReleaseDate = table.Column<DateTime>(type: "TEXT", nullable: true),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: false),
DateUpdated = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RemoteStreamMetadata", x => x.Id);
table.ForeignKey(
name: "FK_RemoteStreamMetadata_RemoteStream_RemoteStreamId",
column: x => x.RemoteStreamId,
principalTable: "RemoteStream",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Tag_RemoteStreamMetadataId",
table: "Tag",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Subtitle_RemoteStreamMetadataId",
table: "Subtitle",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Studio_RemoteStreamMetadataId",
table: "Studio",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_RemoteStreamMetadataId",
table: "MetadataGuid",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MediaVersion_RemoteStreamId",
table: "MediaVersion",
column: "RemoteStreamId");
migrationBuilder.CreateIndex(
name: "IX_Genre_RemoteStreamMetadataId",
table: "Genre",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Artwork_RemoteStreamMetadataId",
table: "Artwork",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Actor_RemoteStreamMetadataId",
table: "Actor",
column: "RemoteStreamMetadataId");
migrationBuilder.CreateIndex(
name: "IX_RemoteStreamMetadata_RemoteStreamId",
table: "RemoteStreamMetadata",
column: "RemoteStreamId");
migrationBuilder.AddForeignKey(
name: "FK_Actor_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Actor",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Artwork_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Artwork",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Genre_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Genre",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MediaVersion_RemoteStream_RemoteStreamId",
table: "MediaVersion",
column: "RemoteStreamId",
principalTable: "RemoteStream",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MetadataGuid_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "MetadataGuid",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Studio_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Studio",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Subtitle_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Subtitle",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tag_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Tag",
column: "RemoteStreamMetadataId",
principalTable: "RemoteStreamMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actor_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Actor");
migrationBuilder.DropForeignKey(
name: "FK_Artwork_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Artwork");
migrationBuilder.DropForeignKey(
name: "FK_Genre_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Genre");
migrationBuilder.DropForeignKey(
name: "FK_MediaVersion_RemoteStream_RemoteStreamId",
table: "MediaVersion");
migrationBuilder.DropForeignKey(
name: "FK_MetadataGuid_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "MetadataGuid");
migrationBuilder.DropForeignKey(
name: "FK_Studio_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Studio");
migrationBuilder.DropForeignKey(
name: "FK_Subtitle_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Subtitle");
migrationBuilder.DropForeignKey(
name: "FK_Tag_RemoteStreamMetadata_RemoteStreamMetadataId",
table: "Tag");
migrationBuilder.DropTable(
name: "RemoteStreamMetadata");
migrationBuilder.DropTable(
name: "RemoteStream");
migrationBuilder.DropIndex(
name: "IX_Tag_RemoteStreamMetadataId",
table: "Tag");
migrationBuilder.DropIndex(
name: "IX_Subtitle_RemoteStreamMetadataId",
table: "Subtitle");
migrationBuilder.DropIndex(
name: "IX_Studio_RemoteStreamMetadataId",
table: "Studio");
migrationBuilder.DropIndex(
name: "IX_MetadataGuid_RemoteStreamMetadataId",
table: "MetadataGuid");
migrationBuilder.DropIndex(
name: "IX_MediaVersion_RemoteStreamId",
table: "MediaVersion");
migrationBuilder.DropIndex(
name: "IX_Genre_RemoteStreamMetadataId",
table: "Genre");
migrationBuilder.DropIndex(
name: "IX_Artwork_RemoteStreamMetadataId",
table: "Artwork");
migrationBuilder.DropIndex(
name: "IX_Actor_RemoteStreamMetadataId",
table: "Actor");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Tag");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Subtitle");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Studio");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "MetadataGuid");
migrationBuilder.DropColumn(
name: "RemoteStreamId",
table: "MediaVersion");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Genre");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Artwork");
migrationBuilder.DropColumn(
name: "RemoteStreamMetadataId",
table: "Actor");
}
}
}

5963
ErsatzTV.Infrastructure.Sqlite/Migrations/20250720030246_Add_LocalLibraryRemoteStream.Designer.cs generated

File diff suppressed because it is too large Load Diff

27
ErsatzTV.Infrastructure.Sqlite/Migrations/20250720030246_Add_LocalLibraryRemoteStream.cs

@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_LocalLibraryRemoteStream : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// create local remote streams library
migrationBuilder.Sql(
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
SELECT 'Remote Streams', 7, Id FROM
(SELECT LMS.Id FROM LocalMediaSource LMS LIMIT 1)");
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_rowid())");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

5975
ErsatzTV.Infrastructure.Sqlite/Migrations/20250720135252_Add_RemoteStreamFields.Designer.cs generated

File diff suppressed because it is too large Load Diff

59
ErsatzTV.Infrastructure.Sqlite/Migrations/20250720135252_Add_RemoteStreamFields.cs

@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_RemoteStreamFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<TimeSpan>(
name: "Duration",
table: "RemoteStream",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FallbackQuery",
table: "RemoteStream",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Script",
table: "RemoteStream",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Url",
table: "RemoteStream",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Duration",
table: "RemoteStream");
migrationBuilder.DropColumn(
name: "FallbackQuery",
table: "RemoteStream");
migrationBuilder.DropColumn(
name: "Script",
table: "RemoteStream");
migrationBuilder.DropColumn(
name: "Url",
table: "RemoteStream");
}
}
}

183
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -50,6 +50,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Role") b.Property<string>("Role")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -79,6 +82,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -188,6 +193,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Path") b.Property<string>("Path")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -216,6 +224,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -740,6 +750,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -763,6 +776,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -1201,6 +1216,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("RFrameRate") b.Property<string>("RFrameRate")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("RemoteStreamId")
.HasColumnType("INTEGER");
b.Property<string>("SampleAspectRatio") b.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -1225,6 +1243,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoId"); b.HasIndex("OtherVideoId");
b.HasIndex("RemoteStreamId");
b.HasIndex("SongId"); b.HasIndex("SongId");
b.ToTable("MediaVersion", (string)null); b.ToTable("MediaVersion", (string)null);
@ -1257,6 +1277,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -1280,6 +1303,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -2066,6 +2091,46 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.UseTptMappingStrategy(); b.UseTptMappingStrategy();
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStreamMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<int>("RemoteStreamId")
.HasColumnType("INTEGER");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RemoteStreamId");
b.ToTable("RemoteStreamMetadata", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -2721,6 +2786,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -2744,6 +2812,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -2822,6 +2892,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Path") b.Property<string>("Path")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<bool>("SDH") b.Property<bool>("SDH")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@ -2859,6 +2932,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -2901,6 +2976,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId") b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId") b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -2924,6 +3002,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId"); b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -3227,6 +3307,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("OtherVideo", (string)null); b.ToTable("OtherVideo", (string)null);
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStream", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<TimeSpan?>("Duration")
.HasColumnType("TEXT");
b.Property<string>("FallbackQuery")
.HasColumnType("TEXT");
b.Property<string>("Script")
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasColumnType("TEXT");
b.ToTable("RemoteStream", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{ {
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -3569,6 +3668,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Actors")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Actors") .WithMany("Actors")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -3635,6 +3739,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Artwork")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Artwork") .WithMany("Artwork")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -3825,6 +3934,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Genres")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres") .WithMany("Genres")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -4002,6 +4116,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoId") .HasForeignKey("OtherVideoId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStream", null)
.WithMany("MediaVersions")
.HasForeignKey("RemoteStreamId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Song", null) b.HasOne("ErsatzTV.Core.Domain.Song", null)
.WithMany("MediaVersions") .WithMany("MediaVersions")
.HasForeignKey("SongId") .HasForeignKey("SongId")
@ -4040,6 +4159,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Guids")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Guids") .WithMany("Guids")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -4536,6 +4660,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Watermark"); b.Navigation("Watermark");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStreamMetadata", b =>
{
b.HasOne("ErsatzTV.Core.Domain.RemoteStream", "RemoteStream")
.WithMany("RemoteStreamMetadata")
.HasForeignKey("RemoteStreamId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RemoteStream");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Block", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Block", b =>
{ {
b.HasOne("ErsatzTV.Core.Domain.Scheduling.BlockGroup", "BlockGroup") b.HasOne("ErsatzTV.Core.Domain.Scheduling.BlockGroup", "BlockGroup")
@ -4825,6 +4960,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Studios")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios") .WithMany("Studios")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -4881,6 +5021,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Subtitles") .WithMany("Subtitles")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -4929,6 +5074,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId") .HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Tags")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Tags") .WithMany("Tags")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -5133,6 +5283,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStream", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.RemoteStream", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{ {
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@ -5576,6 +5735,23 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("ProgramScheduleAlternates"); b.Navigation("ProgramScheduleAlternates");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStreamMetadata", b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Block", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Block", b =>
{ {
b.Navigation("Items"); b.Navigation("Items");
@ -5742,6 +5918,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("OtherVideoMetadata"); b.Navigation("OtherVideoMetadata");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStream", b =>
{
b.Navigation("MediaVersions");
b.Navigation("RemoteStreamMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{ {
b.Navigation("Episodes"); b.Navigation("Episodes");

22
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/RemoteStreamConfiguration.cs

@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class RemoteStreamConfiguration : IEntityTypeConfiguration<RemoteStream>
{
public void Configure(EntityTypeBuilder<RemoteStream> builder)
{
builder.ToTable("RemoteStream");
builder.HasMany(i => i.RemoteStreamMetadata)
.WithOne(m => m.RemoteStream)
.HasForeignKey(m => m.RemoteStreamId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(i => i.MediaVersions)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}

41
ErsatzTV.Infrastructure/Data/Configurations/Metadata/RemoteStreamMetadataConfiguration.cs

@ -0,0 +1,41 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class RemoteStreamMetadataConfiguration : IEntityTypeConfiguration<RemoteStreamMetadata>
{
public void Configure(EntityTypeBuilder<RemoteStreamMetadata> builder)
{
builder.ToTable("RemoteStreamMetadata");
builder.HasMany(sm => sm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Studios)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Actors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Subtitles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}

67
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -56,6 +56,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
mediaItems.AddRange(await GetOtherVideoItems(dbContext, collectionId)); mediaItems.AddRange(await GetOtherVideoItems(dbContext, collectionId));
mediaItems.AddRange(await GetSongItems(dbContext, collectionId)); mediaItems.AddRange(await GetSongItems(dbContext, collectionId));
mediaItems.AddRange(await GetImageItems(dbContext, collectionId)); mediaItems.AddRange(await GetImageItems(dbContext, collectionId));
mediaItems.AddRange(await GetRemoteStreamItems(dbContext, collectionId));
} }
break; break;
@ -146,6 +147,14 @@ public class MediaCollectionRepository : IMediaCollectionRepository
mediaItems.AddRange(await GetImageItems(dbContext, [mediaItemId])); mediaItems.AddRange(await GetImageItems(dbContext, [mediaItemId]));
} }
break;
case ProgramScheduleItemCollectionType.RemoteStream:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
mediaItems.AddRange(await GetRemoteStreamItems(dbContext, [mediaItemId]));
}
break; break;
} }
@ -194,6 +203,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
mediaItems.AddRange(await GetOtherVideoItems(dbContext, collectionId)); mediaItems.AddRange(await GetOtherVideoItems(dbContext, collectionId));
mediaItems.AddRange(await GetSongItems(dbContext, collectionId)); mediaItems.AddRange(await GetSongItems(dbContext, collectionId));
mediaItems.AddRange(await GetImageItems(dbContext, collectionId)); mediaItems.AddRange(await GetImageItems(dbContext, collectionId));
mediaItems.AddRange(await GetRemoteStreamItems(dbContext, collectionId));
} }
break; break;
@ -284,6 +294,14 @@ public class MediaCollectionRepository : IMediaCollectionRepository
mediaItems.AddRange(await GetImageItems(dbContext, [mediaItemId])); mediaItems.AddRange(await GetImageItems(dbContext, [mediaItemId]));
} }
break;
case ProgramScheduleItemCollectionType.RemoteStream:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
mediaItems.AddRange(await GetRemoteStreamItems(dbContext, [mediaItemId]));
}
break; break;
} }
@ -318,6 +336,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetOtherVideoItems(dbContext, id)); result.AddRange(await GetOtherVideoItems(dbContext, id));
result.AddRange(await GetSongItems(dbContext, id)); result.AddRange(await GetSongItems(dbContext, id));
result.AddRange(await GetImageItems(dbContext, id)); result.AddRange(await GetImageItems(dbContext, id));
result.AddRange(await GetRemoteStreamItems(dbContext, id));
return result.Distinct().ToList(); return result.Distinct().ToList();
} }
@ -361,6 +380,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetOtherVideoItems(dbContext, collectionId)); result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
result.AddRange(await GetSongItems(dbContext, collectionId)); result.AddRange(await GetSongItems(dbContext, collectionId));
result.AddRange(await GetImageItems(dbContext, collectionId)); result.AddRange(await GetImageItems(dbContext, collectionId));
result.AddRange(await GetRemoteStreamItems(dbContext, collectionId));
} }
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id)) foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
@ -483,6 +503,12 @@ public class MediaCollectionRepository : IMediaCollectionRepository
.ToList(); .ToList();
result.AddRange(await GetImageItems(dbContext, imageIds)); result.AddRange(await GetImageItems(dbContext, imageIds));
var remoteStreamIds = searchResults.Items
.Filter(i => i.Type == LuceneSearchIndex.RemoteStreamType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetRemoteStreamItems(dbContext, remoteStreamIds));
return result.DistinctBy(x => x.Id).ToList(); return result.DistinctBy(x => x.Id).ToList();
} }
@ -636,6 +662,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetOtherVideoItems(dbContext, collectionId)); result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
result.AddRange(await GetSongItems(dbContext, collectionId)); result.AddRange(await GetSongItems(dbContext, collectionId));
result.AddRange(await GetImageItems(dbContext, collectionId)); result.AddRange(await GetImageItems(dbContext, collectionId));
result.AddRange(await GetRemoteStreamItems(dbContext, collectionId));
} }
break; break;
@ -726,6 +753,14 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetImageItems(dbContext, [mediaItemId])); result.AddRange(await GetImageItems(dbContext, [mediaItemId]));
} }
break;
case ProgramScheduleItemCollectionType.RemoteStream:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
result.AddRange(await GetRemoteStreamItems(dbContext, [mediaItemId]));
}
break; break;
} }
} }
@ -769,6 +804,12 @@ public class MediaCollectionRepository : IMediaCollectionRepository
return await GetImageItems(dbContext, [id]); return await GetImageItems(dbContext, [id]);
} }
public async Task<List<RemoteStream>> GetRemoteStream(int id)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await GetRemoteStreamItems(dbContext, [id]);
}
public async Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections( public async Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(
int? collectionId, int? collectionId,
int? smartCollectionId) int? smartCollectionId)
@ -1136,7 +1177,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
return await GetImageItems(dbContext, ids); return await GetImageItems(dbContext, ids);
} }
private static Task<List<Image>> GetImageItems(TvContext dbContext, IEnumerable<int> songIds) => private static Task<List<Image>> GetImageItems(TvContext dbContext, IEnumerable<int> imageIds) =>
dbContext.Images dbContext.Images
.Include(m => m.ImageMetadata) .Include(m => m.ImageMetadata)
.ThenInclude(im => im.Subtitles) .ThenInclude(im => im.Subtitles)
@ -1144,7 +1185,29 @@ public class MediaCollectionRepository : IMediaCollectionRepository
.ThenInclude(mv => mv.Chapters) .ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Filter(m => songIds.Contains(m.Id)) .Filter(m => imageIds.Contains(m.Id))
.ToListAsync();
private static async Task<List<RemoteStream>> GetRemoteStreamItems(TvContext dbContext, int collectionId)
{
IEnumerable<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT i.Id FROM CollectionItem ci
INNER JOIN RemoteStream i ON i.Id = ci.MediaItemId
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await GetRemoteStreamItems(dbContext, ids);
}
private static Task<List<RemoteStream>> GetRemoteStreamItems(TvContext dbContext, IEnumerable<int> remoteStreamIds) =>
dbContext.RemoteStreams
.Include(m => m.RemoteStreamMetadata)
.ThenInclude(im => im.Subtitles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => remoteStreamIds.Contains(m.Id))
.ToListAsync(); .ToListAsync();
private static async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId) private static async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId)

6
ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs

@ -86,7 +86,7 @@ public class MediaItemRepository : IMediaItemRepository
List<int> ids = await dbContext.Connection.QueryAsync<int>( List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id @"SELECT M.Id
FROM MediaItem M FROM MediaItem M
INNER JOIN MediaVersion MV on M.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, ImageId, EpisodeId) INNER JOIN MediaVersion MV on M.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, ImageId, RemoteStreamId, EpisodeId)
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE M.LibraryPathId = @LibraryPathId AND MF.Path = @Path", WHERE M.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }) new { LibraryPathId = libraryPath.Id, Path = path })
@ -105,7 +105,7 @@ public class MediaItemRepository : IMediaItemRepository
return await dbContext.Connection.QueryAsync<string>( return await dbContext.Connection.QueryAsync<string>(
@"SELECT MF.Path @"SELECT MF.Path
FROM MediaItem M FROM MediaItem M
INNER JOIN MediaVersion MV on M.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId, ImageId) INNER JOIN MediaVersion MV on M.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId, ImageId, RemoteStreamId)
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE M.State IN (1,2) AND M.LibraryPathId = @LibraryPathId", WHERE M.State IN (1,2) AND M.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id }) new { LibraryPathId = libraryPath.Id })
@ -155,7 +155,7 @@ public class MediaItemRepository : IMediaItemRepository
{ {
Option<int> maybeMediaItemId = await dbContext.Connection Option<int> maybeMediaItemId = await dbContext.Connection
.QuerySingleOrDefaultAsync<int?>( .QuerySingleOrDefaultAsync<int?>(
@"select coalesce(EpisodeId, MovieId, MusicVideoId, OtherVideoId, SongId) as MediaItemId @"select coalesce(EpisodeId, MovieId, MusicVideoId, OtherVideoId, SongId, ImageId, RemoteStreamId) as MediaItemId
from MediaVersion MV from MediaVersion MV
inner join MediaFile MF on MV.Id = MF.MediaVersionId inner join MediaFile MF on MV.Id = MF.MediaVersionId
where MF.Path = @Path", where MF.Path = @Path",

172
ErsatzTV.Infrastructure/Data/Repositories/RemoteStreamRepository.cs

@ -0,0 +1,172 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class RemoteStreamRepository(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<RemoteStreamRepository> logger)
: IRemoteStreamRepository
{
public async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> GetOrAdd(
LibraryPath libraryPath,
LibraryFolder libraryFolder,
string path)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
Option<RemoteStream> maybeExisting = await dbContext.RemoteStreams
.AsNoTracking()
.Include(i => i.RemoteStreamMetadata)
.ThenInclude(ovm => ovm.Genres)
.Include(i => i.RemoteStreamMetadata)
.ThenInclude(ovm => ovm.Tags)
.Include(i => i.RemoteStreamMetadata)
.ThenInclude(ovm => ovm.Studios)
.Include(i => i.RemoteStreamMetadata)
.ThenInclude(ovm => ovm.Guids)
.Include(i => i.RemoteStreamMetadata)
.ThenInclude(ovm => ovm.Actors)
.Include(i => i.RemoteStreamMetadata)
.ThenInclude(ovm => ovm.Actors)
.ThenInclude(a => a.Artwork)
.Include(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ov => ov.MediaVersions)
.ThenInclude(ov => ov.MediaFiles)
.Include(ov => ov.MediaVersions)
.ThenInclude(ov => ov.Streams)
.Include(ov => ov.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
return await maybeExisting.Match(
mediaItem =>
Right<BaseError, MediaItemScanResult<RemoteStream>>(
new MediaItemScanResult<RemoteStream>(mediaItem) { IsAdded = false }).AsTask(),
async () => await AddRemoteStream(dbContext, libraryPath.Id, libraryFolder.Id, path));
}
public async Task<IEnumerable<string>> FindRemoteStreamPaths(LibraryPath libraryPath)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN RemoteStream O on MV.RemoteStreamId = O.Id
INNER JOIN MediaItem MI on O.Id = MI.Id
WHERE MI.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id });
}
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT O.Id
FROM RemoteStream O
INNER JOIN MediaItem MI on O.Id = MI.Id
INNER JOIN MediaVersion MV on O.Id = MV.RemoteStreamId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
foreach (int remoteStreamId in ids)
{
RemoteStream remoteStream = await dbContext.RemoteStreams.FindAsync(remoteStreamId);
if (remoteStream != null)
{
dbContext.RemoteStreams.Remove(remoteStream);
}
}
await dbContext.SaveChangesAsync();
return ids;
}
public async Task<bool> AddTag(RemoteStreamMetadata metadata, Tag tag)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, RemoteStreamMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
}
public async Task<List<RemoteStreamMetadata>> GetRemoteStreamsForCards(List<int> ids)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.RemoteStreamMetadata
.AsNoTracking()
.Filter(im => ids.Contains(im.RemoteStreamId))
.Include(im => im.RemoteStream)
.Include(im => im.Artwork)
.Include(im => im.RemoteStream)
.ThenInclude(s => s.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(im => im.SortTitle)
.ToListAsync();
}
public async Task UpdateDefinition(RemoteStream remoteStream)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
await dbContext.RemoteStreams
.Where(rs => rs.Id == remoteStream.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(rs => rs.Url, remoteStream.Url)
.SetProperty(rs => rs.Script, remoteStream.Script)
.SetProperty(rs => rs.Duration, remoteStream.Duration)
.SetProperty(rs => rs.FallbackQuery, remoteStream.FallbackQuery));
}
private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> AddRemoteStream(
TvContext dbContext,
int libraryPathId,
int libraryFolderId,
string path)
{
try
{
if (await MediaItemRepository.MediaFileAlreadyExists(path, libraryPathId, dbContext, logger))
{
return new MediaFileAlreadyExists();
}
var remoteStream = new RemoteStream
{
LibraryPathId = libraryPathId,
MediaVersions =
[
new MediaVersion
{
MediaFiles = [new MediaFile { Path = path, LibraryFolderId = libraryFolderId }],
Streams = []
}
],
TraktListItems = new List<TraktListItem>
{
Capacity = 0
}
};
await dbContext.RemoteStreams.AddAsync(remoteStream);
await dbContext.SaveChangesAsync();
await dbContext.Entry(remoteStream).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(remoteStream.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<RemoteStream>(remoteStream) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}

20
ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs

@ -151,6 +151,16 @@ public class SearchRepository : ISearchRepository
.ThenInclude(mm => mm.Streams) .ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Image).MediaVersions) .Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mm => mm.MediaFiles) .ThenInclude(mm => mm.MediaFiles)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.ThenInclude(mm => mm.Guids)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mm => mm.MediaFiles)
.Include(mi => mi.TraktListItems) .Include(mi => mi.TraktListItems)
.ThenInclude(tli => tli.TraktList) .ThenInclude(tli => tli.TraktList)
.SelectOneAsync(mi => mi.Id, mi => mi.Id == id); .SelectOneAsync(mi => mi.Id, mi => mi.Id == id);
@ -377,6 +387,16 @@ public class SearchRepository : ISearchRepository
.ThenInclude(mm => mm.Streams) .ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Image).MediaVersions) .Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mm => mm.MediaFiles) .ThenInclude(mm => mm.MediaFiles)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
.ThenInclude(mm => mm.Guids)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mm => mm.MediaFiles)
.Include(mi => mi.TraktListItems) .Include(mi => mi.TraktListItems)
.ThenInclude(tli => tli.TraktList) .ThenInclude(tli => tli.TraktList)
.AsAsyncEnumerable(); .AsAsyncEnumerable();

2
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -56,6 +56,8 @@ public class TvContext : DbContext
public DbSet<Image> Images { get; set; } public DbSet<Image> Images { get; set; }
public DbSet<ImageMetadata> ImageMetadata { get; set; } public DbSet<ImageMetadata> ImageMetadata { get; set; }
public DbSet<ImageFolderDuration> ImageFolderDurations { get; set; } public DbSet<ImageFolderDuration> ImageFolderDurations { get; set; }
public DbSet<RemoteStream> RemoteStreams { get; set; }
public DbSet<RemoteStreamMetadata> RemoteStreamMetadata { get; set; }
public DbSet<Show> Shows { get; set; } public DbSet<Show> Shows { get; set; }
public DbSet<ShowMetadata> ShowMetadata { get; set; } public DbSet<ShowMetadata> ShowMetadata { get; set; }
public DbSet<Season> Seasons { get; set; } public DbSet<Season> Seasons { get; set; }

4
ErsatzTV.Infrastructure/Health/Checks/FileNotFoundHealthCheck.cs

@ -32,6 +32,10 @@ public class FileNotFoundHealthCheck : BaseHealthCheck, IFileNotFoundHealthCheck
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Song).MediaVersions) .Include(mi => (mi as Song).MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as RemoteStream).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Show).ShowMetadata) .Include(mi => (mi as Show).ShowMetadata)
.Include(mi => (mi as Season).Show) .Include(mi => (mi as Season).Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)

24
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -51,7 +51,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
{ {
try try
{ {
string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path; string filePath = await PathForMediaItem(mediaItem);
return await RefreshStatistics(ffmpegPath, ffprobePath, mediaItem, filePath); return await RefreshStatistics(ffmpegPath, ffprobePath, mediaItem, filePath);
} }
catch (Exception ex) catch (Exception ex)
@ -126,7 +126,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
async ffprobe => async ffprobe =>
{ {
MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe); MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe);
if (mediaItem is not Image && version.Duration.TotalSeconds < 1) if (mediaItem is not Image and not RemoteStream && version.Duration.TotalSeconds < 1)
{ {
await AnalyzeDuration(ffmpegPath, mediaItemPath, version); await AnalyzeDuration(ffmpegPath, mediaItemPath, version);
} }
@ -158,14 +158,16 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
private static async Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath) private static async Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
{ {
string[] arguments = string[] arguments =
{ [
"-hide_banner", "-hide_banner",
"-print_format", "json", "-print_format", "json",
"-show_format", "-show_format",
"-show_streams", "-show_streams",
"-show_chapters", "-show_chapters",
"-i", filePath "-i", filePath
}; ];
//_logger.LogDebug("ffprobe arguments {FFProbeArguments}", arguments);
BufferedCommandResult probe = await Cli.Wrap(ffprobePath) BufferedCommandResult probe = await Cli.Wrap(ffprobePath)
.WithArguments(arguments) .WithArguments(arguments)
@ -478,6 +480,20 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
_ => VideoScanKind.Unknown _ => VideoScanKind.Unknown
}; };
private static Task<string> PathForMediaItem(MediaItem mediaItem)
{
string path = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
if (mediaItem is RemoteStream remoteStream)
{
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
? remoteStream.Url
: $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}";
}
return Task.FromResult(path);
}
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
public record FFprobe(FFprobeFormat format, List<FFprobeStreamData> streams, List<FFprobeChapter> chapters); public record FFprobe(FFprobeFormat format, List<FFprobeStreamData> streams, List<FFprobeChapter> chapters);

81
ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs

@ -89,6 +89,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
public const string OtherVideoType = "other_video"; public const string OtherVideoType = "other_video";
public const string SongType = "song"; public const string SongType = "song";
public const string ImageType = "image"; public const string ImageType = "image";
public const string RemoteStreamType = "remote_stream";
private readonly string _cleanShutdownPath; private readonly string _cleanShutdownPath;
private readonly List<CultureInfo> _cultureInfos; private readonly List<CultureInfo> _cultureInfos;
@ -187,6 +188,9 @@ public sealed class LuceneSearchIndex : ISearchIndex
case Image image: case Image image:
await UpdateImage(searchRepository, image); await UpdateImage(searchRepository, image);
break; break;
case RemoteStream remoteStream:
await UpdateRemoteStream(searchRepository, remoteStream);
break;
} }
} }
@ -357,6 +361,9 @@ public sealed class LuceneSearchIndex : ISearchIndex
case Image image: case Image image:
await UpdateImage(searchRepository, image); await UpdateImage(searchRepository, image);
break; break;
case RemoteStream remoteStream:
await UpdateRemoteStream(searchRepository, remoteStream);
break;
} }
} }
@ -1420,6 +1427,79 @@ public sealed class LuceneSearchIndex : ISearchIndex
} }
} }
private async Task UpdateRemoteStream(ISearchRepository searchRepository, RemoteStream remoteStream)
{
Option<RemoteStreamMetadata> maybeMetadata = remoteStream.RemoteStreamMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
RemoteStreamMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
new StringField(IdField, remoteStream.Id.ToString(CultureInfo.InvariantCulture), Field.Store.YES),
new StringField(TypeField, RemoteStreamType, Field.Store.YES),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, remoteStream.LibraryPath.Library.Name, Field.Store.NO),
new StringField(
LibraryIdField,
remoteStream.LibraryPath.Library.Id.ToString(CultureInfo.InvariantCulture),
Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, remoteStream.State.ToString(), Field.Store.NO),
new TextField(MetadataKindField, metadata.MetadataKind.ToString(), Field.Store.NO)
};
IEnumerable<int> libraryFolderIds = remoteStream.MediaVersions
.SelectMany(mv => mv.MediaFiles)
.SelectMany(mf => Optional(mf.LibraryFolderId));
foreach (int libraryFolderId in libraryFolderIds)
{
doc.Add(
new StringField(
LibraryFolderIdField,
libraryFolderId.ToString(CultureInfo.InvariantCulture),
Field.Store.NO));
}
await AddLanguages(searchRepository, doc, remoteStream.MediaVersions);
AddStatistics(doc, remoteStream.MediaVersions);
AddCollections(doc, remoteStream.Collections);
doc.Add(
new StringField(
AddedDateField,
metadata.DateAdded.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
Field.Store.NO));
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
doc.Add(new StringField(TagFullField, tag.Name, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
AddMetadataGuids(metadata, doc);
_writer.UpdateDocument(new Term(IdField, remoteStream.Id.ToString(CultureInfo.InvariantCulture)), doc);
}
catch (Exception ex)
{
metadata.RemoteStream = null;
_logger.LogWarning(ex, "Error indexing remote stream with metadata {@Metadata}", metadata);
}
}
}
private static SearchItem ProjectToSearchItem(Document doc) => new( private static SearchItem ProjectToSearchItem(Document doc) => new(
doc.Get(TypeField, CultureInfo.InvariantCulture), doc.Get(TypeField, CultureInfo.InvariantCulture),
Convert.ToInt32(doc.Get(IdField, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture)); Convert.ToInt32(doc.Get(IdField, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture));
@ -1500,6 +1580,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
.ToLowerInvariant(), .ToLowerInvariant(),
SongMetadata sm => $"{Title(sm)}_{sm.Year}_{sm.Song.State}".ToLowerInvariant(), SongMetadata sm => $"{Title(sm)}_{sm.Year}_{sm.Song.State}".ToLowerInvariant(),
ImageMetadata im => $"{Title(im)}_{im.Year}_{im.Image.State}".ToLowerInvariant(), ImageMetadata im => $"{Title(im)}_{im.Year}_{im.Image.State}".ToLowerInvariant(),
RemoteStreamMetadata rsm => $"{Title(rsm)}_{rsm.Year}_{rsm.RemoteStream.State}".ToLowerInvariant(),
MovieMetadata mm => $"{Title(mm)}_{mm.Year}_{mm.Movie.State}".ToLowerInvariant(), MovieMetadata mm => $"{Title(mm)}_{mm.Year}_{mm.Movie.State}".ToLowerInvariant(),
ArtistMetadata am => $"{Title(am)}_{am.Year}_{am.Artist.State}".ToLowerInvariant(), ArtistMetadata am => $"{Title(am)}_{am.Year}_{am.Artist.State}".ToLowerInvariant(),
MusicVideoMetadata mvm => $"{Title(mvm)}_{mvm.Year}_{mvm.MusicVideo.State}".ToLowerInvariant(), MusicVideoMetadata mvm => $"{Title(mvm)}_{mvm.Year}_{mvm.MusicVideo.State}".ToLowerInvariant(),

11
ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs

@ -13,6 +13,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IImageFolderScanner _imageFolderScanner; private readonly IImageFolderScanner _imageFolderScanner;
private readonly IRemoteStreamFolderScanner _remoteStreamFolderScanner;
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger; private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
@ -31,6 +32,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
IOtherVideoFolderScanner otherVideoFolderScanner, IOtherVideoFolderScanner otherVideoFolderScanner,
ISongFolderScanner songFolderScanner, ISongFolderScanner songFolderScanner,
IImageFolderScanner imageFolderScanner, IImageFolderScanner imageFolderScanner,
IRemoteStreamFolderScanner remoteStreamFolderScanner,
IMediator mediator, IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger) ILogger<ScanLocalLibraryHandler> logger)
{ {
@ -42,6 +44,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
_otherVideoFolderScanner = otherVideoFolderScanner; _otherVideoFolderScanner = otherVideoFolderScanner;
_songFolderScanner = songFolderScanner; _songFolderScanner = songFolderScanner;
_imageFolderScanner = imageFolderScanner; _imageFolderScanner = imageFolderScanner;
_remoteStreamFolderScanner = remoteStreamFolderScanner;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
} }
@ -124,6 +127,14 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
progressMin, progressMin,
progressMax, progressMax,
cancellationToken), cancellationToken),
LibraryMediaKind.RemoteStreams =>
await _remoteStreamFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffprobePath,
progressMin,
progressMax,
cancellationToken),
_ => Unit.Default _ => Unit.Default
}; };

2
ErsatzTV.Scanner/Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -14,6 +14,7 @@ public interface ILocalMetadataProvider
Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName); Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName);
Task<bool> RefreshTagMetadata(Song song); Task<bool> RefreshTagMetadata(Song song);
Task<bool> RefreshTagMetadata(Image image, double? durationSeconds); Task<bool> RefreshTagMetadata(Image image, double? durationSeconds);
Task<bool> RefreshTagMetadata(RemoteStream remoteStream);
Task<bool> RefreshFallbackMetadata(Movie movie); Task<bool> RefreshFallbackMetadata(Movie movie);
Task<bool> RefreshFallbackMetadata(Episode episode); Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder); Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
@ -21,5 +22,6 @@ public interface ILocalMetadataProvider
Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo); Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo);
Task<bool> RefreshFallbackMetadata(Song song); Task<bool> RefreshFallbackMetadata(Song song);
Task<bool> RefreshFallbackMetadata(Image image); Task<bool> RefreshFallbackMetadata(Image image);
Task<bool> RefreshFallbackMetadata(RemoteStream remoteStream);
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder); Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
} }

15
ErsatzTV.Scanner/Core/Interfaces/Metadata/IRemoteStreamFolderScanner.cs

@ -0,0 +1,15 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Scanner.Core.Interfaces.Metadata;
public interface IRemoteStreamFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax,
CancellationToken cancellationToken);
}

5
ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs

@ -49,6 +49,11 @@ public abstract class LocalFolderScanner
.Map(s => $"{Path.DirectorySeparatorChar}{s}{Path.DirectorySeparatorChar}") .Map(s => $"{Path.DirectorySeparatorChar}{s}{Path.DirectorySeparatorChar}")
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
public static readonly ImmutableHashSet<string> RemoteStreamExtensions = new[]
{
"yml"
}.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
private readonly IClient _client; private readonly IClient _client;
private readonly IFFmpegPngService _ffmpegPngService; private readonly IFFmpegPngService _ffmpegPngService;

135
ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs

@ -23,6 +23,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
private readonly IEpisodeNfoReader _episodeNfoReader; private readonly IEpisodeNfoReader _episodeNfoReader;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IImageRepository _imageRepository; private readonly IImageRepository _imageRepository;
private readonly IRemoteStreamRepository _remoteStreamRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<LocalMetadataProvider> _logger; private readonly ILogger<LocalMetadataProvider> _logger;
@ -46,6 +47,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
IOtherVideoRepository otherVideoRepository, IOtherVideoRepository otherVideoRepository,
ISongRepository songRepository, ISongRepository songRepository,
IImageRepository imageRepository, IImageRepository imageRepository,
IRemoteStreamRepository remoteStreamRepository,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IMovieNfoReader movieNfoReader, IMovieNfoReader movieNfoReader,
@ -66,6 +68,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
_otherVideoRepository = otherVideoRepository; _otherVideoRepository = otherVideoRepository;
_songRepository = songRepository; _songRepository = songRepository;
_imageRepository = imageRepository; _imageRepository = imageRepository;
_remoteStreamRepository = remoteStreamRepository;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_movieNfoReader = movieNfoReader; _movieNfoReader = movieNfoReader;
@ -221,6 +224,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return await RefreshFallbackMetadata(image); return await RefreshFallbackMetadata(image);
} }
public async Task<bool> RefreshTagMetadata(RemoteStream remoteStream)
{
// Option<RemoteStreamMetadata> maybeMetadata = LoadRemoteStreamMetadata(remoteStream);
// foreach (RemoteStreamMetadata metadata in maybeMetadata)
// {
// return await ApplyMetadataUpdate(remoteStream, metadata);
// }
return await RefreshFallbackMetadata(remoteStream);
}
public Task<bool> RefreshFallbackMetadata(Movie movie) => public Task<bool> RefreshFallbackMetadata(Movie movie) =>
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie)); ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
@ -263,6 +277,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return false; return false;
} }
public async Task<bool> RefreshFallbackMetadata(RemoteStream remoteStream)
{
Option<RemoteStreamMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(remoteStream);
foreach (RemoteStreamMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(remoteStream, metadata);
}
return false;
}
public async Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) public async Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo)
{ {
Option<MusicVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(musicVideo); Option<MusicVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(musicVideo);
@ -474,6 +499,74 @@ public class LocalMetadataProvider : ILocalMetadataProvider
} }
} }
private Option<RemoteStreamMetadata> LoadRemoteStreamMetadata(RemoteStream remoteStream)
{
string path = remoteStream.GetHeadVersion().MediaFiles.Head().Path;
try
{
Either<BaseError, List<SongTag>> maybeTags = _localStatisticsProvider.GetSongTags(remoteStream);
foreach (List<SongTag> tags in maybeTags.RightToSeq())
{
Option<RemoteStreamMetadata> maybeFallbackMetadata =
_fallbackMetadataProvider.GetFallbackMetadata(remoteStream);
var result = new RemoteStreamMetadata
{
MetadataKind = MetadataKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(path),
Artwork = [],
Actors = [],
Genres = [],
Studios = [],
Tags = []
};
foreach (SongTag tag in tags)
{
switch (tag.Tag)
{
case MetadataSongTag.Genre:
result.Genres.Add(new Genre { Name = tag.Value });
break;
case MetadataSongTag.Title:
result.Title = tag.Value;
break;
}
}
foreach (RemoteStreamMetadata fallbackMetadata in maybeFallbackMetadata)
{
if (string.IsNullOrWhiteSpace(result.Title))
{
result.Title = fallbackMetadata.Title;
}
result.OriginalTitle = fallbackMetadata.OriginalTitle;
// preserve folder tagging
foreach (Tag tag in fallbackMetadata.Tags)
{
result.Tags.Add(tag);
}
}
return result;
}
return Option<RemoteStreamMetadata>.None;
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read embedded remote stream metadata from {Path}", path);
_client.Notify(ex);
return None;
}
}
private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata) private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata)
{ {
var updated = false; var updated = false;
@ -1149,6 +1242,48 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return await _metadataRepository.Add(metadata); return await _metadataRepository.Add(metadata);
} }
private async Task<bool> ApplyMetadataUpdate(RemoteStream remoteStream, RemoteStreamMetadata metadata)
{
Option<RemoteStreamMetadata> maybeMetadata = Optional(remoteStream.RemoteStreamMetadata).Flatten().HeadOrNone();
foreach (RemoteStreamMetadata existing in maybeMetadata)
{
existing.Title = metadata.Title;
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle;
existing.OriginalTitle = metadata.OriginalTitle;
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
_remoteStreamRepository.AddTag,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false));
return await _metadataRepository.Update(existing) || updated;
}
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? SortTitle.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.RemoteStreamId = remoteStream.Id;
remoteStream.RemoteStreamMetadata = [metadata];
return await _metadataRepository.Add(metadata);
}
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName) private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
{ {
try try

375
ErsatzTV.Scanner/Core/Metadata/RemoteStreamFolderScanner.cs

@ -0,0 +1,375 @@
using System.Collections.Immutable;
using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Scanner.Core.Interfaces.FFmpeg;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Scanner.Core.Metadata;
public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolderScanner
{
private readonly IClient _client;
private readonly IRemoteStreamRepository _remoteStreamRepository;
private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<RemoteStreamFolderScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMediator _mediator;
public RemoteStreamFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
IMetadataRepository metadataRepository,
IImageCache imageCache,
IMediator mediator,
IRemoteStreamRepository remoteStreamRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegPngService ffmpegPngService,
ITempFilePool tempFilePool,
IClient client,
ILogger<RemoteStreamFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegPngService,
tempFilePool,
client,
logger)
{
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_mediator = mediator;
_remoteStreamRepository = remoteStreamRepository;
_libraryRepository = libraryRepository;
_mediaItemRepository = mediaItemRepository;
_client = client;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax,
CancellationToken cancellationToken)
{
try
{
IDeserializer deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
decimal progressSpread = progressMax - progressMin;
var foldersCompleted = 0;
var allFolders = new System.Collections.Generic.HashSet<string>();
var folderQueue = new Queue<string>();
string normalizedLibraryPath = libraryPath.Path.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar);
if (libraryPath.Path != normalizedLibraryPath)
{
await _libraryRepository.UpdatePath(libraryPath, normalizedLibraryPath);
}
ImmutableHashSet<string> allTrashedItems = await _mediaItemRepository.GetAllTrashedItems(libraryPath);
if (ShouldIncludeFolder(libraryPath.Path) && allFolders.Add(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.Filter(allFolders.Add)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
await _mediator.Publish(
new ScannerProgressUpdate(
libraryPath.LibraryId,
null,
progressMin + percentCompletion * progressSpread,
[],
[]),
cancellationToken);
string remoteStreamFolder = folderQueue.Dequeue();
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, remoteStreamFolder);
foldersCompleted++;
var filesForEtag = _localFileSystem.ListFiles(remoteStreamFolder).ToList();
var allFiles = filesForEtag
.Filter(f => RemoteStreamExtensions.Contains(Path.GetExtension(f).Replace(".", string.Empty)))
.Filter(f => !Path.GetFileName(f).StartsWith("._", StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (string subdirectory in _localFileSystem.ListSubdirectories(remoteStreamFolder)
.Filter(ShouldIncludeFolder)
.Filter(allFolders.Add)
.OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
string etag = FolderEtag.Calculate(remoteStreamFolder, _localFileSystem);
LibraryFolder knownFolder = await _libraryRepository.GetOrAddFolder(
libraryPath,
maybeParentFolder,
remoteStreamFolder);
if (knownFolder.Etag == etag)
{
if (allFiles.Any(allTrashedItems.Contains))
{
_logger.LogDebug("Previously trashed items are now present in folder {Folder}", remoteStreamFolder);
}
else
{
// etag matches and no trashed items are now present, continue to next folder
continue;
}
}
else
{
_logger.LogDebug(
"UPDATE: Etag has changed for folder {Folder}",
remoteStreamFolder);
}
var hasErrors = false;
foreach (string file in allFiles.OrderBy(identity))
{
Either<BaseError, MediaItemScanResult<RemoteStream>> maybeVideo = await _remoteStreamRepository
.GetOrAdd(libraryPath, knownFolder, file)
.BindT(video => ParseRemoteStreamDefinition(video, deserializer, cancellationToken))
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(video => UpdateLibraryFolderId(video, knownFolder))
.BindT(UpdateMetadata)
//.BindT(video => UpdateThumbnail(video, cancellationToken))
//.BindT(UpdateSubtitles)
.BindT(FlagNormal);
foreach (BaseError error in maybeVideo.LeftToSeq())
{
_logger.LogWarning("Error processing remote stream at {Path}: {Error}", file, error.Value);
hasErrors = true;
}
foreach (MediaItemScanResult<RemoteStream> result in maybeVideo.RightToSeq())
{
if (result.IsAdded || result.IsUpdated)
{
await _mediator.Publish(
new ScannerProgressUpdate(
libraryPath.LibraryId,
null,
null,
[result.Item.Id],
[]),
cancellationToken);
}
}
}
// only do this once per folder and only if all files processed successfully
if (!hasErrors)
{
await _libraryRepository.SetEtag(libraryPath, knownFolder, remoteStreamFolder, etag);
}
}
foreach (string path in await _remoteStreamRepository.FindRemoteStreamPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing remote stream at {Path}", path);
List<int> remoteStreamIds = await FlagFileNotFound(libraryPath, path);
await _mediator.Publish(
new ScannerProgressUpdate(
libraryPath.LibraryId,
null,
null,
remoteStreamIds.ToArray(),
[]),
cancellationToken);
}
else if (Path.GetFileName(path).StartsWith("._", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> remoteStreamIds = await _remoteStreamRepository.DeleteByPath(libraryPath, path);
await _mediator.Publish(
new ScannerProgressUpdate(
libraryPath.LibraryId,
null,
null,
[],
remoteStreamIds.ToArray()),
cancellationToken);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
}
private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> UpdateLibraryFolderId(
MediaItemScanResult<RemoteStream> video,
LibraryFolder libraryFolder)
{
MediaFile mediaFile = video.Item.GetHeadVersion().MediaFiles.Head();
if (mediaFile.LibraryFolderId != libraryFolder.Id)
{
await _libraryRepository.UpdateLibraryFolderId(mediaFile, libraryFolder.Id);
video.IsUpdated = true;
}
return video;
}
private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> ParseRemoteStreamDefinition(
MediaItemScanResult<RemoteStream> result,
IDeserializer deserializer,
CancellationToken cancellationToken)
{
try
{
RemoteStream remoteStream = result.Item;
string path = remoteStream.GetHeadVersion().MediaFiles.Head().Path;
string yaml = await File.ReadAllTextAsync(path, cancellationToken);
YamlRemoteStreamDefinition definition = deserializer.Deserialize<YamlRemoteStreamDefinition>(yaml);
bool updated = false;
if (remoteStream.Url != definition.Url)
{
remoteStream.Url = definition.Url;
updated = true;
}
if (remoteStream.Script != definition.Script)
{
remoteStream.Script = definition.Script;
updated = true;
}
if (TimeSpan.TryParse(definition.Duration, out TimeSpan duration))
{
if (remoteStream.Duration != duration)
{
remoteStream.Duration = duration;
updated = true;
}
}
else
{
if (remoteStream.Duration is not null)
{
remoteStream.Duration = null;
updated = true;
}
}
if (remoteStream.FallbackQuery != definition.FallbackQuery)
{
remoteStream.FallbackQuery = definition.FallbackQuery;
updated = true;
}
if (string.IsNullOrEmpty(remoteStream.Url) && string.IsNullOrEmpty(remoteStream.Script))
{
return BaseError.New($"`url` or `script` is required in remote stream definition file {path}");
}
if (updated)
{
await _remoteStreamRepository.UpdateDefinition(remoteStream);
result.IsUpdated = true;
}
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<RemoteStream>>> UpdateMetadata(
MediaItemScanResult<RemoteStream> result)
{
try
{
RemoteStream remoteStream = result.Item;
string path = remoteStream.GetHeadVersion().MediaFiles.Head().Path;
var shouldUpdate = true;
foreach (RemoteStreamMetadata remoteStreamMetadata in Optional(remoteStream.RemoteStreamMetadata)
.Flatten()
.HeadOrNone())
{
shouldUpdate = remoteStreamMetadata.MetadataKind == MetadataKind.Fallback ||
remoteStreamMetadata.DateUpdated != _localFileSystem.GetLastWriteTime(path);
}
if (shouldUpdate)
{
remoteStream.RemoteStreamMetadata ??= [];
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Metadata", path);
if (await _localMetadataProvider.RefreshTagMetadata(remoteStream))
{
result.IsUpdated = true;
}
}
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
}

13
ErsatzTV.Scanner/Program.cs

@ -13,6 +13,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching; using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex; using ErsatzTV.Core.Plex;
@ -29,6 +30,7 @@ using ErsatzTV.Infrastructure.Plex;
using ErsatzTV.Infrastructure.Runtime; using ErsatzTV.Infrastructure.Runtime;
using ErsatzTV.Infrastructure.Search; using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Infrastructure.Sqlite.Data; using ErsatzTV.Infrastructure.Sqlite.Data;
using ErsatzTV.Infrastructure.Streaming;
using ErsatzTV.Scanner.Core.Emby; using ErsatzTV.Scanner.Core.Emby;
using ErsatzTV.Scanner.Core.FFmpeg; using ErsatzTV.Scanner.Core.FFmpeg;
using ErsatzTV.Scanner.Core.Interfaces.FFmpeg; using ErsatzTV.Scanner.Core.Interfaces.FFmpeg;
@ -79,8 +81,12 @@ public class Program
} }
} }
private static IHostBuilder CreateHostBuilder(string[] args) => private static IHostBuilder CreateHostBuilder(string[] args)
Host.CreateDefaultBuilder(args) {
Settings.UiPort = SystemEnvironment.UiPort;
Settings.StreamingPort = SystemEnvironment.StreamingPort;
return Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) => .ConfigureServices((context, services) =>
{ {
string databaseProvider = context.Configuration.GetValue("provider", Provider.Sqlite.Name) ?? string databaseProvider = context.Configuration.GetValue("provider", Provider.Sqlite.Name) ??
@ -173,6 +179,7 @@ public class Program
services.AddScoped<IOtherVideoRepository, OtherVideoRepository>(); services.AddScoped<IOtherVideoRepository, OtherVideoRepository>();
services.AddScoped<ISongRepository, SongRepository>(); services.AddScoped<ISongRepository, SongRepository>();
services.AddScoped<IImageRepository, ImageRepository>(); services.AddScoped<IImageRepository, ImageRepository>();
services.AddScoped<IRemoteStreamRepository, RemoteStreamRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>(); services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<ISearchRepository, SearchRepository>(); services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<ICachingSearchRepository, CachingSearchRepository>(); services.AddScoped<ICachingSearchRepository, CachingSearchRepository>();
@ -188,6 +195,7 @@ public class Program
services.AddScoped<IOtherVideoFolderScanner, OtherVideoFolderScanner>(); services.AddScoped<IOtherVideoFolderScanner, OtherVideoFolderScanner>();
services.AddScoped<ISongFolderScanner, SongFolderScanner>(); services.AddScoped<ISongFolderScanner, SongFolderScanner>();
services.AddScoped<IImageFolderScanner, ImageFolderScanner>(); services.AddScoped<IImageFolderScanner, ImageFolderScanner>();
services.AddScoped<IRemoteStreamFolderScanner, RemoteStreamFolderScanner>();
services.AddScoped<IEpisodeNfoReader, EpisodeNfoReader>(); services.AddScoped<IEpisodeNfoReader, EpisodeNfoReader>();
services.AddScoped<IMovieNfoReader, MovieNfoReader>(); services.AddScoped<IMovieNfoReader, MovieNfoReader>();
services.AddScoped<IArtistNfoReader, ArtistNfoReader>(); services.AddScoped<IArtistNfoReader, ArtistNfoReader>();
@ -245,6 +253,7 @@ public class Program
services.AddHostedService<Worker>(); services.AddHostedService<Worker>();
}) })
.UseSerilog(); .UseSerilog();
}
private class BugsnagNoopClient : IClient private class BugsnagNoopClient : IClient
{ {

55
ErsatzTV/Controllers/InternalController.cs

@ -2,6 +2,7 @@
using CliWrap; using CliWrap;
using ErsatzTV.Application.Emby; using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Jellyfin; using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Plex; using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Streaming; using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Subtitles.Queries; using ErsatzTV.Application.Subtitles.Queries;
@ -54,6 +55,60 @@ public class InternalController : ControllerBase
} }
} }
[HttpGet("ffmpeg/remote-stream/{remoteStreamId}")]
public async Task<IActionResult> GetRemoteStream(int remoteStreamId, CancellationToken cancellationToken)
{
Option<RemoteStreamViewModel> maybeRemoteStream =
await _mediator.Send(new GetRemoteStreamById(remoteStreamId), cancellationToken);
foreach (RemoteStreamViewModel remoteStream in maybeRemoteStream)
{
if (!string.IsNullOrWhiteSpace(remoteStream.Url))
{
return new RedirectResult(remoteStream.Url);
}
if (!string.IsNullOrWhiteSpace(remoteStream.Script))
{
string[] split = remoteStream.Script.Split(" ");
if (split.Length > 0)
{
Command command = Cli.Wrap(split.Head());
if (split.Length > 1)
{
command = command.WithArguments(split.Tail());
}
var process = new FFmpegProcess
{
StartInfo = new ProcessStartInfo
{
FileName = command.TargetFilePath,
Arguments = command.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true
}
};
HttpContext.Response.RegisterForDispose(process);
foreach ((string key, string value) in command.EnvironmentVariables)
{
process.StartInfo.Environment[key] = value;
}
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
}
}
}
return NotFound();
}
[HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")] [HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")]
public async Task<IActionResult> GetPlexMedia( public async Task<IActionResult> GetPlexMedia(
int plexMediaSourceId, int plexMediaSourceId,

43
ErsatzTV/Pages/CollectionItems.razor

@ -86,6 +86,11 @@
{ {
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")">@_data.ImageCards.Count Images</MudLink> <MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")">@_data.ImageCards.Count Images</MudLink>
} }
@if (_data?.RemoteStreamCards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#remote_streams")">@_data.RemoteStreamCards.Count Remote Streams</MudLink>
}
</div> </div>
@if (SupportsCustomOrdering()) @if (SupportsCustomOrdering())
{ {
@ -329,6 +334,31 @@
} }
</MudStack> </MudStack>
} }
@if (_data?.RemoteStreamCards.Count > 0)
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "remote_streams" } })">
Remote Streams
</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (RemoteStreamCardViewModel card in _data.RemoteStreamCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveRemoteStreamFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
</MudContainer> </MudContainer>
</div> </div>
</MudForm> </MudForm>
@ -522,6 +552,19 @@
} }
} }
private async Task RemoveRemoteStreamFromCollection(MediaCardViewModel vm)
{
if (vm is RemoteStreamCardViewModel remoteStream)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = [remoteStream.RemoteStreamId]
};
await RemoveItemsWithConfirmation("remote stream", $"{remoteStream.Title}", request);
}
}
private async Task RemoveItemsWithConfirmation( private async Task RemoveItemsWithConfirmation(
string entityType, string entityType,
string entityName, string entityName,

18
ErsatzTV/Pages/Libraries.razor

@ -48,7 +48,23 @@
<MudTd DataLabel="Server Name">@context.MediaSourceName</MudTd> <MudTd DataLabel="Server Name">@context.MediaSourceName</MudTd>
} }
<MudTd DataLabel="Library Name">@context.Name</MudTd> <MudTd DataLabel="Library Name">@context.Name</MudTd>
<MudTd DataLabel="Media Kind">@context.MediaKind</MudTd> <MudTd DataLabel="Media Kind">
@switch (context.MediaKind)
{
case LibraryMediaKind.MusicVideos:
@:Music Videos
break;
case LibraryMediaKind.OtherVideos:
@:Other Videos
break;
case LibraryMediaKind.RemoteStreams:
@:Remote Streams
break;
default:
@(context.MediaKind)
break;
}
</MudTd>
<MudTd> <MudTd>
<div style="align-items: center; display: flex;"> <div style="align-items: center; display: flex;">
@if (Locker.IsLibraryLocked(context.Id)) @if (Locker.IsLibraryLocked(context.Id))

1
ErsatzTV/Pages/LocalLibraries.razor

@ -97,6 +97,7 @@
LibraryMediaKind.OtherVideos => "Other Videos", LibraryMediaKind.OtherVideos => "Other Videos",
LibraryMediaKind.Songs => "Songs", LibraryMediaKind.Songs => "Songs",
LibraryMediaKind.Images => "Images", LibraryMediaKind.Images => "Images",
LibraryMediaKind.RemoteStreams => "Remote Streams",
_ => "Unknown" _ => "Unknown"
}; };

8
ErsatzTV/Pages/LocalLibraryEditor.razor

@ -9,9 +9,9 @@
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<MudForm Model="@_model" @bind-IsValid="@_success" Style="max-height: 100%"> <MudForm @ref="_form" Model="@_model" @bind-IsValid="@_success" Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center"> <MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="ml-6" OnClick="SaveChangesAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Local Library" : "Add Local Library")</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" Class="ml-6" OnClick="@SaveChangesAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Local Library" : "Add Local Library")</MudButton>
</MudPaper> </MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto"> <div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@ -91,6 +91,7 @@
private readonly LocalLibraryEditViewModel _model = new(); private readonly LocalLibraryEditViewModel _model = new();
private readonly LocalLibraryPathEditViewModel _newPath = new(); private readonly LocalLibraryPathEditViewModel _newPath = new();
private MudForm _form;
private bool _success; private bool _success;
private bool IsEdit => Id != 0; private bool IsEdit => Id != 0;
@ -120,7 +121,7 @@
_model.HasChanges = true; _model.HasChanges = true;
_model.Name = "New Local Library"; _model.Name = "New Local Library";
_model.MediaKind = LibraryMediaKind.Movies; _model.MediaKind = LibraryMediaKind.Movies;
_model.Paths = new List<LocalLibraryPathEditViewModel>(); _model.Paths = [];
} }
} }
@ -219,6 +220,7 @@
private async Task SaveChangesAsync() private async Task SaveChangesAsync()
{ {
await _form.Validate();
if (_success) if (_success)
{ {
Either<BaseError, LocalLibraryViewModel> result = IsEdit Either<BaseError, LocalLibraryViewModel> result = IsEdit

24
ErsatzTV/Pages/MultiSelectBase.cs

@ -13,13 +13,7 @@ namespace ErsatzTV.Pages;
public class MultiSelectBase<T> : FragmentNavigationBase public class MultiSelectBase<T> : FragmentNavigationBase
{ {
private Option<MediaCardViewModel> _recentlySelected; private Option<MediaCardViewModel> _recentlySelected = None;
public MultiSelectBase()
{
_recentlySelected = None;
SelectedItems = [];
}
[Inject] [Inject]
protected IDialogService Dialog { get; set; } protected IDialogService Dialog { get; set; }
@ -33,7 +27,7 @@ public class MultiSelectBase<T> : FragmentNavigationBase
[Inject] [Inject]
protected IMediator Mediator { get; set; } protected IMediator Mediator { get; set; }
protected System.Collections.Generic.HashSet<MediaCardViewModel> SelectedItems { get; } protected System.Collections.Generic.HashSet<MediaCardViewModel> SelectedItems { get; } = [];
protected bool IsSelected(MediaCardViewModel card) => protected bool IsSelected(MediaCardViewModel card) =>
SelectedItems.Contains(card); SelectedItems.Contains(card);
@ -91,7 +85,8 @@ public class MultiSelectBase<T> : FragmentNavigationBase
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(), SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(), SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(), SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList()); SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList(),
SelectedItems.OfType<RemoteStreamCardViewModel>().Map(i => i.RemoteStreamId).ToList());
protected Task AddSelectionToPlaylist() => AddItemsToPlaylist( protected Task AddSelectionToPlaylist() => AddItemsToPlaylist(
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(), SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
@ -102,7 +97,8 @@ public class MultiSelectBase<T> : FragmentNavigationBase
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(), SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(), SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(), SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList()); SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList(),
SelectedItems.OfType<RemoteStreamCardViewModel>().Map(i => i.RemoteStreamId).ToList());
protected async Task AddItemsToCollection( protected async Task AddItemsToCollection(
List<int> movieIds, List<int> movieIds,
@ -114,6 +110,7 @@ public class MultiSelectBase<T> : FragmentNavigationBase
List<int> otherVideoIds, List<int> otherVideoIds,
List<int> songIds, List<int> songIds,
List<int> imageIds, List<int> imageIds,
List<int> remoteStreamIds,
string entityName = "selected items") string entityName = "selected items")
{ {
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count + int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
@ -138,7 +135,8 @@ public class MultiSelectBase<T> : FragmentNavigationBase
musicVideoIds, musicVideoIds,
otherVideoIds, otherVideoIds,
songIds, songIds,
imageIds); imageIds,
remoteStreamIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken); Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match( addResult.Match(
@ -197,6 +195,7 @@ public class MultiSelectBase<T> : FragmentNavigationBase
List<int> otherVideoIds, List<int> otherVideoIds,
List<int> songIds, List<int> songIds,
List<int> imageIds, List<int> imageIds,
List<int> remoteStreamIds,
string entityName = "selected items") string entityName = "selected items")
{ {
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count + int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
@ -221,7 +220,8 @@ public class MultiSelectBase<T> : FragmentNavigationBase
musicVideoIds, musicVideoIds,
otherVideoIds, otherVideoIds,
songIds, songIds,
imageIds); imageIds,
remoteStreamIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken); Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match( addResult.Match(

132
ErsatzTV/Pages/RemoteStreamList.razor

@ -0,0 +1,132 @@
@page "/media/remote/streams"
@page "/media/remote/streams/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.Search
@using ErsatzTV.Extensions
@inherits MultiSelectBase<RemoteStreamList>
@inject NavigationManager NavigationManager
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<MediaCardPager Query="@_query"
PageNumber="@PageNumber"
PageSize="@PageSize"
TotalCount="@_data.Count"
NextPage="@NextPage"
PrevPage="@PrevPage"
AddSelectionToCollection="@AddSelectionToCollection"
AddSelectionToPlaylist="@AddSelectionToPlaylist"
ClearSelection="@ClearSelection"
IsSelectMode="@IsSelectMode"
SelectionLabel="@SelectionLabel"/>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudStack Row="true" Wrap="Wrap.Wrap">
<FragmentLetterAnchor TCard="RemoteStreamCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudStack>
</MudContainer>
</div>
</MudForm>
@if (_data.PageMap is not null)
{
<LetterBar PageMap="@_data.PageMap"
BaseUri="media/remote/streams"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private RemoteStreamCardResultsViewModel _data = new(0, new List<RemoteStreamCardViewModel>(), null);
private string _query;
protected override async Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = NavigationManager.Uri.GetSearchQuery();
await RefreshData();
}
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:remote_stream" : $"type:remote_stream AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexRemoteStreams(searchQuery, PageNumber, PageSize), CancellationToken);
}
private void PrevPage()
{
var uri = $"media/remote/streams/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"media/remote/streams/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is RemoteStreamCardViewModel remoteStream)
{
var parameters = new DialogParameters { { "EntityType", "remote stream" }, { "EntityName", remoteStream.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddMediaItemToCollection(collection.Id, remoteStream.RemoteStreamId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding remote stream to collection: {error.Value}");
Logger.LogError("Unexpected error adding remote stream to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {remoteStream.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

82
ErsatzTV/Pages/Search.razor

@ -95,6 +95,11 @@
{ {
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")" Style="margin-bottom: auto; margin-top: auto">@_images.Count Images</MudLink> <MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")" Style="margin-bottom: auto; margin-top: auto">@_images.Count Images</MudLink>
} }
@if (_remoteStreams?.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#remote_streams")" Style="margin-bottom: auto; margin-top: auto">@_remoteStreams.Count Remote Streams</MudLink>
}
<div class="flex-grow-1 d-none d-md-flex"></div> <div class="flex-grow-1 d-none d-md-flex"></div>
<div> <div>
<MudTooltip Text="Add All To Collection"> <MudTooltip Text="Add All To Collection">
@ -376,6 +381,33 @@
} }
</MudStack> </MudStack>
} }
@if (_remoteStreams?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "remote_streams" } })">
Remote Streams
</MudText>
@if (_remoteStreams.Count > 50)
{
<MudLink Href="@GetRemoteStreamsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (RemoteStreamCardViewModel card in _remoteStreams.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
</MudContainer> </MudContainer>
</div> </div>
</MudForm> </MudForm>
@ -390,6 +422,7 @@
private OtherVideoCardResultsViewModel _otherVideos; private OtherVideoCardResultsViewModel _otherVideos;
private SongCardResultsViewModel _songs; private SongCardResultsViewModel _songs;
private ImageCardResultsViewModel _images; private ImageCardResultsViewModel _images;
private RemoteStreamCardResultsViewModel _remoteStreams;
private ArtistCardResultsViewModel _artists; private ArtistCardResultsViewModel _artists;
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
@ -472,6 +505,15 @@
_images = restoredImages; _images = restoredImages;
} }
if (!ApplicationState.TryTakeFromJson("_remoteStreams", out RemoteStreamCardResultsViewModel restoredRemoteStreams))
{
_remoteStreams = await Mediator.Send(new QuerySearchIndexRemoteStreams($"type:remote_stream AND ({_query})", 1, 50), CancellationToken);
}
else
{
_remoteStreams = restoredRemoteStreams;
}
if (!ApplicationState.TryTakeFromJson("_artists", out ArtistCardResultsViewModel restoredArtists)) if (!ApplicationState.TryTakeFromJson("_artists", out ArtistCardResultsViewModel restoredArtists))
{ {
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), CancellationToken); _artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), CancellationToken);
@ -492,6 +534,8 @@
ApplicationState.PersistAsJson("_musicVideos", _musicVideos); ApplicationState.PersistAsJson("_musicVideos", _musicVideos);
ApplicationState.PersistAsJson("_otherVideos", _otherVideos); ApplicationState.PersistAsJson("_otherVideos", _otherVideos);
ApplicationState.PersistAsJson("_songs", _songs); ApplicationState.PersistAsJson("_songs", _songs);
ApplicationState.PersistAsJson("_images", _images);
ApplicationState.PersistAsJson("_remoteStreams", _remoteStreams);
ApplicationState.PersistAsJson("_artists", _artists); ApplicationState.PersistAsJson("_artists", _artists);
return Task.CompletedTask; return Task.CompletedTask;
@ -514,7 +558,8 @@
.Append(_artists.Cards.OrderBy(a => a.SortTitle)) .Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle)) .Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle)) .Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
.Append(_songs.Cards.OrderBy(ov => ov.SortTitle)) .Append(_images.Cards.OrderBy(ov => ov.SortTitle))
.Append(_remoteStreams.Cards.OrderBy(ov => ov.SortTitle))
.ToList(); .ToList();
} }
@ -711,6 +756,27 @@
Right: _ => Snackbar.Add($"Added {image.Title} to collection {collection.Name}", Severity.Success)); Right: _ => Snackbar.Add($"Added {image.Title} to collection {collection.Name}", Severity.Success));
} }
} }
if (card is RemoteStreamCardViewModel remoteStream)
{
var parameters = new DialogParameters { { "EntityType", "remote stream" }, { "EntityName", remoteStream.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddMediaItemToCollection(collection.Id, remoteStream.RemoteStreamId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding remote stream to collection: {error.Value}");
Logger.LogError("Unexpected error adding remote stream to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {remoteStream.Title} to collection {collection.Name}", Severity.Success));
}
}
} }
private string GetMoviesLink() private string GetMoviesLink()
@ -821,6 +887,18 @@
return uri; return uri;
} }
private string GetRemoteStreamsLink()
{
var uri = "media/remote/streams/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private async Task AddAllToCollection(MouseEventArgs _) private async Task AddAllToCollection(MouseEventArgs _)
{ {
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query), CancellationToken); SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query), CancellationToken);
@ -834,6 +912,7 @@
results.OtherVideoIds, results.OtherVideoIds,
results.SongIds, results.SongIds,
results.ImageIds, results.ImageIds,
results.RemoteStreamIds,
"search results"); "search results");
} }
@ -850,6 +929,7 @@
results.OtherVideoIds, results.OtherVideoIds,
results.SongIds, results.SongIds,
results.ImageIds, results.ImageIds,
results.RemoteStreamIds,
"search results"); "search results");
} }

64
ErsatzTV/Pages/Trash.razor

@ -85,6 +85,11 @@
{ {
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")" Style="margin-bottom: auto; margin-top: auto">@_images.Count Images</MudLink> <MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")" Style="margin-bottom: auto; margin-top: auto">@_images.Count Images</MudLink>
} }
@if (_remoteStreams?.Cards.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#remote_streams")" Style="margin-bottom: auto; margin-top: auto">@_remoteStreams.Count Remote Streams</MudLink>
}
<div class="flex-grow-1 d-none d-md-flex"></div> <div class="flex-grow-1 d-none d-md-flex"></div>
<div> <div>
<MudButton Variant="@Variant.Filled" <MudButton Variant="@Variant.Filled"
@ -340,7 +345,7 @@
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })"> <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
Songs Images
</MudText> </MudText>
@if (_images.Count > 50) @if (_images.Count > 50)
{ {
@ -363,6 +368,34 @@
} }
</MudStack> </MudStack>
} }
@if (_remoteStreams?.Cards.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "remote_streams" } })">
Remote Streams
</MudText>
@if (_remoteStreams.Count > 50)
{
<MudLink Href="@GetRemoteStreamsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
@foreach (RemoteStreamCardViewModel card in _remoteStreams.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Href=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudStack>
}
</MudContainer> </MudContainer>
</div> </div>
</MudForm> </MudForm>
@ -379,6 +412,7 @@
private OtherVideoCardResultsViewModel _otherVideos; private OtherVideoCardResultsViewModel _otherVideos;
private SongCardResultsViewModel _songs; private SongCardResultsViewModel _songs;
private ImageCardResultsViewModel _images; private ImageCardResultsViewModel _images;
private RemoteStreamCardResultsViewModel _remoteStreams;
private ArtistCardResultsViewModel _artists; private ArtistCardResultsViewModel _artists;
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
@ -412,6 +446,7 @@
ApplicationState.PersistAsJson("_otherVideos", _otherVideos); ApplicationState.PersistAsJson("_otherVideos", _otherVideos);
ApplicationState.PersistAsJson("_songs", _songs); ApplicationState.PersistAsJson("_songs", _songs);
ApplicationState.PersistAsJson("_images", _images); ApplicationState.PersistAsJson("_images", _images);
ApplicationState.PersistAsJson("_remoteStreams", _remoteStreams);
ApplicationState.PersistAsJson("_artists", _artists); ApplicationState.PersistAsJson("_artists", _artists);
return Task.CompletedTask; return Task.CompletedTask;
@ -468,6 +503,11 @@
_images = await Mediator.Send(new QuerySearchIndexImages($"type:image AND ({_query})", 1, 50), _cts.Token); _images = await Mediator.Send(new QuerySearchIndexImages($"type:image AND ({_query})", 1, 50), _cts.Token);
} }
if (!ApplicationState.TryTakeFromJson("_remoteStreams", out _remoteStreams))
{
_remoteStreams = await Mediator.Send(new QuerySearchIndexRemoteStreams($"type:remote_stream AND ({_query})", 1, 50), _cts.Token);
}
if (!ApplicationState.TryTakeFromJson("_artists", out _artists)) if (!ApplicationState.TryTakeFromJson("_artists", out _artists))
{ {
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), _cts.Token); _artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), _cts.Token);
@ -491,6 +531,7 @@
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle)) .Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
.Append(_songs.Cards.OrderBy(ov => ov.SortTitle)) .Append(_songs.Cards.OrderBy(ov => ov.SortTitle))
.Append(_images.Cards.OrderBy(i => i.SortTitle)) .Append(_images.Cards.OrderBy(i => i.SortTitle))
.Append(_remoteStreams.Cards.OrderBy(rs => rs.SortTitle))
.ToList(); .ToList();
} }
@ -605,6 +646,18 @@
return uri; return uri;
} }
private string GetRemoteStreamsLink()
{
var uri = "media/remote/streams/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private Task DeleteFromDatabase() => DeleteItemsFromDatabase( private Task DeleteFromDatabase() => DeleteItemsFromDatabase(
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(), SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(), SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
@ -614,7 +667,8 @@
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(), SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(), SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(), SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList()); SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList(),
SelectedItems.OfType<RemoteStreamCardViewModel>().Map(i => i.RemoteStreamId).ToList());
private async Task DeleteItemsFromDatabase( private async Task DeleteItemsFromDatabase(
List<int> movieIds, List<int> movieIds,
@ -626,6 +680,7 @@
List<int> otherVideoIds, List<int> otherVideoIds,
List<int> songIds, List<int> songIds,
List<int> imageIds, List<int> imageIds,
List<int> remoteStreamIds,
string entityName = "selected items") string entityName = "selected items")
{ {
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count + int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
@ -648,6 +703,7 @@
.Append(otherVideoIds) .Append(otherVideoIds)
.Append(songIds) .Append(songIds)
.Append(imageIds) .Append(imageIds)
.Append(remoteStreamIds)
.ToList()); .ToList());
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token); Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
@ -709,6 +765,10 @@
request = new DeleteItemsFromDatabase([image.ImageId]); request = new DeleteItemsFromDatabase([image.ImageId]);
await DeleteItemsWithConfirmation("image", $"{image.Title} ({image.Subtitle})", request); await DeleteItemsWithConfirmation("image", $"{image.Title} ({image.Subtitle})", request);
break; break;
case RemoteStreamCardViewModel remoteStream:
request = new DeleteItemsFromDatabase([remoteStream.RemoteStreamId]);
await DeleteItemsWithConfirmation("remote stream", $"{remoteStream.Title} ({remoteStream.Subtitle})", request);
break;
} }
} }

1
ErsatzTV/Services/RunOnce/DatabaseCleanerService.cs

@ -54,6 +54,7 @@ public class DatabaseCleanerService(
and Id not in (select Id from `Song`) and Id not in (select Id from `Song`)
and Id not in (select Id from `Artist`) and Id not in (select Id from `Artist`)
and Id not in (select Id from `Image`) and Id not in (select Id from `Image`)
and Id not in (select Id from `RemoteStream`)
"""); """);
private static async Task GenerateFallbackMetadata( private static async Task GenerateFallbackMetadata(

1
ErsatzTV/Shared/MainLayout.razor

@ -123,6 +123,7 @@
<MudNavLink Href="media/other/videos">Other Videos</MudNavLink> <MudNavLink Href="media/other/videos">Other Videos</MudNavLink>
<MudNavLink Href="media/music/songs">Songs</MudNavLink> <MudNavLink Href="media/music/songs">Songs</MudNavLink>
<MudNavLink Href="media/browser/images">Images</MudNavLink> <MudNavLink Href="media/browser/images">Images</MudNavLink>
<MudNavLink Href="media/remote/streams">Remote Streams</MudNavLink>
</MudNavGroup> </MudNavGroup>
<MudNavGroup Title="Lists"> <MudNavGroup Title="Lists">
<MudNavLink Href="media/collections">Collections</MudNavLink> <MudNavLink Href="media/collections">Collections</MudNavLink>

1
ErsatzTV/Startup.cs

@ -668,6 +668,7 @@ public class Startup
services.AddScoped<IOtherVideoRepository, OtherVideoRepository>(); services.AddScoped<IOtherVideoRepository, OtherVideoRepository>();
services.AddScoped<ISongRepository, SongRepository>(); services.AddScoped<ISongRepository, SongRepository>();
services.AddScoped<IImageRepository, ImageRepository>(); services.AddScoped<IImageRepository, ImageRepository>();
services.AddScoped<IRemoteStreamRepository, RemoteStreamRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>(); services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>(); services.AddScoped<IMetadataRepository, MetadataRepository>();
services.AddScoped<IArtworkRepository, ArtworkRepository>(); services.AddScoped<IArtworkRepository, ArtworkRepository>();

Loading…
Cancel
Save