Merge branch 'media'

save-path-regex
Pat Hartl 2023-11-02 23:54:15 -05:00
commit 14a92bdc3e
30 changed files with 4152 additions and 1 deletions

2
.gitignore vendored
View File

@ -352,3 +352,5 @@ Upload/
LANCommander/Icon/ LANCommander/Icon/
LANCommander/Settings.yml LANCommander/Settings.yml
LANCommander/Saves/ LANCommander/Saves/
LANCommander/Media/
LANCommander/Uploads/

View File

@ -235,6 +235,11 @@ namespace LANCommander.PlaynitePlugin
return response.Data; return response.Data;
} }
public string GetMediaUrl(Media media)
{
return (new Uri(Client.BaseUrl, $"/api/Media/{media.Id}/Download?fileId={media.FileId}").ToString());
}
public string GetKey(Guid id) public string GetKey(Guid id)
{ {
Logger.Trace("Requesting key allocation..."); Logger.Trace("Requesting key allocation...");

View File

@ -113,7 +113,7 @@ namespace LANCommander.PlaynitePlugin
var games = LANCommander var games = LANCommander
.GetGames() .GetGames()
.Where(g => g.Archives != null && g.Archives.Count() > 0); .Where(g => g != null && g.Archives != null && g.Archives.Count() > 0);
foreach (var game in games) foreach (var game in games)
{ {
@ -183,6 +183,15 @@ namespace LANCommander.PlaynitePlugin
if (manifest.OnlineMultiplayer != null) if (manifest.OnlineMultiplayer != null)
metadata.Features.Add(new MetadataNameProperty($"Online Multiplayer {manifest.OnlineMultiplayer.GetPlayerCount()}".Trim())); metadata.Features.Add(new MetadataNameProperty($"Online Multiplayer {manifest.OnlineMultiplayer.GetPlayerCount()}".Trim()));
if (game.Media.Any(m => m.Type == SDK.Enums.MediaType.Icon))
metadata.Icon = new MetadataFile(LANCommander.GetMediaUrl(game.Media.First(m => m.Type == SDK.Enums.MediaType.Icon)));
if (game.Media.Any(m => m.Type == SDK.Enums.MediaType.Cover))
metadata.CoverImage = new MetadataFile(LANCommander.GetMediaUrl(game.Media.First(m => m.Type == SDK.Enums.MediaType.Cover)));
if (game.Media.Any(m => m.Type == SDK.Enums.MediaType.Background))
metadata.BackgroundImage = new MetadataFile(LANCommander.GetMediaUrl(game.Media.First(m => m.Type == SDK.Enums.MediaType.Background)));
gameMetadata.Add(metadata); gameMetadata.Add(metadata);
} }
catch (Exception ex) catch (Exception ex)

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace LANCommander.SDK.Enums
{
public enum MediaType
{
Icon,
Cover,
Background
}
}

View File

@ -16,6 +16,7 @@ namespace LANCommander.SDK.Models
public virtual Company Developer { get; set; } public virtual Company Developer { get; set; }
public virtual IEnumerable<Archive> Archives { get; set; } public virtual IEnumerable<Archive> Archives { get; set; }
public virtual IEnumerable<Script> Scripts { get; set; } public virtual IEnumerable<Script> Scripts { get; set; }
public virtual IEnumerable<Media> Media { get; set; }
public virtual IEnumerable<Redistributable> Redistributables { get; set; } public virtual IEnumerable<Redistributable> Redistributables { get; set; }
} }
} }

View File

@ -0,0 +1,15 @@
using LANCommander.SDK.Enums;
using System;
using System.Collections.Generic;
using System.Text;
namespace LANCommander.SDK.Models
{
public class Media : BaseModel
{
public Guid FileId { get; set; }
public MediaType Type { get; set; }
public string SourceUrl { get; set; }
public string MimeType { get; set; }
}
}

View File

@ -0,0 +1,27 @@
<div class="image-picker">
<div class="image-picker-images">
@foreach (var image in Images)
{
<div class="image-picker-image" style="width: @(Size)px; max-height: @(Size)px">
<input type="radio" id="image-picker-image-@image.Key" checked="@(Value == image.Key)" name="SelectedResult" @onchange="@(() => SelectionChanged(image.Key))" />
<label for="image-picker-image-@image.Key"></label>
<img src="@image.Value" />
</div>
}
</div>
</div>
@code {
[Parameter] public double Size { get; set; }
[Parameter] public string Value { get; set; }
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public Dictionary<string, string> Images { get; set; }
async Task SelectionChanged(string key)
{
Value = key;
if (ValueChanged.HasDelegate)
await ValueChanged.InvokeAsync(key);
}
}

View File

@ -0,0 +1,63 @@
@inherits FeedbackComponent<MediaGrabberOptions, MediaGrabberResult>
@using LANCommander.Data.Enums;
@using LANCommander.Models;
@inject IMediaGrabberService MediaGrabberService
<GridRow Justify="space-between">
<GridCol Span="6">
<Search @bind-Value="Search" OnSearch="(x) => GetResults(Type, x)" DefaultValue="@Search" />
</GridCol>
<GridCol Span="12"></GridCol>
<GridCol Span="6">
<Slider TValue="double" @bind-Value="Size" DefaultValue="200" Min="50" Max="400" />
</GridCol>
</GridRow>
@foreach (var group in Results)
{
<div class="media-grabber-group">
<h2>@group.First().Group</h2>
<ImagePicker Size="Size" Images="@group.ToDictionary(r => r.Id, r => r.ThumbnailUrl)" ValueChanged="OnImageSelected" />
</div>
}
@code {
[Parameter] public string Search { get; set; }
[Parameter] public MediaType Type { get; set; }
MediaGrabberResult Media { get; set; }
double Size { get; set; } = 200;
IEnumerable<IEnumerable<MediaGrabberResult>> Results = new List<List<MediaGrabberResult>>();
Dictionary<string, string> Images { get; set; } = new Dictionary<string, string>();
protected override async Task OnFirstAfterRenderAsync()
{
Type = Options.Type;
Search = Options.Search;
await GetResults(Type, Search);
}
private async Task GetResults(MediaType type, string search)
{
if (!String.IsNullOrWhiteSpace(search))
{
Results = (await MediaGrabberService.SearchAsync(type, search)).GroupBy(r => r.Group);
StateHasChanged();
}
}
private void OnImageSelected(string key)
{
Media = Results.SelectMany(g => g).FirstOrDefault(r => r.Id == key);
}
public override async Task OnFeedbackOkAsync(ModalClosingEventArgs args)
{
await base.OkCancelRefWithResult!.OnOk(Media);
}
}

View File

@ -0,0 +1,56 @@
using LANCommander.Data;
using LANCommander.Data.Models;
using LANCommander.Extensions;
using LANCommander.Models;
using LANCommander.SDK;
using LANCommander.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace LANCommander.Controllers.Api
{
[Authorize(AuthenticationSchemes = "Bearer")]
[Route("api/[controller]")]
[ApiController]
public class MediaController : ControllerBase
{
private readonly MediaService MediaService;
private readonly LANCommanderSettings Settings = SettingService.GetSettings();
public MediaController(MediaService mediaService)
{
MediaService = mediaService;
}
[HttpGet]
public async Task<IEnumerable<Media>> Get()
{
return await MediaService.Get();
}
[HttpGet("{id}")]
public async Task<Media> Get(Guid id)
{
return await MediaService.Get(id);
}
[AllowAnonymous]
[HttpGet("{id}/Download")]
public async Task<IActionResult> Download(Guid id)
{
try
{
var media = await MediaService.Get(id);
var fs = System.IO.File.OpenRead(MediaService.GetImagePath(media));
return File(fs, media.MimeType);
}
catch (Exception ex)
{
return NotFound();
}
}
}
}

View File

@ -48,5 +48,11 @@ namespace LANCommander.Controllers.Api
} }
} }
} }
[HttpPost("Media")]
public async Task Media(IFormFile file)
{
}
} }
} }

View File

@ -90,6 +90,11 @@ namespace LANCommander.Data
gr => gr.HasOne<Game>().WithMany().HasForeignKey("GameId") gr => gr.HasOne<Game>().WithMany().HasForeignKey("GameId")
); );
builder.Entity<Game>()
.HasMany(g => g.Media)
.WithOne(m => m.Game)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<User>() builder.Entity<User>()
.HasMany(u => u.GameSaves) .HasMany(u => u.GameSaves)
.WithOne(gs => gs.User) .WithOne(gs => gs.User)
@ -146,5 +151,7 @@ namespace LANCommander.Data
public DbSet<ServerConsole>? ServerConsoles { get; set; } public DbSet<ServerConsole>? ServerConsoles { get; set; }
public DbSet<Redistributable>? Redistributables { get; set; } public DbSet<Redistributable>? Redistributables { get; set; }
public DbSet<Media>? Media { get; set; }
} }
} }

View File

@ -0,0 +1,9 @@
namespace LANCommander.Data.Enums
{
public enum MediaType
{
Icon,
Cover,
Background
}
}

View File

@ -35,6 +35,7 @@ namespace LANCommander.Data.Models
public virtual ICollection<SavePath>? SavePaths { get; set; } public virtual ICollection<SavePath>? SavePaths { get; set; }
public virtual ICollection<Server>? Servers { get; set; } public virtual ICollection<Server>? Servers { get; set; }
public virtual ICollection<Redistributable>? Redistributables { get; set; } public virtual ICollection<Redistributable>? Redistributables { get; set; }
public virtual ICollection<Media>? Media { get; set; }
public string? ValidKeyRegex { get; set; } public string? ValidKeyRegex { get; set; }
public virtual ICollection<Key>? Keys { get; set; } public virtual ICollection<Key>? Keys { get; set; }

View File

@ -0,0 +1,26 @@
using LANCommander.Data.Enums;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace LANCommander.Data.Models
{
[Table("Media")]
public class Media : BaseModel
{
public Guid FileId { get; set; }
public MediaType Type { get; set; }
[MaxLength(2048)]
public string SourceUrl { get; set; }
[MaxLength(255)]
public string MimeType { get; set; }
public Guid GameId { get; set; }
[JsonIgnore]
[ForeignKey(nameof(GameId))]
[InverseProperty("Media")]
public virtual Game? Game { get; set; }
}
}

View File

@ -28,6 +28,7 @@
<PackageReference Include="BlazorMonaco" Version="3.1.0" /> <PackageReference Include="BlazorMonaco" Version="3.1.0" />
<PackageReference Include="ByteSize" Version="2.1.1" /> <PackageReference Include="ByteSize" Version="2.1.1" />
<PackageReference Include="CoreRCON" Version="5.0.5" /> <PackageReference Include="CoreRCON" Version="5.0.5" />
<PackageReference Include="craftersmine.SteamGridDB.Net" Version="1.1.5" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.5" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.5" />
<PackageReference Include="Hangfire.Core" Version="1.8.5" /> <PackageReference Include="Hangfire.Core" Version="1.8.5" />
<PackageReference Include="Hangfire.InMemory" Version="0.5.1" /> <PackageReference Include="Hangfire.InMemory" Version="0.5.1" />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Migrations
{
/// <inheritdoc />
public partial class AddMediaEntity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Media",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
FileId = table.Column<Guid>(type: "TEXT", nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
SourceUrl = table.Column<string>(type: "TEXT", maxLength: 2048, nullable: false),
GameId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedById = table.Column<Guid>(type: "TEXT", nullable: true),
UpdatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedById = table.Column<Guid>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Media", x => x.Id);
table.ForeignKey(
name: "FK_Media_AspNetUsers_CreatedById",
column: x => x.CreatedById,
principalTable: "AspNetUsers",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Media_AspNetUsers_UpdatedById",
column: x => x.UpdatedById,
principalTable: "AspNetUsers",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Media_Games_GameId",
column: x => x.GameId,
principalTable: "Games",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Media_CreatedById",
table: "Media",
column: "CreatedById");
migrationBuilder.CreateIndex(
name: "IX_Media_GameId",
table: "Media",
column: "GameId");
migrationBuilder.CreateIndex(
name: "IX_Media_UpdatedById",
table: "Media",
column: "UpdatedById");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Media");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Migrations
{
/// <inheritdoc />
public partial class AddMediaMimeType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "MimeType",
table: "Media",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MimeType",
table: "Media");
}
}
}

View File

@ -474,6 +474,54 @@ namespace LANCommander.Migrations
b.ToTable("Keys"); b.ToTable("Keys");
}); });
modelBuilder.Entity("LANCommander.Data.Models.Media", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CreatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<Guid>("FileId")
.HasColumnType("TEXT");
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.Property<string>("MimeType")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("SourceUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<Guid?>("UpdatedById")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedById");
b.HasIndex("GameId");
b.HasIndex("UpdatedById");
b.ToTable("Media");
});
modelBuilder.Entity("LANCommander.Data.Models.MultiplayerMode", b => modelBuilder.Entity("LANCommander.Data.Models.MultiplayerMode", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1302,6 +1350,29 @@ namespace LANCommander.Migrations
b.Navigation("UpdatedBy"); b.Navigation("UpdatedBy");
}); });
modelBuilder.Entity("LANCommander.Data.Models.Media", b =>
{
b.HasOne("LANCommander.Data.Models.User", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById");
b.HasOne("LANCommander.Data.Models.Game", "Game")
.WithMany("Media")
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LANCommander.Data.Models.User", "UpdatedBy")
.WithMany()
.HasForeignKey("UpdatedById");
b.Navigation("CreatedBy");
b.Navigation("Game");
b.Navigation("UpdatedBy");
});
modelBuilder.Entity("LANCommander.Data.Models.MultiplayerMode", b => modelBuilder.Entity("LANCommander.Data.Models.MultiplayerMode", b =>
{ {
b.HasOne("LANCommander.Data.Models.User", "CreatedBy") b.HasOne("LANCommander.Data.Models.User", "CreatedBy")
@ -1520,6 +1591,8 @@ namespace LANCommander.Migrations
b.Navigation("Keys"); b.Navigation("Keys");
b.Navigation("Media");
b.Navigation("MultiplayerModes"); b.Navigation("MultiplayerModes");
b.Navigation("SavePaths"); b.Navigation("SavePaths");

View File

@ -0,0 +1,11 @@
using LANCommander.Components.FileManagerComponents;
using LANCommander.Data.Enums;
namespace LANCommander.Models
{
public class MediaGrabberOptions
{
public MediaType Type { get; set; }
public string Search { get; set; }
}
}

View File

@ -0,0 +1,14 @@
using LANCommander.Data.Enums;
namespace LANCommander.Models
{
public class MediaGrabberResult
{
public string Id { get; set; }
public MediaType Type { get; set; }
public string SourceUrl { get; set; }
public string ThumbnailUrl { get; set; }
public string Group { get; set; }
public string MimeType { get; set; }
}
}

View File

@ -18,6 +18,7 @@
public LANCommanderAuthenticationSettings Authentication { get; set; } = new LANCommanderAuthenticationSettings(); public LANCommanderAuthenticationSettings Authentication { get; set; } = new LANCommanderAuthenticationSettings();
public LANCommanderUserSaveSettings UserSaves { get; set; } = new LANCommanderUserSaveSettings(); public LANCommanderUserSaveSettings UserSaves { get; set; } = new LANCommanderUserSaveSettings();
public LANCommanderArchiveSettings Archives { get; set; } = new LANCommanderArchiveSettings(); public LANCommanderArchiveSettings Archives { get; set; } = new LANCommanderArchiveSettings();
public LANCommanderMediaSettings Media { get; set; } = new LANCommanderMediaSettings();
public LANCommanderIPXRelaySettings IPXRelay { get; set; } = new LANCommanderIPXRelaySettings(); public LANCommanderIPXRelaySettings IPXRelay { get; set; } = new LANCommanderIPXRelaySettings();
} }
@ -51,6 +52,13 @@
public string StoragePath { get; set; } = "Uploads"; public string StoragePath { get; set; } = "Uploads";
} }
public class LANCommanderMediaSettings
{
public string SteamGridDbApiKey { get; set; } = "";
public string StoragePath { get; set; } = "Media";
public long MaxSize { get; set; } = 25;
}
public class LANCommanderIPXRelaySettings public class LANCommanderIPXRelaySettings
{ {
public bool Enabled { get; set; } = false; public bool Enabled { get; set; } = false;

View File

@ -0,0 +1,167 @@
@using LANCommander.Data.Enums;
@using LANCommander.Models;
@inject MediaService MediaService
@inject ModalService ModalService
@inject IMessageService MessageService
<Space Direction="DirectionVHType.Vertical" Size="@("large")" Style="width: 100%" Class="media-editor">
<SpaceItem>
<Table TItem="Media" DataSource="@Values" HidePagination="true" Responsive>
<PropertyColumn Property="p => p.Id" Title="Preview" Width="100px" Align="ColumnAlign.Center">
@if (MediaService.FileExists(context))
{
<Image Width="100px" Src="@($"/api/Media/{context.Id}/Download?fileId={context.FileId}")" />
}
</PropertyColumn>
<PropertyColumn Property="p => p.Type">
<Select @bind-Value="context.Type" TItem="MediaType" TItemValue="MediaType" DataSource="Enum.GetValues<MediaType>()" />
</PropertyColumn>
<ActionColumn>
<Space Style="display: flex; justify-content: end">
<SpaceItem>
@{
var fileInputId = Guid.NewGuid();
}
<InputFile id="@fileInputId" OnChange="(e) => UploadMedia(e, context)" hidden />
<label class="ant-btn ant-btn-text ant-btn-icon-only" for="@fileInputId">
<Icon Type="@IconType.Outline.Upload" />
</label>
</SpaceItem>
<SpaceItem>
<Button OnClick="() => SearchMedia(context)" Type="@ButtonType.Text" Icon="@IconType.Outline.Search" />
</SpaceItem>
<SpaceItem>
<Popconfirm OnConfirm="() => RemoveMedia(context)" Title="Are you sure you want to delete this media?">
<Button Type="@ButtonType.Text" Danger Icon="@IconType.Outline.Close" />
</Popconfirm>
</SpaceItem>
</Space>
</ActionColumn>
</Table>
</SpaceItem>
<SpaceItem>
<GridRow Justify="end">
<GridCol>
<Button OnClick="AddMedia" Type="@ButtonType.Primary">Add Media</Button>
</GridCol>
</GridRow>
</SpaceItem>
</Space>
@code {
[Parameter] public ICollection<Media> Values { get; set; } = new List<Media>();
[Parameter] public EventCallback<ICollection<Media>> ValuesChanged { get; set; }
[Parameter] public Guid GameId { get; set; }
[Parameter] public string GameTitle { get; set; }
LANCommanderSettings Settings = SettingService.GetSettings();
string[] ValidMimeTypes = new string[]
{
"image/png",
"image/jpeg"
};
private async Task AddMedia()
{
if (Values == null)
Values = new List<Media>();
Values.Add(new Media()
{
GameId = GameId,
Type = Enum.GetValues<MediaType>().ToList().FirstOrDefault(t => !Values.Any(v => v.Type == t))
});
}
private async Task SearchMedia(Media media)
{
var modalOptions = new ModalOptions()
{
Title = $"Download {media.Type}",
Maximizable = false,
DefaultMaximized = true,
Closable = true,
OkText = "Select",
};
var grabberOptions = new MediaGrabberOptions()
{
Type = media.Type,
Search = GameTitle
};
var modalRef = await ModalService.CreateModalAsync<MediaGrabberDialog, MediaGrabberOptions, MediaGrabberResult>(modalOptions, grabberOptions);
modalRef.OnOk = async (result) =>
{
modalRef.Config.ConfirmLoading = true;
media.SourceUrl = result.SourceUrl;
media.MimeType = result.MimeType;
if (media.Id == Guid.Empty)
{
media.FileId = await MediaService.DownloadMediaAsync(result.SourceUrl);
await MediaService.Add(media);
}
else
{
MediaService.DeleteLocalMediaFile(media.FileId);
media.FileId = await MediaService.DownloadMediaAsync(result.SourceUrl);
await MediaService.Update(media);
}
Values = MediaService.Get(m => m.GameId == media.GameId).ToList();
if (ValuesChanged.HasDelegate)
await ValuesChanged.InvokeAsync(Values);
};
}
private async Task UploadMedia(InputFileChangeEventArgs e, Media media)
{
if (!ValidMimeTypes.Contains(e.File.ContentType))
{
MessageService.Error("Unsupported file type");
return;
}
if ((e.File.Size / 1024 / 1024) > Settings.Media.MaxSize)
{
MessageService.Error($"File size must be smaller than {Settings.Media.MaxSize}MB");
return;
}
media.SourceUrl = "";
media.MimeType = e.File.ContentType;
if (media.Id == Guid.Empty)
{
media.FileId = await MediaService.UploadMediaAsync(e.File);
await MediaService.Add(media);
}
else
{
MediaService.DeleteLocalMediaFile(media.FileId);
media.FileId = await MediaService.UploadMediaAsync(e.File);
await MediaService.Update(media);
}
Values = MediaService.Get(m => m.GameId == media.GameId).ToList();
if (ValuesChanged.HasDelegate)
await ValuesChanged.InvokeAsync(Values);
}
private async Task RemoveMedia(Media media)
{
Values.Remove(media);
await MediaService.Delete(media);
}
}

View File

@ -25,6 +25,7 @@
@if (Game != null && Game.Id != Guid.Empty) @if (Game != null && Game.Id != Guid.Empty)
{ {
<MenuItem RouterLink="@($"/Games/{Game.Id}/Media")">Media</MenuItem>
<MenuItem RouterLink="@($"/Games/{Game.Id}/Actions")">Actions</MenuItem> <MenuItem RouterLink="@($"/Games/{Game.Id}/Actions")">Actions</MenuItem>
<MenuItem RouterLink="@($"/Games/{Game.Id}/Multiplayer")">Multiplayer</MenuItem> <MenuItem RouterLink="@($"/Games/{Game.Id}/Multiplayer")">Multiplayer</MenuItem>
<MenuItem RouterLink="@($"/Games/{Game.Id}/SavePaths")">Save Paths</MenuItem> <MenuItem RouterLink="@($"/Games/{Game.Id}/SavePaths")">Save Paths</MenuItem>
@ -107,6 +108,10 @@
@if (Game != null && Game.Id != Guid.Empty) @if (Game != null && Game.Id != Guid.Empty)
{ {
<div data-panel="Media">
<MediaEditor @bind-Values="Game.Media" GameId="Game.Id" GameTitle="@Game.Title" />
</div>
<div data-panel="Actions"> <div data-panel="Actions">
<ActionEditor @bind-Actions="Game.Actions" GameId="Game.Id" ArchiveId="@LatestArchiveId" /> <ActionEditor @bind-Actions="Game.Actions" GameId="Game.Id" ArchiveId="@LatestArchiveId" />
</div> </div>

View File

@ -10,6 +10,7 @@ using NLog.Web;
using System.Text; using System.Text;
using Hangfire; using Hangfire;
using NLog; using NLog;
using LANCommander.Services.MediaGrabbers;
namespace LANCommander namespace LANCommander
{ {
@ -141,7 +142,9 @@ namespace LANCommander
builder.Services.AddScoped<ServerService>(); builder.Services.AddScoped<ServerService>();
builder.Services.AddScoped<ServerConsoleService>(); builder.Services.AddScoped<ServerConsoleService>();
builder.Services.AddScoped<GameSaveService>(); builder.Services.AddScoped<GameSaveService>();
builder.Services.AddScoped<MediaService>();
builder.Services.AddScoped<RedistributableService>(); builder.Services.AddScoped<RedistributableService>();
builder.Services.AddScoped<IMediaGrabberService, SteamGridDBMediaGrabber>();
builder.Services.AddSingleton<ServerProcessService>(); builder.Services.AddSingleton<ServerProcessService>();
builder.Services.AddSingleton<IPXRelayService>(); builder.Services.AddSingleton<IPXRelayService>();
@ -211,6 +214,9 @@ namespace LANCommander
if (!Directory.Exists(settings.UserSaves.StoragePath)) if (!Directory.Exists(settings.UserSaves.StoragePath))
Directory.CreateDirectory(settings.UserSaves.StoragePath); Directory.CreateDirectory(settings.UserSaves.StoragePath);
if (!Directory.Exists(settings.Media.StoragePath))
Directory.CreateDirectory(settings.Media.StoragePath);
if (!Directory.Exists("Snippets")) if (!Directory.Exists("Snippets"))
Directory.CreateDirectory("Snippets"); Directory.CreateDirectory("Snippets");

View File

@ -0,0 +1,10 @@
using LANCommander.Data.Enums;
using LANCommander.Models;
namespace LANCommander.Services
{
public interface IMediaGrabberService
{
Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords);
}
}

View File

@ -0,0 +1,112 @@
using craftersmine.SteamGridDBNet;
using LANCommander.Data.Enums;
using LANCommander.Models;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace LANCommander.Services.MediaGrabbers
{
public class SteamGridDBMediaGrabber : IMediaGrabberService
{
SteamGridDb SteamGridDb { get; set; }
private SteamGridDbFormats[] SupportedFormats = new SteamGridDbFormats[]
{
SteamGridDbFormats.Png,
SteamGridDbFormats.Jpeg,
SteamGridDbFormats.Webp
};
public SteamGridDBMediaGrabber()
{
var settings = SettingService.GetSettings();
SteamGridDb = new SteamGridDb(settings.Media.SteamGridDbApiKey);
}
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
{
var games = await SteamGridDb.SearchForGamesAsync(keywords);
var results = new List<MediaGrabberResult>();
foreach (var game in games)
{
switch (type)
{
case MediaType.Icon:
results.AddRange(await GetIconsAsync(game));
break;
case MediaType.Cover:
results.AddRange(await GetCoversAsync(game));
break;
case MediaType.Background:
results.AddRange(await GetBackgroundsAsync(game));
break;
}
}
return results;
}
private async Task<IEnumerable<MediaGrabberResult>> GetIconsAsync(SteamGridDbGame game)
{
var icons = await SteamGridDb.GetIconsByGameIdAsync(game.Id);
return icons.Where(i => SupportedFormats.Contains(i.Format)).Select(i => new MediaGrabberResult()
{
Id = i.Id.ToString(),
Type = MediaType.Icon,
SourceUrl = i.FullImageUrl,
ThumbnailUrl = i.ThumbnailImageUrl,
Group = game.Name,
MimeType = GetMimeType(i.Format)
});
}
private async Task<IEnumerable<MediaGrabberResult>> GetCoversAsync(SteamGridDbGame game)
{
var covers = await SteamGridDb.GetGridsByGameIdAsync(game.Id);
return covers.Where(c => SupportedFormats.Contains(c.Format)).Select(c => new MediaGrabberResult()
{
Id = c.Id.ToString(),
Type = MediaType.Cover,
SourceUrl = c.FullImageUrl,
ThumbnailUrl = c.ThumbnailImageUrl,
Group = game.Name,
MimeType = GetMimeType(c.Format)
});
}
private async Task<IEnumerable<MediaGrabberResult>> GetBackgroundsAsync(SteamGridDbGame game)
{
var backgrounds = await SteamGridDb.GetHeroesByGameIdAsync(game.Id);
return backgrounds.Where(b => SupportedFormats.Contains(b.Format)).Select(b => new MediaGrabberResult()
{
Id = b.Id.ToString(),
Type = MediaType.Background,
SourceUrl = b.FullImageUrl,
ThumbnailUrl = b.ThumbnailImageUrl,
Group = game.Name,
MimeType = GetMimeType(b.Format)
});
}
private string GetMimeType(SteamGridDbFormats format)
{
switch (format)
{
case SteamGridDbFormats.Png:
return "image/png";
case SteamGridDbFormats.Jpeg:
return "image/jpg";
case SteamGridDbFormats.Webp:
return "image/webp";
default:
throw new NotImplementedException("The SteamGridDB grabber currently does not support this format");
}
}
}
}

View File

@ -0,0 +1,90 @@
using LANCommander.Data;
using LANCommander.Data.Models;
using LANCommander.Helpers;
using LANCommander.Models;
using Microsoft.AspNetCore.Components.Forms;
namespace LANCommander.Services
{
public class MediaService : BaseDatabaseService<Media>
{
private readonly LANCommanderSettings Settings;
public MediaService(DatabaseContext dbContext, IHttpContextAccessor httpContextAccessor) : base(dbContext, httpContextAccessor)
{
Settings = SettingService.GetSettings();
}
public override Task Delete(Media entity)
{
FileHelpers.DeleteIfExists(GetImagePath(entity));
return base.Delete(entity);
}
public bool FileExists(Media entity)
{
var path = GetImagePath(entity);
return File.Exists(path);
}
public async Task<bool> FileExists(Guid id)
{
var path = await GetImagePath(id);
return File.Exists(path);
}
public async Task<string> GetImagePath(Guid id)
{
var entity = await Get(id);
return GetImagePath(entity);
}
public string GetImagePath(Media entity)
{
return Path.Combine(Settings.Media.StoragePath, entity.FileId.ToString());
}
public async Task<Guid> UploadMediaAsync(IBrowserFile file)
{
var fileId = Guid.NewGuid();
var path = Path.Combine(Settings.Media.StoragePath, fileId.ToString());
using (var fs = new FileStream(path, FileMode.Create))
{
await file.OpenReadStream().CopyToAsync(fs);
}
return fileId;
}
public void DeleteLocalMediaFile(Guid fileId)
{
var path = Path.Combine(Settings.Media.StoragePath, fileId.ToString());
if (File.Exists(path))
File.Delete(path);
}
public async Task<Guid> DownloadMediaAsync(string sourceUrl)
{
var fileId = Guid.NewGuid();
var path = Path.Combine(Settings.Media.StoragePath, fileId.ToString());
using (var http = new HttpClient())
using (var fs = new FileStream(path, FileMode.Create))
{
var response = await http.GetStreamAsync(sourceUrl);
await response.CopyToAsync(fs);
}
return fileId;
}
}
}

View File

@ -18,6 +18,12 @@
background: #141414; background: #141414;
} }
label.ant-btn-icon-only {
display: inline-flex;
align-items: center;
justify-content: center;
}
.ant-select-selector { .ant-select-selector {
line-height: 30px; line-height: 30px;
} }
@ -173,6 +179,63 @@
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.image-picker-images {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.image-picker-image {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.image-picker-image img {
border: 4px solid transparent;
border-radius: 2px;
max-height: 100%;
max-width: 100%;
}
.image-picker-image input {
display: none;
}
.image-picker-image input + label {
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: pointer;
}
.image-picker-image input:checked ~ img {
border-color: #1890ff;
}
.media-editor img {
max-width: 100px;
max-height: 100px;
width: auto;
}
.media-grabber-group {
margin-top: 18px;
}
.media-grabber-group + .media-grabber-group {
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.85);
}
[data-theme="Dark"] .media-grabber-group + .media-grabber-group {
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.mobile-menu { .mobile-menu {
display: none; display: none;