Added "Preview" edit view, flesh out media picker
- Added sort order to media for screenshots and videos - Added new "Preview" pane to game editor. Simulates the game details view in the launcher and allows for in-place media upload / grabbing - Added abstractions for media grabbing - Added subtitle to media picker dialog to indicate which services provide which assets - Added preview for videos - Made media searching faster overall
This commit is contained in:
parent
4362e7c22f
commit
3fe6bea7e5
35 changed files with 11684 additions and 334 deletions
930
LANCommander.Launcher.Data/Migrations/20260423000000_AddMediaSortOrder.Designer.cs
generated
Normal file
930
LANCommander.Launcher.Data/Migrations/20260423000000_AddMediaSortOrder.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,930 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using LANCommander.Launcher.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Launcher.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(DatabaseContext))]
|
||||
[Migration("20260423000000_AddMediaSortOrder")]
|
||||
partial class AddMediaSortOrder
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
|
||||
|
||||
modelBuilder.Entity("CategoryGame", b =>
|
||||
{
|
||||
b.Property<Guid>("CategoriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GamesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CategoriesId", "GamesId");
|
||||
|
||||
b.HasIndex("GamesId");
|
||||
|
||||
b.ToTable("CategoryGame");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CollectionGame", b =>
|
||||
{
|
||||
b.Property<Guid>("CollectionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GameId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CollectionId", "GameId");
|
||||
|
||||
b.HasIndex("GameId");
|
||||
|
||||
b.ToTable("CollectionGame");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameDeveloper", b =>
|
||||
{
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GameId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("DeveloperId", "GameId");
|
||||
|
||||
b.HasIndex("GameId");
|
||||
|
||||
b.ToTable("GameDeveloper");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameGenre", b =>
|
||||
{
|
||||
b.Property<Guid>("GamesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GenresId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("GamesId", "GenresId");
|
||||
|
||||
b.HasIndex("GenresId");
|
||||
|
||||
b.ToTable("GameGenre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GamePlatform", b =>
|
||||
{
|
||||
b.Property<Guid>("GamesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PlatformsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("GamesId", "PlatformsId");
|
||||
|
||||
b.HasIndex("PlatformsId");
|
||||
|
||||
b.ToTable("GamePlatform");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GamePublisher", b =>
|
||||
{
|
||||
b.Property<Guid>("GameId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("GameId", "PublisherId");
|
||||
|
||||
b.HasIndex("PublisherId");
|
||||
|
||||
b.ToTable("GamePublisher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameRedistributable", b =>
|
||||
{
|
||||
b.Property<Guid>("GameId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RedistributableId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("GameId", "RedistributableId");
|
||||
|
||||
b.HasIndex("RedistributableId");
|
||||
|
||||
b.ToTable("GameRedistributable");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameTag", b =>
|
||||
{
|
||||
b.Property<Guid>("GamesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("GamesId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("GameTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameTool", b =>
|
||||
{
|
||||
b.Property<Guid>("GamesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ToolsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("GamesId", "ToolsId");
|
||||
|
||||
b.HasIndex("ToolsId");
|
||||
|
||||
b.ToTable("GameTool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ParentId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Collection", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Collections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Company", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Companies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Engine", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Engines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("BaseGameId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("EngineId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("IGDBId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InstallDirectory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Installed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("InstalledOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InstalledVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LatestVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReleasedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Singleplayer")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SortTitle")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BaseGameId");
|
||||
|
||||
b.HasIndex("EngineId");
|
||||
|
||||
b.ToTable("Games");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Genre", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Genres");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Library", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Libraries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Media", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Crc32")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("FileId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("GameId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameId");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Media");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.MultiplayerMode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("GameId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NetworkProtocol")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Spectators")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameId");
|
||||
|
||||
b.ToTable("MultiplayerModes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Platform", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Platforms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.PlaySession", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("End")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("GameId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Start")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameId");
|
||||
|
||||
b.ToTable("PlaySessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Redistributable", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Redistributables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tool", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InstallDirectory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Installed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("InstalledOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InstalledVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LatestVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Tools");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Alias")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ImportedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LibraryGame", b =>
|
||||
{
|
||||
b.Property<Guid>("GameId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("GameId", "LibraryId");
|
||||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("LibraryGame");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryGame", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GamesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CollectionGame", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Collection", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CollectionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GameId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameDeveloper", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Company", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GameId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameGenre", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GamesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Genre", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GenresId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GamePlatform", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GamesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Platform", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("PlatformsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GamePublisher", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GameId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Company", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("PublisherId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameRedistributable", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GameId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Redistributable", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RedistributableId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameTag", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GamesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameTool", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GamesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Tool", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ToolsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Category", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", "BaseGame")
|
||||
.WithMany("DependentGames")
|
||||
.HasForeignKey("BaseGameId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Engine", "Engine")
|
||||
.WithMany("Games")
|
||||
.HasForeignKey("EngineId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("BaseGame");
|
||||
|
||||
b.Navigation("Engine");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Library", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.User", "User")
|
||||
.WithOne("Library")
|
||||
.HasForeignKey("LANCommander.Launcher.Data.Models.Library", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Media", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game")
|
||||
.WithMany("Media")
|
||||
.HasForeignKey("GameId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.User", "User")
|
||||
.WithOne("Avatar")
|
||||
.HasForeignKey("LANCommander.Launcher.Data.Models.Media", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Game");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.MultiplayerMode", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game")
|
||||
.WithMany("MultiplayerModes")
|
||||
.HasForeignKey("GameId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Game");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.PlaySession", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game")
|
||||
.WithMany("PlaySessions")
|
||||
.HasForeignKey("GameId");
|
||||
|
||||
b.Navigation("Game");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LibraryGame", b =>
|
||||
{
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Game", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GameId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LANCommander.Launcher.Data.Models.Library", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("LibraryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Engine", b =>
|
||||
{
|
||||
b.Navigation("Games");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b =>
|
||||
{
|
||||
b.Navigation("DependentGames");
|
||||
|
||||
b.Navigation("Media");
|
||||
|
||||
b.Navigation("MultiplayerModes");
|
||||
|
||||
b.Navigation("PlaySessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LANCommander.Launcher.Data.Models.User", b =>
|
||||
{
|
||||
b.Navigation("Avatar");
|
||||
|
||||
b.Navigation("Library");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Launcher.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMediaSortOrder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SortOrder",
|
||||
table: "Media",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SortOrder",
|
||||
table: "Media");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -404,6 +404,9 @@ namespace LANCommander.Launcher.Data.Migrations
|
|||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ namespace LANCommander.Launcher.Data.Models
|
|||
[MaxLength(8)]
|
||||
public string Crc32 { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public Guid? GameId { get; set; }
|
||||
[JsonIgnore]
|
||||
[ForeignKey(nameof(GameId))]
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ namespace LANCommander.SDK.Models
|
|||
public string SourceUrl { get; set; }
|
||||
public string MimeType { get; set; }
|
||||
public string Crc32 { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2791
LANCommander.Server.Data.MySQL/Migrations/20260423000000_AddMediaSortOrder.Designer.cs
generated
Normal file
2791
LANCommander.Server.Data.MySQL/Migrations/20260423000000_AddMediaSortOrder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Server.Data.MySQL.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMediaSortOrder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SortOrder",
|
||||
table: "Media",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SortOrder",
|
||||
table: "Media");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -752,6 +752,9 @@ namespace LANCommander.Server.Data.MySQL.Migrations
|
|||
.HasMaxLength(2048)
|
||||
.HasColumnType("varchar(2048)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("StorageLocationId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
|
|
|
|||
2791
LANCommander.Server.Data.PostgreSQL/Migrations/20260423000000_AddMediaSortOrder.Designer.cs
generated
Normal file
2791
LANCommander.Server.Data.PostgreSQL/Migrations/20260423000000_AddMediaSortOrder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMediaSortOrder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SortOrder",
|
||||
table: "Media",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SortOrder",
|
||||
table: "Media");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -752,6 +752,9 @@ namespace LANCommander.Server.Data.PostgreSQL.Migrations
|
|||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("StorageLocationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
|
|
|||
3206
LANCommander.Server.Data.SQLite/Migrations/20260423000000_AddMediaSortOrder.Designer.cs
generated
Normal file
3206
LANCommander.Server.Data.SQLite/Migrations/20260423000000_AddMediaSortOrder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LANCommander.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMediaSortOrder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SortOrder",
|
||||
table: "Media",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SortOrder",
|
||||
table: "Media");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -875,6 +875,9 @@ namespace LANCommander.Migrations
|
|||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("StorageLocationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ namespace LANCommander.Server.Data.Models
|
|||
[MaxLength(8)]
|
||||
public string Crc32 { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public Media? Thumbnail { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.Server.Services.Models;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace LANCommander.Server.Services.Abstractions
|
||||
{
|
||||
public interface IMediaGrabberService
|
||||
{
|
||||
string Name { get; }
|
||||
MediaType[] SupportedMediaTypes { get; }
|
||||
Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords);
|
||||
Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result);
|
||||
|
||||
async IAsyncEnumerable<IEnumerable<MediaGrabberResult>> SearchStreamAsync(
|
||||
MediaType type, string keywords,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return await SearchAsync(type, keywords);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,10 @@ public static class IServiceCollectionExtensions
|
|||
services.AddScoped<MediaService>();
|
||||
services.AddScoped<RedistributableService>();
|
||||
services.AddScoped<ToolService>();
|
||||
services.AddScoped<IMediaGrabberService, SteamGridDBMediaGrabber>();
|
||||
services.AddScoped<SteamMediaGrabber>();
|
||||
services.AddScoped<SteamGridDBMediaGrabber>();
|
||||
services.AddScoped<YouTubeMediaGrabber>();
|
||||
services.AddScoped<IMediaGrabberService, CompositeMediaGrabberService>();
|
||||
services.AddScoped<UpdateService>();
|
||||
services.AddScoped<IssueService>();
|
||||
services.AddScoped<PageService>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.Server.Services.Abstractions;
|
||||
using LANCommander.Server.Services.Models;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace LANCommander.Server.Services.MediaGrabbers
|
||||
{
|
||||
public class CompositeMediaGrabberService : IMediaGrabberService
|
||||
{
|
||||
private readonly List<IMediaGrabberService> _grabbers;
|
||||
|
||||
public string Name => "All";
|
||||
public MediaType[] SupportedMediaTypes => _grabbers.SelectMany(g => g.SupportedMediaTypes).Distinct().ToArray();
|
||||
|
||||
public CompositeMediaGrabberService(
|
||||
SteamMediaGrabber steam,
|
||||
SteamGridDBMediaGrabber steamGridDb,
|
||||
YouTubeMediaGrabber youtube)
|
||||
{
|
||||
_grabbers = new List<IMediaGrabberService> { steam, steamGridDb, youtube };
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
|
||||
{
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
await foreach (var batch in SearchStreamAsync(type, keywords))
|
||||
results.AddRange(batch);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<IEnumerable<MediaGrabberResult>> SearchStreamAsync(
|
||||
MediaType type, string keywords,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var applicable = _grabbers.Where(g => g.SupportedMediaTypes.Contains(type)).ToList();
|
||||
|
||||
if (applicable.Count == 0)
|
||||
yield break;
|
||||
|
||||
var channel = Channel.CreateUnbounded<IEnumerable<MediaGrabberResult>>();
|
||||
|
||||
var tasks = applicable.Select(async grabber =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = (await grabber.SearchAsync(type, keywords)).ToList();
|
||||
|
||||
foreach (var result in results)
|
||||
result.GrabberName = grabber.Name;
|
||||
|
||||
await channel.Writer.WriteAsync(results, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Individual grabber failures shouldn't break the stream
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
_ = Task.WhenAll(tasks).ContinueWith(_ => channel.Writer.Complete(), cancellationToken);
|
||||
|
||||
await foreach (var batch in channel.Reader.ReadAllAsync(cancellationToken))
|
||||
yield return batch;
|
||||
}
|
||||
|
||||
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
|
||||
{
|
||||
var grabber = _grabbers.FirstOrDefault(g => g.Name == result.GrabberName)
|
||||
?? _grabbers.First();
|
||||
|
||||
return await grabber.DownloadAsync(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,25 @@
|
|||
using craftersmine.SteamGridDBNet;
|
||||
using craftersmine.SteamGridDBNet;
|
||||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.Steam;
|
||||
using System.Net.Mime;
|
||||
using LANCommander.Server.Services.Abstractions;
|
||||
using LANCommander.Server.Services.Models;
|
||||
using YoutubeExplode;
|
||||
using YoutubeExplode.Common;
|
||||
|
||||
namespace LANCommander.Server.Services.MediaGrabbers
|
||||
{
|
||||
public class SteamGridDBMediaGrabber : IMediaGrabberService
|
||||
{
|
||||
SteamGridDb SteamGridDb { get; set; }
|
||||
SteamClient SteamClient { get; set; }
|
||||
|
||||
public string Name => "SteamGridDB";
|
||||
|
||||
public MediaType[] SupportedMediaTypes =>
|
||||
[
|
||||
MediaType.Icon,
|
||||
MediaType.Cover,
|
||||
MediaType.Background,
|
||||
MediaType.Logo,
|
||||
MediaType.Grid
|
||||
];
|
||||
|
||||
private SteamGridDbFormats[] SupportedFormats = new SteamGridDbFormats[]
|
||||
{
|
||||
|
|
@ -25,22 +32,12 @@ namespace LANCommander.Server.Services.MediaGrabbers
|
|||
public SteamGridDBMediaGrabber(SettingsProvider<Settings.Settings> settingsProvider)
|
||||
{
|
||||
SteamGridDb = new SteamGridDb(settingsProvider.CurrentValue.Server.Media.SteamGridDbApiKey);
|
||||
SteamClient = new SteamClient();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
|
||||
{
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
if (type == MediaType.Manual)
|
||||
return await GetManualsAsync(keywords);
|
||||
|
||||
if (type == MediaType.Screenshot)
|
||||
return await GetScreenshotsAsync(keywords);
|
||||
|
||||
if (type == MediaType.Video)
|
||||
return await GetVideosAsync(keywords);
|
||||
|
||||
var games = await SteamGridDb.SearchForGamesAsync(keywords);
|
||||
|
||||
foreach (var game in games)
|
||||
|
|
@ -72,6 +69,18 @@ namespace LANCommander.Server.Services.MediaGrabbers
|
|||
return results;
|
||||
}
|
||||
|
||||
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
|
||||
{
|
||||
var http = new HttpClient();
|
||||
var stream = await http.GetStreamAsync(result.SourceUrl);
|
||||
|
||||
return new MediaGrabberDownload
|
||||
{
|
||||
Stream = stream,
|
||||
MimeType = result.MimeType
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetIconsAsync(SteamGridDbGame game)
|
||||
{
|
||||
var icons = await SteamGridDb.GetIconsByGameIdAsync(game.Id);
|
||||
|
|
@ -89,31 +98,9 @@ namespace LANCommander.Server.Services.MediaGrabbers
|
|||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetCoversAsync(SteamGridDbGame game)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(game.Name);
|
||||
|
||||
var covers = await SteamGridDb.GetGridsByGameIdAsync(game.Id);
|
||||
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var existsResult = await SteamClient.HasWebAssetAsync(appIdResult.AppId, WebAssetType.LibraryCover);
|
||||
|
||||
if (existsResult.Exists && !results.Any(r => r.Id == appIdResult.AppId.ToString()))
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Background,
|
||||
SourceUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.LibraryCover).ToString(),
|
||||
ThumbnailUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.LibraryCover).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = existsResult.MimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.AddRange(covers.Where(b => SupportedFormats.Contains(b.Format) && !results.Any(r => r.Id == b.Id.ToString())).Select(b => new MediaGrabberResult()
|
||||
return covers.Where(b => SupportedFormats.Contains(b.Format)).Select(b => new MediaGrabberResult()
|
||||
{
|
||||
Id = b.Id.ToString(),
|
||||
Type = MediaType.Cover,
|
||||
|
|
@ -121,38 +108,14 @@ namespace LANCommander.Server.Services.MediaGrabbers
|
|||
ThumbnailUrl = b.ThumbnailImageUrl,
|
||||
Group = game.Name,
|
||||
MimeType = GetMimeType(b.Format)
|
||||
}));
|
||||
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetBackgroundsAsync(SteamGridDbGame game)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(game.Name);
|
||||
|
||||
var backgrounds = await SteamGridDb.GetHeroesByGameIdAsync(game.Id);
|
||||
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var existsResult = await SteamClient.HasWebAssetAsync(appIdResult.AppId, WebAssetType.LibraryHero);
|
||||
|
||||
if (existsResult.Exists)
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Background,
|
||||
SourceUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.LibraryHero).ToString(),
|
||||
ThumbnailUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.LibraryHero).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = existsResult.MimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.AddRange(backgrounds.Where(b => SupportedFormats.Contains(b.Format)).Select(b => new MediaGrabberResult()
|
||||
return backgrounds.Where(b => SupportedFormats.Contains(b.Format)).Select(b => new MediaGrabberResult()
|
||||
{
|
||||
Id = b.Id.ToString(),
|
||||
Type = MediaType.Background,
|
||||
|
|
@ -160,38 +123,14 @@ namespace LANCommander.Server.Services.MediaGrabbers
|
|||
ThumbnailUrl = b.ThumbnailImageUrl,
|
||||
Group = game.Name,
|
||||
MimeType = GetMimeType(b.Format)
|
||||
}));
|
||||
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetLogosAsync(SteamGridDbGame game)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(game.Name);
|
||||
|
||||
var logos = await SteamGridDb.GetLogosByGameIdAsync(game.Id);
|
||||
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var existsResult = await SteamClient.HasWebAssetAsync(appIdResult.AppId, WebAssetType.Logo);
|
||||
|
||||
if (existsResult.Exists)
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Logo,
|
||||
SourceUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.Logo).ToString(),
|
||||
ThumbnailUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.Logo).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = existsResult.MimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.AddRange(logos.Where(b => SupportedFormats.Contains(b.Format)).Select(b => new MediaGrabberResult()
|
||||
return logos.Where(b => SupportedFormats.Contains(b.Format)).Select(b => new MediaGrabberResult()
|
||||
{
|
||||
Id = b.Id.ToString(),
|
||||
Type = MediaType.Logo,
|
||||
|
|
@ -199,9 +138,7 @@ namespace LANCommander.Server.Services.MediaGrabbers
|
|||
ThumbnailUrl = b.ThumbnailImageUrl,
|
||||
Group = game.Name,
|
||||
MimeType = GetMimeType(b.Format)
|
||||
}));
|
||||
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetGridsAsync(SteamGridDbGame game)
|
||||
|
|
@ -210,29 +147,7 @@ namespace LANCommander.Server.Services.MediaGrabbers
|
|||
game.Id,
|
||||
dimensions: SteamGridDbDimensions.W460H215 | SteamGridDbDimensions.W920H430);
|
||||
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(game.Name);
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var existsResult = await SteamClient.HasWebAssetAsync(appIdResult.AppId, WebAssetType.Header);
|
||||
|
||||
if (existsResult.Exists && !results.Any(r => r.Id == appIdResult.AppId.ToString()))
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Grid,
|
||||
SourceUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.Header).ToString(),
|
||||
ThumbnailUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.Header).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = existsResult.MimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.AddRange(grids.Where(g => SupportedFormats.Contains(g.Format)).Select(g => new MediaGrabberResult()
|
||||
return grids.Where(g => SupportedFormats.Contains(g.Format)).Select(g => new MediaGrabberResult()
|
||||
{
|
||||
Id = g.Id.ToString(),
|
||||
Type = MediaType.Grid,
|
||||
|
|
@ -240,120 +155,7 @@ namespace LANCommander.Server.Services.MediaGrabbers
|
|||
ThumbnailUrl = g.ThumbnailImageUrl,
|
||||
Group = game.Name,
|
||||
MimeType = GetMimeType(g.Format)
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetScreenshotsAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var screenshots = await SteamClient.GetScreenshotsAsync(appIdResult.AppId);
|
||||
|
||||
foreach (var screenshot in screenshots)
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = $"{appIdResult.AppId}_{screenshot.Id}",
|
||||
Type = MediaType.Screenshot,
|
||||
SourceUrl = screenshot.PathFull,
|
||||
ThumbnailUrl = screenshot.PathThumbnail,
|
||||
Group = appIdResult.Name,
|
||||
MimeType = MediaTypeNames.Image.Jpeg
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetVideosAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var movies = await SteamClient.GetMoviesAsync(appIdResult.AppId);
|
||||
|
||||
foreach (var movie in movies)
|
||||
{
|
||||
var sourceUrl = movie.Mp4?.Max ?? movie.Mp4?.Resolution480;
|
||||
|
||||
if (String.IsNullOrWhiteSpace(sourceUrl))
|
||||
continue;
|
||||
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = movie.Id.ToString(),
|
||||
Type = MediaType.Video,
|
||||
SourceUrl = sourceUrl,
|
||||
ThumbnailUrl = movie.Thumbnail,
|
||||
Group = appIdResult.Name,
|
||||
MimeType = "video/mp4"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.AddRange(await GetYouTubeVideosAsync(keywords));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetYouTubeVideosAsync(string keywords)
|
||||
{
|
||||
var youtube = new YoutubeClient();
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var video in (await youtube.Search.GetVideosAsync(keywords + "trailer gameplay")).Take(20))
|
||||
{
|
||||
var thumbnail = video.Thumbnails.GetWithHighestResolution();
|
||||
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = video.Id,
|
||||
Type = MediaType.Video,
|
||||
SourceUrl = $"https://www.youtube.com/watch?v={video.Id}",
|
||||
ThumbnailUrl = thumbnail?.Url ?? $"https://img.youtube.com/vi/{video.Id}/hqdefault.jpg",
|
||||
Group = "YouTube",
|
||||
MimeType = "video/mp4"
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetManualsAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var hasManual = await SteamClient.HasManualAsync(appIdResult.AppId);
|
||||
|
||||
if (!hasManual)
|
||||
continue;
|
||||
|
||||
var result = new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Manual,
|
||||
SourceUrl = SteamClient.GetManualUri(appIdResult.AppId).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = MediaTypeNames.Application.Pdf,
|
||||
ThumbnailUrl = "/static/pdf.png"
|
||||
};
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
private string GetMimeType(SteamGridDbFormats format)
|
||||
|
|
|
|||
245
LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs
Normal file
245
LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.Steam;
|
||||
using System.Net.Mime;
|
||||
using LANCommander.Server.Services.Abstractions;
|
||||
using LANCommander.Server.Services.Models;
|
||||
|
||||
namespace LANCommander.Server.Services.MediaGrabbers
|
||||
{
|
||||
public class SteamMediaGrabber : IMediaGrabberService
|
||||
{
|
||||
SteamClient SteamClient { get; set; }
|
||||
|
||||
public string Name => "Steam";
|
||||
|
||||
public MediaType[] SupportedMediaTypes =>
|
||||
[
|
||||
MediaType.Cover,
|
||||
MediaType.Background,
|
||||
MediaType.Logo,
|
||||
MediaType.Grid,
|
||||
MediaType.Screenshot,
|
||||
MediaType.Video,
|
||||
MediaType.Manual
|
||||
];
|
||||
|
||||
public SteamMediaGrabber()
|
||||
{
|
||||
SteamClient = new SteamClient();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
MediaType.Cover => await GetCoversAsync(keywords),
|
||||
MediaType.Background => await GetBackgroundsAsync(keywords),
|
||||
MediaType.Logo => await GetLogosAsync(keywords),
|
||||
MediaType.Grid => await GetGridsAsync(keywords),
|
||||
MediaType.Screenshot => await GetScreenshotsAsync(keywords),
|
||||
MediaType.Video => await GetVideosAsync(keywords),
|
||||
MediaType.Manual => await GetManualsAsync(keywords),
|
||||
_ => Enumerable.Empty<MediaGrabberResult>()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
|
||||
{
|
||||
var http = new HttpClient();
|
||||
var stream = await http.GetStreamAsync(result.SourceUrl);
|
||||
|
||||
return new MediaGrabberDownload
|
||||
{
|
||||
Stream = stream,
|
||||
MimeType = result.MimeType
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetCoversAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var existsResult = await SteamClient.HasWebAssetAsync(appIdResult.AppId, WebAssetType.LibraryCover);
|
||||
|
||||
if (existsResult.Exists && !results.Any(r => r.Id == appIdResult.AppId.ToString()))
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Cover,
|
||||
SourceUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.LibraryCover).ToString(),
|
||||
ThumbnailUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.LibraryCover).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = existsResult.MimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetBackgroundsAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var existsResult = await SteamClient.HasWebAssetAsync(appIdResult.AppId, WebAssetType.LibraryHero);
|
||||
|
||||
if (existsResult.Exists)
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Background,
|
||||
SourceUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.LibraryHero).ToString(),
|
||||
ThumbnailUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.LibraryHero).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = existsResult.MimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetLogosAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var existsResult = await SteamClient.HasWebAssetAsync(appIdResult.AppId, WebAssetType.Logo);
|
||||
|
||||
if (existsResult.Exists)
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Logo,
|
||||
SourceUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.Logo).ToString(),
|
||||
ThumbnailUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.Logo).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = existsResult.MimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetGridsAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var existsResult = await SteamClient.HasWebAssetAsync(appIdResult.AppId, WebAssetType.Header);
|
||||
|
||||
if (existsResult.Exists && !results.Any(r => r.Id == appIdResult.AppId.ToString()))
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Grid,
|
||||
SourceUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.Header).ToString(),
|
||||
ThumbnailUrl = SteamClient.GetWebAssetUri(appIdResult.AppId, WebAssetType.Header).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = existsResult.MimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetScreenshotsAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var screenshots = await SteamClient.GetScreenshotsAsync(appIdResult.AppId);
|
||||
|
||||
foreach (var screenshot in screenshots)
|
||||
{
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = $"{appIdResult.AppId}_{screenshot.Id}",
|
||||
Type = MediaType.Screenshot,
|
||||
SourceUrl = screenshot.PathFull,
|
||||
ThumbnailUrl = screenshot.PathThumbnail,
|
||||
Group = appIdResult.Name,
|
||||
MimeType = MediaTypeNames.Image.Jpeg
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetVideosAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var movies = await SteamClient.GetMoviesAsync(appIdResult.AppId);
|
||||
|
||||
foreach (var movie in movies)
|
||||
{
|
||||
var sourceUrl = movie.Mp4?.Max ?? movie.Mp4?.Resolution480;
|
||||
|
||||
if (String.IsNullOrWhiteSpace(sourceUrl))
|
||||
continue;
|
||||
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = movie.Id.ToString(),
|
||||
Type = MediaType.Video,
|
||||
SourceUrl = sourceUrl,
|
||||
ThumbnailUrl = movie.Thumbnail,
|
||||
Group = appIdResult.Name,
|
||||
MimeType = "video/mp4"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<MediaGrabberResult>> GetManualsAsync(string keywords)
|
||||
{
|
||||
var appIdResults = await SteamClient.SearchGamesAsync(keywords);
|
||||
var results = new List<MediaGrabberResult>();
|
||||
|
||||
foreach (var appIdResult in appIdResults)
|
||||
{
|
||||
var hasManual = await SteamClient.HasManualAsync(appIdResult.AppId);
|
||||
|
||||
if (!hasManual)
|
||||
continue;
|
||||
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = appIdResult.AppId.ToString(),
|
||||
Type = MediaType.Manual,
|
||||
SourceUrl = SteamClient.GetManualUri(appIdResult.AppId).ToString(),
|
||||
Group = appIdResult.Name,
|
||||
MimeType = MediaTypeNames.Application.Pdf,
|
||||
ThumbnailUrl = "/static/pdf.png"
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
using LANCommander.SDK;
|
||||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.Server.Services.Abstractions;
|
||||
using LANCommander.Server.Services.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
using YoutubeExplode;
|
||||
using YoutubeExplode.Common;
|
||||
|
||||
namespace LANCommander.Server.Services.MediaGrabbers
|
||||
{
|
||||
public class YouTubeMediaGrabber(ILogger<YouTubeMediaGrabber> logger) : IMediaGrabberService
|
||||
{
|
||||
public string Name => "YouTube";
|
||||
|
||||
public MediaType[] SupportedMediaTypes => [MediaType.Video];
|
||||
|
||||
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
|
||||
{
|
||||
var youtube = new YoutubeClient();
|
||||
var results = new List<MediaGrabberResult>();
|
||||
var group = $"{keywords.Trim()}: Trailers & Gameplay";
|
||||
|
||||
foreach (var video in (await youtube.Search.GetVideosAsync(keywords + " trailer gameplay")).Take(20))
|
||||
{
|
||||
var thumbnail = video.Thumbnails.GetWithHighestResolution();
|
||||
|
||||
results.Add(new MediaGrabberResult()
|
||||
{
|
||||
Id = video.Id,
|
||||
Type = MediaType.Video,
|
||||
SourceUrl = $"https://www.youtube.com/watch?v={video.Id}",
|
||||
ThumbnailUrl = thumbnail?.Url ?? $"https://img.youtube.com/vi/{video.Id}/hqdefault.jpg",
|
||||
Group = group,
|
||||
MimeType = "video/mp4"
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
|
||||
{
|
||||
if (!IsYouTubeUrl(result.SourceUrl))
|
||||
{
|
||||
var http = new HttpClient();
|
||||
var stream = await http.GetStreamAsync(result.SourceUrl);
|
||||
|
||||
return new MediaGrabberDownload
|
||||
{
|
||||
Stream = stream,
|
||||
MimeType = result.MimeType
|
||||
};
|
||||
}
|
||||
|
||||
var ytdlpPath = await EnsureYtDlpAsync();
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var outputTemplate = Path.Combine(tempDir, "video.%(ext)s");
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ytdlpPath,
|
||||
Arguments = $"--no-playlist -f \"bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best\" --merge-output-format mp4 -o \"{outputTemplate}\" \"{result.SourceUrl}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var error = await process.StandardError.ReadToEndAsync();
|
||||
|
||||
if (Directory.Exists(tempDir))
|
||||
Directory.Delete(tempDir, true);
|
||||
|
||||
throw new Exception($"yt-dlp failed with exit code {process.ExitCode}: {error}");
|
||||
}
|
||||
|
||||
var outputFile = Directory.GetFiles(tempDir).FirstOrDefault();
|
||||
if (outputFile == null)
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
Directory.Delete(tempDir, true);
|
||||
|
||||
throw new Exception("yt-dlp did not produce an output file");
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(outputFile).TrimStart('.').ToLowerInvariant();
|
||||
var mimeType = ext switch
|
||||
{
|
||||
"mp4" => "video/mp4",
|
||||
"webm" => "video/webm",
|
||||
"mkv" => "video/x-matroska",
|
||||
_ => "video/mp4"
|
||||
};
|
||||
|
||||
var stream2 = new TempFileStream(outputFile, tempDir);
|
||||
|
||||
return new MediaGrabberDownload
|
||||
{
|
||||
Stream = stream2,
|
||||
MimeType = mimeType
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsYouTubeUrl(string url) =>
|
||||
url.Contains("youtube.com/") || url.Contains("youtu.be/");
|
||||
|
||||
private static readonly SemaphoreSlim _ytdlpLock = new(1, 1);
|
||||
|
||||
private async Task<string> EnsureYtDlpAsync()
|
||||
{
|
||||
var isWindows = OperatingSystem.IsWindows();
|
||||
var fileName = isWindows ? "yt-dlp.exe" : "yt-dlp";
|
||||
|
||||
var pathDirs = (Environment.GetEnvironmentVariable("PATH") ?? "")
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var dir in pathDirs)
|
||||
{
|
||||
var candidate = Path.Combine(dir, fileName);
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var toolsDir = Path.Combine(AppPaths.GetConfigDirectory(), "Tools");
|
||||
var localPath = Path.Combine(toolsDir, fileName);
|
||||
|
||||
if (File.Exists(localPath))
|
||||
return localPath;
|
||||
|
||||
await _ytdlpLock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(localPath))
|
||||
return localPath;
|
||||
|
||||
Directory.CreateDirectory(toolsDir);
|
||||
|
||||
var downloadUrl = isWindows
|
||||
? "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||
: "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp";
|
||||
|
||||
logger.LogInformation("Downloading yt-dlp from {Url}", downloadUrl);
|
||||
|
||||
using var http = new HttpClient();
|
||||
using var response = await http.GetAsync(downloadUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using (var fs = new FileStream(localPath, FileMode.Create, FileAccess.Write))
|
||||
{
|
||||
await response.Content.CopyToAsync(fs);
|
||||
}
|
||||
|
||||
if (!isWindows)
|
||||
{
|
||||
using var chmod = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "chmod",
|
||||
Arguments = $"+x \"{localPath}\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
});
|
||||
|
||||
if (chmod != null)
|
||||
await chmod.WaitForExitAsync();
|
||||
}
|
||||
|
||||
logger.LogInformation("yt-dlp downloaded to {Path}", localPath);
|
||||
|
||||
return localPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download yt-dlp");
|
||||
throw new Exception("yt-dlp is required to download YouTube videos but could not be found or downloaded. Install it manually or ensure internet access.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ytdlpLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TempFileStream : FileStream
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public TempFileStream(string filePath, string tempDir)
|
||||
: base(filePath, FileMode.Open, FileAccess.Read)
|
||||
{
|
||||
_tempDir = tempDir;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@ using Microsoft.AspNetCore.Http;
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using LANCommander.SDK;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LANCommander.Server.Services
|
||||
{
|
||||
|
|
@ -279,9 +278,6 @@ namespace LANCommander.Server.Services
|
|||
|
||||
public async Task<Media> DownloadMediaAsync(string sourceUrl, Media media)
|
||||
{
|
||||
if (IsYouTubeUrl(sourceUrl))
|
||||
return await DownloadYouTubeVideoAsync(sourceUrl, media);
|
||||
|
||||
using (var http = new HttpClient())
|
||||
{
|
||||
var response = await http.GetStreamAsync(sourceUrl);
|
||||
|
|
@ -290,61 +286,6 @@ namespace LANCommander.Server.Services
|
|||
}
|
||||
}
|
||||
|
||||
private static bool IsYouTubeUrl(string url) =>
|
||||
url.Contains("youtube.com/") || url.Contains("youtu.be/");
|
||||
|
||||
private async Task<Media> DownloadYouTubeVideoAsync(string url, Media media)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var outputTemplate = Path.Combine(tempDir, "video.%(ext)s");
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "yt-dlp",
|
||||
Arguments = $"--no-playlist -f \"bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best\" --merge-output-format mp4 -o \"{outputTemplate}\" \"{url}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var error = await process.StandardError.ReadToEndAsync();
|
||||
throw new Exception($"yt-dlp failed with exit code {process.ExitCode}: {error}");
|
||||
}
|
||||
|
||||
var outputFile = Directory.GetFiles(tempDir).FirstOrDefault();
|
||||
if (outputFile == null)
|
||||
throw new Exception("yt-dlp did not produce an output file");
|
||||
|
||||
var ext = Path.GetExtension(outputFile).TrimStart('.').ToLowerInvariant();
|
||||
media.MimeType = ext switch
|
||||
{
|
||||
"mp4" => "video/mp4",
|
||||
"webm" => "video/webm",
|
||||
"mkv" => "video/x-matroska",
|
||||
_ => "video/mp4"
|
||||
};
|
||||
|
||||
using var stream = new FileStream(outputFile, FileMode.Open, FileAccess.Read);
|
||||
return await WriteToFileAsync(media, stream);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<StorageLocation> GetDefaultStorageLocationAsync()
|
||||
{
|
||||
var defaultStorageLocation = await storageLocationService.FirstOrDefaultAsync(l => l.Type == StorageLocationType.Media && l.Default);
|
||||
|
|
|
|||
13
LANCommander.Server.Services/Models/MediaGrabberDownload.cs
Normal file
13
LANCommander.Server.Services/Models/MediaGrabberDownload.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace LANCommander.Server.Services.Models
|
||||
{
|
||||
public class MediaGrabberDownload : IDisposable
|
||||
{
|
||||
public Stream Stream { get; set; }
|
||||
public string MimeType { get; set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.SDK.Enums;
|
||||
|
||||
namespace LANCommander.Server.Services.Models
|
||||
{
|
||||
|
|
@ -10,5 +10,7 @@ namespace LANCommander.Server.Services.Models
|
|||
public string ThumbnailUrl { get; set; }
|
||||
public string Group { get; set; }
|
||||
public string MimeType { get; set; }
|
||||
public string GrabberName { get; set; }
|
||||
public string UniqueKey { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
372
LANCommander.Server/Styles/_game-detail-preview.scss
Normal file
372
LANCommander.Server/Styles/_game-detail-preview.scss
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
.game-detail-preview {
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
|
||||
// ── Hero Section ─────────────────────────────────────────────
|
||||
&__hero {
|
||||
position: relative;
|
||||
height: 340px;
|
||||
background: #111;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__hero-gradient-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.33), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__hero-gradient-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 128px;
|
||||
background: linear-gradient(to bottom, transparent, #000);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__logo-area {
|
||||
position: absolute;
|
||||
bottom: 48px;
|
||||
left: 48px;
|
||||
max-width: 33%;
|
||||
max-height: 50%;
|
||||
}
|
||||
|
||||
&__logo-img {
|
||||
max-width: 100%;
|
||||
max-height: 160px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__title-fallback {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
// ── Content Grid ─────────────────────────────────────────────
|
||||
&__content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: -100px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__description {
|
||||
line-height: 1.6;
|
||||
opacity: 0.85;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
// ── Cover ────────────────────────────────────────────────────
|
||||
&__cover-wrapper {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #3D1A6B, #0F3460);
|
||||
}
|
||||
|
||||
&__cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__cover-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// ── Badge sections ───────────────────────────────────────────
|
||||
&__badge-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__badge-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__badge-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.13);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// ── Media overlay (hover-to-edit) ────────────────────────────
|
||||
&__media-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__media-overlay--hero {
|
||||
// Only show in top-right corner to avoid blocking logo
|
||||
inset: auto;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: auto;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
flex-direction: row;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.game-detail-preview__hero:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__media-overlay--logo {
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.65);
|
||||
padding: 6px 10px;
|
||||
flex-direction: row;
|
||||
position: absolute;
|
||||
inset: auto;
|
||||
bottom: -36px;
|
||||
left: 0;
|
||||
width: auto;
|
||||
|
||||
.game-detail-preview__logo-area:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__media-overlay--cover {
|
||||
border-radius: 8px;
|
||||
|
||||
.game-detail-preview__cover-wrapper:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__media-overlay-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__media-overlay-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__overlay-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
border-color: rgba(255,77,79,0.5);
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255,77,79,0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Screenshots / Videos carousel ────────────────────────────
|
||||
&__media-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__media-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__media-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__media-section-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.ant-btn {
|
||||
color: rgba(255,255,255,0.65) !important;
|
||||
|
||||
&:hover {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
label.ant-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__carousel {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
|
||||
// Scrollbar styling
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
&__carousel-item {
|
||||
position: relative;
|
||||
flex: 0 0 384px;
|
||||
height: 216px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
&__carousel-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__carousel-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__carousel-video-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__carousel-item-overlay {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.game-detail-preview__carousel-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__carousel-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
|
||||
.ant-empty-description {
|
||||
color: rgba(255,255,255,0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +1,27 @@
|
|||
.image-picker-images {
|
||||
.media-picker-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.image-picker-image {
|
||||
.media-picker-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-picker-image img {
|
||||
border: 4px solid transparent;
|
||||
.media-picker-item img {
|
||||
border-radius: 2px;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.image-picker-image input {
|
||||
.media-picker-item input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-picker-image input + label {
|
||||
.media-picker-item input + label {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -30,8 +29,58 @@
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.image-picker-image input:checked ~ img {
|
||||
.media-picker-item input:checked + label {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Video items ──────────────────────────────────────────────
|
||||
.media-picker-item[data-type="video"] {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border-radius: 2px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play button (centered filled arrow) ──────────────────────
|
||||
.media-picker-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6));
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 20px 0 20px 36px;
|
||||
border-color: transparent transparent transparent #fff;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@
|
|||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.media-grabber-group-header {
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.media-grabber-group-source {
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.media-grabber-group + .media-grabber-group {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||
|
|
@ -9,4 +20,56 @@
|
|||
|
||||
[data-theme="Dark"] .media-grabber-group + .media-grabber-group {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.media-grabber-loading-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
// ── Video preview ────────────────────────────────────────────
|
||||
.media-grabber-preview {
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
background: #000;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
&__player {
|
||||
position: relative;
|
||||
max-width: 75vw;
|
||||
max-height: 75vh;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
iframe,
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
@use '_button';
|
||||
@use '_card';
|
||||
@use '_media-editor';
|
||||
@use '_game-detail-preview';
|
||||
@use '_media-grabber';
|
||||
@use '_image-picker';
|
||||
@use '_game-metadata-lookup';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<div class="image-picker">
|
||||
<div class="image-picker-images">
|
||||
<div class="media-picker">
|
||||
<div class="media-picker-items">
|
||||
@foreach (var image in Images)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
<div class="image-picker-image" style="width: @(Size)px; max-height: @(Size)px">
|
||||
<input type="radio" id="image-picker-image-@id" checked="@(Value == image.Key)" name="SelectedResult" @onchange="@(() => SelectionChanged(image.Key))" />
|
||||
<label for="image-picker-image-@id" @ondblclick="@(() => Pick(image.Key))"></label>
|
||||
<div class="media-picker-item" style="width: @(Size)px; max-height: @(Size)px">
|
||||
<input type="radio" id="media-picker-item-@id" checked="@(Value == image.Key)" name="SelectedResult" @onchange="@(() => SelectionChanged(image.Key))" />
|
||||
<label for="media-picker-item-@id" @ondblclick="@(() => Pick(image.Key))"></label>
|
||||
<img src="@image.Value" />
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@inherits FeedbackComponent<MediaGrabberOptions, MediaGrabberResult>
|
||||
@inherits FeedbackComponent<MediaGrabberOptions, MediaGrabberResult>
|
||||
@using LANCommander.Server.Services.Abstractions
|
||||
@using LANCommander.SDK.Enums
|
||||
@inject IMediaGrabberService MediaGrabberService
|
||||
|
|
@ -15,33 +15,93 @@
|
|||
</GridCol>
|
||||
</GridRow>
|
||||
|
||||
<Spin Spinning="Loading">
|
||||
@foreach (var group in Results)
|
||||
{
|
||||
<div class="media-grabber-group">
|
||||
<h2>@group.First().Group</h2>
|
||||
|
||||
<ImagePicker Size="Size" Images="@group.DistinctBy(g => g.Id).ToDictionary(r => r.Id, r => r.ThumbnailUrl)" ValueChanged="OnImageSelected" OnPicked="OnImagePicked" />
|
||||
@if (_preview != null)
|
||||
{
|
||||
<div class="media-grabber-preview">
|
||||
<div class="media-grabber-preview__player">
|
||||
@if (IsYouTubeUrl(_preview.SourceUrl))
|
||||
{
|
||||
<iframe src="@GetYouTubeEmbedUrl(_preview.SourceUrl)"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen />
|
||||
}
|
||||
else
|
||||
{
|
||||
<video src="@_preview.SourceUrl" controls autoplay />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button class="media-grabber-preview__close" @onclick="() => _preview = null" title="Close preview">
|
||||
<Icon Type="@IconType.Outline.Close" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Results.Count() == 0 && !Loading)
|
||||
{
|
||||
<Empty Description="@($"No results for \"{Search}\"")" />
|
||||
}
|
||||
</Spin>
|
||||
@if (Loading && !_groupedResults.Any())
|
||||
{
|
||||
<Spin Spinning="true">
|
||||
<div style="min-height: 120px"></div>
|
||||
</Spin>
|
||||
}
|
||||
|
||||
@foreach (var grabberGroup in _groupedResults)
|
||||
{
|
||||
<div class="media-grabber-group">
|
||||
<div class="media-grabber-group-header">
|
||||
<h2>@grabberGroup.Group</h2>
|
||||
<span class="media-grabber-group-source">via @grabberGroup.GrabberName</span>
|
||||
</div>
|
||||
|
||||
@if (Type == MediaType.Video)
|
||||
{
|
||||
<div class="media-picker">
|
||||
<div class="media-picker-items">
|
||||
@foreach (var result in grabberGroup.Results)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
<div class="media-picker-item" data-type="video" style="width: @(Size)px">
|
||||
<input type="radio" id="media-picker-item-@id" checked="@(Media == result)" name="SelectedResult" @onchange="() => OnResultSelected(result)" />
|
||||
<label for="media-picker-item-@id" @ondblclick="() => OnResultPicked(result)"></label>
|
||||
<img src="@result.ThumbnailUrl" />
|
||||
<button class="media-picker-play" @onclick="() => PreviewVideo(result)" @onclick:stopPropagation="true" title="Preview"></button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ImagePicker Size="Size" Images="@grabberGroup.Results.ToDictionary(r => r.UniqueKey, r => r.ThumbnailUrl)" ValueChanged="OnImageSelected" OnPicked="OnImagePicked" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Loading && _groupedResults.Any())
|
||||
{
|
||||
<div class="media-grabber-loading-more">
|
||||
<Spin Spinning="true" Size="SpinSize.Small" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!_groupedResults.Any() && !Loading)
|
||||
{
|
||||
<Empty Description="@($"No results for \"{Search}\"")" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string Search { get; set; }
|
||||
[Parameter] public MediaType Type { get; set; }
|
||||
|
||||
MediaGrabberResult Media { get; set; }
|
||||
MediaGrabberResult _preview { get; set; }
|
||||
|
||||
double Size { get; set; } = 200;
|
||||
bool Loading { get; set; } = true;
|
||||
|
||||
IEnumerable<IEnumerable<MediaGrabberResult>> Results = new List<List<MediaGrabberResult>>();
|
||||
Dictionary<string, string> Images { get; set; } = new Dictionary<string, string>();
|
||||
CancellationTokenSource _searchCts;
|
||||
List<MediaGrabberResult> _allResults = new();
|
||||
List<GroupedResults> _groupedResults = new();
|
||||
|
||||
protected override async Task OnFirstAfterRenderAsync()
|
||||
{
|
||||
|
|
@ -53,19 +113,38 @@
|
|||
|
||||
private async Task GetResults(MediaType type, string search)
|
||||
{
|
||||
_searchCts?.Cancel();
|
||||
_searchCts = new CancellationTokenSource();
|
||||
var token = _searchCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
Loading = true;
|
||||
_preview = null;
|
||||
_allResults.Clear();
|
||||
_groupedResults.Clear();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
if (!String.IsNullOrWhiteSpace(search))
|
||||
if (String.IsNullOrWhiteSpace(search))
|
||||
return;
|
||||
|
||||
await foreach (var batch in MediaGrabberService.SearchStreamAsync(type, search, token))
|
||||
{
|
||||
Results = (await MediaGrabberService.SearchAsync(type, search)).GroupBy(r => r.Group);
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
Loading = false;
|
||||
foreach (var result in batch)
|
||||
result.UniqueKey = $"{result.GrabberName}:{result.Id}";
|
||||
|
||||
_allResults.AddRange(batch);
|
||||
RebuildGroups();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
Loading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageService.Error("An unknown error occurred when trying to get media results");
|
||||
|
|
@ -75,22 +154,71 @@
|
|||
}
|
||||
}
|
||||
|
||||
private void OnImageSelected(string key)
|
||||
private void RebuildGroups()
|
||||
{
|
||||
Media = Results.SelectMany(g => g).FirstOrDefault(r => r.Id == key);
|
||||
_groupedResults = _allResults
|
||||
.GroupBy(r => new { r.GrabberName, r.Group })
|
||||
.Select(g => new GroupedResults
|
||||
{
|
||||
GrabberName = g.Key.GrabberName,
|
||||
Group = g.Key.Group,
|
||||
Results = g.DistinctBy(r => r.Id).ToList()
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task OnImagePicked(string key)
|
||||
private void OnResultSelected(MediaGrabberResult result)
|
||||
{
|
||||
OnImageSelected(key);
|
||||
Media = result;
|
||||
}
|
||||
|
||||
private async Task OnResultPicked(MediaGrabberResult result)
|
||||
{
|
||||
Media = result;
|
||||
|
||||
await base.OkCancelRefWithResult!.OnOk(Media);
|
||||
await CloseFeedbackAsync();
|
||||
}
|
||||
|
||||
private void OnImageSelected(string uniqueKey)
|
||||
{
|
||||
Media = _allResults.FirstOrDefault(r => r.UniqueKey == uniqueKey);
|
||||
}
|
||||
|
||||
private async Task OnImagePicked(string uniqueKey)
|
||||
{
|
||||
OnImageSelected(uniqueKey);
|
||||
|
||||
await base.OkCancelRefWithResult!.OnOk(Media);
|
||||
await CloseFeedbackAsync();
|
||||
}
|
||||
|
||||
private void PreviewVideo(MediaGrabberResult result)
|
||||
{
|
||||
_preview = _preview?.Id == result.Id ? null : result;
|
||||
}
|
||||
|
||||
private static bool IsYouTubeUrl(string url) =>
|
||||
url.Contains("youtube.com/") || url.Contains("youtu.be/");
|
||||
|
||||
private static string GetYouTubeEmbedUrl(string url)
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var videoId = query["v"] ?? uri.Segments.LastOrDefault();
|
||||
return $"https://www.youtube.com/embed/{videoId}?autoplay=1";
|
||||
}
|
||||
|
||||
public override async Task OnFeedbackOkAsync(ModalClosingEventArgs args)
|
||||
{
|
||||
await base.OkCancelRefWithResult!.OnOk(Media);
|
||||
await CloseFeedbackAsync();
|
||||
}
|
||||
|
||||
private class GroupedResults
|
||||
{
|
||||
public string GrabberName { get; set; }
|
||||
public string Group { get; set; }
|
||||
public List<MediaGrabberResult> Results { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,560 @@
|
|||
@using Microsoft.Extensions.Options
|
||||
@using LANCommander.Server.Settings
|
||||
@using LANCommander.Server.Services.Abstractions
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.Net.Mime
|
||||
@inject MediaService MediaService
|
||||
@inject GameService GameService
|
||||
@inject IMediaGrabberService MediaGrabberService
|
||||
@inject ModalService ModalService
|
||||
@inject IMessageService MessageService
|
||||
@inject IOptions<Settings> Settings
|
||||
@inject ILogger<GameDetailPreview> Logger
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="game-detail-preview">
|
||||
<!-- ══ Hero Section ═══════════════════════════════════════════ -->
|
||||
<div class="game-detail-preview__hero">
|
||||
@{
|
||||
var bgMedia = GetMedia(MediaType.Background);
|
||||
var bgUrl = GetMediaUrl(bgMedia);
|
||||
}
|
||||
|
||||
@if (bgUrl != null)
|
||||
{
|
||||
<img class="game-detail-preview__hero-bg" src="@bgUrl" />
|
||||
}
|
||||
|
||||
<div class="game-detail-preview__hero-gradient-top"></div>
|
||||
<div class="game-detail-preview__hero-gradient-bottom"></div>
|
||||
|
||||
<!-- Background upload overlay -->
|
||||
<div class="game-detail-preview__media-overlay game-detail-preview__media-overlay--hero">
|
||||
<div class="game-detail-preview__media-overlay-actions">
|
||||
@{ var bgInputId = "bg-upload-" + Guid.NewGuid(); }
|
||||
<InputFile id="@bgInputId" OnChange="(e) => UploadMediaByType(e, MediaType.Background)" hidden accept=".jpg,.jpeg,.png,.gif" />
|
||||
<label class="game-detail-preview__overlay-btn" for="@bgInputId" title="Upload Background">
|
||||
<Icon Type="@IconType.Outline.Upload" />
|
||||
</label>
|
||||
<button class="game-detail-preview__overlay-btn" @onclick="() => SearchMediaByType(MediaType.Background)" title="Search Background">
|
||||
<Icon Type="@IconType.Outline.Search" />
|
||||
</button>
|
||||
@if (bgMedia != null)
|
||||
{
|
||||
<button class="game-detail-preview__overlay-btn game-detail-preview__overlay-btn--danger" @onclick="() => RemoveMediaByType(MediaType.Background)" title="Remove Background">
|
||||
<Icon Type="@IconType.Outline.Delete" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="game-detail-preview__media-overlay-label">Background</span>
|
||||
</div>
|
||||
|
||||
<!-- Logo (bottom-left) -->
|
||||
<div class="game-detail-preview__logo-area">
|
||||
@{
|
||||
var logoMedia = GetMedia(MediaType.Logo);
|
||||
var logoUrl = GetMediaUrl(logoMedia);
|
||||
}
|
||||
|
||||
@if (logoUrl != null)
|
||||
{
|
||||
<img class="game-detail-preview__logo-img" src="@logoUrl" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="game-detail-preview__title-fallback">@_game?.Title</span>
|
||||
}
|
||||
|
||||
<!-- Logo upload overlay -->
|
||||
<div class="game-detail-preview__media-overlay game-detail-preview__media-overlay--logo">
|
||||
<div class="game-detail-preview__media-overlay-actions">
|
||||
@{ var logoInputId = "logo-upload-" + Guid.NewGuid(); }
|
||||
<InputFile id="@logoInputId" OnChange="(e) => UploadMediaByType(e, MediaType.Logo)" hidden accept=".jpg,.jpeg,.png,.gif" />
|
||||
<label class="game-detail-preview__overlay-btn" for="@logoInputId" title="Upload Logo">
|
||||
<Icon Type="@IconType.Outline.Upload" />
|
||||
</label>
|
||||
<button class="game-detail-preview__overlay-btn" @onclick="() => SearchMediaByType(MediaType.Logo)" title="Search Logo">
|
||||
<Icon Type="@IconType.Outline.Search" />
|
||||
</button>
|
||||
@if (logoMedia != null)
|
||||
{
|
||||
<button class="game-detail-preview__overlay-btn game-detail-preview__overlay-btn--danger" @onclick="() => RemoveMediaByType(MediaType.Logo)" title="Remove Logo">
|
||||
<Icon Type="@IconType.Outline.Delete" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="game-detail-preview__media-overlay-label">Logo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Content Grid ═══════════════════════════════════════════ -->
|
||||
<div class="game-detail-preview__content">
|
||||
|
||||
<!-- Left Column -->
|
||||
<div class="game-detail-preview__left">
|
||||
<!-- Description -->
|
||||
@if (!string.IsNullOrWhiteSpace(_game?.Description))
|
||||
{
|
||||
<p class="game-detail-preview__description">@_game.Description</p>
|
||||
}
|
||||
|
||||
<!-- Screenshots & Videos Carousel -->
|
||||
<div class="game-detail-preview__media-section">
|
||||
<div class="game-detail-preview__media-section-header">
|
||||
<span class="game-detail-preview__media-section-title">Media</span>
|
||||
<div class="game-detail-preview__media-section-actions">
|
||||
<InputFile id="@_ssInputId" OnChange="(e) => UploadMediaByType(e, MediaType.Screenshot)" hidden accept=".jpg,.jpeg,.png,.gif" />
|
||||
<InputFile id="@_vidInputId" OnChange="(e) => UploadMediaByType(e, MediaType.Video)" hidden accept=".mp4,.webm,.mkv" />
|
||||
|
||||
<Dropdown>
|
||||
<Overlay>
|
||||
<Menu Mode="MenuMode.Vertical">
|
||||
<SubMenu Key="screenshot" Title="Screenshot">
|
||||
<MenuItem OnClick="() => ClickFileInput(_ssInputId)">
|
||||
<Icon Type="@IconType.Outline.Upload" /> Upload
|
||||
</MenuItem>
|
||||
<MenuItem OnClick="() => SearchMediaByType(MediaType.Screenshot)">
|
||||
<Icon Type="@IconType.Outline.Search" /> Search
|
||||
</MenuItem>
|
||||
</SubMenu>
|
||||
<SubMenu Key="video" Title="Video">
|
||||
<MenuItem OnClick="() => ClickFileInput(_vidInputId)">
|
||||
<Icon Type="@IconType.Outline.Upload" /> Upload
|
||||
</MenuItem>
|
||||
<MenuItem OnClick="() => SearchMediaByType(MediaType.Video)">
|
||||
<Icon Type="@IconType.Outline.Search" /> Search
|
||||
</MenuItem>
|
||||
</SubMenu>
|
||||
</Menu>
|
||||
</Overlay>
|
||||
<ChildContent>
|
||||
<Button Size="@ButtonSize.Small" Type="ButtonType.Text">
|
||||
<Icon Type="@IconType.Outline.Plus" />
|
||||
</Button>
|
||||
</ChildContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-detail-preview__carousel">
|
||||
@foreach (var media in GetMediaList(MediaType.Screenshot, MediaType.Video))
|
||||
{
|
||||
<div class="game-detail-preview__carousel-item">
|
||||
@if (media.Type == MediaType.Video)
|
||||
{
|
||||
<video class="game-detail-preview__carousel-video" src="@GetMediaDownloadUrl(media)" muted loop />
|
||||
<div class="game-detail-preview__carousel-video-badge">
|
||||
<Icon Type="@IconType.Outline.PlayCircle" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img class="game-detail-preview__carousel-img" src="@GetMediaDownloadUrl(media)" />
|
||||
}
|
||||
<div class="game-detail-preview__carousel-item-overlay">
|
||||
<button class="game-detail-preview__overlay-btn" @onclick="() => MoveMediaLeft(media)" title="Move Left">
|
||||
<Icon Type="@IconType.Outline.Left" />
|
||||
</button>
|
||||
<button class="game-detail-preview__overlay-btn" @onclick="() => MoveMediaRight(media)" title="Move Right">
|
||||
<Icon Type="@IconType.Outline.Right" />
|
||||
</button>
|
||||
<Popconfirm Title="Remove this media?" OnConfirm="() => RemoveMedia(media)">
|
||||
<button class="game-detail-preview__overlay-btn game-detail-preview__overlay-btn--danger" title="Remove">
|
||||
<Icon Type="@IconType.Outline.Delete" />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!GetMediaList(MediaType.Screenshot, MediaType.Video).Any())
|
||||
{
|
||||
<div class="game-detail-preview__carousel-empty">
|
||||
<Empty Description="@("No screenshots or videos")" Simple />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="game-detail-preview__right">
|
||||
<!-- Cover Image -->
|
||||
<div class="game-detail-preview__cover-wrapper">
|
||||
@{
|
||||
var coverMedia = GetMedia(MediaType.Cover);
|
||||
var coverUrl = GetMediaUrl(coverMedia);
|
||||
}
|
||||
|
||||
@if (coverUrl != null)
|
||||
{
|
||||
<img class="game-detail-preview__cover-img" src="@coverUrl" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="game-detail-preview__cover-placeholder">
|
||||
<span>@_game?.Title</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cover upload overlay -->
|
||||
<div class="game-detail-preview__media-overlay game-detail-preview__media-overlay--cover">
|
||||
<div class="game-detail-preview__media-overlay-actions">
|
||||
@{ var coverInputId = "cover-upload-" + Guid.NewGuid(); }
|
||||
<InputFile id="@coverInputId" OnChange="(e) => UploadMediaByType(e, MediaType.Cover)" hidden accept=".jpg,.jpeg,.png,.gif" />
|
||||
<label class="game-detail-preview__overlay-btn" for="@coverInputId" title="Upload Cover">
|
||||
<Icon Type="@IconType.Outline.Upload" />
|
||||
</label>
|
||||
<button class="game-detail-preview__overlay-btn" @onclick="() => SearchMediaByType(MediaType.Cover)" title="Search Cover">
|
||||
<Icon Type="@IconType.Outline.Search" />
|
||||
</button>
|
||||
@if (coverMedia != null)
|
||||
{
|
||||
<button class="game-detail-preview__overlay-btn game-detail-preview__overlay-btn--danger" @onclick="() => RemoveMediaByType(MediaType.Cover)" title="Remove Cover">
|
||||
<Icon Type="@IconType.Outline.Delete" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="game-detail-preview__media-overlay-label">Cover</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Badges -->
|
||||
@if (_game?.Singleplayer == true || (_game?.MultiplayerModes?.Any() ?? false))
|
||||
{
|
||||
<div class="game-detail-preview__badge-section">
|
||||
<span class="game-detail-preview__badge-header">Players</span>
|
||||
<div class="game-detail-preview__badge-wrap">
|
||||
@if (_game.Singleplayer)
|
||||
{
|
||||
<span class="game-detail-preview__badge">Singleplayer</span>
|
||||
}
|
||||
@if (_game.MultiplayerModes != null)
|
||||
{
|
||||
@foreach (var mode in _game.MultiplayerModes)
|
||||
{
|
||||
<span class="game-detail-preview__badge">
|
||||
@mode.Type.GetDisplayName()
|
||||
@if (mode.MinPlayers > 0 && mode.MaxPlayers > 0)
|
||||
{
|
||||
<text> · @mode.MinPlayers–@mode.MaxPlayers players</text>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_game?.Developers?.Any() == true)
|
||||
{
|
||||
<div class="game-detail-preview__badge-section">
|
||||
<span class="game-detail-preview__badge-header">Developers</span>
|
||||
<div class="game-detail-preview__badge-wrap">
|
||||
@foreach (var dev in _game.Developers)
|
||||
{
|
||||
<span class="game-detail-preview__badge">@dev.Name</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_game?.Publishers?.Any() == true)
|
||||
{
|
||||
<div class="game-detail-preview__badge-section">
|
||||
<span class="game-detail-preview__badge-header">Publishers</span>
|
||||
<div class="game-detail-preview__badge-wrap">
|
||||
@foreach (var pub in _game.Publishers)
|
||||
{
|
||||
<span class="game-detail-preview__badge">@pub.Name</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_game?.Genres?.Any() == true)
|
||||
{
|
||||
<div class="game-detail-preview__badge-section">
|
||||
<span class="game-detail-preview__badge-header">Genres</span>
|
||||
<div class="game-detail-preview__badge-wrap">
|
||||
@foreach (var genre in _game.Genres)
|
||||
{
|
||||
<span class="game-detail-preview__badge">@genre.Name</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_game?.Tags?.Any() == true)
|
||||
{
|
||||
<div class="game-detail-preview__badge-section">
|
||||
<span class="game-detail-preview__badge-header">Tags</span>
|
||||
<div class="game-detail-preview__badge-wrap">
|
||||
@foreach (var tag in _game.Tags)
|
||||
{
|
||||
<span class="game-detail-preview__badge">@tag.Name</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter(Name = "GameId")] public Guid GameId { get; set; }
|
||||
|
||||
string _ssInputId = "ss-upload-" + Guid.NewGuid();
|
||||
string _vidInputId = "vid-upload-" + Guid.NewGuid();
|
||||
|
||||
string[] ValidImageMimeTypes = [
|
||||
MediaTypeNames.Image.Png,
|
||||
MediaTypeNames.Image.Jpeg,
|
||||
MediaTypeNames.Image.Gif,
|
||||
];
|
||||
|
||||
string[] ValidVideoMimeTypes = [
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/x-matroska"
|
||||
];
|
||||
|
||||
List<Media> _media = new();
|
||||
Game _game;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_game = await GameService.Query(q =>
|
||||
{
|
||||
return q
|
||||
.Include(g => g.Media)
|
||||
.ThenInclude(m => m.StorageLocation)
|
||||
.Include(g => g.Developers)
|
||||
.Include(g => g.Publishers)
|
||||
.Include(g => g.Genres)
|
||||
.Include(g => g.Tags)
|
||||
.Include(g => g.MultiplayerModes);
|
||||
})
|
||||
.GetAsync(GameId);
|
||||
|
||||
_media = _game.Media?.ToList() ?? new();
|
||||
}
|
||||
|
||||
async Task ClickFileInput(string inputId) =>
|
||||
await JS.InvokeVoidAsync("clickElement", inputId);
|
||||
|
||||
Media? GetMedia(MediaType type) =>
|
||||
_media.FirstOrDefault(m => m.Type == type);
|
||||
|
||||
string? GetMediaUrl(Media? media) =>
|
||||
media != null && MediaService.FileExists(media)
|
||||
? $"/api/Media/{media.Id}/Download?fileId={media.FileId}&v={media.UpdatedOn.Ticks}"
|
||||
: null;
|
||||
|
||||
string GetMediaDownloadUrl(Media media) =>
|
||||
$"/api/Media/{media.Id}/Download?fileId={media.FileId}&v={media.UpdatedOn.Ticks}";
|
||||
|
||||
List<Media> GetMediaList(params MediaType[] types) =>
|
||||
_media.Where(m => types.Contains(m.Type) && MediaService.FileExists(m))
|
||||
.OrderBy(m => m.SortOrder)
|
||||
.ToList();
|
||||
|
||||
async Task UploadMediaByType(InputFileChangeEventArgs e, MediaType type)
|
||||
{
|
||||
var validTypes = type == MediaType.Video ? ValidVideoMimeTypes : ValidImageMimeTypes;
|
||||
|
||||
if (!validTypes.Contains(e.File.ContentType))
|
||||
{
|
||||
MessageService.Error("Unsupported file type");
|
||||
return;
|
||||
}
|
||||
|
||||
var config = Settings.Value.Server.Media.GetMediaTypeConfig(type);
|
||||
|
||||
if (e.File.Size > config.MaxFileSize)
|
||||
{
|
||||
MessageService.Error($"File size must be smaller than {ByteSizeLib.ByteSize.FromBytes(config.MaxFileSize)}");
|
||||
return;
|
||||
}
|
||||
|
||||
// For singular types (Background, Logo, Cover), replace existing
|
||||
var isSingular = type != MediaType.Screenshot && type != MediaType.Video;
|
||||
var existing = isSingular ? GetMedia(type) : null;
|
||||
|
||||
Media media;
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
media = existing;
|
||||
media.MimeType = e.File.ContentType;
|
||||
media.SourceUrl = "";
|
||||
|
||||
MediaService.DeleteLocalMediaFile(media);
|
||||
media = await MediaService.WriteToFileAsync(media, e.File.OpenReadStream(maxAllowedSize: config.MaxFileSize));
|
||||
await MediaService.UpdateAsync(media);
|
||||
}
|
||||
else
|
||||
{
|
||||
media = new Media
|
||||
{
|
||||
GameId = GameId,
|
||||
Type = type,
|
||||
MimeType = e.File.ContentType,
|
||||
SourceUrl = "",
|
||||
StorageLocation = await MediaService.GetDefaultStorageLocationAsync(),
|
||||
Crc32 = string.Empty,
|
||||
SortOrder = GetNextSortOrder(type)
|
||||
};
|
||||
|
||||
media = await MediaService.WriteToFileAsync(media, e.File.OpenReadStream(maxAllowedSize: config.MaxFileSize));
|
||||
}
|
||||
|
||||
await RefreshMedia();
|
||||
MessageService.Success($"{type} uploaded!");
|
||||
}
|
||||
|
||||
async Task SearchMediaByType(MediaType type)
|
||||
{
|
||||
var modalOptions = new ModalOptions
|
||||
{
|
||||
Title = $"Download {type}",
|
||||
Maximizable = false,
|
||||
DefaultMaximized = true,
|
||||
Closable = true,
|
||||
OkText = "Select",
|
||||
};
|
||||
|
||||
var grabberOptions = new MediaGrabberOptions
|
||||
{
|
||||
Type = type,
|
||||
Search = _game.Title
|
||||
};
|
||||
|
||||
var modalRef = await ModalService.CreateModalAsync<MediaGrabberDialog, MediaGrabberOptions, MediaGrabberResult>(modalOptions, grabberOptions);
|
||||
|
||||
modalRef.OnOk = async (result) =>
|
||||
{
|
||||
modalRef.Config.ConfirmLoading = true;
|
||||
|
||||
using var download = await MediaGrabberService.DownloadAsync(result);
|
||||
|
||||
var isSingular = type != MediaType.Screenshot && type != MediaType.Video;
|
||||
var existing = isSingular ? GetMedia(type) : null;
|
||||
|
||||
Media media;
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
media = existing;
|
||||
media.SourceUrl = result.SourceUrl;
|
||||
media.MimeType = download.MimeType;
|
||||
|
||||
MediaService.DeleteLocalMediaFile(media);
|
||||
media = await MediaService.WriteToFileAsync(media, download.Stream);
|
||||
}
|
||||
else
|
||||
{
|
||||
media = new Media
|
||||
{
|
||||
GameId = GameId,
|
||||
Type = type,
|
||||
SourceUrl = result.SourceUrl,
|
||||
MimeType = download.MimeType,
|
||||
StorageLocation = await MediaService.GetDefaultStorageLocationAsync(),
|
||||
Crc32 = string.Empty,
|
||||
SortOrder = GetNextSortOrder(type)
|
||||
};
|
||||
|
||||
media = await MediaService.WriteToFileAsync(media, download.Stream);
|
||||
}
|
||||
|
||||
await RefreshMedia();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
};
|
||||
}
|
||||
|
||||
async Task RemoveMediaByType(MediaType type)
|
||||
{
|
||||
var media = GetMedia(type);
|
||||
|
||||
if (media != null)
|
||||
await RemoveMedia(media);
|
||||
}
|
||||
|
||||
async Task RemoveMedia(Media media)
|
||||
{
|
||||
_media.Remove(media);
|
||||
|
||||
if (media.Id != Guid.Empty)
|
||||
await MediaService.DeleteAsync(media);
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
int GetNextSortOrder(MediaType type)
|
||||
{
|
||||
var existing = _media.Where(m => m.Type == type).ToList();
|
||||
return existing.Any() ? existing.Max(m => m.SortOrder) + 1 : 0;
|
||||
}
|
||||
|
||||
async Task NormalizeSortOrders()
|
||||
{
|
||||
var list = _media
|
||||
.Where(m => m.Type == MediaType.Screenshot || m.Type == MediaType.Video)
|
||||
.OrderBy(m => m.SortOrder)
|
||||
.ThenBy(m => m.CreatedOn)
|
||||
.ToList();
|
||||
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
if (list[i].SortOrder != i)
|
||||
{
|
||||
list[i].SortOrder = i;
|
||||
await MediaService.UpdateAsync(list[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async Task MoveMediaLeft(Media media)
|
||||
{
|
||||
await NormalizeSortOrders();
|
||||
|
||||
var list = GetMediaList(MediaType.Screenshot, MediaType.Video);
|
||||
var index = list.IndexOf(media);
|
||||
|
||||
if (index <= 0)
|
||||
return;
|
||||
|
||||
// Swap sort orders
|
||||
(list[index].SortOrder, list[index - 1].SortOrder) = (list[index - 1].SortOrder, list[index].SortOrder);
|
||||
|
||||
await MediaService.UpdateAsync(list[index]);
|
||||
await MediaService.UpdateAsync(list[index - 1]);
|
||||
await RefreshMedia();
|
||||
}
|
||||
|
||||
async Task MoveMediaRight(Media media)
|
||||
{
|
||||
await NormalizeSortOrders();
|
||||
|
||||
var list = GetMediaList(MediaType.Screenshot, MediaType.Video);
|
||||
var index = list.IndexOf(media);
|
||||
|
||||
if (index < 0 || index >= list.Count - 1)
|
||||
return;
|
||||
|
||||
// Swap sort orders
|
||||
(list[index].SortOrder, list[index + 1].SortOrder) = (list[index + 1].SortOrder, list[index].SortOrder);
|
||||
|
||||
await MediaService.UpdateAsync(list[index]);
|
||||
await MediaService.UpdateAsync(list[index + 1]);
|
||||
await RefreshMedia();
|
||||
}
|
||||
|
||||
async Task RefreshMedia()
|
||||
{
|
||||
_media = (await MediaService.Include(m => m.StorageLocation).GetAsync(m => m.GameId == GameId)).ToList();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
@if (Id != Guid.Empty)
|
||||
{
|
||||
<MenuItem RouterLink="@($"/Games/{Id}/Preview")">Preview</MenuItem>
|
||||
<MenuItem RouterLink="@($"/Games/{Id}/Media")">Media</MenuItem>
|
||||
<MenuItem RouterLink="@($"/Games/{Id}/Archives")">Archives</MenuItem>
|
||||
<MenuItem RouterLink="@($"/Games/{Id}/Actions")">Actions</MenuItem>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
@using Microsoft.Extensions.Options
|
||||
@using LANCommander.Server.Settings
|
||||
@using LANCommander.Server.Services.Abstractions
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.Net.Mime
|
||||
@inject MediaService MediaService
|
||||
@inject GameService GameService
|
||||
@inject IMediaGrabberService MediaGrabberService
|
||||
@inject ModalService ModalService
|
||||
@inject IMessageService MessageService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
|
@ -207,20 +209,20 @@
|
|||
{
|
||||
modalRef.Config.ConfirmLoading = true;
|
||||
|
||||
using var download = await MediaGrabberService.DownloadAsync(result);
|
||||
|
||||
media.SourceUrl = result.SourceUrl;
|
||||
media.MimeType = result.MimeType;
|
||||
media.MimeType = download.MimeType;
|
||||
|
||||
if (media.Id == Guid.Empty)
|
||||
{
|
||||
media = await MediaService.DownloadMediaAsync(result.SourceUrl, media);
|
||||
|
||||
await MediaService.AddAsync(media);
|
||||
media = await MediaService.WriteToFileAsync(media, download.Stream);
|
||||
}
|
||||
else
|
||||
{
|
||||
MediaService.DeleteLocalMediaFile(media);
|
||||
|
||||
media = await MediaService.DownloadMediaAsync(result.SourceUrl, media);
|
||||
media = await MediaService.WriteToFileAsync(media, download.Stream);
|
||||
}
|
||||
|
||||
_media = (await MediaService.GetAsync(m => m.GameId == media.GameId)).ToList();
|
||||
|
|
|
|||
10
LANCommander.Server/UI/Pages/Games/Edit/Preview.razor
Normal file
10
LANCommander.Server/UI/Pages/Games/Edit/Preview.razor
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
@page "/Games/{id:guid}/Preview"
|
||||
@attribute [Authorize(Roles = RoleService.AdministratorRoleName)]
|
||||
|
||||
<GameEditView Id="Id" Title="Preview">
|
||||
<GameDetailPreview />
|
||||
</GameEditView>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid Id { get; set; }
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@
|
|||
// for details on configuring this project to bundle and minify static web assets.
|
||||
|
||||
// Write your JavaScript code.
|
||||
function clickElement(id) {
|
||||
document.getElementById(id)?.click();
|
||||
}
|
||||
|
||||
function humanFileSize(bytes, si = false, dp = 1) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue