Skip to content

Commit ace6583

Browse files
authored
Merge pull request #3867 from rommapp/feat/v2-launch-status-and-confirm
feat(v2): auto-mark now-playing on launch + confirm launching shelved games
2 parents 2baf819 + e87571d commit ace6583

49 files changed

Lines changed: 603 additions & 63 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/handler/play_session_handler.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from handler.database import db_device_handler, db_play_session_handler, db_rom_handler
77
from logger.logger import log
88
from models.play_session import PlaySession
9+
from models.rom import RomUserStatus
910
from utils.datetime import to_utc
1011

1112

@@ -37,7 +38,7 @@ def _resolve_device(device_id: str | None, user_id: int) -> str | None:
3738
return device_id if device is not None else None
3839

3940

40-
def _update_rom_user_last_played(
41+
def _apply_play_to_rom_user(
4142
rom_user_updates: dict[int, datetime], user_id: int
4243
) -> None:
4344
for rom_id, latest_end_time in rom_user_updates.items():
@@ -46,10 +47,18 @@ def _update_rom_user_last_played(
4647
rom_user = db_rom_handler.add_rom_user(rom_id=rom_id, user_id=user_id)
4748

4849
current = to_utc(rom_user.last_played) if rom_user.last_played else None
49-
if current is None or latest_end_time > current:
50-
db_rom_handler.update_rom_user(
51-
rom_user.id, {"last_played": latest_end_time}
52-
)
50+
# Only the newest play advances state. A backfilled or device-synced
51+
# older session must not resurrect "now playing" or rewind the status.
52+
if current is not None and latest_end_time <= current:
53+
continue
54+
55+
updates: dict = {"last_played": latest_end_time, "now_playing": True}
56+
# Playing again counts as active: rewind an empty or "finished" status
57+
# to "incomplete", but leave statuses the user set on purpose
58+
# (completed_100 / retired / never_playing) untouched.
59+
if rom_user.status in (None, RomUserStatus.FINISHED):
60+
updates["status"] = RomUserStatus.INCOMPLETE
61+
db_rom_handler.update_rom_user(rom_user.id, updates)
5362

5463

5564
def ingest_play_sessions(
@@ -138,7 +147,7 @@ def ingest_play_sessions(
138147
rom_user_updates[resolved_rom_id] = ps.end_time
139148

140149
# Phase 4: Side effects
141-
_update_rom_user_last_played(rom_user_updates, user_id)
150+
_apply_play_to_rom_user(rom_user_updates, user_id)
142151

143152
if resolved_device_id is not None:
144153
db_device_handler.update_last_seen(

backend/tests/endpoints/test_play_sessions.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from handler.database import db_device_handler, db_play_session_handler, db_rom_handler
1212
from models.device import Device
1313
from models.platform import Platform
14-
from models.rom import Rom
14+
from models.rom import Rom, RomUserStatus
1515
from models.user import User
1616
from utils.datetime import to_utc
1717

@@ -68,6 +68,14 @@ def _ingest(device_id=None, sessions=None):
6868
return {"device_id": device_id, "sessions": sessions or []}
6969

7070

71+
def _prime_rom_user(rom_id, user_id, **fields):
72+
"""Fetch (or create) the rom_user and seed it with the given fields."""
73+
rom_user = db_rom_handler.get_rom_user(rom_id=rom_id, user_id=user_id)
74+
if rom_user is None:
75+
rom_user = db_rom_handler.add_rom_user(rom_id=rom_id, user_id=user_id)
76+
return db_rom_handler.update_rom_user(rom_user.id, fields)
77+
78+
7179
class TestPlaySessionIngest:
7280
def test_single_session(self, client, access_token: str, device: Device, rom: Rom):
7381
payload = _ingest(device_id=device.id, sessions=[_session(rom_id=rom.id)])
@@ -472,6 +480,96 @@ def test_last_played_is_max_end_time(
472480
last_played_utc = to_utc(rom_user.last_played)
473481
assert abs((last_played_utc - expected_latest).total_seconds()) < 2
474482

483+
def test_now_playing_set_on_ingest(
484+
self, client, access_token: str, admin_user: User, rom: Rom
485+
):
486+
payload = _ingest(sessions=[_session(rom_id=rom.id)])
487+
client.post(
488+
"/api/play-sessions",
489+
json=payload,
490+
headers={"Authorization": f"Bearer {access_token}"},
491+
)
492+
493+
rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=admin_user.id)
494+
assert rom_user is not None
495+
assert rom_user.now_playing is True
496+
497+
def test_empty_status_rewound_to_incomplete(
498+
self, client, access_token: str, admin_user: User, rom: Rom
499+
):
500+
client.post(
501+
"/api/play-sessions",
502+
json=_ingest(sessions=[_session(rom_id=rom.id)]),
503+
headers={"Authorization": f"Bearer {access_token}"},
504+
)
505+
506+
rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=admin_user.id)
507+
assert rom_user.status == RomUserStatus.INCOMPLETE
508+
509+
def test_finished_status_rewound_to_incomplete(
510+
self, client, access_token: str, admin_user: User, rom: Rom
511+
):
512+
_prime_rom_user(rom.id, admin_user.id, status=RomUserStatus.FINISHED)
513+
514+
client.post(
515+
"/api/play-sessions",
516+
json=_ingest(sessions=[_session(rom_id=rom.id)]),
517+
headers={"Authorization": f"Bearer {access_token}"},
518+
)
519+
520+
rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=admin_user.id)
521+
assert rom_user.status == RomUserStatus.INCOMPLETE
522+
523+
@pytest.mark.parametrize(
524+
"protected_status",
525+
[
526+
RomUserStatus.COMPLETED_100,
527+
RomUserStatus.RETIRED,
528+
RomUserStatus.NEVER_PLAYING,
529+
],
530+
)
531+
def test_deliberate_status_preserved(
532+
self, client, access_token, admin_user, rom, protected_status
533+
):
534+
_prime_rom_user(rom.id, admin_user.id, status=protected_status)
535+
536+
client.post(
537+
"/api/play-sessions",
538+
json=_ingest(sessions=[_session(rom_id=rom.id)]),
539+
headers={"Authorization": f"Bearer {access_token}"},
540+
)
541+
542+
rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=admin_user.id)
543+
# now_playing is orthogonal, so it still flips on...
544+
assert rom_user.now_playing is True
545+
# ...but the deliberate enum status is left untouched.
546+
assert rom_user.status == protected_status
547+
548+
def test_older_session_does_not_resurrect_now_playing(
549+
self, client, access_token: str, admin_user: User, rom: Rom
550+
):
551+
# A recent play sets last_played and now_playing.
552+
client.post(
553+
"/api/play-sessions",
554+
json=_ingest(sessions=[_session(rom_id=rom.id, start_offset_hours=-1)]),
555+
headers={"Authorization": f"Bearer {access_token}"},
556+
)
557+
rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=admin_user.id)
558+
db_rom_handler.update_rom_user(
559+
rom_user.id, {"now_playing": False, "status": RomUserStatus.FINISHED}
560+
)
561+
562+
# Backfilling an older session must not advance state.
563+
client.post(
564+
"/api/play-sessions",
565+
json=_ingest(sessions=[_session(rom_id=rom.id, start_offset_hours=-10)]),
566+
headers={"Authorization": f"Bearer {access_token}"},
567+
)
568+
569+
rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=admin_user.id)
570+
assert rom_user.now_playing is False
571+
assert rom_user.status == RomUserStatus.FINISHED
572+
475573
def test_rom_user_created_if_not_exists(
476574
self, client, access_token: str, admin_user: User, platform: Platform
477575
):

frontend/src/composables/useUISettings.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ export const UI_SETTINGS_KEYS = {
1515

1616
// NOTE: uiVersion is intentionally NOT tracked here. It's owned by the
1717
// singleton in composables/useUiVersion.ts so a write propagates reactively
18-
// to the RomM.vue gate without a reload. When v2 graduates we can move it
19-
// into this map and pick up backend sync.
18+
// to the RomM.vue gate without a reload.
2019

2120
// Home section
2221
showStats: { key: "settings.showStats", default: true },
@@ -80,6 +79,12 @@ export const UI_SETTINGS_KEYS = {
8079
default: false,
8180
},
8281
boxartStyle: { key: "settings.boxartStyle", default: "cover_path" },
82+
83+
// Gameplay
84+
confirmProtectedLaunch: {
85+
key: "settings.confirmProtectedLaunch",
86+
default: true,
87+
},
8388
} as const;
8489

8590
export type UISettingsKey = keyof typeof UI_SETTINGS_KEYS;

frontend/src/locales/bg_BG/rom.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
"completion": "Завършеност",
6060
"completionist": "Перфекционист",
6161
"confirm-delete-note": "Сигурен ли си, че искаш да изтриеш бележката \"{title}\"?",
62+
"confirm-launch-protected-body": "Отбелязахте „{name}“ като {status}. Искате ли все пак да я играете?",
63+
"confirm-launch-protected-title": "Стартиране на тази игра?",
6264
"convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.",
6365
"convert-to-folder-title": "Convert to folder ROM?",
6466
"copy-download-link-title": "Копирай линк за сваляне",

