diff --git a/Jellyfin/backend/Api/EmulatorController.cs b/Jellyfin/backend/Api/EmulatorController.cs index 008c77a..af9d680 100644 --- a/Jellyfin/backend/Api/EmulatorController.cs +++ b/Jellyfin/backend/Api/EmulatorController.cs @@ -1,9 +1,8 @@ -using System.IO.Compression; -using System.Net.Http; using System.Reflection; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Moonfin.Server.Services; namespace Moonfin.Server.Api; @@ -16,8 +15,7 @@ namespace Moonfin.Server.Api; [Route("Moonfin/EmulatorJS")] public class EmulatorController : ControllerBase { - private readonly IHttpClientFactory _httpClientFactory; - private readonly Assembly _assembly; + private static readonly Assembly Assembly = typeof(EmulatorController).Assembly; private static readonly IReadOnlyDictionary ContentTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -39,11 +37,9 @@ public class EmulatorController : ControllerBase [".woff2"] = "font/woff2", }; - public EmulatorController(IHttpClientFactory httpClientFactory) - { - _httpClientFactory = httpClientFactory; - _assembly = typeof(EmulatorController).Assembly; - } + /// Cores whose WASM build needs SharedArrayBuffer, i.e. a cross-origin isolated document. + private static readonly HashSet ThreadRequiredCores = + new(StringComparer.Ordinal) { "psp" }; /// /// Serves the Moonfin EmulatorJS player shell (embedded resource). Anonymous: the shell @@ -56,7 +52,7 @@ public EmulatorController(IHttpClientFactory httpClientFactory) [ProducesResponseType(StatusCodes.Status404NotFound)] public IActionResult GetPlayer() { - using var stream = _assembly.GetManifestResourceStream("Moonfin.Server.EmulatorJS.player.html"); + using var stream = Assembly.GetManifestResourceStream("Moonfin.Server.EmulatorJS.player.html"); if (stream == null) { return NotFound(new { Error = "player.html not found" }); @@ -79,8 +75,25 @@ public IActionResult GetPlayer() return Content(html, "text/html; charset=utf-8"); } - private static readonly HashSet ThreadRequiredCores = - new(StringComparer.Ordinal) { "psp" }; + /// + /// Serves the Moonfin host/controller bridge used by the anonymous player shell. + /// + [HttpGet("moonfin-bridge.js")] + [AllowAnonymous] + [Produces("application/javascript")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetMoonfinBridge() + { + using var stream = Assembly.GetManifestResourceStream("Moonfin.Server.EmulatorJS.moonfin-bridge.js"); + if (stream == null) + { + return NotFound(new { Error = "moonfin-bridge.js not found" }); + } + + using var reader = new StreamReader(stream); + return Content(reader.ReadToEnd(), "application/javascript"); + } /// /// Resolves where EmulatorJS should load its runtime + cores from. Order: an admin @@ -96,12 +109,17 @@ private string ResolveDataPath() } var dataRoot = GetDataRoot(); - if (!string.IsNullOrEmpty(dataRoot) && System.IO.File.Exists(Path.Combine(dataRoot, "loader.js"))) + if (CoresService.IsDataInstalled(dataRoot)) { // Relative to /Moonfin/EmulatorJS/player.html -> /Moonfin/EmulatorJS/data/. return "./data/"; } + // Tracks EmulatorJS's "stable" channel rather than a fixed release tag, so this URL can + // silently change out from under player.html's internals-reaching navigation adapter. + // See CoresService.ExpectedEmulatorJsVersion for what version that code was last + // verified against; moonfinAssertEmulatorContract in player.html is the runtime guard + // against drift here. return "https://cdn.emulatorjs.org/stable/data/"; } @@ -119,11 +137,15 @@ public IActionResult GetDataAsset([FromRoute] string? path) var dataRoot = GetDataRoot(); if (string.IsNullOrEmpty(dataRoot) || !Directory.Exists(dataRoot)) { + // This endpoint is [AllowAnonymous] (the player shell fetches it with no auth + // header), so the 404 body must not disclose server filesystem layout - the absolute + // ExpectedPath previously returned here reveals the plugin data folder and, on + // Windows, the OS account name embedded in it (e.g. C:\Users\\AppData\...). + // The hint stays; only the concrete path is removed. return NotFound(new { Error = "Self-hosted EmulatorJS data folder not installed.", - ExpectedPath = dataRoot, - Hint = "Optional: drop the EmulatorJS data/ folder there for offline use. Otherwise the CDN is used automatically." + Hint = "Optional: install the EmulatorJS data/ folder in the plugin's data directory for offline use. Otherwise the CDN is used automatically." }); } diff --git a/Jellyfin/backend/Api/GamesController.cs b/Jellyfin/backend/Api/GamesController.cs index 28b3942..a0b6081 100644 --- a/Jellyfin/backend/Api/GamesController.cs +++ b/Jellyfin/backend/Api/GamesController.cs @@ -12,22 +12,40 @@ namespace Moonfin.Server.Api; /// Moonfin clients. ROM files are read straight off disk from the library's physical roots /// because they are not indexed by Jellyfin as media items. /// +/// +/// Accepted risk (M18, reviewed 2026-08-04): per-user library ACL is not enforced on the +/// Games/Artwork endpoints in this controller. Any authenticated user can reach any game +/// library's systems, games, ROM streams, and artwork, regardless of Jellyfin's per-user +/// library access grants. This is pre-existing, was reviewed as part of the MAME emulator +/// prototype branch review, and was consciously accepted rather than fixed as part of that +/// work. Adding a new endpoint to this controller does not change the decision, but it does +/// extend its blast radius - any addition should raise the ACL question with the repo owner +/// rather than silently inheriting the gap. +/// [ApiController] [Route("Moonfin/Games")] public class GamesController : ControllerBase { + private const int MaxArtworkPriorityItems = 128; + private static readonly TimeSpan OriginalManifestRefresh = TimeSpan.FromSeconds(30); private readonly GamesService _gamesService; private readonly CoresService _coresService; - private readonly GameThumbService _thumbService; + private readonly GameArtworkReconciliationService _artwork; + private readonly GameArtworkDeliveryLimiter _artworkDeliveryLimiter; + private readonly ArcadeCompatibilityService _arcadeCompatibility; public GamesController( GamesService gamesService, CoresService coresService, - GameThumbService thumbService) + GameArtworkReconciliationService artwork, + GameArtworkDeliveryLimiter artworkDeliveryLimiter, + ArcadeCompatibilityService arcadeCompatibility) { _gamesService = gamesService; _coresService = coresService; - _thumbService = thumbService; + _artwork = artwork; + _artworkDeliveryLimiter = artworkDeliveryLimiter; + _arcadeCompatibility = arcadeCompatibility; } /// Diagnostic dump for troubleshooting library detection (admin only). @@ -60,14 +78,283 @@ public ActionResult> GetLibraries() [Authorize] [Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetSystems([FromRoute] string libraryId) + public async Task>> GetSystems( + [FromRoute] string libraryId, + CancellationToken cancellationToken) { if (!GamesEnabled()) { return Ok(Array.Empty()); } - return Ok(_gamesService.GetSystems(libraryId)); + var systems = (await _artwork.GetSystemsAsync(libraryId, cancellationToken).ConfigureAwait(false)).ToList(); + + // One catalog pass for every system on the page. Reading them one at a time re-walked the + // whole catalog per system, so a library's browse request cost systems x entries. + var artworkBySystem = await _artwork + .GetSystemArtworkBatchAsync(libraryId, systems.Select(system => system.Id), cancellationToken) + .ConfigureAwait(false); + foreach (var system in systems) + { + if (!artworkBySystem.TryGetValue(system.Id, out var artwork) || artwork.System.PreviewGameIds.Count == 0) + { + continue; + } + + var panels = BuildPreviewPanels(libraryId, artwork); + if (panels.Count > 0) + { + system.PreviewArtwork = new GameSystemPreviewArtwork + { + SelectionGeneration = artwork.System.InventoryGeneration ?? ArtworkGeneration(artwork.System.Generation), + Panels = panels, + }; + } + } + + return Ok(systems); + } + + /// + /// Projects one system's durable preview choice into renderable panels, in the persisted order. + /// The renderable boxart entries are indexed once rather than rescanned per panel. + /// + private List BuildPreviewPanels(string libraryId, GameArtworkSystemReadResult artwork) + { + var renderableBoxart = new Dictionary(StringComparer.Ordinal); + foreach (var entry in artwork.Entries) + { + if (string.Equals(entry.Role, "boxart", StringComparison.Ordinal) && IsRenderable(entry.Entry)) + { + // TryAdd, not the indexer: the replaced FirstOrDefault took the first match. + renderableBoxart.TryAdd(entry.GameId, entry); + } + } + + var panels = new List(); + foreach (var gameId in artwork.System.PreviewGameIds) + { + if (panels.Count == 4) + { + break; + } + + if (renderableBoxart.TryGetValue(gameId, out var entry)) + { + panels.Add(new GameSystemPreviewPanel + { + GameId = entry.GameId, + Artwork = ToDescriptor(libraryId, entry.GameId, entry.Role, entry.Entry), + }); + } + } + + return panels; + } + + /// Advertises the additive, manifest-driven artwork protocol for newer clients. + [HttpGet("ArtworkCapabilities")] + [Authorize] + [Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetArtworkCapabilities() => Ok(new GameArtworkCapabilities + { + ProtocolVersion = 2, + Manifest = true, + VersionedAssets = true, + PriorityHints = true, + SystemPreviews = true, + }); + + /// Returns the locally cataloged state for one system without scheduling provider work. + [HttpGet("{libraryId}/ArtworkManifest")] + [Authorize] + [Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status304NotModified)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetArtworkManifest( + [FromRoute] string libraryId, + [FromQuery] string? system, + [FromQuery] string? generation, + CancellationToken cancellationToken) + { + if (!GamesEnabled() || string.IsNullOrWhiteSpace(system)) + { + return ArtworkNotFound(confirmedMissing: false); + } + + var artwork = await _artwork.GetSystemArtworkAsync(libraryId, system, cancellationToken).ConfigureAwait(false); + if (artwork == null) + { + return ArtworkNotFound(confirmedMissing: false); + } + + // Two conditional-request mechanisms exist here on purpose, for now: the `generation` + // query parameter (below) is the one an already-shipped old client actually sends and is + // therefore authoritative; the `ETag` header is emitted for standard-HTTP-cache clients + // but nothing in this codebase currently reads it back via If-None-Match. Consolidating + // onto one (dropping the query param once no supported client needs it, or wiring up + // If-None-Match instead) is deferred: it needs to land together with manifest pagination + // (Task D8, which changes what a "page" of this response even is) and a separate pending + // change to when `Generation` is bumped. Removing either mechanism unilaterally before + // those land would be an unreviewed protocol break for the old client. + var currentGeneration = ArtworkGeneration(artwork.System.Generation); + Response.Headers["ETag"] = "\"" + currentGeneration + "\""; + if (string.Equals(generation, currentGeneration, StringComparison.Ordinal)) + { + return StatusCode(StatusCodes.Status304NotModified); + } + + var entries = artwork.Entries + .GroupBy(entry => entry.GameId, StringComparer.Ordinal) + .Select(group => new GameArtworkManifestEntry + { + GameId = group.Key, + Artwork = group.ToDictionary( + entry => entry.Role, + entry => ToDescriptor(libraryId, entry.GameId, entry.Role, entry.Entry), + StringComparer.Ordinal), + }) + .ToList(); + return Ok(new GameArtworkManifest { Generation = currentGeneration, Entries = entries }); + } + + /// Promotes already cataloged artwork work in client-provided priority order. + [HttpPost("{libraryId}/ArtworkPriority")] + [Authorize] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PostArtworkPriority( + [FromRoute] string libraryId, + [FromBody] GameArtworkPriorityRequest request, + CancellationToken cancellationToken) + { + if (!GamesEnabled()) + { + return NotFound(); + } + + if (request == null || string.IsNullOrWhiteSpace(request.SystemId) || request.Items == null) + { + return BadRequest(new { error = "A systemId is required." }); + } + + if (request.Items.Count > MaxArtworkPriorityItems) + { + return BadRequest(new { error = $"At most {MaxArtworkPriorityItems} artwork priority items may be submitted at once." }); + } + + var system = await _artwork.GetSystemArtworkAsync(libraryId, request.SystemId, cancellationToken).ConfigureAwait(false); + if (system == null) + { + return NotFound(); + } + + if (!string.Equals(request.KnownGeneration, ArtworkGeneration(system.System.Generation), StringComparison.Ordinal)) + { + return BadRequest(new { error = "The artwork generation is no longer current." }); + } + + // One snapshot for the whole request. Membership and promotion both used to resolve each + // game against an uncached recursive walk of the ROM library, so a maximal 128-item x + // 3-role body cost 512 full enumerations -- an authenticated denial of service on a large + // or network-mounted library. The decisions below are unchanged; only their cost is. + var lookup = _artwork.CreateGameLookup(libraryId); + + var seenGames = new HashSet(StringComparer.Ordinal); + foreach (var item in request.Items) + { + if (item == null || string.IsNullOrWhiteSpace(item.GameId) || !seenGames.Add(item.GameId) || + !system.GameIds.Contains(item.GameId, StringComparer.Ordinal) || + !_artwork.IsCurrentGameMember(libraryId, item.GameId, lookup) || + item.Roles == null || item.Roles.Count == 0) + { + return BadRequest(new { error = "Priority items must name distinct games in the requested system and at least one role." }); + } + + var seenRoles = new HashSet(StringComparer.Ordinal); + if (item.Roles.Any(role => !IsArtworkRole(role) || !seenRoles.Add(role))) + { + return BadRequest(new { error = "Artwork roles must be distinct boxart, snap, or title values." }); + } + } + + foreach (var item in request.Items) + { + foreach (var role in item.Roles) + { + await _artwork.PromoteAsync(libraryId, item.GameId, role, lookup, cancellationToken).ConfigureAwait(false); + } + } + + return NoContent(); + } + + /// Serves exactly one authenticated, versioned, local artwork artifact. + [HttpGet("{libraryId}/Artwork/{gameId}/{role}/{revision}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetArtwork( + [FromRoute] string libraryId, + [FromRoute] string gameId, + [FromRoute] string role, + [FromRoute] string revision, + CancellationToken cancellationToken) + { + if (!GamesEnabled()) + { + return ArtworkNotFound(confirmedMissing: false); + } + + var artwork = await _artwork.GetArtworkAsync(libraryId, gameId, role, cancellationToken).ConfigureAwait(false); + if (artwork == null) + { + return ArtworkNotFound(confirmedMissing: false); + } + + if (artwork.Entry.State == ArtworkCatalogState.Missing) + { + return ArtworkNotFound(confirmedMissing: true); + } + + // Resolved from the entry already read above: re-resolving here cost a second catalog + // lookup for every thumbnail in a grid. + var asset = _artwork.ResolveLocalArtwork(artwork.Entry, revision); + return asset == null + ? ArtworkNotFound(confirmedMissing: false) + : await ServeArtworkAsync(asset, versionedUrl: true, cancellationToken).ConfigureAwait(false); + } + + /// Administrator-only repair path that deliberately reopens and schedules one artifact. + [HttpPost("{libraryId}/Artwork/{gameId}/Refresh")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RefreshArtwork( + [FromRoute] string libraryId, + [FromRoute] string gameId, + [FromQuery] string? type, + CancellationToken cancellationToken) + { + if (!GamesEnabled()) + { + return NotFound(); + } + + var role = NormalizeLegacyRole(type); + if (type != null && !IsArtworkRole(type)) + { + return BadRequest(new { error = "Artwork type must be boxart, snap, or title." }); + } + + return await _artwork.RequestRefreshAsync(libraryId, gameId, role, cancellationToken).ConfigureAwait(false) + ? Accepted() + : NotFound(); } /// Lists the games inside a library, optionally filtered to one system. @@ -93,19 +380,176 @@ public ActionResult> GetGames( [Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetGame( + public async Task> GetGame( [FromRoute] string libraryId, - [FromRoute] string gameId) + [FromRoute] string gameId, + CancellationToken cancellationToken) { if (!GamesEnabled()) { return NotFound(); } - var game = _gamesService.GetGame(libraryId, gameId); + var game = await _gamesService + .GetGameAsync(libraryId, gameId, this.GetUserIdFromClaims(), cancellationToken) + .ConfigureAwait(false); return game == null ? NotFound() : Ok(game); } + /// + /// Sets or clears the current user's explicit arcade emulator choice. A null core returns the + /// game to its server-selected recommendation. + /// + [HttpPut("{libraryId}/Games/{gameId}/Core")] + [Authorize] + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PutCoreOverride( + [FromRoute] string libraryId, + [FromRoute] string gameId, + [FromBody] GameCoreOverrideRequest request, + CancellationToken cancellationToken) + { + if (!GamesEnabled()) + { + return NotFound(); + } + + var userId = this.GetUserIdFromClaims(); + if (userId == null) + { + return Unauthorized(); + } + + try + { + var game = await _gamesService.SetCoreOverrideAsync( + libraryId, + gameId, + userId.Value, + request.Core, + cancellationToken).ConfigureAwait(false); + return game == null ? NotFound() : Ok(game); + } + catch (ArgumentException) + { + return BadRequest(new { error = "The requested core is not available for this game." }); + } + } + + /// + /// Sets or clears the current user's explicit player backend. EmulatorJS is a player backend + /// rather than a libretro core, so this is available for every game system. + /// + [HttpPut("{libraryId}/Games/{gameId}/Backend")] + [Authorize] + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PutBackendOverride( + [FromRoute] string libraryId, + [FromRoute] string gameId, + [FromBody] GameBackendOverrideRequest request, + CancellationToken cancellationToken) + { + if (!GamesEnabled()) + { + return NotFound(); + } + + var userId = this.GetUserIdFromClaims(); + if (userId == null) + { + return Unauthorized(); + } + + try + { + var game = await _gamesService.SetBackendOverrideAsync( + libraryId, + gameId, + userId.Value, + request.Backend, + cancellationToken).ConfigureAwait(false); + return game == null ? NotFound() : Ok(game); + } + catch (ArgumentException) + { + return BadRequest(new { error = "The requested game backend is not available." }); + } + } + + /// Shows whether the locally pinned FBNeo/MAME DAT snapshots are installed. + [HttpGet("ArcadeCompatibility/Status")] + [Authorize(Policy = "RequiresElevation")] + [Produces(MediaTypeNames.Application.Json)] + public ActionResult GetArcadeCompatibilityStatus() => Ok(_arcadeCompatibility.GetStatus()); + + // Real mame -listxml DATs run 200 MB+; FBNeo's is smaller but still substantial and + // growing. This is an explicit ceiling rather than disabling the limit outright, so the + // maximum accepted DAT size is a documented decision instead of an accident. Note: Jellyfin + // owns the Kestrel host configuration, so a server-level MaxRequestBodySize lower than this + // value can still override it and cause uploads to fail before this action ever runs. + private const long MaxDatUploadBytes = 512L * 1024 * 1024; + + /// + /// Replaces one local compatibility DAT with an administrator-supplied, version-pinned XML + /// snapshot. This endpoint deliberately never downloads upstream data itself. + /// + [HttpPut("ArcadeCompatibility/{core}/Dat")] + [Authorize(Policy = "RequiresElevation")] + [Consumes("application/xml", "text/xml")] + [RequestSizeLimit(MaxDatUploadBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = MaxDatUploadBytes)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + public async Task PutArcadeCompatibilityDat( + [FromRoute] string core, + CancellationToken cancellationToken) + { + try + { + await _arcadeCompatibility.InstallDatAsync(core, Request.Body, cancellationToken).ConfigureAwait(false); + return NoContent(); + } + catch (EmptyArcadeDatException) + { + return BadRequest(new { error = "The uploaded DAT contains no usable game or machine sets." }); + } + catch (ArgumentException) + { + return BadRequest(new { error = "Use 'arcade' for FBNeo or 'mame' for MAME." }); + } + catch (System.Xml.XmlException) + { + return BadRequest(new { error = "The uploaded DAT is not valid XML." }); + } + catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex) when (IsRequestTooLarge(ex)) + { + return StatusCode( + StatusCodes.Status413PayloadTooLarge, + new { error = $"The uploaded DAT exceeds the {MaxDatUploadBytes / (1024 * 1024)} MB limit for this endpoint." }); + } + } + + /// + /// is Kestrel's generic + /// "something is wrong with this request" exception; it is also thrown for malformed + /// requests unrelated to size. Its StatusCode is 413 specifically when the body (or a + /// multipart section) exceeded the configured limit, so that is what distinguishes a + /// too-large upload from any other bad request here. + /// + private static bool IsRequestTooLarge(Microsoft.AspNetCore.Http.BadHttpRequestException ex) => + ex.StatusCode == StatusCodes.Status413PayloadTooLarge; + /// /// Streams a ROM file. EmulatorJS fetches this via XHR, and clients append the Jellyfin /// access token as an ApiKey query parameter so the WebView request authenticates. @@ -113,20 +557,34 @@ public ActionResult GetGame( /// reusing it, and treats a refusal as a fatal error rather than a cache miss. /// [HttpGet("{libraryId}/Rom/{token}")] + [HttpGet("{libraryId}/Rom/{token}/{fileName}")] [HttpHead("{libraryId}/Rom/{token}")] + [HttpHead("{libraryId}/Rom/{token}/{fileName}")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult GetRom([FromRoute] string libraryId, [FromRoute] string token) + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + public IActionResult GetRom( + [FromRoute] string libraryId, + [FromRoute] string token, + [FromRoute] string? fileName = null) { if (!GamesEnabled()) { return NotFound(); } + // The optional filename gives filename-sensitive emulators (MAME) the original archive + // name in the URL. It is deliberately never used for path resolution or authorization. + _ = fileName; var path = _gamesService.ResolveFilePath(libraryId, token, allowBios: false); if (!string.IsNullOrEmpty(path) && GamesService.IsArchive(path)) { + if (_gamesService.ShouldPreserveArchive(libraryId, path)) + { + return StreamFile(path); + } + return HttpMethods.IsHead(Request.Method) ? ExtractedRomSize(path) : StreamExtractedRom(path); @@ -136,43 +594,200 @@ public IActionResult GetRom([FromRoute] string libraryId, [FromRoute] string tok } /// - /// Streams a game's box art or screenshot, downloading and caching it on first ask. Takes a - /// game token and a kind, never a URL, so this cannot be pointed at another host. + /// Compatibility endpoint for old clients. This is catalog-only: it can promote existing + /// work, but remote acquisition and thumbnail encoding remain owned by background workers. /// [HttpGet("{libraryId}/Thumb/{gameId}")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [ProducesResponseType(StatusCodes.Status504GatewayTimeout)] public async Task GetThumb( [FromRoute] string libraryId, [FromRoute] string gameId, - [FromQuery] string? type) + [FromQuery] string? type, + [FromQuery] string? full, + CancellationToken cancellationToken) { if (!GamesEnabled()) { - return NotFound(); + return ArtworkNotFound(confirmedMissing: false); } - var source = _gamesService.ResolveThumbSource(libraryId, gameId); - if (source == null) + var role = NormalizeLegacyRole(type); + var artwork = await _artwork.GetArtworkAsync(libraryId, gameId, role, cancellationToken).ConfigureAwait(false); + if (artwork == null) { - return NotFound(); + // Startup reconciliation may not have reached this game yet. Preserve legacy + // availability by creating/promoting local catalog work, but never do provider + // lookup or encoding on the HTTP request path. + if (await _artwork.RequestRefreshAsync(libraryId, gameId, role, cancellationToken).ConfigureAwait(false)) + { + return RetryableArtworkResponse(StatusCodes.Status503ServiceUnavailable, TimeSpan.FromSeconds(5)); + } + + return ArtworkNotFound(confirmedMissing: false); } - var path = await _thumbService - .GetThumbPathAsync(source.Value.Core, source.Value.FileName, GameThumbService.ParseKind(type)) - .ConfigureAwait(false); - if (string.IsNullOrEmpty(path)) + if (artwork.Entry.State == ArtworkCatalogState.Missing) { - return NotFound(); + return ArtworkNotFound(confirmedMissing: true); + } + + if (artwork.Entry.State == ArtworkCatalogState.Pending) + { + await _artwork.PromoteAsync(libraryId, gameId, role, cancellationToken).ConfigureAwait(false); + return RetryableArtworkResponse(StatusCodes.Status503ServiceUnavailable, TimeSpan.FromSeconds(5)); + } + + if (artwork.Entry.State == ArtworkCatalogState.Retryable) + { + await _artwork.PromoteAsync(libraryId, gameId, role, cancellationToken).ConfigureAwait(false); + var retry = artwork.Entry.RetryAfterUtc - DateTimeOffset.UtcNow; + return RetryableArtworkResponse(StatusCodes.Status504GatewayTimeout, retry.GetValueOrDefault(TimeSpan.FromSeconds(5))); } - // Art for a given ROM name never changes, so let clients keep it. - Response.Headers["Cache-Control"] = "public,max-age=31536000,immutable"; + // A current thumbnail may be bypassed for old callers that explicitly request the + // original. Both variants remain local-only and are selected before cache headers. + var asset = _artwork.ResolveLocalArtwork( + artwork.Entry, + artwork.Entry.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture), + WantsFullResolution(full)); + return asset == null + ? ArtworkNotFound(confirmedMissing: false) + : await ServeArtworkAsync(asset, versionedUrl: false, cancellationToken).ConfigureAwait(false); + } + + /// + /// True for full=1/full=true (any other non-empty, non-"0"/"false" value also + /// counts, so this is lenient about exactly how a caller spells "yes"). Missing, empty, + /// "0", or "false" all mean "give me the default derived thumbnail". + /// + private static bool WantsFullResolution(string? full) => + !string.IsNullOrEmpty(full) + && full != "0" + && !string.Equals(full, "false", StringComparison.OrdinalIgnoreCase); + + /// + /// Serves one local artwork asset. distinguishes + /// 's {revision}-qualified route -- where a stale cache entry + /// simply stops being addressed once the server advertises a new revision -- from + /// 's legacy, unversioned route, where the URL never changes and an + /// immutable response would tell old clients to never even ask again after an + /// administrator refreshes the art. + /// + private async Task ServeArtworkAsync(GameArtworkLocalAsset asset, bool versionedUrl, CancellationToken cancellationToken) + { + IDisposable lease; + try + { + lease = await _artworkDeliveryLimiter.AcquireAsync(this.GetUserIdFromClaims(), cancellationToken).ConfigureAwait(false); + } + catch (ArtworkDeliveryUnavailableException ex) + { + return RetryableArtworkResponse(StatusCodes.Status503ServiceUnavailable, ex.RetryAfter); + } + + var abortRegistration = HttpContext.RequestAborted.Register(lease.Dispose); + Response.OnCompleted(() => + { + abortRegistration.Dispose(); + lease.Dispose(); + return Task.CompletedTask; + }); + Response.Headers["Cache-Control"] = SelectArtworkCacheControl(versionedUrl, asset.IsThumbnail); Response.Headers["X-Content-Type-Options"] = "nosniff"; - return PhysicalFile(path, "image/png"); + return PhysicalFile(asset.Path, asset.ContentType); } + /// + /// Picks the artwork Cache-Control value. Versioned URLs (the {revision} + /// route) may be cached as immutable because a stale copy simply falls out of use + /// once the revision segment changes. The legacy, unversioned thumb route has no such + /// signal, so it gets a short, must-revalidate lifetime instead -- PhysicalFile + /// already emits a Last-Modified header and honors conditional GETs, so revalidation is a + /// cheap 304, not a re-fetch. + /// + internal static string SelectArtworkCacheControl(bool versionedUrl, bool isThumbnail) => + versionedUrl && isThumbnail + ? "private,max-age=31536000,immutable" + : versionedUrl + ? "private,max-age=7200" + : "private,max-age=300,must-revalidate"; + + private IActionResult RetryableArtworkResponse(int statusCode, TimeSpan retryAfter) + { + var seconds = Math.Max(1, (int)Math.Ceiling(Math.Max(0, retryAfter.TotalSeconds))); + Response.Headers["Retry-After"] = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + Response.Headers["Cache-Control"] = "no-store"; + return StatusCode(statusCode); + } + + private IActionResult ArtworkNotFound(bool confirmedMissing) + { + Response.Headers["Cache-Control"] = confirmedMissing ? "private,max-age=300" : "no-store"; + return NotFound(); + } + + private static GameArtworkDescriptor ToDescriptor(string libraryId, string gameId, string role, ArtworkCatalogEntry entry) + { + var descriptor = new GameArtworkDescriptor { State = ArtworkState(entry.State) }; + if (IsRenderable(entry)) + { + var revision = entry.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture); + descriptor.Revision = revision; + descriptor.Url = $"/Moonfin/Games/{Uri.EscapeDataString(libraryId)}/Artwork/{Uri.EscapeDataString(gameId)}/{role}/{revision}"; + } + + if (entry.State == ArtworkCatalogState.OriginalReady) + { + var refresh = entry.RetryAfterUtc is { } retryAfter && retryAfter > DateTimeOffset.UtcNow + ? retryAfter - DateTimeOffset.UtcNow + : OriginalManifestRefresh; + descriptor.RefreshAfterSeconds = Math.Max(1, (int)Math.Ceiling(refresh.TotalSeconds)); + } + else if (entry.State == ArtworkCatalogState.Retryable && entry.RetryAfterUtc is { } retryAfter) + { + descriptor.RetryAfterSeconds = Math.Max(1, (int)Math.Ceiling(Math.Max(0, (retryAfter - DateTimeOffset.UtcNow).TotalSeconds))); + } + else if (entry.State == ArtworkCatalogState.Pending) + { + descriptor.RefreshAfterSeconds = 5; + } + + return descriptor; + } + + private static bool IsRenderable(ArtworkCatalogEntry entry) => + entry.State switch + { + ArtworkCatalogState.OriginalReady => !string.IsNullOrWhiteSpace(entry.OriginalPath), + ArtworkCatalogState.ThumbnailReady => !string.IsNullOrWhiteSpace(entry.ThumbnailPath) || !string.IsNullOrWhiteSpace(entry.OriginalPath), + _ => false, + }; + + private static string ArtworkState(ArtworkCatalogState state) => state switch + { + ArtworkCatalogState.OriginalReady => "originalReady", + ArtworkCatalogState.ThumbnailReady => "thumbnailReady", + ArtworkCatalogState.Missing => "missing", + ArtworkCatalogState.Retryable => "transientFailure", + _ => "pending", + }; + + private static string ArtworkGeneration(long generation) => + "artwork-" + generation.ToString(System.Globalization.CultureInfo.InvariantCulture); + + private static string NormalizeLegacyRole(string? type) => + string.Equals(type, "snap", StringComparison.OrdinalIgnoreCase) ? "snap" : + string.Equals(type, "title", StringComparison.OrdinalIgnoreCase) ? "title" : "boxart"; + + private static bool IsArtworkRole(string? role) => + string.Equals(role, "boxart", StringComparison.Ordinal) || + string.Equals(role, "snap", StringComparison.Ordinal) || + string.Equals(role, "title", StringComparison.Ordinal); + /// Streams a BIOS file required by a system's core, HEAD included for the same /// cache check EmulatorJS runs on ROMs. [HttpGet("{libraryId}/Bios/{token}")] @@ -254,8 +869,8 @@ private IActionResult StreamFile(string? path) return PhysicalFile(path, "application/octet-stream", enableRangeProcessing: true); } - // Unpacks a .zip/.7z ROM in memory so the client gets raw ROM bytes (no client-side unzip), - // exactly like an unpacked file. The archive on disk is untouched. ROMs only, never BIOS. + // Unpacks a single-ROM .zip/.7z in memory so the client gets raw ROM bytes, exactly like an + // unpacked file. MAME ZIPs bypass this and are streamed intact. ROMs only, never BIOS. private IActionResult StreamExtractedRom(string path) { byte[]? rom; @@ -263,6 +878,17 @@ private IActionResult StreamExtractedRom(string path) { rom = GamesService.ExtractRomFromArchive(path); } + catch (RomTooLargeException ex) + { + // Reachable by any authenticated non-admin user (unlike the admin-only DAT upload + // path), so this is a hard, documented ceiling rather than an unbounded allocation -- + // see GamesService.MaxExtractedRomBytes. Report it distinctly from the generic + // extraction-failure 404 below so a legitimately oversized/corrupt archive is + // diagnosable instead of looking like a missing file. + return StatusCode( + StatusCodes.Status413PayloadTooLarge, + new { error = $"The archive entry exceeds the {ex.MaxBytes / (1024 * 1024)} MB limit for extracted ROMs." }); + } catch { return NotFound(); @@ -297,3 +923,15 @@ private static bool GamesEnabled() return MoonfinPlugin.Instance?.Configuration?.GamesEnabled == true; } } + +/// Request body for a per-user arcade core override. +public sealed class GameCoreOverrideRequest +{ + public string? Core { get; set; } +} + +/// Request body for a per-user game player backend override. +public sealed class GameBackendOverrideRequest +{ + public string? Backend { get; set; } +} diff --git a/Jellyfin/backend/EmulatorJS/moonfin-bridge.js b/Jellyfin/backend/EmulatorJS/moonfin-bridge.js new file mode 100644 index 0000000..04d38e4 --- /dev/null +++ b/Jellyfin/backend/EmulatorJS/moonfin-bridge.js @@ -0,0 +1,679 @@ +// Moonfin's adapter for EmulatorJS. EmulatorJS remains the mapping and persistence authority. +(function () { + 'use strict'; + + // Match the original inline script: invalid launch URLs stop before any bridge globals exist. + if (!window.EJS_pathtodata) { + return; + } + + var GAMEPAD_REGISTRATION_MAX_ATTEMPTS = 20; + var GAMEPAD_REGISTRATION_RETRY_DELAY_MS = 100; + var FOCUS_AREA_ROW = 'row'; + var FOCUS_AREA_TAB = 'tab'; + var FOCUS_AREA_GAMEPAD = 'gamepad'; + var FOCUS_AREA_FOOTER = 'footer'; + var postToHost; + var applyRegisteredGamepads; + var installMoonfinGamepad; + // Exposed below as window.moonfinEscapeHandler so the behaviour is testable + // without a DOM event system. + var moonfinEscapeHandler; + + // Host transport and the version-sensitive EmulatorJS contract. + (function () { + var moonfinContractChecked = false; + + postToHost = function (message) { + try { + if (window.parent && window.parent !== window) { + // Wildcard target origin is acceptable only because every message posted here today + // (moonfin-ready, moonfin-controls-closed, moonfin-emulator-contract-violation, + // gamepad button state) is non-sensitive telemetry/control, and there is no matching + // window.addEventListener('message', ...) anywhere in this file, so there is no + // receive-side gap either. If a future message ever carries something sensitive (e.g. + // a save-state payload), it must not inherit this wildcard silently - restrict the + // target origin at that point. + window.parent.postMessage(message, '*'); + } + } catch (e) { /* ignore */ } + + // flutter_inappwebview handler (registered by the Flutter host). + try { + if (window.flutter_inappwebview) { + window.flutter_inappwebview.callHandler('moonfinPlayer', message); + } + } catch (e) { /* ignore */ } + }; + + // Escape belongs to the host, not to EmulatorJS. + // + // While a game runs the WebView owns focus, so a keyboard Escape is + // delivered to this page rather than to the Flutter host. EmulatorJS acts + // on it itself and tears the session down, which the host never learns + // about -- so its save-on-exit never runs and the player loses everything + // since the last save. Capture phase, on window, so this sees the key + // before any EmulatorJS handler and can stop it propagating; the host is + // asked for its menu instead, matching what Back does on every other input + // device. Nothing else is intercepted: game keys must still reach the core. + moonfinEscapeHandler = function (event) { + if (event.key !== 'Escape' && event.keyCode !== 27) { return; } + if (event.preventDefault) { event.preventDefault(); } + if (event.stopPropagation) { event.stopPropagation(); } + if (event.stopImmediatePropagation) { event.stopImmediatePropagation(); } + postToHost({ type: 'moonfin-menu-request' }); + }; + window.moonfinEscapeHandler = moonfinEscapeHandler; + if (window.addEventListener) { + window.addEventListener('keydown', moonfinEscapeHandler, true); + } + + // Guards against a silent EmulatorJS version bump. The gamepad/controller-mapping + // navigation adapter below (installMoonfinGamepad, moonfinControlInput, and friends) + // deliberately reaches into EmulatorJS's private instance state instead of maintaining a + // second mapping model, and every individual access is already wrapped in its own + // try/catch that fails silently (returns false/undefined) rather than throwing -- which + // means an upstream rename degrades to an unresponsive controller-config screen with no + // diagnostic. This runs once, at ready-time, and reports (but never throws for) each + // internal the adapter depends on that is missing, so a version bump is loud instead of + // silent. See CoresService.ExpectedEmulatorJsVersion (server-side) for the EmulatorJS + // release this was last verified against. The Flutter host now listens for + // 'moonfin-emulator-contract-violation' (game_emulator_screen.dart) and logs it for + // diagnosis; it deliberately has no other user-visible effect. + function moonfinAssertEmulatorContract(emu) { + if (moonfinContractChecked) { return; } + moonfinContractChecked = true; + try { + var missing = []; + function check(name, value) { + if (typeof value === 'undefined' || value === null) { + missing.push(name); + } + } + check('emu', emu); + if (!emu) { + missing.forEach(function (name) { + postToHost({ type: 'moonfin-emulator-contract-violation', missing: name }); + }); + return; + } + check('emu.gamepad', emu.gamepad); + check('emu.gamepad.gamepads', emu.gamepad && emu.gamepad.gamepads); + check('emu.gamepadSelection', emu.gamepadSelection); + check('emu.gamepadLabels', emu.gamepadLabels); + check('emu.controls', emu.controls); + check('emu.controlPopup', emu.controlPopup); + check('emu.controlMenu', emu.controlMenu); + check('emu.checkGamepadInputs', emu.checkGamepadInputs); + check('emu.saveSettings', emu.saveSettings); + check('emu.gamepadEvent', emu.gamepadEvent); + check('emu.keyChange', emu.keyChange); + check('emu.gameManager', emu.gameManager); + check('emu.getSettingValue', emu.getSettingValue); + check('emu.changeSettingOption', emu.changeSettingOption); + // DOM selectors the navigation adapter queries for (visibleRows, footerButtons, tabs, + // highlight, changeGamepad). EmulatorJS builds this markup once at init (controlMenu + // merely toggles display:none later), so these should already be present at ready-time + // even though the menu has never been opened. + if (emu.controlMenu) { + check('.ejs_control_bar', emu.controlMenu.querySelector('.ejs_control_bar')); + check('.ejs_control_player_bar > li', emu.controlMenu.querySelector('.ejs_control_player_bar > li')); + check(':scope > .ejs_button', emu.controlMenu.querySelector(':scope > .ejs_button')); + check('.ejs_gamepad_dropdown', emu.controlMenu.querySelector('.ejs_gamepad_dropdown')); + check('.ejs_control_selected', emu.controlMenu.querySelector('.ejs_control_selected')); + } else { + missing.push('.ejs_control_bar', '.ejs_control_player_bar > li', ':scope > .ejs_button', '.ejs_gamepad_dropdown', '.ejs_control_selected'); + } + missing.forEach(function (name) { + postToHost({ type: 'moonfin-emulator-contract-violation', missing: name }); + }); + } catch (e) { /* Contract reporting must never break emulator start. */ } + } + + window.EJS_ready = function () { + moonfinAssertEmulatorContract(window.EJS_emulator); + applyRegisteredGamepads(); + postToHost({ type: 'moonfin-ready' }); + }; + })(); + + // Android gamepad/keyboard registration and forwarding. + // + // Android System WebView does not provide navigator.getGamepads(). Represent its native + // controller as one EmulatorJS gamepad instead of keeping a second Moonfin mapping table. + // EmulatorJS then remains responsible for both mapping and saved settings. + (function () { + var registeredGamepads = []; + var registrationAttempts = 0; + + installMoonfinGamepad = function (emu, device, activatePlayer) { + try { + if (!device || !device.id || !emu || !emu.gamepadSelection || !emu.gamepad || !emu.gamepad.gamepads) { + return false; + } + var id = 'moonfin-' + device.id; + var selectionKey = id + '_' + id; + emu.gamepad.gamepads[id] = { + id: id, + index: id + }; + if (activatePlayer && !emu.moonfinActiveGamepad) { + emu.gamepadSelection[0] = selectionKey; + emu.moonfinActiveGamepad = selectionKey; + } + if (emu.gamepadLabels) { + emu.gamepadLabels.forEach(function (select, player) { + var option = select.querySelector('option[value="' + selectionKey + '"]'); + if (!option) { + option = document.createElement('option'); + option.value = selectionKey; + option.textContent = device.name || 'Android gamepad'; + select.appendChild(option); + } + select.value = emu.gamepadSelection[player] || 'notconnected'; + }); + } + return true; + } catch (e) { return false; } + }; + + applyRegisteredGamepads = function () { + try { + var emu = window.EJS_emulator; + if (!emu || !emu.gamepadSelection || !emu.gamepad || !emu.gamepad.gamepads) { + if (registrationAttempts++ < GAMEPAD_REGISTRATION_MAX_ATTEMPTS) { + window.setTimeout(applyRegisteredGamepads, GAMEPAD_REGISTRATION_RETRY_DELAY_MS); + } + return false; + } + registeredGamepads.forEach(function (device) { + installMoonfinGamepad(emu, device, registeredGamepads.length === 1); + }); + return true; + } catch (e) { + if (registrationAttempts++ < GAMEPAD_REGISTRATION_MAX_ATTEMPTS) { + window.setTimeout(applyRegisteredGamepads, GAMEPAD_REGISTRATION_RETRY_DELAY_MS); + } + return false; + } + }; + + window.moonfinRegisterGamepads = function (devices) { + registeredGamepads = Array.isArray(devices) ? devices : []; + registrationAttempts = 0; + return applyRegisteredGamepads(); + }; + window.moonfinGamepadInput = function (label, pressed, device) { + try { + var emu = window.EJS_emulator; + // TV remotes are navigation-only and therefore never registered as players. + if (!installMoonfinGamepad(emu, device, pressed)) { + return; + } + var id = 'moonfin-' + device.id; + emu.gamepadEvent({ + type: pressed ? 'buttondown' : 'buttonup', + gamepadIndex: id, + label: label, + index: label, + value: pressed ? 1 : 0, + oldValue: pressed ? 0 : 1 + }); + } catch (e) { /* ignore */ } + }; + window.moonfinKeyboardInput = function (keyCode) { + try { + var emu = window.EJS_emulator; + if (emu && emu.keyChange) { + emu.keyChange({ keyCode: keyCode, repeat: false, type: 'keydown' }); + } + } catch (e) { /* ignore */ } + }; + })(); + + // Controls-menu navigation, binding capture, persistence, and host close notifications. + (function () { + function visibleRows(emu) { + return Array.prototype.slice.call(emu.controlMenu.querySelectorAll('.ejs_control_bar')) + .filter(function (row) { return row.getClientRects().length > 0; }); + } + + function footerButtons(emu) { + return Array.prototype.slice.call(emu.controlMenu.querySelectorAll(':scope > .ejs_button')); + } + + function tabs(emu) { + return Array.prototype.slice.call(emu.controlMenu.querySelectorAll('.ejs_control_player_bar > li')); + } + function focus(emu) { + if (!emu.moonfinControlFocus) { + emu.moonfinControlFocus = { area: FOCUS_AREA_ROW, index: 0, column: 0 }; + } + return emu.moonfinControlFocus; + } + function selectPlayer(emu, index) { + var playerTabs = tabs(emu); + if (!playerTabs.length) { + return; + } + index = (index + playerTabs.length) % playerTabs.length; + var link = playerTabs[index].querySelector('a'); + // EmulatorJS attaches player selection to click (control rows use mousedown). + if (link) { + link.click(); + } + var current = focus(emu); + current.index = index; + current.area = FOCUS_AREA_TAB; + } + function highlight(emu) { + var current = focus(emu); + var rows = visibleRows(emu); + var playerTabs = tabs(emu); + var footer = footerButtons(emu); + if (current.area === FOCUS_AREA_ROW) { + current.index = Math.max(0, Math.min(rows.length - 1, current.index)); + } else if (current.area === FOCUS_AREA_TAB) { + current.index = Math.max(0, Math.min(playerTabs.length - 1, current.index)); + } else if (current.area === FOCUS_AREA_FOOTER) { + current.index = Math.max(0, Math.min(footer.length - 1, current.index)); + } + rows.forEach(function (row, index) { + row.style.outline = current.area === FOCUS_AREA_ROW && index === current.index ? '3px solid #3f8cff' : ''; + row.style.outlineOffset = '3px'; + Array.prototype.slice.call(row.querySelectorAll('input')).forEach(function (input, column) { + input.style.outline = current.area === FOCUS_AREA_ROW && index === current.index && column === current.column ? '2px solid #3f8cff' : ''; + }); + }); + playerTabs.forEach(function (tab, index) { + tab.style.outline = current.area === FOCUS_AREA_TAB && index === current.index ? '3px solid #3f8cff' : ''; + }); + var selector = emu.controlMenu.querySelector('.ejs_gamepad_dropdown'); + if (selector) { + selector.style.outline = current.area === FOCUS_AREA_GAMEPAD ? '3px solid #3f8cff' : ''; + } + footer.forEach(function (button, index) { + button.style.outline = current.area === FOCUS_AREA_FOOTER && index === current.index ? '3px solid #3f8cff' : ''; + }); + var target = current.area === FOCUS_AREA_ROW ? rows[current.index] : + current.area === FOCUS_AREA_TAB ? playerTabs[current.index] : + current.area === FOCUS_AREA_GAMEPAD ? selector : footer[current.index]; + if (target) { + target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + } + function openControlRow(emu) { + var current = focus(emu); + var row = visibleRows(emu)[current.index]; + if (!row) { + return; + } + // The upstream mousedown handler opens its keyboard-capture popup. + row.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + if (emu.controlPopup) { + emu.controlPopup.innerText = '[ ' + row.getAttribute('data-label') + ' ]\n' + + (current.column === 0 ? 'Press Gamepad' : 'Press a physical keyboard key'); + } + } + function clearDuplicateGamepadBinding(emu, player, button, label) { + var controls = emu.controls && emu.controls[player]; + if (!controls) { + return; + } + controls.forEach(function (binding, index) { + if (index !== button && binding && binding.value2 === label) { + binding.value2 = ''; + } + }); + } + function clearCurrentControl(emu) { + var popup = emu.controlPopup; + if (!popup) { return; } + var button = Number(popup.getAttribute('button-num')); + var player = Number(popup.getAttribute('player-num')); + if (!Number.isFinite(button) || !Number.isFinite(player)) { + return; + } + if (!emu.controls[player]) { + emu.controls[player] = []; + } + if (!emu.controls[player][button]) { + emu.controls[player][button] = {}; + } + emu.controls[player][button].value2 = ''; + popup.parentElement.parentElement.setAttribute('hidden', ''); + emu.checkGamepadInputs(); + emu.saveSettings(); + } + function changeGamepad(emu, delta) { + var selector = emu.controlMenu.querySelector('.ejs_gamepad_dropdown'); + if (!selector || !selector.options.length) { + return; + } + selector.selectedIndex = (selector.selectedIndex + delta + selector.options.length) % selector.options.length; + selector.dispatchEvent(new Event('change', { bubbles: true })); + } + + window.moonfinControlInput = function (label, pressed, device) { + try { + var emu = window.EJS_emulator; + if (!emu || !emu.controlMenu || emu.controlMenu.style.display === 'none') { + return; + } + if (!pressed) { + return; + } + var popup = emu.controlPopup && emu.controlPopup.parentElement.parentElement; + if (label === 'BACK') { + if (popup && popup.getAttribute('hidden') === null) { + popup.setAttribute('hidden', ''); + } else { + emu.controlMenu.style.display = 'none'; + } + if (emu.controlMenu.style.display === 'none') { + postToHost({ type: 'moonfin-controls-closed', reason: 'back' }); + } + return; + } + if (popup && popup.getAttribute('hidden') === null) { + // A TV remote is not a gamepad player; confirm invokes the normal Clear action. + if (!device) { + if (label === 'BUTTON_2') { + clearCurrentControl(emu); + } + return; + } + if (!installMoonfinGamepad(emu, device)) { + return; + } + var id = 'moonfin-' + device.id; + var button = Number(emu.controlPopup.getAttribute('button-num')); + var player = Number(emu.controlPopup.getAttribute('player-num')); + emu.gamepadEvent({ + type: 'buttondown', + gamepadIndex: id, + label: label, + index: label, + value: 1, + oldValue: 0 + }); + if (Number.isFinite(button) && Number.isFinite(player)) { + clearDuplicateGamepadBinding(emu, player, button, label); + emu.checkGamepadInputs(); + emu.saveSettings(); + } + return; + } + var current = focus(emu); + var rows = visibleRows(emu); + var playerTabs = tabs(emu); + var footer = footerButtons(emu); + if (label === 'DPAD_UP') { + if (current.area === FOCUS_AREA_ROW) { + if (current.index > 0) { + current.index--; + } else { + current.area = FOCUS_AREA_GAMEPAD; + } + } else if (current.area === FOCUS_AREA_GAMEPAD) { + current.area = FOCUS_AREA_TAB; + current.index = playerTabs.findIndex(function (tab) { + return tab.classList.contains('ejs_control_selected'); + }); + if (current.index < 0) { + current.index = 0; + } + } else if (current.area === FOCUS_AREA_FOOTER) { + current.area = FOCUS_AREA_ROW; + current.index = Math.max(0, rows.length - 1); + } + } else if (label === 'DPAD_DOWN') { + if (current.area === FOCUS_AREA_TAB) { + current.area = FOCUS_AREA_GAMEPAD; + } else if (current.area === FOCUS_AREA_GAMEPAD) { + current.area = FOCUS_AREA_ROW; + current.index = 0; + } else if (current.area === FOCUS_AREA_ROW) { + if (current.index < rows.length - 1) { + current.index++; + } else { + current.area = FOCUS_AREA_FOOTER; + current.index = 0; + } + } + } else if (label === 'DPAD_LEFT') { + if (current.area === FOCUS_AREA_TAB) { + selectPlayer(emu, current.index - 1); + } else if (current.area === FOCUS_AREA_GAMEPAD) { + changeGamepad(emu, -1); + } else if (current.area === FOCUS_AREA_ROW) { + current.column = 0; + } else if (current.area === FOCUS_AREA_FOOTER && footer.length) { + current.index = (current.index + footer.length - 1) % footer.length; + } + } else if (label === 'DPAD_RIGHT') { + if (current.area === FOCUS_AREA_TAB) { + selectPlayer(emu, current.index + 1); + } else if (current.area === FOCUS_AREA_GAMEPAD) { + changeGamepad(emu, 1); + } else if (current.area === FOCUS_AREA_ROW) { + current.column = 1; + } else if (current.area === FOCUS_AREA_FOOTER && footer.length) { + current.index = (current.index + 1) % footer.length; + } + } else if (label === 'BUTTON_2') { + if (current.area === FOCUS_AREA_TAB) { + selectPlayer(emu, current.index); + } else if (current.area === FOCUS_AREA_ROW) { + openControlRow(emu); + } else if (current.area === FOCUS_AREA_FOOTER && footer[current.index]) { + footer[current.index].click(); + } + } + highlight(emu); + // Footer actions (notably Close) can synchronously hide the upstream menu. + if (emu.controlMenu.style.display === 'none') { + postToHost({ type: 'moonfin-controls-closed', reason: 'close' }); + } + } catch (e) { /* ignore */ } + }; + // Versioned capability contract for the Android host. Older player pages do not advertise + // it, so newer clients leave the menu closed instead of routing controller input into a + // dialog they cannot drive. + window.moonfinControlsApiVersion = 1; + window.moonfinOpenControls = function () { + try { + var emu = window.EJS_emulator; + if (emu && emu.controlMenu) { + emu.controlMenu.style.display = ''; + emu.moonfinControlFocus = { area: FOCUS_AREA_ROW, index: 0, column: 0 }; + highlight(emu); + return emu.controlMenu.style.display !== 'none'; + } + } catch (e) { /* ignore */ } + return false; + }; + window.moonfinControlsOpen = function () { + try { + var emu = window.EJS_emulator; + return !!(emu && emu.controlMenu && emu.controlMenu.style.display !== 'none'); + } catch (e) { + return false; + } + }; + })(); + + // Save-state and core-option host bridge. + (function () { + function uint8ToBase64(bytes) { + var binary = ''; + for (var i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + function base64ToUint8(b64) { + var binary = atob(b64); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + // Returns the current save state as a base64 string, or null on failure. + window.moonfinGetState = function () { + try { + var emu = window.EJS_emulator; + if (!emu || !emu.gameManager) { + return null; + } + var state = emu.gameManager.getState(); + return state ? uint8ToBase64(state) : null; + } catch (e) { + return null; + } + }; + + window.moonfinLoadState = function (b64) { + try { + var emu = window.EJS_emulator; + if (emu && emu.gameManager && b64) { + emu.gameManager.loadState(base64ToUint8(b64)); + } + } catch (e) { /* ignore */ } + }; + + window.moonfinRestart = function () { + try { + var emu = window.EJS_emulator; + if (emu && emu.gameManager) { + emu.gameManager.restart(); + } + } catch (e) { /* ignore */ } + }; + + window.moonfinFastForward = function (on) { + try { + var emu = window.EJS_emulator; + if (emu && emu.gameManager && emu.gameManager.toggleFastForward) { + emu.gameManager.toggleFastForward(on ? 1 : 0); + } + } catch (e) { /* ignore */ } + }; + + window.moonfinPause = function (paused) { + try { + var emu = window.EJS_emulator; + if (emu && emu.gameManager && emu.gameManager.toggleMainLoop) { + emu.gameManager.toggleMainLoop(paused ? 0 : 1); + } + } catch (e) { /* ignore */ } + }; + + // EmulatorJS's stored settings (control remaps + options), for cloud sync on exit. + window.moonfinGetSettings = function () { + try { + return localStorage.getItem('ejs-settings'); + } catch (e) { + return null; + } + }; + window.moonfinGetOptions = function () { + try { + var emu = window.EJS_emulator; + if (!emu || !emu.gameManager || !emu.gameManager.getCoreOptions) { + return '[]'; + } + var raw = emu.gameManager.getCoreOptions(); + if (!raw) { + return '[]'; + } + var out = []; + raw.split('\n').forEach(function (line) { + if (!line) { + return; + } + var parts = line.split('; '); + if (parts.length < 2) { + return; + } + var id = parts[0].split('|')[0]; + var choices = parts[1].split('|'); + if (choices.length <= 1) { + return; + } + out.push({ + id: id, + label: id.replace(/_/g, ' ').replace(/.+\-(.+)/, '$1'), + choices: choices.map(function (choice) { + return { value: choice, label: choice.replace('(Default) ', '') }; + }), + current: emu.getSettingValue ? emu.getSettingValue(id) : null + }); + }); + [ + { id: 'shader', label: 'Shader', choices: ['disabled', '2xScaleHQ', '4xScaleHQ', 'crt-aperture', 'crt-easymode', 'crt-geom', 'crt-mattias', 'sabr', 'bicubic'] }, + { id: 'fps', label: 'FPS counter', choices: ['show', 'hide'] }, + { id: 'vsync', label: 'VSync', choices: ['enabled', 'disabled'] }, + { id: 'ff-ratio', label: 'Fast-forward ratio', choices: ['1.5', '2.0', '2.5', '3.0', '4.0', '5.0', '6.0', '8.0', 'unlimited'] }, + { id: 'sm-ratio', label: 'Slow-motion ratio', choices: ['1.5', '2.0', '2.5', '3.0', '4.0', '5.0'] }, + { id: 'save-state-slot', label: 'Save state slot', choices: ['1', '2', '3', '4', '5', '6', '7', '8', '9'] } + ].forEach(function (setting) { + var current = emu.getSettingValue ? emu.getSettingValue(setting.id) : null; + if (current == null) { + return; + } + out.push({ + id: setting.id, + label: setting.label, + choices: setting.choices.map(function (choice) { + return { value: choice, label: choice }; + }), + current: current + }); + }); + return JSON.stringify(out); + } catch (e) { return '[]'; } + }; + window.moonfinSetOption = function (id, value) { + try { + var emu = window.EJS_emulator; + if (emu && emu.changeSettingOption) { + emu.changeSettingOption(id, value); + } + } catch (e) { /* ignore */ } + }; + })(); + + // Forward hardware-gamepad buttons to the Flutter host on platforms whose WebView exposes the + // Gamepad API (iOS / desktop). Self-gating: does nothing on Android, where getGamepads() is + // empty and the native channel provides input instead. + (function () { + var previous = {}; + function poll() { + try { + var pads = navigator.getGamepads ? navigator.getGamepads() : []; + var pad = null; + for (var i = 0; i < pads.length; i++) { + if (pads[i]) { + pad = pads[i]; + break; + } + } + if (pad) { + for (var button = 0; button < pad.buttons.length; button++) { + var pressed = !!pad.buttons[button].pressed; + if (previous[button] !== pressed) { + previous[button] = pressed; + postToHost({ type: 'gamepad', index: button, pressed: pressed }); + } + } + } + } catch (e) { /* ignore */ } + requestAnimationFrame(poll); + } + requestAnimationFrame(poll); + })(); +})(); diff --git a/Jellyfin/backend/EmulatorJS/player.html b/Jellyfin/backend/EmulatorJS/player.html index 3320c02..4e77cfd 100644 --- a/Jellyfin/backend/EmulatorJS/player.html +++ b/Jellyfin/backend/EmulatorJS/player.html @@ -5,23 +5,8 @@ Moonfin Game