Compare commits

...
28 changed files with 3673 additions and 35 deletions
+13 -1
View File
@@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.57-alpha] - 2021-09-10
### Added
- Add `released_inthelast` search field for relative release date queries
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
- Allow adding smart collections to multi collections
### Fixed
- Fix loading artwork in Kodi
- Use fake image extension (`.jpg`) for artwork in M3U and XMLTV since Kodi detects MIME type from URL
- Enable HEAD requests for IPTV image paths since Kodi requires those
## [0.0.56-alpha] - 2021-09-10
### Added
- Add Smart Collections
@@ -569,7 +580,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...HEAD
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
[0.0.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
@@ -6,7 +6,7 @@ using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollection
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;
@@ -41,6 +41,11 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Query()
.Include(i => i.Collection)
.LoadAsync();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionSmartItems)
.Query()
.Include(i => i.SmartCollection)
.LoadAsync();
return ProjectToViewModel(multiCollection);
}
@@ -51,12 +56,44 @@ namespace ErsatzTV.Application.MediaCollections.Commands
name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
}).ToList()
MultiCollectionItems = request.Items.Map(
i =>
{
if (i.CollectionId.HasValue)
{
return Some(
new MultiCollectionItem
{
CollectionId = i.CollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
return None;
})
.Sequence()
.Flatten()
.ToList(),
MultiCollectionSmartItems = request.Items.Map(
i =>
{
if (i.SmartCollectionId.HasValue)
{
return Some(
new MultiCollectionSmartItem
{
SmartCollectionId = i.SmartCollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
return None;
})
.Sequence()
.Flatten()
.ToList()
});
private static async Task<Validation<BaseError, string>> ValidateName(
@@ -5,7 +5,7 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollection
(
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
@@ -48,11 +49,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
await dbContext.SaveChangesAsync();
var toAdd = request.Items
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId))
.Map(
i => new MultiCollectionItem
.Filter(i => i.CollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId.Value))
.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
// ReSharper disable once PossibleInvalidOperationException
CollectionId = i.CollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
@@ -79,6 +82,40 @@ namespace ErsatzTV.Application.MediaCollections.Commands
// add new items
c.MultiCollectionItems.AddRange(toAdd);
var toAddSmart = request.Items
.Filter(i => i.SmartCollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionSmartItems.All(i2 => i2.SmartCollectionId != i.SmartCollectionId.Value))
.Map(i => new MultiCollectionSmartItem
{
// ReSharper disable once PossibleInvalidOperationException
SmartCollectionId = i.SmartCollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemoveSmart = c.MultiCollectionSmartItems
.Filter(i => request.Items.All(i2 => i2.SmartCollectionId != i.SmartCollectionId))
.ToList();
// remove items that are no longer present
c.MultiCollectionSmartItems.RemoveAll(toRemoveSmart.Contains);
// update existing items
foreach (MultiCollectionSmartItem item in c.MultiCollectionSmartItems)
{
foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(
i => i.SmartCollectionId == item.SmartCollectionId))
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
}
}
// add new items
c.MultiCollectionSmartItems.AddRange(toAddSmart);
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
@@ -104,6 +141,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
UpdateMultiCollection updateCollection) =>
dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.Include(mc => mc.MultiCollectionSmartItems)
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
@@ -13,7 +13,8 @@ namespace ErsatzTV.Application.MediaCollections
new(
multiCollection.Id,
multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList());
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(),
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList());
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
@@ -24,5 +25,12 @@ namespace ErsatzTV.Application.MediaCollections
ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder);
private static MultiCollectionSmartItemViewModel ProjectToViewModel(MultiCollectionSmartItem multiCollectionSmartItem) =>
new(
multiCollectionSmartItem.MultiCollectionId,
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder);
}
}
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionSmartItemViewModel(
int MultiCollectionId,
SmartCollectionViewModel SmartCollection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
}
@@ -2,5 +2,9 @@
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionViewModel(int Id, string Name, List<MultiCollectionItemViewModel> Items);
public record MultiCollectionViewModel(
int Id,
string Name,
List<MultiCollectionItemViewModel> Items,
List<MultiCollectionSmartItemViewModel> SmartItems);
}
@@ -24,6 +24,8 @@ namespace ErsatzTV.Application.MediaCollections.Queries
return await dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(mc => mc.Collection)
.Include(mc => mc.MultiCollectionSmartItems)
.ThenInclude(mc => mc.SmartCollection)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.MapT(ProjectToViewModel);
}
@@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Domain
public int Id { get; set; }
public string Name { get; set; }
public List<Collection> Collections { get; set; }
public List<SmartCollection> SmartCollections { get; set; }
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
}
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain
{
public class MultiCollectionSmartItem
{
public int MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public bool ScheduleAsGroup { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
}
}
@@ -1,9 +1,13 @@
namespace ErsatzTV.Core.Domain
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class SmartCollection
{
public int Id { get; set; }
public string Name { get; set; }
public string Query { get; set; }
public List<MultiCollection> MultiCollections { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
}
}
+2 -2
View File
@@ -58,7 +58,7 @@ namespace ErsatzTV.Core.Iptv
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}.jpg",
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
xml.WriteAttributeString("src", logo);
xml.WriteEndElement(); // icon
@@ -276,7 +276,7 @@ namespace ErsatzTV.Core.Iptv
_ => "posters"
};
artworkPath = $"{_scheme}://{_host}/iptv/artwork/{artworkFolder}/{artwork.Path}";
artworkPath = $"{_scheme}://{_host}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg";
}
return artworkPath;
+1 -1
View File
@@ -32,7 +32,7 @@ namespace ErsatzTV.Core.Iptv
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}.jpg",
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
string shortUniqueId = Convert.ToBase64String(channel.UniqueId.ToByteArray())
@@ -22,6 +22,19 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId }));
builder.HasMany(m => m.SmartCollections)
.WithMany(m => m.MultiCollections)
.UsingEntity<MultiCollectionSmartItem>(
j => j.HasOne(mci => mci.SmartCollection)
.WithMany(c => c.MultiCollectionSmartItems)
.HasForeignKey(mci => mci.SmartCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(mci => mci.MultiCollection)
.WithMany(mc => mc.MultiCollectionSmartItems)
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.SmartCollectionId }));
}
}
}
@@ -66,6 +66,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
.Include(mc => mc.Collections)
.Include(mc => mc.SmartCollections)
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
foreach (MultiCollection multiCollection in maybeMultiCollection)
@@ -79,6 +80,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetArtistItems(dbContext, collectionId));
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
}
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
{
result.AddRange(await GetSmartCollectionItems(smartCollectionId));
}
}
return result.Distinct().ToList();
@@ -138,8 +144,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
.Include(mc => mc.Collections)
.Include(mc => mc.SmartCollections)
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(mci => mci.Collection)
.Include(mc => mc.MultiCollectionSmartItems)
.ThenInclude(mci => mci.SmartCollection)
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
foreach (MultiCollection multiCollection in maybeMultiCollection)
@@ -178,6 +187,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
multiCollectionItem.Collection.UseCustomPlaybackOrder));
}
}
foreach (MultiCollectionSmartItem multiCollectionSmartItem in multiCollection.MultiCollectionSmartItems)
{
List<MediaItem> items = await GetSmartCollectionItems(multiCollectionSmartItem.SmartCollectionId);
result.Add(
new CollectionWithItems(
multiCollectionSmartItem.SmartCollectionId,
items,
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder,
false));
}
}
// remove duplicate items from ungrouped collections
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MultiCollectionSmartCollection : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MultiCollectionSmartItem",
columns: table => new
{
MultiCollectionId = table.Column<int>(type: "INTEGER", nullable: false),
SmartCollectionId = table.Column<int>(type: "INTEGER", nullable: false),
ScheduleAsGroup = table.Column<bool>(type: "INTEGER", nullable: false),
PlaybackOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MultiCollectionSmartItem", x => new { x.MultiCollectionId, x.SmartCollectionId });
table.ForeignKey(
name: "FK_MultiCollectionSmartItem_MultiCollection_MultiCollectionId",
column: x => x.MultiCollectionId,
principalTable: "MultiCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MultiCollectionSmartItem_SmartCollection_SmartCollectionId",
column: x => x.SmartCollectionId,
principalTable: "SmartCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_MultiCollectionSmartItem_SmartCollectionId",
table: "MultiCollectionSmartItem",
column: "SmartCollectionId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MultiCollectionSmartItem");
}
}
}
@@ -979,6 +979,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MultiCollectionItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionSmartItem", b =>
{
b.Property<int>("MultiCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("SmartCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("PlaybackOrder")
.HasColumnType("INTEGER");
b.Property<bool>("ScheduleAsGroup")
.HasColumnType("INTEGER");
b.HasKey("MultiCollectionId", "SmartCollectionId");
b.HasIndex("SmartCollectionId");
b.ToTable("MultiCollectionSmartItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
{
b.Property<int>("Id")
@@ -2247,6 +2268,25 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MultiCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionSmartItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection")
.WithMany("MultiCollectionSmartItems")
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection")
.WithMany("MultiCollectionSmartItems")
.HasForeignKey("SmartCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MultiCollection");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MusicVideo", "MusicVideo")
@@ -2972,6 +3012,8 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollection", b =>
{
b.Navigation("MultiCollectionItems");
b.Navigation("MultiCollectionSmartItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
@@ -3033,6 +3075,11 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SmartCollection", b =>
{
b.Navigation("MultiCollectionSmartItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b =>
{
b.Navigation("ArtistMetadata");
@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using Lucene.Net.Analysis;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Util;
namespace ErsatzTV.Infrastructure.Search
{
public class RelativeDateMultiFieldQueryParser : MultiFieldQueryParser
{
public RelativeDateMultiFieldQueryParser(
LuceneVersion matchVersion,
string[] fields,
Analyzer analyzer,
IDictionary<string, float> boosts) : base(matchVersion, fields, analyzer, boosts)
{
}
public RelativeDateMultiFieldQueryParser(LuceneVersion matchVersion, string[] fields, Analyzer analyzer) : base(
matchVersion,
fields,
analyzer)
{
}
protected override Query GetFieldQuery(string field, string queryText, int slop)
{
if (field == "released_inthelast" && RelativeDateQueryParser.ParseStart(queryText, out DateTime start))
{
var todayString = DateTime.Today.ToString("yyyyMMdd");
var dateString = start.ToString("yyyyMMdd");
return base.GetRangeQuery("release_date", dateString, todayString, true, true);
}
return base.GetFieldQuery(field, queryText, slop);
}
}
}
@@ -0,0 +1,77 @@
using System;
using Lucene.Net.Analysis;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Util;
namespace ErsatzTV.Infrastructure.Search
{
public class RelativeDateQueryParser : QueryParser
{
public RelativeDateQueryParser(LuceneVersion matchVersion, string f, Analyzer a) : base(matchVersion, f, a)
{
}
protected internal RelativeDateQueryParser(ICharStream stream) : base(stream)
{
}
protected RelativeDateQueryParser(QueryParserTokenManager tm) : base(tm)
{
}
protected override Query GetFieldQuery(string field, string queryText, int slop)
{
if (field == "released_inthelast" && ParseStart(queryText, out DateTime start))
{
var todayString = DateTime.Today.ToString("yyyyMMdd");
var dateString = start.ToString("yyyyMMdd");
return base.GetRangeQuery("release_date", dateString, todayString, true, true);
}
return base.GetFieldQuery(field, queryText, slop);
}
internal static bool ParseStart(string text, out DateTime start)
{
start = DateTime.MinValue;
try
{
if (int.TryParse(text.Split(" ")[0], out int number))
{
if (text.Contains("day"))
{
start = DateTime.Today.AddDays(number * -1);
return true;
}
if (text.Contains("week"))
{
start = DateTime.Today.AddDays(number * -7);
return true;
}
if (text.Contains("month"))
{
start = DateTime.Today.AddMonths(number * -1);
return true;
}
if (text.Contains("year"))
{
start = DateTime.Today.AddYears(number * -1);
return true;
}
}
}
catch
{
// do nothing
}
return false;
}
}
}
@@ -148,8 +148,8 @@ namespace ErsatzTV.Infrastructure.Search
};
using var analyzerWrapper = new PerFieldAnalyzerWrapper(analyzer, customAnalyzers);
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
? new QueryParser(AppLuceneVersion, searchField, analyzerWrapper)
: new MultiFieldQueryParser(AppLuceneVersion, new[] { TitleField }, analyzerWrapper);
? new RelativeDateQueryParser(AppLuceneVersion, searchField, analyzerWrapper)
: new RelativeDateMultiFieldQueryParser(AppLuceneVersion, new[] { TitleField }, analyzerWrapper);
parser.AllowLeadingWildcard = true;
Query query = ParseQuery(searchQuery, parser);
var filter = new DuplicateFilter(TitleAndYearField);
+11
View File
@@ -35,7 +35,10 @@ namespace ErsatzTV.Controllers
_httpClientFactory = httpClientFactory;
}
[HttpHead("/iptv/artwork/posters/{fileName}")]
[HttpGet("/iptv/artwork/posters/{fileName}")]
[HttpHead("/iptv/artwork/posters/{fileName}.jpg")]
[HttpGet("/iptv/artwork/posters/{fileName}.jpg")]
[HttpGet("/artwork/posters/{fileName}")]
public async Task<IActionResult> GetPoster(string fileName)
{
@@ -67,8 +70,10 @@ namespace ErsatzTV.Controllers
}
[HttpHead("/iptv/artwork/posters/jellyfin/{*path}")]
[HttpGet("/iptv/artwork/posters/jellyfin/{*path}")]
[HttpGet("/artwork/posters/jellyfin/{*path}")]
[HttpHead("/iptv/artwork/thumbnails/jellyfin/{*path}")]
[HttpGet("/iptv/artwork/thumbnails/jellyfin/{*path}")]
[HttpGet("/artwork/thumbnails/jellyfin/{*path}")]
[HttpGet("/artwork/fanart/jellyfin/{*path}")]
@@ -82,8 +87,10 @@ namespace ErsatzTV.Controllers
return GetJellyfinArtwork(path);
}
[HttpHead("/iptv/artwork/posters/emby/{*path}")]
[HttpGet("/iptv/artwork/posters/emby/{*path}")]
[HttpGet("/artwork/posters/emby/{*path}")]
[HttpHead("/iptv/artwork/thumbnails/emby/{*path}")]
[HttpGet("/iptv/artwork/thumbnails/emby/{*path}")]
[HttpGet("/artwork/thumbnails/emby/{*path}")]
[HttpGet("/artwork/fanart/emby/{*path}")]
@@ -97,6 +104,7 @@ namespace ErsatzTV.Controllers
return GetEmbyArtwork(path);
}
[HttpHead("/iptv/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
[HttpGet("/iptv/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
[HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
public Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path) =>
@@ -116,7 +124,10 @@ namespace ErsatzTV.Controllers
plexMediaSourceId,
$"photo/:/transcode?url=/{path}&height=220&width=392&minSize=1&upscale=0");
[HttpHead("/iptv/artwork/thumbnails/{fileName}")]
[HttpGet("/iptv/artwork/thumbnails/{fileName}")]
[HttpHead("/iptv/artwork/thumbnails/{fileName}.jpg")]
[HttpGet("/iptv/artwork/thumbnails/{fileName}.jpg")]
[HttpGet("/artwork/thumbnails/{fileName}")]
public async Task<IActionResult> GetThumbnail(string fileName)
{
+2
View File
@@ -71,6 +71,8 @@ namespace ErsatzTV.Controllers
error => BadRequest(error.Value)));
[HttpGet("iptv/logos/{fileName}")]
[HttpHead("iptv/logos/{fileName}.jpg")]
[HttpGet("iptv/logos/{fileName}.jpg")]
public async Task<IActionResult> GetImage(string fileName)
{
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
+80 -12
View File
@@ -24,11 +24,21 @@
@bind-value="_selectedCollection"
SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)"/>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddCollection())" Class="ml-2">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddCollection())" Class="mt-4 mr-auto">
Add Collection
</MudButton>
<MudAutocomplete @ref="_smartCollectionAutocomplete"
Class="mt-4"
T="SmartCollectionViewModel"
Label="Smart Collection"
@bind-value="_selectedSmartCollection"
SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)"/>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddSmartCollection())" Class="mt-4 mr-auto">
Add Smart Collection
</MudButton>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="mr-2 ml-auto">
@(IsEdit ? "Save Changes" : "Add Multi Collection")
</MudButton>
@@ -87,14 +97,20 @@
private EditContext _editContext;
private ValidationMessageStore _messageStore;
private List<MediaCollectionViewModel> _collections;
private List<SmartCollectionViewModel> _smartCollections;
private MediaCollectionViewModel _selectedCollection;
private SmartCollectionViewModel _selectedSmartCollection;
private MudAutocomplete<MediaCollectionViewModel> _collectionAutocomplete;
private MudAutocomplete<SmartCollectionViewModel> _smartCollectionAutocomplete;
protected override async Task OnParametersSetAsync()
{
_collections = await _mediator.Send(new GetAllCollections())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_smartCollections = await _mediator.Send(new GetAllSmartCollections())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
if (IsEdit)
{
Option<MultiCollectionViewModel> maybeCollection = await _mediator.Send(new GetMultiCollectionById(Id));
@@ -102,13 +118,21 @@
{
_model.Id = collection.Id;
_model.Name = collection.Name;
_model.Items = collection.Items.Map(item =>
new MultiCollectionItemEditViewModel
{
Collection = item.Collection,
ScheduleAsGroup = item.ScheduleAsGroup,
PlaybackOrder = item.PlaybackOrder,
}).ToList();
_model.Items = collection.Items
.Map(item =>
new MultiCollectionItemEditViewModel
{
Collection = item.Collection,
ScheduleAsGroup = item.ScheduleAsGroup,
PlaybackOrder = item.PlaybackOrder,
})
.Append(collection.SmartItems.Map(item =>
new MultiCollectionSmartItemEditViewModel
{
SmartCollection = item.SmartCollection,
ScheduleAsGroup = item.ScheduleAsGroup,
PlaybackOrder = item.PlaybackOrder
})).ToList();
});
}
else
@@ -132,8 +156,8 @@
if (_editContext.Validate())
{
Seq<BaseError> errorMessage = IsEdit ?
(await _mediator.Send(new UpdateMultiCollection(Id, _model.Name, _model.Items.Map(i => new UpdateMultiCollectionItem(i.Collection.Id, i.ScheduleAsGroup, i.PlaybackOrder)).ToList()))).LeftToSeq() :
(await _mediator.Send(new CreateMultiCollection(_model.Name, _model.Items.Map(i => new CreateMultiCollectionItem(i.Collection.Id, i.ScheduleAsGroup, i.PlaybackOrder)).ToList()))).LeftToSeq();
(await _mediator.Send(new UpdateMultiCollection(Id, _model.Name, GetUpdateItems()))).LeftToSeq() :
(await _mediator.Send(new CreateMultiCollection(_model.Name, GetCreateItems()))).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
@@ -145,6 +169,32 @@
}
}
private List<UpdateMultiCollectionItem> GetUpdateItems() =>
_model.Items.Map(i =>
i switch
{
MultiCollectionSmartItemEditViewModel smartVm =>
new UpdateMultiCollectionItem(
null,
smartVm.SmartCollection.Id,
smartVm.ScheduleAsGroup,
smartVm.PlaybackOrder),
_ => new UpdateMultiCollectionItem(i.Collection.Id, null, i.ScheduleAsGroup, i.PlaybackOrder)
}).ToList();
private List<CreateMultiCollectionItem> GetCreateItems() =>
_model.Items.Map(i =>
i switch
{
MultiCollectionSmartItemEditViewModel smartVm =>
new CreateMultiCollectionItem(
null,
smartVm.SmartCollection.Id,
smartVm.ScheduleAsGroup,
smartVm.PlaybackOrder),
_ => new CreateMultiCollectionItem(i.Collection.Id, null, i.ScheduleAsGroup, i.PlaybackOrder)
}).ToList();
private void RemoveCollection(MultiCollectionItemEditViewModel item)
{
_model.Items.Remove(item);
@@ -165,7 +215,25 @@
}
}
private void AddSmartCollection()
{
if (_selectedSmartCollection != null && _model.Items.OfType<MultiCollectionSmartItemEditViewModel>().All(i => i.SmartCollection != _selectedSmartCollection))
{
_model.Items.Add(new MultiCollectionSmartItemEditViewModel
{
SmartCollection = _selectedSmartCollection,
PlaybackOrder = PlaybackOrder.Chronological
});
_selectedSmartCollection = null;
_smartCollectionAutocomplete.Reset();
}
}
private Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value) =>
_collections.Filter(c => _model.Items.All(i => i.Collection != c) && c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value) =>
_smartCollections.Filter(c => _model.Items.OfType<MultiCollectionSmartItemEditViewModel>().All(i => i.SmartCollection != c) && c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
}
@@ -5,7 +5,7 @@ namespace ErsatzTV.ViewModels
{
public class MultiCollectionItemEditViewModel
{
public MediaCollectionViewModel Collection { get; set; }
public virtual MediaCollectionViewModel Collection { get; set; }
public bool ScheduleAsGroup { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
}
@@ -0,0 +1,25 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.ViewModels
{
public class MultiCollectionSmartItemEditViewModel : MultiCollectionItemEditViewModel
{
private SmartCollectionViewModel _smartCollection;
public SmartCollectionViewModel SmartCollection
{
get => _smartCollection;
set
{
_smartCollection = value;
Collection = new MediaCollectionViewModel(
_smartCollection.Id,
_smartCollection.Name,
false);
}
}
public override MediaCollectionViewModel Collection { get; set; }
}
}
+8 -1
View File
@@ -24,6 +24,7 @@ The following fields are available for searching movies:
- `content_rating`: The movie content rating (case-sensitive)
- `language`: The movie audio stream language
- `release_date`: The movie release date (YYYYMMDD)
- `released_inthelast`: A range for the movie release date (days, weeks, months, years)
- `type`: Always `movie`
### Shows
@@ -40,6 +41,7 @@ The following fields are available for searching shows:
- `content_rating`: The show content rating (case-sensitive)
- `language`: The show audio stream language
- `release_date`: The show release date (YYYYMMDD)
- `released_inthelast`: A range for the show release date (days, weeks, months, years)
- `type`: Always `show`
### Episodes
@@ -53,6 +55,7 @@ The following fields are available for searching episodes:
- `library_name`: The name of the library that contains the episode
- `language`: The episode audio stream language
- `release_date`: The episode release date (YYYYMMDD)
- `released_inthelast`: A range for the episode release date (days, weeks, months, years)
- `type`: Always `episode`
### Artists
@@ -96,4 +99,8 @@ The following fields are available for searching music videos:
### Lush Music
`mood:lush`
`mood:lush`
### Episodes from the past week
`type:episode AND released_inthelast:"1 week"`