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:
Pat Hartl 2026-04-24 02:05:56 -05:00
parent 4362e7c22f
commit 3fe6bea7e5
35 changed files with 11684 additions and 334 deletions

View 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
}
}
}

View file

@ -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");
}
}
}

View file

@ -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");

View file

@ -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))]

View file

@ -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; }
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -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)");

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -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");

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -875,6 +875,9 @@ namespace LANCommander.Migrations
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<Guid>("StorageLocationId")
.HasColumnType("TEXT");

View file

@ -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]

View file

@ -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);
}
}
}

View file

@ -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>();

View file

@ -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);
}
}
}

View file

@ -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)

View 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;
}
}
}

View file

@ -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 { }
}
}
}
}

View file

@ -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);

View 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();
}
}
}

View file

@ -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; }
}
}

View 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);
}
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}
}

View file

@ -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';

View file

@ -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>
}

View file

@ -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; }
}
}

View file

@ -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> &middot; @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);
}
}

View file

@ -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>

View file

@ -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();

View 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; }
}

View file

@ -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;