mirror of
https://github.com/drakmor/ShadowMountPlus.git
synced 2026-10-21 22:22:28 -05:00
Improve multilingual library controls and game actions (web)
This commit is contained in:
1 parent
9e6b7165a0
commit
f81eb60e94
10 files changed
+858
-197
No files matched your search
@@ -80,6 +80,18 @@ jobs:
|
||||
makepkg -c -f
|
||||
sudo pacman --noconfirm -U ./ps5-payload-*.pkg.tar.gz
|
||||
|
||||
- name: Build PS5 zlib package
|
||||
working-directory: pacbrew-repo/zlib
|
||||
run: |
|
||||
makepkg -c -f
|
||||
sudo pacman --noconfirm -U ./ps5-payload-*.pkg.tar.gz
|
||||
|
||||
- name: Build PS5 libpng package
|
||||
working-directory: pacbrew-repo/libpng
|
||||
run: |
|
||||
makepkg -c -f
|
||||
sudo pacman --noconfirm -U ./ps5-payload-*.pkg.tar.gz
|
||||
|
||||
- name: Build PS5 libmicrohttpd package
|
||||
working-directory: pacbrew-repo/libmicrohttpd
|
||||
run: |
|
||||
|
||||
@@ -6,8 +6,10 @@ BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Compiler and dependency flags
|
||||
HOMEBREW_ROOT := $(PS5_PAYLOAD_SDK)/target/user/homebrew
|
||||
JSON_CFLAGS := -I$(HOMEBREW_ROOT)/include
|
||||
HOMEBREW_CFLAGS := -I$(HOMEBREW_ROOT)/include
|
||||
MHD_LIB := $(HOMEBREW_ROOT)/lib/libmicrohttpd.a
|
||||
PNG_LIB := $(HOMEBREW_ROOT)/lib/libpng16.a
|
||||
ZLIB_LIB := $(HOMEBREW_ROOT)/lib/libz.a
|
||||
CFLAGS := -O3 -flto=thin -DNDEBUG -ffunction-sections -fdata-sections -Wall -Wextra -Wstrict-prototypes -Wmissing-prototypes -Werror=strict-prototypes -Werror=missing-prototypes -D_BSD_SOURCE -std=gnu11 -Iinclude -Isrc
|
||||
CFLAGS += -DSHADOWMOUNT_VERSION=\"$(VERSION_TAG)\"
|
||||
|
||||
@@ -15,7 +17,7 @@ CFLAGS += -DSHADOWMOUNT_VERSION=\"$(VERSION_TAG)\"
|
||||
LDFLAGS := -flto=thin -Wl,--gc-sections
|
||||
|
||||
# Libraries
|
||||
LIBS := -lSceNotification -lSceSystemService -lSceUserService -lSceAppInstUtil -lSceNet -lSceSsl -lSceHttp2 -lsqlite3 $(HOMEBREW_ROOT)/lib/libjson-c.a $(MHD_LIB) -lpthread -lm
|
||||
LIBS := -lSceNotification -lSceSystemService -lSceUserService -lSceAppInstUtil -lSceNet -lSceSsl -lSceHttp2 -lsqlite3 $(HOMEBREW_ROOT)/lib/libjson-c.a $(MHD_LIB) $(PNG_LIB) $(ZLIB_LIB) -lpthread -lm
|
||||
PS5_SCE_STUBS_DIR ?= $(PS5_PAYLOAD_SDK)/src/sce_stubs
|
||||
KERNEL_SYS_STUB_SO := src/libkernel_sys_ext.so
|
||||
KERNEL_SYS_STUB_SRCS := $(PS5_SCE_STUBS_DIR)/libkernel_sys.c src/libkernel_sys_ext.c
|
||||
@@ -50,7 +52,7 @@ src/notify_icon_asset.c: smp_icon.png
|
||||
src/config_ini_example_asset.c: config.ini.example
|
||||
xxd -i $< > $@
|
||||
|
||||
src/sm_api_service.o: CFLAGS += $(JSON_CFLAGS)
|
||||
src/sm_api_service.o src/sm_icon_thumb.o: CFLAGS += $(HOMEBREW_CFLAGS)
|
||||
src/main.o src/sm_image_index.o: CFLAGS += -DSHADOWMOUNT_BUILD_TIME=\"$(BUILD_TIME)\"
|
||||
src/main.o src/sm_image_index.o: FORCE
|
||||
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ device.
|
||||
| `/api/v1/manual/remove` | `{"path":"/mnt/usb0/games/PPSA12345"}` | Remove matching source lines from `manual.lst` |
|
||||
| `/api/v1/games` | `{"include_size":false}` | Detailed game snapshot; optional physical source-size calculation |
|
||||
| `/api/v1/games/info` | `{"title_id":"PPSA12345"}` | Detailed app.db and source information for one game; size is always calculated |
|
||||
| `/api/v1/games/icon?title_id=PPSA12345` | GET | Stream the PNG referenced by app.db `icon0Info` |
|
||||
| `/api/v1/games/icon?title_id=PPSA12345[&size=thumb]` | GET | Stream the full PNG or a cached 128x128 thumbnail |
|
||||
| `/api/v1/games/mount` | `{"title_id":"PPSA12345","mode":"ro"}` | Mount a managed game, optionally overriding its image mode with `ro`/`rw` |
|
||||
| `/api/v1/games/unmount` | `{"title_id":"PPSA12345"}` | Unmount a managed game |
|
||||
| `/api/v1/games/uninstall` | `{"title_id":"PPSA12345"}` | Request uninstallation through AppInstUtil |
|
||||
|
||||
+8
-1
@@ -344,7 +344,7 @@ paths:
|
||||
/api/v1/games/icon:
|
||||
get:
|
||||
operationId: getGameIcon
|
||||
summary: Stream the icon referenced by app.db icon0Info
|
||||
summary: Stream a game icon or a cached 128x128 thumbnail
|
||||
tags: [Games]
|
||||
parameters:
|
||||
- name: title_id
|
||||
@@ -352,6 +352,13 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/TitleId"
|
||||
- name: size
|
||||
in: query
|
||||
required: false
|
||||
description: Generate and return the cached thumbnail variant
|
||||
schema:
|
||||
type: string
|
||||
enum: [thumb]
|
||||
responses:
|
||||
"200":
|
||||
description: PNG icon
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#ifndef SM_ICON_THUMB_H
|
||||
#define SM_ICON_THUMB_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "sm_limits.h"
|
||||
|
||||
#define SM_ICON_THUMB_SIZE 128u
|
||||
|
||||
// Return a cached thumbnail path, creating it atomically when necessary.
|
||||
bool sm_icon_thumbnail_path(const char *title_id, const char *source_path,
|
||||
const struct stat *source_st,
|
||||
char out[MAX_PATH]);
|
||||
|
||||
#endif
|
||||
@@ -11,6 +11,7 @@
|
||||
"https://github.com/drakmor/ampr_emu/releases/latest/download/" \
|
||||
"libSceAmpr.sprx"
|
||||
#define FAKELIB_CACHE_PATH "/data/shadowmount/cache"
|
||||
#define ICON_CACHE_DIR "/data/shadowmount/icon-cache"
|
||||
#define LOG_DIR "/data/shadowmount"
|
||||
#define LOG_FILE "/data/shadowmount/debug.log"
|
||||
#define LOG_FILE_PREV "/data/shadowmount/debug.log.1"
|
||||
|
||||
+124
-49
@@ -15,6 +15,7 @@
|
||||
#include "sm_game_cache.h"
|
||||
#include "sm_game_lifecycle.h"
|
||||
#include "sm_gameinfo.h"
|
||||
#include "sm_icon_thumb.h"
|
||||
#include "sm_image.h"
|
||||
#include "sm_image_cache.h"
|
||||
#include "sm_image_index.h"
|
||||
@@ -38,9 +39,9 @@
|
||||
#define SM_API_RETRY_INITIAL_MS 250u
|
||||
#define SM_API_RETRY_MAX_MS 5000u
|
||||
#define SM_API_ROUTE_SIZE 128u
|
||||
#define SM_API_WORKER_COUNT 4u
|
||||
#define SM_API_CONNECTION_LIMIT 16u
|
||||
#define SM_API_CONNECTION_TIMEOUT_SECONDS 5u
|
||||
#define SM_API_WORKER_COUNT 8u
|
||||
#define SM_API_CONNECTION_LIMIT 32u
|
||||
#define SM_API_CONNECTION_TIMEOUT_SECONDS 15u
|
||||
#define SM_API_LISTEN_BACKLOG 32u
|
||||
#define SM_API_CONNECTION_MEMORY_LIMIT 16384u
|
||||
#define SM_API_STORAGE_STOP_LOG_SECONDS 5
|
||||
@@ -810,9 +811,67 @@ static bool api_listener_stop_requested(void *ctx) {
|
||||
return stopping;
|
||||
}
|
||||
|
||||
static bool copy_regular_file_path(const char *path, char out[MAX_PATH]) {
|
||||
struct stat st;
|
||||
if (!path || path[0] == '\0' || stat(path, &st) != 0 ||
|
||||
!S_ISREG(st.st_mode) || st.st_size <= 0) {
|
||||
return false;
|
||||
}
|
||||
(void)strlcpy(out, path, MAX_PATH);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool resolve_game_icon_path(const char *title_id,
|
||||
const char *recorded_path,
|
||||
char out[MAX_PATH]) {
|
||||
char appmeta_root[MAX_PATH];
|
||||
char app_sce_sys_root[MAX_PATH];
|
||||
char candidate[MAX_PATH];
|
||||
int appmeta_written = snprintf(appmeta_root, sizeof(appmeta_root), "%s/%s",
|
||||
APPMETA_BASE, title_id);
|
||||
int app_written = snprintf(app_sce_sys_root, sizeof(app_sce_sys_root),
|
||||
"%s/%s/sce_sys", APP_BASE, title_id);
|
||||
if (appmeta_written < 0 ||
|
||||
(size_t)appmeta_written >= sizeof(appmeta_root) || app_written < 0 ||
|
||||
(size_t)app_written >= sizeof(app_sce_sys_root)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int written = snprintf(candidate, sizeof(candidate), "%s/%s/icon0.png",
|
||||
APP_BASE, title_id);
|
||||
if (written > 0 && (size_t)written < sizeof(candidate) &&
|
||||
copy_regular_file_path(candidate, out)) {
|
||||
return true;
|
||||
}
|
||||
written = snprintf(candidate, sizeof(candidate), "%s/icon0.png",
|
||||
appmeta_root);
|
||||
if (written > 0 && (size_t)written < sizeof(candidate) &&
|
||||
copy_regular_file_path(candidate, out)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (recorded_path && recorded_path[0] != '\0') {
|
||||
(void)strlcpy(candidate, recorded_path, sizeof(candidate));
|
||||
char *query = strchr(candidate, '?');
|
||||
if (query)
|
||||
*query = '\0';
|
||||
if ((path_matches_root_or_child(candidate, appmeta_root) ||
|
||||
path_matches_root_or_child(candidate, app_sce_sys_root)) &&
|
||||
copy_regular_file_path(candidate, out)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
written = snprintf(candidate, sizeof(candidate), "%s/icon0.png",
|
||||
app_sce_sys_root);
|
||||
return written > 0 && (size_t)written < sizeof(candidate) &&
|
||||
copy_regular_file_path(candidate, out);
|
||||
}
|
||||
|
||||
static struct json_object *game_to_json(
|
||||
const sm_game_cache_snapshot_entry_t *source,
|
||||
const sm_app_db_game_info_t *metadata, bool include_size) {
|
||||
const sm_app_db_game_info_t *metadata, bool include_size,
|
||||
bool thumbnail_icon) {
|
||||
struct json_object *item = json_object_new_object();
|
||||
if (!item)
|
||||
return NULL;
|
||||
@@ -836,10 +895,19 @@ static struct json_object *game_to_json(
|
||||
const char *last_access_time = metadata ? metadata->last_access_time : "";
|
||||
const char *install_time = metadata ? metadata->install_time : "";
|
||||
char icon_url[SM_API_ROUTE_SIZE + MAX_TITLE_ID + 32u];
|
||||
char icon_path[MAX_PATH];
|
||||
icon_url[0] = '\0';
|
||||
if (metadata && metadata->icon_path[0] != '\0') {
|
||||
(void)snprintf(icon_url, sizeof(icon_url), "%s?title_id=%s",
|
||||
SM_API_ROUTE_GAME_ICON, source->title_id);
|
||||
if (resolve_game_icon_path(source->title_id,
|
||||
metadata ? metadata->icon_path : NULL,
|
||||
icon_path)) {
|
||||
struct stat icon_st;
|
||||
if (stat(icon_path, &icon_st) == 0) {
|
||||
const char *size_arg = thumbnail_icon ? "&size=thumb" : "";
|
||||
(void)snprintf(
|
||||
icon_url, sizeof(icon_url), "%s?title_id=%s%s&v=%lld-%lld",
|
||||
SM_API_ROUTE_GAME_ICON, source->title_id, size_arg,
|
||||
(long long)icon_st.st_mtime, (long long)icon_st.st_size);
|
||||
}
|
||||
}
|
||||
|
||||
if (!add_json_string(item, "path", source_info.physical_path) ||
|
||||
@@ -992,7 +1060,7 @@ static void handle_games(struct MHD_Connection *fd,
|
||||
const sm_app_db_game_info_t *game_metadata = app_db_find_game_info(
|
||||
metadata, metadata_count, snapshot[i].title_id);
|
||||
struct json_object *game =
|
||||
game_to_json(&snapshot[i], game_metadata, include_size);
|
||||
game_to_json(&snapshot[i], game_metadata, include_size, true);
|
||||
if (!game || json_object_array_add(games, game) != 0) {
|
||||
if (game)
|
||||
json_object_put(game);
|
||||
@@ -1082,7 +1150,7 @@ static void handle_game_info(struct MHD_Connection *connection,
|
||||
const sm_app_db_game_info_t *game_metadata =
|
||||
app_db_find_game_info(metadata, metadata_count, title_id);
|
||||
struct json_object *response =
|
||||
game_to_json(&game_entry, game_metadata, true);
|
||||
game_to_json(&game_entry, game_metadata, true, false);
|
||||
free(metadata);
|
||||
if (!response) {
|
||||
send_out_of_memory_response(connection);
|
||||
@@ -1105,48 +1173,34 @@ static bool handle_game_icon(struct MHD_Connection *connection) {
|
||||
connection, 400, EINVAL,
|
||||
"title_id query parameter must be a valid PS4 or PS5 title ID");
|
||||
}
|
||||
|
||||
sm_app_db_game_info_t *metadata = NULL;
|
||||
size_t metadata_count = 0;
|
||||
if (!app_db_game_info_snapshot(&metadata, &metadata_count)) {
|
||||
int status = errno != 0 ? errno : EIO;
|
||||
return send_error_response(connection, operation_http_status(status),
|
||||
status, strerror(status));
|
||||
}
|
||||
const sm_app_db_game_info_t *game_metadata =
|
||||
app_db_find_game_info(metadata, metadata_count, title_id);
|
||||
if (!game_metadata || game_metadata->icon_path[0] == '\0') {
|
||||
free(metadata);
|
||||
return send_error_response(connection, 404, ENOENT,
|
||||
"game icon is not available");
|
||||
const char *size = MHD_lookup_connection_value(
|
||||
connection, MHD_GET_ARGUMENT_KIND, "size");
|
||||
bool want_thumbnail = size && size[0] != '\0';
|
||||
if (want_thumbnail && strcmp(size, "thumb") != 0) {
|
||||
return send_error_response(connection, 400, EINVAL,
|
||||
"size query parameter must be thumb");
|
||||
}
|
||||
|
||||
char icon_path[MAX_PATH];
|
||||
(void)strlcpy(icon_path, game_metadata->icon_path, sizeof(icon_path));
|
||||
free(metadata);
|
||||
char *query = strchr(icon_path, '?');
|
||||
if (query)
|
||||
*query = '\0';
|
||||
|
||||
char appmeta_root[MAX_PATH];
|
||||
char app_sce_sys_root[MAX_PATH];
|
||||
int appmeta_written = snprintf(appmeta_root, sizeof(appmeta_root), "%s/%s",
|
||||
APPMETA_BASE, title_id);
|
||||
int app_written = snprintf(app_sce_sys_root, sizeof(app_sce_sys_root),
|
||||
"%s/%s/sce_sys", APP_BASE, title_id);
|
||||
if (appmeta_written < 0 ||
|
||||
(size_t)appmeta_written >= sizeof(appmeta_root) || app_written < 0 ||
|
||||
(size_t)app_written >= sizeof(app_sce_sys_root) ||
|
||||
(!path_matches_root_or_child(icon_path, appmeta_root) &&
|
||||
!path_matches_root_or_child(icon_path, app_sce_sys_root))) {
|
||||
return send_error_response(connection, 404, ENOENT,
|
||||
"game icon path is not valid");
|
||||
}
|
||||
|
||||
struct stat st;
|
||||
if (stat(icon_path, &st) != 0 || !S_ISREG(st.st_mode)) {
|
||||
return send_error_response(connection, 404, ENOENT,
|
||||
"game icon is not available");
|
||||
if (!resolve_game_icon_path(title_id, NULL, icon_path)) {
|
||||
sm_app_db_game_info_t *metadata = NULL;
|
||||
size_t metadata_count = 0;
|
||||
if (!app_db_game_info_snapshot(&metadata, &metadata_count)) {
|
||||
int status = errno != 0 ? errno : EIO;
|
||||
return send_error_response(connection, operation_http_status(status),
|
||||
status, strerror(status));
|
||||
}
|
||||
const sm_app_db_game_info_t *game_metadata =
|
||||
app_db_find_game_info(metadata, metadata_count, title_id);
|
||||
char recorded_path[MAX_PATH];
|
||||
(void)strlcpy(recorded_path,
|
||||
game_metadata ? game_metadata->icon_path : "",
|
||||
sizeof(recorded_path));
|
||||
free(metadata);
|
||||
if (!resolve_game_icon_path(title_id, recorded_path, icon_path)) {
|
||||
return send_error_response(connection, 404, ENOENT,
|
||||
"game icon is not available");
|
||||
}
|
||||
}
|
||||
int fd = open(icon_path, O_RDONLY);
|
||||
if (fd < 0) {
|
||||
@@ -1154,8 +1208,29 @@ static bool handle_game_icon(struct MHD_Connection *connection) {
|
||||
return send_error_response(connection, operation_http_status(status),
|
||||
status, strerror(status));
|
||||
}
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode) || st.st_size <= 0) {
|
||||
(void)close(fd);
|
||||
return send_error_response(connection, 404, ENOENT,
|
||||
"game icon is not available");
|
||||
}
|
||||
if (want_thumbnail) {
|
||||
char thumbnail_path[MAX_PATH];
|
||||
if (sm_icon_thumbnail_path(title_id, icon_path, &st, thumbnail_path)) {
|
||||
int thumbnail_fd = open(thumbnail_path, O_RDONLY);
|
||||
struct stat thumbnail_st;
|
||||
if (thumbnail_fd >= 0 && fstat(thumbnail_fd, &thumbnail_st) == 0 &&
|
||||
S_ISREG(thumbnail_st.st_mode) && thumbnail_st.st_size > 0) {
|
||||
(void)close(fd);
|
||||
fd = thumbnail_fd;
|
||||
st = thumbnail_st;
|
||||
} else if (thumbnail_fd >= 0) {
|
||||
(void)close(thumbnail_fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
return send_file_response(connection, fd, (uint64_t)st.st_size, "image/png",
|
||||
"private, max-age=300");
|
||||
"public, max-age=86400, immutable");
|
||||
}
|
||||
|
||||
static bool handle_index_page(struct MHD_Connection *connection) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#include "sm_platform.h"
|
||||
|
||||
#include <png.h>
|
||||
|
||||
#include "sm_icon_thumb.h"
|
||||
#include "sm_paths.h"
|
||||
|
||||
#define SM_ICON_SOURCE_MAX_SIZE (4u * 1024u * 1024u)
|
||||
#define SM_ICON_SOURCE_MAX_DIMENSION 2048u
|
||||
#define SM_ICON_TEMP_MAX_AGE_SECONDS 300
|
||||
|
||||
static unsigned int g_icon_temp_serial;
|
||||
|
||||
static bool ensure_icon_cache_directory(void) {
|
||||
if (mkdir(LOG_DIR, 0777) != 0 && errno != EEXIST)
|
||||
return false;
|
||||
return mkdir(ICON_CACHE_DIR, 0777) == 0 || errno == EEXIST;
|
||||
}
|
||||
|
||||
static void resize_rgba(const unsigned char *source, uint32_t source_width,
|
||||
uint32_t source_height, unsigned char *target) {
|
||||
const uint32_t target_size = SM_ICON_THUMB_SIZE;
|
||||
for (uint32_t y = 0; y < target_size; ++y) {
|
||||
uint64_t source_y = target_size > 1u
|
||||
? ((uint64_t)y * (source_height - 1u) << 16u) /
|
||||
(target_size - 1u)
|
||||
: 0;
|
||||
uint32_t y0 = (uint32_t)(source_y >> 16u);
|
||||
uint32_t y1 = y0 + 1u < source_height ? y0 + 1u : y0;
|
||||
uint32_t wy = (uint32_t)(source_y & 0xffffu);
|
||||
for (uint32_t x = 0; x < target_size; ++x) {
|
||||
uint64_t source_x = target_size > 1u
|
||||
? ((uint64_t)x * (source_width - 1u) << 16u) /
|
||||
(target_size - 1u)
|
||||
: 0;
|
||||
uint32_t x0 = (uint32_t)(source_x >> 16u);
|
||||
uint32_t x1 = x0 + 1u < source_width ? x0 + 1u : x0;
|
||||
uint32_t wx = (uint32_t)(source_x & 0xffffu);
|
||||
const unsigned char *p00 = source + ((size_t)y0 * source_width + x0) * 4u;
|
||||
const unsigned char *p10 = source + ((size_t)y0 * source_width + x1) * 4u;
|
||||
const unsigned char *p01 = source + ((size_t)y1 * source_width + x0) * 4u;
|
||||
const unsigned char *p11 = source + ((size_t)y1 * source_width + x1) * 4u;
|
||||
unsigned char *pixel = target + ((size_t)y * target_size + x) * 4u;
|
||||
for (size_t channel = 0; channel < 4u; ++channel) {
|
||||
uint32_t top = (p00[channel] * (65536u - wx) +
|
||||
p10[channel] * wx + 32768u) >>
|
||||
16u;
|
||||
uint32_t bottom = (p01[channel] * (65536u - wx) +
|
||||
p11[channel] * wx + 32768u) >>
|
||||
16u;
|
||||
pixel[channel] = (unsigned char)((top * (65536u - wy) +
|
||||
bottom * wy + 32768u) >>
|
||||
16u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void cleanup_stale_temp_files(const char *title_id) {
|
||||
DIR *directory = opendir(ICON_CACHE_DIR);
|
||||
if (!directory)
|
||||
return;
|
||||
|
||||
char prefix[MAX_TITLE_ID + 32u];
|
||||
int written = snprintf(prefix, sizeof(prefix), "%s-%u-", title_id,
|
||||
SM_ICON_THUMB_SIZE);
|
||||
if (written < 0 || (size_t)written >= sizeof(prefix)) {
|
||||
(void)closedir(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct dirent *entry;
|
||||
while ((entry = readdir(directory)) != NULL) {
|
||||
if (strncmp(entry->d_name, prefix, (size_t)written) != 0)
|
||||
continue;
|
||||
char path[MAX_PATH];
|
||||
int path_written = snprintf(path, sizeof(path), "%s/%s", ICON_CACHE_DIR,
|
||||
entry->d_name);
|
||||
if (path_written < 0 || (size_t)path_written >= sizeof(path) ||
|
||||
!strstr(entry->d_name, ".tmp.")) {
|
||||
continue;
|
||||
}
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0 || now < st.st_mtime ||
|
||||
now - st.st_mtime < SM_ICON_TEMP_MAX_AGE_SECONDS) {
|
||||
continue;
|
||||
}
|
||||
(void)unlink(path);
|
||||
}
|
||||
(void)closedir(directory);
|
||||
}
|
||||
|
||||
static bool create_thumbnail(const char *source_path, const char *cache_path) {
|
||||
png_image image;
|
||||
memset(&image, 0, sizeof(image));
|
||||
image.version = PNG_IMAGE_VERSION;
|
||||
if (!png_image_begin_read_from_file(&image, source_path))
|
||||
return false;
|
||||
if (image.width == 0 || image.height == 0 ||
|
||||
image.width > SM_ICON_SOURCE_MAX_DIMENSION ||
|
||||
image.height > SM_ICON_SOURCE_MAX_DIMENSION) {
|
||||
png_image_free(&image);
|
||||
return false;
|
||||
}
|
||||
|
||||
image.format = PNG_FORMAT_RGBA;
|
||||
size_t source_size = PNG_IMAGE_SIZE(image);
|
||||
unsigned char *source = malloc(source_size);
|
||||
if (!source) {
|
||||
png_image_free(&image);
|
||||
return false;
|
||||
}
|
||||
if (!png_image_finish_read(&image, NULL, source, 0, NULL)) {
|
||||
free(source);
|
||||
png_image_free(&image);
|
||||
return false;
|
||||
}
|
||||
uint32_t source_width = image.width;
|
||||
uint32_t source_height = image.height;
|
||||
png_image_free(&image);
|
||||
|
||||
size_t target_size = (size_t)SM_ICON_THUMB_SIZE * SM_ICON_THUMB_SIZE * 4u;
|
||||
unsigned char *target = malloc(target_size);
|
||||
if (!target) {
|
||||
free(source);
|
||||
return false;
|
||||
}
|
||||
resize_rgba(source, source_width, source_height, target);
|
||||
free(source);
|
||||
|
||||
unsigned int serial = __sync_add_and_fetch(&g_icon_temp_serial, 1u);
|
||||
char temp_path[MAX_PATH];
|
||||
int written = snprintf(temp_path, sizeof(temp_path), "%s.tmp.%ld.%u",
|
||||
cache_path, (long)getpid(), serial);
|
||||
if (written < 0 || (size_t)written >= sizeof(temp_path)) {
|
||||
free(target);
|
||||
return false;
|
||||
}
|
||||
|
||||
png_image output;
|
||||
memset(&output, 0, sizeof(output));
|
||||
output.version = PNG_IMAGE_VERSION;
|
||||
output.width = SM_ICON_THUMB_SIZE;
|
||||
output.height = SM_ICON_THUMB_SIZE;
|
||||
output.format = PNG_FORMAT_RGBA;
|
||||
(void)unlink(temp_path);
|
||||
bool encoded = png_image_write_to_file(&output, temp_path, 0, target, 0,
|
||||
NULL) != 0;
|
||||
free(target);
|
||||
png_image_free(&output);
|
||||
if (!encoded) {
|
||||
(void)unlink(temp_path);
|
||||
return false;
|
||||
}
|
||||
|
||||
int fd = open(temp_path, O_RDONLY);
|
||||
bool synced = fd >= 0 && fsync(fd) == 0;
|
||||
if (fd >= 0)
|
||||
(void)close(fd);
|
||||
if (!synced || rename(temp_path, cache_path) != 0) {
|
||||
(void)unlink(temp_path);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool sm_icon_thumbnail_path(const char *title_id, const char *source_path,
|
||||
const struct stat *source_st,
|
||||
char out[MAX_PATH]) {
|
||||
if (!title_id || title_id[0] == '\0' || strpbrk(title_id, "/\\") ||
|
||||
!source_path || !source_st || !S_ISREG(source_st->st_mode) ||
|
||||
source_st->st_size <= 0 ||
|
||||
(uint64_t)source_st->st_size > SM_ICON_SOURCE_MAX_SIZE ||
|
||||
!ensure_icon_cache_directory()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int written = snprintf(out, MAX_PATH, "%s/%s-%u-%lld-%lld.png",
|
||||
ICON_CACHE_DIR, title_id, SM_ICON_THUMB_SIZE,
|
||||
(long long)source_st->st_mtime,
|
||||
(long long)source_st->st_size);
|
||||
if (written < 0 || written >= MAX_PATH)
|
||||
return false;
|
||||
|
||||
struct stat cache_st;
|
||||
if (stat(out, &cache_st) == 0 && S_ISREG(cache_st.st_mode) &&
|
||||
cache_st.st_size > 0) {
|
||||
return true;
|
||||
}
|
||||
if (!create_thumbnail(source_path, out))
|
||||
return false;
|
||||
cleanup_stale_temp_files(title_id);
|
||||
return true;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import errno
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
@@ -386,6 +387,38 @@ def test_game_details(client: ApiClient, games: list) -> None:
|
||||
require(required_fields.issubset(game), "game list item lacks metadata")
|
||||
require("size_bytes" not in game, "game list calculated size by default")
|
||||
|
||||
thumbnail_url = next(
|
||||
(
|
||||
game.get("icon_url")
|
||||
for game in games
|
||||
if isinstance(game.get("icon_url"), str) and game.get("icon_url")
|
||||
),
|
||||
"",
|
||||
)
|
||||
if thumbnail_url:
|
||||
require("size=thumb" in thumbnail_url, "game list icon is not a thumbnail")
|
||||
first_status, headers, first_thumbnail = client.request("GET", thumbnail_url)
|
||||
second_status, _, second_thumbnail = client.request("GET", thumbnail_url)
|
||||
require(first_status == 200, f"game thumbnail returned HTTP {first_status}")
|
||||
require(second_status == 200, "cached game thumbnail request failed")
|
||||
require(
|
||||
headers.get("content-type", "").lower().startswith("image/png"),
|
||||
"game thumbnail response is not PNG",
|
||||
)
|
||||
require(
|
||||
"max-age=" in headers.get("cache-control", "").lower(),
|
||||
"game thumbnail response is not cacheable",
|
||||
)
|
||||
require(
|
||||
len(first_thumbnail) >= 24 and first_thumbnail[:8] == b"\x89PNG\r\n\x1a\n",
|
||||
"game thumbnail has an invalid PNG header",
|
||||
)
|
||||
require(
|
||||
struct.unpack(">II", first_thumbnail[16:24]) == (128, 128),
|
||||
"game thumbnail is not 128x128",
|
||||
)
|
||||
require(first_thumbnail == second_thumbnail, "cached thumbnail changed")
|
||||
|
||||
title_id = games[0].get("title_id")
|
||||
require(isinstance(title_id, str) and title_id, "game has invalid title_id")
|
||||
http_status, detail = client.post(ROUTE_GAME_INFO, {"title_id": title_id})
|
||||
|
||||
+463
-143
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) 170px auto auto auto;
|
||||
grid-template-columns: minmax(220px, 1fr) 150px 110px auto auto auto;
|
||||
gap: 10px;
|
||||
padding: 13px;
|
||||
border-radius: var(--radius);
|
||||
@@ -209,8 +209,11 @@
|
||||
.cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover-fallback { display: grid; place-items: center; width: 100%; height: 100%; color: var(--blue); font-weight: 800; }
|
||||
.game-main { min-width: 0; }
|
||||
.game-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px; }
|
||||
.game-identity { min-width: 0; }
|
||||
.game-title { overflow: hidden; font-size: 15px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.game-id { color: var(--muted); font: 12px/1.5 ui-monospace, "SFMono-Regular", Consolas, monospace; }
|
||||
.game-card-size { color: var(--blue); font-size: 14px; font-weight: 750; white-space: nowrap; }
|
||||
|
||||
.badges { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||||
.badge { border: 1px solid var(--line); border-radius: 999px; color: var(--muted); padding: 2px 7px; font-size: 11px; text-transform: uppercase; }
|
||||
@@ -218,7 +221,7 @@
|
||||
.badge.image { border-color: rgba(85, 167, 255, .38); color: #8cc4ff; }
|
||||
|
||||
.game-path { overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.game-actions { display: flex; gap: 7px; margin-top: 10px; }
|
||||
.game-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
|
||||
.panel { padding: 16px; border-radius: var(--radius); box-shadow: none; }
|
||||
.sidebar { display: grid; gap: 14px; position: sticky; top: 14px; }
|
||||
@@ -347,99 +350,103 @@
|
||||
<div class="brand">
|
||||
<div class="brand-mark" aria-hidden="true">S</div>
|
||||
<div>
|
||||
<h1>ShadowMount Library</h1>
|
||||
<p class="subtle" id="version">Подключение к API…</p>
|
||||
<h1 data-i18n="pageTitle">ShadowMount Library</h1>
|
||||
<p class="subtle" id="version" data-i18n="connecting">Connecting to API…</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="connection" id="connection" role="status">
|
||||
<span class="connection-dot"></span>
|
||||
<span id="connectionText">Проверка</span>
|
||||
<span id="connectionText" data-i18n="checking">Checking</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="toolbar" aria-label="Управление библиотекой">
|
||||
<input id="search" type="search" autocomplete="off" placeholder="Название, Title ID, Content ID или путь">
|
||||
<select id="filter" aria-label="Фильтр библиотеки">
|
||||
<option value="all">Все игры</option>
|
||||
<option value="folder">Папки</option>
|
||||
<option value="image">Образы</option>
|
||||
<section class="toolbar" aria-label="Library controls" data-i18n-aria-label="libraryControls">
|
||||
<input id="search" type="search" autocomplete="off" placeholder="Name, Title ID, Content ID, or path" data-i18n-placeholder="searchPlaceholder">
|
||||
<select id="filter" aria-label="Library filter" data-i18n-aria-label="libraryFilter">
|
||||
<option value="all" data-i18n="allGames">All games</option>
|
||||
<option value="folder" data-i18n="folders">Folders</option>
|
||||
<option value="image" data-i18n="images">Images</option>
|
||||
<option value="ps4">PS4</option>
|
||||
<option value="ps5">PS5</option>
|
||||
</select>
|
||||
<label class="check"><input id="includeSize" type="checkbox"> Считать размер</label>
|
||||
<button class="btn" id="refreshBtn" type="button">Обновить</button>
|
||||
<button class="btn primary" id="scanBtn" type="button">Сканировать</button>
|
||||
<select id="language" aria-label="Language" data-i18n-aria-label="language">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
<label class="check"><input id="includeSize" type="checkbox"> <span data-i18n="calculateSize">Calculate size</span></label>
|
||||
<button class="btn" id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
|
||||
<button class="btn primary" id="scanBtn" type="button" data-i18n="scan">Scan</button>
|
||||
</section>
|
||||
|
||||
<section class="stats" aria-label="Сводка библиотеки">
|
||||
<div class="stat"><span class="subtle">Всего игр</span><strong class="stat-value" id="totalGames">—</strong></div>
|
||||
<div class="stat"><span class="subtle">Образы</span><strong class="stat-value" id="imageGames">—</strong></div>
|
||||
<div class="stat"><span class="subtle">Папки</span><strong class="stat-value" id="folderGames">—</strong></div>
|
||||
<div class="stat"><span class="subtle">Смонтировано</span><strong class="stat-value" id="mountedGames">—</strong></div>
|
||||
<section class="stats" aria-label="Library summary" data-i18n-aria-label="librarySummary">
|
||||
<div class="stat"><span class="subtle" data-i18n="totalGames">Total games</span><strong class="stat-value" id="totalGames">—</strong></div>
|
||||
<div class="stat"><span class="subtle" data-i18n="images">Images</span><strong class="stat-value" id="imageGames">—</strong></div>
|
||||
<div class="stat"><span class="subtle" data-i18n="folders">Folders</span><strong class="stat-value" id="folderGames">—</strong></div>
|
||||
<div class="stat"><span class="subtle" data-i18n="mounted">Mounted</span><strong class="stat-value" id="mountedGames">—</strong></div>
|
||||
</section>
|
||||
|
||||
<div class="layout">
|
||||
<section aria-labelledby="gamesHeading">
|
||||
<div class="section-head">
|
||||
<h2 id="gamesHeading">Библиотека</h2>
|
||||
<h2 id="gamesHeading" data-i18n="library">Library</h2>
|
||||
<span class="subtle" id="visibleCount"></span>
|
||||
</div>
|
||||
<div class="games" id="games" aria-live="polite">
|
||||
<div class="empty">Загрузка библиотеки…</div>
|
||||
<div class="empty" data-i18n="loadingLibrary">Loading library…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="sidebar">
|
||||
<section class="panel" aria-labelledby="storageHeading">
|
||||
<h2 id="storageHeading">Хранилища</h2>
|
||||
<p class="subtle">Свободное место в доступных точках монтирования</p>
|
||||
<div class="storage-list" id="storageList" aria-live="polite"><span class="subtle">Загрузка…</span></div>
|
||||
<h2 id="storageHeading" data-i18n="storage">Storage</h2>
|
||||
<p class="subtle" data-i18n="storageDescription">Free space at available mount points</p>
|
||||
<div class="storage-list" id="storageList" aria-live="polite"><span class="subtle" data-i18n="loading">Loading…</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="transferHeading">
|
||||
<div class="storage-head">
|
||||
<h2 id="transferHeading">Операции с данными</h2>
|
||||
<span class="badge" id="transferState">Проверка</span>
|
||||
<h2 id="transferHeading" data-i18n="dataOperations">Data operations</h2>
|
||||
<span class="badge" id="transferState" data-i18n="checking">Checking</span>
|
||||
</div>
|
||||
<p class="subtle" id="transferIdle">Получение статуса операции…</p>
|
||||
<p class="subtle" id="transferIdle" data-i18n="gettingOperationStatus">Getting operation status…</p>
|
||||
<div class="transfer-details" id="transferDetails" hidden>
|
||||
<strong id="transferSummary"></strong>
|
||||
<div class="transfer-route" id="transferRoute"></div>
|
||||
<div class="storage-meter transfer-meter" id="transferMeter" role="progressbar" aria-label="Готовность операции" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div class="storage-meter transfer-meter" id="transferMeter" role="progressbar" aria-label="Operation progress" data-i18n-aria-label="operationProgress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div class="storage-fill" id="transferFill"></div>
|
||||
</div>
|
||||
<div class="transfer-stats">
|
||||
<div class="transfer-stat"><span class="subtle">Объём</span><strong id="transferBytes">—</strong></div>
|
||||
<div class="transfer-stat"><span class="subtle">Файлы</span><strong id="transferFiles">—</strong></div>
|
||||
<div class="transfer-stat"><span class="subtle">Скорость</span><strong id="transferSpeed">—</strong></div>
|
||||
<div class="transfer-stat"><span class="subtle">Время</span><strong id="transferElapsed">—</strong></div>
|
||||
<div class="transfer-stat"><span class="subtle" data-i18n="size">Size</span><strong id="transferBytes">—</strong></div>
|
||||
<div class="transfer-stat"><span class="subtle" data-i18n="files">Files</span><strong id="transferFiles">—</strong></div>
|
||||
<div class="transfer-stat"><span class="subtle" data-i18n="speed">Speed</span><strong id="transferSpeed">—</strong></div>
|
||||
<div class="transfer-stat"><span class="subtle" data-i18n="time">Time</span><strong id="transferElapsed">—</strong></div>
|
||||
</div>
|
||||
<div class="transfer-actions">
|
||||
<strong id="transferPercent">0%</strong>
|
||||
<button class="btn small danger" id="cancelTransfer" type="button">Отменить</button>
|
||||
<button class="btn small danger" id="cancelTransfer" type="button" data-i18n="cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="manualHeading">
|
||||
<h2 id="manualHeading">manual.lst</h2>
|
||||
<p class="subtle">Дополнительные источники для сканирования</p>
|
||||
<p class="subtle" data-i18n="manualDescription">Additional scan sources</p>
|
||||
<form class="manual-form" id="manualForm">
|
||||
<input id="manualPath" required placeholder="/mnt/usb0/games">
|
||||
<button class="btn small" type="submit">Добавить</button>
|
||||
<button class="btn small" type="submit" data-i18n="add">Add</button>
|
||||
</form>
|
||||
<div class="manual-list" id="manualList"><span class="subtle">Загрузка…</span></div>
|
||||
<div class="manual-list" id="manualList"><span class="subtle" data-i18n="loading">Loading…</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="scanHeading">
|
||||
<h2 id="scanHeading">Параметры сканирования</h2>
|
||||
<label class="check"><input id="resetAttempts" type="checkbox"> Сбросить счётчики попыток</label>
|
||||
<p class="subtle">Опция применяется только к следующему принудительному сканированию.</p>
|
||||
<h2 id="scanHeading" data-i18n="scanOptions">Scan options</h2>
|
||||
<label class="check"><input id="resetAttempts" type="checkbox"> <span data-i18n="resetAttempts">Reset attempt counters</span></label>
|
||||
<p class="subtle" data-i18n="resetAttemptsDescription">This option applies only to the next forced scan.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="activityHeading">
|
||||
<h2 id="activityHeading">Последние операции</h2>
|
||||
<div class="activity-list" id="activity"><span class="subtle">Операций пока нет</span></div>
|
||||
<h2 id="activityHeading" data-i18n="recentOperations">Recent operations</h2>
|
||||
<div class="activity-list" id="activity"><span class="subtle" data-i18n="noOperations">No operations yet</span></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -449,10 +456,10 @@
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 id="modalTitle">Информация об игре</h2>
|
||||
<h2 id="modalTitle" data-i18n="gameInformation">Game information</h2>
|
||||
<p class="subtle" id="modalSubtitle"></p>
|
||||
</div>
|
||||
<button class="btn close" id="closeModal" type="button" aria-label="Закрыть">×</button>
|
||||
<button class="btn close" id="closeModal" type="button" aria-label="Close" data-i18n-aria-label="close">×</button>
|
||||
</div>
|
||||
<div class="details" id="details"></div>
|
||||
<div class="modal-actions" id="modalActions"></div>
|
||||
@@ -473,10 +480,282 @@
|
||||
busy: false,
|
||||
storageJob: null,
|
||||
storagePollTimer: 0,
|
||||
reportedStorageJob: ""
|
||||
reportedStorageJob: "",
|
||||
apiVersion: null,
|
||||
connectionStatus: "",
|
||||
connectionKey: "checking"
|
||||
};
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
pageTitle: "ShadowMount Library",
|
||||
connecting: "Connecting to API…",
|
||||
checking: "Checking",
|
||||
libraryControls: "Library controls",
|
||||
searchPlaceholder: "Name, Title ID, Content ID, or path",
|
||||
libraryFilter: "Library filter",
|
||||
allGames: "All games",
|
||||
folders: "Folders",
|
||||
images: "Images",
|
||||
language: "Language",
|
||||
calculateSize: "Calculate size",
|
||||
refresh: "Refresh",
|
||||
scan: "Scan",
|
||||
librarySummary: "Library summary",
|
||||
totalGames: "Total games",
|
||||
mounted: "Mounted",
|
||||
library: "Library",
|
||||
loadingLibrary: "Loading library…",
|
||||
storage: "Storage",
|
||||
storageDescription: "Free space at available mount points",
|
||||
loading: "Loading…",
|
||||
dataOperations: "Data operations",
|
||||
gettingOperationStatus: "Getting operation status…",
|
||||
operationProgress: "Operation progress",
|
||||
size: "Size",
|
||||
files: "Files",
|
||||
speed: "Speed",
|
||||
perSecond: "/s",
|
||||
time: "Time",
|
||||
cancel: "Cancel",
|
||||
manualDescription: "Additional scan sources",
|
||||
add: "Add",
|
||||
scanOptions: "Scan options",
|
||||
resetAttempts: "Reset attempt counters",
|
||||
resetAttemptsDescription: "This option applies only to the next forced scan.",
|
||||
recentOperations: "Recent operations",
|
||||
noOperations: "No operations yet",
|
||||
gameInformation: "Game information",
|
||||
close: "Close",
|
||||
coverAlt: "Cover for {title}",
|
||||
unknown: "unknown",
|
||||
folder: "folder",
|
||||
image: "image",
|
||||
sourceUnavailable: "Source unavailable",
|
||||
details: "Details",
|
||||
connect: "Mount",
|
||||
disconnect: "Unmount",
|
||||
nothingFound: "Nothing found",
|
||||
libraryEmpty: "The library is empty",
|
||||
shown: "Shown: {count}",
|
||||
jobIdle: "No job",
|
||||
jobPreparing: "Preparing",
|
||||
jobMeasuring: "Measuring",
|
||||
jobDeleting: "Deleting",
|
||||
jobFinalizing: "Finalizing",
|
||||
jobCompleted: "Completed",
|
||||
jobFailed: "Failed",
|
||||
jobCancelled: "Cancelled",
|
||||
operationCopy: "Copying",
|
||||
operationMove: "Moving",
|
||||
operationDelete: "Deleting",
|
||||
noActiveOperations: "No active operations",
|
||||
calculating: "Calculating…",
|
||||
cancelling: "Cancelling…",
|
||||
operationCompleted: "{operation} {title} completed",
|
||||
operationCompletedShort: "{operation} completed",
|
||||
operationCancelled: "{operation} {title} cancelled",
|
||||
operationCancelledShort: "{operation} cancelled",
|
||||
statusError: "error {status}",
|
||||
operationError: "{operation} {title}: {error}",
|
||||
unavailable: "Unavailable",
|
||||
cancelRequested: "Cancellation requested for job {id}",
|
||||
storageNotFound: "No storage found",
|
||||
freeSpace: "Free {size}",
|
||||
totalSpace: "of {size}",
|
||||
storageUsed: "Used space at {path}",
|
||||
connected: "PS5 connected",
|
||||
disconnected: "No connection",
|
||||
apiError: "API error: {error}",
|
||||
listEmpty: "The list is empty",
|
||||
remove: "Remove",
|
||||
manualAdded: "Source added to manual.lst",
|
||||
manualExists: "Source is already in manual.lst",
|
||||
removeManualConfirm: "Remove this source from manual.lst?\n\n{path}",
|
||||
manualRemoved: "Source removed from manual.lst",
|
||||
scanQueuedReset: "Scan queued with attempt counters reset",
|
||||
scanQueued: "Scan queued",
|
||||
scanStarted: "Scan started",
|
||||
mountedTitle: "Mounted {title}{mode}",
|
||||
unmountedTitle: "Unmounted {title}",
|
||||
source: "Source",
|
||||
runtimePath: "Runtime path",
|
||||
sizeError: "Error {status}",
|
||||
installed: "Installed",
|
||||
lastLaunch: "Last launch",
|
||||
mountReadOnly: "Mount R/O",
|
||||
mountReadWrite: "Mount R/W",
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
deleteSource: "Delete source",
|
||||
storageBusyHint: "Wait for the current storage operation to finish",
|
||||
uninstall: "Uninstall",
|
||||
loadingSource: "Calculating source size…",
|
||||
storageBusy: "A storage operation is already running",
|
||||
copyDestination: "Destination folder for copying:",
|
||||
moveDestination: "Destination folder for moving:",
|
||||
jobStarted: "{operation} {title} started as job {id}",
|
||||
operationStarted: "Operation started",
|
||||
uninstallConfirm: "Request uninstall of {game}?\n\nThe source image or folder will not be deleted.",
|
||||
uninstallAccepted: "Uninstall request for {title} accepted",
|
||||
deleteConfirm: "Permanently delete the physical source for {game}?\n\nThis cannot be undone."
|
||||
},
|
||||
ru: {
|
||||
pageTitle: "Библиотека ShadowMount",
|
||||
connecting: "Подключение к API…",
|
||||
checking: "Проверка",
|
||||
libraryControls: "Управление библиотекой",
|
||||
searchPlaceholder: "Название, Title ID, Content ID или путь",
|
||||
libraryFilter: "Фильтр библиотеки",
|
||||
allGames: "Все игры",
|
||||
folders: "Папки",
|
||||
images: "Образы",
|
||||
language: "Язык",
|
||||
calculateSize: "Подсчитать размер",
|
||||
refresh: "Обновить",
|
||||
scan: "Сканировать",
|
||||
librarySummary: "Сводка библиотеки",
|
||||
totalGames: "Всего игр",
|
||||
mounted: "Смонтировано",
|
||||
library: "Библиотека",
|
||||
loadingLibrary: "Загрузка библиотеки…",
|
||||
storage: "Хранилища",
|
||||
storageDescription: "Свободное место в доступных точках монтирования",
|
||||
loading: "Загрузка…",
|
||||
dataOperations: "Операции с данными",
|
||||
gettingOperationStatus: "Получение статуса операции…",
|
||||
operationProgress: "Прогресс операции",
|
||||
size: "Размер",
|
||||
files: "Файлы",
|
||||
speed: "Скорость",
|
||||
perSecond: "/с",
|
||||
time: "Время",
|
||||
cancel: "Отменить",
|
||||
manualDescription: "Дополнительные источники сканирования",
|
||||
add: "Добавить",
|
||||
scanOptions: "Параметры сканирования",
|
||||
resetAttempts: "Сбросить счётчики попыток",
|
||||
resetAttemptsDescription: "Параметр применяется только к следующему принудительному сканированию.",
|
||||
recentOperations: "Последние операции",
|
||||
noOperations: "Операций пока нет",
|
||||
gameInformation: "Информация об игре",
|
||||
close: "Закрыть",
|
||||
coverAlt: "Обложка {title}",
|
||||
unknown: "неизвестно",
|
||||
folder: "папка",
|
||||
image: "образ",
|
||||
sourceUnavailable: "Источник недоступен",
|
||||
details: "Подробнее",
|
||||
connect: "Подключить",
|
||||
disconnect: "Отключить",
|
||||
nothingFound: "Ничего не найдено",
|
||||
libraryEmpty: "Библиотека пуста",
|
||||
shown: "Показано: {count}",
|
||||
jobIdle: "Нет задачи",
|
||||
jobPreparing: "Подготовка",
|
||||
jobMeasuring: "Подсчёт",
|
||||
jobDeleting: "Удаление",
|
||||
jobFinalizing: "Завершение",
|
||||
jobCompleted: "Готово",
|
||||
jobFailed: "Ошибка",
|
||||
jobCancelled: "Отменено",
|
||||
operationCopy: "Копирование",
|
||||
operationMove: "Перемещение",
|
||||
operationDelete: "Удаление",
|
||||
noActiveOperations: "Активных операций нет",
|
||||
calculating: "Подсчёт…",
|
||||
cancelling: "Отмена…",
|
||||
operationCompleted: "{operation} {title} завершено",
|
||||
operationCompletedShort: "{operation} завершено",
|
||||
operationCancelled: "{operation} {title} отменено",
|
||||
operationCancelledShort: "{operation} отменено",
|
||||
statusError: "ошибка {status}",
|
||||
operationError: "{operation} {title}: {error}",
|
||||
unavailable: "Недоступно",
|
||||
cancelRequested: "Запрошена отмена задачи {id}",
|
||||
storageNotFound: "Хранилища не найдены",
|
||||
freeSpace: "Свободно {size}",
|
||||
totalSpace: "из {size}",
|
||||
storageUsed: "Заполнено {path}",
|
||||
connected: "PS5 подключена",
|
||||
disconnected: "Нет связи",
|
||||
apiError: "Ошибка API: {error}",
|
||||
listEmpty: "Список пуст",
|
||||
remove: "Удалить",
|
||||
manualAdded: "Источник добавлен в manual.lst",
|
||||
manualExists: "Источник уже есть в manual.lst",
|
||||
removeManualConfirm: "Удалить источник из manual.lst?\n\n{path}",
|
||||
manualRemoved: "Источник удалён из manual.lst",
|
||||
scanQueuedReset: "Сканирование поставлено в очередь со сбросом попыток",
|
||||
scanQueued: "Сканирование поставлено в очередь",
|
||||
scanStarted: "Сканирование запущено",
|
||||
mountedTitle: "Смонтировано {title}{mode}",
|
||||
unmountedTitle: "Размонтировано {title}",
|
||||
source: "Источник",
|
||||
runtimePath: "Runtime path",
|
||||
sizeError: "Ошибка {status}",
|
||||
installed: "Установлена",
|
||||
lastLaunch: "Последний запуск",
|
||||
mountReadOnly: "Подключить R/O",
|
||||
mountReadWrite: "Подключить R/W",
|
||||
copy: "Копировать",
|
||||
move: "Переместить",
|
||||
deleteSource: "Удалить источник",
|
||||
storageBusyHint: "Дождитесь завершения текущей операции с хранилищем",
|
||||
uninstall: "Деинсталлировать",
|
||||
loadingSource: "Подсчёт размера источника…",
|
||||
storageBusy: "Уже выполняется операция с хранилищем",
|
||||
copyDestination: "Папка назначения для копирования:",
|
||||
moveDestination: "Папка назначения для перемещения:",
|
||||
jobStarted: "{operation} {title} запущено, задача {id}",
|
||||
operationStarted: "Операция запущена",
|
||||
uninstallConfirm: "Запросить деинсталляцию {game}?\n\nИсходный образ или папка не удаляются.",
|
||||
uninstallAccepted: "Деинсталляция {title} принята",
|
||||
deleteConfirm: "Удалить физический источник игры {game}?\n\nОперацию нельзя отменить."
|
||||
}
|
||||
};
|
||||
const byteUnits = {
|
||||
en: ["B", "KiB", "MiB", "GiB", "TiB"],
|
||||
ru: ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ"]
|
||||
};
|
||||
const supportedLanguages = Object.keys(translations);
|
||||
let savedLanguage = null;
|
||||
try { savedLanguage = localStorage.getItem("shadowmount.language"); } catch (_) { /* Storage may be disabled. */ }
|
||||
let currentLanguage = supportedLanguages.includes(savedLanguage)
|
||||
? savedLanguage
|
||||
: String(navigator.language || "en").toLowerCase().startsWith("ru") ? "ru" : "en";
|
||||
|
||||
function t(key, values = {}) {
|
||||
const template = translations[currentLanguage][key] || translations.en[key] || key;
|
||||
return template.replace(/\{(\w+)\}/g, (match, name) =>
|
||||
Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match
|
||||
);
|
||||
}
|
||||
|
||||
function applyLanguage(language, persist = false) {
|
||||
currentLanguage = supportedLanguages.includes(language) ? language : "en";
|
||||
document.documentElement.lang = currentLanguage;
|
||||
document.title = t("pageTitle");
|
||||
$("language").value = currentLanguage;
|
||||
document.querySelectorAll("[data-i18n]").forEach((item) => {
|
||||
item.textContent = t(item.dataset.i18n);
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach((item) => {
|
||||
item.placeholder = t(item.dataset.i18nPlaceholder);
|
||||
});
|
||||
document.querySelectorAll("[data-i18n-aria-label]").forEach((item) => {
|
||||
item.setAttribute("aria-label", t(item.dataset.i18nAriaLabel));
|
||||
});
|
||||
if (state.apiVersion) {
|
||||
$("version").textContent = `ShadowMount ${state.apiVersion.shadowmount_version} · API v${state.apiVersion.api_version}`;
|
||||
}
|
||||
if (state.connectionStatus) setConnection(state.connectionStatus, state.connectionKey);
|
||||
if (persist) {
|
||||
try { localStorage.setItem("shadowmount.language", currentLanguage); } catch (_) { /* Keep the in-memory selection. */ }
|
||||
}
|
||||
}
|
||||
|
||||
function node(tag, className, text) {
|
||||
const item = document.createElement(tag);
|
||||
if (className) item.className = className;
|
||||
@@ -486,8 +765,8 @@
|
||||
|
||||
function formatBytes(value) {
|
||||
if (!Number.isFinite(value) || value < 0) return "—";
|
||||
if (value === 0) return "0 Б";
|
||||
const units = ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ"];
|
||||
const units = byteUnits[currentLanguage];
|
||||
if (value === 0) return `0 ${units[0]}`;
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
const precision = index < 2 ? 0 : 1;
|
||||
return `${(value / (1024 ** index)).toFixed(precision)} ${units[index]}`;
|
||||
@@ -523,10 +802,12 @@
|
||||
return data;
|
||||
}
|
||||
|
||||
function setConnection(status, text) {
|
||||
function setConnection(status, textKey) {
|
||||
state.connectionStatus = status;
|
||||
state.connectionKey = textKey;
|
||||
const item = $("connection");
|
||||
item.className = `connection ${status}`;
|
||||
$("connectionText").textContent = text;
|
||||
$("connectionText").textContent = t(textKey);
|
||||
}
|
||||
|
||||
let toastTimer = 0;
|
||||
@@ -540,7 +821,7 @@
|
||||
}
|
||||
|
||||
function logActivity(message, kind = "ok") {
|
||||
state.activity.unshift({ message, kind, time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) });
|
||||
state.activity.unshift({ message, kind, time: new Date().toLocaleTimeString(currentLanguage, { hour: "2-digit", minute: "2-digit" }) });
|
||||
state.activity = state.activity.slice(0, 8);
|
||||
const list = $("activity");
|
||||
list.replaceChildren();
|
||||
@@ -555,7 +836,7 @@
|
||||
cover.append(fallback);
|
||||
if (game.icon_url) {
|
||||
const image = new Image();
|
||||
image.alt = large ? `Обложка ${game.title_name || game.title_id}` : "";
|
||||
image.alt = large ? t("coverAlt", { title: game.title_name || game.title_id }) : "";
|
||||
image.addEventListener("load", () => fallback.remove(), { once: true });
|
||||
image.addEventListener("error", () => image.remove(), { once: true });
|
||||
cover.append(image);
|
||||
@@ -568,36 +849,53 @@
|
||||
return node("span", `badge ${extra}`.trim(), text);
|
||||
}
|
||||
|
||||
function gameLabel(game) {
|
||||
const name = game.title_name || game.title_id;
|
||||
return name === game.title_id ? game.title_id : `${name} (${game.title_id})`;
|
||||
}
|
||||
|
||||
function gameCard(game) {
|
||||
const card = node("article", "game-card");
|
||||
card.append(coverFor(game));
|
||||
const body = node("div", "game-main");
|
||||
body.append(node("h3", "game-title", game.title_name || game.title_id));
|
||||
body.append(node("div", "game-id", game.title_id));
|
||||
const heading = node("div", "game-heading");
|
||||
const identity = node("div", "game-identity");
|
||||
identity.append(node("h3", "game-title", game.title_name || game.title_id));
|
||||
identity.append(node("div", "game-id", game.title_id));
|
||||
heading.append(identity);
|
||||
if (game.size_bytes !== undefined) {
|
||||
heading.append(node("strong", "game-card-size", formatBytes(game.size_bytes)));
|
||||
}
|
||||
body.append(heading);
|
||||
const badges = node("div", "badges");
|
||||
badges.append(badge(game.platform || "unknown"));
|
||||
badges.append(badge(game.image_backed ? (game.image_type || "image") : "folder", game.image_backed ? "image" : ""));
|
||||
if (game.mounted) badges.append(badge("mounted", "live"));
|
||||
badges.append(badge(game.platform || t("unknown")));
|
||||
badges.append(badge(game.image_backed ? (game.image_type || t("image")) : t("folder"), game.image_backed ? "image" : ""));
|
||||
if (game.mounted) badges.append(badge(t("mounted"), "live"));
|
||||
body.append(badges);
|
||||
const size = game.size_bytes !== undefined ? ` · ${formatBytes(game.size_bytes)}` : "";
|
||||
body.append(node("div", "game-path", `${game.path || "Источник недоступен"}${size}`));
|
||||
const path = node("div", "game-path", game.path || t("sourceUnavailable"));
|
||||
path.title = game.path || "";
|
||||
body.append(path);
|
||||
|
||||
const actions = node("div", "game-actions");
|
||||
const details = node("button", "btn small", "Подробнее");
|
||||
const details = node("button", "btn small", t("details"));
|
||||
details.type = "button";
|
||||
details.addEventListener("click", () => showDetails(game.title_id));
|
||||
actions.append(details);
|
||||
const runtime = node("button", "btn small", game.mounted ? "Отключить" : "Подключить");
|
||||
runtime.type = "button";
|
||||
runtime.addEventListener("click", () => titleRuntime(game, game.mounted ? "unmount" : "mount"));
|
||||
actions.append(runtime);
|
||||
if (game.mounted) {
|
||||
actions.append(actionButton(t("disconnect"), () => titleRuntime(game, "unmount")));
|
||||
} else if (game.image_backed) {
|
||||
actions.append(actionButton(t("mountReadOnly"), () => titleRuntime(game, "mount", "ro")));
|
||||
actions.append(actionButton(t("mountReadWrite"), () => titleRuntime(game, "mount", "rw")));
|
||||
} else {
|
||||
actions.append(actionButton(t("connect"), () => titleRuntime(game, "mount")));
|
||||
}
|
||||
body.append(actions);
|
||||
card.append(body);
|
||||
return card;
|
||||
}
|
||||
|
||||
function filteredGames() {
|
||||
const query = $("search").value.trim().toLocaleLowerCase();
|
||||
const query = $("search").value.trim().toLocaleLowerCase(currentLanguage);
|
||||
const filter = $("filter").value;
|
||||
return state.games.filter((game) => {
|
||||
const matchesFilter = filter === "all" ||
|
||||
@@ -607,7 +905,7 @@
|
||||
if (!matchesFilter) return false;
|
||||
if (!query) return true;
|
||||
return [game.title_name, game.title_id, game.content_id, game.path]
|
||||
.some((value) => String(value || "").toLocaleLowerCase().includes(query));
|
||||
.some((value) => String(value || "").toLocaleLowerCase(currentLanguage).includes(query));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -616,11 +914,11 @@
|
||||
const list = $("games");
|
||||
list.replaceChildren();
|
||||
if (!games.length) {
|
||||
list.append(node("div", "empty", state.games.length ? "Ничего не найдено" : "Библиотека пуста"));
|
||||
list.append(node("div", "empty", t(state.games.length ? "nothingFound" : "libraryEmpty")));
|
||||
} else {
|
||||
games.forEach((game) => list.append(gameCard(game)));
|
||||
}
|
||||
$("visibleCount").textContent = `Показано: ${games.length}`;
|
||||
$("visibleCount").textContent = t("shown", { count: games.length });
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
@@ -633,32 +931,32 @@
|
||||
|
||||
function storageJobStateLabel(job) {
|
||||
const labels = {
|
||||
idle: "Нет задачи",
|
||||
preparing: "Подготовка",
|
||||
measuring: "Подсчёт",
|
||||
idle: t("jobIdle"),
|
||||
preparing: t("jobPreparing"),
|
||||
measuring: t("jobMeasuring"),
|
||||
transferring: storageOperationLabel(job.operation),
|
||||
deleting: "Удаление",
|
||||
finalizing: "Завершение",
|
||||
completed: "Готово",
|
||||
failed: "Ошибка",
|
||||
cancelled: "Отменено"
|
||||
deleting: t("jobDeleting"),
|
||||
finalizing: t("jobFinalizing"),
|
||||
completed: t("jobCompleted"),
|
||||
failed: t("jobFailed"),
|
||||
cancelled: t("jobCancelled")
|
||||
};
|
||||
return labels[job.state] || job.state;
|
||||
}
|
||||
|
||||
function storageOperationLabel(operation) {
|
||||
if (operation === "move") return "Перемещение";
|
||||
if (operation === "delete") return "Удаление";
|
||||
return "Копирование";
|
||||
if (operation === "move") return t("operationMove");
|
||||
if (operation === "delete") return t("operationDelete");
|
||||
return t("operationCopy");
|
||||
}
|
||||
|
||||
function renderStorageJob(job) {
|
||||
state.storageJob = job;
|
||||
const hasJob = job && Number(job.job_id) > 0;
|
||||
$("transferState").textContent = hasJob ? storageJobStateLabel(job) : "Нет задачи";
|
||||
$("transferState").textContent = hasJob ? storageJobStateLabel(job) : t("jobIdle");
|
||||
$("transferState").className = `badge ${job && job.active ? "live" : ""}`.trim();
|
||||
$("transferIdle").hidden = hasJob;
|
||||
$("transferIdle").textContent = "Активных операций нет";
|
||||
$("transferIdle").textContent = t("noActiveOperations");
|
||||
$("transferDetails").hidden = !hasJob;
|
||||
if (!hasJob) return;
|
||||
|
||||
@@ -672,17 +970,17 @@
|
||||
$("transferMeter").setAttribute("aria-valuenow", String(Math.round(percent)));
|
||||
$("transferBytes").textContent = job.total_bytes > 0
|
||||
? `${formatBytes(Number(job.processed_bytes))} / ${formatBytes(Number(job.total_bytes))}`
|
||||
: job.state === "measuring" ? "Подсчёт…" : formatBytes(Number(job.processed_bytes));
|
||||
: job.state === "measuring" ? t("calculating") : formatBytes(Number(job.processed_bytes));
|
||||
$("transferFiles").textContent = job.total_files > 0
|
||||
? `${job.processed_files} / ${job.total_files}`
|
||||
: job.state === "measuring" ? "Подсчёт…" : String(job.processed_files || 0);
|
||||
: job.state === "measuring" ? t("calculating") : String(job.processed_files || 0);
|
||||
$("transferSpeed").textContent = job.speed_bytes_per_second > 0
|
||||
? `${formatBytes(Number(job.speed_bytes_per_second))}/с`
|
||||
? `${formatBytes(Number(job.speed_bytes_per_second))}${t("perSecond")}`
|
||||
: "—";
|
||||
$("transferElapsed").textContent = formatDuration(job.elapsed_ms);
|
||||
$("transferPercent").textContent = `${percent.toFixed(percent < 10 && percent > 0 ? 1 : 0)}%`;
|
||||
$("cancelTransfer").disabled = !job.cancellable || job.cancel_requested;
|
||||
$("cancelTransfer").textContent = job.cancel_requested ? "Отмена…" : "Отменить";
|
||||
$("cancelTransfer").textContent = job.cancel_requested ? t("cancelling") : t("cancel");
|
||||
}
|
||||
|
||||
function reportStorageJob(job) {
|
||||
@@ -691,14 +989,14 @@
|
||||
state.reportedStorageJob = key;
|
||||
const operation = storageOperationLabel(job.operation);
|
||||
if (job.state === "completed") {
|
||||
logActivity(`${operation} ${job.title_id} завершено`);
|
||||
toast(`${operation} завершено`);
|
||||
logActivity(t("operationCompleted", { operation, title: job.title_id }));
|
||||
toast(t("operationCompletedShort", { operation }));
|
||||
} else if (job.state === "cancelled") {
|
||||
logActivity(`${operation} ${job.title_id} отменено`);
|
||||
toast(`${operation} отменено`);
|
||||
logActivity(t("operationCancelled", { operation, title: job.title_id }));
|
||||
toast(t("operationCancelledShort", { operation }));
|
||||
} else if (job.state === "failed") {
|
||||
const reason = job.result_error || `ошибка ${job.result_status}`;
|
||||
logActivity(`${operation} ${job.title_id}: ${reason}`, "error");
|
||||
const reason = job.result_error || t("statusError", { status: job.result_status });
|
||||
logActivity(t("operationError", { operation, title: job.title_id, error: reason }), "error");
|
||||
toast(reason, true);
|
||||
}
|
||||
refreshAll();
|
||||
@@ -718,7 +1016,7 @@
|
||||
if (wasActive && !job.active) reportStorageJob(job);
|
||||
} catch (error) {
|
||||
if (!state.storageJob) {
|
||||
$("transferState").textContent = "Недоступно";
|
||||
$("transferState").textContent = t("unavailable");
|
||||
$("transferIdle").textContent = errorMessage(error);
|
||||
}
|
||||
} finally {
|
||||
@@ -732,7 +1030,7 @@
|
||||
$("cancelTransfer").disabled = true;
|
||||
try {
|
||||
renderStorageJob(await api("/games/storage/cancel", { job_id: job.job_id }));
|
||||
logActivity(`Запрошена отмена задачи ${job.job_id}`);
|
||||
logActivity(t("cancelRequested", { id: job.job_id }));
|
||||
scheduleStorageJobPoll();
|
||||
} catch (error) {
|
||||
toast(errorMessage(error), true);
|
||||
@@ -753,7 +1051,7 @@
|
||||
const mounts = libraryMounts.length ? libraryMounts : allMounts.filter((mount) => !mount.read_only);
|
||||
list.replaceChildren();
|
||||
if (!mounts.length) {
|
||||
list.append(node("span", "subtle", "Хранилища не найдены"));
|
||||
list.append(node("span", "subtle", t("storageNotFound")));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -768,16 +1066,16 @@
|
||||
path.title = mount.mount_point;
|
||||
head.append(path, badge(mount.read_only ? "R/O" : mount.filesystem || "FS", mount.read_only ? "" : "live"));
|
||||
row.append(head);
|
||||
const meta = node("div", "storage-meta", `${mount.filesystem || "unknown"} · ${mount.source || "—"}`);
|
||||
const meta = node("div", "storage-meta", `${mount.filesystem || t("unknown")} · ${mount.source || "—"}`);
|
||||
meta.title = mount.source || "";
|
||||
row.append(meta);
|
||||
const space = node("div", "storage-space");
|
||||
space.append(node("span", "", `Свободно ${formatBytes(available)}`));
|
||||
space.append(node("span", "", `из ${formatBytes(total)}`));
|
||||
space.append(node("span", "", t("freeSpace", { size: formatBytes(available) })));
|
||||
space.append(node("span", "", t("totalSpace", { size: formatBytes(total) })));
|
||||
row.append(space);
|
||||
const meter = node("div", "storage-meter");
|
||||
meter.setAttribute("role", "progressbar");
|
||||
meter.setAttribute("aria-label", `Заполнено ${mount.mount_point}`);
|
||||
meter.setAttribute("aria-label", t("storageUsed", { path: mount.mount_point }));
|
||||
meter.setAttribute("aria-valuemin", "0");
|
||||
meter.setAttribute("aria-valuemax", "100");
|
||||
meter.setAttribute("aria-valuenow", String(Math.round(usedPercent)));
|
||||
@@ -803,13 +1101,14 @@
|
||||
api("/games", { include_size: $("includeSize").checked })
|
||||
]);
|
||||
state.games = games.games || [];
|
||||
state.apiVersion = version;
|
||||
$("version").textContent = `ShadowMount ${version.shadowmount_version} · API v${version.api_version}`;
|
||||
setConnection("online", "PS5 подключена");
|
||||
setConnection("online", "connected");
|
||||
renderStats();
|
||||
renderGames();
|
||||
} catch (error) {
|
||||
setConnection("offline", "Нет связи");
|
||||
$("games").replaceChildren(node("div", "empty", `Ошибка API: ${errorMessage(error)}`));
|
||||
setConnection("offline", "disconnected");
|
||||
$("games").replaceChildren(node("div", "empty", t("apiError", { error: errorMessage(error) })));
|
||||
toast(errorMessage(error), true);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
@@ -827,14 +1126,14 @@
|
||||
const data = await api("/manual/list");
|
||||
list.replaceChildren();
|
||||
if (!data.paths.length) {
|
||||
list.append(node("span", "subtle", "Список пуст"));
|
||||
list.append(node("span", "subtle", t("listEmpty")));
|
||||
return;
|
||||
}
|
||||
data.paths.forEach((path) => {
|
||||
const row = node("div", "manual-row");
|
||||
const value = node("span", "manual-path", path);
|
||||
value.title = path;
|
||||
const remove = node("button", "btn small danger", "Удалить");
|
||||
const remove = node("button", "btn small danger", t("remove"));
|
||||
remove.type = "button";
|
||||
remove.addEventListener("click", () => removeManual(path));
|
||||
row.append(value, remove);
|
||||
@@ -853,7 +1152,7 @@
|
||||
try {
|
||||
const result = await api("/manual/add", { path });
|
||||
input.value = "";
|
||||
logActivity(result.changed ? "Источник добавлен в manual.lst" : "Источник уже есть в manual.lst");
|
||||
logActivity(t(result.changed ? "manualAdded" : "manualExists"));
|
||||
await refreshManual();
|
||||
} catch (error) {
|
||||
logActivity(errorMessage(error), "error");
|
||||
@@ -862,10 +1161,10 @@
|
||||
}
|
||||
|
||||
async function removeManual(path) {
|
||||
if (!confirm(`Удалить источник из manual.lst?\n\n${path}`)) return;
|
||||
if (!confirm(t("removeManualConfirm", { path }))) return;
|
||||
try {
|
||||
await api("/manual/remove", { path });
|
||||
logActivity("Источник удалён из manual.lst");
|
||||
logActivity(t("manualRemoved"));
|
||||
await refreshManual();
|
||||
} catch (error) {
|
||||
logActivity(errorMessage(error), "error");
|
||||
@@ -878,8 +1177,8 @@
|
||||
$("scanBtn").disabled = true;
|
||||
try {
|
||||
await api("/scan", { reset_attempts: resetAttempts });
|
||||
logActivity(resetAttempts ? "Сканирование поставлено в очередь со сбросом попыток" : "Сканирование поставлено в очередь");
|
||||
toast("Сканирование запущено");
|
||||
logActivity(t(resetAttempts ? "scanQueuedReset" : "scanQueued"));
|
||||
toast(t("scanStarted"));
|
||||
setTimeout(refreshAll, 1600);
|
||||
} catch (error) {
|
||||
logActivity(errorMessage(error), "error");
|
||||
@@ -905,8 +1204,17 @@
|
||||
function titleRuntime(game, operation, mode) {
|
||||
const payload = { title_id: game.title_id };
|
||||
if (mode) payload.mode = mode;
|
||||
const label = operation === "mount" ? `Смонтировано ${game.title_id}${mode ? ` (${mode})` : ""}` : `Размонтировано ${game.title_id}`;
|
||||
return runMutation(label, () => api(`/games/${operation}`, payload));
|
||||
const label = operation === "mount"
|
||||
? t("mountedTitle", { title: game.title_id, mode: mode ? ` (${mode})` : "" })
|
||||
: t("unmountedTitle", { title: game.title_id });
|
||||
return runMutation(label, async () => {
|
||||
await api(`/games/${operation}`, payload);
|
||||
if (!$('modal').hidden && state.selected &&
|
||||
state.selected.title_id === game.title_id) {
|
||||
state.selected.mounted = operation === "mount";
|
||||
renderDetails(state.selected, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function actionButton(text, action, extra = "") {
|
||||
@@ -921,64 +1229,70 @@
|
||||
grid.append(node("div", "detail-value", value || "—"));
|
||||
}
|
||||
|
||||
function renderDetails(game) {
|
||||
function renderDetails(game, focusRuntimeAction = false) {
|
||||
state.selected = game;
|
||||
$("modalTitle").textContent = game.title_name || game.title_id;
|
||||
$("modalSubtitle").textContent = `${game.title_id} · ${(game.platform || "unknown").toUpperCase()}`;
|
||||
$("modalSubtitle").textContent = `${game.title_id} · ${(game.platform || t("unknown")).toUpperCase()}`;
|
||||
const details = $("details");
|
||||
details.replaceChildren();
|
||||
details.append(coverFor(game, true));
|
||||
const content = node("div");
|
||||
const badges = node("div", "badges");
|
||||
badges.append(badge(game.source_type));
|
||||
badges.append(badge(game.source_type === "folder" ? t("folder") : game.source_type === "image" ? t("image") : game.source_type));
|
||||
if (game.image_type) badges.append(badge(game.image_type, "image"));
|
||||
if (game.mounted) badges.append(badge("mounted", "live"));
|
||||
if (game.mounted) badges.append(badge(t("mounted"), "live"));
|
||||
content.append(badges);
|
||||
const grid = node("div", "detail-grid");
|
||||
detailRow(grid, "Content ID", game.content_id);
|
||||
detailRow(grid, "Источник", game.path);
|
||||
detailRow(grid, "Runtime path", game.runtime_path);
|
||||
detailRow(grid, "Размер", game.size_status === 0 ? formatBytes(game.size_bytes) : `Ошибка ${game.size_status}`);
|
||||
detailRow(grid, t("source"), game.path);
|
||||
detailRow(grid, t("runtimePath"), game.runtime_path);
|
||||
detailRow(grid, t("size"), game.size_status === 0 ? formatBytes(game.size_bytes) : t("sizeError", { status: game.size_status }));
|
||||
detailRow(grid, "app.db", formatBytes(game.app_db_size_bytes));
|
||||
detailRow(grid, "Установлена", game.install_time);
|
||||
detailRow(grid, "Последний запуск", game.last_access_time);
|
||||
detailRow(grid, t("installed"), game.install_time);
|
||||
detailRow(grid, t("lastLaunch"), game.last_access_time);
|
||||
content.append(grid);
|
||||
details.append(content);
|
||||
|
||||
const actions = $("modalActions");
|
||||
actions.replaceChildren();
|
||||
if (game.image_backed) {
|
||||
actions.append(actionButton("Mount R/O", () => titleRuntime(game, "mount", "ro")));
|
||||
actions.append(actionButton("Mount R/W", () => titleRuntime(game, "mount", "rw")));
|
||||
let runtimeAction;
|
||||
if (game.mounted) {
|
||||
runtimeAction = actionButton(t("disconnect"), () => titleRuntime(game, "unmount"));
|
||||
actions.append(runtimeAction);
|
||||
} else if (game.image_backed) {
|
||||
runtimeAction = actionButton(t("mountReadOnly"), () => titleRuntime(game, "mount", "ro"));
|
||||
actions.append(runtimeAction);
|
||||
actions.append(actionButton(t("mountReadWrite"), () => titleRuntime(game, "mount", "rw")));
|
||||
} else {
|
||||
actions.append(actionButton("Подключить", () => titleRuntime(game, "mount")));
|
||||
runtimeAction = actionButton(t("connect"), () => titleRuntime(game, "mount"));
|
||||
actions.append(runtimeAction);
|
||||
}
|
||||
actions.append(actionButton("Отключить", () => titleRuntime(game, "unmount")));
|
||||
const storageBusy = Boolean(state.storageJob && state.storageJob.active);
|
||||
const copy = actionButton("Копировать", () => storageOperation(game, "copy"));
|
||||
const move = actionButton("Переместить", () => storageOperation(game, "move"));
|
||||
const remove = actionButton("Удалить источник", () => deleteSource(game), "danger");
|
||||
const copy = actionButton(t("copy"), () => storageOperation(game, "copy"));
|
||||
const move = actionButton(t("move"), () => storageOperation(game, "move"));
|
||||
const remove = actionButton(t("deleteSource"), () => deleteSource(game), "danger");
|
||||
copy.disabled = storageBusy;
|
||||
move.disabled = storageBusy;
|
||||
remove.disabled = storageBusy;
|
||||
if (storageBusy) {
|
||||
copy.title = "Дождитесь завершения текущей операции с хранилищем";
|
||||
copy.title = t("storageBusyHint");
|
||||
move.title = copy.title;
|
||||
remove.title = copy.title;
|
||||
}
|
||||
actions.append(copy, move);
|
||||
actions.append(actionButton("Деинсталлировать", () => uninstallGame(game), "danger"));
|
||||
actions.append(actionButton(t("uninstall"), () => uninstallGame(game), "danger"));
|
||||
actions.append(remove);
|
||||
if (focusRuntimeAction) runtimeAction.focus();
|
||||
}
|
||||
|
||||
async function showDetails(titleId) {
|
||||
$("modal").hidden = false;
|
||||
$("modalTitle").textContent = "Загрузка…";
|
||||
$("modalTitle").textContent = t("loading");
|
||||
$("modalSubtitle").textContent = titleId;
|
||||
$("details").replaceChildren(node("div", "empty", "Подсчёт размера источника…"));
|
||||
$("details").replaceChildren(node("div", "empty", t("loadingSource")));
|
||||
$("modalActions").replaceChildren();
|
||||
try {
|
||||
renderDetails(await api("/games/info", { title_id: titleId }));
|
||||
renderDetails(await api("/games/info", { title_id: titleId }), true);
|
||||
} catch (error) {
|
||||
$("details").replaceChildren(node("div", "empty", errorMessage(error)));
|
||||
}
|
||||
@@ -991,10 +1305,10 @@
|
||||
|
||||
async function storageOperation(game, operation) {
|
||||
if (state.storageJob && state.storageJob.active) {
|
||||
toast("Уже выполняется операция с хранилищем", true);
|
||||
toast(t("storageBusy"), true);
|
||||
return;
|
||||
}
|
||||
const destination = prompt(`Папка назначения для ${operation === "copy" ? "копирования" : "перемещения"}:`, "/mnt/usb0");
|
||||
const destination = prompt(t(operation === "copy" ? "copyDestination" : "moveDestination"), "/mnt/usb0");
|
||||
if (!destination) return;
|
||||
await startStorageJob(game, operation, { title_id: game.title_id, destination_dir: destination });
|
||||
}
|
||||
@@ -1004,8 +1318,8 @@
|
||||
const job = await api(`/games/${operation}`, payload);
|
||||
renderStorageJob(job);
|
||||
closeModal();
|
||||
logActivity(`${storageOperationLabel(operation)} ${game.title_id} запущено, задача ${job.job_id}`);
|
||||
toast("Операция запущена");
|
||||
logActivity(t("jobStarted", { operation: storageOperationLabel(operation), title: game.title_id, id: job.job_id }));
|
||||
toast(t("operationStarted"));
|
||||
if (job.active) scheduleStorageJobPoll();
|
||||
else reportStorageJob(job);
|
||||
} catch (error) {
|
||||
@@ -1016,25 +1330,30 @@
|
||||
}
|
||||
|
||||
function uninstallGame(game) {
|
||||
if (!confirm(`Запросить деинсталляцию ${game.title_name || game.title_id}?\n\nИсходный образ или папка не удаляются.`)) return;
|
||||
runMutation(`Деинсталляция ${game.title_id} принята`, () => api("/games/uninstall", { title_id: game.title_id }), true);
|
||||
if (!confirm(t("uninstallConfirm", { game: gameLabel(game) }))) return;
|
||||
runMutation(t("uninstallAccepted", { title: game.title_id }), () => api("/games/uninstall", { title_id: game.title_id }), true);
|
||||
}
|
||||
|
||||
async function deleteSource(game) {
|
||||
if (state.storageJob && state.storageJob.active) {
|
||||
toast("Уже выполняется операция с хранилищем", true);
|
||||
return;
|
||||
}
|
||||
const entered = prompt(`Операция необратима. Для удаления физического источника введите Title ID:\n\n${game.title_id}`);
|
||||
if (entered !== game.title_id) {
|
||||
if (entered !== null) toast("Title ID не совпадает", true);
|
||||
toast(t("storageBusy"), true);
|
||||
return;
|
||||
}
|
||||
if (!confirm(t("deleteConfirm", { game: gameLabel(game) }))) return;
|
||||
await startStorageJob(game, "delete", { title_id: game.title_id, confirm: true });
|
||||
}
|
||||
|
||||
$("search").addEventListener("input", renderGames);
|
||||
$("filter").addEventListener("change", renderGames);
|
||||
$("language").addEventListener("change", () => {
|
||||
applyLanguage($("language").value, true);
|
||||
renderStats();
|
||||
renderGames();
|
||||
if (state.storageJob) renderStorageJob(state.storageJob);
|
||||
if (state.selected) renderDetails(state.selected);
|
||||
refreshStorage();
|
||||
refreshManual();
|
||||
});
|
||||
$("includeSize").addEventListener("change", refreshLibrary);
|
||||
$("refreshBtn").addEventListener("click", refreshAll);
|
||||
$("scanBtn").addEventListener("click", requestScan);
|
||||
@@ -1044,6 +1363,7 @@
|
||||
$("modal").addEventListener("click", (event) => { if (event.target === $("modal")) closeModal(); });
|
||||
document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !$("modal").hidden) closeModal(); });
|
||||
|
||||
applyLanguage(currentLanguage);
|
||||
Promise.all([refreshAll(), refreshManual(), refreshStorageJob()]);
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in new issue
Block a user