mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
feat(devices): push downloads from the web UI to a device's download queue
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""Record the capabilities a device sends when it registers or updates itself.
|
||||
|
||||
Revision ID: 0141_device_capabilities
|
||||
Revises: 0140_device_save_sync_baseline
|
||||
Create Date: 2026-09-27 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0141_device_capabilities"
|
||||
down_revision = "0140_device_save_sync_baseline"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("devices", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("capabilities", sa.JSON(), nullable=True),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("devices", schema=None) as batch_op:
|
||||
batch_op.drop_column("capabilities", if_exists=True)
|
||||
@@ -353,6 +353,23 @@ SYNC_RETROARCH_PSP_SERIAL_MAP: Final[dict[str, str]] = json.loads(
|
||||
_get_env("SYNC_RETROARCH_PSP_SERIAL_MAP", "{}")
|
||||
)
|
||||
|
||||
# DEVICE INSTALL
|
||||
DEVICE_INSTALL_ENABLED: Final[bool] = safe_str_to_bool(
|
||||
_get_env("DEVICE_INSTALL_ENABLED", "true")
|
||||
)
|
||||
# Days an unfinished install request lives after its last change; 0 or less never expires.
|
||||
DEVICE_INSTALL_REQUEST_TTL_DAYS: Final[int] = safe_int(
|
||||
_get_env("DEVICE_INSTALL_REQUEST_TTL_DAYS"), 2
|
||||
)
|
||||
# Platforms whose roms cannot be pushed to a device for install.
|
||||
DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS: Final[frozenset[str]] = frozenset(
|
||||
slug.strip().lower()
|
||||
for slug in _get_env(
|
||||
"DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS", "win,win3x,win9x,windows-apps"
|
||||
).split(",")
|
||||
if slug.strip()
|
||||
)
|
||||
|
||||
# EMULATION
|
||||
DISABLE_EMULATOR_JS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_EMULATOR_JS"))
|
||||
DISABLE_RUFFLE_RS: Final[bool] = safe_str_to_bool(_get_env("DISABLE_RUFFLE_RS"))
|
||||
|
||||
@@ -15,6 +15,7 @@ from handler.auth import auth_handler
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_client_token_handler
|
||||
from handler.redis_handler import sync_cache
|
||||
from handler.socket_handler import close_client_token_sockets
|
||||
from models.audit_event import AuditAction, AuditTargetType
|
||||
from models.client_token import ClientToken
|
||||
from utils.client_tokens import (
|
||||
@@ -94,7 +95,7 @@ def list_tokens(request: Request) -> list[ClientTokenSchema]:
|
||||
|
||||
|
||||
@protected_route(router.delete, "/{token_id}", [Scope.ME_WRITE])
|
||||
def delete_token(request: Request, token_id: int) -> None:
|
||||
async def delete_token(request: Request, token_id: int) -> None:
|
||||
token = db_client_token_handler.get_token(
|
||||
token_id=token_id, user_id=request.user.id
|
||||
)
|
||||
@@ -107,10 +108,11 @@ def delete_token(request: Request, token_id: int) -> None:
|
||||
detail="Token not found",
|
||||
)
|
||||
_record_token(request, AuditAction.CLIENT_TOKEN_REVOKE, token)
|
||||
await close_client_token_sockets([token_id])
|
||||
|
||||
|
||||
@protected_route(router.put, "/{token_id}/regenerate", [Scope.ME_WRITE])
|
||||
def regenerate_token(request: Request, token_id: int) -> ClientTokenCreateSchema:
|
||||
async def regenerate_token(request: Request, token_id: int) -> ClientTokenCreateSchema:
|
||||
raw_token = auth_handler.generate_client_token()
|
||||
new_hash = auth_handler.hash_client_token(raw_token)
|
||||
|
||||
@@ -125,6 +127,7 @@ def regenerate_token(request: Request, token_id: int) -> ClientTokenCreateSchema
|
||||
detail="Token not found",
|
||||
)
|
||||
_record_token(request, AuditAction.CLIENT_TOKEN_REGENERATE, token)
|
||||
await close_client_token_sockets([token_id])
|
||||
return build_create_schema(token, raw_token)
|
||||
|
||||
|
||||
@@ -161,11 +164,13 @@ def pair_status(code: str) -> None:
|
||||
|
||||
|
||||
@router.post("/exchange")
|
||||
def exchange_pair_code(
|
||||
async def exchange_pair_code(
|
||||
request: Request,
|
||||
payload: ClientTokenExchangePayload,
|
||||
) -> ClientTokenCreateSchema:
|
||||
return exchange(request, payload.code)
|
||||
token = exchange(request, payload.code)
|
||||
await close_client_token_sockets([token.id])
|
||||
return token
|
||||
|
||||
|
||||
@protected_route(router.get, "/all", [Scope.USERS_READ])
|
||||
@@ -175,7 +180,7 @@ def list_all_tokens(request: Request) -> list[ClientTokenAdminSchema]:
|
||||
|
||||
|
||||
@protected_route(router.delete, "/{token_id}/admin", [Scope.USERS_WRITE])
|
||||
def admin_delete_token(request: Request, token_id: int) -> None:
|
||||
async def admin_delete_token(request: Request, token_id: int) -> None:
|
||||
token = db_client_token_handler.get_token(token_id=token_id)
|
||||
rows = db_client_token_handler.delete_token(token_id=token_id)
|
||||
if rows == 0 or token is None:
|
||||
@@ -184,3 +189,4 @@ def admin_delete_token(request: Request, token_id: int) -> None:
|
||||
detail="Token not found",
|
||||
)
|
||||
_record_token(request, AuditAction.CLIENT_TOKEN_REVOKE, token)
|
||||
await close_client_token_sockets([token_id])
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
StrictBool,
|
||||
StringConstraints,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.device import DeviceCreateResponse, DeviceSchema
|
||||
from endpoints.sockets.devices import online_device_ids
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_device_handler, db_device_save_sync_handler
|
||||
from handler.database import (
|
||||
db_client_token_handler,
|
||||
db_device_handler,
|
||||
db_device_save_sync_handler,
|
||||
)
|
||||
from handler.device_install_handler import device_install_handler
|
||||
from handler.filesystem import get_fs_sync_handler
|
||||
from handler.socket_handler import close_client_token_sockets
|
||||
from logger.logger import log
|
||||
from models.device import Device, SyncMode
|
||||
from utils.router import APIRouter
|
||||
@@ -19,6 +32,12 @@ router = APIRouter(
|
||||
tags=["devices"],
|
||||
)
|
||||
|
||||
# Flags a device reports about itself, such as {"remote_install": true}.
|
||||
DeviceCapabilities = Annotated[
|
||||
dict[Annotated[str, StringConstraints(min_length=1, max_length=64)], StrictBool],
|
||||
Field(max_length=32),
|
||||
]
|
||||
|
||||
|
||||
class DeviceCreatePayload(BaseModel):
|
||||
name: str | None = None
|
||||
@@ -30,6 +49,7 @@ class DeviceCreatePayload(BaseModel):
|
||||
hostname: str | None = None
|
||||
sync_mode: SyncMode | None = None
|
||||
sync_config: dict[str, Any] | None = None
|
||||
capabilities: DeviceCapabilities | None = None
|
||||
allow_existing: bool = True
|
||||
allow_duplicate: bool = False
|
||||
reset_syncs: bool = False
|
||||
@@ -52,6 +72,7 @@ class DeviceUpdatePayload(BaseModel):
|
||||
sync_enabled: bool | None = None
|
||||
sync_mode: SyncMode | None = None
|
||||
sync_config: dict[str, Any] | None = None
|
||||
capabilities: DeviceCapabilities | None = None
|
||||
|
||||
|
||||
@protected_route(router.post, "", [Scope.DEVICES_WRITE])
|
||||
@@ -88,6 +109,12 @@ def register_device(
|
||||
db_device_handler.update_last_seen(
|
||||
device_id=existing_device.id, user_id=request.user.id
|
||||
)
|
||||
if payload.capabilities is not None:
|
||||
db_device_handler.update_device(
|
||||
device_id=existing_device.id,
|
||||
user_id=request.user.id,
|
||||
data={"capabilities": payload.capabilities},
|
||||
)
|
||||
log.info(
|
||||
f"Returned existing device {existing_device.id} for user {request.user.username}"
|
||||
)
|
||||
@@ -115,6 +142,7 @@ def register_device(
|
||||
hostname=payload.hostname,
|
||||
sync_mode=payload.sync_mode,
|
||||
sync_config=payload.sync_config,
|
||||
capabilities=payload.capabilities,
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
@@ -141,6 +169,15 @@ def get_devices(request: Request) -> list[DeviceSchema]:
|
||||
return [DeviceSchema.model_validate(device) for device in devices]
|
||||
|
||||
|
||||
@protected_route(router.get, "/online", [Scope.DEVICES_READ])
|
||||
async def get_online_devices(request: Request) -> list[str]:
|
||||
"""Ids of the caller's devices holding an open device socket."""
|
||||
device_ids = [
|
||||
device.id for device in db_device_handler.get_devices(user_id=request.user.id)
|
||||
]
|
||||
return await online_device_ids(device_ids)
|
||||
|
||||
|
||||
@protected_route(router.get, "/{device_id}", [Scope.DEVICES_READ])
|
||||
def get_device(request: Request, device_id: str) -> DeviceSchema:
|
||||
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
|
||||
@@ -182,7 +219,7 @@ def update_device(
|
||||
[Scope.DEVICES_WRITE],
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def delete_device(request: Request, device_id: str) -> None:
|
||||
async def delete_device(request: Request, device_id: str) -> None:
|
||||
device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
|
||||
if not device:
|
||||
raise HTTPException(
|
||||
@@ -190,5 +227,8 @@ def delete_device(request: Request, device_id: str) -> None:
|
||||
detail=f"Device with ID {device_id} not found",
|
||||
)
|
||||
|
||||
token_ids = db_client_token_handler.get_token_ids_by_device(device_id)
|
||||
db_device_handler.delete_device(device_id=device_id, user_id=request.user.id)
|
||||
log.info(f"Deleted device {device_id} for user {request.user.username}")
|
||||
await close_client_token_sockets(token_ids)
|
||||
await device_install_handler.discard_for_device(device_id)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Install requests: the web UI queues and cancels them, the device claims and reports them."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Path as PathVar
|
||||
from fastapi import Request, Response, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from config import DEVICE_INSTALL_ENABLED
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.device_install import (
|
||||
InstallReason,
|
||||
InstallRequestSchema,
|
||||
InstallStatus,
|
||||
)
|
||||
from endpoints.sockets.devices import (
|
||||
emit_install_cancelled,
|
||||
emit_install_queued,
|
||||
emit_install_updated,
|
||||
)
|
||||
from exceptions.endpoint_exceptions import (
|
||||
DeviceInstallDisabledException,
|
||||
RomNotFoundInDatabaseException,
|
||||
)
|
||||
from handler.auth.constants import Scope
|
||||
from handler.auth.dependencies import assert_rom_visible
|
||||
from handler.database import db_device_handler, db_rom_handler
|
||||
from handler.device_install_handler import (
|
||||
InstallTransitionError,
|
||||
device_install_handler,
|
||||
)
|
||||
from handler.device_install_policy import (
|
||||
InstallNotAllowedError,
|
||||
accepts_remote_install,
|
||||
is_installable_platform,
|
||||
select_install_files,
|
||||
)
|
||||
from handler.notification_handler import notify
|
||||
from logger.logger import log
|
||||
from models.device import Device
|
||||
from models.notification import NotificationKind, NotificationLevel
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/devices",
|
||||
tags=["devices"],
|
||||
)
|
||||
|
||||
DEVICE_REPORTS: Final = frozenset(
|
||||
{InstallStatus.DONE, InstallStatus.ALREADY_INSTALLED, InstallStatus.FAILED}
|
||||
)
|
||||
|
||||
|
||||
class InstallRequestCreatePayload(BaseModel):
|
||||
rom_id: int = Field(ge=1)
|
||||
|
||||
|
||||
class InstallRequestUpdatePayload(BaseModel):
|
||||
status: InstallStatus
|
||||
reason: InstallReason | None = None
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _reportable(cls, value: InstallStatus) -> InstallStatus:
|
||||
if value not in DEVICE_REPORTS:
|
||||
raise ValueError(
|
||||
f"A device reports one of {', '.join(sorted(DEVICE_REPORTS))}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _require_device(
|
||||
request: Request, device_id: str, device_only: bool = False
|
||||
) -> Device:
|
||||
"""The caller's device, 404 when not theirs and 403 under ``device_only`` for another caller."""
|
||||
bound_device_id = getattr(request.state, "device_id", None)
|
||||
owned = db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
|
||||
if owned is None or bound_device_id not in (None, device_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Device with ID {device_id} not found",
|
||||
)
|
||||
if device_only and bound_device_id != device_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the device itself can do this",
|
||||
)
|
||||
return owned
|
||||
|
||||
|
||||
async def _require_request(request: Request, device_id: str, request_id: str) -> None:
|
||||
install = await device_install_handler.get(request_id)
|
||||
if (
|
||||
install is None
|
||||
or install.device_id != device_id
|
||||
or install.user_id != request.user.id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Install request {request_id} not found",
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _install_errors() -> Iterator[None]:
|
||||
"""A gone request as a 404, a request in the wrong status as a 409."""
|
||||
try:
|
||||
yield
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Install request {exc.args[0]} not found",
|
||||
) from exc
|
||||
except InstallTransitionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
async def _notify_outcome(install: InstallRequestSchema, device: Device) -> None:
|
||||
rom = db_rom_handler.get_rom_visibility_label(install.rom_id)
|
||||
failed = install.status == InstallStatus.FAILED
|
||||
await notify(
|
||||
install.user_id,
|
||||
(
|
||||
NotificationKind.DEVICE_INSTALL_FAILED
|
||||
if failed
|
||||
else NotificationKind.DEVICE_INSTALL_COMPLETED
|
||||
),
|
||||
NotificationLevel.ERROR if failed else NotificationLevel.SUCCESS,
|
||||
{
|
||||
"rom_id": install.rom_id,
|
||||
"rom_name": (rom.name or rom.fs_name) if rom else None,
|
||||
"device_name": device.name or device.hostname,
|
||||
"status": install.status,
|
||||
"reason": install.reason,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/{device_id}/installs",
|
||||
[Scope.DEVICES_WRITE, Scope.ROMS_READ],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
status.HTTP_200_OK: {"model": InstallRequestSchema},
|
||||
status.HTTP_400_BAD_REQUEST: {},
|
||||
status.HTTP_404_NOT_FOUND: {},
|
||||
status.HTTP_409_CONFLICT: {},
|
||||
},
|
||||
)
|
||||
async def create_install_request(
|
||||
request: Request,
|
||||
response: Response,
|
||||
device_id: Annotated[str, PathVar(description="Target device id.")],
|
||||
payload: InstallRequestCreatePayload,
|
||||
) -> InstallRequestSchema:
|
||||
"""Queue a rom for install on a device, returning a live request for the same pair with 200."""
|
||||
if not DEVICE_INSTALL_ENABLED:
|
||||
raise DeviceInstallDisabledException()
|
||||
device = _require_device(request, device_id)
|
||||
if not accepts_remote_install(device.capabilities):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Device {device_id} does not accept remote installs",
|
||||
)
|
||||
|
||||
rom = db_rom_handler.get_rom_install_target(payload.rom_id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(payload.rom_id)
|
||||
assert_rom_visible(request, rom)
|
||||
|
||||
if not is_installable_platform(rom.platform_slug):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Roms for {rom.platform_slug} cannot be installed on a device",
|
||||
)
|
||||
try:
|
||||
file_ids = select_install_files(db_rom_handler.rom_files_for_rom_id(rom.id))
|
||||
except InstallNotAllowedError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
with _install_errors():
|
||||
install, created = await device_install_handler.create(
|
||||
user_id=request.user.id,
|
||||
device_id=device_id,
|
||||
rom_id=rom.id,
|
||||
file_ids=file_ids,
|
||||
)
|
||||
|
||||
if not created:
|
||||
response.status_code = status.HTTP_200_OK
|
||||
return install
|
||||
|
||||
# A delete that ran between the device check and the write missed this request.
|
||||
if (
|
||||
db_device_handler.get_device(device_id=device_id, user_id=request.user.id)
|
||||
is None
|
||||
):
|
||||
await device_install_handler.discard_for_device(device_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Device with ID {device_id} not found",
|
||||
)
|
||||
|
||||
log.info(f"Queued rom {rom.id} for install on device {device_id}")
|
||||
await emit_install_queued(install)
|
||||
await emit_install_updated(install)
|
||||
return install
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.get,
|
||||
"/{device_id}/installs",
|
||||
[Scope.DEVICES_READ],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def list_install_requests(
|
||||
request: Request,
|
||||
device_id: Annotated[str, PathVar(description="Target device id.")],
|
||||
) -> list[InstallRequestSchema]:
|
||||
"""The device's pending and taken requests, oldest first."""
|
||||
if not DEVICE_INSTALL_ENABLED:
|
||||
raise DeviceInstallDisabledException()
|
||||
_require_device(request, device_id)
|
||||
|
||||
return await device_install_handler.list_for_device(device_id)
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/{device_id}/installs/claim",
|
||||
[Scope.DEVICES_WRITE, Scope.ROMS_READ],
|
||||
responses={
|
||||
status.HTTP_403_FORBIDDEN: {},
|
||||
status.HTTP_404_NOT_FOUND: {},
|
||||
status.HTTP_409_CONFLICT: {},
|
||||
},
|
||||
)
|
||||
async def claim_install_requests(
|
||||
request: Request,
|
||||
device_id: Annotated[str, PathVar(description="Target device id.")],
|
||||
) -> list[InstallRequestSchema]:
|
||||
"""Take every pending request and return all the device holds taken, oldest first."""
|
||||
if not DEVICE_INSTALL_ENABLED:
|
||||
raise DeviceInstallDisabledException()
|
||||
_require_device(request, device_id, device_only=True)
|
||||
|
||||
with _install_errors():
|
||||
claim = await device_install_handler.claim(device_id)
|
||||
|
||||
for install in claim.newly_taken:
|
||||
await emit_install_updated(install)
|
||||
return claim.taken
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.put,
|
||||
"/{device_id}/installs/{request_id}",
|
||||
[Scope.DEVICES_WRITE],
|
||||
responses={
|
||||
status.HTTP_403_FORBIDDEN: {},
|
||||
status.HTTP_404_NOT_FOUND: {},
|
||||
status.HTTP_409_CONFLICT: {},
|
||||
},
|
||||
)
|
||||
async def update_install_request(
|
||||
request: Request,
|
||||
device_id: Annotated[str, PathVar(description="Target device id.")],
|
||||
request_id: Annotated[str, PathVar(description="Install request id.")],
|
||||
payload: InstallRequestUpdatePayload,
|
||||
) -> InstallRequestSchema:
|
||||
"""The device reports the outcome of a request it has taken, which ends the request."""
|
||||
if not DEVICE_INSTALL_ENABLED:
|
||||
raise DeviceInstallDisabledException()
|
||||
device = _require_device(request, device_id, device_only=True)
|
||||
await _require_request(request, device_id, request_id)
|
||||
|
||||
with _install_errors():
|
||||
ended = await device_install_handler.report(
|
||||
request_id, payload.status, payload.reason
|
||||
)
|
||||
|
||||
await emit_install_updated(ended)
|
||||
await _notify_outcome(ended, device)
|
||||
return ended
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.delete,
|
||||
"/{device_id}/installs/{request_id}",
|
||||
[Scope.DEVICES_WRITE],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}, status.HTTP_409_CONFLICT: {}},
|
||||
)
|
||||
async def cancel_install_request(
|
||||
request: Request,
|
||||
device_id: Annotated[str, PathVar(description="Target device id.")],
|
||||
request_id: Annotated[str, PathVar(description="Install request id.")],
|
||||
) -> InstallRequestSchema:
|
||||
"""Cancel a request still pending or taken; one already ended is a 404."""
|
||||
if not DEVICE_INSTALL_ENABLED:
|
||||
raise DeviceInstallDisabledException()
|
||||
_require_device(request, device_id)
|
||||
await _require_request(request, device_id, request_id)
|
||||
|
||||
with _install_errors():
|
||||
ended = await device_install_handler.cancel(request_id)
|
||||
|
||||
await emit_install_cancelled(ended)
|
||||
await emit_install_updated(ended)
|
||||
return ended
|
||||
@@ -8,6 +8,8 @@ from fastapi import HTTPException, Request, status
|
||||
|
||||
from adapters.services.sigil import SigilService
|
||||
from config import (
|
||||
DEVICE_INSTALL_ENABLED,
|
||||
DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS,
|
||||
DISABLE_EMULATOR_JS,
|
||||
DISABLE_JSDOS,
|
||||
DISABLE_LOGS_VIEWER,
|
||||
@@ -184,6 +186,10 @@ async def heartbeat() -> HeartbeatResponse:
|
||||
"EMAIL_ENABLED": EMAIL_ENABLED,
|
||||
"EMAILS_RESET_LINKS": reset_link_base_url() is not None,
|
||||
},
|
||||
"DEVICE_INSTALL": {
|
||||
"ENABLED": DEVICE_INSTALL_ENABLED,
|
||||
"EXCLUDED_PLATFORM_SLUGS": sorted(DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS),
|
||||
},
|
||||
"TASKS": {
|
||||
"ENABLE_SCHEDULED_RESCAN": ENABLE_SCHEDULED_RESCAN,
|
||||
"SCHEDULED_RESCAN_CRON": SCHEDULED_RESCAN_CRON,
|
||||
|
||||
@@ -20,12 +20,13 @@ from handler.audit_handler import AuditTarget, record
|
||||
from handler.auth.constants import Scope
|
||||
from handler.auth.dependencies import assert_admin, get_permissions
|
||||
from handler.database import (
|
||||
db_client_token_handler,
|
||||
db_permission_handler,
|
||||
db_platform_handler,
|
||||
db_rom_handler,
|
||||
db_user_handler,
|
||||
)
|
||||
from handler.socket_handler import socket_handler
|
||||
from handler.socket_handler import close_client_token_sockets, socket_handler
|
||||
from logger.logger import log
|
||||
from models.audit_event import AuditAction, AuditTargetType
|
||||
from models.permission import PermAction, PermEntity, PermissionGroup
|
||||
@@ -50,6 +51,9 @@ async def emit_permissions_changed(user_id: int) -> None:
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(f"Failed to broadcast permissions:changed for user {user_id}: {e}")
|
||||
await close_client_token_sockets(
|
||||
db_client_token_handler.get_token_ids_by_user(user_id)
|
||||
)
|
||||
|
||||
|
||||
@protected_route(router.get, "/me", [Scope.ME_READ])
|
||||
|
||||
@@ -35,6 +35,7 @@ class DeviceSchema(BaseModel):
|
||||
sync_mode: SyncMode
|
||||
sync_enabled: bool
|
||||
sync_config: dict[str, Any] | None
|
||||
capabilities: dict[str, bool] | None
|
||||
last_seen: UTCDatetime | None
|
||||
created_at: UTCDatetime
|
||||
updated_at: UTCDatetime
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import enum
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .base import BaseModel, UTCDatetime
|
||||
|
||||
|
||||
class InstallStatus(enum.StrEnum):
|
||||
PENDING = "pending"
|
||||
TAKEN = "taken"
|
||||
DONE = "done"
|
||||
ALREADY_INSTALLED = "already_installed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
InstallReason = Annotated[str, Field(max_length=500)]
|
||||
|
||||
|
||||
class InstallRequestSchema(BaseModel):
|
||||
"""An install request, as served and as stored in Redis while pending or taken."""
|
||||
|
||||
id: str
|
||||
user_id: int
|
||||
device_id: str
|
||||
rom_id: int
|
||||
file_ids: list[int]
|
||||
status: InstallStatus
|
||||
reason: InstallReason | None
|
||||
created_at: UTCDatetime
|
||||
updated_at: UTCDatetime
|
||||
@@ -61,6 +61,11 @@ class NotificationsDict(TypedDict):
|
||||
EMAILS_RESET_LINKS: bool
|
||||
|
||||
|
||||
class DeviceInstallDict(TypedDict):
|
||||
ENABLED: bool
|
||||
EXCLUDED_PLATFORM_SLUGS: list[str]
|
||||
|
||||
|
||||
class TasksDict(TypedDict):
|
||||
ENABLE_SCHEDULED_RESCAN: bool
|
||||
SCHEDULED_RESCAN_CRON: str
|
||||
@@ -80,6 +85,7 @@ class HeartbeatResponse(TypedDict):
|
||||
FRONTEND: FrontendDict
|
||||
OIDC: OIDCDict
|
||||
NOTIFICATIONS: NotificationsDict
|
||||
DEVICE_INSTALL: DeviceInstallDict
|
||||
TASKS: TasksDict
|
||||
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ from utils.zip_cache import (
|
||||
)
|
||||
|
||||
from .files import router as files_router
|
||||
from .installs import router as installs_router
|
||||
from .manual import router as manual_router
|
||||
from .notes import router as notes_router
|
||||
from .patch import router as patch_router
|
||||
@@ -171,6 +172,7 @@ router = APIRouter(
|
||||
)
|
||||
router.include_router(upload_router)
|
||||
router.include_router(files_router)
|
||||
router.include_router(installs_router)
|
||||
router.include_router(manual_router)
|
||||
router.include_router(walkthrough_router)
|
||||
router.include_router(soundtrack_router)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path as PathVar
|
||||
from fastapi import Request, status
|
||||
|
||||
from config import DEVICE_INSTALL_ENABLED
|
||||
from decorators.auth import protected_route
|
||||
from endpoints.responses.device_install import InstallRequestSchema
|
||||
from exceptions.endpoint_exceptions import (
|
||||
DeviceInstallDisabledException,
|
||||
RomNotFoundInDatabaseException,
|
||||
)
|
||||
from handler.auth.constants import Scope
|
||||
from handler.auth.dependencies import assert_rom_visible
|
||||
from handler.database import db_rom_handler
|
||||
from handler.device_install_handler import device_install_handler
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.get,
|
||||
"/{id}/installs",
|
||||
[Scope.DEVICES_READ, Scope.ROMS_READ],
|
||||
responses={status.HTTP_404_NOT_FOUND: {}},
|
||||
)
|
||||
async def get_rom_installs(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
|
||||
) -> list[InstallRequestSchema]:
|
||||
"""The caller's pending and taken requests for a rom on any device, oldest first."""
|
||||
if not DEVICE_INSTALL_ENABLED:
|
||||
raise DeviceInstallDisabledException()
|
||||
|
||||
rom = db_rom_handler.get_rom_visibility(id)
|
||||
if not rom:
|
||||
raise RomNotFoundInDatabaseException(id)
|
||||
assert_rom_visible(request, rom)
|
||||
|
||||
return await device_install_handler.list_for_rom(request.user.id, id)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""The ``/devices`` socket namespace, open only to device-bound client tokens."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Final, NamedTuple
|
||||
|
||||
from socketio import exceptions as socketio_exceptions
|
||||
|
||||
from config import DEVICE_INSTALL_ENABLED, SESSION_MAX_AGE_SECONDS
|
||||
from endpoints.responses.device_install import InstallRequestSchema
|
||||
from handler.auth.constants import CLIENT_TOKEN_PREFIX, Scope
|
||||
from handler.auth.hybrid_auth import resolve_client_token
|
||||
from handler.database import db_client_token_handler, db_device_handler
|
||||
from handler.redis_handler import async_cache
|
||||
from handler.socket_handler import (
|
||||
DEVICES_NAMESPACE,
|
||||
client_token_sockets_key,
|
||||
socket_handler,
|
||||
)
|
||||
from logger.logger import log
|
||||
from utils.datetime import to_utc
|
||||
|
||||
CLIENT_TOKEN_ID_KEY: Final = "client_token_id"
|
||||
DEVICE_ID_KEY: Final = "device_id"
|
||||
# Clears entries a crashed worker left behind.
|
||||
SOCKET_TRACKING_TTL_SECONDS: Final = SESSION_MAX_AGE_SECONDS
|
||||
# A device is online while one of its sockets refreshed its presence within the TTL.
|
||||
PRESENCE_TTL_SECONDS: Final = 90
|
||||
PRESENCE_REFRESH_SECONDS: Final = 30
|
||||
|
||||
|
||||
def device_room(device_id: str) -> str:
|
||||
return f"device:{device_id}"
|
||||
|
||||
|
||||
def _presence_key(device_id: str) -> str:
|
||||
return f"device_presence:{device_id}"
|
||||
|
||||
|
||||
def _raw_client_token(environ: dict[str, Any], auth: Any) -> str | None:
|
||||
"""The token from the handshake's auth payload, else its bearer header."""
|
||||
token = auth.get("token") if isinstance(auth, dict) else None
|
||||
if isinstance(token, str):
|
||||
return token
|
||||
|
||||
scheme, _, credentials = str(environ.get("HTTP_AUTHORIZATION", "")).partition(" ")
|
||||
if scheme.lower() == "bearer" and credentials:
|
||||
return credentials.strip()
|
||||
return None
|
||||
|
||||
|
||||
class BoundToken(NamedTuple):
|
||||
token_id: int
|
||||
device_id: str
|
||||
expires_at: datetime | None
|
||||
|
||||
|
||||
def _bound_token(raw_token: str) -> BoundToken | None:
|
||||
"""A live, device-bound token holding devices.read."""
|
||||
identity = resolve_client_token(raw_token)
|
||||
if (
|
||||
identity is None
|
||||
or not identity.token.device_id
|
||||
or Scope.DEVICES_READ not in identity.scopes
|
||||
):
|
||||
return None
|
||||
expires_at = identity.token.expires_at
|
||||
return BoundToken(
|
||||
identity.token.id,
|
||||
identity.token.device_id,
|
||||
to_utc(expires_at) if expires_at else None,
|
||||
)
|
||||
|
||||
|
||||
async def _keep_tracked(sid: str, bound: BoundToken) -> None:
|
||||
"""Refresh the socket's tracking while connected, closing it once its token expires."""
|
||||
server = socket_handler.socket_server
|
||||
token_id, device_id, expires_at = bound
|
||||
while True:
|
||||
await server.sleep(PRESENCE_REFRESH_SECONDS)
|
||||
if not server.manager.is_connected(sid, DEVICES_NAMESPACE):
|
||||
return
|
||||
if expires_at and expires_at <= datetime.now(timezone.utc):
|
||||
await server.disconnect(sid, namespace=DEVICES_NAMESPACE)
|
||||
return
|
||||
try:
|
||||
await socket_handler.track(
|
||||
_presence_key(device_id), sid, PRESENCE_TTL_SECONDS
|
||||
)
|
||||
await socket_handler.track(
|
||||
client_token_sockets_key(token_id), sid, SOCKET_TRACKING_TTL_SECONDS
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning(
|
||||
f"Failed to refresh the tracking of device {device_id}", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
@socket_handler.on("connect", namespace=DEVICES_NAMESPACE)
|
||||
async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None:
|
||||
"""Admit a live client token bound to a device, whose owner and token hold devices.read."""
|
||||
if not DEVICE_INSTALL_ENABLED:
|
||||
raise socketio_exceptions.ConnectionRefusedError("disabled")
|
||||
|
||||
raw_token = _raw_client_token(environ, auth)
|
||||
if not raw_token or not raw_token.startswith(CLIENT_TOKEN_PREFIX):
|
||||
raise socketio_exceptions.ConnectionRefusedError("unauthorized")
|
||||
|
||||
bound = _bound_token(raw_token)
|
||||
if bound is None:
|
||||
raise socketio_exceptions.ConnectionRefusedError("unauthorized")
|
||||
|
||||
token_id, device_id, _ = bound
|
||||
tracking_key = client_token_sockets_key(token_id)
|
||||
await socket_handler.track(tracking_key, sid, SOCKET_TRACKING_TTL_SECONDS)
|
||||
# A revocation that ran before the socket was tracked found nothing to close.
|
||||
if _bound_token(raw_token) != bound:
|
||||
await socket_handler.untrack(tracking_key, sid)
|
||||
raise socketio_exceptions.ConnectionRefusedError("unauthorized")
|
||||
|
||||
db_client_token_handler.update_last_used(token_id)
|
||||
db_device_handler.update_last_seen_debounced(device_id)
|
||||
await socket_handler.track(_presence_key(device_id), sid, PRESENCE_TTL_SECONDS)
|
||||
async with socket_handler.socket_server.session(
|
||||
sid, namespace=DEVICES_NAMESPACE
|
||||
) as session:
|
||||
session[CLIENT_TOKEN_ID_KEY] = token_id
|
||||
session[DEVICE_ID_KEY] = device_id
|
||||
await socket_handler.socket_server.enter_room(
|
||||
sid, device_room(device_id), namespace=DEVICES_NAMESPACE
|
||||
)
|
||||
socket_handler.socket_server.start_background_task(_keep_tracked, sid, bound)
|
||||
|
||||
|
||||
@socket_handler.on("disconnect", namespace=DEVICES_NAMESPACE)
|
||||
async def disconnect(sid: str) -> None:
|
||||
"""Forget the socket for revocation and presence."""
|
||||
session = await socket_handler.get_session(sid, namespace=DEVICES_NAMESPACE)
|
||||
token_id = session.get(CLIENT_TOKEN_ID_KEY)
|
||||
device_id = session.get(DEVICE_ID_KEY)
|
||||
if token_id is not None:
|
||||
await socket_handler.untrack(client_token_sockets_key(token_id), sid)
|
||||
if device_id is not None:
|
||||
await socket_handler.untrack(_presence_key(device_id), sid)
|
||||
|
||||
|
||||
async def online_device_ids(device_ids: Sequence[str]) -> list[str]:
|
||||
"""The devices among ``device_ids`` with a socket that refreshed its presence lately."""
|
||||
if not device_ids:
|
||||
return []
|
||||
async with async_cache.pipeline(transaction=False) as pipe:
|
||||
for device_id in device_ids:
|
||||
pipe.exists(_presence_key(device_id))
|
||||
counts = await pipe.execute()
|
||||
return [
|
||||
device_id for device_id, count in zip(device_ids, counts, strict=True) if count
|
||||
]
|
||||
|
||||
|
||||
async def _emit_to_device(event: str, request: InstallRequestSchema) -> None:
|
||||
try:
|
||||
await socket_handler.write_manager().emit(
|
||||
event,
|
||||
{"id": request.id, "rom_id": request.rom_id},
|
||||
namespace=DEVICES_NAMESPACE,
|
||||
room=device_room(request.device_id),
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning(
|
||||
f"Failed to push {event} to device {request.device_id}", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
async def emit_install_queued(request: InstallRequestSchema) -> None:
|
||||
"""Tell the device a request waits in its queue, logging a failure."""
|
||||
await _emit_to_device("install:queued", request)
|
||||
|
||||
|
||||
async def emit_install_cancelled(request: InstallRequestSchema) -> None:
|
||||
"""Tell the device to drop a request it may already be downloading, logging a failure."""
|
||||
await _emit_to_device("install:cancelled", request)
|
||||
|
||||
|
||||
async def emit_install_updated(request: InstallRequestSchema) -> None:
|
||||
"""Tell the owner's open tabs a request changed."""
|
||||
await socket_handler.emit_to_user(
|
||||
request.user_id, "install:updated", request.model_dump(mode="json")
|
||||
)
|
||||
@@ -20,7 +20,12 @@ from handler.audit_handler import (
|
||||
)
|
||||
from handler.auth import auth_handler
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_user_handler
|
||||
from handler.database import (
|
||||
db_client_token_handler,
|
||||
db_device_handler,
|
||||
db_user_handler,
|
||||
)
|
||||
from handler.device_install_handler import device_install_handler
|
||||
from handler.filesystem import fs_asset_handler
|
||||
from handler.filesystem.assets_handler import (
|
||||
build_asset_file_response,
|
||||
@@ -29,6 +34,7 @@ from handler.filesystem.assets_handler import (
|
||||
from handler.metadata import meta_ra_handler
|
||||
from handler.metadata.ra_handler import RAUserProgression
|
||||
from handler.notification_handler import notify
|
||||
from handler.socket_handler import close_client_token_sockets
|
||||
from logger.logger import log
|
||||
from models.audit_event import AuditAction
|
||||
from models.notification import NotificationKind, NotificationLevel
|
||||
@@ -556,6 +562,11 @@ async def update_user(
|
||||
actor_id=request.user.id,
|
||||
)
|
||||
|
||||
if cleaned_data.get("enabled") is False:
|
||||
await close_client_token_sockets(
|
||||
db_client_token_handler.get_token_ids_by_user(id)
|
||||
)
|
||||
|
||||
before = db_user
|
||||
db_user = db_user_handler.get_user(id)
|
||||
if not db_user:
|
||||
@@ -602,7 +613,12 @@ async def delete_user(
|
||||
status_code=400, detail="You cannot delete the last admin user"
|
||||
)
|
||||
|
||||
token_ids = db_client_token_handler.get_token_ids_by_user(id)
|
||||
device_ids = [device.id for device in db_device_handler.get_devices(user_id=id)]
|
||||
db_user_handler.delete_user(id)
|
||||
await close_client_token_sockets(token_ids)
|
||||
for device_id in device_ids:
|
||||
await device_install_handler.discard_for_device(device_id)
|
||||
record(
|
||||
AuditAction.USER_DELETE,
|
||||
request,
|
||||
|
||||
@@ -88,6 +88,16 @@ class MusicPlaylistAlreadyExistsException(Exception):
|
||||
return self.message
|
||||
|
||||
|
||||
class DeviceInstallDisabledException(Exception):
|
||||
def __init__(self) -> None:
|
||||
self.message = "Installing on a device is disabled on this server"
|
||||
super().__init__(self.message)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=self.message)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
class RomNotFoundInRetroAchievementsException(Exception):
|
||||
def __init__(self, id: int) -> None:
|
||||
self.message = f"Rom with id '{id}' does not exist on RetroAchievements"
|
||||
|
||||
@@ -28,7 +28,12 @@ from config import (
|
||||
)
|
||||
from decorators.auth import oauth
|
||||
from exceptions.auth_exceptions import OAuthCredentialsException, UserDisabledException
|
||||
from handler.auth.constants import ALGORITHM, DEFAULT_OAUTH_TOKEN_EXPIRY, TokenPurpose
|
||||
from handler.auth.constants import (
|
||||
ALGORITHM,
|
||||
CLIENT_TOKEN_PREFIX,
|
||||
DEFAULT_OAUTH_TOKEN_EXPIRY,
|
||||
TokenPurpose,
|
||||
)
|
||||
from handler.auth.middleware.redis_session_middleware import RedisSessionMiddleware
|
||||
from handler.email_handler import EmailError, send_email
|
||||
from handler.redis_handler import redis_client
|
||||
@@ -106,7 +111,7 @@ class AuthHandler:
|
||||
|
||||
@staticmethod
|
||||
def generate_client_token() -> str:
|
||||
return "rmm_" + secrets.token_hex(32)
|
||||
return CLIENT_TOKEN_PREFIX + secrets.token_hex(32)
|
||||
|
||||
@staticmethod
|
||||
def hash_client_token(raw: str) -> str:
|
||||
|
||||
@@ -7,6 +7,7 @@ DEFAULT_OAUTH_TOKEN_EXPIRY: Final = timedelta(minutes=15)
|
||||
# Name of the session cookie set by RedisSessionMiddleware (see main.py). Shared
|
||||
# so the socket log-stream handshake reads the same cookie the middleware writes.
|
||||
SESSION_COOKIE_NAME: Final = "romm_session"
|
||||
CLIENT_TOKEN_PREFIX: Final = "rmm_"
|
||||
|
||||
|
||||
class Scope(enum.StrEnum):
|
||||
|
||||
@@ -30,7 +30,7 @@ from models.permission import PermAction, PermEntity
|
||||
if TYPE_CHECKING:
|
||||
from models.firmware import Firmware
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom, RomVisibility, RomVisibilityLabel
|
||||
from models.rom import Rom, RomInstallTarget, RomVisibility, RomVisibilityLabel
|
||||
|
||||
|
||||
def get_permissions(request: Request) -> ResolvedPermissions:
|
||||
@@ -109,7 +109,7 @@ def assert_admin(request: Request) -> ResolvedPermissions:
|
||||
# download endpoints, which carry no permission context to resolve.
|
||||
def assert_rom_visible(
|
||||
request: Request,
|
||||
rom: Rom | RomVisibility | RomVisibilityLabel,
|
||||
rom: Rom | RomVisibility | RomVisibilityLabel | RomInstallTarget,
|
||||
*,
|
||||
not_found_detail: str | None = None,
|
||||
) -> None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import NamedTuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security.http import HTTPBasic
|
||||
@@ -12,10 +13,40 @@ from handler.database import (
|
||||
db_device_handler,
|
||||
db_user_handler,
|
||||
)
|
||||
from models.client_token import ClientToken
|
||||
from models.user import User
|
||||
from utils.datetime import to_utc
|
||||
|
||||
from .constants import READ_SCOPES
|
||||
from .constants import CLIENT_TOKEN_PREFIX, READ_SCOPES
|
||||
|
||||
|
||||
class ClientTokenIdentity(NamedTuple):
|
||||
token: ClientToken
|
||||
user: User
|
||||
scopes: list[str]
|
||||
|
||||
|
||||
def resolve_client_token(raw_token: str) -> ClientTokenIdentity | None:
|
||||
"""The live client token behind a raw one, its enabled owner, and the scopes both hold."""
|
||||
client_token = db_client_token_handler.get_token_by_hash(
|
||||
auth_handler.hash_client_token(raw_token)
|
||||
)
|
||||
if client_token is None:
|
||||
return None
|
||||
|
||||
if client_token.expires_at and to_utc(client_token.expires_at) < datetime.now(
|
||||
timezone.utc
|
||||
):
|
||||
return None
|
||||
|
||||
user = db_user_handler.get_user(client_token.user_id)
|
||||
if user is None or not user.enabled:
|
||||
return None
|
||||
|
||||
token_scopes = set(client_token.scopes.split())
|
||||
return ClientTokenIdentity(
|
||||
client_token, user, list(token_scopes & set(user.oauth_scopes))
|
||||
)
|
||||
|
||||
|
||||
class HybridAuthBackend(AuthenticationBackend):
|
||||
@@ -57,25 +88,12 @@ class HybridAuthBackend(AuthenticationBackend):
|
||||
|
||||
# Check if bearer auth header is valid
|
||||
if scheme.lower() == "bearer":
|
||||
# Client API tokens use the rmm_ prefix
|
||||
if token.startswith("rmm_"):
|
||||
hashed = auth_handler.hash_client_token(token)
|
||||
client_token = db_client_token_handler.get_token_by_hash(hashed)
|
||||
if client_token is None:
|
||||
if token.startswith(CLIENT_TOKEN_PREFIX):
|
||||
identity = resolve_client_token(token)
|
||||
if identity is None:
|
||||
return None
|
||||
|
||||
if client_token.expires_at and to_utc(
|
||||
client_token.expires_at
|
||||
) < datetime.now(timezone.utc):
|
||||
return None
|
||||
|
||||
user = db_user_handler.get_user(client_token.user_id)
|
||||
if user is None or not user.enabled:
|
||||
return None
|
||||
|
||||
token_scopes = set(client_token.scopes.split())
|
||||
effective_scopes = list(token_scopes & set(user.oauth_scopes))
|
||||
|
||||
client_token, user, effective_scopes = identity
|
||||
db_client_token_handler.update_last_used(client_token.id)
|
||||
user.set_last_active()
|
||||
conn.state.client_token_id = client_token.id
|
||||
|
||||
@@ -44,6 +44,26 @@ class DBClientTokensHandler(DBBaseHandler):
|
||||
.order_by(ClientToken.created_at.desc())
|
||||
).all()
|
||||
|
||||
@begin_session
|
||||
def get_token_ids_by_user(
|
||||
self,
|
||||
user_id: int,
|
||||
session: Session = None, # type: ignore[assignment]
|
||||
) -> Sequence[int]:
|
||||
return session.scalars(
|
||||
select(ClientToken.id).where(ClientToken.user_id == user_id)
|
||||
).all()
|
||||
|
||||
@begin_session
|
||||
def get_token_ids_by_device(
|
||||
self,
|
||||
device_id: str,
|
||||
session: Session = None, # type: ignore[assignment]
|
||||
) -> Sequence[int]:
|
||||
return session.scalars(
|
||||
select(ClientToken.id).where(ClientToken.device_id == device_id)
|
||||
).all()
|
||||
|
||||
@begin_session
|
||||
def get_all_tokens(
|
||||
self,
|
||||
|
||||
@@ -76,6 +76,7 @@ from models.rom import (
|
||||
RomFileDocMeta,
|
||||
RomFileUser,
|
||||
RomIdentityKey,
|
||||
RomInstallTarget,
|
||||
RomMetadata,
|
||||
RomNote,
|
||||
RomUser,
|
||||
@@ -772,6 +773,27 @@ class DBRomsHandler(DBBaseHandler):
|
||||
id=row.id, platform_id=row.platform_id, name=row.name, fs_name=row.fs_name
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def get_rom_install_target(
|
||||
self,
|
||||
id: int,
|
||||
*,
|
||||
session: Session = None, # type: ignore[assignment]
|
||||
) -> RomInstallTarget | None:
|
||||
"""The columns a device install request checks, in one query."""
|
||||
row = session.execute(
|
||||
select(Rom.id, Rom.platform_id, Platform.slug.label("platform_slug"))
|
||||
.join(Platform, Rom.platform_id == Platform.id)
|
||||
.where(Rom.id == id)
|
||||
).one_or_none()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return RomInstallTarget(
|
||||
id=row.id, platform_id=row.platform_id, platform_slug=row.platform_slug
|
||||
)
|
||||
|
||||
@begin_session
|
||||
def get_rom_deletion_target(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Install requests a user pushes to one of their devices, held in Redis while live."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final, NamedTuple
|
||||
|
||||
from redis.asyncio import Redis
|
||||
from redis.asyncio.client import Pipeline
|
||||
from redis.exceptions import WatchError
|
||||
|
||||
from config import DEVICE_INSTALL_REQUEST_TTL_DAYS
|
||||
from endpoints.responses.device_install import InstallRequestSchema, InstallStatus
|
||||
from handler.redis_handler import as_text, async_cache
|
||||
from logger.logger import log
|
||||
|
||||
TRANSACTION_ATTEMPTS: Final = 3
|
||||
REPORTABLE_FROM: Final = frozenset({InstallStatus.TAKEN})
|
||||
CANCELLABLE_FROM: Final = frozenset({InstallStatus.PENDING, InstallStatus.TAKEN})
|
||||
|
||||
|
||||
class InstallTransitionError(Exception):
|
||||
"""The request is not in the status the operation needs, or kept changing."""
|
||||
|
||||
|
||||
class InstallClaim(NamedTuple):
|
||||
"""Every request the device holds taken, oldest first, and those this claim took."""
|
||||
|
||||
taken: list[InstallRequestSchema]
|
||||
newly_taken: list[InstallRequestSchema]
|
||||
|
||||
|
||||
def _oldest_first(
|
||||
requests: list[InstallRequestSchema],
|
||||
) -> list[InstallRequestSchema]:
|
||||
return sorted(requests, key=lambda request: request.created_at)
|
||||
|
||||
|
||||
def _claim(
|
||||
already_taken: list[InstallRequestSchema], newly_taken: list[InstallRequestSchema]
|
||||
) -> InstallClaim:
|
||||
return InstallClaim(
|
||||
taken=_oldest_first(already_taken + newly_taken),
|
||||
newly_taken=_oldest_first(newly_taken),
|
||||
)
|
||||
|
||||
|
||||
def _request_key(request_id: str) -> str:
|
||||
return f"install:req:{request_id}"
|
||||
|
||||
|
||||
def _device_key(device_id: str) -> str:
|
||||
return f"install:device:{device_id}"
|
||||
|
||||
|
||||
def _rom_key(user_id: int, rom_id: int) -> str:
|
||||
return f"install:rom:{user_id}:{rom_id}"
|
||||
|
||||
|
||||
def _active_key(request: InstallRequestSchema) -> str:
|
||||
return f"install:active:{request.user_id}:{request.device_id}:{request.rom_id}"
|
||||
|
||||
|
||||
class DeviceInstallHandler:
|
||||
def __init__(self, ttl_days: int = DEVICE_INSTALL_REQUEST_TTL_DAYS) -> None:
|
||||
self.ttl_seconds: int | None = ttl_days * 24 * 60 * 60 if ttl_days > 0 else None
|
||||
|
||||
async def create(
|
||||
self,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
rom_id: int,
|
||||
file_ids: list[int],
|
||||
) -> tuple[InstallRequestSchema, bool]:
|
||||
"""The live request for the rom on the device, and whether this call queued it.
|
||||
|
||||
Raises:
|
||||
InstallTransitionError: Concurrent creates kept colliding.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
request = InstallRequestSchema(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
rom_id=rom_id,
|
||||
file_ids=file_ids,
|
||||
status=InstallStatus.PENDING,
|
||||
reason=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
active_key = _active_key(request)
|
||||
async with async_cache.pipeline(transaction=True) as pipe:
|
||||
for _ in range(TRANSACTION_ATTEMPTS):
|
||||
try:
|
||||
await pipe.watch(active_key)
|
||||
active_id = await pipe.get(active_key)
|
||||
if active_id is not None:
|
||||
[existing] = await self._read(pipe, [as_text(active_id)])
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
|
||||
pipe.multi()
|
||||
self._stage_write(pipe, request, owns_active_key=True)
|
||||
await pipe.execute()
|
||||
return request, True
|
||||
except WatchError:
|
||||
continue
|
||||
raise InstallTransitionError(
|
||||
"Another request for this rom was queued at the same time"
|
||||
)
|
||||
|
||||
async def get(self, request_id: str) -> InstallRequestSchema | None:
|
||||
[request] = await self._read(async_cache, [request_id])
|
||||
return request
|
||||
|
||||
async def list_for_device(self, device_id: str) -> list[InstallRequestSchema]:
|
||||
"""The device's live requests, oldest first."""
|
||||
return await self._load(_device_key(device_id))
|
||||
|
||||
async def list_for_rom(
|
||||
self, user_id: int, rom_id: int
|
||||
) -> list[InstallRequestSchema]:
|
||||
"""Every live request the user made for a rom, oldest first."""
|
||||
return await self._load(_rom_key(user_id, rom_id))
|
||||
|
||||
async def claim(self, device_id: str) -> InstallClaim:
|
||||
"""Take every pending request of the device, returning all it holds taken.
|
||||
|
||||
Raises:
|
||||
InstallTransitionError: Other writers kept changing the requests.
|
||||
"""
|
||||
device_key = _device_key(device_id)
|
||||
async with async_cache.pipeline(transaction=True) as pipe:
|
||||
for _ in range(TRANSACTION_ATTEMPTS):
|
||||
try:
|
||||
await pipe.watch(device_key)
|
||||
ids = [as_text(i) for i in await pipe.smembers(device_key)]
|
||||
if ids:
|
||||
await pipe.watch(*[_request_key(i) for i in ids])
|
||||
stored = await self._read(pipe, ids)
|
||||
gone = [
|
||||
request_id
|
||||
for request_id, request in zip(ids, stored, strict=True)
|
||||
if request is None
|
||||
]
|
||||
live = [request for request in stored if request is not None]
|
||||
already_taken = [
|
||||
request
|
||||
for request in live
|
||||
if request.status == InstallStatus.TAKEN
|
||||
]
|
||||
pending = [
|
||||
request
|
||||
for request in live
|
||||
if request.status == InstallStatus.PENDING
|
||||
]
|
||||
owned = (
|
||||
await self._owns_active_keys(pipe, pending) if pending else []
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
taken = [
|
||||
request.model_copy(
|
||||
update={"status": InstallStatus.TAKEN, "updated_at": now}
|
||||
)
|
||||
for request in pending
|
||||
]
|
||||
if gone or taken:
|
||||
pipe.multi()
|
||||
if gone:
|
||||
pipe.srem(device_key, *gone)
|
||||
for request, owns_active_key in zip(taken, owned, strict=True):
|
||||
self._stage_write(pipe, request, owns_active_key)
|
||||
await pipe.execute()
|
||||
return _claim(already_taken, taken)
|
||||
except WatchError:
|
||||
continue
|
||||
raise InstallTransitionError(
|
||||
"The device's requests kept changing while they were being claimed"
|
||||
)
|
||||
|
||||
async def report(
|
||||
self, request_id: str, outcome: InstallStatus, reason: str | None
|
||||
) -> InstallRequestSchema:
|
||||
"""End a taken request with the device's outcome, returning it as it ended.
|
||||
|
||||
Raises:
|
||||
KeyError: The request is gone.
|
||||
InstallTransitionError: The request is not taken.
|
||||
"""
|
||||
return await self._end(request_id, outcome, REPORTABLE_FROM, reason)
|
||||
|
||||
async def cancel(self, request_id: str) -> InstallRequestSchema:
|
||||
"""End a pending or taken request as cancelled, returning it as it ended.
|
||||
|
||||
Raises:
|
||||
KeyError: The request is gone.
|
||||
InstallTransitionError: The request kept changing.
|
||||
"""
|
||||
return await self._end(
|
||||
request_id, InstallStatus.CANCELLED, CANCELLABLE_FROM, None
|
||||
)
|
||||
|
||||
async def discard_for_device(self, device_id: str) -> None:
|
||||
"""Drop a deleted device's live requests and their index, logging a failure."""
|
||||
try:
|
||||
requests = await self.list_for_device(device_id)
|
||||
async with async_cache.pipeline(transaction=True) as pipe:
|
||||
for request in requests:
|
||||
self._stage_delete(pipe, request, owns_active_key=True)
|
||||
pipe.delete(_device_key(device_id))
|
||||
await pipe.execute()
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning(
|
||||
f"Failed to drop the install requests of device {device_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _end(
|
||||
self,
|
||||
request_id: str,
|
||||
status: InstallStatus,
|
||||
from_statuses: frozenset[InstallStatus],
|
||||
reason: str | None,
|
||||
) -> InstallRequestSchema:
|
||||
key = _request_key(request_id)
|
||||
async with async_cache.pipeline(transaction=True) as pipe:
|
||||
for _ in range(TRANSACTION_ATTEMPTS):
|
||||
try:
|
||||
await pipe.watch(key)
|
||||
[current] = await self._read(pipe, [request_id])
|
||||
if current is None:
|
||||
raise KeyError(request_id)
|
||||
if current.status not in from_statuses:
|
||||
raise InstallTransitionError(
|
||||
f"Cannot move a {current.status} request to {status}"
|
||||
)
|
||||
|
||||
[owns_active_key] = await self._owns_active_keys(pipe, [current])
|
||||
pipe.multi()
|
||||
self._stage_delete(pipe, current, owns_active_key)
|
||||
await pipe.execute()
|
||||
return current.model_copy(
|
||||
update={
|
||||
"status": status,
|
||||
"reason": reason,
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
except WatchError:
|
||||
continue
|
||||
raise InstallTransitionError("The request kept changing while it was ended")
|
||||
|
||||
async def _read(
|
||||
self, client: Redis[str], request_ids: list[str]
|
||||
) -> list[InstallRequestSchema | None]:
|
||||
"""The stored requests in ``request_ids`` order, None where one is gone."""
|
||||
if not request_ids:
|
||||
return []
|
||||
stored = await client.mget([_request_key(i) for i in request_ids])
|
||||
return [
|
||||
InstallRequestSchema.model_validate_json(raw) if raw else None
|
||||
for raw in stored
|
||||
]
|
||||
|
||||
async def _load(self, set_key: str) -> list[InstallRequestSchema]:
|
||||
ids = [as_text(i) for i in await async_cache.smembers(set_key)]
|
||||
stored = await self._read(async_cache, ids)
|
||||
gone = [
|
||||
request_id
|
||||
for request_id, request in zip(ids, stored, strict=True)
|
||||
if request is None
|
||||
]
|
||||
if gone:
|
||||
await async_cache.srem(set_key, *gone)
|
||||
return _oldest_first([request for request in stored if request is not None])
|
||||
|
||||
async def _owns_active_keys(
|
||||
self, pipe: Pipeline[str], requests: list[InstallRequestSchema]
|
||||
) -> list[bool]:
|
||||
"""Whether each rom's dedupe key still names its request, watching the keys."""
|
||||
active_keys = [_active_key(request) for request in requests]
|
||||
await pipe.watch(*active_keys)
|
||||
active_ids = await pipe.mget(active_keys)
|
||||
return [
|
||||
active_id is not None and as_text(active_id) == request.id
|
||||
for request, active_id in zip(requests, active_ids, strict=True)
|
||||
]
|
||||
|
||||
def _stage_write(
|
||||
self, pipe: Pipeline[str], request: InstallRequestSchema, owns_active_key: bool
|
||||
) -> None:
|
||||
"""Queue the request's write; each index lives as long as its newest request."""
|
||||
device_key = _device_key(request.device_id)
|
||||
rom_key = _rom_key(request.user_id, request.rom_id)
|
||||
pipe.set(
|
||||
_request_key(request.id), request.model_dump_json(), ex=self.ttl_seconds
|
||||
)
|
||||
pipe.sadd(device_key, request.id)
|
||||
pipe.sadd(rom_key, request.id)
|
||||
if owns_active_key:
|
||||
pipe.set(_active_key(request), request.id, ex=self.ttl_seconds)
|
||||
if self.ttl_seconds is not None:
|
||||
pipe.expire(device_key, self.ttl_seconds)
|
||||
pipe.expire(rom_key, self.ttl_seconds)
|
||||
|
||||
def _stage_delete(
|
||||
self, pipe: Pipeline[str], request: InstallRequestSchema, owns_active_key: bool
|
||||
) -> None:
|
||||
pipe.delete(_request_key(request.id))
|
||||
pipe.srem(_device_key(request.device_id), request.id)
|
||||
pipe.srem(_rom_key(request.user_id, request.rom_id), request.id)
|
||||
if owns_active_key:
|
||||
pipe.delete(_active_key(request))
|
||||
|
||||
|
||||
device_install_handler = DeviceInstallHandler()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Which roms, and which of their files, a user may push to a device."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Final
|
||||
|
||||
from config import DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS
|
||||
from models.rom import RomFile, RomFileCategory
|
||||
|
||||
# None is an uncategorized root file. Documents reach a device through its own
|
||||
# manual/walkthrough download.
|
||||
INSTALLABLE_FILE_CATEGORIES: Final = frozenset(
|
||||
{None, RomFileCategory.GAME, RomFileCategory.UPDATE, RomFileCategory.DLC}
|
||||
)
|
||||
REMOTE_INSTALL_CAPABILITY: Final = "remote_install"
|
||||
|
||||
|
||||
class InstallNotAllowedError(Exception):
|
||||
"""The rom has nothing that can be pushed to a device."""
|
||||
|
||||
|
||||
def is_installable_platform(platform_slug: str) -> bool:
|
||||
return platform_slug.lower() not in DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS
|
||||
|
||||
|
||||
def accepts_remote_install(capabilities: dict[str, bool] | None) -> bool:
|
||||
return (capabilities or {}).get(REMOTE_INSTALL_CAPABILITY) is True
|
||||
|
||||
|
||||
def select_install_files(files: Sequence[RomFile]) -> list[int]:
|
||||
"""The ids of the rom's game, update and DLC files on disk, ascending.
|
||||
|
||||
Raises:
|
||||
InstallNotAllowedError: None of the rom's files qualify.
|
||||
"""
|
||||
selected = sorted(
|
||||
f.id
|
||||
for f in files
|
||||
if f.category in INSTALLABLE_FILE_CATEGORIES and not f.missing_from_fs
|
||||
)
|
||||
if not selected:
|
||||
raise InstallNotAllowedError("This rom has no installable files")
|
||||
return selected
|
||||
@@ -3,6 +3,7 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from endpoints.responses.device_install import InstallStatus
|
||||
from endpoints.responses.notification import NotificationSchema
|
||||
from models.notification import NotificationKind
|
||||
from utils.urls import get_public_base_url
|
||||
@@ -78,6 +79,24 @@ def _describe(n: NotificationSchema) -> tuple[str, str | None, str | None]:
|
||||
_text(data.get("error")),
|
||||
"/notifications?tab=channels",
|
||||
)
|
||||
case (
|
||||
NotificationKind.DEVICE_INSTALL_COMPLETED
|
||||
| NotificationKind.DEVICE_INSTALL_FAILED
|
||||
):
|
||||
game = _text(data.get("rom_name")) or "A game"
|
||||
device = _text(data.get("device_name")) or "your device"
|
||||
rom_id = _count(data.get("rom_id"))
|
||||
if n.kind == NotificationKind.DEVICE_INSTALL_FAILED:
|
||||
title = f"{game} could not be installed on {device}"
|
||||
elif data.get("status") == InstallStatus.ALREADY_INSTALLED:
|
||||
title = f"{game} is already installed on {device}"
|
||||
else:
|
||||
title = f"{game} was added to downloads on {device}"
|
||||
return (
|
||||
title,
|
||||
_text(data.get("reason")),
|
||||
f"/rom/{rom_id}" if rom_id else None,
|
||||
)
|
||||
case _:
|
||||
return n.title or "New notification", n.body, n.link
|
||||
|
||||
|
||||
@@ -101,6 +101,11 @@ def __get_async_binary_cache() -> AsyncRedis[bytes]:
|
||||
async_binary_cache = __get_async_binary_cache()
|
||||
|
||||
|
||||
def as_text(value: bytes | str) -> str:
|
||||
"""A cached value as text, since the fake caches return bytes where Redis decodes."""
|
||||
return value.decode() if isinstance(value, bytes) else value
|
||||
|
||||
|
||||
def get_job_func_name(job: Job, fallback: str = "") -> str:
|
||||
"""Safely get the function name from an RQ job, handling DeserializationError.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Final
|
||||
import socketio
|
||||
|
||||
from config import REDIS_URL, SESSION_MAX_AGE_SECONDS
|
||||
from handler.redis_handler import async_cache
|
||||
from handler.redis_handler import as_text, async_cache
|
||||
from logger.logger import log
|
||||
from utils import json_module
|
||||
|
||||
@@ -14,6 +14,11 @@ if TYPE_CHECKING:
|
||||
|
||||
# Where a socket's own session records the login session that opened it.
|
||||
LOGIN_SESSION_ID_KEY: Final = "login_session_id"
|
||||
DEVICES_NAMESPACE: Final = "/devices"
|
||||
|
||||
|
||||
def client_token_sockets_key(token_id: int) -> str:
|
||||
return f"device_token_sockets:{token_id}"
|
||||
|
||||
|
||||
class SocketHandler:
|
||||
@@ -37,11 +42,13 @@ class SocketHandler:
|
||||
self._write_manager: socketio.AsyncRedisManager | None = None
|
||||
self._write_manager_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def on[F: Callable[..., Awaitable[object]]](self, event: str) -> Callable[[F], F]:
|
||||
def on[F: Callable[..., Awaitable[object]]](
|
||||
self, event: str, namespace: str | None = None
|
||||
) -> Callable[[F], F]:
|
||||
"""Register a socket event handler without changing its signature."""
|
||||
|
||||
def register(handler: F) -> F:
|
||||
self.socket_server.on(event, handler)
|
||||
self.socket_server.on(event, handler, namespace=namespace)
|
||||
return handler
|
||||
|
||||
return register
|
||||
@@ -72,10 +79,12 @@ class SocketHandler:
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning(f"Failed to push {event} to user {user_id}", exc_info=True)
|
||||
|
||||
async def get_session(self, sid: str) -> dict[str, Any]:
|
||||
async def get_session(
|
||||
self, sid: str, namespace: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""The socket's server-side session, empty once the socket is gone."""
|
||||
try:
|
||||
return await self.socket_server.get_session(sid) or {}
|
||||
return await self.socket_server.get_session(sid, namespace=namespace) or {}
|
||||
except KeyError:
|
||||
return {}
|
||||
|
||||
@@ -99,11 +108,38 @@ class SocketHandler:
|
||||
await self.bind_to_login_session(sid, session_id)
|
||||
return user
|
||||
|
||||
async def track(self, key: str, sid: str, ttl_seconds: int) -> None:
|
||||
"""Add a socket to the set under ``key`` and restart its expiry."""
|
||||
await async_cache.sadd(key, sid)
|
||||
await async_cache.expire(key, ttl_seconds)
|
||||
|
||||
async def untrack(self, key: str, sid: str) -> None:
|
||||
"""Drop a socket from the set under ``key``, logging a failure."""
|
||||
try:
|
||||
await async_cache.srem(key, sid)
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning(f"Failed to forget socket {sid} under {key}", exc_info=True)
|
||||
|
||||
async def close_tracked(self, key: str, namespace: str | None = None) -> None:
|
||||
"""Disconnect every socket tracked under ``key`` on any worker, logging a failure."""
|
||||
try:
|
||||
sids = await async_cache.smembers(key)
|
||||
await async_cache.delete(key)
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning(f"Failed to close the sockets under {key}", exc_info=True)
|
||||
return
|
||||
for sid in sids:
|
||||
try:
|
||||
# A socket on another worker is disconnected through the broker.
|
||||
await self.socket_server.disconnect(as_text(sid), namespace=namespace)
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning(f"Failed to close socket {as_text(sid)}", exc_info=True)
|
||||
|
||||
async def bind_to_login_session(self, sid: str, session_id: str) -> None:
|
||||
"""Record which login session opened a socket, so revoking it closes the socket."""
|
||||
key = self._login_session_sockets_key(session_id)
|
||||
await async_cache.sadd(key, sid)
|
||||
await async_cache.expire(key, SESSION_MAX_AGE_SECONDS)
|
||||
await self.track(
|
||||
self._login_session_sockets_key(session_id), sid, SESSION_MAX_AGE_SECONDS
|
||||
)
|
||||
async with self.socket_server.session(sid) as session:
|
||||
session[LOGIN_SESSION_ID_KEY] = session_id
|
||||
|
||||
@@ -112,7 +148,7 @@ class SocketHandler:
|
||||
try:
|
||||
session_id = (await self.get_session(sid)).get(LOGIN_SESSION_ID_KEY)
|
||||
if session_id:
|
||||
await async_cache.srem(self._login_session_sockets_key(session_id), sid)
|
||||
await self.untrack(self._login_session_sockets_key(session_id), sid)
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning(f"Failed to unbind socket {sid}", exc_info=True)
|
||||
|
||||
@@ -123,20 +159,7 @@ class SocketHandler:
|
||||
session would otherwise go on receiving its user's and the admins' events.
|
||||
"""
|
||||
for session_id in session_ids:
|
||||
key = self._login_session_sockets_key(session_id)
|
||||
try:
|
||||
sids = await async_cache.smembers(key)
|
||||
await async_cache.delete(key)
|
||||
for sid in sids:
|
||||
# A socket on another worker is disconnected through the broker.
|
||||
await self.socket_server.disconnect(
|
||||
sid.decode() if isinstance(sid, bytes) else sid
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning(
|
||||
f"Failed to close the sockets of session {session_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
await self.close_tracked(self._login_session_sockets_key(session_id))
|
||||
|
||||
|
||||
socket_handler = SocketHandler(path="/ws/socket.io")
|
||||
@@ -152,3 +175,11 @@ async def close_login_session_sockets(session_ids: Iterable[str]) -> None:
|
||||
socket_handler.close_login_sessions(ids),
|
||||
netplay_socket_handler.close_login_sessions(ids),
|
||||
)
|
||||
|
||||
|
||||
async def close_client_token_sockets(token_ids: Iterable[int]) -> None:
|
||||
"""Disconnect every device socket the given client tokens opened, on any worker."""
|
||||
for token_id in token_ids:
|
||||
await socket_handler.close_tracked(
|
||||
client_token_sockets_key(token_id), DEVICES_NAMESPACE
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from startup import main
|
||||
|
||||
import endpoints.sockets.activity # noqa
|
||||
import endpoints.sockets.devices # noqa
|
||||
import endpoints.sockets.logs # noqa
|
||||
import endpoints.sockets.netplay # noqa
|
||||
import endpoints.sockets.scan # noqa
|
||||
@@ -38,6 +39,7 @@ from endpoints.collections import router as collections_router
|
||||
from endpoints.configs import router as configs_router
|
||||
from endpoints.device import router as device_router
|
||||
from endpoints.device_auth import router as device_auth_router
|
||||
from endpoints.device_install import router as device_install_router
|
||||
from endpoints.export import router as export_router
|
||||
from endpoints.feeds import router as feeds_router
|
||||
from endpoints.firmware import router as firmware_router
|
||||
@@ -197,6 +199,7 @@ app.include_router(notifications_router, prefix="/api")
|
||||
app.include_router(notification_channels_router, prefix="/api")
|
||||
app.include_router(device_router, prefix="/api")
|
||||
app.include_router(device_auth_router, prefix="/api")
|
||||
app.include_router(device_install_router, prefix="/api")
|
||||
app.include_router(play_sessions_router, prefix="/api")
|
||||
app.include_router(platform_router, prefix="/api")
|
||||
app.include_router(rom_router, prefix="/api")
|
||||
|
||||
@@ -73,6 +73,7 @@ class Device(BaseModel):
|
||||
sync_mode: Mapped[SyncMode] = mapped_column(Enum(SyncMode), default=SyncMode.API)
|
||||
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
sync_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
capabilities: Mapped[dict[str, bool] | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
last_seen: Mapped[datetime | None] = mapped_column(
|
||||
TIMESTAMP(timezone=True), index=True
|
||||
|
||||
@@ -32,6 +32,8 @@ class NotificationKind(enum.StrEnum):
|
||||
STREAMING_SESSION_ENDED = "streaming_session_ended"
|
||||
ROLE_CHANGED = "role_changed"
|
||||
CHANNEL_DISABLED = "channel_disabled"
|
||||
DEVICE_INSTALL_COMPLETED = "device_install_completed"
|
||||
DEVICE_INSTALL_FAILED = "device_install_failed"
|
||||
|
||||
|
||||
class NotificationTopic(enum.StrEnum):
|
||||
@@ -40,6 +42,7 @@ class NotificationTopic(enum.StrEnum):
|
||||
SCANS = "scans"
|
||||
TASKS = "tasks"
|
||||
STREAMING = "streaming"
|
||||
DEVICES = "devices"
|
||||
ACCOUNT = "account"
|
||||
CUSTOM = "custom"
|
||||
|
||||
@@ -52,6 +55,8 @@ _KIND_TOPICS: Final[dict[str, NotificationTopic]] = {
|
||||
NotificationKind.STREAMING_SESSION_ENDED: NotificationTopic.STREAMING,
|
||||
NotificationKind.ROLE_CHANGED: NotificationTopic.ACCOUNT,
|
||||
NotificationKind.CHANNEL_DISABLED: NotificationTopic.ACCOUNT,
|
||||
NotificationKind.DEVICE_INSTALL_COMPLETED: NotificationTopic.DEVICES,
|
||||
NotificationKind.DEVICE_INSTALL_FAILED: NotificationTopic.DEVICES,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -613,6 +613,14 @@ class RomVisibilityLabel(NamedTuple):
|
||||
fs_name: str
|
||||
|
||||
|
||||
class RomInstallTarget(NamedTuple):
|
||||
"""`RomVisibility` plus the platform slug a device install request is checked against."""
|
||||
|
||||
id: int
|
||||
platform_id: int
|
||||
platform_slug: str
|
||||
|
||||
|
||||
class RomDeletionTarget(NamedTuple):
|
||||
"""The columns the bulk-delete route reads off one rom, and no relations."""
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import functools
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
@@ -22,6 +23,7 @@ from handler.auth import auth_handler
|
||||
from handler.auth.base_handler import oct_key
|
||||
from handler.auth.constants import ALGORITHM
|
||||
from handler.database import (
|
||||
db_client_token_handler,
|
||||
db_firmware_handler,
|
||||
db_memory_card_handler,
|
||||
db_permission_handler,
|
||||
@@ -525,6 +527,36 @@ def viewer_user():
|
||||
return db_user_handler.add_user(user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def add_device_token():
|
||||
"""Factory for a client token, bound to ``device_id`` unless it is None.
|
||||
|
||||
Returns the stored token and the raw ``rmm_`` credential a client sends.
|
||||
"""
|
||||
|
||||
def _add(
|
||||
user: User,
|
||||
device_id: str | None,
|
||||
*,
|
||||
scopes: str = "devices.read devices.write roms.read",
|
||||
expires_at: datetime | None = None,
|
||||
) -> tuple[ClientToken, str]:
|
||||
raw_token = f"rmm_test_{uuid.uuid4().hex}"
|
||||
token = db_client_token_handler.add_token(
|
||||
ClientToken(
|
||||
user_id=user.id,
|
||||
name="Handheld",
|
||||
hashed_token=auth_handler.hash_client_token(raw_token),
|
||||
scopes=scopes,
|
||||
expires_at=expires_at,
|
||||
device_id=device_id,
|
||||
)
|
||||
)
|
||||
return token, raw_token
|
||||
|
||||
return _add
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_refresh_token(admin_user: User) -> str:
|
||||
expire = int((datetime.now(timezone.utc) + timedelta(seconds=-1)).timestamp())
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
from endpoints.responses.device_install import InstallRequestSchema
|
||||
from endpoints.roms import installs
|
||||
from handler.database.base_handler import sync_session
|
||||
from handler.device_install_handler import device_install_handler
|
||||
from models.permission import HiddenEntity, PermEntity
|
||||
from models.rom import Rom
|
||||
from models.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def headers(access_token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor_headers(editor_access_token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {editor_access_token}"}
|
||||
|
||||
|
||||
def _queue(user: User, rom: Rom, device_id: str) -> InstallRequestSchema:
|
||||
request, _ = asyncio.run(
|
||||
device_install_handler.create(
|
||||
user_id=user.id, device_id=device_id, rom_id=rom.id, file_ids=[1]
|
||||
)
|
||||
)
|
||||
return request
|
||||
|
||||
|
||||
def _hide_rom(rom: Rom, user: User) -> None:
|
||||
with sync_session.begin() as s:
|
||||
s.add(HiddenEntity(entity=PermEntity.ROMS, entity_id=rom.id, user_id=user.id))
|
||||
|
||||
|
||||
class TestRomInstalls:
|
||||
def test_lists_the_callers_requests_for_the_rom(
|
||||
self, client, headers, admin_user, rom
|
||||
):
|
||||
first = _queue(admin_user, rom, "dev-1")
|
||||
second = _queue(admin_user, rom, "dev-2")
|
||||
|
||||
response = client.get(f"/api/roms/{rom.id}/installs", headers=headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [r["id"] for r in response.json()] == [first.id, second.id]
|
||||
|
||||
def test_another_users_requests_are_not_listed(
|
||||
self, client, editor_headers, admin_user, rom
|
||||
):
|
||||
_queue(admin_user, rom, "dev-1")
|
||||
|
||||
response = client.get(f"/api/roms/{rom.id}/installs", headers=editor_headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
def test_an_ended_request_is_not_listed(self, client, headers, admin_user, rom):
|
||||
request = _queue(admin_user, rom, "dev-1")
|
||||
asyncio.run(device_install_handler.cancel(request.id))
|
||||
|
||||
response = client.get(f"/api/roms/{rom.id}/installs", headers=headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
def test_a_hidden_rom_is_not_found(self, client, editor_headers, editor_user, rom):
|
||||
_hide_rom(rom, editor_user)
|
||||
|
||||
response = client.get(f"/api/roms/{rom.id}/installs", headers=editor_headers)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_is_not_found_while_disabled(self, mocker, client, headers, rom):
|
||||
mocker.patch.object(installs, "DEVICE_INSTALL_ENABLED", False)
|
||||
|
||||
response = client.get(f"/api/roms/{rom.id}/installs", headers=headers)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -0,0 +1,415 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from socketio import exceptions as socketio_exceptions
|
||||
|
||||
from endpoints.responses.device_install import InstallRequestSchema, InstallStatus
|
||||
from endpoints.sockets import devices
|
||||
from handler.auth.hybrid_auth import resolve_client_token
|
||||
from handler.database import db_client_token_handler, db_device_handler
|
||||
from handler.redis_handler import as_text, async_cache, sync_cache
|
||||
from handler.socket_handler import DEVICES_NAMESPACE, socket_handler
|
||||
from models.client_token import ClientToken
|
||||
from models.device import Device
|
||||
from models.user import User
|
||||
|
||||
NAMESPACE = DEVICES_NAMESPACE
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
sync_cache.flushall()
|
||||
yield
|
||||
sync_cache.flushall()
|
||||
|
||||
|
||||
async def _members(key: str) -> set[str]:
|
||||
return {as_text(member) for member in await async_cache.smembers(key)}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def socket_session(mocker) -> dict[str, Any]:
|
||||
stored: dict[str, Any] = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(sid: str, namespace: str | None = None):
|
||||
yield stored
|
||||
|
||||
mocker.patch.object(socket_handler.socket_server, "session", session)
|
||||
return stored
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def background(mocker) -> MagicMock:
|
||||
start = MagicMock()
|
||||
mocker.patch.object(socket_handler.socket_server, "start_background_task", start)
|
||||
return start
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enter_room(mocker, socket_session, background):
|
||||
return mocker.patch.object(socket_handler.socket_server, "enter_room", AsyncMock())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_token(add_device_token):
|
||||
"""A token for a new ``dev-socket`` device, or an unbound one."""
|
||||
|
||||
def _make(
|
||||
user: User, *, device_bound: bool = True, **kwargs
|
||||
) -> tuple[ClientToken, str]:
|
||||
device_id = None
|
||||
if device_bound:
|
||||
device_id = db_device_handler.add_device(
|
||||
Device(id="dev-socket", user_id=user.id, name="Handheld")
|
||||
).id
|
||||
token: tuple[ClientToken, str] = add_device_token(user, device_id, **kwargs)
|
||||
return token
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
class TestConnect:
|
||||
async def test_joins_the_devices_room_from_the_auth_payload(
|
||||
self, enter_room, socket_session, admin_user, device_token
|
||||
):
|
||||
token, raw = device_token(admin_user)
|
||||
|
||||
await devices.connect("sid-1", {}, {"token": raw})
|
||||
|
||||
enter_room.assert_awaited_once_with(
|
||||
"sid-1", "device:dev-socket", namespace=NAMESPACE
|
||||
)
|
||||
assert socket_session == {
|
||||
devices.CLIENT_TOKEN_ID_KEY: token.id,
|
||||
devices.DEVICE_ID_KEY: "dev-socket",
|
||||
}
|
||||
|
||||
async def test_accepts_a_bearer_header(self, enter_room, admin_user, device_token):
|
||||
_, raw = device_token(admin_user)
|
||||
|
||||
await devices.connect("sid-1", {"HTTP_AUTHORIZATION": f"Bearer {raw}"}, None)
|
||||
|
||||
enter_room.assert_awaited_once()
|
||||
|
||||
async def test_tracks_the_socket_for_revocation_and_presence(
|
||||
self, enter_room, admin_user, device_token
|
||||
):
|
||||
token, raw = device_token(admin_user)
|
||||
|
||||
await devices.connect("sid-1", {}, {"token": raw})
|
||||
|
||||
token_key = f"device_token_sockets:{token.id}"
|
||||
presence_key = "device_presence:dev-socket"
|
||||
assert await _members(token_key) == {"sid-1"}
|
||||
assert await _members(presence_key) == {"sid-1"}
|
||||
assert (
|
||||
0 < await async_cache.ttl(token_key) <= devices.SOCKET_TRACKING_TTL_SECONDS
|
||||
)
|
||||
assert 0 < await async_cache.ttl(presence_key) <= devices.PRESENCE_TTL_SECONDS
|
||||
|
||||
async def test_refuses_a_token_revoked_before_the_socket_was_tracked(
|
||||
self, mocker, enter_room, admin_user, device_token
|
||||
):
|
||||
token, raw = device_token(admin_user)
|
||||
mocker.patch.object(
|
||||
devices,
|
||||
"resolve_client_token",
|
||||
side_effect=[resolve_client_token(raw), None],
|
||||
)
|
||||
|
||||
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
|
||||
await devices.connect("sid-1", {}, {"token": raw})
|
||||
|
||||
assert not await async_cache.exists(f"device_token_sockets:{token.id}")
|
||||
assert await devices.online_device_ids(["dev-socket"]) == []
|
||||
enter_room.assert_not_awaited()
|
||||
|
||||
async def test_keeps_the_tracking_fresh_while_connected(
|
||||
self, enter_room, background, admin_user, device_token
|
||||
):
|
||||
token, raw = device_token(admin_user)
|
||||
|
||||
await devices.connect("sid-1", {}, {"token": raw})
|
||||
|
||||
background.assert_called_once_with(
|
||||
devices._keep_tracked,
|
||||
"sid-1",
|
||||
devices.BoundToken(token.id, "dev-socket", None),
|
||||
)
|
||||
|
||||
async def test_bumps_the_tokens_last_use(
|
||||
self, enter_room, admin_user, device_token
|
||||
):
|
||||
token, raw = device_token(admin_user)
|
||||
|
||||
await devices.connect("sid-1", {}, {"token": raw})
|
||||
|
||||
[stored] = db_client_token_handler.get_tokens_by_user(admin_user.id)
|
||||
assert stored.id == token.id
|
||||
assert stored.last_used_at is not None
|
||||
|
||||
async def test_never_writes_the_devices_capabilities(
|
||||
self, enter_room, admin_user, device_token
|
||||
):
|
||||
_, raw = device_token(admin_user)
|
||||
|
||||
await devices.connect(
|
||||
"sid-1", {}, {"token": raw, "capabilities": {"remote_install": True}}
|
||||
)
|
||||
|
||||
stored = db_device_handler.get_device(
|
||||
device_id="dev-socket", user_id=admin_user.id
|
||||
)
|
||||
assert stored is not None
|
||||
assert stored.capabilities is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token_kwargs",
|
||||
[
|
||||
{"device_bound": False},
|
||||
{"scopes": "roms.read"},
|
||||
{"expires_at": datetime.now(timezone.utc) - timedelta(minutes=1)},
|
||||
],
|
||||
)
|
||||
async def test_refuses_a_token_that_does_not_speak_for_a_device(
|
||||
self, enter_room, admin_user, device_token, token_kwargs
|
||||
):
|
||||
_, raw = device_token(admin_user, **token_kwargs)
|
||||
|
||||
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
|
||||
await devices.connect("sid-1", {}, {"token": raw})
|
||||
|
||||
enter_room.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize("auth", [None, {"token": "rmm_unknown"}, {"token": "x"}])
|
||||
async def test_refuses_a_missing_or_unknown_token(self, enter_room, auth):
|
||||
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
|
||||
await devices.connect("sid-1", {}, auth)
|
||||
|
||||
enter_room.assert_not_awaited()
|
||||
|
||||
async def test_never_accepts_a_browser_session(self, enter_room):
|
||||
environ = {"HTTP_COOKIE": "romm_session=abc"}
|
||||
|
||||
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
|
||||
await devices.connect("sid-1", environ, None)
|
||||
|
||||
async def test_refuses_every_device_while_install_is_disabled(
|
||||
self, mocker, enter_room, admin_user, device_token
|
||||
):
|
||||
_, raw = device_token(admin_user)
|
||||
mocker.patch.object(devices, "DEVICE_INSTALL_ENABLED", False)
|
||||
|
||||
with pytest.raises(socketio_exceptions.ConnectionRefusedError):
|
||||
await devices.connect("sid-1", {}, {"token": raw})
|
||||
|
||||
enter_room.assert_not_awaited()
|
||||
|
||||
|
||||
BOUND = devices.BoundToken(42, "dev-1", None)
|
||||
|
||||
|
||||
class TestKeepTracked:
|
||||
@pytest.fixture(autouse=True)
|
||||
def sleep(self, mocker) -> AsyncMock:
|
||||
sleep = AsyncMock()
|
||||
mocker.patch.object(socket_handler.socket_server, "sleep", sleep)
|
||||
return sleep
|
||||
|
||||
async def test_refreshes_while_the_socket_is_connected(self, mocker):
|
||||
mocker.patch.object(
|
||||
socket_handler.socket_server.manager,
|
||||
"is_connected",
|
||||
side_effect=[True, False],
|
||||
)
|
||||
|
||||
await devices._keep_tracked("sid-1", BOUND)
|
||||
|
||||
assert await _members("device_presence:dev-1") == {"sid-1"}
|
||||
assert 0 < await async_cache.ttl("device_presence:dev-1")
|
||||
|
||||
async def test_keeps_a_long_lived_socket_revocable(self, mocker):
|
||||
mocker.patch.object(
|
||||
socket_handler.socket_server.manager,
|
||||
"is_connected",
|
||||
side_effect=[True, False],
|
||||
)
|
||||
await async_cache.sadd("device_token_sockets:42", "sid-1")
|
||||
await async_cache.expire("device_token_sockets:42", 5)
|
||||
|
||||
await devices._keep_tracked("sid-1", BOUND)
|
||||
|
||||
assert await _members("device_token_sockets:42") == {"sid-1"}
|
||||
assert (
|
||||
await async_cache.ttl("device_token_sockets:42")
|
||||
> devices.SOCKET_TRACKING_TTL_SECONDS - 60
|
||||
)
|
||||
|
||||
async def test_stops_once_the_socket_is_gone(self, mocker):
|
||||
mocker.patch.object(
|
||||
socket_handler.socket_server.manager, "is_connected", return_value=False
|
||||
)
|
||||
|
||||
await devices._keep_tracked("sid-1", BOUND)
|
||||
|
||||
assert not await async_cache.exists("device_presence:dev-1")
|
||||
assert not await async_cache.exists("device_token_sockets:42")
|
||||
|
||||
async def test_closes_the_socket_once_its_token_expires(self, mocker):
|
||||
mocker.patch.object(
|
||||
socket_handler.socket_server.manager,
|
||||
"is_connected",
|
||||
side_effect=[True, False],
|
||||
)
|
||||
disconnect = mocker.patch.object(
|
||||
socket_handler.socket_server, "disconnect", AsyncMock()
|
||||
)
|
||||
expired = BOUND._replace(
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
)
|
||||
|
||||
await devices._keep_tracked("sid-1", expired)
|
||||
|
||||
disconnect.assert_awaited_once_with("sid-1", namespace=NAMESPACE)
|
||||
assert not await async_cache.exists("device_presence:dev-1")
|
||||
|
||||
async def test_keeps_a_socket_whose_token_has_not_expired(self, mocker):
|
||||
mocker.patch.object(
|
||||
socket_handler.socket_server.manager,
|
||||
"is_connected",
|
||||
side_effect=[True, False],
|
||||
)
|
||||
disconnect = mocker.patch.object(
|
||||
socket_handler.socket_server, "disconnect", AsyncMock()
|
||||
)
|
||||
later = BOUND._replace(
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=1)
|
||||
)
|
||||
|
||||
await devices._keep_tracked("sid-1", later)
|
||||
|
||||
disconnect.assert_not_awaited()
|
||||
assert await _members("device_presence:dev-1") == {"sid-1"}
|
||||
|
||||
|
||||
class TestEmitToDevice:
|
||||
async def test_cancelled_goes_to_the_devices_room(self, mocker):
|
||||
emit = AsyncMock()
|
||||
mocker.patch.object(
|
||||
socket_handler, "write_manager", return_value=MagicMock(emit=emit)
|
||||
)
|
||||
request = InstallRequestSchema(
|
||||
id="req-1",
|
||||
user_id=1,
|
||||
device_id="dev-1",
|
||||
rom_id=7,
|
||||
file_ids=[10],
|
||||
status=InstallStatus.CANCELLED,
|
||||
reason=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
await devices.emit_install_cancelled(request)
|
||||
|
||||
emit.assert_awaited_once_with(
|
||||
"install:cancelled",
|
||||
{"id": "req-1", "rom_id": 7},
|
||||
namespace=NAMESPACE,
|
||||
room="device:dev-1",
|
||||
)
|
||||
|
||||
|
||||
class TestDisconnect:
|
||||
async def test_forgets_the_socket(self, mocker):
|
||||
await async_cache.sadd("device_token_sockets:42", "sid-1", "sid-2")
|
||||
await async_cache.sadd("device_presence:dev-1", "sid-1")
|
||||
mocker.patch.object(
|
||||
socket_handler.socket_server,
|
||||
"get_session",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
devices.CLIENT_TOKEN_ID_KEY: 42,
|
||||
devices.DEVICE_ID_KEY: "dev-1",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
await devices.disconnect("sid-1")
|
||||
|
||||
assert await _members("device_token_sockets:42") == {"sid-2"}
|
||||
assert await devices.online_device_ids(["dev-1"]) == []
|
||||
|
||||
def test_is_registered_on_the_devices_namespace_only(self):
|
||||
handlers = socket_handler.socket_server.handlers
|
||||
assert handlers[NAMESPACE]["disconnect"] is devices.disconnect
|
||||
assert handlers[NAMESPACE]["connect"] is devices.connect
|
||||
assert handlers["/"]["connect"] is not devices.connect
|
||||
|
||||
|
||||
class TestPresence:
|
||||
async def test_a_device_with_a_fresh_presence_is_online(self):
|
||||
await async_cache.sadd("device_presence:dev-1", "sid-1")
|
||||
|
||||
assert await devices.online_device_ids(["dev-1", "dev-2"]) == ["dev-1"]
|
||||
|
||||
async def test_keeps_the_callers_order(self):
|
||||
await async_cache.sadd("device_presence:dev-1", "sid-1")
|
||||
await async_cache.sadd("device_presence:dev-2", "sid-2")
|
||||
|
||||
assert await devices.online_device_ids(["dev-2", "dev-1"]) == [
|
||||
"dev-2",
|
||||
"dev-1",
|
||||
]
|
||||
|
||||
async def test_a_revocation_entry_alone_is_not_presence(self):
|
||||
await async_cache.sadd("device_token_sockets:42", "sid-1")
|
||||
|
||||
assert await devices.online_device_ids(["dev-1"]) == []
|
||||
|
||||
async def test_no_devices_asks_nothing(self):
|
||||
assert await devices.online_device_ids([]) == []
|
||||
|
||||
|
||||
class TestEmit:
|
||||
@pytest.fixture
|
||||
def manager(self, mocker) -> MagicMock:
|
||||
manager = MagicMock(emit=AsyncMock())
|
||||
mocker.patch.object(socket_handler, "write_manager", return_value=manager)
|
||||
return manager
|
||||
|
||||
def _request(self) -> InstallRequestSchema:
|
||||
now = datetime.now(timezone.utc)
|
||||
return InstallRequestSchema(
|
||||
id="r1",
|
||||
user_id=5,
|
||||
device_id="dev-1",
|
||||
rom_id=7,
|
||||
file_ids=[1],
|
||||
status=InstallStatus.PENDING,
|
||||
reason=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
async def test_queued_reaches_only_the_devices_room_on_its_namespace(self, manager):
|
||||
await devices.emit_install_queued(self._request())
|
||||
|
||||
manager.emit.assert_awaited_once_with(
|
||||
"install:queued",
|
||||
{"id": "r1", "rom_id": 7},
|
||||
namespace=NAMESPACE,
|
||||
room="device:dev-1",
|
||||
)
|
||||
|
||||
async def test_updated_reaches_the_owners_tabs(self, manager):
|
||||
await devices.emit_install_updated(self._request())
|
||||
|
||||
event, payload = manager.emit.await_args.args
|
||||
assert event == "install:updated"
|
||||
assert payload["id"] == "r1"
|
||||
assert manager.emit.await_args.kwargs == {"room": "user:5"}
|
||||
@@ -73,7 +73,7 @@ def server(mocker) -> Mock:
|
||||
"""The netplay socket server, with the session store stubbed per socket."""
|
||||
sessions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def get_session(sid: str) -> dict[str, Any]:
|
||||
async def get_session(sid: str, namespace: str | None = None) -> dict[str, Any]:
|
||||
if sid not in sessions:
|
||||
raise KeyError(sid)
|
||||
return sessions[sid]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
from endpoints import client_tokens as client_tokens_endpoints
|
||||
from handler.auth import auth_handler, oauth_handler
|
||||
from handler.database import db_client_token_handler, db_user_handler
|
||||
from handler.redis_handler import sync_cache
|
||||
@@ -690,3 +692,73 @@ class TestClientTokenAdmin:
|
||||
headers={"Authorization": f"Bearer {editor_access_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
class TestRevocationClosesDeviceSockets:
|
||||
@pytest.fixture
|
||||
def close(self, mocker) -> AsyncMock:
|
||||
close = AsyncMock()
|
||||
mocker.patch.object(
|
||||
client_tokens_endpoints, "close_client_token_sockets", close
|
||||
)
|
||||
return close
|
||||
|
||||
@pytest.fixture
|
||||
def headers(self, access_token) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
@pytest.fixture
|
||||
def token_id(self, client, headers) -> int:
|
||||
response = client.post(
|
||||
"/api/client-tokens",
|
||||
json={"name": "Handheld", "scopes": ["devices.read"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
return int(response.json()["id"])
|
||||
|
||||
def test_deleting_a_token(self, client, headers, token_id, close):
|
||||
response = client.delete(f"/api/client-tokens/{token_id}", headers=headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
close.assert_awaited_once_with([token_id])
|
||||
|
||||
def test_regenerating_a_token(self, client, headers, token_id, close):
|
||||
response = client.put(
|
||||
f"/api/client-tokens/{token_id}/regenerate", headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
close.assert_awaited_once_with([token_id])
|
||||
|
||||
def test_exchanging_a_pair_code(self, client, headers, token_id, close):
|
||||
code = client.post(
|
||||
f"/api/client-tokens/{token_id}/pair", headers=headers
|
||||
).json()["code"]
|
||||
|
||||
response = client.post("/api/client-tokens/exchange", json={"code": code})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
close.assert_awaited_once_with([token_id])
|
||||
|
||||
def test_an_admin_revoking_a_token(
|
||||
self, client, headers, editor_access_token, editor_user, close
|
||||
):
|
||||
token_id = client.post(
|
||||
"/api/client-tokens",
|
||||
json={"name": "Editor handheld", "scopes": ["devices.read"]},
|
||||
headers={"Authorization": f"Bearer {editor_access_token}"},
|
||||
).json()["id"]
|
||||
|
||||
response = client.delete(
|
||||
f"/api/client-tokens/{token_id}/admin", headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
close.assert_awaited_once_with([token_id])
|
||||
|
||||
def test_a_missing_token_closes_nothing(self, client, headers, close):
|
||||
response = client.delete("/api/client-tokens/99999", headers=headers)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
close.assert_not_awaited()
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
from endpoints import device as device_endpoints
|
||||
from handler.database import db_device_handler
|
||||
from handler.device_install_handler import device_install_handler
|
||||
from handler.redis_handler import sync_cache
|
||||
from models.device import Device
|
||||
from models.user import User
|
||||
|
||||
@@ -170,6 +177,194 @@ class TestDeviceEndpoints:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["client_device_identifier"] == "install-uuid-abc123"
|
||||
|
||||
def test_deleting_a_device_closes_its_token_sockets(
|
||||
self, mocker, client, access_token: str, admin_user: User, add_device_token
|
||||
):
|
||||
device = db_device_handler.add_device(
|
||||
Device(id="test-device-sockets", user_id=admin_user.id, name="Handheld")
|
||||
)
|
||||
token, _ = add_device_token(admin_user, device.id, scopes="devices.read")
|
||||
close = mocker.patch.object(
|
||||
device_endpoints, "close_client_token_sockets", AsyncMock()
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/devices/{device.id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
close.assert_awaited_once_with([token.id])
|
||||
|
||||
def test_deleting_a_device_drops_its_install_requests(
|
||||
self, client, access_token: str, admin_user: User
|
||||
):
|
||||
device = db_device_handler.add_device(
|
||||
Device(id="test-device-installs", user_id=admin_user.id, name="Handheld")
|
||||
)
|
||||
install, _ = asyncio.run(
|
||||
device_install_handler.create(
|
||||
user_id=admin_user.id, device_id=device.id, rom_id=7, file_ids=[1]
|
||||
)
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/devices/{device.id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert asyncio.run(device_install_handler.get(install.id)) is None
|
||||
assert asyncio.run(device_install_handler.list_for_rom(admin_user.id, 7)) == []
|
||||
|
||||
|
||||
class TestDeviceCapabilities:
|
||||
def test_registering_stores_the_reported_capabilities(
|
||||
self, client, access_token: str, admin_user: User
|
||||
):
|
||||
response = client.post(
|
||||
"/api/devices",
|
||||
json={"name": "Handheld", "capabilities": {"remote_install": True}},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
stored = db_device_handler.get_device(
|
||||
device_id=response.json()["device_id"], user_id=admin_user.id
|
||||
)
|
||||
assert stored is not None
|
||||
assert stored.capabilities == {"remote_install": True}
|
||||
|
||||
def test_registering_again_replaces_the_capabilities(
|
||||
self, client, access_token: str, admin_user: User
|
||||
):
|
||||
body = {"name": "Handheld", "hostname": "thor", "platform": "android"}
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
first = client.post(
|
||||
"/api/devices",
|
||||
json={**body, "capabilities": {"remote_install": False}},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
again = client.post(
|
||||
"/api/devices",
|
||||
json={**body, "capabilities": {"remote_install": True}},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert again.status_code == status.HTTP_200_OK
|
||||
assert again.json()["device_id"] == first.json()["device_id"]
|
||||
stored = db_device_handler.get_device(
|
||||
device_id=first.json()["device_id"], user_id=admin_user.id
|
||||
)
|
||||
assert stored is not None
|
||||
assert stored.capabilities == {"remote_install": True}
|
||||
|
||||
def test_updating_replaces_the_capabilities(
|
||||
self, client, access_token: str, admin_user: User
|
||||
):
|
||||
device = db_device_handler.add_device(
|
||||
Device(id="test-device-caps", user_id=admin_user.id, name="Handheld")
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/api/devices/{device.id}",
|
||||
json={"capabilities": {"remote_install": True}},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["capabilities"] == {"remote_install": True}
|
||||
|
||||
def test_an_update_without_capabilities_keeps_them(
|
||||
self, client, access_token: str, admin_user: User
|
||||
):
|
||||
device = db_device_handler.add_device(
|
||||
Device(id="test-device-keep-caps", user_id=admin_user.id, name="Handheld")
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
client.put(
|
||||
f"/api/devices/{device.id}",
|
||||
json={"capabilities": {"remote_install": True}},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/api/devices/{device.id}",
|
||||
json={"name": "Renamed", "client_version": "2.18.0"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["capabilities"] == {"remote_install": True}
|
||||
stored = db_device_handler.get_device(
|
||||
device_id=device.id, user_id=admin_user.id
|
||||
)
|
||||
assert stored is not None
|
||||
assert stored.capabilities == {"remote_install": True}
|
||||
|
||||
def test_registering_again_without_capabilities_keeps_them(
|
||||
self, client, access_token: str, admin_user: User
|
||||
):
|
||||
body = {"name": "Handheld", "hostname": "thor", "platform": "android"}
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
first = client.post(
|
||||
"/api/devices",
|
||||
json={**body, "capabilities": {"remote_install": True}},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
again = client.post("/api/devices", json=body, headers=headers)
|
||||
|
||||
assert again.status_code == status.HTTP_200_OK
|
||||
stored = db_device_handler.get_device(
|
||||
device_id=first.json()["device_id"], user_id=admin_user.id
|
||||
)
|
||||
assert stored is not None
|
||||
assert stored.capabilities == {"remote_install": True}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"capabilities",
|
||||
[
|
||||
{"remote_install": "yes"},
|
||||
{"": True},
|
||||
{"x" * 65: True},
|
||||
{f"flag{i}": True for i in range(33)},
|
||||
],
|
||||
)
|
||||
def test_rejects_capabilities_that_are_not_named_flags(
|
||||
self, client, access_token: str, capabilities
|
||||
):
|
||||
response = client.post(
|
||||
"/api/devices",
|
||||
json={"name": "Handheld", "capabilities": capabilities},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
|
||||
|
||||
class TestDevicePresence:
|
||||
def test_lists_the_callers_devices_holding_a_socket(
|
||||
self, client, access_token: str, admin_user: User, editor_user: User
|
||||
):
|
||||
for device_id, user in (
|
||||
("dev-online", admin_user),
|
||||
("dev-offline", admin_user),
|
||||
("dev-editor", editor_user),
|
||||
):
|
||||
db_device_handler.add_device(Device(id=device_id, user_id=user.id))
|
||||
sync_cache.sadd("device_presence:dev-online", "sid-1")
|
||||
sync_cache.sadd("device_presence:dev-editor", "sid-2")
|
||||
|
||||
response = client.get(
|
||||
"/api/devices/online",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == ["dev-online"]
|
||||
|
||||
|
||||
class TestDeviceUserIsolation:
|
||||
def test_list_devices_only_returns_own_devices(
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
from endpoints import device_install
|
||||
from endpoints.responses.device_install import InstallRequestSchema, InstallStatus
|
||||
from handler.database import db_device_handler, db_platform_handler, db_rom_handler
|
||||
from handler.database.base_handler import sync_session
|
||||
from handler.device_install_handler import device_install_handler
|
||||
from models.device import Device
|
||||
from models.notification import NotificationKind, NotificationLevel
|
||||
from models.permission import HiddenEntity, PermEntity
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom, RomFile, RomFileCategory
|
||||
from models.user import User
|
||||
|
||||
REMOTE_INSTALL = {"remote_install": True}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def emits(mocker):
|
||||
return {
|
||||
"queued": mocker.patch.object(
|
||||
device_install, "emit_install_queued", AsyncMock()
|
||||
),
|
||||
"cancelled": mocker.patch.object(
|
||||
device_install, "emit_install_cancelled", AsyncMock()
|
||||
),
|
||||
"updated": mocker.patch.object(
|
||||
device_install, "emit_install_updated", AsyncMock()
|
||||
),
|
||||
"notify": mocker.patch.object(device_install, "notify", AsyncMock()),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device(admin_user: User) -> Device:
|
||||
return db_device_handler.add_device(
|
||||
Device(
|
||||
id="dev-1",
|
||||
user_id=admin_user.id,
|
||||
name="Handheld",
|
||||
capabilities=REMOTE_INSTALL,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_device(admin_user: User) -> Device:
|
||||
return db_device_handler.add_device(
|
||||
Device(
|
||||
id="dev-2",
|
||||
user_id=admin_user.id,
|
||||
name="TV box",
|
||||
capabilities=REMOTE_INSTALL,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_headers(
|
||||
admin_user: User, device: Device, add_device_token
|
||||
) -> dict[str, str]:
|
||||
_, raw_token = add_device_token(admin_user, device.id)
|
||||
return {"Authorization": f"Bearer {raw_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def headers(access_token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor_headers(editor_access_token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {editor_access_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor_device(editor_user: User) -> Device:
|
||||
return db_device_handler.add_device(
|
||||
Device(
|
||||
id="dev-editor",
|
||||
user_id=editor_user.id,
|
||||
name="Editor handheld",
|
||||
capabilities=REMOTE_INSTALL,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _hide_rom(rom: Rom, user: User) -> None:
|
||||
with sync_session.begin() as s:
|
||||
s.add(HiddenEntity(entity=PermEntity.ROMS, entity_id=rom.id, user_id=user.id))
|
||||
|
||||
|
||||
def _add_file(
|
||||
rom: Rom,
|
||||
file_name: str,
|
||||
category: RomFileCategory | None,
|
||||
missing: bool = False,
|
||||
) -> RomFile:
|
||||
return db_rom_handler.add_rom_file(
|
||||
RomFile(
|
||||
rom_id=rom.id,
|
||||
file_name=file_name,
|
||||
file_path=rom.fs_path,
|
||||
file_size_bytes=1,
|
||||
category=category,
|
||||
missing_from_fs=missing,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rom_files(rom: Rom) -> dict[str, RomFile]:
|
||||
return {
|
||||
"game": _add_file(rom, "game.bin", RomFileCategory.GAME),
|
||||
"update": _add_file(rom, "update.nsp", RomFileCategory.UPDATE),
|
||||
"soundtrack": _add_file(rom, "ost.flac", RomFileCategory.SOUNDTRACK),
|
||||
"manual": _add_file(rom, "manual.pdf", RomFileCategory.MANUAL),
|
||||
"gone": _add_file(rom, "gone.bin", RomFileCategory.GAME, missing=True),
|
||||
}
|
||||
|
||||
|
||||
def _create(client, headers, rom_id: int, device_id: str = "dev-1"):
|
||||
return client.post(
|
||||
f"/api/devices/{device_id}/installs",
|
||||
json={"rom_id": rom_id},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _claim(client, headers, device_id: str = "dev-1"):
|
||||
return client.post(f"/api/devices/{device_id}/installs/claim", headers=headers)
|
||||
|
||||
|
||||
def _report(client, device_headers, request_id: str, new_status: str, **body):
|
||||
return client.put(
|
||||
f"/api/devices/dev-1/installs/{request_id}",
|
||||
json={"status": new_status, **body},
|
||||
headers=device_headers,
|
||||
)
|
||||
|
||||
|
||||
def _cancel(client, headers, request_id: str, device_id: str = "dev-1"):
|
||||
return client.delete(
|
||||
f"/api/devices/{device_id}/installs/{request_id}", headers=headers
|
||||
)
|
||||
|
||||
|
||||
def _stored(request_id: str) -> InstallRequestSchema | None:
|
||||
return asyncio.run(device_install_handler.get(request_id))
|
||||
|
||||
|
||||
def _live_on_device(device_id: str = "dev-1") -> list[InstallRequestSchema]:
|
||||
return asyncio.run(device_install_handler.list_for_device(device_id))
|
||||
|
||||
|
||||
def _taken(client, headers, device_headers, rom_id: int) -> str:
|
||||
request_id = str(_create(client, headers, rom_id).json()["id"])
|
||||
assert _claim(client, device_headers).status_code == status.HTTP_200_OK
|
||||
return request_id
|
||||
|
||||
|
||||
class TestCreate:
|
||||
def test_queues_the_installable_files_and_signals_the_device(
|
||||
self, client, headers, device, rom, rom_files, emits
|
||||
):
|
||||
response = _create(client, headers, rom.id)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
body = response.json()
|
||||
assert body["status"] == "pending"
|
||||
assert body["device_id"] == device.id
|
||||
assert body["file_ids"] == sorted(
|
||||
[rom_files["game"].id, rom_files["update"].id]
|
||||
)
|
||||
emits["queued"].assert_awaited_once()
|
||||
assert emits["queued"].await_args.args[0].id == body["id"]
|
||||
|
||||
def test_a_second_tick_returns_the_request_already_queued(
|
||||
self, client, headers, device, rom, rom_files, emits
|
||||
):
|
||||
first = _create(client, headers, rom.id).json()
|
||||
|
||||
response = _create(client, headers, rom.id)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["id"] == first["id"]
|
||||
emits["queued"].assert_awaited_once()
|
||||
|
||||
def test_rejects_a_rom_with_nothing_installable(self, client, headers, device, rom):
|
||||
_add_file(rom, "ost.flac", RomFileCategory.SOUNDTRACK)
|
||||
_add_file(rom, "gone.bin", RomFileCategory.GAME, missing=True)
|
||||
|
||||
response = _create(client, headers, rom.id)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_rejects_an_excluded_platform(self, client, headers, device):
|
||||
platform = db_platform_handler.add_platform(
|
||||
Platform(name="Windows", slug="win", fs_slug="win")
|
||||
)
|
||||
rom = db_rom_handler.add_rom(
|
||||
Rom(
|
||||
platform_id=platform.id,
|
||||
name="setup",
|
||||
slug="setup",
|
||||
fs_name="setup.exe",
|
||||
fs_name_no_tags="setup",
|
||||
fs_name_no_ext="setup",
|
||||
fs_extension="exe",
|
||||
fs_path="win/roms",
|
||||
)
|
||||
)
|
||||
_add_file(rom, "setup.exe", RomFileCategory.GAME)
|
||||
|
||||
response = _create(client, headers, rom.id)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_a_hidden_rom_is_not_found(
|
||||
self, client, editor_headers, editor_device, editor_user, rom, rom_files, emits
|
||||
):
|
||||
_hide_rom(rom, editor_user)
|
||||
|
||||
response = _create(client, editor_headers, rom.id, device_id=editor_device.id)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
emits["queued"].assert_not_awaited()
|
||||
|
||||
def test_another_users_device_is_not_found(
|
||||
self, client, editor_headers, device, rom, rom_files
|
||||
):
|
||||
response = _create(client, editor_headers, rom.id)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"capabilities", [None, {}, {"remote_install": False}, {"sync": True}]
|
||||
)
|
||||
def test_rejects_a_device_that_does_not_accept_remote_installs(
|
||||
self, client, headers, admin_user, rom, rom_files, emits, capabilities
|
||||
):
|
||||
db_device_handler.add_device(
|
||||
Device(
|
||||
id="dev-sync",
|
||||
user_id=admin_user.id,
|
||||
name="Sync only",
|
||||
capabilities=capabilities,
|
||||
)
|
||||
)
|
||||
|
||||
response = _create(client, headers, rom.id, device_id="dev-sync")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
emits["queued"].assert_not_awaited()
|
||||
|
||||
def test_a_device_deleted_during_the_create_keeps_no_request(
|
||||
self, mocker, client, headers, admin_user, device, rom, rom_files, emits
|
||||
):
|
||||
real_create = device_install_handler.create
|
||||
|
||||
async def create_then_delete_device(
|
||||
user_id: int, device_id: str, rom_id: int, file_ids: list[int]
|
||||
) -> tuple[InstallRequestSchema, bool]:
|
||||
created = await real_create(
|
||||
user_id=user_id, device_id=device_id, rom_id=rom_id, file_ids=file_ids
|
||||
)
|
||||
db_device_handler.delete_device(device_id=device_id, user_id=user_id)
|
||||
return created
|
||||
|
||||
mocker.patch.object(
|
||||
device_install_handler, "create", side_effect=create_then_delete_device
|
||||
)
|
||||
|
||||
response = _create(client, headers, rom.id)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert _live_on_device() == []
|
||||
emits["queued"].assert_not_awaited()
|
||||
|
||||
def test_is_not_found_while_disabled(
|
||||
self, mocker, client, headers, device, rom, rom_files
|
||||
):
|
||||
mocker.patch.object(device_install, "DEVICE_INSTALL_ENABLED", False)
|
||||
|
||||
response = _create(client, headers, rom.id)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_the_device_reads_its_live_requests(
|
||||
self, client, headers, device_headers, device, rom, second_rom, rom_files
|
||||
):
|
||||
taken = _taken(client, headers, device_headers, rom.id)
|
||||
_add_file(second_rom, "other.bin", RomFileCategory.GAME)
|
||||
pending = _create(client, headers, second_rom.id).json()["id"]
|
||||
|
||||
response = client.get("/api/devices/dev-1/installs", headers=device_headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [(r["id"], r["status"]) for r in response.json()] == [
|
||||
(taken, "taken"),
|
||||
(pending, "pending"),
|
||||
]
|
||||
|
||||
def test_a_device_token_cannot_read_another_device(
|
||||
self, client, device_headers, other_device
|
||||
):
|
||||
response = client.get("/api/devices/dev-2/installs", headers=device_headers)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
class TestClaim:
|
||||
def test_takes_and_returns_the_pending_requests(
|
||||
self, client, headers, device_headers, device, rom, rom_files, emits
|
||||
):
|
||||
created = _create(client, headers, rom.id).json()
|
||||
|
||||
response = _claim(client, device_headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [(r["id"], r["status"]) for r in response.json()] == [
|
||||
(created["id"], "taken")
|
||||
]
|
||||
stored = _stored(created["id"])
|
||||
assert stored is not None and stored.status == InstallStatus.TAKEN
|
||||
assert emits["updated"].await_args.args[0].status == "taken"
|
||||
|
||||
def test_a_second_claim_returns_the_taken_request_without_signalling_it(
|
||||
self, client, headers, device_headers, device, rom, rom_files, emits
|
||||
):
|
||||
request_id = _create(client, headers, rom.id).json()["id"]
|
||||
_claim(client, device_headers)
|
||||
emits["updated"].reset_mock()
|
||||
|
||||
response = _claim(client, device_headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [(r["id"], r["status"]) for r in response.json()] == [
|
||||
(request_id, "taken")
|
||||
]
|
||||
emits["updated"].assert_not_awaited()
|
||||
|
||||
def test_a_reported_request_is_not_claimed_again(
|
||||
self, client, headers, device_headers, device, rom, rom_files
|
||||
):
|
||||
request_id = _taken(client, headers, device_headers, rom.id)
|
||||
_report(client, device_headers, request_id, "done")
|
||||
|
||||
response = _claim(client, device_headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
def test_a_cancelled_request_is_never_claimed(
|
||||
self, client, headers, device_headers, device, rom, rom_files
|
||||
):
|
||||
request_id = _create(client, headers, rom.id).json()["id"]
|
||||
_cancel(client, headers, request_id)
|
||||
|
||||
response = _claim(client, device_headers)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
def test_another_devices_token_is_refused(
|
||||
self, client, headers, device_headers, device, other_device, rom, rom_files
|
||||
):
|
||||
_create(client, headers, rom.id, device_id="dev-2")
|
||||
|
||||
response = _claim(client, device_headers, device_id="dev-2")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_the_web_ui_cannot_claim(self, client, headers, device, rom, rom_files):
|
||||
_create(client, headers, rom.id)
|
||||
|
||||
response = _claim(client, headers)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_a_token_that_cannot_download_leaves_requests_pending(
|
||||
self, client, headers, admin_user, device, rom, rom_files, add_device_token
|
||||
):
|
||||
request_id = _create(client, headers, rom.id).json()["id"]
|
||||
_, raw = add_device_token(
|
||||
admin_user, device.id, scopes="devices.read devices.write"
|
||||
)
|
||||
|
||||
response = _claim(client, {"Authorization": f"Bearer {raw}"})
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
stored = _stored(request_id)
|
||||
assert stored is not None and stored.status == InstallStatus.PENDING
|
||||
|
||||
|
||||
class TestDeviceReport:
|
||||
def test_a_failure_ends_the_request_and_notifies_the_owner(
|
||||
self, client, headers, device_headers, admin_user, device, rom, rom_files, emits
|
||||
):
|
||||
request_id = _taken(client, headers, device_headers, rom.id)
|
||||
|
||||
response = _report(
|
||||
client, device_headers, request_id, "failed", reason="no space"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["status"] == "failed"
|
||||
assert response.json()["reason"] == "no space"
|
||||
assert _stored(request_id) is None
|
||||
assert _live_on_device() == []
|
||||
assert emits["updated"].await_args.args[0].status == "failed"
|
||||
emits["notify"].assert_awaited_once()
|
||||
user_id, kind, level, data = emits["notify"].await_args.args
|
||||
assert user_id == admin_user.id
|
||||
assert kind == NotificationKind.DEVICE_INSTALL_FAILED
|
||||
assert level == NotificationLevel.ERROR
|
||||
assert data["reason"] == "no space"
|
||||
assert data["rom_id"] == rom.id
|
||||
assert data["device_name"] == "Handheld"
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["done", "already_installed"])
|
||||
def test_a_completion_notifies_the_owner(
|
||||
self, client, headers, device_headers, device, rom, rom_files, emits, outcome
|
||||
):
|
||||
request_id = _taken(client, headers, device_headers, rom.id)
|
||||
|
||||
_report(client, device_headers, request_id, outcome)
|
||||
|
||||
_, kind, level, data = emits["notify"].await_args.args
|
||||
assert kind == NotificationKind.DEVICE_INSTALL_COMPLETED
|
||||
assert level == NotificationLevel.SUCCESS
|
||||
assert data["status"] == outcome
|
||||
|
||||
def test_claiming_a_request_notifies_nobody(
|
||||
self, client, headers, device_headers, device, rom, rom_files, emits
|
||||
):
|
||||
_taken(client, headers, device_headers, rom.id)
|
||||
|
||||
emits["notify"].assert_not_awaited()
|
||||
|
||||
def test_a_repeated_report_is_not_found_and_notifies_once(
|
||||
self, client, headers, device_headers, device, rom, rom_files, emits
|
||||
):
|
||||
request_id = _taken(client, headers, device_headers, rom.id)
|
||||
|
||||
first = _report(client, device_headers, request_id, "done")
|
||||
again = _report(client, device_headers, request_id, "done")
|
||||
|
||||
assert first.status_code == status.HTTP_200_OK
|
||||
assert again.status_code == status.HTTP_404_NOT_FOUND
|
||||
emits["notify"].assert_awaited_once()
|
||||
|
||||
def test_a_pending_request_is_a_conflict(
|
||||
self, client, headers, device_headers, device, rom, rom_files, emits
|
||||
):
|
||||
request_id = _create(client, headers, rom.id).json()["id"]
|
||||
|
||||
response = _report(client, device_headers, request_id, "done")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
stored = _stored(request_id)
|
||||
assert stored is not None and stored.status == InstallStatus.PENDING
|
||||
emits["notify"].assert_not_awaited()
|
||||
|
||||
def test_the_web_ui_cannot_report(
|
||||
self, client, headers, device_headers, device, rom, rom_files
|
||||
):
|
||||
request_id = _taken(client, headers, device_headers, rom.id)
|
||||
|
||||
response = _report(client, headers, request_id, "done")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@pytest.mark.parametrize("reported", ["pending", "taken", "cancelled"])
|
||||
def test_a_device_cannot_report_a_status_it_does_not_own(
|
||||
self, client, headers, device_headers, device, rom, rom_files, reported
|
||||
):
|
||||
request_id = _taken(client, headers, device_headers, rom.id)
|
||||
|
||||
response = _report(client, device_headers, request_id, reported)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
|
||||
|
||||
class TestCancel:
|
||||
@pytest.mark.parametrize("claim", [False, True])
|
||||
def test_cancels_a_live_request_and_deletes_it(
|
||||
self, client, headers, device_headers, device, rom, rom_files, emits, claim
|
||||
):
|
||||
request_id = (
|
||||
_taken(client, headers, device_headers, rom.id)
|
||||
if claim
|
||||
else _create(client, headers, rom.id).json()["id"]
|
||||
)
|
||||
|
||||
response = _cancel(client, headers, request_id)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["status"] == "cancelled"
|
||||
assert _stored(request_id) is None
|
||||
assert emits["updated"].await_args.args[0].status == "cancelled"
|
||||
emits["cancelled"].assert_awaited_once()
|
||||
cancelled = emits["cancelled"].await_args
|
||||
assert cancelled is not None
|
||||
assert cancelled.args[0].id == request_id
|
||||
emits["notify"].assert_not_awaited()
|
||||
|
||||
def test_the_device_report_after_a_cancel_is_not_found(
|
||||
self, client, headers, device_headers, device, rom, rom_files
|
||||
):
|
||||
request_id = _taken(client, headers, device_headers, rom.id)
|
||||
_cancel(client, headers, request_id)
|
||||
|
||||
response = _report(client, device_headers, request_id, "done")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_an_ended_request_cannot_be_cancelled(
|
||||
self, client, headers, device_headers, device, rom, rom_files
|
||||
):
|
||||
request_id = _taken(client, headers, device_headers, rom.id)
|
||||
_report(client, device_headers, request_id, "done")
|
||||
|
||||
response = _cancel(client, headers, request_id)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_a_request_on_another_device_path_is_not_found(
|
||||
self, client, headers, device, other_device, rom, rom_files
|
||||
):
|
||||
request_id = _create(client, headers, rom.id).json()["id"]
|
||||
|
||||
response = _cancel(client, headers, request_id, device_id="dev-2")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import threading
|
||||
@@ -14,14 +15,23 @@ from fastapi.testclient import TestClient
|
||||
from joserfc.errors import InvalidClaimError
|
||||
|
||||
from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
from endpoints import permissions as permissions_endpoints
|
||||
from endpoints import user as user_endpoints
|
||||
from handler.auth import auth_handler
|
||||
from handler.auth import base_handler as auth_handler_module
|
||||
from handler.auth import oauth_handler
|
||||
from handler.auth.constants import SESSION_COOKIE_NAME
|
||||
from handler.auth.middleware.redis_session_middleware import RedisSessionMiddleware
|
||||
from handler.database import db_notification_handler
|
||||
from handler.database import (
|
||||
db_client_token_handler,
|
||||
db_device_handler,
|
||||
db_notification_handler,
|
||||
)
|
||||
from handler.database.users_handler import DBUsersHandler
|
||||
from handler.device_install_handler import device_install_handler
|
||||
from handler.redis_handler import async_cache, redis_client
|
||||
from models.client_token import ClientToken
|
||||
from models.device import Device
|
||||
from models.notification import NotificationKind
|
||||
from models.user import Role, User
|
||||
|
||||
@@ -461,6 +471,120 @@ def test_delete_user(client, access_token: str, editor_user: User):
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
def test_delete_user_closes_their_device_sockets(
|
||||
mocker, client, access_token: str, editor_user: User
|
||||
):
|
||||
token = db_client_token_handler.add_token(
|
||||
ClientToken(
|
||||
user_id=editor_user.id,
|
||||
name="Handheld",
|
||||
hashed_token=auth_handler.hash_client_token("rmm_deleted_user_device"),
|
||||
scopes="devices.read",
|
||||
)
|
||||
)
|
||||
close = mocker.patch.object(
|
||||
user_endpoints, "close_client_token_sockets", mock.AsyncMock()
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/users/{editor_user.id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
close.assert_awaited_once_with([token.id])
|
||||
|
||||
|
||||
def test_delete_user_drops_their_install_requests(
|
||||
client, access_token: str, editor_user: User
|
||||
):
|
||||
db_device_handler.add_device(
|
||||
Device(id="deleted-user-device", user_id=editor_user.id, name="Handheld")
|
||||
)
|
||||
install, _ = asyncio.run(
|
||||
device_install_handler.create(
|
||||
user_id=editor_user.id,
|
||||
device_id="deleted-user-device",
|
||||
rom_id=7,
|
||||
file_ids=[1],
|
||||
)
|
||||
)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/users/{editor_user.id}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert asyncio.run(device_install_handler.get(install.id)) is None
|
||||
|
||||
|
||||
def _editor_device_token(editor_user: User) -> ClientToken:
|
||||
return db_client_token_handler.add_token(
|
||||
ClientToken(
|
||||
user_id=editor_user.id,
|
||||
name="Handheld",
|
||||
hashed_token=auth_handler.hash_client_token("rmm_updated_user_device"),
|
||||
scopes="devices.read",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_disabling_a_user_closes_their_device_sockets(
|
||||
mocker, client, access_token: str, editor_user: User
|
||||
):
|
||||
token = _editor_device_token(editor_user)
|
||||
close = mocker.patch.object(
|
||||
user_endpoints, "close_client_token_sockets", mock.AsyncMock()
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/api/users/{editor_user.id}",
|
||||
data={"enabled": "false"},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
close.assert_awaited_once_with([token.id])
|
||||
|
||||
|
||||
def test_changing_a_users_role_closes_their_device_sockets(
|
||||
mocker, client, access_token: str, editor_user: User
|
||||
):
|
||||
token = _editor_device_token(editor_user)
|
||||
close = mocker.patch.object(
|
||||
permissions_endpoints, "close_client_token_sockets", mock.AsyncMock()
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/api/users/{editor_user.id}",
|
||||
data={"role": "admin"},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
close.assert_awaited_once_with([token.id])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("form", [{"ra_username": "someone"}, {"enabled": "true"}])
|
||||
def test_other_user_edits_leave_their_device_sockets_open(
|
||||
mocker, client, access_token: str, editor_user: User, form
|
||||
):
|
||||
_editor_device_token(editor_user)
|
||||
close = mocker.patch.object(
|
||||
user_endpoints, "close_client_token_sockets", mock.AsyncMock()
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/api/users/{editor_user.id}",
|
||||
data=form,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
close.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_password_reset_invalidates_the_target_user_sessions(
|
||||
client, access_token: str, editor_user: User
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
end-to-end effect on a member's /permissions/me."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
from handler.auth import oauth_handler
|
||||
from handler.database import db_user_handler
|
||||
from endpoints import permissions as permissions_endpoints
|
||||
from handler.auth import auth_handler, oauth_handler
|
||||
from handler.database import db_client_token_handler, db_user_handler
|
||||
from handler.database.base_handler import sync_session
|
||||
from handler.database.permissions_handler import DBPermissionsHandler
|
||||
from models.client_token import ClientToken
|
||||
from models.permission import PermissionGroup
|
||||
|
||||
|
||||
@@ -134,6 +137,31 @@ def test_user_override_adds_capability(client, access_token, viewer_user):
|
||||
_cleanup()
|
||||
|
||||
|
||||
def test_a_permission_change_closes_the_users_device_sockets(
|
||||
mocker, client, access_token, viewer_user
|
||||
):
|
||||
token = db_client_token_handler.add_token(
|
||||
ClientToken(
|
||||
user_id=viewer_user.id,
|
||||
name="Handheld",
|
||||
hashed_token=auth_handler.hash_client_token("rmm_regrouped_user_device"),
|
||||
scopes="devices.read",
|
||||
)
|
||||
)
|
||||
close = mocker.patch.object(
|
||||
permissions_endpoints, "close_client_token_sockets", AsyncMock()
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"/api/permissions/users/{viewer_user.id}",
|
||||
headers=_bearer(access_token),
|
||||
json={"overrides": [{"entity": "roms", "action": "delete", "granted": True}]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
close.assert_awaited_once_with([token.id])
|
||||
|
||||
|
||||
def test_hide_entity_for_user(client, access_token, viewer_user):
|
||||
resp = client.post(
|
||||
"/api/permissions/hidden",
|
||||
|
||||
@@ -87,6 +87,37 @@ def base_url(mocker):
|
||||
"discord.com answered 404",
|
||||
"https://romm.example.com/notifications?tab=channels",
|
||||
),
|
||||
(
|
||||
NotificationKind.DEVICE_INSTALL_COMPLETED,
|
||||
{
|
||||
"rom_name": "Zelda",
|
||||
"rom_id": 12,
|
||||
"device_name": "Thor",
|
||||
"status": "done",
|
||||
},
|
||||
"Zelda was added to downloads on Thor",
|
||||
None,
|
||||
"https://romm.example.com/rom/12",
|
||||
),
|
||||
(
|
||||
NotificationKind.DEVICE_INSTALL_COMPLETED,
|
||||
{
|
||||
"rom_name": "Zelda",
|
||||
"rom_id": 12,
|
||||
"device_name": "Thor",
|
||||
"status": "already_installed",
|
||||
},
|
||||
"Zelda is already installed on Thor",
|
||||
None,
|
||||
"https://romm.example.com/rom/12",
|
||||
),
|
||||
(
|
||||
NotificationKind.DEVICE_INSTALL_FAILED,
|
||||
{"rom_name": "Zelda", "rom_id": 12, "reason": "No space left"},
|
||||
"Zelda could not be installed on your device",
|
||||
"No space left",
|
||||
"https://romm.example.com/rom/12",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_words_romms_own_kinds_in_english(kind, data, title, body, url):
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import pytest
|
||||
|
||||
from endpoints.responses.device_install import InstallRequestSchema, InstallStatus
|
||||
from handler.device_install_handler import (
|
||||
DeviceInstallHandler,
|
||||
InstallTransitionError,
|
||||
)
|
||||
from handler.redis_handler import as_text, async_cache, sync_cache
|
||||
|
||||
TWO_DAYS = 2 * 24 * 60 * 60
|
||||
OUTCOMES = [
|
||||
InstallStatus.DONE,
|
||||
InstallStatus.ALREADY_INSTALLED,
|
||||
InstallStatus.FAILED,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
sync_cache.flushall()
|
||||
yield
|
||||
sync_cache.flushall()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler() -> DeviceInstallHandler:
|
||||
return DeviceInstallHandler(ttl_days=2)
|
||||
|
||||
|
||||
async def _create(
|
||||
handler: DeviceInstallHandler, device_id: str = "dev-1", rom_id: int = 7
|
||||
) -> InstallRequestSchema:
|
||||
request, created = await handler.create(
|
||||
user_id=1, device_id=device_id, rom_id=rom_id, file_ids=[10, 11]
|
||||
)
|
||||
assert created
|
||||
return request
|
||||
|
||||
|
||||
async def _taken(
|
||||
handler: DeviceInstallHandler, device_id: str = "dev-1", rom_id: int = 7
|
||||
) -> InstallRequestSchema:
|
||||
request = await _create(handler, device_id, rom_id)
|
||||
await handler.claim(device_id)
|
||||
return request
|
||||
|
||||
|
||||
async def _active_id(rom_id: int) -> str | None:
|
||||
value = await async_cache.get(f"install:active:1:dev-1:{rom_id}")
|
||||
return None if value is None else as_text(value)
|
||||
|
||||
|
||||
async def _assert_gone(request: InstallRequestSchema) -> None:
|
||||
assert not await async_cache.exists(f"install:req:{request.id}")
|
||||
assert not await async_cache.exists(
|
||||
f"install:active:{request.user_id}:{request.device_id}:{request.rom_id}"
|
||||
)
|
||||
assert not await async_cache.sismember(
|
||||
f"install:device:{request.device_id}", request.id
|
||||
)
|
||||
assert not await async_cache.sismember(
|
||||
f"install:rom:{request.user_id}:{request.rom_id}", request.id
|
||||
)
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
async def test_a_new_request_is_listed_for_its_device_and_its_rom(self, handler):
|
||||
request = await _create(handler)
|
||||
|
||||
assert request.status == InstallStatus.PENDING
|
||||
assert [r.id for r in await handler.list_for_device("dev-1")] == [request.id]
|
||||
assert await handler.list_for_device("dev-2") == []
|
||||
assert [r.id for r in await handler.list_for_rom(1, 7)] == [request.id]
|
||||
assert await handler.get(request.id) == request
|
||||
|
||||
async def test_a_live_request_expires_after_the_configured_days(self, handler):
|
||||
request = await _create(handler)
|
||||
|
||||
for key in (
|
||||
f"install:req:{request.id}",
|
||||
"install:device:dev-1",
|
||||
"install:rom:1:7",
|
||||
"install:active:1:dev-1:7",
|
||||
):
|
||||
assert 0 < await async_cache.ttl(key) <= TWO_DAYS
|
||||
|
||||
async def test_an_unlimited_ttl_keeps_a_live_request(self):
|
||||
handler = DeviceInstallHandler(ttl_days=0)
|
||||
request = await _taken(handler)
|
||||
|
||||
assert await async_cache.ttl(f"install:req:{request.id}") == -1
|
||||
|
||||
@pytest.mark.parametrize("outcome", OUTCOMES)
|
||||
async def test_a_report_ends_a_taken_request_and_deletes_it(self, handler, outcome):
|
||||
request = await _taken(handler)
|
||||
|
||||
ended = await handler.report(request.id, outcome, "no space")
|
||||
|
||||
assert (ended.id, ended.status, ended.reason) == (
|
||||
request.id,
|
||||
outcome,
|
||||
"no space",
|
||||
)
|
||||
await _assert_gone(request)
|
||||
assert await handler.list_for_device("dev-1") == []
|
||||
|
||||
async def test_a_pending_request_cannot_be_reported(self, handler):
|
||||
request = await _create(handler)
|
||||
|
||||
with pytest.raises(InstallTransitionError):
|
||||
await handler.report(request.id, InstallStatus.DONE, None)
|
||||
|
||||
stored = await handler.get(request.id)
|
||||
assert stored is not None and stored.status == InstallStatus.PENDING
|
||||
|
||||
@pytest.mark.parametrize("claim", [False, True])
|
||||
async def test_a_live_request_can_be_cancelled(self, handler, claim):
|
||||
request = await (_taken if claim else _create)(handler)
|
||||
|
||||
cancelled = await handler.cancel(request.id)
|
||||
|
||||
assert cancelled.status == InstallStatus.CANCELLED
|
||||
await _assert_gone(request)
|
||||
|
||||
async def test_a_repeated_report_finds_nothing(self, handler):
|
||||
request = await _taken(handler)
|
||||
await handler.report(request.id, InstallStatus.DONE, None)
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
await handler.report(request.id, InstallStatus.DONE, None)
|
||||
|
||||
async def test_a_missing_request_is_a_key_error(self, handler):
|
||||
with pytest.raises(KeyError):
|
||||
await handler.cancel("gone")
|
||||
|
||||
async def test_ending_leaves_another_requests_dedupe_key_alone(self, handler):
|
||||
request = await _create(handler)
|
||||
await async_cache.set("install:active:1:dev-1:7", "other")
|
||||
|
||||
await handler.cancel(request.id)
|
||||
|
||||
assert await _active_id(7) == "other"
|
||||
|
||||
async def test_an_expired_request_drops_out_of_every_list(self, handler):
|
||||
request = await _create(handler)
|
||||
await async_cache.delete(f"install:req:{request.id}")
|
||||
|
||||
assert await handler.list_for_device("dev-1") == []
|
||||
assert await handler.list_for_rom(1, 7) == []
|
||||
assert not await async_cache.sismember("install:device:dev-1", request.id)
|
||||
|
||||
|
||||
class TestEndRace:
|
||||
async def test_a_cancel_between_the_read_and_the_commit_wins(self, mocker, handler):
|
||||
request = await _taken(handler)
|
||||
real_read = handler._read
|
||||
cancelled_once = False
|
||||
|
||||
async def read_then_cancel(client, request_ids):
|
||||
nonlocal cancelled_once
|
||||
current = await real_read(client, request_ids)
|
||||
if not cancelled_once:
|
||||
cancelled_once = True
|
||||
await async_cache.delete(f"install:req:{request.id}")
|
||||
return current
|
||||
|
||||
mocker.patch.object(handler, "_read", side_effect=read_then_cancel)
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
await handler.report(request.id, InstallStatus.DONE, None)
|
||||
|
||||
|
||||
class TestClaim:
|
||||
async def test_takes_and_returns_every_pending_request(self, handler):
|
||||
first = await _create(handler, rom_id=7)
|
||||
second = await _create(handler, rom_id=8)
|
||||
other_device = await _create(handler, device_id="dev-2", rom_id=7)
|
||||
|
||||
claim = await handler.claim("dev-1")
|
||||
|
||||
assert [r.id for r in claim.taken] == [first.id, second.id]
|
||||
assert claim.newly_taken == claim.taken
|
||||
assert {r.status for r in claim.taken} == {InstallStatus.TAKEN}
|
||||
stored = await handler.get(first.id)
|
||||
assert stored is not None and stored.status == InstallStatus.TAKEN
|
||||
untouched = await handler.get(other_device.id)
|
||||
assert untouched is not None and untouched.status == InstallStatus.PENDING
|
||||
|
||||
async def test_a_second_claim_returns_the_taken_requests_again(self, handler):
|
||||
request = await _create(handler)
|
||||
first = await handler.claim("dev-1")
|
||||
|
||||
again = await handler.claim("dev-1")
|
||||
|
||||
assert again.taken == first.taken
|
||||
assert [r.id for r in again.taken] == [request.id]
|
||||
assert again.newly_taken == []
|
||||
|
||||
async def test_returns_earlier_and_new_requests_oldest_first(self, handler):
|
||||
earlier = await _taken(handler, rom_id=7)
|
||||
later = await _create(handler, rom_id=8)
|
||||
|
||||
claim = await handler.claim("dev-1")
|
||||
|
||||
assert [r.id for r in claim.taken] == [earlier.id, later.id]
|
||||
assert [r.id for r in claim.newly_taken] == [later.id]
|
||||
|
||||
async def test_a_reported_request_stops_coming_back(self, handler):
|
||||
request = await _taken(handler)
|
||||
await handler.report(request.id, InstallStatus.DONE, None)
|
||||
|
||||
assert await handler.claim("dev-1") == ([], [])
|
||||
|
||||
async def test_renews_only_the_dedupe_keys_its_requests_still_own(self, handler):
|
||||
first = await _create(handler, rom_id=7)
|
||||
second = await _create(handler, rom_id=8)
|
||||
await async_cache.set("install:active:1:dev-1:7", "other")
|
||||
|
||||
claim = await handler.claim("dev-1")
|
||||
|
||||
assert [r.id for r in claim.newly_taken] == [first.id, second.id]
|
||||
assert await _active_id(7) == "other"
|
||||
assert await _active_id(8) == second.id
|
||||
assert 0 < await async_cache.ttl("install:active:1:dev-1:8") <= TWO_DAYS
|
||||
|
||||
@pytest.mark.parametrize("with_pending", [False, True])
|
||||
async def test_drops_expired_ids_from_the_device_index(self, handler, with_pending):
|
||||
expired = await _create(handler, rom_id=7)
|
||||
await async_cache.delete(f"install:req:{expired.id}")
|
||||
if with_pending:
|
||||
await _create(handler, rom_id=8)
|
||||
|
||||
await handler.claim("dev-1")
|
||||
|
||||
assert not await async_cache.sismember("install:device:dev-1", expired.id)
|
||||
|
||||
async def test_leaves_cancelled_requests_alone(self, handler):
|
||||
request = await _create(handler)
|
||||
await handler.cancel(request.id)
|
||||
|
||||
assert await handler.claim("dev-1") == ([], [])
|
||||
|
||||
async def test_a_cancel_between_the_read_and_the_commit_is_never_returned(
|
||||
self, mocker, handler
|
||||
):
|
||||
request = await _create(handler)
|
||||
real_read = handler._read
|
||||
cancelled_once = False
|
||||
|
||||
async def read_then_cancel(client, request_ids):
|
||||
nonlocal cancelled_once
|
||||
current = await real_read(client, request_ids)
|
||||
if not cancelled_once:
|
||||
cancelled_once = True
|
||||
await handler.cancel(request.id)
|
||||
return current
|
||||
|
||||
mocker.patch.object(handler, "_read", side_effect=read_then_cancel)
|
||||
|
||||
claim = await handler.claim("dev-1")
|
||||
|
||||
assert claim == ([], [])
|
||||
assert await handler.get(request.id) is None
|
||||
|
||||
|
||||
class TestDiscardForDevice:
|
||||
async def test_drops_every_live_request_of_the_device(self, handler):
|
||||
pending = await _create(handler, rom_id=7)
|
||||
taken = await _taken(handler, rom_id=8)
|
||||
other_device = await _create(handler, device_id="dev-2", rom_id=7)
|
||||
|
||||
await handler.discard_for_device("dev-1")
|
||||
|
||||
await _assert_gone(pending)
|
||||
await _assert_gone(taken)
|
||||
assert not await async_cache.exists("install:device:dev-1")
|
||||
assert [r.id for r in await handler.list_for_rom(1, 7)] == [other_device.id]
|
||||
|
||||
async def test_a_device_without_requests_is_a_no_op(self, handler):
|
||||
await handler.discard_for_device("dev-1")
|
||||
|
||||
assert not await async_cache.exists("install:device:dev-1")
|
||||
|
||||
async def test_a_broker_failure_leaves_the_device_deletion_standing(
|
||||
self, mocker, handler
|
||||
):
|
||||
mocker.patch.object(
|
||||
handler, "list_for_device", side_effect=ConnectionError("redis")
|
||||
)
|
||||
|
||||
await handler.discard_for_device("dev-1")
|
||||
|
||||
|
||||
class TestDedupe:
|
||||
@pytest.mark.parametrize("claim", [False, True])
|
||||
async def test_a_second_create_returns_the_live_request(self, handler, claim):
|
||||
first = await (_taken if claim else _create)(handler)
|
||||
|
||||
second, created = await handler.create(
|
||||
user_id=1, device_id="dev-1", rom_id=7, file_ids=[10]
|
||||
)
|
||||
|
||||
assert not created
|
||||
assert second.id == first.id
|
||||
assert len(await handler.list_for_device("dev-1")) == 1
|
||||
|
||||
async def test_each_device_gets_its_own_request(self, handler):
|
||||
first = await _create(handler, device_id="dev-1")
|
||||
second = await _create(handler, device_id="dev-2")
|
||||
|
||||
assert first.id != second.id
|
||||
|
||||
@pytest.mark.parametrize("outcome", [*OUTCOMES, InstallStatus.CANCELLED])
|
||||
async def test_an_ended_request_frees_the_rom_for_a_new_one(self, handler, outcome):
|
||||
first = await _taken(handler)
|
||||
if outcome == InstallStatus.CANCELLED:
|
||||
await handler.cancel(first.id)
|
||||
else:
|
||||
await handler.report(first.id, outcome, None)
|
||||
|
||||
second = await _create(handler)
|
||||
|
||||
assert second.id != first.id
|
||||
assert await _active_id(7) == second.id
|
||||
@@ -0,0 +1,65 @@
|
||||
import pytest
|
||||
|
||||
from handler import device_install_policy as policy_module
|
||||
from handler.device_install_policy import (
|
||||
InstallNotAllowedError,
|
||||
is_installable_platform,
|
||||
select_install_files,
|
||||
)
|
||||
from models.rom import RomFile, RomFileCategory
|
||||
|
||||
|
||||
def _file(file_id: int, category: RomFileCategory | None, missing: bool = False):
|
||||
return RomFile(
|
||||
id=file_id,
|
||||
file_name=f"f{file_id}",
|
||||
file_path="p",
|
||||
category=category,
|
||||
missing_from_fs=missing,
|
||||
)
|
||||
|
||||
|
||||
FILES = [
|
||||
_file(4, RomFileCategory.DLC),
|
||||
_file(1, None),
|
||||
_file(2, RomFileCategory.GAME),
|
||||
_file(3, RomFileCategory.UPDATE),
|
||||
_file(5, RomFileCategory.PATCH),
|
||||
_file(6, RomFileCategory.SOUNDTRACK),
|
||||
_file(7, RomFileCategory.SCREENSHOT),
|
||||
_file(8, RomFileCategory.CHEAT),
|
||||
_file(9, RomFileCategory.MANUAL),
|
||||
_file(10, RomFileCategory.WALKTHROUGH),
|
||||
_file(11, RomFileCategory.GAME, missing=True),
|
||||
]
|
||||
|
||||
|
||||
class TestFileSelection:
|
||||
def test_takes_the_game_update_and_dlc_files_on_disk_in_order(self):
|
||||
assert select_install_files(FILES) == [1, 2, 3, 4]
|
||||
|
||||
def test_rejects_a_rom_with_nothing_installable(self):
|
||||
with pytest.raises(InstallNotAllowedError):
|
||||
select_install_files(
|
||||
[
|
||||
_file(6, RomFileCategory.SOUNDTRACK),
|
||||
_file(11, RomFileCategory.GAME, missing=True),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestPlatformExclusion:
|
||||
@pytest.mark.parametrize("slug", ["win", "win3x", "win9x", "windows-apps", "WIN"])
|
||||
def test_windows_platforms_are_excluded_by_default(self, slug):
|
||||
assert not is_installable_platform(slug)
|
||||
|
||||
def test_other_platforms_are_installable(self):
|
||||
assert is_installable_platform("n64")
|
||||
|
||||
def test_the_configured_slugs_decide(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
policy_module, "DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS", frozenset({"n64"})
|
||||
)
|
||||
|
||||
assert not is_installable_platform("n64")
|
||||
assert is_installable_platform("win")
|
||||
@@ -8,8 +8,10 @@ import socketio
|
||||
from handler import socket_handler as socket_handler_module
|
||||
from handler.database import db_user_handler
|
||||
from handler.socket_handler import (
|
||||
DEVICES_NAMESPACE,
|
||||
LOGIN_SESSION_ID_KEY,
|
||||
SocketHandler,
|
||||
close_client_token_sockets,
|
||||
close_login_session_sockets,
|
||||
netplay_socket_handler,
|
||||
socket_handler,
|
||||
@@ -125,7 +127,7 @@ class TestLoginSessionSockets:
|
||||
await handler.close_login_sessions(["s1"])
|
||||
|
||||
cache.delete.assert_awaited_once_with("session_sockets:socketio:s1")
|
||||
disconnect.assert_awaited_once_with("sid-1")
|
||||
disconnect.assert_awaited_once_with("sid-1", namespace=None)
|
||||
|
||||
async def test_a_broker_failure_moves_on_to_the_next_session(self, mocker, cache):
|
||||
handler = SocketHandler(path="/test")
|
||||
@@ -154,6 +156,83 @@ class TestLoginSessionSockets:
|
||||
cache.sadd.assert_awaited_once_with("session_sockets:netplay:s1", "sid-1")
|
||||
|
||||
|
||||
class TestTrackedSockets:
|
||||
async def test_tracking_adds_the_socket_and_renews_the_ttl(self, cache):
|
||||
handler = SocketHandler(path="/test")
|
||||
|
||||
await handler.track("some:key", "sid-1", 90)
|
||||
|
||||
cache.sadd.assert_awaited_once_with("some:key", "sid-1")
|
||||
cache.expire.assert_awaited_once_with("some:key", 90)
|
||||
|
||||
async def test_untracking_drops_the_socket(self, cache):
|
||||
handler = SocketHandler(path="/test")
|
||||
|
||||
await handler.untrack("some:key", "sid-1")
|
||||
|
||||
cache.srem.assert_awaited_once_with("some:key", "sid-1")
|
||||
|
||||
async def test_untracking_swallows_a_broker_failure(self, cache):
|
||||
cache.srem.side_effect = ConnectionError("redis")
|
||||
handler = SocketHandler(path="/test")
|
||||
|
||||
await handler.untrack("some:key", "sid-1")
|
||||
|
||||
async def test_closing_disconnects_on_the_given_namespace(self, mocker, cache):
|
||||
handler = SocketHandler(path="/test")
|
||||
disconnect = mocker.patch.object(
|
||||
handler.socket_server, "disconnect", AsyncMock()
|
||||
)
|
||||
cache.smembers.return_value = {b"sid-1"}
|
||||
|
||||
await handler.close_tracked("some:key", "/devices")
|
||||
|
||||
cache.delete.assert_awaited_once_with("some:key")
|
||||
disconnect.assert_awaited_once_with("sid-1", namespace="/devices")
|
||||
|
||||
async def test_a_failed_disconnect_still_closes_the_other_sockets(
|
||||
self, mocker, cache
|
||||
):
|
||||
handler = SocketHandler(path="/test")
|
||||
|
||||
async def fail_for_the_first(sid: str, namespace: str | None = None) -> None:
|
||||
if sid == "sid-1":
|
||||
raise ConnectionError("broker")
|
||||
|
||||
disconnect = mocker.patch.object(
|
||||
handler.socket_server,
|
||||
"disconnect",
|
||||
AsyncMock(side_effect=fail_for_the_first),
|
||||
)
|
||||
cache.smembers.return_value = {b"sid-1", b"sid-2"}
|
||||
|
||||
await handler.close_tracked("some:key", "/devices")
|
||||
|
||||
disconnect.assert_any_await("sid-2", namespace="/devices")
|
||||
assert disconnect.await_count == 2
|
||||
|
||||
|
||||
class TestClientTokenSockets:
|
||||
async def test_closing_a_token_disconnects_its_device_sockets(self, mocker):
|
||||
close = mocker.patch.object(socket_handler, "close_tracked", AsyncMock())
|
||||
|
||||
await close_client_token_sockets([42])
|
||||
|
||||
close.assert_awaited_once_with("device_token_sockets:42", DEVICES_NAMESPACE)
|
||||
|
||||
async def test_a_broker_failure_moves_on_to_the_next_token(self, mocker, cache):
|
||||
cache.smembers.side_effect = [{b"sid-1"}, {b"sid-2"}]
|
||||
disconnect = mocker.patch.object(
|
||||
socket_handler.socket_server,
|
||||
"disconnect",
|
||||
AsyncMock(side_effect=[ConnectionError("redis"), None]),
|
||||
)
|
||||
|
||||
await close_client_token_sockets([1, 2])
|
||||
|
||||
assert disconnect.await_count == 2
|
||||
|
||||
|
||||
async def test_revoking_a_session_closes_its_sockets_on_every_server(mocker):
|
||||
main = mocker.patch.object(socket_handler, "close_login_sessions", AsyncMock())
|
||||
netplay = mocker.patch.object(
|
||||
|
||||
@@ -239,6 +239,7 @@ def _replay(connection: sa.Connection, filename: str) -> None:
|
||||
("0138_exact_save_slots.py", "saves"),
|
||||
("0139_rom_user_pinned_media.py", "rom_user"),
|
||||
("0140_device_save_sync_baseline.py", "device_save_sync"),
|
||||
("0141_device_capabilities.py", "devices"),
|
||||
],
|
||||
)
|
||||
def test_a_revision_replayed_over_the_migrated_schema_is_a_no_op(
|
||||
@@ -274,6 +275,22 @@ def test_the_play_session_sync_link_revision_reverses_and_replays():
|
||||
assert _schema_of(connection, "play_sessions") == before
|
||||
|
||||
|
||||
def test_the_device_capabilities_revision_reverses_and_replays():
|
||||
migration = _load_migration("0141_device_capabilities.py")
|
||||
|
||||
with sync_engine.begin() as connection:
|
||||
before = _schema_of(connection, "devices")
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.downgrade()
|
||||
assert not has_column(connection, "devices", "capabilities")
|
||||
|
||||
migration.downgrade()
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
assert _schema_of(connection, "devices") == before
|
||||
|
||||
|
||||
def _slot_collations(connection: sa.Connection) -> dict[str, str | None]:
|
||||
inspector = sa.inspect(connection)
|
||||
collations = {}
|
||||
|
||||
+118
-28
@@ -914,6 +914,12 @@ A reset link is emailed when SMTP is set up, the user has an address and `ROMM_B
|
||||
| GET | `/{id}/files` | ROMS_READ | Get ROM file metadata |
|
||||
| GET | `/{id}/files/content/{name}` | ROMS_READ | Download file (nginx X-Accel or direct) |
|
||||
|
||||
#### ROM Installs
|
||||
|
||||
| Method | Path | Scope | Description |
|
||||
| ------ | ---------------- | ----------------------- | ---------------------------------------------- |
|
||||
| GET | `/{id}/installs` | DEVICES_READ, ROMS_READ | The caller's live install requests for the ROM |
|
||||
|
||||
### 6.6 Music (`/api/music`)
|
||||
|
||||
Music-first read API over soundtrack `track_meta`, for external music-player clients. All routes are visibility-filtered (hidden platforms/ROMs excluded).
|
||||
@@ -1020,13 +1026,21 @@ World`), so a name shared across platforms resolves to the lowest visible ROM id
|
||||
|
||||
### 6.11 Devices (`/api/devices`)
|
||||
|
||||
| Method | Path | Scope | Description |
|
||||
| ------ | ------- | ------------- | ----------------------------------- |
|
||||
| POST | `/` | DEVICES_WRITE | Register device (fingerprint dedup) |
|
||||
| GET | `/` | DEVICES_READ | List devices |
|
||||
| GET | `/{id}` | DEVICES_READ | Get device |
|
||||
| PUT | `/{id}` | DEVICES_WRITE | Update device |
|
||||
| DELETE | `/{id}` | DEVICES_WRITE | Delete device |
|
||||
| Method | Path | Scope | Description |
|
||||
| ------ | ------------------------- | ------------------------ | ------------------------------------------------------------- |
|
||||
| POST | `/` | DEVICES_WRITE | Register device (fingerprint dedup) |
|
||||
| GET | `/` | DEVICES_READ | List devices |
|
||||
| GET | `/online` | DEVICES_READ | Ids of devices with a live device socket |
|
||||
| GET | `/{id}` | DEVICES_READ | Get device |
|
||||
| PUT | `/{id}` | DEVICES_WRITE | Update device |
|
||||
| DELETE | `/{id}` | DEVICES_WRITE | Delete device |
|
||||
| POST | `/{id}/installs` | DEVICES_WRITE, ROMS_READ | Queue a rom for install on the device |
|
||||
| GET | `/{id}/installs` | DEVICES_READ | List pending and taken install requests |
|
||||
| POST | `/{id}/installs/claim` | DEVICES_WRITE, ROMS_READ | Device-bound token takes every pending one, returns all taken |
|
||||
| PUT | `/{id}/installs/{req_id}` | DEVICES_WRITE | Device-bound token reports a taken outcome |
|
||||
| DELETE | `/{id}/installs/{req_id}` | DEVICES_WRITE | Cancel a pending or taken request |
|
||||
|
||||
Register and update take `capabilities`, boolean flags such as `{"remote_install": true}`, stored on `devices.capabilities`. A register or update that omits the field leaves the stored flags as they are.
|
||||
|
||||
### 6.12 Collections (`/api/collections`)
|
||||
|
||||
@@ -1396,7 +1410,7 @@ Manages two Socket.IO servers:
|
||||
| `socket_handler` | `/ws` | Scan progress, general notifications |
|
||||
| `netplay_socket_handler` | `/netplay` | Netplay room management |
|
||||
|
||||
Both use Redis as the message queue backend for horizontal scaling, each on its own channel: netplay clients are unauthenticated and name their own rooms, so they must never see the `user:{id}` or `admin` rooms. `socket_handler.emit_to_user(user_id, event, payload)` pushes to one user's open tabs from the web process or a worker.
|
||||
Both use Redis as the message queue backend for horizontal scaling, each on its own channel: netplay clients are unauthenticated and name their own rooms, so they must never see the `user:{id}` or `admin` rooms. `socket_handler.emit_to_user(user_id, event, payload)` pushes to one user's open tabs from the web process or a worker. Registered devices connect to the `/devices` namespace of the `/ws` server (see Devices below); rooms and events are per namespace, so the web UI never receives device traffic.
|
||||
|
||||
**Scan Progress Events:**
|
||||
|
||||
@@ -1553,6 +1567,68 @@ relay.
|
||||
|
||||
Redis-backed for horizontal scaling across multiple server instances.
|
||||
|
||||
### Devices (namespace `/devices` on `/ws/socket.io`)
|
||||
|
||||
Only a registered device connects, with a client token bound to it
|
||||
(`ClientToken.device_id`) that holds `devices.read`, sent as `token` in the
|
||||
handshake auth payload or as an `Authorization: Bearer` header. A browser
|
||||
session is never accepted, and every connection is refused while
|
||||
`DEVICE_INSTALL_ENABLED` is off. Connecting bumps the token's `last_used_at`
|
||||
and the device's `last_seen`; capabilities travel over the device REST
|
||||
endpoints. The socket joins `device:{device_id}` in the namespace and is
|
||||
closed when its token is deleted, regenerated or re-paired, its device or
|
||||
owner is deleted, its owner is disabled, or its owner's permissions change,
|
||||
and within 30 seconds of the token's `expires_at`. Each open socket refreshes `device_presence:{device_id}` and its token's
|
||||
`device_token_sockets:{id}` every 30 seconds. The presence key lapses 90
|
||||
seconds after the last refresh and loses the socket on disconnect;
|
||||
`GET /api/devices/online` lists the caller's devices whose key is live. The
|
||||
per-token socket sets exist only to close sockets on revocation and never
|
||||
answer presence.
|
||||
|
||||
| Event | Direction | Description |
|
||||
| ------------------- | ---------------------- | ---------------------------------------------------------- |
|
||||
| `install:queued` | Server to device | `{id, rom_id}`: an install request is waiting |
|
||||
| `install:cancelled` | Server to device | `{id, rom_id}`: the owner cancelled a pending or taken one |
|
||||
| `install:updated` | Server to owner on `/` | An `InstallRequestSchema` changed status |
|
||||
|
||||
An install request exists only while it is `pending` or `taken`. It lives in
|
||||
Redis as JSON under `install:req:{id}`, indexed by `install:device:{device_id}`
|
||||
and `install:rom:{user_id}:{rom_id}`;
|
||||
`install:active:{user_id}:{device_id}:{rom_id}` names the one live request per
|
||||
rom and device, so a second create returns it. A live request expires
|
||||
`DEVICE_INSTALL_REQUEST_TTL_DAYS` after its last change (never when that is 0),
|
||||
which bounds an abandoned one. A request carries the rom's game, update and DLC
|
||||
files on disk; a rom without any, a rom on a platform in
|
||||
`DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS`, or a device whose `capabilities` lack
|
||||
`remote_install`, is a 400.
|
||||
|
||||
The owner creates requests (`POST /api/devices/{device_id}/installs`), reads a
|
||||
rom's live ones (`GET /api/roms/{id}/installs`) and cancels one still pending or
|
||||
taken (`DELETE /api/devices/{device_id}/installs/{id}`).
|
||||
|
||||
Every client drains its queue through `POST
|
||||
/api/devices/{device_id}/installs/claim`, which moves all pending requests to
|
||||
`taken` in one Redis transaction and returns every request the device holds
|
||||
taken, so a device that lost a claim's response gets them back on its next
|
||||
claim. Only newly taken requests emit `install:updated`. The socket is optional: a
|
||||
client holding it claims on `install:queued`, and one that cannot hold a socket
|
||||
polls claim on its own schedule. Only a client token bound to the device claims
|
||||
or reports. A report (`PUT /api/devices/{device_id}/installs/{id}`) names
|
||||
`done`, `already_installed` or `failed` for a taken request.
|
||||
|
||||
Each operation checks the status it needs and writes inside one Redis `WATCH`
|
||||
transaction: claim takes only pending requests, a report needs a taken one, and
|
||||
a cancel a pending or taken one. A request in another live status is a 409.
|
||||
A report or a cancel ends the request: the server emits `install:updated` with
|
||||
the final status, deletes the request, its index entries and its
|
||||
`install:active` key in the same transaction, and for a report notifies the
|
||||
owner (`device_install_completed` or `device_install_failed`, with the reason).
|
||||
A report or cancel for a request that no longer exists is a 404, so a device
|
||||
treats a 404 on a retried report as already handled, and a cancel racing a
|
||||
report cannot both succeed. A cancel racing a claim either keeps the request
|
||||
out of that claim or cancels it after the claim took it. Deleting a device, or its owner, drops
|
||||
the device's live requests.
|
||||
|
||||
---
|
||||
|
||||
## 11. Background Tasks & Scheduling
|
||||
@@ -1700,26 +1776,32 @@ Falls back to `FakeRedis` in test mode.
|
||||
|
||||
### Cache Key Patterns
|
||||
|
||||
| Pattern | TTL | Content |
|
||||
| --------------------------------- | --------------- | ----------------------------------- |
|
||||
| `session:{id}` | 14 days | Session JSON |
|
||||
| `user_sessions:{username}` | 14 days | Set of session IDs |
|
||||
| `session_sockets:{channel}:{id}` | 14 days | Socket IDs a session opened |
|
||||
| `notification-channel:{id}:code` | 30 min | Hash of an email confirmation code |
|
||||
| `notification-channel-cooldown:*` | 1 min | A user's and an address's last code |
|
||||
| `reset-email:{user_id}` | 1 min | A user's last emailed reset link |
|
||||
| `reset-jti:{jti}` | 10 min | Password reset token (one-time) |
|
||||
| `invite-jti:{jti}` | 10 min | Invite token (one-time) |
|
||||
| `refresh-jti:{jti}` | 7 days | Refresh token validation |
|
||||
| `romm:mame_index` | Permanent | MAME game index |
|
||||
| `romm:scummvm_index` | Permanent | ScummVM game index |
|
||||
| `romm:ps1_serials` | Permanent | PS1 serial codes |
|
||||
| `romm:ps2_serials` | Permanent | PS2 serial codes |
|
||||
| `romm:psp_serials` | Permanent | PSP serial codes |
|
||||
| `romm:switch_titledb` | Refreshed daily | Switch TitleDB |
|
||||
| `romm:known_bios` | Permanent | Verified BIOS hashes |
|
||||
| Upload sessions | 24 hours | Chunked upload state |
|
||||
| Netplay rooms | Dynamic | Active room state |
|
||||
| Pattern | TTL | Content |
|
||||
| ----------------------------------- | --------------- | ----------------------------------- |
|
||||
| `session:{id}` | 14 days | Session JSON |
|
||||
| `user_sessions:{username}` | 14 days | Set of session IDs |
|
||||
| `session_sockets:{channel}:{id}` | 14 days | Socket IDs a session opened |
|
||||
| `device_token_sockets:{id}` | 14 days | Device socket IDs a token opened |
|
||||
| `device_presence:{device_id}` | 90 s | A device's recently live socket IDs |
|
||||
| `install:req:{id}` | 2 days | A live install request (JSON) |
|
||||
| `install:active:{u}:{device}:{rom}` | 2 days | The pending or taken request's id |
|
||||
| `install:device:{device_id}` | 2 days | A device's install requests |
|
||||
| `install:rom:{user_id}:{rom_id}` | 2 days | A user's install requests for a rom |
|
||||
| `notification-channel:{id}:code` | 30 min | Hash of an email confirmation code |
|
||||
| `notification-channel-cooldown:*` | 1 min | A user's and an address's last code |
|
||||
| `reset-email:{user_id}` | 1 min | A user's last emailed reset link |
|
||||
| `reset-jti:{jti}` | 10 min | Password reset token (one-time) |
|
||||
| `invite-jti:{jti}` | 10 min | Invite token (one-time) |
|
||||
| `refresh-jti:{jti}` | 7 days | Refresh token validation |
|
||||
| `romm:mame_index` | Permanent | MAME game index |
|
||||
| `romm:scummvm_index` | Permanent | ScummVM game index |
|
||||
| `romm:ps1_serials` | Permanent | PS1 serial codes |
|
||||
| `romm:ps2_serials` | Permanent | PS2 serial codes |
|
||||
| `romm:psp_serials` | Permanent | PSP serial codes |
|
||||
| `romm:switch_titledb` | Refreshed daily | Switch TitleDB |
|
||||
| `romm:known_bios` | Permanent | Verified BIOS hashes |
|
||||
| Upload sessions | 24 hours | Chunked upload state |
|
||||
| Netplay rooms | Dynamic | Active room state |
|
||||
|
||||
---
|
||||
|
||||
@@ -1847,6 +1929,14 @@ Falls back to `FakeRedis` in test mode.
|
||||
| `SYNC_SSH_KNOWN_HOSTS_PATH` | | SSH known hosts path |
|
||||
| `SYNC_RETROARCH_PSP_SERIAL_MAP` | `{}` | JSON map of PSP serial to ROM file name |
|
||||
|
||||
#### Device Install
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------------------------------- | ------------------------------ | ------------------------------------------------------------- |
|
||||
| `DEVICE_INSTALL_ENABLED` | `true` | Let users push a ROM to one of their devices |
|
||||
| `DEVICE_INSTALL_REQUEST_TTL_DAYS` | `2` | Unfinished request lifetime after its last change; 0 keeps it |
|
||||
| `DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS` | `win,win3x,win9x,windows-apps` | Platforms that can never be pushed |
|
||||
|
||||
### YAML Configuration (`config.yml`)
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -131,6 +131,11 @@ SYNC_SSH_KEYS_PATH= # Path to SSH keys for sync remotes (defaults to $ROMM_BASE
|
||||
SYNC_SSH_KNOWN_HOSTS_PATH= # Path to SSH known_hosts (defaults to $ROMM_BASE_PATH/sync/known_hosts)
|
||||
SYNC_RETROARCH_PSP_SERIAL_MAP= # JSON map of PSP serial to extensionless ROM file name, for RetroArch PSP saves whose title matches no ROM
|
||||
|
||||
# Device install
|
||||
DEVICE_INSTALL_ENABLED=true # Let users push a ROM to one of their registered devices for install
|
||||
DEVICE_INSTALL_REQUEST_TTL_DAYS=2 # Days an unfinished install request waits for its device after its last change; 0 waits forever
|
||||
DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS=win,win3x,win9x,windows-apps # Comma-separated platform slugs that can never be pushed to a device
|
||||
|
||||
# Emulation
|
||||
DISABLE_EMULATOR_JS=false # Disable in-browser play via EmulatorJS
|
||||
DISABLE_RUFFLE_RS=false # Disable in-browser Flash playback via RuffleRS
|
||||
|
||||
Generated
+5
@@ -100,6 +100,7 @@ export type { DeviceAuthTokenResponse } from './models/DeviceAuthTokenResponse';
|
||||
export type { DeviceCreatePayload } from './models/DeviceCreatePayload';
|
||||
export type { DeviceCreateResponse } from './models/DeviceCreateResponse';
|
||||
export type { DeviceHeartbeatPayload } from './models/DeviceHeartbeatPayload';
|
||||
export type { DeviceInstallDict } from './models/DeviceInstallDict';
|
||||
export type { DeviceSchema } from './models/DeviceSchema';
|
||||
export type { DeviceSyncSchema } from './models/DeviceSyncSchema';
|
||||
export type { DeviceUpdatePayload } from './models/DeviceUpdatePayload';
|
||||
@@ -132,6 +133,10 @@ export type { IGDBMetadataMultiplayerMode } from './models/IGDBMetadataMultiplay
|
||||
export type { IGDBMetadataPlatform } from './models/IGDBMetadataPlatform';
|
||||
export type { IGDBRelatedGame } from './models/IGDBRelatedGame';
|
||||
export type { ImportRefusalSchema } from './models/ImportRefusalSchema';
|
||||
export type { InstallRequestCreatePayload } from './models/InstallRequestCreatePayload';
|
||||
export type { InstallRequestSchema } from './models/InstallRequestSchema';
|
||||
export type { InstallRequestUpdatePayload } from './models/InstallRequestUpdatePayload';
|
||||
export type { InstallStatus } from './models/InstallStatus';
|
||||
export type { InviteLinkSchema } from './models/InviteLinkSchema';
|
||||
export type { JobStatus } from './models/JobStatus';
|
||||
export type { JoinableSessionSchema } from './models/JoinableSessionSchema';
|
||||
|
||||
@@ -13,6 +13,7 @@ export type DeviceCreatePayload = {
|
||||
hostname?: (string | null);
|
||||
sync_mode?: (SyncMode | null);
|
||||
sync_config?: (Record<string, any> | null);
|
||||
capabilities?: (Record<string, boolean> | null);
|
||||
allow_existing?: boolean;
|
||||
allow_duplicate?: boolean;
|
||||
reset_syncs?: boolean;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DeviceInstallDict = {
|
||||
ENABLED: boolean;
|
||||
EXCLUDED_PLATFORM_SLUGS: Array<string>;
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export type DeviceSchema = {
|
||||
sync_mode: SyncMode;
|
||||
sync_enabled: boolean;
|
||||
sync_config: (Record<string, any> | null);
|
||||
capabilities: (Record<string, any> | null);
|
||||
last_seen: (string | null);
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -14,5 +14,6 @@ export type DeviceUpdatePayload = {
|
||||
sync_enabled?: (boolean | null);
|
||||
sync_mode?: (SyncMode | null);
|
||||
sync_config?: (Record<string, any> | null);
|
||||
capabilities?: (Record<string, boolean> | null);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { DeviceInstallDict } from './DeviceInstallDict';
|
||||
import type { EmulationDict } from './EmulationDict';
|
||||
import type { FilesystemDict } from './FilesystemDict';
|
||||
import type { FrontendDict } from './FrontendDict';
|
||||
@@ -18,6 +19,7 @@ export type HeartbeatResponse = {
|
||||
FRONTEND: FrontendDict;
|
||||
OIDC: OIDCDict;
|
||||
NOTIFICATIONS: NotificationsDict;
|
||||
DEVICE_INSTALL: DeviceInstallDict;
|
||||
TASKS: TasksDict;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type InstallRequestCreatePayload = {
|
||||
rom_id: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { InstallStatus } from './InstallStatus';
|
||||
/**
|
||||
* An install request, as served and as stored in Redis while pending or taken.
|
||||
*/
|
||||
export type InstallRequestSchema = {
|
||||
id: string;
|
||||
user_id: number;
|
||||
device_id: string;
|
||||
rom_id: number;
|
||||
file_ids: Array<number>;
|
||||
status: InstallStatus;
|
||||
reason: (string | null);
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { InstallStatus } from './InstallStatus';
|
||||
export type InstallRequestUpdatePayload = {
|
||||
status: InstallStatus;
|
||||
reason?: (string | null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type InstallStatus = 'pending' | 'taken' | 'done' | 'already_installed' | 'failed' | 'cancelled';
|
||||
+1
-1
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* What happened, translated by the client; any other kind shows its own title and body.
|
||||
*/
|
||||
export type NotificationKind = 'custom' | 'scan_completed' | 'scan_failed' | 'task_completed' | 'task_failed' | 'streaming_session_ended' | 'role_changed' | 'channel_disabled';
|
||||
export type NotificationKind = 'custom' | 'scan_completed' | 'scan_failed' | 'task_completed' | 'task_failed' | 'streaming_session_ended' | 'role_changed' | 'channel_disabled' | 'device_install_completed' | 'device_install_failed';
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* The groups a notification channel picks what it forwards by.
|
||||
*/
|
||||
export type NotificationTopic = 'scans' | 'tasks' | 'streaming' | 'account' | 'custom';
|
||||
export type NotificationTopic = 'scans' | 'tasks' | 'streaming' | 'devices' | 'account' | 'custom';
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Все още няма канали",
|
||||
"channels-empty-hint": "Препращайте известията си извън RomM: към уебхук, имейл адрес и други.",
|
||||
"channels-load-failed": "Каналите ви не можаха да бъдат заредени",
|
||||
"device-install-already-installed": "{game}: вече е инсталирано на {device}",
|
||||
"device-install-completed": "{game}: добавено към изтеглянията на {device}",
|
||||
"device-install-failed": "{game}: не можа да бъде инсталирано на {device}",
|
||||
"device-install-unknown-game": "Игра",
|
||||
"device-install-your-device": "вашето устройство",
|
||||
"dismiss-all": "Затвори всички",
|
||||
"dismiss-all-body": "Всички известия ще бъдат премахнати.",
|
||||
"dismiss-all-title": "Затваряне на всички известия?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "Задачата е неуспешна: {task}",
|
||||
"topic-account": "Акаунт",
|
||||
"topic-custom": "Персонализирани",
|
||||
"topic-devices": "Устройства",
|
||||
"topic-scans": "Сканирания",
|
||||
"topic-streaming": "Стрийминг",
|
||||
"topic-tasks": "Задачи",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Скрито",
|
||||
"how-long-to-beat": "How Long to Beat",
|
||||
"info": "Информация",
|
||||
"install-on-device": "Инсталиране на устройство",
|
||||
"install-on-device-cancel-failed": "Инсталацията не можа да бъде отменена: {error}",
|
||||
"install-on-device-empty": "Все още нито едно устройство не може да получава инсталации. Влезте в RomM от приложение за устройство, което поддържа отдалечено инсталиране.",
|
||||
"install-on-device-failed": "Инсталацията не можа да бъде изпратена до устройството: {error}",
|
||||
"install-on-device-load-failed": "Устройствата ви не можаха да бъдат заредени",
|
||||
"install-on-device-preparing": "Подготовка на устройството…",
|
||||
"install-on-device-status-already-installed": "Вече е инсталирано",
|
||||
"install-on-device-status-cancelled": "Отменено",
|
||||
"install-on-device-status-done": "Добавено към изтеглянията",
|
||||
"install-on-device-status-failed": "Неуспешно",
|
||||
"install-on-device-status-pending": "Изчакване на устройството",
|
||||
"install-on-device-status-taken": "Изтегля се",
|
||||
"join-session": "Присъединяване към сесия",
|
||||
"join-session-of": "Присъединяване към сесията на {user}",
|
||||
"labels-applied-n": "Добавени етикети към {n} елемент | Добавени етикети към {n} елемента",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Zatím žádné kanály",
|
||||
"channels-empty-hint": "Přeposílejte oznámení mimo RomM: na webhook, e-mailovou adresu a další.",
|
||||
"channels-load-failed": "Vaše kanály se nepodařilo načíst",
|
||||
"device-install-already-installed": "{game}: již nainstalováno na {device}",
|
||||
"device-install-completed": "{game}: přidáno do stahování na {device}",
|
||||
"device-install-failed": "{game}: instalace na {device} selhala",
|
||||
"device-install-unknown-game": "Hra",
|
||||
"device-install-your-device": "vašem zařízení",
|
||||
"dismiss-all": "Zavřít vše",
|
||||
"dismiss-all-body": "Všechna oznámení budou odstraněna.",
|
||||
"dismiss-all-title": "Zavřít všechna oznámení?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "Úloha selhala: {task}",
|
||||
"topic-account": "Účet",
|
||||
"topic-custom": "Vlastní",
|
||||
"topic-devices": "Zařízení",
|
||||
"topic-scans": "Skenování",
|
||||
"topic-streaming": "Streamování",
|
||||
"topic-tasks": "Úlohy",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Skryté",
|
||||
"how-long-to-beat": "Délka do dokončení",
|
||||
"info": "Info",
|
||||
"install-on-device": "Nainstalovat do zařízení",
|
||||
"install-on-device-cancel-failed": "Instalaci se nepodařilo zrušit: {error}",
|
||||
"install-on-device-empty": "Zatím žádné zařízení nemůže přijímat instalace. Přihlaste se do RomM z aplikace v zařízení, která podporuje vzdálenou instalaci.",
|
||||
"install-on-device-failed": "Instalaci se nepodařilo odeslat do zařízení: {error}",
|
||||
"install-on-device-load-failed": "Vaše zařízení se nepodařilo načíst",
|
||||
"install-on-device-preparing": "Připravuje se zařízení…",
|
||||
"install-on-device-status-already-installed": "Již nainstalováno",
|
||||
"install-on-device-status-cancelled": "Zrušeno",
|
||||
"install-on-device-status-done": "Přidáno do stahování",
|
||||
"install-on-device-status-failed": "Selhalo",
|
||||
"install-on-device-status-pending": "Čeká se na zařízení",
|
||||
"install-on-device-status-taken": "Stahuje se",
|
||||
"join-session": "Připojit se k relaci",
|
||||
"join-session-of": "Připojit se k relaci uživatele {user}",
|
||||
"labels-applied-n": "Štítky přidány k {n} položce | Štítky přidány k {n} položkám",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Noch keine Kanäle",
|
||||
"channels-empty-hint": "Leite deine Benachrichtigungen aus RomM heraus weiter: an einen Webhook, eine E-Mail-Adresse und mehr.",
|
||||
"channels-load-failed": "Deine Kanäle konnten nicht geladen werden",
|
||||
"device-install-already-installed": "{game} ist auf {device} bereits installiert",
|
||||
"device-install-completed": "{game} wurde auf {device} zu den Downloads hinzugefügt",
|
||||
"device-install-failed": "{game} konnte nicht auf {device} installiert werden",
|
||||
"device-install-unknown-game": "Ein Spiel",
|
||||
"device-install-your-device": "deinem Gerät",
|
||||
"dismiss-all": "Alle verwerfen",
|
||||
"dismiss-all-body": "Alle Benachrichtigungen werden entfernt.",
|
||||
"dismiss-all-title": "Alle Benachrichtigungen verwerfen?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "{task} fehlgeschlagen",
|
||||
"topic-account": "Konto",
|
||||
"topic-custom": "Benutzerdefiniert",
|
||||
"topic-devices": "Geräte",
|
||||
"topic-scans": "Scans",
|
||||
"topic-streaming": "Streaming",
|
||||
"topic-tasks": "Aufgaben",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Versteckt",
|
||||
"how-long-to-beat": "Spieldauer",
|
||||
"info": "Info",
|
||||
"install-on-device": "Auf Gerät installieren",
|
||||
"install-on-device-cancel-failed": "Die Installation konnte nicht abgebrochen werden: {error}",
|
||||
"install-on-device-empty": "Noch kann kein Gerät Installationen empfangen. Melde dich in einer Geräte-App, die Ferninstallation unterstützt, bei RomM an.",
|
||||
"install-on-device-failed": "Die Installation konnte nicht an das Gerät gesendet werden: {error}",
|
||||
"install-on-device-load-failed": "Deine Geräte konnten nicht geladen werden",
|
||||
"install-on-device-preparing": "Gerät wird vorbereitet…",
|
||||
"install-on-device-status-already-installed": "Bereits installiert",
|
||||
"install-on-device-status-cancelled": "Abgebrochen",
|
||||
"install-on-device-status-done": "Zu den Downloads hinzugefügt",
|
||||
"install-on-device-status-failed": "Fehlgeschlagen",
|
||||
"install-on-device-status-pending": "Wartet auf das Gerät",
|
||||
"install-on-device-status-taken": "Wird heruntergeladen",
|
||||
"join-session": "Sitzung beitreten",
|
||||
"join-session-of": "Sitzung von {user} beitreten",
|
||||
"labels-applied-n": "Labels zu {n} Element hinzugefügt | Labels zu {n} Elementen hinzugefügt",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "No channels yet",
|
||||
"channels-empty-hint": "Forward your notifications outside RomM, to a webhook, an email address and more.",
|
||||
"channels-load-failed": "Could not load your channels",
|
||||
"device-install-already-installed": "{game} is already installed on {device}",
|
||||
"device-install-completed": "{game} was added to downloads on {device}",
|
||||
"device-install-failed": "{game} could not be installed on {device}",
|
||||
"device-install-unknown-game": "A game",
|
||||
"device-install-your-device": "your device",
|
||||
"dismiss-all": "Dismiss all",
|
||||
"dismiss-all-body": "Every notification will be removed.",
|
||||
"dismiss-all-title": "Dismiss all notifications?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "{task} failed",
|
||||
"topic-account": "Account",
|
||||
"topic-custom": "Custom",
|
||||
"topic-devices": "Devices",
|
||||
"topic-scans": "Scans",
|
||||
"topic-streaming": "Streaming",
|
||||
"topic-tasks": "Tasks",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Hidden",
|
||||
"how-long-to-beat": "How Long to Beat",
|
||||
"info": "Info",
|
||||
"install-on-device": "Install on device",
|
||||
"install-on-device-cancel-failed": "Could not cancel the install: {error}",
|
||||
"install-on-device-empty": "No device can receive installs yet. Sign in to RomM from a device app that supports remote install.",
|
||||
"install-on-device-failed": "Could not send the install to the device: {error}",
|
||||
"install-on-device-load-failed": "Could not load your devices",
|
||||
"install-on-device-preparing": "Preparing device…",
|
||||
"install-on-device-status-already-installed": "Already installed",
|
||||
"install-on-device-status-cancelled": "Cancelled",
|
||||
"install-on-device-status-done": "Added to downloads",
|
||||
"install-on-device-status-failed": "Failed",
|
||||
"install-on-device-status-pending": "Waiting for the device",
|
||||
"install-on-device-status-taken": "Downloading",
|
||||
"join-session": "Join session",
|
||||
"join-session-of": "Join {user}'s session",
|
||||
"labels-applied-n": "Labels added to {n} item | Labels added to {n} items",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "No channels yet",
|
||||
"channels-empty-hint": "Forward your notifications outside RomM, to a webhook, an email address and more.",
|
||||
"channels-load-failed": "Could not load your channels",
|
||||
"device-install-already-installed": "{game} is already installed on {device}",
|
||||
"device-install-completed": "{game} was added to downloads on {device}",
|
||||
"device-install-failed": "{game} could not be installed on {device}",
|
||||
"device-install-unknown-game": "A game",
|
||||
"device-install-your-device": "your device",
|
||||
"dismiss-all": "Dismiss all",
|
||||
"dismiss-all-body": "Every notification will be removed.",
|
||||
"dismiss-all-title": "Dismiss all notifications?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "{task} failed",
|
||||
"topic-account": "Account",
|
||||
"topic-custom": "Custom",
|
||||
"topic-devices": "Devices",
|
||||
"topic-scans": "Scans",
|
||||
"topic-streaming": "Streaming",
|
||||
"topic-tasks": "Tasks",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Hidden",
|
||||
"how-long-to-beat": "How Long to Beat",
|
||||
"info": "Info",
|
||||
"install-on-device": "Install on device",
|
||||
"install-on-device-cancel-failed": "Could not cancel the install: {error}",
|
||||
"install-on-device-empty": "No device can receive installs yet. Sign in to RomM from a device app that supports remote install.",
|
||||
"install-on-device-failed": "Could not send the install to the device: {error}",
|
||||
"install-on-device-load-failed": "Could not load your devices",
|
||||
"install-on-device-preparing": "Preparing device…",
|
||||
"install-on-device-status-already-installed": "Already installed",
|
||||
"install-on-device-status-cancelled": "Canceled",
|
||||
"install-on-device-status-done": "Added to downloads",
|
||||
"install-on-device-status-failed": "Failed",
|
||||
"install-on-device-status-pending": "Waiting for the device",
|
||||
"install-on-device-status-taken": "Downloading",
|
||||
"join-session": "Join session",
|
||||
"join-session-of": "Join {user}'s session",
|
||||
"labels-applied-n": "Labels added to {n} item | Labels added to {n} items",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Aún no hay canales",
|
||||
"channels-empty-hint": "Reenvía tus notificaciones fuera de RomM: a un webhook, a un correo y más.",
|
||||
"channels-load-failed": "No se pudieron cargar tus canales",
|
||||
"device-install-already-installed": "{game} ya está instalado en {device}",
|
||||
"device-install-completed": "{game} se añadió a las descargas en {device}",
|
||||
"device-install-failed": "No se pudo instalar {game} en {device}",
|
||||
"device-install-unknown-game": "Un juego",
|
||||
"device-install-your-device": "tu dispositivo",
|
||||
"dismiss-all": "Descartar todas",
|
||||
"dismiss-all-body": "Se eliminarán todas las notificaciones.",
|
||||
"dismiss-all-title": "¿Descartar todas las notificaciones?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "{task} ha fallado",
|
||||
"topic-account": "Cuenta",
|
||||
"topic-custom": "Personalizadas",
|
||||
"topic-devices": "Dispositivos",
|
||||
"topic-scans": "Escaneos",
|
||||
"topic-streaming": "Streaming",
|
||||
"topic-tasks": "Tareas",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Oculto",
|
||||
"how-long-to-beat": "Tiempo de Juego",
|
||||
"info": "Info",
|
||||
"install-on-device": "Instalar en un dispositivo",
|
||||
"install-on-device-cancel-failed": "No se pudo cancelar la instalación: {error}",
|
||||
"install-on-device-empty": "Ningún dispositivo puede recibir instalaciones todavía. Inicia sesión en RomM desde una app de dispositivo que admita la instalación remota.",
|
||||
"install-on-device-failed": "No se pudo enviar la instalación al dispositivo: {error}",
|
||||
"install-on-device-load-failed": "No se pudieron cargar tus dispositivos",
|
||||
"install-on-device-preparing": "Preparando el dispositivo…",
|
||||
"install-on-device-status-already-installed": "Ya instalado",
|
||||
"install-on-device-status-cancelled": "Cancelado",
|
||||
"install-on-device-status-done": "Añadido a las descargas",
|
||||
"install-on-device-status-failed": "Error",
|
||||
"install-on-device-status-pending": "Esperando al dispositivo",
|
||||
"install-on-device-status-taken": "Descargando",
|
||||
"join-session": "Unirse a la sesión",
|
||||
"join-session-of": "Unirse a la sesión de {user}",
|
||||
"labels-applied-n": "Etiquetas añadidas a {n} elemento | Etiquetas añadidas a {n} elementos",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Aucun canal pour l'instant",
|
||||
"channels-empty-hint": "Transférez vos notifications hors de RomM : vers un webhook, une adresse e-mail et plus encore.",
|
||||
"channels-load-failed": "Impossible de charger vos canaux",
|
||||
"device-install-already-installed": "{game} est déjà installé sur {device}",
|
||||
"device-install-completed": "{game} a été ajouté aux téléchargements sur {device}",
|
||||
"device-install-failed": "{game} n'a pas pu être installé sur {device}",
|
||||
"device-install-unknown-game": "Un jeu",
|
||||
"device-install-your-device": "votre appareil",
|
||||
"dismiss-all": "Tout ignorer",
|
||||
"dismiss-all-body": "Toutes les notifications seront supprimées.",
|
||||
"dismiss-all-title": "Ignorer toutes les notifications ?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "Échec de la tâche : {task}",
|
||||
"topic-account": "Compte",
|
||||
"topic-custom": "Personnalisées",
|
||||
"topic-devices": "Appareils",
|
||||
"topic-scans": "Scans",
|
||||
"topic-streaming": "Streaming",
|
||||
"topic-tasks": "Tâches",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Caché",
|
||||
"how-long-to-beat": "Durée de Jeu",
|
||||
"info": "Info",
|
||||
"install-on-device": "Installer sur un appareil",
|
||||
"install-on-device-cancel-failed": "Impossible d'annuler l'installation : {error}",
|
||||
"install-on-device-empty": "Aucun appareil ne peut encore recevoir d'installation. Connectez-vous à RomM depuis une application qui prend en charge l'installation à distance.",
|
||||
"install-on-device-failed": "Impossible d'envoyer l'installation à l'appareil : {error}",
|
||||
"install-on-device-load-failed": "Impossible de charger vos appareils",
|
||||
"install-on-device-preparing": "Préparation de l'appareil…",
|
||||
"install-on-device-status-already-installed": "Déjà installé",
|
||||
"install-on-device-status-cancelled": "Annulé",
|
||||
"install-on-device-status-done": "Ajouté aux téléchargements",
|
||||
"install-on-device-status-failed": "Échec",
|
||||
"install-on-device-status-pending": "En attente de l'appareil",
|
||||
"install-on-device-status-taken": "Téléchargement en cours",
|
||||
"join-session": "Rejoindre la session",
|
||||
"join-session-of": "Rejoindre la session de {user}",
|
||||
"labels-applied-n": "Étiquettes ajoutées à {n} élément | Étiquettes ajoutées à {n} éléments",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Még nincsenek csatornák",
|
||||
"channels-empty-hint": "Továbbítsd az értesítéseidet a RomM-on kívülre: webhookra, e-mail-címre és máshová.",
|
||||
"channels-load-failed": "Nem sikerült betölteni a csatornáidat",
|
||||
"device-install-already-installed": "{game} már telepítve van ezen: {device}",
|
||||
"device-install-completed": "{game} hozzáadva a letöltésekhez ezen: {device}",
|
||||
"device-install-failed": "{game} telepítése nem sikerült ezen: {device}",
|
||||
"device-install-unknown-game": "Egy játék",
|
||||
"device-install-your-device": "az eszközöd",
|
||||
"dismiss-all": "Összes elvetése",
|
||||
"dismiss-all-body": "Az összes értesítés törlődik.",
|
||||
"dismiss-all-title": "Elveted az összes értesítést?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "A feladat sikertelen: {task}",
|
||||
"topic-account": "Fiók",
|
||||
"topic-custom": "Egyéni",
|
||||
"topic-devices": "Eszközök",
|
||||
"topic-scans": "Szkennelések",
|
||||
"topic-streaming": "Streamelés",
|
||||
"topic-tasks": "Feladatok",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Rejtett",
|
||||
"how-long-to-beat": "How Long to Beat",
|
||||
"info": "Info",
|
||||
"install-on-device": "Telepítés eszközre",
|
||||
"install-on-device-cancel-failed": "Nem sikerült megszakítani a telepítést: {error}",
|
||||
"install-on-device-empty": "Még egyetlen eszköz sem fogadhat telepítést. Jelentkezz be a RomM-be egy távoli telepítést támogató eszközalkalmazásból.",
|
||||
"install-on-device-failed": "Nem sikerült elküldeni a telepítést az eszközre: {error}",
|
||||
"install-on-device-load-failed": "Nem sikerült betölteni az eszközeidet",
|
||||
"install-on-device-preparing": "Eszköz előkészítése…",
|
||||
"install-on-device-status-already-installed": "Már telepítve",
|
||||
"install-on-device-status-cancelled": "Megszakítva",
|
||||
"install-on-device-status-done": "Hozzáadva a letöltésekhez",
|
||||
"install-on-device-status-failed": "Sikertelen",
|
||||
"install-on-device-status-pending": "Várakozás az eszközre",
|
||||
"install-on-device-status-taken": "Letöltés folyamatban",
|
||||
"join-session": "Csatlakozás a munkamenethez",
|
||||
"join-session-of": "Csatlakozás {user} munkamenetéhez",
|
||||
"labels-applied-n": "Címkék hozzáadva {n} elemhez | Címkék hozzáadva {n} elemhez",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Ancora nessun canale",
|
||||
"channels-empty-hint": "Inoltra le tue notifiche fuori da RomM: a un webhook, a un indirizzo email e altro.",
|
||||
"channels-load-failed": "Impossibile caricare i tuoi canali",
|
||||
"device-install-already-installed": "{game} è già installato su {device}",
|
||||
"device-install-completed": "{game} è stato aggiunto ai download su {device}",
|
||||
"device-install-failed": "Impossibile installare {game} su {device}",
|
||||
"device-install-unknown-game": "Un gioco",
|
||||
"device-install-your-device": "il tuo dispositivo",
|
||||
"dismiss-all": "Ignora tutte",
|
||||
"dismiss-all-body": "Tutte le notifiche verranno rimosse.",
|
||||
"dismiss-all-title": "Ignorare tutte le notifiche?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "Attività non riuscita: {task}",
|
||||
"topic-account": "Account",
|
||||
"topic-custom": "Personalizzate",
|
||||
"topic-devices": "Dispositivi",
|
||||
"topic-scans": "Scansioni",
|
||||
"topic-streaming": "Streaming",
|
||||
"topic-tasks": "Attività",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Nascosto",
|
||||
"how-long-to-beat": "Durata di Gioco",
|
||||
"info": "Info",
|
||||
"install-on-device": "Installa sul dispositivo",
|
||||
"install-on-device-cancel-failed": "Impossibile annullare l'installazione: {error}",
|
||||
"install-on-device-empty": "Nessun dispositivo può ancora ricevere installazioni. Accedi a RomM da un'app per dispositivi che supporta l'installazione remota.",
|
||||
"install-on-device-failed": "Impossibile inviare l'installazione al dispositivo: {error}",
|
||||
"install-on-device-load-failed": "Impossibile caricare i tuoi dispositivi",
|
||||
"install-on-device-preparing": "Preparazione del dispositivo…",
|
||||
"install-on-device-status-already-installed": "Già installato",
|
||||
"install-on-device-status-cancelled": "Annullato",
|
||||
"install-on-device-status-done": "Aggiunto ai download",
|
||||
"install-on-device-status-failed": "Non riuscito",
|
||||
"install-on-device-status-pending": "In attesa del dispositivo",
|
||||
"install-on-device-status-taken": "Download in corso",
|
||||
"join-session": "Partecipa alla sessione",
|
||||
"join-session-of": "Partecipa alla sessione di {user}",
|
||||
"labels-applied-n": "Etichette aggiunte a {n} elemento | Etichette aggiunte a {n} elementi",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "チャンネルはまだありません",
|
||||
"channels-empty-hint": "通知を RomM の外へ転送できます。Webhook、メールアドレスなどに送れます。",
|
||||
"channels-load-failed": "チャンネルを読み込めませんでした",
|
||||
"device-install-already-installed": "{game} は {device} にインストール済みです",
|
||||
"device-install-completed": "{game} を {device} のダウンロードに追加しました",
|
||||
"device-install-failed": "{game} を {device} にインストールできませんでした",
|
||||
"device-install-unknown-game": "ゲーム",
|
||||
"device-install-your-device": "お使いのデバイス",
|
||||
"dismiss-all": "すべて閉じる",
|
||||
"dismiss-all-body": "すべての通知が削除されます。",
|
||||
"dismiss-all-title": "すべての通知を閉じますか?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "タスクが失敗しました: {task}",
|
||||
"topic-account": "アカウント",
|
||||
"topic-custom": "カスタム",
|
||||
"topic-devices": "デバイス",
|
||||
"topic-scans": "スキャン",
|
||||
"topic-streaming": "ストリーミング",
|
||||
"topic-tasks": "タスク",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "非表示",
|
||||
"how-long-to-beat": "プレイ時間",
|
||||
"info": "詳細",
|
||||
"install-on-device": "デバイスにインストール",
|
||||
"install-on-device-cancel-failed": "インストールを取り消せませんでした:{error}",
|
||||
"install-on-device-empty": "インストールを受け取れるデバイスはまだありません。リモートインストールに対応したデバイスのアプリから RomM にサインインしてください。",
|
||||
"install-on-device-failed": "デバイスにインストールを送信できませんでした:{error}",
|
||||
"install-on-device-load-failed": "デバイスを読み込めませんでした",
|
||||
"install-on-device-preparing": "デバイスを準備しています…",
|
||||
"install-on-device-status-already-installed": "インストール済み",
|
||||
"install-on-device-status-cancelled": "取り消し済み",
|
||||
"install-on-device-status-done": "ダウンロードに追加しました",
|
||||
"install-on-device-status-failed": "失敗",
|
||||
"install-on-device-status-pending": "デバイスを待っています",
|
||||
"install-on-device-status-taken": "ダウンロード中",
|
||||
"join-session": "セッションに参加",
|
||||
"join-session-of": "{user} のセッションに参加",
|
||||
"labels-applied-n": "{n} 件にラベルを追加しました",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "아직 채널이 없습니다",
|
||||
"channels-empty-hint": "알림을 RomM 밖으로 전달하세요. 웹훅, 이메일 주소 등으로 보낼 수 있습니다.",
|
||||
"channels-load-failed": "채널을 불러올 수 없습니다",
|
||||
"device-install-already-installed": "{game}은(는) 이미 {device}에 설치되어 있습니다",
|
||||
"device-install-completed": "{game}을(를) {device}의 다운로드에 추가했습니다",
|
||||
"device-install-failed": "{game}을(를) {device}에 설치하지 못했습니다",
|
||||
"device-install-unknown-game": "게임",
|
||||
"device-install-your-device": "내 기기",
|
||||
"dismiss-all": "모두 닫기",
|
||||
"dismiss-all-body": "모든 알림이 삭제됩니다.",
|
||||
"dismiss-all-title": "모든 알림을 닫으시겠습니까?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "작업 실패: {task}",
|
||||
"topic-account": "계정",
|
||||
"topic-custom": "사용자 지정",
|
||||
"topic-devices": "기기",
|
||||
"topic-scans": "스캔",
|
||||
"topic-streaming": "스트리밍",
|
||||
"topic-tasks": "작업",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "숨김",
|
||||
"how-long-to-beat": "플레이 시간",
|
||||
"info": "정보",
|
||||
"install-on-device": "기기에 설치",
|
||||
"install-on-device-cancel-failed": "설치를 취소하지 못했습니다: {error}",
|
||||
"install-on-device-empty": "아직 설치를 받을 수 있는 기기가 없습니다. 원격 설치를 지원하는 기기 앱에서 RomM에 로그인하세요.",
|
||||
"install-on-device-failed": "기기로 설치를 보내지 못했습니다: {error}",
|
||||
"install-on-device-load-failed": "기기 목록을 불러오지 못했습니다",
|
||||
"install-on-device-preparing": "기기 준비 중…",
|
||||
"install-on-device-status-already-installed": "이미 설치됨",
|
||||
"install-on-device-status-cancelled": "취소됨",
|
||||
"install-on-device-status-done": "다운로드에 추가됨",
|
||||
"install-on-device-status-failed": "실패",
|
||||
"install-on-device-status-pending": "기기를 기다리는 중",
|
||||
"install-on-device-status-taken": "다운로드 중",
|
||||
"join-session": "세션 참가",
|
||||
"join-session-of": "{user}의 세션에 참가",
|
||||
"labels-applied-n": "{n}개 항목에 라벨을 추가했습니다",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Brak kanałów",
|
||||
"channels-empty-hint": "Przekazuj powiadomienia poza RomM: do webhooka, na adres e-mail i nie tylko.",
|
||||
"channels-load-failed": "Nie udało się wczytać Twoich kanałów",
|
||||
"device-install-already-installed": "{game}: już zainstalowano na {device}",
|
||||
"device-install-completed": "{game}: dodano do pobranych na {device}",
|
||||
"device-install-failed": "{game}: nie udało się zainstalować na {device}",
|
||||
"device-install-unknown-game": "Gra",
|
||||
"device-install-your-device": "twoim urządzeniu",
|
||||
"dismiss-all": "Odrzuć wszystkie",
|
||||
"dismiss-all-body": "Wszystkie powiadomienia zostaną usunięte.",
|
||||
"dismiss-all-title": "Odrzucić wszystkie powiadomienia?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "Zadanie nie powiodło się: {task}",
|
||||
"topic-account": "Konto",
|
||||
"topic-custom": "Własne",
|
||||
"topic-devices": "Urządzenia",
|
||||
"topic-scans": "Skanowania",
|
||||
"topic-streaming": "Streaming",
|
||||
"topic-tasks": "Zadania",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Ukryte",
|
||||
"how-long-to-beat": "Czas Gry",
|
||||
"info": "Informacje",
|
||||
"install-on-device": "Zainstaluj na urządzeniu",
|
||||
"install-on-device-cancel-failed": "Nie udało się anulować instalacji: {error}",
|
||||
"install-on-device-empty": "Żadne urządzenie nie może jeszcze odbierać instalacji. Zaloguj się do RomM z aplikacji na urządzeniu, która obsługuje zdalną instalację.",
|
||||
"install-on-device-failed": "Nie udało się wysłać instalacji na urządzenie: {error}",
|
||||
"install-on-device-load-failed": "Nie udało się wczytać twoich urządzeń",
|
||||
"install-on-device-preparing": "Przygotowywanie urządzenia…",
|
||||
"install-on-device-status-already-installed": "Już zainstalowano",
|
||||
"install-on-device-status-cancelled": "Anulowano",
|
||||
"install-on-device-status-done": "Dodano do pobranych",
|
||||
"install-on-device-status-failed": "Niepowodzenie",
|
||||
"install-on-device-status-pending": "Oczekiwanie na urządzenie",
|
||||
"install-on-device-status-taken": "Pobieranie",
|
||||
"join-session": "Dołącz do sesji",
|
||||
"join-session-of": "Dołącz do sesji użytkownika {user}",
|
||||
"labels-applied-n": "Dodano etykiety do {n} elementu | Dodano etykiety do {n} elementów",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Nenhum canal ainda",
|
||||
"channels-empty-hint": "Encaminhe suas notificações para fora do RomM: para um webhook, um endereço de e-mail e mais.",
|
||||
"channels-load-failed": "Não foi possível carregar seus canais",
|
||||
"device-install-already-installed": "{game} já está instalado em {device}",
|
||||
"device-install-completed": "{game} foi adicionado aos downloads em {device}",
|
||||
"device-install-failed": "Não foi possível instalar {game} em {device}",
|
||||
"device-install-unknown-game": "Um jogo",
|
||||
"device-install-your-device": "seu dispositivo",
|
||||
"dismiss-all": "Dispensar todas",
|
||||
"dismiss-all-body": "Todas as notificações serão removidas.",
|
||||
"dismiss-all-title": "Dispensar todas as notificações?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "Falha na tarefa: {task}",
|
||||
"topic-account": "Conta",
|
||||
"topic-custom": "Personalizadas",
|
||||
"topic-devices": "Dispositivos",
|
||||
"topic-scans": "Escaneamentos",
|
||||
"topic-streaming": "Streaming",
|
||||
"topic-tasks": "Tarefas",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Oculto",
|
||||
"how-long-to-beat": "Tempo de Jogo",
|
||||
"info": "Informações",
|
||||
"install-on-device": "Instalar no dispositivo",
|
||||
"install-on-device-cancel-failed": "Não foi possível cancelar a instalação: {error}",
|
||||
"install-on-device-empty": "Nenhum dispositivo pode receber instalações ainda. Entre no RomM a partir de um app de dispositivo que suporte instalação remota.",
|
||||
"install-on-device-failed": "Não foi possível enviar a instalação ao dispositivo: {error}",
|
||||
"install-on-device-load-failed": "Não foi possível carregar seus dispositivos",
|
||||
"install-on-device-preparing": "Preparando o dispositivo…",
|
||||
"install-on-device-status-already-installed": "Já instalado",
|
||||
"install-on-device-status-cancelled": "Cancelado",
|
||||
"install-on-device-status-done": "Adicionado aos downloads",
|
||||
"install-on-device-status-failed": "Falhou",
|
||||
"install-on-device-status-pending": "Aguardando o dispositivo",
|
||||
"install-on-device-status-taken": "Baixando",
|
||||
"join-session": "Entrar na sessão",
|
||||
"join-session-of": "Entrar na sessão de {user}",
|
||||
"labels-applied-n": "Etiquetas adicionadas a {n} item | Etiquetas adicionadas a {n} itens",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Încă nu există canale",
|
||||
"channels-empty-hint": "Redirecționează notificările în afara RomM: către un webhook, o adresă de e-mail și altele.",
|
||||
"channels-load-failed": "Canalele tale nu au putut fi încărcate",
|
||||
"device-install-already-installed": "{game} este deja instalat pe {device}",
|
||||
"device-install-completed": "{game} a fost adăugat la descărcări pe {device}",
|
||||
"device-install-failed": "{game} nu a putut fi instalat pe {device}",
|
||||
"device-install-unknown-game": "Un joc",
|
||||
"device-install-your-device": "dispozitivul tău",
|
||||
"dismiss-all": "Închide toate",
|
||||
"dismiss-all-body": "Toate notificările vor fi eliminate.",
|
||||
"dismiss-all-title": "Închizi toate notificările?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "Sarcina a eșuat: {task}",
|
||||
"topic-account": "Cont",
|
||||
"topic-custom": "Personalizate",
|
||||
"topic-devices": "Dispozitive",
|
||||
"topic-scans": "Scanări",
|
||||
"topic-streaming": "Streaming",
|
||||
"topic-tasks": "Sarcini",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Ascuns",
|
||||
"how-long-to-beat": "Timp de Joc",
|
||||
"info": "Informații",
|
||||
"install-on-device": "Instalează pe dispozitiv",
|
||||
"install-on-device-cancel-failed": "Instalarea nu a putut fi anulată: {error}",
|
||||
"install-on-device-empty": "Niciun dispozitiv nu poate primi încă instalări. Conectează-te la RomM dintr-o aplicație de dispozitiv care acceptă instalarea la distanță.",
|
||||
"install-on-device-failed": "Instalarea nu a putut fi trimisă către dispozitiv: {error}",
|
||||
"install-on-device-load-failed": "Dispozitivele tale nu au putut fi încărcate",
|
||||
"install-on-device-preparing": "Se pregătește dispozitivul…",
|
||||
"install-on-device-status-already-installed": "Deja instalat",
|
||||
"install-on-device-status-cancelled": "Anulat",
|
||||
"install-on-device-status-done": "Adăugat la descărcări",
|
||||
"install-on-device-status-failed": "Eșuat",
|
||||
"install-on-device-status-pending": "Se așteaptă dispozitivul",
|
||||
"install-on-device-status-taken": "Se descarcă",
|
||||
"join-session": "Alătură-te sesiunii",
|
||||
"join-session-of": "Alătură-te sesiunii lui {user}",
|
||||
"labels-applied-n": "Etichete adăugate la {n} element | Etichete adăugate la {n} elemente",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Каналов пока нет",
|
||||
"channels-empty-hint": "Пересылайте уведомления за пределы RomM: в вебхук, на адрес эл. почты и не только.",
|
||||
"channels-load-failed": "Не удалось загрузить ваши каналы",
|
||||
"device-install-already-installed": "{game}: уже установлено на {device}",
|
||||
"device-install-completed": "{game}: добавлено в загрузки на {device}",
|
||||
"device-install-failed": "{game}: не удалось установить на {device}",
|
||||
"device-install-unknown-game": "Игра",
|
||||
"device-install-your-device": "вашем устройстве",
|
||||
"dismiss-all": "Закрыть все",
|
||||
"dismiss-all-body": "Все уведомления будут удалены.",
|
||||
"dismiss-all-title": "Закрыть все уведомления?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "Задача не удалась: {task}",
|
||||
"topic-account": "Аккаунт",
|
||||
"topic-custom": "Пользовательские",
|
||||
"topic-devices": "Устройства",
|
||||
"topic-scans": "Сканирования",
|
||||
"topic-streaming": "Стриминг",
|
||||
"topic-tasks": "Задачи",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Скрыто",
|
||||
"how-long-to-beat": "Время Прохождения",
|
||||
"info": "Информация",
|
||||
"install-on-device": "Установить на устройство",
|
||||
"install-on-device-cancel-failed": "Не удалось отменить установку: {error}",
|
||||
"install-on-device-empty": "Пока ни одно устройство не может принимать установки. Войдите в RomM из приложения на устройстве, которое поддерживает удалённую установку.",
|
||||
"install-on-device-failed": "Не удалось отправить установку на устройство: {error}",
|
||||
"install-on-device-load-failed": "Не удалось загрузить ваши устройства",
|
||||
"install-on-device-preparing": "Подготовка устройства…",
|
||||
"install-on-device-status-already-installed": "Уже установлено",
|
||||
"install-on-device-status-cancelled": "Отменено",
|
||||
"install-on-device-status-done": "Добавлено в загрузки",
|
||||
"install-on-device-status-failed": "Ошибка",
|
||||
"install-on-device-status-pending": "Ожидание устройства",
|
||||
"install-on-device-status-taken": "Загружается",
|
||||
"join-session": "Присоединиться к сессии",
|
||||
"join-session-of": "Присоединиться к сессии {user}",
|
||||
"labels-applied-n": "Метки добавлены к {n} элементу | Метки добавлены к {n} элементам",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "Henüz kanal yok",
|
||||
"channels-empty-hint": "Bildirimlerinizi RomM dışına iletin: bir webhook'a, bir e-posta adresine ve daha fazlasına.",
|
||||
"channels-load-failed": "Kanallarınız yüklenemedi",
|
||||
"device-install-already-installed": "{game} zaten {device} üzerinde yüklü",
|
||||
"device-install-completed": "{game}, {device} üzerinde indirmelere eklendi",
|
||||
"device-install-failed": "{game}, {device} üzerine yüklenemedi",
|
||||
"device-install-unknown-game": "Bir oyun",
|
||||
"device-install-your-device": "cihazınız",
|
||||
"dismiss-all": "Tümünü kapat",
|
||||
"dismiss-all-body": "Tüm bildirimler kaldırılacak.",
|
||||
"dismiss-all-title": "Tüm bildirimler kapatılsın mı?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "Görev başarısız: {task}",
|
||||
"topic-account": "Hesap",
|
||||
"topic-custom": "Özel",
|
||||
"topic-devices": "Cihazlar",
|
||||
"topic-scans": "Taramalar",
|
||||
"topic-streaming": "Yayın",
|
||||
"topic-tasks": "Görevler",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "Gizli",
|
||||
"how-long-to-beat": "Tamamlama Süresi",
|
||||
"info": "Bilgi",
|
||||
"install-on-device": "Cihaza yükle",
|
||||
"install-on-device-cancel-failed": "Yükleme iptal edilemedi: {error}",
|
||||
"install-on-device-empty": "Henüz hiçbir cihaz yükleme alamıyor. Uzaktan yüklemeyi destekleyen bir cihaz uygulamasından RomM'a giriş yapın.",
|
||||
"install-on-device-failed": "Yükleme cihaza gönderilemedi: {error}",
|
||||
"install-on-device-load-failed": "Cihazlarınız yüklenemedi",
|
||||
"install-on-device-preparing": "Cihaz hazırlanıyor…",
|
||||
"install-on-device-status-already-installed": "Zaten yüklü",
|
||||
"install-on-device-status-cancelled": "İptal edildi",
|
||||
"install-on-device-status-done": "İndirmelere eklendi",
|
||||
"install-on-device-status-failed": "Başarısız",
|
||||
"install-on-device-status-pending": "Cihaz bekleniyor",
|
||||
"install-on-device-status-taken": "İndiriliyor",
|
||||
"join-session": "Oturuma katıl",
|
||||
"join-session-of": "{user} adlı kullanıcının oturumuna katıl",
|
||||
"labels-applied-n": "{n} ögeye etiket eklendi",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "暂无渠道",
|
||||
"channels-empty-hint": "将你的通知转发到 RomM 之外:Webhook、电子邮件地址等。",
|
||||
"channels-load-failed": "无法加载你的渠道",
|
||||
"device-install-already-installed": "{game} 已安装在 {device} 上",
|
||||
"device-install-completed": "{game} 已加入 {device} 的下载",
|
||||
"device-install-failed": "无法将 {game} 安装到 {device}",
|
||||
"device-install-unknown-game": "一款游戏",
|
||||
"device-install-your-device": "你的设备",
|
||||
"dismiss-all": "全部忽略",
|
||||
"dismiss-all-body": "所有通知都将被移除。",
|
||||
"dismiss-all-title": "忽略所有通知?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "任务失败:{task}",
|
||||
"topic-account": "账户",
|
||||
"topic-custom": "自定义",
|
||||
"topic-devices": "设备",
|
||||
"topic-scans": "扫描",
|
||||
"topic-streaming": "串流",
|
||||
"topic-tasks": "任务",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "隐藏",
|
||||
"how-long-to-beat": "游戏时长",
|
||||
"info": "信息",
|
||||
"install-on-device": "安装到设备",
|
||||
"install-on-device-cancel-failed": "无法取消安装:{error}",
|
||||
"install-on-device-empty": "目前还没有设备可以接收安装。请在支持远程安装的设备应用中登录 RomM。",
|
||||
"install-on-device-failed": "无法将安装发送到设备:{error}",
|
||||
"install-on-device-load-failed": "无法加载你的设备",
|
||||
"install-on-device-preparing": "正在准备设备…",
|
||||
"install-on-device-status-already-installed": "已安装",
|
||||
"install-on-device-status-cancelled": "已取消",
|
||||
"install-on-device-status-done": "已加入下载",
|
||||
"install-on-device-status-failed": "失败",
|
||||
"install-on-device-status-pending": "正在等待设备",
|
||||
"install-on-device-status-taken": "正在下载",
|
||||
"join-session": "加入会话",
|
||||
"join-session-of": "加入 {user} 的会话",
|
||||
"labels-applied-n": "已为 {n} 个项目添加标签",
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
"channels-empty": "尚無管道",
|
||||
"channels-empty-hint": "將你的通知轉發到 RomM 之外:Webhook、電子郵件地址等。",
|
||||
"channels-load-failed": "無法載入你的管道",
|
||||
"device-install-already-installed": "{game} 已安裝在 {device} 上",
|
||||
"device-install-completed": "{game} 已加入 {device} 的下載",
|
||||
"device-install-failed": "無法將 {game} 安裝到 {device}",
|
||||
"device-install-unknown-game": "一款遊戲",
|
||||
"device-install-your-device": "你的裝置",
|
||||
"dismiss-all": "全部關閉",
|
||||
"dismiss-all-body": "所有通知都將被移除。",
|
||||
"dismiss-all-title": "關閉所有通知?",
|
||||
@@ -105,6 +110,7 @@
|
||||
"task-failed": "任務失敗:{task}",
|
||||
"topic-account": "帳戶",
|
||||
"topic-custom": "自訂",
|
||||
"topic-devices": "裝置",
|
||||
"topic-scans": "掃描",
|
||||
"topic-streaming": "串流",
|
||||
"topic-tasks": "任務",
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
"hidden": "隱藏",
|
||||
"how-long-to-beat": "遊戲時長",
|
||||
"info": "資訊",
|
||||
"install-on-device": "安裝到裝置",
|
||||
"install-on-device-cancel-failed": "無法取消安裝:{error}",
|
||||
"install-on-device-empty": "目前還沒有裝置可以接收安裝。請在支援遠端安裝的裝置應用程式中登入 RomM。",
|
||||
"install-on-device-failed": "無法將安裝傳送到裝置:{error}",
|
||||
"install-on-device-load-failed": "無法載入你的裝置",
|
||||
"install-on-device-preparing": "正在準備裝置…",
|
||||
"install-on-device-status-already-installed": "已安裝",
|
||||
"install-on-device-status-cancelled": "已取消",
|
||||
"install-on-device-status-done": "已加入下載",
|
||||
"install-on-device-status-failed": "失敗",
|
||||
"install-on-device-status-pending": "正在等待裝置",
|
||||
"install-on-device-status-taken": "正在下載",
|
||||
"join-session": "加入工作階段",
|
||||
"join-session-of": "加入 {user} 的工作階段",
|
||||
"labels-applied-n": "已為 {n} 個項目新增標籤",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
InstallRequestCreatePayload,
|
||||
InstallRequestSchema,
|
||||
} from "@/__generated__";
|
||||
import api from "@/services/api";
|
||||
|
||||
async function fetchRomInstalls(romId: number) {
|
||||
return api.get<InstallRequestSchema[]>(`/roms/${romId}/installs`);
|
||||
}
|
||||
|
||||
async function createInstall(
|
||||
deviceId: string,
|
||||
payload: InstallRequestCreatePayload,
|
||||
) {
|
||||
return api.post<InstallRequestSchema>(
|
||||
`/devices/${encodeURIComponent(deviceId)}/installs`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
async function cancelInstall(deviceId: string, requestId: string) {
|
||||
return api.delete<InstallRequestSchema>(
|
||||
`/devices/${encodeURIComponent(deviceId)}/installs/${encodeURIComponent(requestId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
fetchRomInstalls,
|
||||
createInstall,
|
||||
cancelInstall,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DeviceSchema } from "@/__generated__";
|
||||
import api from "@/services/api";
|
||||
|
||||
async function fetchDevices() {
|
||||
return api.get<DeviceSchema[]>("/devices");
|
||||
}
|
||||
|
||||
async function fetchOnlineDeviceIds() {
|
||||
return api.get<string[]>("/devices/online");
|
||||
}
|
||||
|
||||
export default {
|
||||
fetchDevices,
|
||||
fetchOnlineDeviceIds,
|
||||
};
|
||||
@@ -63,6 +63,10 @@ const defaultHeartbeat: Heartbeat = {
|
||||
EMAIL_ENABLED: false,
|
||||
EMAILS_RESET_LINKS: false,
|
||||
},
|
||||
DEVICE_INSTALL: {
|
||||
ENABLED: false,
|
||||
EXCLUDED_PLATFORM_SLUGS: [],
|
||||
},
|
||||
TASKS: {
|
||||
ENABLE_SCHEDULED_RESCAN: false,
|
||||
SCHEDULED_RESCAN_CRON: "",
|
||||
|
||||
Vendored
+1
@@ -103,6 +103,7 @@ export type Events = {
|
||||
// and the NDS/download helpers all accept SimpleRom, so gallery surfaces
|
||||
// (which only ever hold SimpleRom) can trigger it too.
|
||||
showQRCodeDialog: SimpleRom;
|
||||
showInstallOnDeviceDialog: SimpleRom;
|
||||
selectSaveDialog: DetailedRom;
|
||||
selectStateDialog: DetailedRom;
|
||||
saveSelected: SaveSchema;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
// GlobalDialogs — the stack of emitter-driven dialogs + notification
|
||||
// mount-points that need to live at the layout level so they overlay
|
||||
// every route.
|
||||
import AboutDialog from "@/v2/components/Dialogs/AboutDialog.vue";
|
||||
import AddPhysicalGameDialog from "@/v2/components/Dialogs/AddPhysicalGameDialog.vue";
|
||||
import ChangelogDialog from "@/v2/components/Dialogs/ChangelogDialog.vue";
|
||||
@@ -10,6 +7,7 @@ import CreateSmartCollectionDialog from "@/v2/components/Dialogs/CreateSmartColl
|
||||
import DeleteManualDialog from "@/v2/components/Dialogs/DeleteManualDialog.vue";
|
||||
import DeleteRomDialog from "@/v2/components/Dialogs/DeleteRomDialog.vue";
|
||||
import EditRomDialog from "@/v2/components/Dialogs/EditRomDialog.vue";
|
||||
import InstallOnDeviceDialog from "@/v2/components/Dialogs/InstallOnDeviceDialog.vue";
|
||||
import ManageCollectionsDialog from "@/v2/components/Dialogs/ManageCollectionsDialog.vue";
|
||||
import ManualUploadTargetDialog from "@/v2/components/Dialogs/ManualUploadTargetDialog.vue";
|
||||
import MatchRomDialog from "@/v2/components/Dialogs/MatchRomDialog.vue";
|
||||
@@ -39,6 +37,7 @@ defineOptions({ inheritAttrs: false });
|
||||
<RefreshMetadataDialog />
|
||||
<SearchCoverDialog />
|
||||
<ShowQRCodeDialog />
|
||||
<InstallOnDeviceDialog />
|
||||
<CopyDownloadLinkDialog />
|
||||
<ManageCollectionsDialog />
|
||||
<CreateSmartCollectionDialog />
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import mitt, { type Emitter } from "mitt";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import type { DeviceSchema, InstallRequestSchema } from "@/__generated__";
|
||||
import type { SimpleRom } from "@/stores/roms";
|
||||
import type { Events } from "@/types/emitter";
|
||||
import InstallOnDeviceDialog from "./InstallOnDeviceDialog.vue";
|
||||
|
||||
const {
|
||||
fetchDevices,
|
||||
fetchOnlineDeviceIds,
|
||||
fetchRomInstalls,
|
||||
createInstall,
|
||||
cancelInstall,
|
||||
socketHandlers,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchDevices: vi.fn(),
|
||||
fetchOnlineDeviceIds: vi.fn(),
|
||||
fetchRomInstalls: vi.fn(),
|
||||
createInstall: vi.fn(),
|
||||
cancelInstall: vi.fn(),
|
||||
socketHandlers: new Map<string, (payload: unknown) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/api/device", () => ({
|
||||
default: { fetchDevices, fetchOnlineDeviceIds },
|
||||
}));
|
||||
|
||||
vi.mock("@/services/api/device-install", () => ({
|
||||
default: { fetchRomInstalls, createInstall, cancelInstall },
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/composables/useSocketEvent", () => ({
|
||||
useSocketEvent: (event: string, handler: (payload: unknown) => void) => {
|
||||
socketHandlers.set(event, handler);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/composables/useBreakpoint", () => ({
|
||||
useBreakpoint: () => ({ mdAndUp: ref(true) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/composables/useSnackbar", () => ({
|
||||
useSnackbar: () => ({ error: vi.fn() }),
|
||||
}));
|
||||
|
||||
const ROM = { id: 7, name: "Zelda", fs_name: "zelda.zip" } as SimpleRom;
|
||||
|
||||
function device(id: string, install = true): DeviceSchema {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
capabilities: install ? { remote_install: true } : null,
|
||||
last_seen: null,
|
||||
} as DeviceSchema;
|
||||
}
|
||||
|
||||
function installRequest(
|
||||
overrides: Partial<InstallRequestSchema> = {},
|
||||
): InstallRequestSchema {
|
||||
return {
|
||||
id: "req-1",
|
||||
user_id: 1,
|
||||
device_id: "handheld",
|
||||
rom_id: ROM.id,
|
||||
file_ids: [10],
|
||||
status: "pending",
|
||||
reason: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mountDialog(emitter: Emitter<Events>): VueWrapper {
|
||||
return mount(InstallOnDeviceDialog, {
|
||||
global: {
|
||||
provide: { emitter },
|
||||
stubs: {
|
||||
RDialog: {
|
||||
props: { modelValue: { type: Boolean, default: false } },
|
||||
emits: ["update:modelValue", "close"],
|
||||
template: `<div v-if="modelValue">
|
||||
<slot name="content" />
|
||||
<button data-test-close @click="$emit('update:modelValue', false); $emit('close')" />
|
||||
</div>`,
|
||||
},
|
||||
RCheckbox: {
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
subtitle: { type: String, default: undefined },
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
template: `<label data-test-device>
|
||||
<input type="checkbox" :checked="modelValue" :disabled="disabled"
|
||||
@change="$emit('update:modelValue', $event.target.checked)" />
|
||||
<slot />
|
||||
<span data-test-subtitle>{{ subtitle }}</span>
|
||||
</label>`,
|
||||
},
|
||||
RBtn: true,
|
||||
RIcon: true,
|
||||
RSpinner: true,
|
||||
REmptyState: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function open(devices: DeviceSchema[] = [device("handheld")]) {
|
||||
fetchDevices.mockResolvedValue({ data: devices });
|
||||
const emitter = mitt<Events>();
|
||||
const wrapper = mountDialog(emitter);
|
||||
emitter.emit("showInstallOnDeviceDialog", ROM);
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
async function toggle(wrapper: VueWrapper, checked: boolean) {
|
||||
await wrapper.find("[data-test-device] input").setValue(checked);
|
||||
}
|
||||
|
||||
describe("InstallOnDeviceDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
socketHandlers.clear();
|
||||
fetchOnlineDeviceIds.mockResolvedValue({ data: [] });
|
||||
fetchRomInstalls.mockResolvedValue({ data: [] });
|
||||
createInstall.mockImplementation((deviceId: string) =>
|
||||
Promise.resolve({ data: installRequest({ device_id: deviceId }) }),
|
||||
);
|
||||
cancelInstall.mockResolvedValue({
|
||||
data: installRequest({ status: "cancelled" }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps a reopened dialog's send busy when an earlier one finishes", async () => {
|
||||
fetchDevices.mockResolvedValue({ data: [device("handheld")] });
|
||||
const releases: Array<() => void> = [];
|
||||
createInstall.mockImplementation(
|
||||
(deviceId: string) =>
|
||||
new Promise((resolve) => {
|
||||
releases.push(() =>
|
||||
resolve({ data: installRequest({ device_id: deviceId }) }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const emitter = mitt<Events>();
|
||||
const wrapper = mountDialog(emitter);
|
||||
|
||||
emitter.emit("showInstallOnDeviceDialog", ROM);
|
||||
await flushPromises();
|
||||
await toggle(wrapper, true);
|
||||
await wrapper.find("[data-test-close]").trigger("click");
|
||||
emitter.emit("showInstallOnDeviceDialog", { ...ROM, id: 8 });
|
||||
await flushPromises();
|
||||
await toggle(wrapper, true);
|
||||
vi.advanceTimersByTime(3000);
|
||||
await flushPromises();
|
||||
expect(createInstall).toHaveBeenCalledTimes(2);
|
||||
|
||||
releases[0]?.();
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.find("[data-test-device] input").attributes("disabled"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("lists only the devices that accept installs", async () => {
|
||||
const wrapper = await open([device("handheld"), device("tv", false)]);
|
||||
|
||||
expect(wrapper.findAll("[data-test-device]")).toHaveLength(1);
|
||||
expect(wrapper.text()).toContain("handheld");
|
||||
});
|
||||
|
||||
it("sends after the preparing delay", async () => {
|
||||
const wrapper = await open();
|
||||
|
||||
await toggle(wrapper, true);
|
||||
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
|
||||
"rom.install-on-device-preparing",
|
||||
);
|
||||
vi.advanceTimersByTime(2999);
|
||||
expect(createInstall).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
await flushPromises();
|
||||
|
||||
expect(createInstall).toHaveBeenCalledOnce();
|
||||
expect(createInstall).toHaveBeenCalledWith("handheld", { rom_id: ROM.id });
|
||||
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
|
||||
"rom.install-on-device-status-pending",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends nothing when unchecked while preparing", async () => {
|
||||
const wrapper = await open();
|
||||
|
||||
await toggle(wrapper, true);
|
||||
vi.advanceTimersByTime(2000);
|
||||
await toggle(wrapper, false);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(createInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a preparing install at once when closed", async () => {
|
||||
const wrapper = await open();
|
||||
|
||||
await toggle(wrapper, true);
|
||||
await wrapper.find("[data-test-close]").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(createInstall).toHaveBeenCalledOnce();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(createInstall).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cancels a live request when unchecked", async () => {
|
||||
fetchRomInstalls.mockResolvedValue({ data: [installRequest()] });
|
||||
const wrapper = await open();
|
||||
|
||||
await toggle(wrapper, false);
|
||||
await flushPromises();
|
||||
|
||||
expect(cancelInstall).toHaveBeenCalledWith("handheld", "req-1");
|
||||
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
|
||||
"rom.install-on-device-status-cancelled",
|
||||
);
|
||||
});
|
||||
|
||||
it("follows socket updates for the open rom only", async () => {
|
||||
const wrapper = await open();
|
||||
const update = socketHandlers.get("install:updated");
|
||||
if (!update) throw new Error("the dialog never subscribed");
|
||||
|
||||
update(installRequest({ rom_id: 99, status: "taken" }));
|
||||
await flushPromises();
|
||||
expect(wrapper.find("[data-test-subtitle]").text()).toBe("");
|
||||
|
||||
update(installRequest({ status: "taken" }));
|
||||
await flushPromises();
|
||||
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
|
||||
"rom.install-on-device-status-taken",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the newer request when an older one arrives late", async () => {
|
||||
const wrapper = await open();
|
||||
const update = socketHandlers.get("install:updated");
|
||||
if (!update) throw new Error("the dialog never subscribed");
|
||||
|
||||
update(
|
||||
installRequest({
|
||||
id: "req-2",
|
||||
status: "taken",
|
||||
created_at: "2026-01-02T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
update(installRequest({ status: "cancelled" }));
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
|
||||
"rom.install-on-device-status-taken",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the newer status when a stale one of the same request arrives late", async () => {
|
||||
const wrapper = await open();
|
||||
const update = socketHandlers.get("install:updated");
|
||||
if (!update) throw new Error("the dialog never subscribed");
|
||||
|
||||
update(
|
||||
installRequest({ status: "taken", updated_at: "2026-01-01T00:00:05Z" }),
|
||||
);
|
||||
update(installRequest({ status: "pending" }));
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
|
||||
"rom.install-on-device-status-taken",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a socket update that lands while the dialog loads", async () => {
|
||||
let resolveInstalls: (value: {
|
||||
data: InstallRequestSchema[];
|
||||
}) => void = () => {};
|
||||
fetchRomInstalls.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveInstalls = resolve;
|
||||
}),
|
||||
);
|
||||
fetchDevices.mockResolvedValue({ data: [device("handheld")] });
|
||||
const emitter = mitt<Events>();
|
||||
const wrapper = mountDialog(emitter);
|
||||
emitter.emit("showInstallOnDeviceDialog", ROM);
|
||||
const update = socketHandlers.get("install:updated");
|
||||
if (!update) throw new Error("the dialog never subscribed");
|
||||
|
||||
update(
|
||||
installRequest({ status: "done", updated_at: "2026-01-01T00:00:09Z" }),
|
||||
);
|
||||
resolveInstalls({
|
||||
data: [
|
||||
installRequest({ status: "taken", updated_at: "2026-01-01T00:00:05Z" }),
|
||||
],
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find("[data-test-subtitle]").text()).toBe(
|
||||
"rom.install-on-device-status-done",
|
||||
);
|
||||
});
|
||||
|
||||
it("locks a delivered install", async () => {
|
||||
fetchRomInstalls.mockResolvedValue({ data: [] });
|
||||
const wrapper = await open();
|
||||
const update = socketHandlers.get("install:updated");
|
||||
if (!update) throw new Error("the dialog never subscribed");
|
||||
|
||||
update(installRequest({ status: "done" }));
|
||||
await flushPromises();
|
||||
|
||||
const input = wrapper.find("[data-test-device] input");
|
||||
expect(input.attributes("disabled")).toBeDefined();
|
||||
expect((input.element as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user