Browse Source

add trakt playlist option (#2171)

* add generate playlist option; add system playlists

* fix official lists; sync items to playlist
pull/2172/head
Jason Dove 4 weeks ago committed by GitHub
parent
commit
867c88d8fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 17
      ErsatzTV.Application/MediaCollections/Commands/AddTraktListHandler.cs
  3. 5
      ErsatzTV.Application/MediaCollections/Commands/DeletePlaylistGroupHandler.cs
  4. 5
      ErsatzTV.Application/MediaCollections/Commands/DeletePlaylistHandler.cs
  5. 41
      ErsatzTV.Application/MediaCollections/Commands/TraktCommandBase.cs
  6. 2
      ErsatzTV.Application/MediaCollections/Commands/UpdateTraktList.cs
  7. 60
      ErsatzTV.Application/MediaCollections/Commands/UpdateTraktListHandler.cs
  8. 10
      ErsatzTV.Application/MediaCollections/Mapper.cs
  9. 2
      ErsatzTV.Application/MediaCollections/PlaylistGroupViewModel.cs
  10. 2
      ErsatzTV.Application/MediaCollections/PlaylistViewModel.cs
  11. 2
      ErsatzTV.Application/MediaCollections/Queries/GetAllPlaylistGroupsHandler.cs
  12. 2
      ErsatzTV.Application/MediaCollections/Queries/GetPlaylistTreeHandler.cs
  13. 3
      ErsatzTV.Application/MediaCollections/TraktListViewModel.cs
  14. 2
      ErsatzTV.Application/Tree/TreeGroupViewModel.cs
  15. 2
      ErsatzTV.Application/Tree/TreeItemViewModel.cs
  16. 1
      ErsatzTV.Core/Domain/Collection/Playlist.cs
  17. 1
      ErsatzTV.Core/Domain/Collection/PlaylistGroup.cs
  18. 3
      ErsatzTV.Core/Domain/Collection/TraktList.cs
  19. 5947
      ErsatzTV.Infrastructure.MySql/Migrations/20250719152525_Add_TraktListPlaylist.Designer.cs
  20. 60
      ErsatzTV.Infrastructure.MySql/Migrations/20250719152525_Add_TraktListPlaylist.cs
  21. 5953
      ErsatzTV.Infrastructure.MySql/Migrations/20250719153708_Add_PlaylistIsSystem.Designer.cs
  22. 40
      ErsatzTV.Infrastructure.MySql/Migrations/20250719153708_Add_PlaylistIsSystem.cs
  23. 24
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  24. 5786
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250719152544_Add_TraktListPlaylist.Designer.cs
  25. 60
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250719152544_Add_TraktListPlaylist.cs
  26. 5792
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250719153643_Add_PlaylistIsSystem.Designer.cs
  27. 40
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250719153643_Add_PlaylistIsSystem.cs
  28. 24
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  29. 6
      ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListConfiguration.cs
  30. 12
      ErsatzTV.Infrastructure/Data/DbInitializer.cs
  31. 12
      ErsatzTV.Infrastructure/Trakt/ITraktApi.cs
  32. 17
      ErsatzTV.Infrastructure/Trakt/TraktApiClient.cs
  33. 31
      ErsatzTV/Pages/PlaylistEditor.razor
  34. 29
      ErsatzTV/Pages/Playlists.razor
  35. 11
      ErsatzTV/Pages/TraktListEditor.razor
  36. 1
      ErsatzTV/ViewModels/PlaylistItemsEditViewModel.cs
  37. 6
      ErsatzTV/ViewModels/PlaylistTreeItemViewModel.cs
  38. 1
      ErsatzTV/ViewModels/TraktListEditViewModel.cs

2
CHANGELOG.md

@ -100,6 +100,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -100,6 +100,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `Better Episode (Part Two)`
- `Not So Great (Part Three)`
- Add Trakt List option `Auto Refresh` to automatically update list from trakt.tv once each day
- Add Trakt List option `Generate Playlist` to automatically generate ETV Playlist from matched Trakt List items
### Changed
- Allow `Other Video` libraries and `Image` libraries to use the same folders
@ -141,6 +142,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -141,6 +142,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix green bars after VAAPI tonemap
- Fix bug where playout mode `Multiple` would ignore fixed start time
- Fix block playout EPG generation to use `XMLTV Time Zone` setting
- Fix adding "official" Trakt lists
## [25.2.0] - 2025-06-24
### Added

17
ErsatzTV.Application/MediaCollections/Commands/AddTraktListHandler.cs

@ -58,7 +58,7 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add @@ -58,7 +58,7 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
// if we get a url, ensure it's for trakt.tv
Match match = Uri.IsWellFormedUriString(request.TraktListUrl, UriKind.Absolute)
? UriTraktListRegex().Match(request.TraktListUrl)
? MatchTraktListUrl(request.TraktListUrl)
: ShorthandTraktListRegex().Match(request.TraktListUrl);
if (match.Success)
@ -71,6 +71,17 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add @@ -71,6 +71,17 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
return BaseError.New("Invalid Trakt list url");
}
private static Match MatchTraktListUrl(string traktListUrl)
{
Match match = UriTraktListRegex().Match(traktListUrl);
if (!match.Success)
{
match = UriTraktListRegex2().Match(traktListUrl);
}
return match;
}
private async Task<Either<BaseError, Unit>> DoAdd(Parameters parameters)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -80,6 +91,7 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add @@ -80,6 +91,7 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
foreach (TraktList list in maybeList.RightToSeq())
{
list.User = parameters.User.ToLowerInvariant();
maybeList = await SaveList(dbContext, list);
}
@ -100,6 +112,9 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add @@ -100,6 +112,9 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
[GeneratedRegex(@"https:\/\/trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
private static partial Regex UriTraktListRegex();
[GeneratedRegex(@"https:\/\/trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
private static partial Regex UriTraktListRegex2();
[GeneratedRegex(@"([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
private static partial Regex ShorthandTraktListRegex();

5
ErsatzTV.Application/MediaCollections/Commands/DeletePlaylistGroupHandler.cs

@ -18,6 +18,11 @@ public class DeletePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFa @@ -18,6 +18,11 @@ public class DeletePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFa
foreach (PlaylistGroup playlistGroup in maybePlaylistGroup)
{
if (playlistGroup.IsSystem)
{
return BaseError.New("Cannot delete system playlist group");
}
dbContext.PlaylistGroups.Remove(playlistGroup);
await dbContext.SaveChangesAsync(cancellationToken);
}

5
ErsatzTV.Application/MediaCollections/Commands/DeletePlaylistHandler.cs

@ -18,6 +18,11 @@ public class DeletePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory @@ -18,6 +18,11 @@ public class DeletePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory
foreach (Playlist playlist in maybePlaylist)
{
if (playlist.IsSystem)
{
return BaseError.New("Cannot delete system (generated) playlist");
}
dbContext.Playlists.Remove(playlist);
await dbContext.SaveChangesAsync(cancellationToken);
}

41
ErsatzTV.Application/MediaCollections/Commands/TraktCommandBase.cs

@ -44,6 +44,8 @@ public abstract class TraktCommandBase @@ -44,6 +44,8 @@ public abstract class TraktCommandBase
dbContext.TraktLists
.Include(l => l.Items)
.ThenInclude(i => i.Guids)
.Include(tl => tl.Playlist)
.ThenInclude(tl => tl.Items)
.SelectOneAsync(c => c.Id, c => c.Id == traktListId)
.Map(o => o.ToValidation<BaseError>($"TraktList {traktListId} does not exist."));
@ -173,6 +175,45 @@ public abstract class TraktCommandBase @@ -173,6 +175,45 @@ public abstract class TraktCommandBase
break;
}
if (list.GeneratePlaylist)
{
if (item.MediaItemId is not null)
{
// found matching item - sync as playlist item
PlaylistItem playlistItem = list.Playlist.Items.FirstOrDefault(i => i.Index == item.Rank);
if (playlistItem is null)
{
playlistItem = new PlaylistItem
{
Index = item.Rank,
PlaylistId = list.Playlist.Id,
Playlist = list.Playlist,
IncludeInProgramGuide = true
};
await dbContext.PlaylistItems.AddAsync(playlistItem);
}
playlistItem.CollectionType = item.Kind switch
{
TraktListItemKind.Movie => ProgramScheduleItemCollectionType.Movie,
TraktListItemKind.Show => ProgramScheduleItemCollectionType.TelevisionShow,
TraktListItemKind.Season => ProgramScheduleItemCollectionType.TelevisionSeason,
_ => ProgramScheduleItemCollectionType.Episode
};
playlistItem.MediaItemId = item.MediaItemId;
}
else
{
// could not find item; remove playlist item if present
foreach (PlaylistItem maybeItem in list.Playlist.Items.Find(i => i.Index == item.Rank))
{
list.Playlist.Items.Remove(maybeItem);
}
}
}
}
if (await dbContext.SaveChangesAsync() > 0)

2
ErsatzTV.Application/MediaCollections/Commands/UpdateTraktList.cs

@ -2,4 +2,4 @@ using ErsatzTV.Core; @@ -2,4 +2,4 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record UpdateTraktList(int Id, bool AutoRefresh) : IRequest<Option<BaseError>>;
public record UpdateTraktList(int Id, bool AutoRefresh, bool GeneratePlaylist) : IRequest<Option<BaseError>>;

60
ErsatzTV.Application/MediaCollections/Commands/UpdateTraktListHandler.cs

@ -1,10 +1,17 @@ @@ -1,10 +1,17 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class UpdateTraktListHandler(IDbContextFactory<TvContext> dbContextFactory)
public class UpdateTraktListHandler(
IDbContextFactory<TvContext> dbContextFactory,
IEntityLocker entityLocker,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<UpdateTraktList, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(UpdateTraktList request, CancellationToken cancellationToken)
@ -13,11 +20,52 @@ public class UpdateTraktListHandler(IDbContextFactory<TvContext> dbContextFactor @@ -13,11 +20,52 @@ public class UpdateTraktListHandler(IDbContextFactory<TvContext> dbContextFactor
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await dbContext.TraktLists
.Where(tl => tl.Id == request.Id)
.ExecuteUpdateAsync(
u => u.SetProperty(p => p.AutoRefresh, p => request.AutoRefresh),
cancellationToken);
Option<TraktList> maybeTraktList = await dbContext.TraktLists
.Include(tl => tl.Playlist)
.SelectOneAsync(t => t.Id, t => t.Id == request.Id);
foreach (TraktList traktList in maybeTraktList)
{
traktList.AutoRefresh = request.AutoRefresh;
traktList.GeneratePlaylist = request.GeneratePlaylist;
await dbContext.SaveChangesAsync(cancellationToken);
if (request.GeneratePlaylist)
{
if (traktList.PlaylistId is null)
{
PlaylistGroup traktListGroup = await dbContext.PlaylistGroups
.Filter(pg => pg.IsSystem)
.FirstOrDefaultAsync(cancellationToken);
var playlist = new Playlist
{
IsSystem = true,
Name = $"{traktList.User}/{traktList.List}",
PlaylistGroupId = traktListGroup.Id,
PlaylistGroup = traktListGroup
};
traktList.Playlist = playlist;
await dbContext.Playlists.AddAsync(playlist, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
if (entityLocker.LockTrakt())
{
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
}
}
else if (traktList.PlaylistId is not null)
{
// delete playlist
await dbContext.Playlists
.Where(p => p.Id == traktList.PlaylistId)
.ExecuteDeleteAsync(cancellationToken);
}
}
return Option<BaseError>.None;
}

10
ErsatzTV.Application/MediaCollections/Mapper.cs

@ -31,7 +31,8 @@ internal static class Mapper @@ -31,7 +31,8 @@ internal static class Mapper
traktList.Name,
traktList.ItemCount,
traktList.Items.Count(i => i.MediaItemId.HasValue),
traktList.AutoRefresh);
traktList.AutoRefresh,
traktList.GeneratePlaylist);
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
new(
@ -53,13 +54,14 @@ internal static class Mapper @@ -53,13 +54,14 @@ internal static class Mapper
playlistGroups.Map(bg => new TreeGroupViewModel(
bg.Id,
bg.Name,
bg.Playlists.Map(b => new TreeItemViewModel(b.Id, b.Name)).ToList())).ToList());
bg.Playlists.Map(b => new TreeItemViewModel(b.Id, b.Name, b.IsSystem)).ToList(),
bg.IsSystem)).ToList());
internal static PlaylistGroupViewModel ProjectToViewModel(PlaylistGroup playlistGroup) =>
new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count);
new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count, playlistGroup.IsSystem);
internal static PlaylistViewModel ProjectToViewModel(Playlist playlist) =>
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name);
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name, playlist.IsSystem);
internal static PlaylistItemViewModel ProjectToViewModel(PlaylistItem playlistItem) =>
new(

2
ErsatzTV.Application/MediaCollections/PlaylistGroupViewModel.cs

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record PlaylistGroupViewModel(int Id, string Name, int PlaylistCount);
public record PlaylistGroupViewModel(int Id, string Name, int PlaylistCount, bool IsSystem);

2
ErsatzTV.Application/MediaCollections/PlaylistViewModel.cs

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name);
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name, bool IsSystem);

2
ErsatzTV.Application/MediaCollections/Queries/GetAllPlaylistGroupsHandler.cs

@ -15,6 +15,8 @@ public class GetAllPlaylistGroupsHandler(IDbContextFactory<TvContext> dbContextF @@ -15,6 +15,8 @@ public class GetAllPlaylistGroupsHandler(IDbContextFactory<TvContext> dbContextF
List<PlaylistGroup> playlistGroups = await dbContext.PlaylistGroups
.AsNoTracking()
.OrderByDescending(pg => pg.IsSystem)
.ThenBy(pg => pg.Name)
.Include(g => g.Playlists)
.ToListAsync(cancellationToken);

2
ErsatzTV.Application/MediaCollections/Queries/GetPlaylistTreeHandler.cs

@ -16,6 +16,8 @@ public class GetPlaylistTreeHandler(IDbContextFactory<TvContext> dbContextFactor @@ -16,6 +16,8 @@ public class GetPlaylistTreeHandler(IDbContextFactory<TvContext> dbContextFactor
List<PlaylistGroup> playlistGroups = await dbContext.PlaylistGroups
.AsNoTracking()
.OrderByDescending(pg => pg.IsSystem)
.ThenBy(pg => pg.Name)
.Include(g => g.Playlists)
.ToListAsync(cancellationToken);

3
ErsatzTV.Application/MediaCollections/TraktListViewModel.cs

@ -7,4 +7,5 @@ public record TraktListViewModel( @@ -7,4 +7,5 @@ public record TraktListViewModel(
string Name,
int ItemCount,
int MatchCount,
bool AutoRefresh);
bool AutoRefresh,
bool GeneratePlaylist);

2
ErsatzTV.Application/Tree/TreeGroupViewModel.cs

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Tree;
public record TreeGroupViewModel(int Id, string Name, List<TreeItemViewModel> Children);
public record TreeGroupViewModel(int Id, string Name, List<TreeItemViewModel> Children, bool IsSystem = false);

2
ErsatzTV.Application/Tree/TreeItemViewModel.cs

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Tree;
public record TreeItemViewModel(int Id, string Name);
public record TreeItemViewModel(int Id, string Name, bool IsSystem = false);

1
ErsatzTV.Core/Domain/Collection/Playlist.cs

@ -6,6 +6,7 @@ public class Playlist @@ -6,6 +6,7 @@ public class Playlist
public int PlaylistGroupId { get; set; }
public PlaylistGroup PlaylistGroup { get; set; }
public string Name { get; set; }
public bool IsSystem { get; set; }
public ICollection<PlaylistItem> Items { get; set; }
//public DateTime DateUpdated { get; set; }

1
ErsatzTV.Core/Domain/Collection/PlaylistGroup.cs

@ -4,5 +4,6 @@ public class PlaylistGroup @@ -4,5 +4,6 @@ public class PlaylistGroup
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsSystem { get; set; }
public ICollection<Playlist> Playlists { get; set; }
}

3
ErsatzTV.Core/Domain/Collection/TraktList.cs

@ -10,6 +10,9 @@ public class TraktList @@ -10,6 +10,9 @@ public class TraktList
public string Description { get; set; }
public int ItemCount { get; set; }
public bool AutoRefresh { get; set; }
public bool GeneratePlaylist { get; set; }
public int? PlaylistId { get; set; }
public Playlist Playlist { get; set; }
public DateTime? LastUpdate { get; set; }
public DateTime? LastMatch { get; set; }
public List<TraktListItem> Items { get; set; }

5947
ErsatzTV.Infrastructure.MySql/Migrations/20250719152525_Add_TraktListPlaylist.Designer.cs generated

File diff suppressed because it is too large Load Diff

60
ErsatzTV.Infrastructure.MySql/Migrations/20250719152525_Add_TraktListPlaylist.cs

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_TraktListPlaylist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "GeneratePlaylist",
table: "TraktList",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "PlaylistId",
table: "TraktList",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_TraktList_PlaylistId",
table: "TraktList",
column: "PlaylistId");
migrationBuilder.AddForeignKey(
name: "FK_TraktList_Playlist_PlaylistId",
table: "TraktList",
column: "PlaylistId",
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TraktList_Playlist_PlaylistId",
table: "TraktList");
migrationBuilder.DropIndex(
name: "IX_TraktList_PlaylistId",
table: "TraktList");
migrationBuilder.DropColumn(
name: "GeneratePlaylist",
table: "TraktList");
migrationBuilder.DropColumn(
name: "PlaylistId",
table: "TraktList");
}
}
}

5953
ErsatzTV.Infrastructure.MySql/Migrations/20250719153708_Add_PlaylistIsSystem.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure.MySql/Migrations/20250719153708_Add_PlaylistIsSystem.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlaylistIsSystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsSystem",
table: "PlaylistGroup",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsSystem",
table: "Playlist",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsSystem",
table: "PlaylistGroup");
migrationBuilder.DropColumn(
name: "IsSystem",
table: "Playlist");
}
}
}

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

@ -1623,6 +1623,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1623,6 +1623,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsSystem")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.HasColumnType("varchar(255)");
@ -1645,6 +1648,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1645,6 +1648,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsSystem")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.HasColumnType("varchar(255)");
@ -3090,6 +3096,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3090,6 +3096,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<bool>("GeneratePlaylist")
.HasColumnType("tinyint(1)");
b.Property<int>("ItemCount")
.HasColumnType("int");
@ -3105,6 +3114,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3105,6 +3114,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Name")
.HasColumnType("longtext");
b.Property<int?>("PlaylistId")
.HasColumnType("int");
b.Property<int>("TraktId")
.HasColumnType("int");
@ -3113,6 +3125,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3113,6 +3125,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasKey("Id");
b.HasIndex("PlaylistId");
b.ToTable("TraktList", (string)null);
});
@ -5092,6 +5106,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5092,6 +5106,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktList", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Playlist", "Playlist")
.WithMany()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Playlist");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")

5786
ErsatzTV.Infrastructure.Sqlite/Migrations/20250719152544_Add_TraktListPlaylist.Designer.cs generated

File diff suppressed because it is too large Load Diff

60
ErsatzTV.Infrastructure.Sqlite/Migrations/20250719152544_Add_TraktListPlaylist.cs

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_TraktListPlaylist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "GeneratePlaylist",
table: "TraktList",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "PlaylistId",
table: "TraktList",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_TraktList_PlaylistId",
table: "TraktList",
column: "PlaylistId");
migrationBuilder.AddForeignKey(
name: "FK_TraktList_Playlist_PlaylistId",
table: "TraktList",
column: "PlaylistId",
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TraktList_Playlist_PlaylistId",
table: "TraktList");
migrationBuilder.DropIndex(
name: "IX_TraktList_PlaylistId",
table: "TraktList");
migrationBuilder.DropColumn(
name: "GeneratePlaylist",
table: "TraktList");
migrationBuilder.DropColumn(
name: "PlaylistId",
table: "TraktList");
}
}
}

5792
ErsatzTV.Infrastructure.Sqlite/Migrations/20250719153643_Add_PlaylistIsSystem.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure.Sqlite/Migrations/20250719153643_Add_PlaylistIsSystem.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlaylistIsSystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsSystem",
table: "PlaylistGroup",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsSystem",
table: "Playlist",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsSystem",
table: "PlaylistGroup");
migrationBuilder.DropColumn(
name: "IsSystem",
table: "Playlist");
}
}
}

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

@ -1542,6 +1542,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1542,6 +1542,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@ -1562,6 +1565,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1562,6 +1565,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@ -2939,6 +2945,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2939,6 +2945,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("GeneratePlaylist")
.HasColumnType("INTEGER");
b.Property<int>("ItemCount")
.HasColumnType("INTEGER");
@ -2954,6 +2963,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2954,6 +2963,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<int>("TraktId")
.HasColumnType("INTEGER");
@ -2962,6 +2974,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2962,6 +2974,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasKey("Id");
b.HasIndex("PlaylistId");
b.ToTable("TraktList", (string)null);
});
@ -4931,6 +4945,16 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4931,6 +4945,16 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktList", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Playlist", "Playlist")
.WithMany()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Playlist");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")

6
ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListConfiguration.cs

@ -14,5 +14,11 @@ public class TraktListConfiguration : IEntityTypeConfiguration<TraktList> @@ -14,5 +14,11 @@ public class TraktListConfiguration : IEntityTypeConfiguration<TraktList>
.WithOne(i => i.TraktList)
.HasForeignKey(i => i.TraktListId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Playlist)
.WithMany()
.HasForeignKey(i => i.PlaylistId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
}
}

12
ErsatzTV.Infrastructure/Data/DbInitializer.cs

@ -48,6 +48,18 @@ public static class DbInitializer @@ -48,6 +48,18 @@ public static class DbInitializer
await context.SaveChangesAsync(cancellationToken);
}
if (!context.PlaylistGroups.Any(pg => pg.IsSystem))
{
var pg = new PlaylistGroup
{
Name = "Trakt Lists",
IsSystem = true
};
await context.PlaylistGroups.AddAsync(pg, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
if (context.Resolutions.Any(x => x.Width == 1920))
{
return Unit.Default;

12
ErsatzTV.Infrastructure/Trakt/ITraktApi.cs

@ -19,4 +19,16 @@ public interface ITraktApi @@ -19,4 +19,16 @@ public interface ITraktApi
string clientId,
string user,
string list);
[Get("/lists/{list}")]
Task<TraktListResponse> GetOfficialList(
[Header("trakt-api-key")]
string clientId,
string list);
[Get("/lists/{list}/items")]
Task<List<TraktListItemResponse>> GetOfficialListItems(
[Header("trakt-api-key")]
string clientId,
string list);
}

17
ErsatzTV.Infrastructure/Trakt/TraktApiClient.cs

@ -13,15 +13,12 @@ namespace ErsatzTV.Infrastructure.Trakt; @@ -13,15 +13,12 @@ namespace ErsatzTV.Infrastructure.Trakt;
public class TraktApiClient : ITraktApiClient
{
private readonly ILogger<TraktApiClient> _logger;
private readonly ITraktApi _traktApi;
private readonly IOptions<TraktConfiguration> _traktConfiguration;
public TraktApiClient(
ITraktApi traktApi,
IOptions<TraktConfiguration> traktConfiguration,
ILogger<TraktApiClient> logger)
{
_traktApi = traktApi;
_traktConfiguration = traktConfiguration;
_logger = logger;
}
@ -30,10 +27,9 @@ public class TraktApiClient : ITraktApiClient @@ -30,10 +27,9 @@ public class TraktApiClient : ITraktApiClient
{
try
{
TraktListResponse response = await JsonService().GetUserList(
_traktConfiguration.Value.ClientId,
user,
list);
TraktListResponse response = string.Equals(user, "official", StringComparison.OrdinalIgnoreCase)
? await JsonService().GetOfficialList(_traktConfiguration.Value.ClientId, list)
: await JsonService().GetUserList(_traktConfiguration.Value.ClientId, user, list);
return new TraktList
{
@ -62,10 +58,9 @@ public class TraktApiClient : ITraktApiClient @@ -62,10 +58,9 @@ public class TraktApiClient : ITraktApiClient
{
var result = new List<TraktListItemWithGuids>();
List<TraktListItemResponse> apiItems = await _traktApi.GetUserListItems(
_traktConfiguration.Value.ClientId,
user,
list);
List<TraktListItemResponse> apiItems = string.Equals(user, "official", StringComparison.OrdinalIgnoreCase)
? await JsonService().GetOfficialListItems(_traktConfiguration.Value.ClientId, list)
: await JsonService().GetUserListItems(_traktConfiguration.Value.ClientId, user, list);
foreach (TraktListItemResponse apiItem in apiItems)
{

31
ErsatzTV/Pages/PlaylistEditor.razor

@ -13,22 +13,22 @@ @@ -13,22 +13,22 @@
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
<div class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveChanges" StartIcon="@Icons.Material.Filled.Save">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@SaveChanges" StartIcon="@Icons.Material.Filled.Save" Disabled="@(_playlist.IsSystem)">
Save Playlist
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="AddPlaylistItem" StartIcon="@Icons.Material.Filled.PlaylistAdd">
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="@AddPlaylistItem" StartIcon="@Icons.Material.Filled.PlaylistAdd" Disabled="@(_playlist.IsSystem)">
Add Playlist Item
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Secondary" OnClick="PreviewPlayout" StartIcon="@Icons.Material.Filled.Preview">
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Secondary" OnClick="@PreviewPlayout" StartIcon="@Icons.Material.Filled.Preview">
Preview Playlist Playout
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save Playlist" OnClick="SaveChanges"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Playlist Item" OnClick="AddPlaylistItem"/>
<MudMenuItem Icon="@Icons.Material.Filled.Preview" Label="Preview Playlist Playout" OnClick="PreviewPlayout"/>
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save Playlist" OnClick="@SaveChanges" Disabled="@(_playlist.IsSystem)"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Playlist Item" OnClick="@AddPlaylistItem" Disabled="@(_playlist.IsSystem)"/>
<MudMenuItem Icon="@Icons.Material.Filled.Preview" Label="Preview Playlist Playout" OnClick="@PreviewPlayout"/>
</MudMenu>
</div>
</div>
@ -41,7 +41,7 @@ @@ -41,7 +41,7 @@
<div class="d-flex">
<MudText>Name</MudText>
</div>
<MudTextField @bind-Value="_playlist.Name" For="@(() => _playlist.Name)"/>
<MudTextField @bind-Value="_playlist.Name" For="@(() => _playlist.Name)" Disabled="@(_playlist.IsSystem)"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playlist Items</MudText>
<MudDivider Class="mb-6"/>
@ -81,32 +81,34 @@ @@ -81,32 +81,34 @@
</MudText>
</MudTd>
<MudTd DataLabel="Play All">
<MudCheckBox T="bool" Value="@context.PlayAll" ValueChanged="@(e => UpdatePlayAll(context, e))"/>
<MudCheckBox T="bool" Value="@context.PlayAll" ValueChanged="@(e => UpdatePlayAll(context, e))" Disabled="@(_playlist.IsSystem)"/>
</MudTd>
<MudTd DataLabel="Show In EPG">
<MudCheckBox T="bool" Value="@context.IncludeInProgramGuide" ValueChanged="@(e => UpdateEPG(context, e))"/>
<MudCheckBox T="bool" Value="@context.IncludeInProgramGuide" ValueChanged="@(e => UpdateEPG(context, e))" Disabled="@(_playlist.IsSystem)"/>
</MudTd>
<MudTd>
<div class="d-flex">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyItem(context))">
OnClick="@(_ => CopyItem(context))"
Disabled="@(_playlist.IsSystem)">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
OnClick="@(_ => MoveItemUp(context))"
Disabled="@(_playlist.Items.All(x => x.Index >= context.Index))">
Disabled="@(_playlist.IsSystem || _playlist.Items.All(x => x.Index >= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
OnClick="@(_ => MoveItemDown(context))"
Disabled="@(_playlist.Items.All(x => x.Index <= context.Index))">
Disabled="@(_playlist.IsSystem || _playlist.Items.All(x => x.Index <= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemovePlaylistItem(context))">
OnClick="@(_ => RemovePlaylistItem(context))"
Disabled="@(_playlist.IsSystem)">
</MudIconButton>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@if (_selectedItem is not null)
@if (!_playlist.IsSystem && _selectedItem is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playlist Item</MudText>
<MudDivider Class="mb-6"/>
@ -350,6 +352,7 @@ @@ -350,6 +352,7 @@
_playlist = new PlaylistItemsEditViewModel
{
Name = playlist.Name,
IsSystem = playlist.IsSystem,
Items = []
};
}

29
ErsatzTV/Pages/Playlists.razor

@ -72,7 +72,11 @@ @@ -72,7 +72,11 @@
{
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Href="@($"media/playlists/{playlistId}")"/>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="@(_ => DeleteItem(item.Value))"/>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Medium"
Color="Color.Inherit"
Disabled="@item.Value.IsSystem"
OnClick="@(_ => DeleteItem(item.Value))"/>
</div>
</div>
</BodyContent>
@ -128,12 +132,9 @@ @@ -128,12 +132,9 @@
Logger.LogError("Unexpected error adding playlist group: {Error}", error.Value);
}
foreach (PlaylistGroupViewModel playlistGroup in result.RightToSeq())
foreach (PlaylistGroupViewModel _ in result.RightToSeq())
{
_treeItems.Add(new TreeItemData<PlaylistTreeItemViewModel> { Value = new PlaylistTreeItemViewModel(playlistGroup) });
_playlistGroupName = null;
_playlistGroups = await Mediator.Send(new GetAllPlaylistGroups(), _cts.Token);
await ReloadPlaylistTree();
await InvokeAsync(StateHasChanged);
}
}
@ -175,7 +176,13 @@ @@ -175,7 +176,13 @@
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeletePlaylistGroup(playlistGroupId), _cts.Token);
Option<BaseError> deleteResult = await Mediator.Send(new DeletePlaylistGroup(playlistGroupId), _cts.Token);
foreach (BaseError error in deleteResult)
{
Snackbar.Add(error.ToString(), Severity.Error);
return;
}
_treeItems.RemoveAll(i => i.Value?.PlaylistGroupId == playlistGroupId);
if (_selectedPlaylistGroup?.Id == playlistGroupId)
{
@ -196,7 +203,13 @@ @@ -196,7 +203,13 @@
DialogResult result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await Mediator.Send(new DeletePlaylist(playlistId), _cts.Token);
Option<BaseError> deleteResult = await Mediator.Send(new DeletePlaylist(playlistId), _cts.Token);
foreach (BaseError error in deleteResult)
{
Snackbar.Add(error.ToString(), Severity.Error);
return;
}
foreach (PlaylistTreeItemViewModel parent in _treeItems.Map(i => i.Value))
{
parent.TreeItems.RemoveAll(i => i.Value == treeItem);

11
ErsatzTV/Pages/TraktListEditor.razor

@ -30,6 +30,14 @@ @@ -30,6 +30,14 @@
<MudText Typo="Typo.caption" Style="font-weight: normal">Update list from trakt.tv once each day</MudText>
</MudCheckBox>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Generate Playlist</MudText>
</div>
<MudCheckBox @bind-Value="@_model.GeneratePlaylist" For="@(() => _model.GeneratePlaylist)" Dense="true">
<MudText Typo="Typo.caption" Style="font-weight: normal">Generate a playlist from the sorted trakt list items</MudText>
</MudCheckBox>
</MudStack>
</MudContainer>
</div>
</MudForm>
@ -59,6 +67,7 @@ @@ -59,6 +67,7 @@
_model.Id = viewModel.Id;
_model.Slug = viewModel.Slug;
_model.AutoRefresh = viewModel.AutoRefresh;
_model.GeneratePlaylist = viewModel.GeneratePlaylist;
},
() => NavigationManager.NavigateTo("404"));
}
@ -68,7 +77,7 @@ @@ -68,7 +77,7 @@
await _form.Validate();
if (_success)
{
var request = new UpdateTraktList(_model.Id, _model.AutoRefresh);
var request = new UpdateTraktList(_model.Id, _model.AutoRefresh, _model.GeneratePlaylist);
Option<BaseError> result = await Mediator.Send(request, _cts.Token);
foreach (BaseError error in result)
{

1
ErsatzTV/ViewModels/PlaylistItemsEditViewModel.cs

@ -3,5 +3,6 @@ @@ -3,5 +3,6 @@
public class PlaylistItemsEditViewModel
{
public string Name { get; set; }
public bool IsSystem { get; set; }
public List<PlaylistItemEditViewModel> Items { get; set; }
}

6
ErsatzTV/ViewModels/PlaylistTreeItemViewModel.cs

@ -13,6 +13,7 @@ public class PlaylistTreeItemViewModel @@ -13,6 +13,7 @@ public class PlaylistTreeItemViewModel
TreeItems = [];
PlaylistGroupId = playlistGroup.Id;
Icon = Icons.Material.Filled.Folder;
IsSystem = playlistGroup.IsSystem;
}
public PlaylistTreeItemViewModel(TreeGroupViewModel playlistGroup)
@ -23,6 +24,7 @@ public class PlaylistTreeItemViewModel @@ -23,6 +24,7 @@ public class PlaylistTreeItemViewModel
{ Value = new PlaylistTreeItemViewModel(p) }).ToList();
PlaylistGroupId = playlistGroup.Id;
Icon = Icons.Material.Filled.Folder;
IsSystem = playlistGroup.IsSystem;
}
public PlaylistTreeItemViewModel(PlaylistViewModel playlist)
@ -31,6 +33,7 @@ public class PlaylistTreeItemViewModel @@ -31,6 +33,7 @@ public class PlaylistTreeItemViewModel
TreeItems = [];
CanExpand = false;
PlaylistId = playlist.Id;
IsSystem = playlist.IsSystem;
}
public PlaylistTreeItemViewModel(TreeItemViewModel playlist)
@ -39,6 +42,7 @@ public class PlaylistTreeItemViewModel @@ -39,6 +42,7 @@ public class PlaylistTreeItemViewModel
TreeItems = [];
CanExpand = false;
PlaylistId = playlist.Id;
IsSystem = playlist.IsSystem;
}
public string Text { get; }
@ -53,5 +57,7 @@ public class PlaylistTreeItemViewModel @@ -53,5 +57,7 @@ public class PlaylistTreeItemViewModel
public int? PlaylistGroupId { get; }
public bool IsSystem { get; }
public List<TreeItemData<PlaylistTreeItemViewModel>> TreeItems { get; }
}

1
ErsatzTV/ViewModels/TraktListEditViewModel.cs

@ -5,4 +5,5 @@ public class TraktListEditViewModel @@ -5,4 +5,5 @@ public class TraktListEditViewModel
public int Id { get; set; }
public string Slug { get; set; }
public bool AutoRefresh { get; set; }
public bool GeneratePlaylist { get; set; }
}

Loading…
Cancel
Save