frontend/src/locales/bg_BG/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
"config-file-parse-error-title": "Configuration file could not be parsed!",
5353
"config-tab": "Конфигурация",
5454
"confirm-delete-mapping": "Потвърди?",
55+
"confirm-protected-launch": "Потвърждаване при стартиране на архивирани игри",
56+
"confirm-protected-launch-desc": "Питане преди стартиране на игра, отбелязана като „Изоставена“ или „Неиграна никога“.",
5557
"continue-playing-as-grid": "Продължи игра като решетка",
5658
"continue-playing-as-grid-desc": "Показвай кориците за продължаване на игра като решетка на началната страница",
5759
"controller-debug": "Диагностика на контролера",
@@ -161,6 +163,7 @@
161163
"folder-name": "Име на папка",
162164
"folder-name-header": "Име на папка",
163165
"gallery": "Галерия",
166+
"gameplay": "Геймплей",
164167
"games-label": "Игри",
165168
"grant-full": "Full access",
166169
"grant-none": "Not granted",

frontend/src/locales/cs_CZ/rom.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
"completion": "Dokončení",
6060
"completionist": "Kompletista",
6161
"confirm-delete-note": "Opravdu chcete smazat poznámku \"{title}\"?",
62+
"confirm-launch-protected-body": "Označili jste „{name}“ jako {status}. Přesto ji chcete hrát?",
63+
"confirm-launch-protected-title": "Spustit tuto hru?",
6264
"convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.",
6365
"convert-to-folder-title": "Convert to folder ROM?",
6466
"copy-download-link-title": "Kopírovat odkaz ke stažení",

frontend/src/locales/cs_CZ/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
"config-file-parse-error-title": "Configuration file could not be parsed!",
5353
"config-tab": "Konfigurace",
5454
"confirm-delete-mapping": "Potvrzujete?",
55+
"confirm-protected-launch": "Potvrzovat spuštění odložených her",
56+
"confirm-protected-launch-desc": "Zeptat se před spuštěním hry označené jako Opuštěno nebo Nikdy nebudu hrát.",
5557
"continue-playing-as-grid": "Pokračovat ve hraní jako mřížka",
5658
"continue-playing-as-grid-desc": "Zobrazit karty pokračovaného hraní na hlavní stránce jako mřížku",
5759
"controller-debug": "Ladění ovladače",
@@ -161,6 +163,7 @@
161163
"folder-name": "Název složky",
162164
"folder-name-header": "Název složky",
163165
"gallery": "Galerie",
166+
"gameplay": "Hraní",
164167
"games-label": "Hry",
165168
"grant-full": "Full access",
166169
"grant-none": "Not granted",

frontend/src/locales/de_DE/rom.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
"completion": "Durchgespielt",
6060
"completionist": "Vollendung",
6161
"confirm-delete-note": "Sind Sie sicher, dass Sie die Notiz \"{title}\" löschen möchten?",
62+
"confirm-launch-protected-body": "Du hast „{name}“ als {status} markiert. Möchtest du es trotzdem spielen?",
63+
"confirm-launch-protected-title": "Dieses Spiel starten?",
6264
"convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.",
6365
"convert-to-folder-title": "Convert to folder ROM?",
6466
"copy-download-link-title": "Download-Link kopieren",

frontend/src/locales/de_DE/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
"config-file-parse-error-title": "Configuration file could not be parsed!",
5353
"config-tab": "Konfiguration",
5454
"confirm-delete-mapping": "Bestätigen Sie?",
55+
"confirm-protected-launch": "Starten zurückgestellter Spiele bestätigen",
56+
"confirm-protected-launch-desc": "Vor dem Starten eines Spiels nachfragen, das als Aufgegeben oder Nie spielen markiert ist.",
5557
"continue-playing-as-grid": "Weiterspielen als Raster",
5658
"continue-playing-as-grid-desc": "Zeige die Weiterspielen-Karten als Raster auf der Startseite",
5759
"controller-debug": "Controller-Debug",
@@ -161,6 +163,7 @@
161163
"folder-name": "Verzeichnisname",
162164
"folder-name-header": "Ordnername",
163165
"gallery": "Galerie",
166+
"gameplay": "Gameplay",
164167
"games-label": "Spiele",
165168
"grant-full": "Full access",
166169
"grant-none": "Not granted",

frontend/src/locales/en_GB/rom.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
"completion": "Completion",
6060
"completionist": "Completionist",
6161
"confirm-delete-note": "Are you sure you want to delete the note \"{title}\"?",
62+
"confirm-launch-protected-body": "You marked \"{name}\" as {status}. Do you want to play it anyway?",
63+
"confirm-launch-protected-title": "Launch this game?",
6264
"convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.",
6365
"convert-to-folder-title": "Convert to folder ROM?",
6466
"copy-download-link-title": "Copy download link",

0 commit comments

Comments
 (0)