-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdata_handler.py
More file actions
1446 lines (1186 loc) · 56.1 KB
/
data_handler.py
File metadata and controls
1446 lines (1186 loc) · 56.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
import yaml
import time
import shutil
import enum
import logging
import hashlib
from pathlib import Path
from dataclasses import dataclass
from typing import List, Optional, Dict, Any, Tuple, Union
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Gdk', '4.0')
from gi.repository import Gtk, Gdk
from data import Game, Runner, Source, SourceType, RomPath
from data_mapping import (
CompletionStatus, InvalidCompletionStatusError,
Platforms, InvalidPlatformError,
AgeRatings, InvalidAgeRatingError,
Features, InvalidFeatureError,
Genres, InvalidGenreError,
Regions, InvalidRegionError
)
import gi
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import GdkPixbuf
# Set up logger
logger = logging.getLogger(__name__)
def get_media_filename_for_url(url: str) -> str:
"""
Generate a media filename for a given URL using SHA256 hash
Args:
url: The URL to generate a filename for
Returns:
Filename with .jpg extension
"""
url_hash = hashlib.sha256(url.encode('utf-8')).hexdigest()
return f"{url_hash}.jpg"
class DataHandler:
def __init__(self, data_dir: str = "data"):
self.data_dir = Path(data_dir)
self.games_dir = self.data_dir / "games"
self.runners_dir = self.data_dir / "runners"
self.sources_dir = self.data_dir / "sources"
self.media_dir = self.data_dir / "media"
# Get the project root directory for finding media directory
self.project_root = Path(__file__).parent
# Runner icon mapping
self.runner_icon_map = {
"steam": "steam-symbolic",
"wine": "wine-symbolic",
"native": "system-run-symbolic",
"browser": "web-browser-symbolic",
"emulator": "media-optical-symbolic",
}
# Ensure directories exist
self.games_dir.mkdir(parents=True, exist_ok=True)
self.runners_dir.mkdir(parents=True, exist_ok=True)
self.sources_dir.mkdir(parents=True, exist_ok=True)
self.media_dir.mkdir(parents=True, exist_ok=True)
def load_games(self) -> List[Game]:
games = []
for game_file in self.games_dir.glob("*/*/*/game.yaml"):
try:
# Extract the game ID from the directory structure
game_id = self._extract_game_id_from_path(game_file)
with open(game_file, "r") as f:
game_data = yaml.safe_load(f)
# Get the completion status string from game.yaml
completion_status_str = game_data.get("completion_status")
try:
# Convert string to enum
if completion_status_str:
completion_status = CompletionStatus.from_string(completion_status_str)
else:
completion_status = CompletionStatus.NOT_PLAYED
except InvalidCompletionStatusError as e:
logger.error(f"Error loading game {game_id} - invalid completion status '{completion_status_str}': {e}")
completion_status = CompletionStatus.NOT_PLAYED
# Extract platforms list if available
platforms = []
if "platforms" in game_data and isinstance(game_data["platforms"], list):
for platform_str in game_data["platforms"]:
try:
platform = Platforms.from_string(platform_str)
platforms.append(platform)
except InvalidPlatformError:
# Skip invalid platforms
logger.warning(f"Skipping invalid platform '{platform_str}' for game {game_id}")
# Extract age ratings list if available
age_ratings = []
if "age_ratings" in game_data and isinstance(game_data["age_ratings"], list):
for rating_str in game_data["age_ratings"]:
try:
rating = AgeRatings.from_string(rating_str)
age_ratings.append(rating)
except InvalidAgeRatingError:
# Skip invalid age ratings
logger.warning(f"Skipping invalid age rating '{rating_str}' for game {game_id}")
# Extract features list if available
features = []
if "features" in game_data and isinstance(game_data["features"], list):
for feature_str in game_data["features"]:
try:
feature = Features.from_string(feature_str)
features.append(feature)
except InvalidFeatureError:
# Skip invalid features
logger.warning(f"Skipping invalid feature '{feature_str}' for game {game_id}")
# Extract genres list if available
genres = []
if "genres" in game_data and isinstance(game_data["genres"], list):
for genre_str in game_data["genres"]:
try:
genre = Genres.from_string(genre_str)
genres.append(genre)
except InvalidGenreError:
# Skip invalid genres
logger.warning(f"Skipping invalid genre '{genre_str}' for game {game_id}")
# Extract regions list if available
regions = []
if "regions" in game_data and isinstance(game_data["regions"], list):
for region_str in game_data["regions"]:
try:
region = Regions.from_string(region_str)
regions.append(region)
except InvalidRegionError:
# Skip invalid regions
logger.warning(f"Skipping invalid region '{region_str}' for game {game_id}")
game = Game(
title=game_data.get("title", "Unknown Game"),
id=game_id,
created=game_data.get("created"),
hidden=game_data.get("hidden", False),
completion_status=completion_status,
platforms=platforms,
age_ratings=age_ratings,
features=features,
genres=genres,
regions=regions,
source=game_data.get("source")
)
# Load developer and publisher
game.developer = game_data.get("developer")
game.publisher = game_data.get("publisher")
# Load installation data
game.installation_directory = game_data.get("installation_directory")
game.installation_files = game_data.get("installation_files")
game.installation_size = game_data.get("installation_size")
# Load playtime data from game.yaml (with fallback to playtime.yaml for migration)
if "play_count" in game_data:
# New format: playtime fields in game.yaml
game.play_count = game_data.get("play_count")
game.play_time = game_data.get("play_time_seconds")
game.last_played = game_data.get("last_played")
game.first_played = game_data.get("first_played")
else:
# Legacy format: fallback to playtime.yaml for migration
play_time_file = game_file.parent / "playtime.yaml"
if play_time_file.exists():
try:
with open(play_time_file, "r") as pt_file:
play_time_data = yaml.safe_load(pt_file)
if play_time_data and isinstance(play_time_data, dict):
game.play_count = play_time_data.get("play_count")
game.play_time = play_time_data.get("play_time_seconds")
game.last_played = play_time_data.get("last_played")
game.first_played = play_time_data.get("first_played")
# Auto-migrate: save playtime data to game.yaml
logger.info(f"Migrating playtime data for game {game_id} from playtime.yaml to game.yaml")
self.save_game(game, preserve_created_time=True)
# Remove old playtime.yaml file after successful migration
try:
play_time_file.unlink()
logger.info(f"Removed legacy playtime.yaml for game {game_id}")
except Exception as e:
logger.warning(f"Could not remove legacy playtime.yaml for game {game_id}: {e}")
except Exception as pt_err:
logger.error(f"Error loading playtime data for {game_id}: {pt_err}")
# Set defaults if no playtime data exists
if "play_count" not in locals() or not hasattr(game, 'play_count'):
game.play_count = None
game.play_time = None
game.last_played = None
game.first_played = None
# Load description if exists
description_file = game_file.parent / "description.yaml"
if description_file.exists():
try:
with open(description_file, "r") as desc_file:
desc_data = yaml.safe_load(desc_file)
if desc_data and isinstance(desc_data, dict):
game.description = desc_data.get("text")
except Exception as desc_err:
logger.error(f"Error loading description for {game_id}: {desc_err}")
# Load launcher data from game.yaml
game.launcher_type = game_data.get("launcher_type")
game.launcher_id = game_data.get("launcher_id")
games.append(game)
except Exception as e:
logger.error(f"Error loading game {game_file}: {e}")
return games
def load_runners(self) -> List[Runner]:
runners = []
for runner_file in self.runners_dir.glob("*.yaml"):
try:
with open(runner_file, "r") as f:
runner_data = yaml.safe_load(f)
# Extract platforms list if available
platforms = []
if "platforms" in runner_data and isinstance(runner_data["platforms"], list):
for platform_str in runner_data["platforms"]:
try:
platform = Platforms.from_string(platform_str)
platforms.append(platform)
except InvalidPlatformError:
# Skip invalid platforms
logger.warning(f"Skipping invalid platform '{platform_str}' for runner {runner_file.stem}")
runner = Runner(
title=runner_data.get("title", "Unknown Runner"),
image=runner_data.get("image", ""),
command=runner_data.get("command", ""),
id=runner_file.stem,
platforms=platforms,
launcher_type=runner_data.get("launcher_type", []),
install_command=runner_data.get("install_command"),
uninstall_command=runner_data.get("uninstall_command")
)
runners.append(runner)
except Exception as e:
logger.error(f"Error loading runner {runner_file}: {e}")
return runners
def save_game(self, game: Game, preserve_created_time: bool = False) -> bool:
"""
Save a game to disk.
Args:
game: The game to save
preserve_created_time: If True, won't overwrite game.created when assigning a new ID
Returns:
True if successful, False otherwise
"""
if not game.id:
next_id = self.get_next_game_id()
game.id = str(next_id)
if not preserve_created_time or game.created is None:
game.created = time.time()
game_data = {
"title": game.title,
"completion_status": game.completion_status.value,
# Include playtime fields in game.yaml
"play_count": game.play_count,
"play_time_seconds": game.play_time,
"first_played": game.first_played,
"last_played": game.last_played
}
if game.created:
game_data["created"] = game.created
if game.hidden:
game_data["hidden"] = game.hidden
if game.platforms:
# Save platform enum display values
game_data["platforms"] = [platform.value for platform in game.platforms]
if game.age_ratings:
# Save age rating enum display values
game_data["age_ratings"] = [rating.value for rating in game.age_ratings]
if game.features:
# Save feature enum display values
game_data["features"] = [feature.value for feature in game.features]
if game.genres:
# Save genre enum display values
game_data["genres"] = [genre.value for genre in game.genres]
if game.regions:
# Save region enum display values
game_data["regions"] = [region.value for region in game.regions]
# Save source if present
if game.source:
game_data["source"] = game.source
# Save launcher data if present
if hasattr(game, 'launcher_type') and game.launcher_type:
game_data["launcher_type"] = game.launcher_type
if hasattr(game, 'launcher_id') and game.launcher_id:
game_data["launcher_id"] = game.launcher_id
# Save developer if present
if game.developer:
game_data["developer"] = game.developer
# Save publisher if present
if game.publisher:
game_data["publisher"] = game.publisher
# Save installation data if present
if game.installation_directory:
game_data["installation_directory"] = game.installation_directory
if game.installation_files is not None:
game_data["installation_files"] = game.installation_files
if game.installation_size is not None:
game_data["installation_size"] = game.installation_size
try:
game_dir = self._get_game_dir_from_id(game.id)
game_dir.mkdir(parents=True, exist_ok=True)
game_file = game_dir / "game.yaml"
with open(game_file, "w") as f:
yaml.dump(game_data, f)
return True
except Exception as e:
logger.error(f"Error saving game {game.id}: {e}")
return False
def save_runner(self, runner: Runner) -> bool:
if not runner.id:
runner.id = runner.title.lower().replace(" ", "_")
runner_data = {
"title": runner.title,
"image": runner.image,
"command": runner.command,
}
# Save launcher type if it exists
if hasattr(runner, 'launcher_type') and runner.launcher_type:
runner_data["launcher_type"] = runner.launcher_type
# Save install/uninstall commands if they exist
if hasattr(runner, 'install_command') and runner.install_command:
runner_data["install_command"] = runner.install_command
if hasattr(runner, 'uninstall_command') and runner.uninstall_command:
runner_data["uninstall_command"] = runner.uninstall_command
# Save platform enum display values
if runner.platforms:
runner_data["platforms"] = [platform.value for platform in runner.platforms]
try:
with open(self.runners_dir / f"{runner.id}.yaml", "w") as f:
yaml.dump(runner_data, f)
return True
except Exception as e:
logger.error(f"Error saving runner {runner.id}: {e}")
return False
def save_game_image(self, source_path: str, game_id: str, url: str = None) -> bool:
"""
Save a game image to the centralized media directory and create a symlink
in the game's directory. If url is provided, uses URL-based naming.
Args:
source_path: Path to the source image
game_id: ID of the game
url: Optional URL of the image for hash-based naming
Returns:
True if the image was successfully saved, False otherwise
"""
if not source_path or not os.path.exists(source_path):
return False
try:
# Create game directory if it doesn't exist
game_dir = self._get_game_dir_from_id(game_id)
game_dir.mkdir(parents=True, exist_ok=True)
# Determine media file name
if url:
media_filename = get_media_filename_for_url(url)
else:
# Fallback to game-specific naming for manually added images
media_filename = f"game_{game_id}.jpg"
media_path = self.media_dir / media_filename
# Only copy if the media file doesn't already exist
if not media_path.exists():
shutil.copy2(source_path, media_path)
logger.debug(f"Saved image to media directory: {media_path}")
# Create symlink in game directory
cover_symlink = game_dir / "cover.jpg"
# Remove existing cover file/symlink if it exists
if cover_symlink.exists() or cover_symlink.is_symlink():
cover_symlink.unlink()
# Create relative symlink to media file
relative_media_path = os.path.relpath(media_path, game_dir)
cover_symlink.symlink_to(relative_media_path)
logger.debug(f"Created symlink for game {game_id}: {cover_symlink} -> {relative_media_path}")
return True
except Exception as e:
logger.error(f"Error saving image: {e}")
return False
def remove_game_image(self, game_id: str) -> bool:
"""
Remove a game's cover symlink if it exists.
Note: This does NOT remove the media file to allow reuse.
Args:
game_id: ID of the game
Returns:
True if the symlink was successfully removed or didn't exist, False if error
"""
try:
game_dir = self._get_game_dir_from_id(game_id)
cover_path = game_dir / "cover.jpg"
if cover_path.exists() or cover_path.is_symlink():
cover_path.unlink()
return True
except Exception as e:
logger.error(f"Error removing cover symlink for game {game_id}: {e}")
return False
def create_game_with_image(self, title: str, image_path: Optional[str] = None) -> Game:
"""
Create a new game object with an image, handling ID generation and image copying.
Args:
title: The title of the game
image_path: Optional path to an image file
Returns:
A new Game object
"""
# Get the next numeric game ID
next_id = self.get_next_game_id()
game_id = str(next_id)
# Create the game object with creation timestamp
game = Game(
id=game_id,
title=title,
created=time.time(),
completion_status=CompletionStatus.NOT_PLAYED
)
# Save image if provided
if image_path:
self.save_game_image(image_path, game_id)
return game
def get_runner_icon(self, runner_id: str) -> str:
"""
Get the icon name for a given runner ID.
Args:
runner_id: The ID of the runner
Returns:
The name of the icon to use for the runner
"""
if not runner_id:
return "application-x-executable-symbolic"
# Try to get stored icon from runner data first
runner = self._get_runner_by_id(runner_id)
if runner and runner.image:
# Check if image field contains an icon name (not a file path)
if not runner.image.startswith('/'):
# Looks like an icon name, verify it exists
display = Gdk.Display.get_default()
if display:
icon_theme = Gtk.IconTheme.get_for_display(display)
if icon_theme.has_icon(runner.image):
return runner.image
# Fall back to detecting icon from command
if runner and runner.command:
command_icon = self._get_icon_from_command(runner.command)
if command_icon:
return command_icon
# Try to match beginning of runner name to known icons
for key, icon in self.runner_icon_map.items():
if runner_id.lower().startswith(key):
return icon
# Default icon for unknown runners
return "application-x-executable-symbolic"
def _get_runner_by_id(self, runner_id: str) -> Optional[Runner]:
"""Get a runner object by ID"""
try:
runner_file = self.runners_dir / f"{runner_id}.yaml"
if runner_file.exists():
with open(runner_file, "r") as f:
runner_data = yaml.safe_load(f)
# Create runner object
runner = Runner(
id=runner_id,
title=runner_data.get("title", runner_id),
command=runner_data.get("command", ""),
image=runner_data.get("image"),
platforms=[Platforms.from_string(p) for p in runner_data.get("platforms", [])],
launcher_type=runner_data.get("launcher_type", []),
install_command=runner_data.get("install_command"),
uninstall_command=runner_data.get("uninstall_command")
)
return runner
except Exception as e:
logger.debug(f"Error loading runner {runner_id}: {e}")
return None
return None
def _get_icon_from_command(self, command: str) -> Optional[str]:
"""Extract icon name from a command, handling Flatpak and protocol-based commands"""
if not command:
return None
display = Gdk.Display.get_default()
if not display:
return None
icon_theme = Gtk.IconTheme.get_for_display(display)
# Handle Flatpak commands: "flatpak run org.flycast.Flycast ..."
if command.startswith("flatpak run "):
parts = command.split()
if len(parts) >= 3:
app_id = parts[2]
# Check if this app ID exists as an icon
if icon_theme.has_icon(app_id):
return app_id
# Handle xdg-open protocol commands: "xdg-open steam://run/"
elif command.startswith("xdg-open "):
parts = command.split()
if len(parts) >= 2:
url_or_path = parts[1]
if "://" in url_or_path:
protocol = url_or_path.split("://")[0].lower()
# Try different icon name variations for the protocol
icon_candidates = [
protocol, # "steam"
f"{protocol}-symbolic", # "steam-symbolic"
f"{protocol}.desktop", # "steam.desktop"
f"application-{protocol}" # "application-steam"
]
for candidate in icon_candidates:
if icon_theme.has_icon(candidate):
return candidate
return None
def get_compatible_runners(self, game: Game, all_runners: List[Runner]) -> List[Runner]:
"""
Find runners that are compatible with the game's platforms and launcher type.
Args:
game: The game to find compatible runners for
all_runners: List of all available runners
Returns:
List of compatible Runner objects, prioritized by exact matches first
"""
if not game.platforms:
return []
game_launcher_type = None
if hasattr(game, 'launcher_type') and game.launcher_type:
game_launcher_type = game.launcher_type
# Filter to runners that support at least one of the game's platforms
compatible = []
generic_runners = [] # Runners with matching platforms but no launcher type
for runner in all_runners:
if not runner.platforms:
continue
# Track if this runner is platform-compatible
platform_compatible = False
# Check if any of the game's platforms are supported by this runner
for platform in game.platforms:
if platform in runner.platforms:
platform_compatible = True
break
# If not platform compatible, skip this runner
if not platform_compatible:
continue
# For games with launcher type, check for matching runners
if game_launcher_type:
# Check if runner has launcher type that matches the game
if hasattr(runner, 'launcher_type') and runner.launcher_type:
# If runner launcher type list contains game launcher type, add to compatible list
if game_launcher_type in runner.launcher_type:
compatible.append(runner)
else:
# Runner has launcher types but doesn't match the game's launcher type
# Skip this runner entirely for launcher-specific games
continue
else:
# Runner with matching platform but no launcher type
generic_runners.append(runner)
else:
# For games without launcher type, only add runners that also don't have launcher type
if not hasattr(runner, 'launcher_type') or not runner.launcher_type:
compatible.append(runner)
# Skip runners with launcher_type for non-launcher games
# If we have matched launcher-type runners, return only those
if game_launcher_type and compatible:
return compatible
# Otherwise, return generic runners
return compatible + generic_runners
def get_primary_runner_for_game(self, game: Game, all_runners: List[Runner]) -> Optional[Runner]:
"""
Get the primary (best match) runner for a game.
Args:
game: The game to find a runner for
all_runners: List of all available runners
Returns:
The primary runner for the game, or None if no compatible runners
"""
compatible_runners = self.get_compatible_runners(game, all_runners)
return compatible_runners[0] if compatible_runners else None
def load_game_image(self, game: Game, width: int = 200, height: int = 260) -> Optional[GdkPixbuf.Pixbuf]:
"""
Load a game's image as a pixbuf, scaled to the specified dimensions.
Args:
game: The game to load the image for
width: The desired width of the image
height: The desired height of the image
Returns:
A pixbuf containing the game's image, or None if no image is available
"""
try:
cover_path = game.get_cover_path(self.data_dir)
if not os.path.exists(cover_path):
return None
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
cover_path, width, height, True)
except Exception as e:
logger.error(f"Error loading image for {game.title}: {e}")
return None
def get_default_icon_paintable(self, icon_name: str, size: int = 128) -> 'Gdk.Paintable':
"""
Get a default icon as a paintable for use with GtkPicture widgets.
Args:
icon_name: The name of the icon to get
size: The size of the icon
Returns:
A paintable that can be used with GtkPicture widgets
"""
display = Gdk.Display.get_default()
icon_theme = Gtk.IconTheme.get_for_display(display)
# The empty list is for icon sizes, 1 is scale factor, Gtk.TextDirection.LTR is text direction
return icon_theme.lookup_icon(icon_name, [], size, 1, Gtk.TextDirection.LTR, 0)
def _get_game_dir_from_id(self, game_id: str) -> Path:
"""
Get the game directory path from a game ID using the new structured format.
For example, game ID 23 would be in data/games/000/000/023/
Args:
game_id: The ID of the game (will be converted to string and padded)
Returns:
Path to the game's directory
"""
# Convert to string if it's an integer
game_id_str = str(game_id)
# Ensure ID is padded to 9 digits
padded_id = game_id_str.zfill(9)
# Split into 3 groups of 3 digits
dir1, dir2, dir3 = padded_id[:3], padded_id[3:6], padded_id[6:]
# Return the full path
return self.games_dir / dir1 / dir2 / dir3
def _extract_game_id_from_path(self, game_path: Path) -> str:
"""
Extract a game ID from a directory path structured as 000/000/023.
Handles the reverse of _get_game_dir_from_id.
Args:
game_path: Path to a game directory or file within it
Returns:
The game ID as a string with leading zeros removed
"""
# If we're given a file, get its parent directory
if game_path.is_file():
game_path = game_path.parent
# Extract the directory components
dir3 = game_path.name
dir2 = game_path.parent.name
dir1 = game_path.parent.parent.name
# Combine directory parts to get the padded ID
padded_id = dir1 + dir2 + dir3
# Convert to integer and back to string to remove leading zeros
if padded_id.isdigit():
return str(int(padded_id))
else:
return padded_id
def get_next_game_id(self) -> int:
"""
Get the next available game ID by finding the highest existing numeric ID
and incrementing it by 1.
Returns:
The next available numeric ID for a game
"""
try:
# Look for the highest existing ID across all game directories
highest_id = -1
# Recursively search through all directories that might contain games
for game_yaml in self.games_dir.glob("*/*/*/game.yaml"):
try:
# Extract the ID from the path, which handles removing leading zeros
game_id = self._extract_game_id_from_path(game_yaml)
# Convert to integer for comparison
if game_id.isdigit():
id_int = int(game_id)
highest_id = max(highest_id, id_int)
except Exception as inner_e:
logger.error(f"Error parsing game ID from {game_yaml}: {inner_e}")
continue
# Start from the next ID after the highest found, or 0 if no numeric IDs exist
return highest_id + 1
except Exception as e:
logger.error(f"Error getting next game ID: {e}")
return 0
def load_runner_image(self, runner: Runner, width: int = 64, height: int = 64) -> Optional[GdkPixbuf.Pixbuf]:
"""
Load a runner's image as a pixbuf, scaled to the specified dimensions.
Args:
runner: The runner to load the image for
width: The desired width of the image
height: The desired height of the image
Returns:
A pixbuf containing the runner's image, or None if no image is available
"""
try:
if not runner.image or not os.path.exists(runner.image):
return None
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
runner.image, width, height, True)
except Exception as e:
logger.error(f"Error loading image for {runner.title}: {e}")
return None
def _update_completion_status_based_on_activity(self, game: Game) -> bool:
"""
Update completion status based on play activity indicators.
If the game has any play activity (count > 0, time > 0, or timestamps)
and status is NOT_PLAYED, change it to PLAYED.
Args:
game: The game to check and update
Returns:
True if completion status was updated, False otherwise
"""
if game.completion_status != CompletionStatus.NOT_PLAYED:
return False
# Check for any play activity
has_play_count = game.play_count is not None and game.play_count > 0
has_play_time = game.play_time is not None and game.play_time > 0
has_first_played = game.first_played is not None
has_last_played = game.last_played is not None
if has_play_count or has_play_time or has_first_played or has_last_played:
game.completion_status = CompletionStatus.PLAYED
logger.debug(f"Auto-updating completion status to PLAYED for game {game.id} (has play activity)")
return True
return False
def update_play_activity(self, game: Game, play_count: Optional[int] = None,
play_time: Optional[int] = None,
first_played: Optional[float] = None,
last_played: Optional[float] = None) -> bool:
"""
Update multiple play activity fields at once with consistent completion status handling.
Args:
game: The game to update
play_count: New play count (None = don't change)
play_time: New play time (None = don't change)
first_played: New first played timestamp (None = don't change)
last_played: New last played timestamp (None = don't change)
Returns:
True if successfully updated, False otherwise
"""
try:
# Update fields that were provided
if play_count is not None:
game.play_count = play_count
if play_time is not None:
game.play_time = play_time
if first_played is not None:
game.first_played = first_played
if last_played is not None:
game.last_played = last_played
# Update completion status based on all play activity
self._update_completion_status_based_on_activity(game)
# Save all data
return self.save_game(game, preserve_created_time=True)
except Exception as e:
logger.error(f"Error updating play activity for {game.id}: {e}")
return False
def update_play_count(self, game: Game, count: Optional[int]) -> bool:
"""
Update the play count for a game and save it to playtime.yaml.
Also manages the completion status based on play count:
- If count is None: No completion status changes (play count not tracked)
- If count is 0 and status is Playing/Played/Beaten/Completed, reset to Not Played
- If count > 0 and status is Not Played, change to Played
Args:
game: The game to update the play count for
count: The new play count value (None = not tracked, 0 = explicitly zero)
Returns:
True if the play count was successfully updated, False otherwise
"""
try:
# Check if we need to update completion status based on play count
status_updated = False
# Define states that should be reset to NOT_PLAYED when play count is 0
playable_states = [
CompletionStatus.PLAYING,
CompletionStatus.PLAYED,
CompletionStatus.BEATEN,
CompletionStatus.COMPLETED
]
# Update the play count in the game object
old_count = game.play_count
game.play_count = count
# Set last_played timestamp when play count increases
if old_count is not None and count is not None and count > old_count:
game.last_played = time.time()
# Set first_played if this is the first time
if not hasattr(game, 'first_played') or game.first_played is None:
game.first_played = game.last_played
# Handle completion status based on all play activity
# Special case: if count is explicitly 0 and no other play activity, reset to NOT_PLAYED
if (count == 0 and not game.first_played and not game.last_played and
(game.play_time is None or game.play_time == 0)):
if game.completion_status in playable_states:
game.completion_status = CompletionStatus.NOT_PLAYED
logger.info(f"Resetting completion status for game {game.title} to NOT_PLAYED (no play activity)")
else:
# Otherwise, update to PLAYED if there's any activity and status is NOT_PLAYED
self._update_completion_status_based_on_activity(game)
# Save consolidated data to game.yaml (includes playtime and completion status)
return self.save_game(game, preserve_created_time=True)
except Exception as e:
logger.error(f"Error updating play count for {game.id}: {e}")
return False
def increment_play_count(self, game: Game) -> bool:
"""
Increment the play count for a game by 1.
Uses update_play_count with current count + 1.
This will automatically update the completion status if needed:
- If the current status is NOT_PLAYED and play count becomes > 0, it changes to PLAYED
Args:
game: The game to increment the play count for
Returns:
True if the play count was successfully incremented, False otherwise
"""
# Incrementing will always result in a count > 0, which means
# the update_play_count method will handle changing NOT_PLAYED to PLAYED
current_count = game.play_count if game.play_count is not None else 0
return self.update_play_count(game, current_count + 1)
def _save_playtime_data(self, game: Game) -> bool:
"""
Save consolidated playtime data to playtime.yaml file.
Args:
game: The game to save playtime data for
Returns:
True if successfully saved, False otherwise
"""
game_dir = self._get_game_dir_from_id(game.id)
play_time_file = game_dir / "playtime.yaml"
try:
# Create consolidated playtime data
playtime_data = {
"play_count": game.play_count,
"play_time_seconds": game.play_time,
"last_played": game.last_played,
"first_played": getattr(game, 'first_played', None)
}
# Write to the file
with open(play_time_file, "w") as f:
yaml.dump(playtime_data, f)
return True
except Exception as e:
logger.error(f"Error saving playtime data for {game.id}: {e}")
return False
def update_play_time(self, game: Game, seconds: Optional[int]) -> bool:
"""
Update the play time for a game with a specific value.
Args:
game: The game to update the play time for
seconds: The total seconds to set play time to (None = not tracked, 0 = explicitly zero)
Returns: