game_list.cpp (59055B)
1 // SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com> 2 // SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) 3 4 #include "game_list.h" 5 #include "bios.h" 6 #include "fullscreen_ui.h" 7 #include "host.h" 8 #include "memory_card_image.h" 9 #include "psf_loader.h" 10 #include "settings.h" 11 #include "system.h" 12 13 #include "util/cd_image.h" 14 #include "util/http_downloader.h" 15 #include "util/image.h" 16 #include "util/ini_settings_interface.h" 17 18 #include "common/binary_reader_writer.h" 19 #include "common/error.h" 20 #include "common/file_system.h" 21 #include "common/heterogeneous_containers.h" 22 #include "common/log.h" 23 #include "common/path.h" 24 #include "common/progress_callback.h" 25 #include "common/string_util.h" 26 #include "common/timer.h" 27 28 #include <algorithm> 29 #include <array> 30 #include <ctime> 31 #include <string_view> 32 #include <type_traits> 33 #include <unordered_map> 34 #include <utility> 35 36 Log_SetChannel(GameList); 37 38 #ifdef _WIN32 39 #include "common/windows_headers.h" 40 #endif 41 42 namespace GameList { 43 namespace { 44 45 enum : u32 46 { 47 GAME_LIST_CACHE_SIGNATURE = 0x45434C48, 48 GAME_LIST_CACHE_VERSION = 35, 49 50 PLAYED_TIME_SERIAL_LENGTH = 32, 51 PLAYED_TIME_LAST_TIME_LENGTH = 20, // uint64 52 PLAYED_TIME_TOTAL_TIME_LENGTH = 20, // uint64 53 PLAYED_TIME_LINE_LENGTH = 54 PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1 + PLAYED_TIME_TOTAL_TIME_LENGTH, 55 }; 56 57 struct PlayedTimeEntry 58 { 59 std::time_t last_played_time; 60 std::time_t total_played_time; 61 }; 62 63 #pragma pack(push, 1) 64 struct MemcardTimestampCacheEntry 65 { 66 enum : u32 67 { 68 MAX_SERIAL_LENGTH = 31, 69 }; 70 71 char serial[MAX_SERIAL_LENGTH]; 72 bool icon_was_extracted; 73 s64 memcard_timestamp; 74 }; 75 #pragma pack(pop) 76 77 } // namespace 78 79 using CacheMap = PreferUnorderedStringMap<Entry>; 80 using PlayedTimeMap = PreferUnorderedStringMap<PlayedTimeEntry>; 81 82 static_assert(std::is_same_v<decltype(Entry::hash), System::GameHash>); 83 84 static bool GetExeListEntry(const std::string& path, Entry* entry); 85 static bool GetPsfListEntry(const std::string& path, Entry* entry); 86 static bool GetDiscListEntry(const std::string& path, Entry* entry); 87 88 static void ApplyCustomAttributes(const std::string& path, Entry* entry, 89 const INISettingsInterface& custom_attributes_ini); 90 static bool RescanCustomAttributesForPath(const std::string& path, const INISettingsInterface& custom_attributes_ini); 91 static bool GetGameListEntryFromCache(const std::string& path, Entry* entry, 92 const INISettingsInterface& custom_attributes_ini); 93 static Entry* GetMutableEntryForPath(std::string_view path); 94 static void ScanDirectory(const char* path, bool recursive, bool only_cache, 95 const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map, 96 const INISettingsInterface& custom_attributes_ini, BinaryFileWriter& cache_writer, 97 ProgressCallback* progress); 98 static bool AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map, 99 const INISettingsInterface& custom_attributes_ini); 100 static bool ScanFile(std::string path, std::time_t timestamp, std::unique_lock<std::recursive_mutex>& lock, 101 const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini, 102 BinaryFileWriter& cache_writer); 103 104 static bool LoadOrInitializeCache(std::FILE* fp, bool invalidate_cache); 105 static bool LoadEntriesFromCache(BinaryFileReader& reader); 106 static bool WriteEntryToCache(const Entry* entry, BinaryFileWriter& writer); 107 static void CreateDiscSetEntries(const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map); 108 109 static std::string GetPlayedTimeFile(); 110 static bool ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry); 111 static std::string MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry); 112 static PlayedTimeMap LoadPlayedTimeMap(const std::string& path); 113 static PlayedTimeEntry UpdatePlayedTimeFile(const std::string& path, const std::string& serial, std::time_t last_time, 114 std::time_t add_time); 115 116 static std::string GetCustomPropertiesFile(); 117 118 static FileSystem::ManagedCFilePtr OpenMemoryCardTimestampCache(bool for_write); 119 static bool UpdateMemcardTimestampCache(const MemcardTimestampCacheEntry& entry); 120 121 static EntryList s_entries; 122 static std::recursive_mutex s_mutex; 123 static CacheMap s_cache_map; 124 static std::vector<MemcardTimestampCacheEntry> s_memcard_timestamp_cache_entries; 125 126 static bool s_game_list_loaded = false; 127 128 } // namespace GameList 129 130 const char* GameList::GetEntryTypeName(EntryType type) 131 { 132 static std::array<const char*, static_cast<int>(EntryType::Count)> names = { 133 {"Disc", "DiscSet", "PSExe", "Playlist", "PSF"}}; 134 return names[static_cast<int>(type)]; 135 } 136 137 const char* GameList::GetEntryTypeDisplayName(EntryType type) 138 { 139 static std::array<const char*, static_cast<int>(EntryType::Count)> names = { 140 {TRANSLATE_NOOP("GameList", "Disc"), TRANSLATE_NOOP("GameList", "Disc Set"), TRANSLATE_NOOP("GameList", "PS-EXE"), 141 TRANSLATE_NOOP("GameList", "Playlist"), TRANSLATE_NOOP("GameList", "PSF")}}; 142 return Host::TranslateToCString("GameList", names[static_cast<int>(type)]); 143 } 144 145 bool GameList::IsGameListLoaded() 146 { 147 return s_game_list_loaded; 148 } 149 150 bool GameList::IsScannableFilename(std::string_view path) 151 { 152 // we don't scan bin files because they'll duplicate 153 if (StringUtil::EndsWithNoCase(path, ".bin")) 154 return false; 155 156 return System::IsLoadableFilename(path); 157 } 158 159 bool GameList::GetExeListEntry(const std::string& path, GameList::Entry* entry) 160 { 161 std::FILE* fp = FileSystem::OpenCFile(path.c_str(), "rb"); 162 if (!fp) 163 return false; 164 165 std::fseek(fp, 0, SEEK_END); 166 const u32 file_size = static_cast<u32>(std::ftell(fp)); 167 std::fseek(fp, 0, SEEK_SET); 168 169 BIOS::PSEXEHeader header; 170 if (std::fread(&header, sizeof(header), 1, fp) != 1) 171 { 172 std::fclose(fp); 173 return false; 174 } 175 176 std::fclose(fp); 177 178 if (!BIOS::IsValidPSExeHeader(header, file_size)) 179 { 180 WARNING_LOG("{} is not a valid PS-EXE", path); 181 return false; 182 } 183 184 const System::GameHash hash = System::GetGameHashFromFile(path.c_str()); 185 186 entry->serial = hash ? System::GetGameHashId(hash) : std::string(); 187 entry->title = Path::GetFileTitle(FileSystem::GetDisplayNameFromPath(path)); 188 entry->region = BIOS::GetPSExeDiscRegion(header); 189 entry->file_size = ZeroExtend64(file_size); 190 entry->uncompressed_size = entry->file_size; 191 entry->type = EntryType::PSExe; 192 entry->compatibility = GameDatabase::CompatibilityRating::Unknown; 193 194 return true; 195 } 196 197 bool GameList::GetPsfListEntry(const std::string& path, Entry* entry) 198 { 199 // we don't need to walk the library chain here - the top file is enough 200 PSFLoader::File file; 201 if (!file.Load(path.c_str(), nullptr)) 202 return false; 203 204 entry->serial.clear(); 205 entry->region = file.GetRegion(); 206 entry->file_size = static_cast<u32>(file.GetProgramData().size()); 207 entry->uncompressed_size = entry->file_size; 208 entry->type = EntryType::PSF; 209 entry->compatibility = GameDatabase::CompatibilityRating::Unknown; 210 211 // Game - Title 212 std::optional<std::string> game(file.GetTagString("game")); 213 if (game.has_value()) 214 { 215 entry->title = std::move(game.value()); 216 entry->title += " - "; 217 } 218 else 219 { 220 entry->title.clear(); 221 } 222 223 std::optional<std::string> title(file.GetTagString("title")); 224 if (title.has_value()) 225 { 226 entry->title += title.value(); 227 } 228 else 229 { 230 const std::string display_name(FileSystem::GetDisplayNameFromPath(path)); 231 entry->title += Path::GetFileTitle(display_name); 232 } 233 234 return true; 235 } 236 237 bool GameList::GetDiscListEntry(const std::string& path, Entry* entry) 238 { 239 std::unique_ptr<CDImage> cdi = CDImage::Open(path.c_str(), false, nullptr); 240 if (!cdi) 241 return false; 242 243 entry->path = path; 244 entry->file_size = cdi->GetSizeOnDisk(); 245 entry->uncompressed_size = static_cast<u64>(CDImage::RAW_SECTOR_SIZE) * static_cast<u64>(cdi->GetLBACount()); 246 entry->type = EntryType::Disc; 247 entry->compatibility = GameDatabase::CompatibilityRating::Unknown; 248 249 std::string id; 250 System::GetGameDetailsFromImage(cdi.get(), &id, &entry->hash); 251 252 // try the database first 253 const GameDatabase::Entry* dentry = GameDatabase::GetEntryForGameDetails(id, entry->hash); 254 if (dentry) 255 { 256 // pull from database 257 entry->serial = dentry->serial; 258 entry->title = dentry->title; 259 entry->genre = dentry->genre; 260 entry->publisher = dentry->publisher; 261 entry->developer = dentry->developer; 262 entry->release_date = dentry->release_date; 263 entry->min_players = dentry->min_players; 264 entry->max_players = dentry->max_players; 265 entry->min_blocks = dentry->min_blocks; 266 entry->max_blocks = dentry->max_blocks; 267 entry->supported_controllers = dentry->supported_controllers; 268 entry->compatibility = dentry->compatibility; 269 270 if (!cdi->HasSubImages()) 271 { 272 for (size_t i = 0; i < dentry->disc_set_serials.size(); i++) 273 { 274 if (dentry->disc_set_serials[i] == entry->serial) 275 { 276 entry->disc_set_name = dentry->disc_set_name; 277 entry->disc_set_index = static_cast<s8>(i); 278 break; 279 } 280 } 281 } 282 } 283 else 284 { 285 const std::string display_name(FileSystem::GetDisplayNameFromPath(path)); 286 287 // no game code, so use the filename title 288 entry->serial = std::move(id); 289 entry->title = Path::GetFileTitle(display_name); 290 entry->compatibility = GameDatabase::CompatibilityRating::Unknown; 291 entry->release_date = 0; 292 entry->min_players = 0; 293 entry->max_players = 0; 294 entry->min_blocks = 0; 295 entry->max_blocks = 0; 296 entry->supported_controllers = static_cast<u16>(~0u); 297 } 298 299 // region detection 300 entry->region = System::GetRegionForImage(cdi.get()); 301 302 if (cdi->HasSubImages()) 303 { 304 entry->type = EntryType::Playlist; 305 306 std::string image_title(cdi->GetMetadata("title")); 307 if (!image_title.empty()) 308 entry->title = std::move(image_title); 309 310 // get the size of all the subimages 311 const u32 subimage_count = cdi->GetSubImageCount(); 312 for (u32 i = 1; i < subimage_count; i++) 313 { 314 if (!cdi->SwitchSubImage(i, nullptr)) 315 { 316 ERROR_LOG("Failed to switch to subimage {} in '{}'", i, entry->path); 317 continue; 318 } 319 320 entry->uncompressed_size += static_cast<u64>(CDImage::RAW_SECTOR_SIZE) * static_cast<u64>(cdi->GetLBACount()); 321 } 322 } 323 324 return true; 325 } 326 327 bool GameList::PopulateEntryFromPath(const std::string& path, Entry* entry) 328 { 329 if (System::IsExeFileName(path)) 330 return GetExeListEntry(path, entry); 331 if (System::IsPsfFileName(path.c_str())) 332 return GetPsfListEntry(path, entry); 333 return GetDiscListEntry(path, entry); 334 } 335 336 bool GameList::GetGameListEntryFromCache(const std::string& path, Entry* entry, 337 const INISettingsInterface& custom_attributes_ini) 338 { 339 auto iter = s_cache_map.find(path); 340 if (iter == s_cache_map.end()) 341 return false; 342 343 *entry = std::move(iter->second); 344 s_cache_map.erase(iter); 345 ApplyCustomAttributes(path, entry, custom_attributes_ini); 346 return true; 347 } 348 349 bool GameList::LoadEntriesFromCache(BinaryFileReader& reader) 350 { 351 u32 file_signature, file_version; 352 if (!reader.ReadU32(&file_signature) || !reader.ReadU32(&file_version) || 353 file_signature != GAME_LIST_CACHE_SIGNATURE || file_version != GAME_LIST_CACHE_VERSION) 354 { 355 WARNING_LOG("Game list cache is corrupted"); 356 return false; 357 } 358 359 while (!reader.IsAtEnd()) 360 { 361 std::string path; 362 Entry ge; 363 364 u8 type; 365 u8 region; 366 u8 compatibility_rating; 367 368 if (!reader.ReadU8(&type) || !reader.ReadU8(®ion) || !reader.ReadSizePrefixedString(&path) || 369 !reader.ReadSizePrefixedString(&ge.serial) || !reader.ReadSizePrefixedString(&ge.title) || 370 !reader.ReadSizePrefixedString(&ge.disc_set_name) || !reader.ReadSizePrefixedString(&ge.genre) || 371 !reader.ReadSizePrefixedString(&ge.publisher) || !reader.ReadSizePrefixedString(&ge.developer) || 372 !reader.ReadU64(&ge.hash) || !reader.ReadS64(&ge.file_size) || !reader.ReadU64(&ge.uncompressed_size) || 373 !reader.ReadU64(reinterpret_cast<u64*>(&ge.last_modified_time)) || !reader.ReadU64(&ge.release_date) || 374 !reader.ReadU16(&ge.supported_controllers) || !reader.ReadU8(&ge.min_players) || 375 !reader.ReadU8(&ge.max_players) || !reader.ReadU8(&ge.min_blocks) || !reader.ReadU8(&ge.max_blocks) || 376 !reader.ReadS8(&ge.disc_set_index) || !reader.ReadU8(&compatibility_rating) || 377 region >= static_cast<u8>(DiscRegion::Count) || type >= static_cast<u8>(EntryType::Count) || 378 compatibility_rating >= static_cast<u8>(GameDatabase::CompatibilityRating::Count)) 379 { 380 WARNING_LOG("Game list cache entry is corrupted"); 381 return false; 382 } 383 384 ge.path = path; 385 ge.region = static_cast<DiscRegion>(region); 386 ge.type = static_cast<EntryType>(type); 387 ge.compatibility = static_cast<GameDatabase::CompatibilityRating>(compatibility_rating); 388 389 auto iter = s_cache_map.find(ge.path); 390 if (iter != s_cache_map.end()) 391 iter->second = std::move(ge); 392 else 393 s_cache_map.emplace(std::move(path), std::move(ge)); 394 } 395 396 return true; 397 } 398 399 bool GameList::WriteEntryToCache(const Entry* entry, BinaryFileWriter& writer) 400 { 401 writer.WriteU8(static_cast<u8>(entry->type)); 402 writer.WriteU8(static_cast<u8>(entry->region)); 403 writer.WriteSizePrefixedString(entry->path); 404 writer.WriteSizePrefixedString(entry->serial); 405 writer.WriteSizePrefixedString(entry->title); 406 writer.WriteSizePrefixedString(entry->disc_set_name); 407 writer.WriteSizePrefixedString(entry->genre); 408 writer.WriteSizePrefixedString(entry->publisher); 409 writer.WriteSizePrefixedString(entry->developer); 410 writer.WriteU64(entry->hash); 411 writer.WriteS64(entry->file_size); 412 writer.WriteU64(entry->uncompressed_size); 413 writer.WriteU64(entry->last_modified_time); 414 writer.WriteU64(entry->release_date); 415 writer.WriteU16(entry->supported_controllers); 416 writer.WriteU8(entry->min_players); 417 writer.WriteU8(entry->max_players); 418 writer.WriteU8(entry->min_blocks); 419 writer.WriteU8(entry->max_blocks); 420 writer.WriteS8(entry->disc_set_index); 421 writer.WriteU8(static_cast<u8>(entry->compatibility)); 422 return writer.IsGood(); 423 } 424 425 bool GameList::LoadOrInitializeCache(std::FILE* fp, bool invalidate_cache) 426 { 427 BinaryFileReader reader(fp); 428 if (!invalidate_cache && !reader.IsAtEnd() && LoadEntriesFromCache(reader)) 429 { 430 // Prepare for writing. 431 return (FileSystem::FSeek64(fp, 0, SEEK_END) == 0); 432 } 433 434 WARNING_LOG("Initializing game list cache."); 435 s_cache_map.clear(); 436 437 // Truncate file, and re-write header. 438 Error error; 439 if (!FileSystem::FSeek64(fp, 0, SEEK_SET, &error) || !FileSystem::FTruncate64(fp, 0, &error)) 440 { 441 ERROR_LOG("Failed to truncate game list cache: {}", error.GetDescription()); 442 return false; 443 } 444 445 BinaryFileWriter writer(fp); 446 writer.WriteU32(GAME_LIST_CACHE_SIGNATURE); 447 writer.WriteU32((GAME_LIST_CACHE_VERSION)); 448 if (!writer.Flush(&error)) 449 { 450 ERROR_LOG("Failed to write game list cache header: {}", error.GetDescription()); 451 return false; 452 } 453 454 return true; 455 } 456 457 static bool IsPathExcluded(const std::vector<std::string>& excluded_paths, const std::string& path) 458 { 459 return std::find_if(excluded_paths.begin(), excluded_paths.end(), 460 [&path](const std::string& entry) { return path.starts_with(entry); }) != excluded_paths.end(); 461 } 462 463 void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, 464 const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map, 465 const INISettingsInterface& custom_attributes_ini, BinaryFileWriter& cache_writer, 466 ProgressCallback* progress) 467 { 468 INFO_LOG("Scanning {}{}", path, recursive ? " (recursively)" : ""); 469 470 progress->SetStatusText(SmallString::from_format(TRANSLATE_FS("GameList", "Scanning directory '{}'..."), path)); 471 472 FileSystem::FindResultsArray files; 473 FileSystem::FindFiles(path, "*", 474 recursive ? (FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_HIDDEN_FILES | FILESYSTEM_FIND_RECURSIVE) : 475 (FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_HIDDEN_FILES), 476 &files); 477 if (files.empty()) 478 return; 479 480 progress->PushState(); 481 progress->SetProgressRange(static_cast<u32>(files.size())); 482 progress->SetProgressValue(0); 483 484 u32 files_scanned = 0; 485 for (FILESYSTEM_FIND_DATA& ffd : files) 486 { 487 files_scanned++; 488 489 if (progress->IsCancelled() || !GameList::IsScannableFilename(ffd.FileName) || 490 IsPathExcluded(excluded_paths, ffd.FileName)) 491 { 492 continue; 493 } 494 495 std::unique_lock lock(s_mutex); 496 if (GetEntryForPath(ffd.FileName) || 497 AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map, custom_attributes_ini) || only_cache) 498 { 499 continue; 500 } 501 502 progress->SetStatusText(SmallString::from_format(TRANSLATE_FS("GameList", "Scanning '{}'..."), 503 FileSystem::GetDisplayNameFromPath(ffd.FileName))); 504 ScanFile(std::move(ffd.FileName), ffd.ModificationTime, lock, played_time_map, custom_attributes_ini, cache_writer); 505 progress->SetProgressValue(files_scanned); 506 } 507 508 progress->SetProgressValue(files_scanned); 509 progress->PopState(); 510 } 511 512 bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map, 513 const INISettingsInterface& custom_attributes_ini) 514 { 515 Entry entry; 516 if (!GetGameListEntryFromCache(path, &entry, custom_attributes_ini) || entry.last_modified_time != timestamp) 517 return false; 518 519 auto iter = played_time_map.find(entry.serial); 520 if (iter != played_time_map.end()) 521 { 522 entry.last_played_time = iter->second.last_played_time; 523 entry.total_played_time = iter->second.total_played_time; 524 } 525 526 s_entries.push_back(std::move(entry)); 527 return true; 528 } 529 530 bool GameList::ScanFile(std::string path, std::time_t timestamp, std::unique_lock<std::recursive_mutex>& lock, 531 const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini, 532 BinaryFileWriter& cache_writer) 533 { 534 // don't block UI while scanning 535 lock.unlock(); 536 537 DEV_LOG("Scanning '{}'...", path); 538 539 Entry entry; 540 if (!PopulateEntryFromPath(path, &entry)) 541 return false; 542 543 entry.path = std::move(path); 544 entry.last_modified_time = timestamp; 545 546 if (cache_writer.IsOpen() && !WriteEntryToCache(&entry, cache_writer)) [[unlikely]] 547 WARNING_LOG("Failed to write entry '{}' to cache", entry.path); 548 549 const auto iter = played_time_map.find(entry.serial); 550 if (iter != played_time_map.end()) 551 { 552 entry.last_played_time = iter->second.last_played_time; 553 entry.total_played_time = iter->second.total_played_time; 554 } 555 556 ApplyCustomAttributes(entry.path, &entry, custom_attributes_ini); 557 558 lock.lock(); 559 560 // replace if present 561 auto it = std::find_if(s_entries.begin(), s_entries.end(), 562 [&entry](const Entry& existing_entry) { return (existing_entry.path == entry.path); }); 563 if (it != s_entries.end()) 564 *it = std::move(entry); 565 else 566 s_entries.push_back(std::move(entry)); 567 568 return true; 569 } 570 571 bool GameList::RescanCustomAttributesForPath(const std::string& path, const INISettingsInterface& custom_attributes_ini) 572 { 573 FILESYSTEM_STAT_DATA sd; 574 if (!FileSystem::StatFile(path.c_str(), &sd)) 575 return false; 576 577 { 578 // cancel if excluded 579 const std::vector<std::string> excluded_paths(Host::GetBaseStringListSetting("GameList", "ExcludedPaths")); 580 if (IsPathExcluded(excluded_paths, path)) 581 return false; 582 } 583 584 Entry entry; 585 if (!PopulateEntryFromPath(path, &entry)) 586 return false; 587 588 entry.path = path; 589 entry.last_modified_time = sd.ModificationTime; 590 591 const PlayedTimeMap played_time_map(LoadPlayedTimeMap(GetPlayedTimeFile())); 592 const auto iter = played_time_map.find(entry.serial); 593 if (iter != played_time_map.end()) 594 { 595 entry.last_played_time = iter->second.last_played_time; 596 entry.total_played_time = iter->second.total_played_time; 597 } 598 599 ApplyCustomAttributes(entry.path, &entry, custom_attributes_ini); 600 601 std::unique_lock lock(s_mutex); 602 603 // replace if present 604 auto it = std::find_if(s_entries.begin(), s_entries.end(), 605 [&entry](const Entry& existing_entry) { return (existing_entry.path == entry.path); }); 606 if (it != s_entries.end()) 607 *it = std::move(entry); 608 else 609 s_entries.push_back(std::move(entry)); 610 611 return true; 612 } 613 614 void GameList::ApplyCustomAttributes(const std::string& path, Entry* entry, 615 const INISettingsInterface& custom_attributes_ini) 616 { 617 std::optional<std::string> custom_title = custom_attributes_ini.GetOptionalStringValue(path.c_str(), "Title"); 618 if (custom_title.has_value()) 619 { 620 entry->title = std::move(custom_title.value()); 621 entry->has_custom_title = true; 622 } 623 const std::optional<SmallString> custom_region_str = 624 custom_attributes_ini.GetOptionalSmallStringValue(path.c_str(), "Region"); 625 if (custom_region_str.has_value()) 626 { 627 const std::optional<DiscRegion> custom_region = Settings::ParseDiscRegionName(custom_region_str.value()); 628 if (custom_region.has_value()) 629 { 630 entry->region = custom_region.value(); 631 entry->has_custom_region = true; 632 } 633 else 634 { 635 WARNING_LOG("Invalid region '{}' in custom attributes for '{}'", custom_region_str.value(), path); 636 } 637 } 638 } 639 640 std::unique_lock<std::recursive_mutex> GameList::GetLock() 641 { 642 return std::unique_lock<std::recursive_mutex>(s_mutex); 643 } 644 645 const GameList::Entry* GameList::GetEntryByIndex(u32 index) 646 { 647 return (index < s_entries.size()) ? &s_entries[index] : nullptr; 648 } 649 650 const GameList::Entry* GameList::GetEntryForPath(std::string_view path) 651 { 652 return GetMutableEntryForPath(path); 653 } 654 655 GameList::Entry* GameList::GetMutableEntryForPath(std::string_view path) 656 { 657 for (Entry& entry : s_entries) 658 { 659 // Use case-insensitive compare on Windows, since it's the same file. 660 #ifdef _WIN32 661 if (StringUtil::EqualNoCase(entry.path, path)) 662 return &entry; 663 #else 664 if (entry.path == path) 665 return &entry; 666 #endif 667 } 668 669 return nullptr; 670 } 671 672 const GameList::Entry* GameList::GetEntryBySerial(std::string_view serial) 673 { 674 for (const Entry& entry : s_entries) 675 { 676 if (entry.serial == serial) 677 return &entry; 678 } 679 680 return nullptr; 681 } 682 683 const GameList::Entry* GameList::GetEntryBySerialAndHash(std::string_view serial, u64 hash) 684 { 685 for (const Entry& entry : s_entries) 686 { 687 if (entry.serial == serial && entry.hash == hash) 688 return &entry; 689 } 690 691 return nullptr; 692 } 693 694 std::vector<const GameList::Entry*> GameList::GetDiscSetMembers(std::string_view disc_set_name, 695 bool sort_by_most_recent) 696 { 697 std::vector<const Entry*> ret; 698 for (const Entry& entry : s_entries) 699 { 700 if (!entry.disc_set_member || disc_set_name != entry.disc_set_name) 701 continue; 702 703 ret.push_back(&entry); 704 } 705 706 if (sort_by_most_recent) 707 { 708 std::sort(ret.begin(), ret.end(), [](const Entry* lhs, const Entry* rhs) { 709 if (lhs->last_played_time == rhs->last_played_time) 710 return (lhs->disc_set_index < rhs->disc_set_index); 711 else 712 return (lhs->last_played_time > rhs->last_played_time); 713 }); 714 } 715 else 716 { 717 std::sort(ret.begin(), ret.end(), 718 [](const Entry* lhs, const Entry* rhs) { return (lhs->disc_set_index < rhs->disc_set_index); }); 719 } 720 721 return ret; 722 } 723 724 const GameList::Entry* GameList::GetFirstDiscSetMember(std::string_view disc_set_name) 725 { 726 for (const Entry& entry : s_entries) 727 { 728 if (!entry.disc_set_member || disc_set_name != entry.disc_set_name) 729 continue; 730 731 // Disc set should not have been created without the first disc being present. 732 if (entry.disc_set_index == 0) 733 return &entry; 734 } 735 736 return nullptr; 737 } 738 739 u32 GameList::GetEntryCount() 740 { 741 return static_cast<u32>(s_entries.size()); 742 } 743 744 void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* progress /* = nullptr */) 745 { 746 s_game_list_loaded = true; 747 748 if (!progress) 749 progress = ProgressCallback::NullProgressCallback; 750 751 Error error; 752 FileSystem::ManagedCFilePtr cache_file = 753 FileSystem::OpenExistingOrCreateManagedCFile(Path::Combine(EmuFolders::Cache, "gamelist.cache").c_str(), 0, &error); 754 if (!cache_file) 755 ERROR_LOG("Failed to open game list cache: {}", error.GetDescription()); 756 757 #ifndef _WIN32 758 // Lock cache file for multi-instance on Linux. Implicitly done on Windows. 759 std::optional<FileSystem::POSIXLock> cache_file_lock; 760 if (cache_file) 761 cache_file_lock.emplace(cache_file.get()); 762 if (!LoadOrInitializeCache(cache_file.get(), invalidate_cache)) 763 { 764 cache_file_lock.reset(); 765 cache_file.reset(); 766 } 767 #else 768 if (!LoadOrInitializeCache(cache_file.get(), invalidate_cache)) 769 cache_file.reset(); 770 #endif 771 BinaryFileWriter cache_writer(cache_file.get()); 772 773 // don't delete the old entries, since the frontend might still access them 774 std::vector<Entry> old_entries; 775 { 776 std::unique_lock lock(s_mutex); 777 old_entries.swap(s_entries); 778 } 779 780 const std::vector<std::string> excluded_paths(Host::GetBaseStringListSetting("GameList", "ExcludedPaths")); 781 const std::vector<std::string> dirs(Host::GetBaseStringListSetting("GameList", "Paths")); 782 std::vector<std::string> recursive_dirs(Host::GetBaseStringListSetting("GameList", "RecursivePaths")); 783 const PlayedTimeMap played_time(LoadPlayedTimeMap(GetPlayedTimeFile())); 784 INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile()); 785 custom_attributes_ini.Load(); 786 787 #ifdef __ANDROID__ 788 recursive_dirs.push_back(Path::Combine(EmuFolders::DataRoot, "games")); 789 #endif 790 791 if (!dirs.empty() || !recursive_dirs.empty()) 792 { 793 progress->SetProgressRange(static_cast<u32>(dirs.size() + recursive_dirs.size())); 794 progress->SetProgressValue(0); 795 796 // we manually count it here, because otherwise pop state updates it itself 797 int directory_counter = 0; 798 for (const std::string& dir : dirs) 799 { 800 if (progress->IsCancelled()) 801 break; 802 803 ScanDirectory(dir.c_str(), false, only_cache, excluded_paths, played_time, custom_attributes_ini, cache_writer, 804 progress); 805 progress->SetProgressValue(++directory_counter); 806 } 807 for (const std::string& dir : recursive_dirs) 808 { 809 if (progress->IsCancelled()) 810 break; 811 812 ScanDirectory(dir.c_str(), true, only_cache, excluded_paths, played_time, custom_attributes_ini, cache_writer, 813 progress); 814 progress->SetProgressValue(++directory_counter); 815 } 816 } 817 818 // don't need unused cache entries 819 s_cache_map.clear(); 820 821 // merge multi-disc games 822 CreateDiscSetEntries(excluded_paths, played_time); 823 } 824 825 GameList::EntryList GameList::TakeEntryList() 826 { 827 EntryList ret = std::move(s_entries); 828 s_entries = {}; 829 return ret; 830 } 831 832 void GameList::CreateDiscSetEntries(const std::vector<std::string>& excluded_paths, const PlayedTimeMap& played_time_map) 833 { 834 std::unique_lock lock(s_mutex); 835 836 for (size_t i = 0; i < s_entries.size(); i++) 837 { 838 const Entry& entry = s_entries[i]; 839 840 // only first discs can create sets 841 if (entry.type != EntryType::Disc || entry.disc_set_member || entry.disc_set_index != 0) 842 continue; 843 844 // already have a disc set by this name? 845 const std::string& disc_set_name = entry.disc_set_name; 846 if (GetEntryForPath(disc_set_name.c_str())) 847 continue; 848 849 const GameDatabase::Entry* dbentry = GameDatabase::GetEntryForSerial(entry.serial); 850 if (!dbentry) 851 continue; 852 853 // need at least two discs for a set 854 bool found_another_disc = false; 855 for (const Entry& other_entry : s_entries) 856 { 857 if (other_entry.type != EntryType::Disc || other_entry.disc_set_member || 858 other_entry.disc_set_name != disc_set_name || other_entry.disc_set_index == entry.disc_set_index) 859 { 860 continue; 861 } 862 found_another_disc = true; 863 break; 864 } 865 if (!found_another_disc) 866 { 867 DEV_LOG("Not creating disc set {}, only one disc found", disc_set_name); 868 continue; 869 } 870 871 Entry set_entry; 872 set_entry.type = EntryType::DiscSet; 873 set_entry.region = entry.region; 874 set_entry.path = disc_set_name; 875 set_entry.serial = entry.serial; 876 set_entry.title = entry.disc_set_name; 877 set_entry.genre = entry.developer; 878 set_entry.publisher = entry.publisher; 879 set_entry.developer = entry.developer; 880 set_entry.hash = entry.hash; 881 set_entry.file_size = 0; 882 set_entry.uncompressed_size = 0; 883 set_entry.last_modified_time = entry.last_modified_time; 884 set_entry.last_played_time = 0; 885 set_entry.total_played_time = 0; 886 set_entry.release_date = entry.release_date; 887 set_entry.supported_controllers = entry.supported_controllers; 888 set_entry.min_players = entry.min_players; 889 set_entry.max_players = entry.max_players; 890 set_entry.min_blocks = entry.min_blocks; 891 set_entry.max_blocks = entry.max_blocks; 892 set_entry.compatibility = entry.compatibility; 893 894 // figure out play time for all discs, and sum it 895 // we do this via lookups, rather than the other entries, because of duplicates 896 for (const std::string& set_serial : dbentry->disc_set_serials) 897 { 898 const auto it = played_time_map.find(set_serial); 899 if (it == played_time_map.end()) 900 continue; 901 902 set_entry.last_played_time = 903 (set_entry.last_played_time == 0) ? 904 it->second.last_played_time : 905 ((it->second.last_played_time != 0) ? std::max(set_entry.last_played_time, it->second.last_played_time) : 906 set_entry.last_played_time); 907 set_entry.total_played_time += it->second.total_played_time; 908 } 909 910 // mark all discs for this set as part of it, so we don't try to add them again, and for filtering 911 u32 num_parts = 0; 912 for (Entry& other_entry : s_entries) 913 { 914 if (other_entry.type != EntryType::Disc || other_entry.disc_set_member || 915 other_entry.disc_set_name != disc_set_name) 916 { 917 continue; 918 } 919 920 DEV_LOG("Adding {} to disc set {}", Path::GetFileName(other_entry.path), disc_set_name); 921 other_entry.disc_set_member = true; 922 set_entry.last_modified_time = std::min(set_entry.last_modified_time, other_entry.last_modified_time); 923 set_entry.file_size += other_entry.file_size; 924 set_entry.uncompressed_size += other_entry.uncompressed_size; 925 num_parts++; 926 } 927 928 DEV_LOG("Created disc set {} from {} entries", disc_set_name, num_parts); 929 930 // we have to do the exclusion check at the end, because otherwise the individual discs get added 931 if (!IsPathExcluded(excluded_paths, disc_set_name)) 932 s_entries.push_back(std::move(set_entry)); 933 } 934 } 935 936 std::string GameList::GetCoverImagePathForEntry(const Entry* entry) 937 { 938 return GetCoverImagePath(entry->path, entry->serial, entry->title); 939 } 940 941 static std::string GetFullCoverPath(std::string_view filename, std::string_view extension) 942 { 943 return fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{}.{}", EmuFolders::Covers, filename, extension); 944 } 945 946 std::string GameList::GetCoverImagePath(const std::string& path, const std::string& serial, const std::string& title) 947 { 948 static constexpr const std::array extensions = {"jpg", "jpeg", "png", "webp"}; 949 950 for (const char* extension : extensions) 951 { 952 // Prioritize lookup by serial (Most specific) 953 if (!serial.empty()) 954 { 955 const std::string cover_path(GetFullCoverPath(serial, extension)); 956 if (FileSystem::FileExists(cover_path.c_str())) 957 return cover_path; 958 } 959 960 // Try file title (for modded games or specific like above) 961 const std::string_view file_title(Path::GetFileTitle(path)); 962 if (!file_title.empty() && title != file_title) 963 { 964 const std::string cover_path(GetFullCoverPath(file_title, extension)); 965 if (FileSystem::FileExists(cover_path.c_str())) 966 return cover_path; 967 } 968 969 // Last resort, check the game title 970 if (!title.empty()) 971 { 972 const std::string cover_path(GetFullCoverPath(title, extension)); 973 if (FileSystem::FileExists(cover_path.c_str())) 974 return cover_path; 975 } 976 } 977 978 return {}; 979 } 980 981 std::string GameList::GetNewCoverImagePathForEntry(const Entry* entry, const char* new_filename, bool use_serial) 982 { 983 const char* extension = std::strrchr(new_filename, '.'); 984 if (!extension) 985 return {}; 986 987 std::string existing_filename = GetCoverImagePathForEntry(entry); 988 if (!existing_filename.empty()) 989 { 990 std::string::size_type pos = existing_filename.rfind('.'); 991 if (pos != std::string::npos && existing_filename.compare(pos, std::strlen(extension), extension) == 0) 992 return existing_filename; 993 } 994 995 // Check for illegal characters, use serial instead. 996 const std::string sanitized_name(Path::SanitizeFileName(entry->title)); 997 998 std::string name; 999 if (sanitized_name != entry->title || use_serial) 1000 name = fmt::format("{}{}", entry->serial, extension); 1001 else 1002 name = fmt::format("{}{}", entry->title, extension); 1003 1004 return Path::Combine(EmuFolders::Covers, Path::SanitizeFileName(name)); 1005 } 1006 1007 size_t GameList::Entry::GetReleaseDateString(char* buffer, size_t buffer_size) const 1008 { 1009 if (release_date == 0) 1010 return StringUtil::Strlcpy(buffer, "Unknown", buffer_size); 1011 1012 std::time_t date_as_time = static_cast<std::time_t>(release_date); 1013 #ifdef _WIN32 1014 tm date_tm = {}; 1015 gmtime_s(&date_tm, &date_as_time); 1016 #else 1017 tm date_tm = {}; 1018 gmtime_r(&date_as_time, &date_tm); 1019 #endif 1020 1021 return std::strftime(buffer, buffer_size, "%d %B %Y", &date_tm); 1022 } 1023 1024 std::string GameList::GetPlayedTimeFile() 1025 { 1026 return Path::Combine(EmuFolders::DataRoot, "playtime.dat"); 1027 } 1028 1029 bool GameList::ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry) 1030 { 1031 size_t len = std::strlen(line); 1032 if (len != (PLAYED_TIME_LINE_LENGTH + 1)) // \n 1033 { 1034 WARNING_LOG("Malformed line: '{}'", line); 1035 return false; 1036 } 1037 1038 const std::string_view serial_tok(StringUtil::StripWhitespace(std::string_view(line, PLAYED_TIME_SERIAL_LENGTH))); 1039 const std::string_view total_played_time_tok( 1040 StringUtil::StripWhitespace(std::string_view(line + PLAYED_TIME_SERIAL_LENGTH + 1, PLAYED_TIME_LAST_TIME_LENGTH))); 1041 const std::string_view last_played_time_tok(StringUtil::StripWhitespace(std::string_view( 1042 line + PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1, PLAYED_TIME_TOTAL_TIME_LENGTH))); 1043 1044 const std::optional<u64> total_played_time(StringUtil::FromChars<u64>(total_played_time_tok)); 1045 const std::optional<u64> last_played_time(StringUtil::FromChars<u64>(last_played_time_tok)); 1046 if (serial_tok.empty() || !last_played_time.has_value() || !total_played_time.has_value()) 1047 { 1048 WARNING_LOG("Malformed line: '{}'", line); 1049 return false; 1050 } 1051 1052 serial = serial_tok; 1053 entry.last_played_time = static_cast<std::time_t>(last_played_time.value()); 1054 entry.total_played_time = static_cast<std::time_t>(total_played_time.value()); 1055 return true; 1056 } 1057 1058 std::string GameList::MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry) 1059 { 1060 return fmt::format("{:<{}} {:<{}} {:<{}}\n", serial, static_cast<unsigned>(PLAYED_TIME_SERIAL_LENGTH), 1061 entry.total_played_time, static_cast<unsigned>(PLAYED_TIME_TOTAL_TIME_LENGTH), 1062 entry.last_played_time, static_cast<unsigned>(PLAYED_TIME_LAST_TIME_LENGTH)); 1063 } 1064 1065 GameList::PlayedTimeMap GameList::LoadPlayedTimeMap(const std::string& path) 1066 { 1067 PlayedTimeMap ret; 1068 1069 // Use write mode here, even though we're not writing, so we can lock the file from other updates. 1070 Error error; 1071 auto fp = FileSystem::OpenExistingOrCreateManagedCFile(path.c_str(), 0, &error); 1072 if (!fp) 1073 { 1074 ERROR_LOG("Failed to open '{}' for load: {}", Path::GetFileName(path), error.GetDescription()); 1075 return ret; 1076 } 1077 1078 #ifndef _WIN32 1079 FileSystem::POSIXLock flock(fp.get()); 1080 #endif 1081 1082 char line[256]; 1083 while (std::fgets(line, sizeof(line), fp.get())) 1084 { 1085 std::string serial; 1086 PlayedTimeEntry entry; 1087 if (!ParsePlayedTimeLine(line, serial, entry)) 1088 continue; 1089 1090 if (ret.find(serial) != ret.end()) 1091 { 1092 WARNING_LOG("Duplicate entry: '{}'", serial); 1093 continue; 1094 } 1095 1096 ret.emplace(std::move(serial), entry); 1097 } 1098 1099 return ret; 1100 } 1101 1102 GameList::PlayedTimeEntry GameList::UpdatePlayedTimeFile(const std::string& path, const std::string& serial, 1103 std::time_t last_time, std::time_t add_time) 1104 { 1105 const PlayedTimeEntry new_entry{last_time, add_time}; 1106 1107 Error error; 1108 auto fp = FileSystem::OpenExistingOrCreateManagedCFile(path.c_str(), 0, &error); 1109 if (!fp) 1110 { 1111 ERROR_LOG("Failed to open '{}' for update: {}", Path::GetFileName(path), error.GetDescription()); 1112 return new_entry; 1113 } 1114 1115 #ifndef _WIN32 1116 FileSystem::POSIXLock flock(fp.get()); 1117 #endif 1118 1119 for (;;) 1120 { 1121 char line[256]; 1122 const s64 line_pos = FileSystem::FTell64(fp.get()); 1123 if (!std::fgets(line, sizeof(line), fp.get())) 1124 break; 1125 1126 std::string line_serial; 1127 PlayedTimeEntry line_entry; 1128 if (!ParsePlayedTimeLine(line, line_serial, line_entry)) 1129 continue; 1130 1131 if (line_serial != serial) 1132 continue; 1133 1134 // found it! 1135 line_entry.last_played_time = (last_time != 0) ? last_time : 0; 1136 line_entry.total_played_time = (last_time != 0) ? (line_entry.total_played_time + add_time) : 0; 1137 1138 std::string new_line(MakePlayedTimeLine(serial, line_entry)); 1139 if (FileSystem::FSeek64(fp.get(), line_pos, SEEK_SET) != 0 || 1140 std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1) 1141 { 1142 ERROR_LOG("Failed to update '{}'.", path); 1143 } 1144 1145 return line_entry; 1146 } 1147 1148 if (last_time != 0) 1149 { 1150 // new entry. 1151 std::string new_line(MakePlayedTimeLine(serial, new_entry)); 1152 if (FileSystem::FSeek64(fp.get(), 0, SEEK_END) != 0 || 1153 std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1) 1154 { 1155 ERROR_LOG("Failed to write '{}'.", path); 1156 } 1157 } 1158 1159 return new_entry; 1160 } 1161 1162 void GameList::AddPlayedTimeForSerial(const std::string& serial, std::time_t last_time, std::time_t add_time) 1163 { 1164 if (serial.empty()) 1165 return; 1166 1167 const PlayedTimeEntry pt(UpdatePlayedTimeFile(GetPlayedTimeFile(), serial, last_time, add_time)); 1168 VERBOSE_LOG("Add {} seconds play time to {} -> now {}", static_cast<unsigned>(add_time), serial.c_str(), 1169 static_cast<unsigned>(pt.total_played_time)); 1170 1171 std::unique_lock<std::recursive_mutex> lock(s_mutex); 1172 for (GameList::Entry& entry : s_entries) 1173 { 1174 if (entry.serial != serial) 1175 continue; 1176 1177 entry.last_played_time = pt.last_played_time; 1178 entry.total_played_time = pt.total_played_time; 1179 } 1180 1181 // We don't need to update the disc sets if we're not running Big Picture, because Qt refreshes on system destory, 1182 // which causes the disc set entries to get recreated. 1183 if (FullscreenUI::IsInitialized()) 1184 { 1185 const GameDatabase::Entry* dbentry = GameDatabase::GetEntryForSerial(serial); 1186 if (dbentry && !dbentry->disc_set_serials.empty()) 1187 { 1188 for (GameList::Entry& entry : s_entries) 1189 { 1190 if (entry.type != EntryType::DiscSet || entry.path != dbentry->disc_set_name) 1191 continue; 1192 1193 entry.last_played_time = 0; 1194 entry.total_played_time = 0; 1195 1196 // We shouldn't ever have duplicates for disc sets, so this should be fine. 1197 const PlayedTimeMap ptm = LoadPlayedTimeMap(GetPlayedTimeFile()); 1198 for (const std::string& dsserial : dbentry->disc_set_serials) 1199 { 1200 const auto it = ptm.find(dsserial); 1201 if (it == ptm.end()) 1202 continue; 1203 1204 entry.last_played_time = 1205 (entry.last_played_time == 0) ? 1206 it->second.last_played_time : 1207 ((it->second.last_played_time != 0) ? std::max(entry.last_played_time, it->second.last_played_time) : 1208 entry.last_played_time); 1209 entry.total_played_time += it->second.total_played_time; 1210 } 1211 1212 break; 1213 } 1214 } 1215 } 1216 } 1217 1218 void GameList::ClearPlayedTimeForSerial(const std::string& serial) 1219 { 1220 if (serial.empty()) 1221 return; 1222 1223 UpdatePlayedTimeFile(GetPlayedTimeFile(), serial, 0, 0); 1224 1225 std::unique_lock<std::recursive_mutex> lock(s_mutex); 1226 for (GameList::Entry& entry : s_entries) 1227 { 1228 if (entry.serial != serial) 1229 continue; 1230 1231 entry.last_played_time = 0; 1232 entry.total_played_time = 0; 1233 } 1234 } 1235 1236 std::time_t GameList::GetCachedPlayedTimeForSerial(const std::string& serial) 1237 { 1238 if (serial.empty()) 1239 return 0; 1240 1241 std::unique_lock<std::recursive_mutex> lock(s_mutex); 1242 for (GameList::Entry& entry : s_entries) 1243 { 1244 if (entry.serial == serial) 1245 return entry.total_played_time; 1246 } 1247 1248 return 0; 1249 } 1250 1251 TinyString GameList::FormatTimestamp(std::time_t timestamp) 1252 { 1253 TinyString ret; 1254 1255 if (timestamp == 0) 1256 { 1257 ret = TRANSLATE("GameList", "Never"); 1258 } 1259 else 1260 { 1261 struct tm ctime = {}; 1262 struct tm ttime = {}; 1263 const std::time_t ctimestamp = std::time(nullptr); 1264 #ifdef _MSC_VER 1265 localtime_s(&ctime, &ctimestamp); 1266 localtime_s(&ttime, ×tamp); 1267 #else 1268 localtime_r(&ctimestamp, &ctime); 1269 localtime_r(×tamp, &ttime); 1270 #endif 1271 1272 if (ctime.tm_year == ttime.tm_year && ctime.tm_yday == ttime.tm_yday) 1273 { 1274 ret = TRANSLATE("GameList", "Today"); 1275 } 1276 else if ((ctime.tm_year == ttime.tm_year && ctime.tm_yday == (ttime.tm_yday + 1)) || 1277 (ctime.tm_yday == 0 && (ctime.tm_year - 1) == ttime.tm_year)) 1278 { 1279 ret = TRANSLATE("GameList", "Yesterday"); 1280 } 1281 else 1282 { 1283 char buf[128]; 1284 std::strftime(buf, std::size(buf), "%x", &ttime); 1285 ret.assign(buf); 1286 } 1287 } 1288 1289 return ret; 1290 } 1291 1292 TinyString GameList::FormatTimespan(std::time_t timespan, bool long_format) 1293 { 1294 const u32 hours = static_cast<u32>(timespan / 3600); 1295 const u32 minutes = static_cast<u32>((timespan % 3600) / 60); 1296 const u32 seconds = static_cast<u32>((timespan % 3600) % 60); 1297 1298 TinyString ret; 1299 if (!long_format) 1300 { 1301 if (hours >= 100) 1302 ret.format(TRANSLATE_FS("GameList", "{}h {}m"), hours, minutes); 1303 else if (hours > 0) 1304 ret.format(TRANSLATE_FS("GameList", "{}h {}m {}s"), hours, minutes, seconds); 1305 else if (minutes > 0) 1306 ret.format(TRANSLATE_FS("GameList", "{}m {}s"), minutes, seconds); 1307 else if (seconds > 0) 1308 ret.format(TRANSLATE_FS("GameList", "{}s"), seconds); 1309 else 1310 ret = TRANSLATE_SV("GameList", "None"); 1311 } 1312 else 1313 { 1314 if (hours > 0) 1315 ret.assign(TRANSLATE_PLURAL_STR("GameList", "%n hours", "", hours)); 1316 else 1317 ret.assign(TRANSLATE_PLURAL_STR("GameList", "%n minutes", "", minutes)); 1318 } 1319 1320 return ret; 1321 } 1322 1323 std::vector<std::pair<std::string, const GameList::Entry*>> 1324 GameList::GetMatchingEntriesForSerial(const std::span<const std::string> serials) 1325 { 1326 std::vector<std::pair<std::string, const GameList::Entry*>> ret; 1327 ret.reserve(serials.size()); 1328 1329 for (const std::string& serial : serials) 1330 { 1331 const Entry* matching_entry = nullptr; 1332 bool has_multiple_entries = false; 1333 1334 for (const Entry& entry : s_entries) 1335 { 1336 if (entry.IsDiscSet() || entry.serial != serial) 1337 continue; 1338 1339 if (!matching_entry) 1340 matching_entry = &entry; 1341 else 1342 has_multiple_entries = true; 1343 } 1344 1345 if (!matching_entry) 1346 continue; 1347 1348 if (!has_multiple_entries) 1349 { 1350 ret.emplace_back(matching_entry->title, matching_entry); 1351 continue; 1352 } 1353 1354 // Have to add all matching files. 1355 for (const Entry& entry : s_entries) 1356 { 1357 if (entry.IsDiscSet() || entry.serial != serial) 1358 continue; 1359 1360 ret.emplace_back(Path::GetFileName(entry.path), &entry); 1361 } 1362 } 1363 1364 return ret; 1365 } 1366 1367 bool GameList::DownloadCovers(const std::vector<std::string>& url_templates, bool use_serial, 1368 ProgressCallback* progress, std::function<void(const Entry*, std::string)> save_callback) 1369 { 1370 if (!progress) 1371 progress = ProgressCallback::NullProgressCallback; 1372 1373 bool has_title = false; 1374 bool has_file_title = false; 1375 bool has_serial = false; 1376 for (const std::string& url_template : url_templates) 1377 { 1378 if (!has_title && url_template.find("${title}") != std::string::npos) 1379 has_title = true; 1380 if (!has_file_title && url_template.find("${filetitle}") != std::string::npos) 1381 has_file_title = true; 1382 if (!has_serial && url_template.find("${serial}") != std::string::npos) 1383 has_serial = true; 1384 } 1385 if (!has_title && !has_file_title && !has_serial) 1386 { 1387 progress->DisplayError("URL template must contain at least one of ${title}, ${filetitle}, or ${serial}."); 1388 return false; 1389 } 1390 1391 std::vector<std::pair<std::string, std::string>> download_urls; 1392 { 1393 std::unique_lock lock(s_mutex); 1394 for (const GameList::Entry& entry : s_entries) 1395 { 1396 const std::string existing_path(GetCoverImagePathForEntry(&entry)); 1397 if (!existing_path.empty()) 1398 continue; 1399 1400 for (const std::string& url_template : url_templates) 1401 { 1402 std::string url(url_template); 1403 if (has_title) 1404 StringUtil::ReplaceAll(&url, "${title}", Path::URLEncode(entry.title)); 1405 if (has_file_title) 1406 { 1407 std::string display_name(FileSystem::GetDisplayNameFromPath(entry.path)); 1408 StringUtil::ReplaceAll(&url, "${filetitle}", Path::URLEncode(Path::GetFileTitle(display_name))); 1409 } 1410 if (has_serial) 1411 StringUtil::ReplaceAll(&url, "${serial}", Path::URLEncode(entry.serial)); 1412 1413 download_urls.emplace_back(entry.path, std::move(url)); 1414 } 1415 } 1416 } 1417 if (download_urls.empty()) 1418 { 1419 progress->DisplayError("No URLs to download enumerated."); 1420 return false; 1421 } 1422 1423 std::unique_ptr<HTTPDownloader> downloader(HTTPDownloader::Create(Host::GetHTTPUserAgent())); 1424 if (!downloader) 1425 { 1426 progress->DisplayError("Failed to create HTTP downloader."); 1427 return false; 1428 } 1429 1430 progress->SetCancellable(true); 1431 progress->SetProgressRange(static_cast<u32>(download_urls.size())); 1432 1433 for (auto& [entry_path, url] : download_urls) 1434 { 1435 if (progress->IsCancelled()) 1436 break; 1437 1438 // make sure it didn't get done already 1439 { 1440 std::unique_lock lock(s_mutex); 1441 const GameList::Entry* entry = GetEntryForPath(entry_path); 1442 if (!entry || !GetCoverImagePathForEntry(entry).empty()) 1443 { 1444 progress->IncrementProgressValue(); 1445 continue; 1446 } 1447 1448 progress->FormatStatusText("Downloading cover for {}...", entry->title); 1449 } 1450 1451 // we could actually do a few in parallel here... 1452 std::string filename = Path::URLDecode(url); 1453 downloader->CreateRequest( 1454 std::move(url), [use_serial, &save_callback, entry_path = std::move(entry_path), filename = std::move(filename)]( 1455 s32 status_code, const std::string& content_type, HTTPDownloader::Request::Data data) { 1456 if (status_code != HTTPDownloader::HTTP_STATUS_OK || data.empty()) 1457 return; 1458 1459 std::unique_lock lock(s_mutex); 1460 const GameList::Entry* entry = GetEntryForPath(entry_path); 1461 if (!entry || !GetCoverImagePathForEntry(entry).empty()) 1462 return; 1463 1464 // prefer the content type from the response for the extension 1465 // otherwise, if it's missing, and the request didn't have an extension.. fall back to jpegs. 1466 std::string template_filename; 1467 std::string content_type_extension(HTTPDownloader::GetExtensionForContentType(content_type)); 1468 1469 // don't treat the domain name as an extension.. 1470 const std::string::size_type last_slash = filename.find('/'); 1471 const std::string::size_type last_dot = filename.find('.'); 1472 if (!content_type_extension.empty()) 1473 template_filename = fmt::format("cover.{}", content_type_extension); 1474 else if (last_slash != std::string::npos && last_dot != std::string::npos && last_dot > last_slash) 1475 template_filename = Path::GetFileName(filename); 1476 else 1477 template_filename = "cover.jpg"; 1478 1479 std::string write_path(GetNewCoverImagePathForEntry(entry, template_filename.c_str(), use_serial)); 1480 if (write_path.empty()) 1481 return; 1482 1483 if (FileSystem::WriteBinaryFile(write_path.c_str(), data.data(), data.size()) && save_callback) 1484 save_callback(entry, std::move(write_path)); 1485 }); 1486 downloader->WaitForAllRequests(); 1487 progress->IncrementProgressValue(); 1488 } 1489 1490 return true; 1491 } 1492 1493 std::string GameList::GetCustomPropertiesFile() 1494 { 1495 return Path::Combine(EmuFolders::DataRoot, "custom_properties.ini"); 1496 } 1497 1498 void GameList::SaveCustomTitleForPath(const std::string& path, const std::string& custom_title) 1499 { 1500 INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile()); 1501 custom_attributes_ini.Load(); 1502 1503 if (!custom_title.empty()) 1504 { 1505 custom_attributes_ini.SetStringValue(path.c_str(), "Title", custom_title.c_str()); 1506 } 1507 else 1508 { 1509 custom_attributes_ini.DeleteValue(path.c_str(), "Title"); 1510 custom_attributes_ini.RemoveEmptySections(); 1511 } 1512 1513 Error error; 1514 if (!custom_attributes_ini.Save(&error)) 1515 { 1516 ERROR_LOG("Failed to save custom attributes: {}", error.GetDescription()); 1517 return; 1518 } 1519 1520 if (!custom_title.empty()) 1521 { 1522 // Can skip the rescan and just update the value directly. 1523 auto lock = GetLock(); 1524 Entry* entry = GetMutableEntryForPath(path); 1525 if (entry) 1526 { 1527 entry->title = custom_title; 1528 entry->has_custom_title = true; 1529 } 1530 } 1531 else 1532 { 1533 // Let the cache update by rescanning. Only need to do this on deletion, to get the original value. 1534 RescanCustomAttributesForPath(path, custom_attributes_ini); 1535 } 1536 } 1537 1538 void GameList::SaveCustomRegionForPath(const std::string& path, const std::optional<DiscRegion> custom_region) 1539 { 1540 INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile()); 1541 custom_attributes_ini.Load(); 1542 1543 if (custom_region.has_value()) 1544 { 1545 custom_attributes_ini.SetStringValue(path.c_str(), "Region", Settings::GetDiscRegionName(custom_region.value())); 1546 } 1547 else 1548 { 1549 custom_attributes_ini.DeleteValue(path.c_str(), "Region"); 1550 custom_attributes_ini.RemoveEmptySections(); 1551 } 1552 1553 Error error; 1554 if (!custom_attributes_ini.Save(&error)) 1555 { 1556 ERROR_LOG("Failed to save custom attributes: {}", error.GetDescription()); 1557 return; 1558 } 1559 1560 if (custom_region.has_value()) 1561 { 1562 // Can skip the rescan and just update the value directly. 1563 auto lock = GetLock(); 1564 Entry* entry = GetMutableEntryForPath(path); 1565 if (entry) 1566 { 1567 entry->region = custom_region.value(); 1568 entry->has_custom_region = true; 1569 } 1570 } 1571 else 1572 { 1573 // Let the cache update by rescanning. Only need to do this on deletion, to get the original value. 1574 RescanCustomAttributesForPath(path, custom_attributes_ini); 1575 } 1576 } 1577 1578 std::string GameList::GetCustomTitleForPath(const std::string_view path) 1579 { 1580 std::string ret; 1581 1582 std::unique_lock lock(s_mutex); 1583 const GameList::Entry* entry = GetEntryForPath(path); 1584 if (entry && entry->has_custom_title) 1585 ret = entry->title; 1586 1587 return ret; 1588 } 1589 1590 std::optional<DiscRegion> GameList::GetCustomRegionForPath(const std::string_view path) 1591 { 1592 const GameList::Entry* entry = GetEntryForPath(path); 1593 if (entry && entry->has_custom_region) 1594 return entry->region; 1595 else 1596 return std::nullopt; 1597 } 1598 1599 static constexpr const char MEMCARD_TIMESTAMP_CACHE_SIGNATURE[] = {'M', 'C', 'D', 'I', 'C', 'N', '0', '3'}; 1600 1601 FileSystem::ManagedCFilePtr GameList::OpenMemoryCardTimestampCache(bool for_write) 1602 { 1603 const std::string filename = Path::Combine(EmuFolders::Cache, "memcard_icons.cache"); 1604 const FileSystem::FileShareMode share_mode = 1605 for_write ? FileSystem::FileShareMode::DenyReadWrite : FileSystem::FileShareMode::DenyWrite; 1606 #ifdef _WIN32 1607 const char* mode = for_write ? "r+b" : "rb"; 1608 #else 1609 // Always open read/write on Linux, since we need it for flock(). 1610 const char* mode = "r+b"; 1611 #endif 1612 1613 FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedSharedCFile(filename.c_str(), mode, share_mode, nullptr); 1614 if (fp) 1615 return fp; 1616 1617 // Doesn't exist? Create it. 1618 if (errno == ENOENT) 1619 { 1620 if (!for_write) 1621 return nullptr; 1622 1623 mode = "w+b"; 1624 fp = FileSystem::OpenManagedSharedCFile(filename.c_str(), mode, share_mode, nullptr); 1625 if (fp) 1626 return fp; 1627 } 1628 1629 // If there's a sharing violation, try again for 100ms. 1630 if (errno != EACCES) 1631 return nullptr; 1632 1633 Common::Timer timer; 1634 while (timer.GetTimeMilliseconds() <= 100.0f) 1635 { 1636 fp = FileSystem::OpenManagedSharedCFile(filename.c_str(), mode, share_mode, nullptr); 1637 if (fp) 1638 return fp; 1639 1640 if (errno != EACCES) 1641 return nullptr; 1642 } 1643 1644 ERROR_LOG("Timed out while trying to open memory card cache file."); 1645 return nullptr; 1646 } 1647 1648 void GameList::ReloadMemcardTimestampCache() 1649 { 1650 s_memcard_timestamp_cache_entries.clear(); 1651 1652 FileSystem::ManagedCFilePtr fp = OpenMemoryCardTimestampCache(false); 1653 if (!fp) 1654 return; 1655 1656 #ifndef _WIN32 1657 FileSystem::POSIXLock lock(fp.get()); 1658 #endif 1659 1660 const s64 file_size = FileSystem::FSize64(fp.get()); 1661 if (file_size < static_cast<s64>(sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE))) 1662 return; 1663 1664 const size_t count = 1665 (static_cast<size_t>(file_size) - sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)) / sizeof(MemcardTimestampCacheEntry); 1666 if (count <= 0) 1667 return; 1668 1669 char signature[sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)]; 1670 if (std::fread(signature, sizeof(signature), 1, fp.get()) != 1 || 1671 std::memcmp(signature, MEMCARD_TIMESTAMP_CACHE_SIGNATURE, sizeof(signature)) != 0) 1672 { 1673 return; 1674 } 1675 1676 s_memcard_timestamp_cache_entries.resize(static_cast<size_t>(count)); 1677 if (std::fread(s_memcard_timestamp_cache_entries.data(), sizeof(MemcardTimestampCacheEntry), 1678 s_memcard_timestamp_cache_entries.size(), fp.get()) != s_memcard_timestamp_cache_entries.size()) 1679 { 1680 s_memcard_timestamp_cache_entries = {}; 1681 return; 1682 } 1683 1684 // Just in case. 1685 for (MemcardTimestampCacheEntry& entry : s_memcard_timestamp_cache_entries) 1686 entry.serial[sizeof(entry.serial) - 1] = 0; 1687 } 1688 1689 std::string GameList::GetGameIconPath(std::string_view serial, std::string_view path) 1690 { 1691 std::string ret; 1692 1693 if (serial.empty()) 1694 return ret; 1695 1696 // might exist already, or the user used a custom icon 1697 ret = Path::Combine(EmuFolders::GameIcons, TinyString::from_format("{}.png", serial)); 1698 if (FileSystem::FileExists(ret.c_str())) 1699 return ret; 1700 1701 MemoryCardType type; 1702 std::string memcard_path = System::GetGameMemoryCardPath(serial, path, 0, &type); 1703 FILESYSTEM_STAT_DATA memcard_sd; 1704 if (memcard_path.empty() || type == MemoryCardType::Shared || 1705 !FileSystem::StatFile(memcard_path.c_str(), &memcard_sd)) 1706 { 1707 ret = {}; 1708 return ret; 1709 } 1710 1711 const s64 timestamp = memcard_sd.ModificationTime; 1712 TinyString index_serial; 1713 index_serial.assign( 1714 serial.substr(0, std::min<size_t>(serial.length(), MemcardTimestampCacheEntry::MAX_SERIAL_LENGTH - 1))); 1715 1716 MemcardTimestampCacheEntry* serial_entry = nullptr; 1717 for (MemcardTimestampCacheEntry& entry : s_memcard_timestamp_cache_entries) 1718 { 1719 if (StringUtil::EqualNoCase(index_serial, entry.serial)) 1720 { 1721 // user might've deleted the file, so re-extract it if so 1722 // otherwise, card hasn't changed, still no icon 1723 if (entry.memcard_timestamp == timestamp && !entry.icon_was_extracted) 1724 { 1725 ret = {}; 1726 return ret; 1727 } 1728 1729 serial_entry = &entry; 1730 break; 1731 } 1732 } 1733 1734 if (!serial_entry) 1735 { 1736 serial_entry = &s_memcard_timestamp_cache_entries.emplace_back(); 1737 std::memset(serial_entry, 0, sizeof(MemcardTimestampCacheEntry)); 1738 } 1739 1740 serial_entry->memcard_timestamp = timestamp; 1741 serial_entry->icon_was_extracted = false; 1742 StringUtil::Strlcpy(serial_entry->serial, index_serial.view(), sizeof(serial_entry->serial)); 1743 1744 // Try extracting an icon. 1745 Error error; 1746 std::unique_ptr<MemoryCardImage::DataArray> data = std::make_unique<MemoryCardImage::DataArray>(); 1747 if (MemoryCardImage::LoadFromFile(data.get(), memcard_path.c_str(), &error)) 1748 { 1749 std::vector<MemoryCardImage::FileInfo> files = MemoryCardImage::EnumerateFiles(*data.get(), false); 1750 if (!files.empty()) 1751 { 1752 const MemoryCardImage::FileInfo& fi = files.front(); 1753 if (!fi.icon_frames.empty()) 1754 { 1755 INFO_LOG("Extracting memory card icon from {} ({}) to {}", fi.filename, Path::GetFileTitle(memcard_path), 1756 Path::GetFileTitle(ret)); 1757 1758 RGBA8Image image(MemoryCardImage::ICON_WIDTH, MemoryCardImage::ICON_HEIGHT); 1759 std::memcpy(image.GetPixels(), &fi.icon_frames.front().pixels, 1760 MemoryCardImage::ICON_WIDTH * MemoryCardImage::ICON_HEIGHT * sizeof(u32)); 1761 serial_entry->icon_was_extracted = image.SaveToFile(ret.c_str()); 1762 if (!serial_entry->icon_was_extracted) 1763 { 1764 ERROR_LOG("Failed to save memory card icon to {}.", ret); 1765 ret = {}; 1766 return ret; 1767 } 1768 } 1769 } 1770 } 1771 else 1772 { 1773 ERROR_LOG("Failed to load memory card '{}': {}", Path::GetFileName(memcard_path), error.GetDescription()); 1774 } 1775 1776 UpdateMemcardTimestampCache(*serial_entry); 1777 return ret; 1778 } 1779 1780 bool GameList::UpdateMemcardTimestampCache(const MemcardTimestampCacheEntry& entry) 1781 { 1782 FileSystem::ManagedCFilePtr fp = OpenMemoryCardTimestampCache(true); 1783 if (!fp) 1784 return false; 1785 1786 #ifndef _WIN32 1787 FileSystem::POSIXLock lock(fp.get()); 1788 #endif 1789 1790 // check signature, write it if it's non-existent or invalid 1791 char signature[sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE)]; 1792 if (std::fread(signature, sizeof(signature), 1, fp.get()) != 1 || 1793 std::memcmp(signature, MEMCARD_TIMESTAMP_CACHE_SIGNATURE, sizeof(signature)) != 0) 1794 { 1795 if (!FileSystem::FTruncate64(fp.get(), 0) || FileSystem::FSeek64(fp.get(), 0, SEEK_SET) != 0 || 1796 std::fwrite(MEMCARD_TIMESTAMP_CACHE_SIGNATURE, sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE), 1, fp.get()) != 1) 1797 { 1798 return false; 1799 } 1800 } 1801 1802 // need to seek to switch from read->write? 1803 s64 current_pos = sizeof(MEMCARD_TIMESTAMP_CACHE_SIGNATURE); 1804 if (FileSystem::FSeek64(fp.get(), current_pos, SEEK_SET) != 0) 1805 return false; 1806 1807 for (;;) 1808 { 1809 MemcardTimestampCacheEntry existing_entry; 1810 if (std::fread(&existing_entry, sizeof(existing_entry), 1, fp.get()) != 1) 1811 break; 1812 1813 existing_entry.serial[sizeof(existing_entry.serial) - 1] = 0; 1814 if (!StringUtil::EqualNoCase(existing_entry.serial, entry.serial)) 1815 { 1816 current_pos += sizeof(existing_entry); 1817 continue; 1818 } 1819 1820 // found it here, so overwrite 1821 return (FileSystem::FSeek64(fp.get(), current_pos, SEEK_SET) == 0 && 1822 std::fwrite(&entry, sizeof(entry), 1, fp.get()) == 1); 1823 } 1824 1825 if (FileSystem::FSeek64(fp.get(), current_pos, SEEK_SET) != 0) 1826 return false; 1827 1828 // append it. 1829 return (std::fwrite(&entry, sizeof(entry), 1, fp.get()) == 1); 1830 }