mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* 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 changelogpull/2176/head
86 changed files with 39340 additions and 101 deletions
@ -0,0 +1,5 @@ |
|||||||
|
using ErsatzTV.Core.Search; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCards; |
||||||
|
|
||||||
|
public record RemoteStreamCardResultsViewModel(int Count, List<RemoteStreamCardViewModel> Cards, SearchPageMap PageMap); |
||||||
@ -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; } |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections; |
||||||
|
|
||||||
|
public record AddMediaItemToCollection(int CollectionId, int MediaItemId) : IRequest<Either<BaseError, Unit>>; |
||||||
@ -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); |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaItems; |
||||||
|
|
||||||
|
public record GetRemoteStreamById(int RemoteStreamId) : IRequest<Option<RemoteStreamViewModel>>; |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
namespace ErsatzTV.Application.MediaItems; |
||||||
|
|
||||||
|
public record RemoteStreamViewModel(int Id, string Url, string Script); |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
using ErsatzTV.Application.MediaCards; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search; |
||||||
|
|
||||||
|
public record QuerySearchIndexRemoteStreams(string Query, int PageNumber, int PageSize) : IRequest<RemoteStreamCardResultsViewModel>; |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; } |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
namespace ErsatzTV.Core.Domain; |
||||||
|
|
||||||
|
public class RemoteStreamMetadata : Metadata |
||||||
|
{ |
||||||
|
public int RemoteStreamId { get; set; } |
||||||
|
public RemoteStream RemoteStream { get; set; } |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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; } |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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) |
||||||
|
{ |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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) |
||||||
|
{ |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue