Compare commits
13 Commits
v0.0.56-al
...
v0.0.58-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9912b47df | ||
|
|
55fb2624e7 | ||
|
|
8ced20dc39 | ||
|
|
e718cb0faf | ||
|
|
e218ff9a6d | ||
|
|
c2a49cbaea | ||
|
|
17e74f7314 | ||
|
|
2032bb4777 | ||
|
|
7877ec641e | ||
|
|
767a9779bb | ||
|
|
bb9127e546 | ||
|
|
c932577cb8 | ||
|
|
ad2685fb2e |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -5,6 +5,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.58-alpha] - 2021-09-15
|
||||
### Added
|
||||
- Add `released_notinthelast` search field for relative release date queries
|
||||
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
|
||||
- Add `released_onthisday` search field for historical queries
|
||||
- Syntax is `released_onthisday:1` and will search for items released on this month number and day number in prior years
|
||||
- Add tooltip explaining `Keep Multi-Part Episodes Together`
|
||||
|
||||
### Fixed
|
||||
- Properly display watermark when no other video filters (like scaling or padding) are required
|
||||
- Fix building some playouts in timezones with positive offsets (like UTC+2)
|
||||
- Fix `Shuffle In Order` so all collections/shows start from the earliest episode
|
||||
- You may need to rebuild playouts to see this fixed behavior more quickly
|
||||
|
||||
## [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 +594,9 @@ 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.58-alpha...HEAD
|
||||
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.57-alpha
|
||||
[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
|
||||
|
||||
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
|
||||
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
@@ -43,7 +44,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
bool isSearchResult) =>
|
||||
new(
|
||||
episodeMetadata.EpisodeId,
|
||||
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
|
||||
episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc,
|
||||
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
|
||||
m => m.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
|
||||
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
|
||||
|
||||
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
if (forceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
|
||||
@@ -65,7 +65,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Core.FFmpeg;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
@@ -113,6 +114,161 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.BottomLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=134:y=H-h-54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.BottomRight,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=W-w-134:y=H-h-54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopRight,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=W-w-134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=134:y=54:enable='lt(mod(mod(time(0),60*60),10*60),15)'[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
true,
|
||||
100,
|
||||
"[1:v]scale=384:-1[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
90,
|
||||
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.90[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
true,
|
||||
100,
|
||||
"[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||
"[a]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:1]apad=whole_dur=3300000ms[a];[0:0][1:v]overlay=x=134:y=54[v]",
|
||||
"[a]",
|
||||
"[v]")]
|
||||
public void Should_Return_Watermark(
|
||||
bool alignAudio,
|
||||
bool deinterlace,
|
||||
bool intermittent,
|
||||
ChannelWatermarkLocation location,
|
||||
bool scaled,
|
||||
int opacity,
|
||||
string expectedVideoFilter,
|
||||
string expectedAudioLabel,
|
||||
string expectedVideoLabel)
|
||||
{
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithWatermark(
|
||||
Some(
|
||||
new ChannelWatermark
|
||||
{
|
||||
Mode = intermittent
|
||||
? ChannelWatermarkMode.Intermittent
|
||||
: ChannelWatermarkMode.Permanent,
|
||||
DurationSeconds = intermittent ? 15 : 0,
|
||||
FrequencyMinutes = intermittent ? 10 : 0,
|
||||
Location = location,
|
||||
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
|
||||
WidthPercent = scaled ? 20 : 0,
|
||||
Opacity = opacity,
|
||||
HorizontalMarginPercent = 7,
|
||||
VerticalMarginPercent = 5
|
||||
}),
|
||||
new Resolution { Width = 1920, Height = 1080 })
|
||||
.WithDeinterlace(deinterlace)
|
||||
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(expectedVideoFilter);
|
||||
filter.AudioLabel.Should().Be(expectedAudioLabel);
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
|
||||
[TestCase(
|
||||
|
||||
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
{
|
||||
public record FakeFileEntry(string Path)
|
||||
{
|
||||
public DateTime LastWriteTime { get; set; } = DateTime.MinValue;
|
||||
public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public DateTime GetLastWriteTime(string path) =>
|
||||
Optional(_files.SingleOrDefault(f => f.Path == path))
|
||||
.Map(f => f.LastWriteTime)
|
||||
.IfNone(DateTime.MinValue);
|
||||
.IfNone(SystemTime.MinValueUtc);
|
||||
|
||||
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
|
||||
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
12
ErsatzTV.Core/Domain/Collection/MultiCollectionSmartItem.cs
Normal file
12
ErsatzTV.Core/Domain/Collection/MultiCollectionSmartItem.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,28 +233,39 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
complexFilter.Append(audioLabel);
|
||||
}
|
||||
|
||||
if (videoFilterQueue.Any())
|
||||
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
if (hasAudioFilters)
|
||||
{
|
||||
complexFilter.Append(';');
|
||||
}
|
||||
|
||||
complexFilter.Append($"[{videoLabel}]");
|
||||
var filters = string.Join(",", videoFilterQueue);
|
||||
complexFilter.Append(filters);
|
||||
if (videoFilterQueue.Any())
|
||||
{
|
||||
complexFilter.Append($"[{videoLabel}]");
|
||||
var filters = string.Join(",", videoFilterQueue);
|
||||
complexFilter.Append(filters);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
complexFilter.Append("[vt]");
|
||||
if (videoFilterQueue.Any())
|
||||
{
|
||||
complexFilter.Append("[vt];");
|
||||
}
|
||||
|
||||
var watermarkLabel = "[1:v]";
|
||||
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
|
||||
{
|
||||
complexFilter.Append($";{watermarkLabel}{watermarkPreprocess}[wmp]");
|
||||
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");
|
||||
watermarkLabel = "[wmp]";
|
||||
}
|
||||
|
||||
complexFilter.Append($";[vt]{watermarkLabel}{watermarkOverlay}");
|
||||
complexFilter.Append(
|
||||
videoFilterQueue.Any()
|
||||
? $"[vt]{watermarkLabel}{watermarkOverlay}"
|
||||
: $"[{videoLabel}]{watermarkLabel}{watermarkOverlay}");
|
||||
|
||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
|
||||
{
|
||||
complexFilter.Append(",hwupload");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 +22,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
|
||||
public DateTime GetLastWriteTime(string path) =>
|
||||
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
|
||||
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => SystemTime.MinValueUtc);
|
||||
|
||||
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
|
||||
Directory.Exists(libraryPath.Path);
|
||||
|
||||
@@ -218,7 +218,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
@@ -318,7 +318,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
@@ -423,7 +423,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
@@ -486,7 +486,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Disambiguation = metadata.Disambiguation;
|
||||
existing.Biography = metadata.Biography;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
@@ -581,7 +581,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Album = metadata.Album;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
async () =>
|
||||
{
|
||||
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.DateUpdated == DateTime.MinValue,
|
||||
m => m.DateUpdated == SystemTime.MinValueUtc,
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
|
||||
@@ -374,9 +374,9 @@ namespace ErsatzTV.Core.Scheduling
|
||||
}
|
||||
|
||||
bool willNotFinishInTime =
|
||||
currentTime <= durationFinish.IfNone(DateTime.MinValue) &&
|
||||
currentTime <= durationFinish.IfNone(SystemTime.MinValueUtc) &&
|
||||
currentTime + peekVersion.Duration >
|
||||
durationFinish.IfNone(DateTime.MinValue);
|
||||
durationFinish.IfNone(SystemTime.MinValueUtc);
|
||||
if (willNotFinishInTime)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
|
||||
@@ -147,13 +147,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
ordered.AddRange(larger);
|
||||
}
|
||||
|
||||
int offset = random.Next(ordered.Count);
|
||||
result.Add(
|
||||
new OrderedCollection
|
||||
{
|
||||
Index = 0,
|
||||
Items = ordered.Skip(offset).Concat(ordered.Take(offset)).ToList()
|
||||
});
|
||||
result.Add(new OrderedCollection { Index = 0, Items = ordered });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
9
ErsatzTV.Core/SystemTime.cs
Normal file
9
ErsatzTV.Core/SystemTime.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Core
|
||||
{
|
||||
public static class SystemTime
|
||||
{
|
||||
public static DateTime MinValueUtc = new(0, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -703,7 +703,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
new()
|
||||
{
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.MinValue,
|
||||
DateUpdated = SystemTime.MinValueUtc,
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Actors = new List<Actor>(),
|
||||
Guids = new List<MetadataGuid>(),
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.9">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.10">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.10" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
3150
ErsatzTV.Infrastructure/Migrations/20210911024558_Add_MultiCollectionSmartCollection.Designer.cs
generated
Normal file
3150
ErsatzTV.Infrastructure/Migrations/20210911024558_Add_MultiCollectionSmartCollection.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,58 @@
|
||||
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 CustomMultiFieldQueryParser : MultiFieldQueryParser
|
||||
{
|
||||
public CustomMultiFieldQueryParser(
|
||||
LuceneVersion matchVersion,
|
||||
string[] fields,
|
||||
Analyzer analyzer,
|
||||
IDictionary<string, float> boosts) : base(matchVersion, fields, analyzer, boosts)
|
||||
{
|
||||
}
|
||||
|
||||
public CustomMultiFieldQueryParser(LuceneVersion matchVersion, string[] fields, Analyzer analyzer) : base(
|
||||
matchVersion,
|
||||
fields,
|
||||
analyzer)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Query GetFieldQuery(string field, string queryText, bool quoted)
|
||||
{
|
||||
if (field == "released_onthisday")
|
||||
{
|
||||
var todayString = DateTime.Today.ToString("*MMdd");
|
||||
return base.GetWildcardQuery("release_date", todayString);
|
||||
}
|
||||
|
||||
return base.GetFieldQuery(field, queryText, quoted);
|
||||
}
|
||||
|
||||
protected override Query GetFieldQuery(string field, string queryText, int slop)
|
||||
{
|
||||
if (field == "released_inthelast" && CustomQueryParser.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);
|
||||
}
|
||||
|
||||
if (field == "released_notinthelast" && CustomQueryParser.ParseStart(queryText, out DateTime finish))
|
||||
{
|
||||
var dateString = finish.ToString("yyyyMMdd");
|
||||
|
||||
return base.GetRangeQuery("release_date", "00000000", dateString, false, false);
|
||||
}
|
||||
|
||||
return base.GetFieldQuery(field, queryText, slop);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
ErsatzTV.Infrastructure/Search/CustomQueryParser.cs
Normal file
96
ErsatzTV.Infrastructure/Search/CustomQueryParser.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using ErsatzTV.Core;
|
||||
using Lucene.Net.Analysis;
|
||||
using Lucene.Net.QueryParsers.Classic;
|
||||
using Lucene.Net.Search;
|
||||
using Lucene.Net.Util;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Search
|
||||
{
|
||||
public class CustomQueryParser : QueryParser
|
||||
{
|
||||
public CustomQueryParser(LuceneVersion matchVersion, string f, Analyzer a) : base(matchVersion, f, a)
|
||||
{
|
||||
}
|
||||
|
||||
protected internal CustomQueryParser(ICharStream stream) : base(stream)
|
||||
{
|
||||
}
|
||||
|
||||
protected CustomQueryParser(QueryParserTokenManager tm) : base(tm)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Query GetFieldQuery(string field, string queryText, bool quoted)
|
||||
{
|
||||
if (field == "released_onthisday")
|
||||
{
|
||||
var todayString = DateTime.Today.ToString("*MMdd");
|
||||
return base.GetWildcardQuery("release_date", todayString);
|
||||
}
|
||||
|
||||
return base.GetFieldQuery(field, queryText, quoted);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (field == "released_notinthelast" && ParseStart(queryText, out DateTime finish))
|
||||
{
|
||||
var dateString = finish.ToString("yyyyMMdd");
|
||||
|
||||
return base.GetRangeQuery("release_date", "00000000", dateString, false, false);
|
||||
}
|
||||
|
||||
return base.GetFieldQuery(field, queryText, slop);
|
||||
}
|
||||
|
||||
internal static bool ParseStart(string text, out DateTime start)
|
||||
{
|
||||
start = SystemTime.MinValueUtc;
|
||||
|
||||
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 CustomQueryParser(AppLuceneVersion, searchField, analyzerWrapper)
|
||||
: new CustomMultiFieldQueryParser(AppLuceneVersion, new[] { TitleField }, analyzerWrapper);
|
||||
parser.AllowLeadingWildcard = true;
|
||||
Query query = ParseQuery(searchQuery, parser);
|
||||
var filter = new DuplicateFilter(TitleAndYearField);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
<PackageReference Include="Markdig" Version="0.26.0" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.9">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.10">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -34,7 +34,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="5.1.3" />
|
||||
<PackageReference Include="MudBlazor" Version="5.1.4" />
|
||||
<PackageReference Include="NaturalSort.Extension" Version="3.1.0" />
|
||||
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.94" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -17,9 +17,11 @@
|
||||
<MudCardContent>
|
||||
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Label="Keep Multi-Part Episodes Together"
|
||||
@bind-Checked="@_model.KeepMultiPartEpisodesTogether"
|
||||
For="@(() => _model.KeepMultiPartEpisodesTogether)"/>
|
||||
<MudTooltip Text="Always schedule multi-part episodes chronologically when shuffling">
|
||||
<MudCheckBox Label="Keep Multi-Part Episodes Together"
|
||||
@bind-Checked="@_model.KeepMultiPartEpisodesTogether"
|
||||
For="@(() => _model.KeepMultiPartEpisodesTogether)"/>
|
||||
</MudTooltip>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTooltip Text="This is useful for multi-part crossover episodes">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
25
ErsatzTV/ViewModels/MultiCollectionSmartItemEditViewModel.cs
Normal file
25
ErsatzTV/ViewModels/MultiCollectionSmartItemEditViewModel.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -74,8 +74,15 @@ The following fields are available for searching music videos:
|
||||
- `genre`: The music video genre
|
||||
- `library_name`: The name of the library that contains the music video
|
||||
- `language`: The music video audio stream language
|
||||
- `release_date`: The music video release date (YYYYMMDD)
|
||||
- `type`: Always `music_video`
|
||||
|
||||
## Special Search Fields
|
||||
|
||||
- `released_inthelast`: For any media type that supports `release_date`, `released_inthelast` takes a number and a unit (days, weeks, months, years) and returns items released between the specified time ago and now
|
||||
- `released_notinthelast`: For any media type that supports `release_date`, `released_notinthelast` takes a number and a unit (days, weeks, months, years) and returns items released before the specified time ago
|
||||
- `released_onthisday`: For any media type that supports `release_date`, `released_onthisday` takes any value (ignored) and will return items released on this month number and day number in previous years
|
||||
|
||||
## Sample Searches
|
||||
|
||||
### Christmas
|
||||
@@ -96,4 +103,16 @@ 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"`
|
||||
|
||||
### Episodes older than the past week
|
||||
|
||||
`type:episode AND released_notinthelast:"1 week"`
|
||||
|
||||
### Episodes released on this day
|
||||
|
||||
`type:episode AND released_onthisday:1`
|
||||
Reference in New Issue
Block a user