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 4 weeks 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/). @@ -102,6 +102,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add Trakt List option `Auto Refresh` to automatically update list from trakt.tv once each day
- Add Trakt List option `Generate Playlist` to automatically generate ETV Playlist from matched Trakt List items
- Read `country` field from movie NFO files and include in search index as `country`
- Add *experimental* and *incomplete* `Remote Stream` library kind
- Remote Stream libraries have fallback metadata added like Other Video libraries (every folder is a tag)
- Remote Stream library items consist of YAML (`.yml`) files with the following fields
- `url`: the URL of the content that can be played directly by ffmpeg
- `script`: the process name and arguments for a command that will output content to stdout
- `duration`: when the content is "live" and does not have duration metadata, this must be provided to allow scheduling
- The remote stream definition (YAML file) may provide either a `url` or a `script`
- If both are provided, `url` will be used
### Changed
- Allow `Other Video` libraries and `Image` libraries to use the same folders

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

@ -45,6 +45,7 @@ @@ -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/=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/=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/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<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( @@ -10,7 +10,8 @@ public record CollectionCardResultsViewModel(
List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards,
List<SongCardViewModel> SongCards,
List<ImageCardViewModel> ImageCards)
List<ImageCardViewModel> ImageCards,
List<RemoteStreamCardViewModel> RemoteStreamCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}

13
ErsatzTV.Application/MediaCards/Mapper.cs

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

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

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

5
ErsatzTV.Application/MediaCards/RemoteStreamCardResultsViewModel.cs

@ -0,0 +1,5 @@ @@ -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 @@ @@ -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( @@ -12,4 +12,5 @@ public record AddItemsToCollection(
List<int> MusicVideoIds,
List<int> OtherVideoIds,
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 : @@ -60,6 +60,7 @@ public class AddItemsToCollectionHandler :
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
.Append(request.RemoteStreamIds)
.ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();

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

@ -12,4 +12,5 @@ public record AddItemsToPlaylist( @@ -12,4 +12,5 @@ public record AddItemsToPlaylist(
List<int> MusicVideoIds,
List<int> OtherVideoIds,
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 @@ @@ -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 @@ @@ -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 @@ -32,6 +32,9 @@ internal static class Mapper
internal static NamedMediaItemViewModel ProjectToViewModel(Image image) =>
new(image.Id, image.ImageMetadata.HeadOrNone().Match(i => i.Title, () => "???"));
internal static RemoteStreamViewModel ProjectToViewModel(RemoteStream remoteStream) =>
new(remoteStream.Id, remoteStream.Url, remoteStream.Script);
private static string MovieTitle(Movie movie)
{
var title = "???";

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

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

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

@ -0,0 +1,5 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ -75,6 +75,8 @@ internal static class Mapper
.IfNone("[unknown song]");
case Image i:
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:
return string.Empty;
}

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

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

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

@ -81,6 +81,8 @@ public class PreviewBlockPlayoutHandler( @@ -81,6 +81,8 @@ public class PreviewBlockPlayoutHandler(
.Include(mi => (mi as Song).MediaVersions)
.Include(mi => (mi as Image).ImageMetadata)
.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);
foreach (MediaItem mediaItem in maybeMediaItem)

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

@ -4,17 +4,9 @@ using ErsatzTV.Infrastructure.Search; @@ -4,17 +4,9 @@ using ErsatzTV.Infrastructure.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(
QuerySearchIndexAllItems request,
CancellationToken cancellationToken) =>
@ -27,9 +19,10 @@ public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexA @@ -27,9 +19,10 @@ public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexA
await GetIds(LuceneSearchIndex.MusicVideoType, request.Query),
await GetIds(LuceneSearchIndex.OtherVideoType, 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) =>
(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();
}

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

@ -0,0 +1,5 @@ @@ -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 @@ @@ -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( @@ -9,4 +9,5 @@ public record SearchResultAllItemsViewModel(
List<int> MusicVideoIds,
List<int> OtherVideoIds,
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< @@ -162,6 +162,15 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata)
.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)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(item => ValidatePlayoutItemPath(dbContext, item));
@ -313,7 +322,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -313,7 +322,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItemWithPath.PlayoutItem.SubtitleMode ?? channel.SubtitleMode,
start,
finish,
request.StartAtZero ? start : now,
effectiveNow,
playoutItemWatermark,
maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDisplay,
@ -594,6 +603,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -594,6 +603,13 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
// check filesystem first
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);
}

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

@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin; @@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -32,11 +33,20 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -32,11 +33,20 @@ public class PrepareTroubleshootingPlaybackHandler(
{
public async Task<Either<BaseError, Command>> Handle(PrepareTroubleshootingPlayback request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate(dbContext, request);
return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4),
error => Task.FromResult<Either<BaseError, Command>>(error.Join()));
try
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate(dbContext, request);
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(
@ -78,6 +88,10 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -78,6 +88,10 @@ public class PrepareTroubleshootingPlaybackHandler(
DateTimeOffset now = DateTimeOffset.Now;
var duration = TimeSpan.FromSeconds(Math.Min(version.Duration.TotalSeconds, 30));
if (duration <= TimeSpan.Zero)
{
duration = TimeSpan.FromSeconds(30);
}
Command process = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
@ -182,6 +196,11 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -182,6 +196,11 @@ public class PrepareTroubleshootingPlaybackHandler(
.Include(mi => (mi as Image).MediaVersions)
.ThenInclude(mv => mv.Streams)
.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)
.Map(o => o.ToValidation<BaseError>(new UnableToLocatePlayoutItem()));
}
@ -213,6 +232,13 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -213,6 +232,13 @@ public class PrepareTroubleshootingPlaybackHandler(
// check filesystem first
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;
}

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

@ -40,6 +40,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository @@ -40,6 +40,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
public Task<List<OtherVideo>> GetOtherVideo(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<RemoteStream>> GetRemoteStream(int id) => throw new NotSupportedException();
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
throw new NotSupportedException();

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

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

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

@ -0,0 +1,14 @@ @@ -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 @@ @@ -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 @@ -16,6 +16,7 @@ public enum ProgramScheduleItemCollectionType
OtherVideo = 40,
Song = 50,
Image = 60,
RemoteStream = 70,
FakeCollection = 100
}

1
ErsatzTV.Core/Extensions/MediaItemExtensions.cs

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

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

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

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

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

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

@ -0,0 +1,18 @@ @@ -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; @@ -6,12 +6,9 @@ using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Core.Metadata;
public partial class FallbackMetadataProvider : IFallbackMetadataProvider
public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadataProvider
{
private static readonly Regex SeasonPattern = SeasonNumber();
private readonly IClient _client;
public FallbackMetadataProvider(IClient client) => _client = client;
public Option<int> GetSeasonNumberForFolder(string folder)
{
@ -173,6 +170,25 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -173,6 +170,25 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
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)]
private static partial Regex SeasonNumber();
@ -235,7 +251,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -235,7 +251,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
}
catch (Exception ex)
{
_client.Notify(ex);
client.Notify(ex);
}
return result;
@ -258,7 +274,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -258,7 +274,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
}
catch (Exception ex)
{
_client.Notify(ex);
client.Notify(ex);
}
return metadata;
@ -284,7 +300,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -284,7 +300,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
}
catch (Exception ex)
{
_client.Notify(ex);
client.Notify(ex);
return None;
}
}
@ -322,7 +338,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -322,7 +338,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
}
catch (Exception ex)
{
_client.Notify(ex);
client.Notify(ex);
return None;
}
}
@ -360,7 +376,45 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -360,7 +376,45 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
}
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;
}
}
@ -398,7 +452,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -398,7 +452,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
}
catch (Exception ex)
{
_client.Notify(ex);
client.Notify(ex);
return None;
}
}
@ -420,7 +474,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider @@ -420,7 +474,7 @@ public partial class FallbackMetadataProvider : IFallbackMetadataProvider
}
catch (Exception ex)
{
_client.Notify(ex);
client.Notify(ex);
}
return metadata;

29
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -5,17 +5,8 @@ using Microsoft.Extensions.Logging; @@ -5,17 +5,8 @@ using Microsoft.Extensions.Logging;
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)
{
try
@ -27,7 +18,7 @@ public class LocalFileSystem : ILocalFileSystem @@ -27,7 +18,7 @@ public class LocalFileSystem : ILocalFileSystem
}
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;
@ -58,12 +49,12 @@ public class LocalFileSystem : ILocalFileSystem @@ -58,12 +49,12 @@ public class LocalFileSystem : ILocalFileSystem
}
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)
{
// do nothing
_client.Notify(ex);
client.Notify(ex);
}
}
@ -81,12 +72,12 @@ public class LocalFileSystem : ILocalFileSystem @@ -81,12 +72,12 @@ public class LocalFileSystem : ILocalFileSystem
}
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)
{
// do nothing
_client.Notify(ex);
client.Notify(ex);
}
}
@ -104,12 +95,12 @@ public class LocalFileSystem : ILocalFileSystem @@ -104,12 +95,12 @@ public class LocalFileSystem : ILocalFileSystem
}
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)
{
// do nothing
_client.Notify(ex);
client.Notify(ex);
}
}
@ -138,7 +129,7 @@ public class LocalFileSystem : ILocalFileSystem @@ -138,7 +129,7 @@ public class LocalFileSystem : ILocalFileSystem
}
catch (Exception ex)
{
_client.Notify(ex);
client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
@ -159,7 +150,7 @@ public class LocalFileSystem : ILocalFileSystem @@ -159,7 +150,7 @@ public class LocalFileSystem : ILocalFileSystem
}
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;

7
ErsatzTV.Core/Scheduling/MediaItemsForCollection.cs

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

9
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -860,6 +860,9 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -860,6 +860,9 @@ public class PlayoutBuilder : IPlayoutBuilder
Song s => await s.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
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
};
@ -1194,7 +1197,11 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1194,7 +1197,11 @@ public class PlayoutBuilder : IPlayoutBuilder
case Image i:
return i.ImageMetadata.HeadOrNone().Match(
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:
return string.Empty;
}

8
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -207,6 +207,14 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe @@ -207,6 +207,14 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
}
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;
}

20
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs

@ -47,6 +47,20 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul @@ -47,6 +47,20 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
}
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);
var playoutItem = new PlayoutItem
@ -69,12 +83,6 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul @@ -69,12 +83,6 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
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>();
foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{

12
ErsatzTV.Core/Streaming/YamlRemoteStreamDefinition.cs

@ -0,0 +1,12 @@ @@ -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 @@ @@ -2,16 +2,12 @@
namespace ErsatzTV.FFmpeg.OutputOption;
public class TimeLimitOutputOption : IPipelineStep
public class TimeLimitOutputOption(TimeSpan finish) : IPipelineStep
{
private readonly TimeSpan _finish;
public TimeLimitOutputOption(TimeSpan finish) => _finish = finish;
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
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 EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => [];
public string[] FilterOptions => [];
public string[] OutputOptions => ["-t", $"{((int)finish.TotalHours):00}:{finish:mm}:{finish:ss\\.fffffff}"];
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ -57,6 +57,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<string>("Role")
.HasColumnType("longtext");
@ -86,6 +89,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -86,6 +89,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -199,6 +204,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -199,6 +204,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Path")
.HasColumnType("longtext");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("int");
@ -227,6 +235,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -227,6 +235,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -775,6 +785,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -775,6 +785,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("int");
@ -798,6 +811,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -798,6 +811,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -1266,6 +1281,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1266,6 +1281,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("RFrameRate")
.HasColumnType("longtext");
b.Property<int?>("RemoteStreamId")
.HasColumnType("int");
b.Property<string>("SampleAspectRatio")
.HasColumnType("longtext");
@ -1290,6 +1308,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1290,6 +1308,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoId");
b.HasIndex("RemoteStreamId");
b.HasIndex("SongId");
b.ToTable("MediaVersion", (string)null);
@ -1324,6 +1344,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1324,6 +1344,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("int");
@ -1347,6 +1370,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1347,6 +1370,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -2171,6 +2196,48 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2171,6 +2196,48 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
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 =>
{
b.Property<int>("Id")
@ -2864,6 +2931,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2864,6 +2931,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("int");
@ -2887,6 +2957,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2887,6 +2957,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -2969,6 +3041,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2969,6 +3041,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Path")
.HasColumnType("longtext");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<bool>("SDH")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
@ -3006,6 +3081,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3006,6 +3081,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -3050,6 +3127,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3050,6 +3127,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("int");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("int");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("int");
@ -3073,6 +3153,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3073,6 +3153,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -3388,6 +3470,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3388,6 +3470,25 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
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 =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -3730,6 +3831,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3730,6 +3831,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Actors")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Actors")
.HasForeignKey("SeasonMetadataId")
@ -3796,6 +3902,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3796,6 +3902,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Artwork")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Artwork")
.HasForeignKey("SeasonMetadataId")
@ -3986,6 +4097,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3986,6 +4097,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Genres")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres")
.HasForeignKey("SeasonMetadataId")
@ -4163,6 +4279,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4163,6 +4279,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStream", null)
.WithMany("MediaVersions")
.HasForeignKey("RemoteStreamId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Song", null)
.WithMany("MediaVersions")
.HasForeignKey("SongId")
@ -4201,6 +4322,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4201,6 +4322,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Guids")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Guids")
.HasForeignKey("SeasonMetadataId")
@ -4697,6 +4823,17 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4697,6 +4823,17 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
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 =>
{
b.HasOne("ErsatzTV.Core.Domain.Scheduling.BlockGroup", "BlockGroup")
@ -4986,6 +5123,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4986,6 +5123,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Studios")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios")
.HasForeignKey("SeasonMetadataId")
@ -5042,6 +5184,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5042,6 +5184,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("SeasonMetadataId")
@ -5090,6 +5237,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5090,6 +5237,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Tags")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Tags")
.HasForeignKey("SeasonMetadataId")
@ -5294,6 +5446,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5294,6 +5446,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.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 =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@ -5737,6 +5898,23 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5737,6 +5898,23 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
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 =>
{
b.Navigation("Items");
@ -5903,6 +6081,13 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5903,6 +6081,13 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("OtherVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStream", b =>
{
b.Navigation("MediaVersions");
b.Navigation("RemoteStreamMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ -50,6 +50,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.HasColumnType("TEXT");
@ -79,6 +82,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -79,6 +82,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -188,6 +193,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -188,6 +193,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@ -216,6 +224,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -216,6 +224,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -740,6 +750,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -740,6 +750,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@ -763,6 +776,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -763,6 +776,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -1201,6 +1216,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1201,6 +1216,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("RFrameRate")
.HasColumnType("TEXT");
b.Property<int?>("RemoteStreamId")
.HasColumnType("INTEGER");
b.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
@ -1225,6 +1243,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1225,6 +1243,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoId");
b.HasIndex("RemoteStreamId");
b.HasIndex("SongId");
b.ToTable("MediaVersion", (string)null);
@ -1257,6 +1277,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1257,6 +1277,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@ -1280,6 +1303,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1280,6 +1303,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -2066,6 +2091,46 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2066,6 +2091,46 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
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 =>
{
b.Property<int>("Id")
@ -2721,6 +2786,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2721,6 +2786,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@ -2744,6 +2812,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2744,6 +2812,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -2822,6 +2892,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2822,6 +2892,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<bool>("SDH")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@ -2859,6 +2932,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2859,6 +2932,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -2901,6 +2976,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2901,6 +2976,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("RemoteStreamMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@ -2924,6 +3002,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2924,6 +3002,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("RemoteStreamMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -3227,6 +3307,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3227,6 +3307,25 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
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 =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -3569,6 +3668,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3569,6 +3668,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Actors")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Actors")
.HasForeignKey("SeasonMetadataId")
@ -3635,6 +3739,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3635,6 +3739,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Artwork")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Artwork")
.HasForeignKey("SeasonMetadataId")
@ -3825,6 +3934,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3825,6 +3934,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Genres")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres")
.HasForeignKey("SeasonMetadataId")
@ -4002,6 +4116,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4002,6 +4116,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStream", null)
.WithMany("MediaVersions")
.HasForeignKey("RemoteStreamId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Song", null)
.WithMany("MediaVersions")
.HasForeignKey("SongId")
@ -4040,6 +4159,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4040,6 +4159,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Guids")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Guids")
.HasForeignKey("SeasonMetadataId")
@ -4536,6 +4660,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4536,6 +4660,17 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
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 =>
{
b.HasOne("ErsatzTV.Core.Domain.Scheduling.BlockGroup", "BlockGroup")
@ -4825,6 +4960,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4825,6 +4960,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Studios")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios")
.HasForeignKey("SeasonMetadataId")
@ -4881,6 +5021,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4881,6 +5021,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("SeasonMetadataId")
@ -4929,6 +5074,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4929,6 +5074,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.RemoteStreamMetadata", null)
.WithMany("Tags")
.HasForeignKey("RemoteStreamMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Tags")
.HasForeignKey("SeasonMetadataId")
@ -5133,6 +5283,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5133,6 +5283,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.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 =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@ -5576,6 +5735,23 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5576,6 +5735,23 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
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 =>
{
b.Navigation("Items");
@ -5742,6 +5918,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5742,6 +5918,13 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("OtherVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.RemoteStream", b =>
{
b.Navigation("MediaVersions");
b.Navigation("RemoteStreamMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{
b.Navigation("Episodes");

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

@ -0,0 +1,22 @@ @@ -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 @@ @@ -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 @@ -56,6 +56,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
mediaItems.AddRange(await GetOtherVideoItems(dbContext, collectionId));
mediaItems.AddRange(await GetSongItems(dbContext, collectionId));
mediaItems.AddRange(await GetImageItems(dbContext, collectionId));
mediaItems.AddRange(await GetRemoteStreamItems(dbContext, collectionId));
}
break;
@ -146,6 +147,14 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -146,6 +147,14 @@ public class MediaCollectionRepository : IMediaCollectionRepository
mediaItems.AddRange(await GetImageItems(dbContext, [mediaItemId]));
}
break;
case ProgramScheduleItemCollectionType.RemoteStream:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
mediaItems.AddRange(await GetRemoteStreamItems(dbContext, [mediaItemId]));
}
break;
}
@ -194,6 +203,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -194,6 +203,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
mediaItems.AddRange(await GetOtherVideoItems(dbContext, collectionId));
mediaItems.AddRange(await GetSongItems(dbContext, collectionId));
mediaItems.AddRange(await GetImageItems(dbContext, collectionId));
mediaItems.AddRange(await GetRemoteStreamItems(dbContext, collectionId));
}
break;
@ -284,6 +294,14 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -284,6 +294,14 @@ public class MediaCollectionRepository : IMediaCollectionRepository
mediaItems.AddRange(await GetImageItems(dbContext, [mediaItemId]));
}
break;
case ProgramScheduleItemCollectionType.RemoteStream:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
mediaItems.AddRange(await GetRemoteStreamItems(dbContext, [mediaItemId]));
}
break;
}
@ -318,6 +336,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -318,6 +336,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetOtherVideoItems(dbContext, id));
result.AddRange(await GetSongItems(dbContext, id));
result.AddRange(await GetImageItems(dbContext, id));
result.AddRange(await GetRemoteStreamItems(dbContext, id));
return result.Distinct().ToList();
}
@ -361,6 +380,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -361,6 +380,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
result.AddRange(await GetSongItems(dbContext, collectionId));
result.AddRange(await GetImageItems(dbContext, collectionId));
result.AddRange(await GetRemoteStreamItems(dbContext, collectionId));
}
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
@ -483,6 +503,12 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -483,6 +503,12 @@ public class MediaCollectionRepository : IMediaCollectionRepository
.ToList();
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();
}
@ -636,6 +662,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -636,6 +662,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
result.AddRange(await GetSongItems(dbContext, collectionId));
result.AddRange(await GetImageItems(dbContext, collectionId));
result.AddRange(await GetRemoteStreamItems(dbContext, collectionId));
}
break;
@ -726,6 +753,14 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -726,6 +753,14 @@ public class MediaCollectionRepository : IMediaCollectionRepository
result.AddRange(await GetImageItems(dbContext, [mediaItemId]));
}
break;
case ProgramScheduleItemCollectionType.RemoteStream:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
result.AddRange(await GetRemoteStreamItems(dbContext, [mediaItemId]));
}
break;
}
}
@ -769,6 +804,12 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -769,6 +804,12 @@ public class MediaCollectionRepository : IMediaCollectionRepository
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(
int? collectionId,
int? smartCollectionId)
@ -1136,7 +1177,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -1136,7 +1177,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
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
.Include(m => m.ImageMetadata)
.ThenInclude(im => im.Subtitles)
@ -1144,7 +1185,29 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -1144,7 +1185,29 @@ public class MediaCollectionRepository : IMediaCollectionRepository
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.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();
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 @@ -86,7 +86,7 @@ public class MediaItemRepository : IMediaItemRepository
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
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
WHERE M.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path })
@ -105,7 +105,7 @@ public class MediaItemRepository : IMediaItemRepository @@ -105,7 +105,7 @@ public class MediaItemRepository : IMediaItemRepository
return await dbContext.Connection.QueryAsync<string>(
@"SELECT MF.Path
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
WHERE M.State IN (1,2) AND M.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id })
@ -155,7 +155,7 @@ public class MediaItemRepository : IMediaItemRepository @@ -155,7 +155,7 @@ public class MediaItemRepository : IMediaItemRepository
{
Option<int> maybeMediaItemId = await dbContext.Connection
.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
inner join MediaFile MF on MV.Id = MF.MediaVersionId
where MF.Path = @Path",

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

@ -0,0 +1,172 @@ @@ -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 @@ -151,6 +151,16 @@ public class SearchRepository : ISearchRepository
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Image).MediaVersions)
.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)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(mi => mi.Id, mi => mi.Id == id);
@ -377,6 +387,16 @@ public class SearchRepository : ISearchRepository @@ -377,6 +387,16 @@ public class SearchRepository : ISearchRepository
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Image).MediaVersions)
.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)
.ThenInclude(tli => tli.TraktList)
.AsAsyncEnumerable();

2
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -56,6 +56,8 @@ public class TvContext : DbContext @@ -56,6 +56,8 @@ public class TvContext : DbContext
public DbSet<Image> Images { get; set; }
public DbSet<ImageMetadata> ImageMetadata { 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<ShowMetadata> ShowMetadata { get; set; }
public DbSet<Season> Seasons { get; set; }

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

@ -32,6 +32,10 @@ public class FileNotFoundHealthCheck : BaseHealthCheck, IFileNotFoundHealthCheck @@ -32,6 +32,10 @@ public class FileNotFoundHealthCheck : BaseHealthCheck, IFileNotFoundHealthCheck
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Song).MediaVersions)
.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 Season).Show)
.ThenInclude(s => s.ShowMetadata)

24
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -51,7 +51,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -51,7 +51,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
{
try
{
string filePath = mediaItem.GetHeadVersion().MediaFiles.Head().Path;
string filePath = await PathForMediaItem(mediaItem);
return await RefreshStatistics(ffmpegPath, ffprobePath, mediaItem, filePath);
}
catch (Exception ex)
@ -126,7 +126,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -126,7 +126,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
async 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);
}
@ -158,14 +158,16 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -158,14 +158,16 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
private static async Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
{
string[] arguments =
{
[
"-hide_banner",
"-print_format", "json",
"-show_format",
"-show_streams",
"-show_chapters",
"-i", filePath
};
];
//_logger.LogDebug("ffprobe arguments {FFProbeArguments}", arguments);
BufferedCommandResult probe = await Cli.Wrap(ffprobePath)
.WithArguments(arguments)
@ -478,6 +480,20 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -478,6 +480,20 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
_ => 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
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 @@ -89,6 +89,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
public const string OtherVideoType = "other_video";
public const string SongType = "song";
public const string ImageType = "image";
public const string RemoteStreamType = "remote_stream";
private readonly string _cleanShutdownPath;
private readonly List<CultureInfo> _cultureInfos;
@ -187,6 +188,9 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -187,6 +188,9 @@ public sealed class LuceneSearchIndex : ISearchIndex
case Image image:
await UpdateImage(searchRepository, image);
break;
case RemoteStream remoteStream:
await UpdateRemoteStream(searchRepository, remoteStream);
break;
}
}
@ -357,6 +361,9 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -357,6 +361,9 @@ public sealed class LuceneSearchIndex : ISearchIndex
case Image image:
await UpdateImage(searchRepository, image);
break;
case RemoteStream remoteStream:
await UpdateRemoteStream(searchRepository, remoteStream);
break;
}
}
@ -1420,6 +1427,79 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -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(
doc.Get(TypeField, CultureInfo.InvariantCulture),
Convert.ToInt32(doc.Get(IdField, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture));
@ -1500,6 +1580,7 @@ public sealed class LuceneSearchIndex : ISearchIndex @@ -1500,6 +1580,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
.ToLowerInvariant(),
SongMetadata sm => $"{Title(sm)}_{sm.Year}_{sm.Song.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(),
ArtistMetadata am => $"{Title(am)}_{am.Year}_{am.Artist.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< @@ -13,6 +13,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IImageFolderScanner _imageFolderScanner;
private readonly IRemoteStreamFolderScanner _remoteStreamFolderScanner;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMediator _mediator;
@ -31,6 +32,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either< @@ -31,6 +32,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
IOtherVideoFolderScanner otherVideoFolderScanner,
ISongFolderScanner songFolderScanner,
IImageFolderScanner imageFolderScanner,
IRemoteStreamFolderScanner remoteStreamFolderScanner,
IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger)
{
@ -42,6 +44,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either< @@ -42,6 +44,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
_otherVideoFolderScanner = otherVideoFolderScanner;
_songFolderScanner = songFolderScanner;
_imageFolderScanner = imageFolderScanner;
_remoteStreamFolderScanner = remoteStreamFolderScanner;
_mediator = mediator;
_logger = logger;
}
@ -124,6 +127,14 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either< @@ -124,6 +127,14 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
progressMin,
progressMax,
cancellationToken),
LibraryMediaKind.RemoteStreams =>
await _remoteStreamFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffprobePath,
progressMin,
progressMax,
cancellationToken),
_ => Unit.Default
};

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

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

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

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

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

@ -23,6 +23,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -23,6 +23,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
private readonly IEpisodeNfoReader _episodeNfoReader;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IImageRepository _imageRepository;
private readonly IRemoteStreamRepository _remoteStreamRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<LocalMetadataProvider> _logger;
@ -46,6 +47,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -46,6 +47,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
IOtherVideoRepository otherVideoRepository,
ISongRepository songRepository,
IImageRepository imageRepository,
IRemoteStreamRepository remoteStreamRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
IMovieNfoReader movieNfoReader,
@ -66,6 +68,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -66,6 +68,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
_otherVideoRepository = otherVideoRepository;
_songRepository = songRepository;
_imageRepository = imageRepository;
_remoteStreamRepository = remoteStreamRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_movieNfoReader = movieNfoReader;
@ -221,6 +224,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -221,6 +224,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider
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) =>
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
@ -263,6 +277,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -263,6 +277,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider
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)
{
Option<MusicVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(musicVideo);
@ -474,6 +499,74 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -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)
{
var updated = false;
@ -1149,6 +1242,48 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1149,6 +1242,48 @@ public class LocalMetadataProvider : ILocalMetadataProvider
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)
{
try

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

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

55
ErsatzTV/Controllers/InternalController.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using CliWrap;
using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Subtitles.Queries;
@ -54,6 +55,60 @@ public class InternalController : ControllerBase @@ -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}")]
public async Task<IActionResult> GetPlexMedia(
int plexMediaSourceId,

43
ErsatzTV/Pages/CollectionItems.razor

@ -86,6 +86,11 @@ @@ -86,6 +86,11 @@
{
<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>
@if (SupportsCustomOrdering())
{
@ -329,6 +334,31 @@ @@ -329,6 +334,31 @@
}
</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>
</div>
</MudForm>
@ -522,6 +552,19 @@ @@ -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(
string entityType,
string entityName,

18
ErsatzTV/Pages/Libraries.razor

@ -48,7 +48,23 @@ @@ -48,7 +48,23 @@
<MudTd DataLabel="Server Name">@context.MediaSourceName</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>
<div style="align-items: center; display: flex;">
@if (Locker.IsLibraryLocked(context.Id))

1
ErsatzTV/Pages/LocalLibraries.razor

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

8
ErsatzTV/Pages/LocalLibraryEditor.razor

@ -9,9 +9,9 @@ @@ -9,9 +9,9 @@
@inject ISnackbar Snackbar
@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">
<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>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@ -91,6 +91,7 @@ @@ -91,6 +91,7 @@
private readonly LocalLibraryEditViewModel _model = new();
private readonly LocalLibraryPathEditViewModel _newPath = new();
private MudForm _form;
private bool _success;
private bool IsEdit => Id != 0;
@ -120,7 +121,7 @@ @@ -120,7 +121,7 @@
_model.HasChanges = true;
_model.Name = "New Local Library";
_model.MediaKind = LibraryMediaKind.Movies;
_model.Paths = new List<LocalLibraryPathEditViewModel>();
_model.Paths = [];
}
}
@ -219,6 +220,7 @@ @@ -219,6 +220,7 @@
private async Task SaveChangesAsync()
{
await _form.Validate();
if (_success)
{
Either<BaseError, LocalLibraryViewModel> result = IsEdit

24
ErsatzTV/Pages/MultiSelectBase.cs

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

132
ErsatzTV/Pages/RemoteStreamList.razor

@ -0,0 +1,132 @@ @@ -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 @@ @@ -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>
}
@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>
<MudTooltip Text="Add All To Collection">
@ -376,6 +381,33 @@ @@ -376,6 +381,33 @@
}
</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>
</div>
</MudForm>
@ -390,6 +422,7 @@ @@ -390,6 +422,7 @@
private OtherVideoCardResultsViewModel _otherVideos;
private SongCardResultsViewModel _songs;
private ImageCardResultsViewModel _images;
private RemoteStreamCardResultsViewModel _remoteStreams;
private ArtistCardResultsViewModel _artists;
private PersistingComponentStateSubscription _persistingSubscription;
@ -472,6 +505,15 @@ @@ -472,6 +505,15 @@
_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))
{
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), CancellationToken);
@ -492,6 +534,8 @@ @@ -492,6 +534,8 @@
ApplicationState.PersistAsJson("_musicVideos", _musicVideos);
ApplicationState.PersistAsJson("_otherVideos", _otherVideos);
ApplicationState.PersistAsJson("_songs", _songs);
ApplicationState.PersistAsJson("_images", _images);
ApplicationState.PersistAsJson("_remoteStreams", _remoteStreams);
ApplicationState.PersistAsJson("_artists", _artists);
return Task.CompletedTask;
@ -514,7 +558,8 @@ @@ -514,7 +558,8 @@
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.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();
}
@ -711,6 +756,27 @@ @@ -711,6 +756,27 @@
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()
@ -821,6 +887,18 @@ @@ -821,6 +887,18 @@
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 _)
{
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query), CancellationToken);
@ -834,6 +912,7 @@ @@ -834,6 +912,7 @@
results.OtherVideoIds,
results.SongIds,
results.ImageIds,
results.RemoteStreamIds,
"search results");
}
@ -850,6 +929,7 @@ @@ -850,6 +929,7 @@
results.OtherVideoIds,
results.SongIds,
results.ImageIds,
results.RemoteStreamIds,
"search results");
}

64
ErsatzTV/Pages/Trash.razor

@ -85,6 +85,11 @@ @@ -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>
}
@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>
<MudButton Variant="@Variant.Filled"
@ -340,7 +345,7 @@ @@ -340,7 +345,7 @@
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
Songs
Images
</MudText>
@if (_images.Count > 50)
{
@ -363,6 +368,34 @@ @@ -363,6 +368,34 @@
}
</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>
</div>
</MudForm>
@ -379,6 +412,7 @@ @@ -379,6 +412,7 @@
private OtherVideoCardResultsViewModel _otherVideos;
private SongCardResultsViewModel _songs;
private ImageCardResultsViewModel _images;
private RemoteStreamCardResultsViewModel _remoteStreams;
private ArtistCardResultsViewModel _artists;
private PersistingComponentStateSubscription _persistingSubscription;
@ -412,6 +446,7 @@ @@ -412,6 +446,7 @@
ApplicationState.PersistAsJson("_otherVideos", _otherVideos);
ApplicationState.PersistAsJson("_songs", _songs);
ApplicationState.PersistAsJson("_images", _images);
ApplicationState.PersistAsJson("_remoteStreams", _remoteStreams);
ApplicationState.PersistAsJson("_artists", _artists);
return Task.CompletedTask;
@ -468,6 +503,11 @@ @@ -468,6 +503,11 @@
_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))
{
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), _cts.Token);
@ -491,6 +531,7 @@ @@ -491,6 +531,7 @@
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
.Append(_songs.Cards.OrderBy(ov => ov.SortTitle))
.Append(_images.Cards.OrderBy(i => i.SortTitle))
.Append(_remoteStreams.Cards.OrderBy(rs => rs.SortTitle))
.ToList();
}
@ -605,6 +646,18 @@ @@ -605,6 +646,18 @@
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(
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
@ -614,7 +667,8 @@ @@ -614,7 +667,8 @@
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).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(
List<int> movieIds,
@ -626,6 +680,7 @@ @@ -626,6 +680,7 @@
List<int> otherVideoIds,
List<int> songIds,
List<int> imageIds,
List<int> remoteStreamIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
@ -648,6 +703,7 @@ @@ -648,6 +703,7 @@
.Append(otherVideoIds)
.Append(songIds)
.Append(imageIds)
.Append(remoteStreamIds)
.ToList());
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
@ -709,6 +765,10 @@ @@ -709,6 +765,10 @@
request = new DeleteItemsFromDatabase([image.ImageId]);
await DeleteItemsWithConfirmation("image", $"{image.Title} ({image.Subtitle})", request);
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( @@ -54,6 +54,7 @@ public class DatabaseCleanerService(
and Id not in (select Id from `Song`)
and Id not in (select Id from `Artist`)
and Id not in (select Id from `Image`)
and Id not in (select Id from `RemoteStream`)
""");
private static async Task GenerateFallbackMetadata(

1
ErsatzTV/Shared/MainLayout.razor

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

1
ErsatzTV/Startup.cs

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

Loading…
Cancel
Save