From ca571f8a785eaa95d5fc7352d3fe4d8bcadfe056 Mon Sep 17 00:00:00 2001 From: Connor McLaughlin Date: Fri, 21 Oct 2022 21:02:19 +1000 Subject: [PATCH] GameList: Add played time tracker --- src/common/file_system.cpp | 24 ++ src/common/file_system.h | 14 ++ src/duckstation-qt/gamelistmodel.cpp | 38 ++- src/duckstation-qt/gamelistmodel.h | 2 + src/duckstation-qt/gamelistwidget.cpp | 6 +- src/duckstation-qt/mainwindow.cpp | 4 + src/frontend-common/common_host.cpp | 26 ++ src/frontend-common/fullscreen_ui.cpp | 7 +- src/frontend-common/game_list.cpp | 346 +++++++++++++++++++++++--- src/frontend-common/game_list.h | 12 + 10 files changed, 439 insertions(+), 40 deletions(-) diff --git a/src/common/file_system.cpp b/src/common/file_system.cpp index bbbc7946c..817d289be 100644 --- a/src/common/file_system.cpp +++ b/src/common/file_system.cpp @@ -1914,4 +1914,28 @@ bool FileSystem::SetPathCompression(const char* path, bool enable) return false; } +FileSystem::POSIXLock::POSIXLock(int fd) +{ + if (lockf(fd, F_LOCK, 0) == 0) + { + m_fd = fd; + } + else + { + Log_ErrorPrintf("lockf() failed: %d", fd); + m_fd = -1; + } +} + +FileSystem::POSIXLock::POSIXLock(std::FILE* fp) +{ + POSIXLock(fileno(fp)); +} + +FileSystem::POSIXLock::~POSIXLock() +{ + if (m_fd >= 0) + lockf(m_fd, F_ULOCK, m_fd); +} + #endif diff --git a/src/common/file_system.h b/src/common/file_system.h index 2bdc770e8..2d7974623 100644 --- a/src/common/file_system.h +++ b/src/common/file_system.h @@ -108,6 +108,20 @@ enum class FileShareMode ManagedCFilePtr OpenManagedSharedCFile(const char* filename, const char* mode, FileShareMode share_mode); std::FILE* OpenSharedCFile(const char* filename, const char* mode, FileShareMode share_mode); +/// Abstracts a POSIX file lock. +#ifndef _WIN32 +class POSIXLock +{ +public: + POSIXLock(int fd); + POSIXLock(std::FILE* fp); + ~POSIXLock(); + +private: + int m_fd; +}; +#endif + std::optional> ReadBinaryFile(const char* filename); std::optional> ReadBinaryFile(std::FILE* fp); std::optional ReadFileToString(const char* filename); diff --git a/src/duckstation-qt/gamelistmodel.cpp b/src/duckstation-qt/gamelistmodel.cpp index a8db4681f..96b1d11d1 100644 --- a/src/duckstation-qt/gamelistmodel.cpp +++ b/src/duckstation-qt/gamelistmodel.cpp @@ -301,6 +301,17 @@ QVariant GameListModel::data(const QModelIndex& index, int role) const case Column_Size: return QString("%1 MB").arg(static_cast(ge->total_size) / 1048576.0, 0, 'f', 2); + case Column_TimePlayed: + { + if (ge->total_played_time == 0) + return {}; + else + return QtUtils::StringViewToQString(GameList::FormatTimespan(ge->total_played_time)); + } + + case Column_LastPlayed: + return QtUtils::StringViewToQString(GameList::FormatTimestamp(ge->last_played_time)); + case Column_Cover: { if (m_show_titles_for_covers) @@ -352,6 +363,12 @@ QVariant GameListModel::data(const QModelIndex& index, int role) const case Column_Compatibility: return static_cast(ge->compatibility); + case Column_TimePlayed: + return static_cast(ge->total_played_time); + + case Column_LastPlayed: + return static_cast(ge->last_played_time); + case Column_Size: return static_cast(ge->total_size); @@ -534,6 +551,22 @@ bool GameListModel::lessThan(const QModelIndex& left_index, const QModelIndex& r return (left->release_date < right->release_date); } + case Column_TimePlayed: + { + if (left->total_played_time == right->total_played_time) + return titlesLessThan(left_row, right_row); + + return (left->total_played_time < right->total_played_time); + } + + case Column_LastPlayed: + { + if (left->last_played_time == right->last_played_time) + return titlesLessThan(left_row, right_row); + + return (left->last_played_time < right->last_played_time); + } + case Column_Players: { u8 left_players = (left->min_players << 4) + left->max_players; @@ -558,7 +591,8 @@ void GameListModel::loadCommonImages() m_region_pixmaps[i] = QtUtils::GetIconForRegion(static_cast(i)).pixmap(42, 30); for (int i = 0; i < static_cast(GameDatabase::CompatibilityRating::Count); i++) - m_compatibility_pixmaps[i] = QtUtils::GetIconForCompatibility(static_cast(i)).pixmap(96, 24); + m_compatibility_pixmaps[i] = + QtUtils::GetIconForCompatibility(static_cast(i)).pixmap(96, 24); m_placeholder_pixmap.load(QStringLiteral("%1/images/cover-placeholder.png").arg(QtHost::GetResourcesBasePath())); } @@ -574,6 +608,8 @@ void GameListModel::setColumnDisplayNames() m_column_display_names[Column_Genre] = tr("Genre"); m_column_display_names[Column_Year] = tr("Year"); m_column_display_names[Column_Players] = tr("Players"); + m_column_display_names[Column_TimePlayed] = tr("Time Played"); + m_column_display_names[Column_LastPlayed] = tr("Last Played"); m_column_display_names[Column_Size] = tr("Size"); m_column_display_names[Column_Region] = tr("Region"); m_column_display_names[Column_Compatibility] = tr("Compatibility"); diff --git a/src/duckstation-qt/gamelistmodel.h b/src/duckstation-qt/gamelistmodel.h index b7db5eda7..c4fee7cc4 100644 --- a/src/duckstation-qt/gamelistmodel.h +++ b/src/duckstation-qt/gamelistmodel.h @@ -26,6 +26,8 @@ public: Column_Genre, Column_Year, Column_Players, + Column_TimePlayed, + Column_LastPlayed, Column_Size, Column_Region, Column_Compatibility, diff --git a/src/duckstation-qt/gamelistwidget.cpp b/src/duckstation-qt/gamelistwidget.cpp index 5ad3e23a4..f23622043 100644 --- a/src/duckstation-qt/gamelistwidget.cpp +++ b/src/duckstation-qt/gamelistwidget.cpp @@ -457,6 +457,8 @@ void GameListWidget::resizeTableViewColumnsToFit() 200, // genre 50, // year 100, // players + 80, // time played + 80, // last played 80, // size 50, // region 100 // compatibility @@ -483,8 +485,10 @@ void GameListWidget::loadTableViewColumnVisibilitySettings() true, // developer false, // publisher false, // genre - true, // year + false, // year false, // players + true, // time played + true, // last played true, // size true, // region true // compatibility diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp index 0f2450887..8fbc43b76 100644 --- a/src/duckstation-qt/mainwindow.cpp +++ b/src/duckstation-qt/mainwindow.cpp @@ -542,6 +542,10 @@ void MainWindow::onSystemDestroyed() updateEmulationActions(false, false, Achievements::ChallengeModeActive()); switchToGameListView(); + // reload played time + if (m_game_list_widget->isShowingGameList()) + m_game_list_widget->refresh(false); + if (m_cheat_manager_dialog) { delete m_cheat_manager_dialog; diff --git a/src/frontend-common/common_host.cpp b/src/frontend-common/common_host.cpp index a273ac5ff..901902654 100644 --- a/src/frontend-common/common_host.cpp +++ b/src/frontend-common/common_host.cpp @@ -70,6 +70,8 @@ Log_SetChannel(CommonHostInterface); namespace CommonHost { +static void UpdateSessionTime(const std::string& new_serial); + #ifdef WITH_DISCORD_PRESENCE static void SetDiscordPresenceEnabled(bool enabled); static void InitializeDiscordPresence(); @@ -79,6 +81,10 @@ static void PollDiscordPresence(); #endif } // namespace CommonHost +// Used to track play time. We use a monotonic timer here, in case of clock changes. +static u64 s_session_start_time = 0; +static std::string s_session_serial; + #ifdef WITH_DISCORD_PRESENCE // discord rich presence bool m_discord_presence_enabled = false; @@ -278,6 +284,8 @@ void CommonHost::OnGameChanged(const std::string& disc_path, const std::string& UpdateDiscordPresence(false); #endif + UpdateSessionTime(game_serial); + SaveStateSelectorUI::RefreshList(); } @@ -374,6 +382,24 @@ void CommonHost::CheckForSettingsChanges(const Settings& old_settings) } } +void CommonHost::UpdateSessionTime(const std::string& new_serial) +{ + if (s_session_serial == new_serial) + return; + + const u64 ctime = Common::Timer::GetCurrentValue(); + if (!s_session_serial.empty()) + { + // round up to seconds + const std::time_t etime = static_cast(std::round(Common::Timer::ConvertValueToSeconds(ctime - s_session_start_time))); + const std::time_t wtime = std::time(nullptr); + GameList::AddPlayedTimeForSerial(s_session_serial, wtime, etime); + } + + s_session_serial = new_serial; + s_session_start_time = ctime; +} + void Host::SetPadVibrationIntensity(u32 pad_index, float large_or_single_motor_intensity, float small_motor_intensity) { InputManager::SetPadVibrationIntensity(pad_index, large_or_single_motor_intensity, small_motor_intensity); diff --git a/src/frontend-common/fullscreen_ui.cpp b/src/frontend-common/fullscreen_ui.cpp index 7da1e9432..1fc7b8c1d 100644 --- a/src/frontend-common/fullscreen_ui.cpp +++ b/src/frontend-common/fullscreen_ui.cpp @@ -5029,7 +5029,6 @@ void FullscreenUI::DrawGameList(const ImVec2& heading_size) ImGui::PushFont(g_medium_font); // developer - const char* developer = "Unknown Developer"; if (!selected_entry->developer.empty()) { text_width = @@ -5037,7 +5036,7 @@ void FullscreenUI::DrawGameList(const ImVec2& heading_size) selected_entry->developer.c_str() + selected_entry->developer.length(), false, work_width) .x; ImGui::SetCursorPosX((work_width - text_width) / 2.0f); - ImGui::TextWrapped("%s", developer); + ImGui::TextWrapped("%s", selected_entry->developer.c_str()); } // code @@ -5076,6 +5075,10 @@ void FullscreenUI::DrawGameList(const ImVec2& heading_size) } ImGui::Text(" (%s)", GameDatabase::GetCompatibilityRatingDisplayName(selected_entry->compatibility)); + // play time + ImGui::Text("Time Played: %s", GameList::FormatTimespan(selected_entry->total_played_time).GetCharArray()); + ImGui::Text("Last Played: %s", GameList::FormatTimestamp(selected_entry->last_played_time).GetCharArray()); + // size ImGui::Text("Size: %.2f MB", static_cast(selected_entry->total_size) / 1048576.0f); diff --git a/src/frontend-common/game_list.cpp b/src/frontend-common/game_list.cpp index e84217bbc..0ae2a683f 100644 --- a/src/frontend-common/game_list.cpp +++ b/src/frontend-common/game_list.cpp @@ -2,6 +2,7 @@ #include "common/assert.h" #include "common/byte_stream.h" #include "common/file_system.h" +#include "common/heterogeneous_containers.h" #include "common/http_downloader.h" #include "common/log.h" #include "common/make_array.h" @@ -25,14 +26,31 @@ #include Log_SetChannel(GameList); +#ifdef _WIN32 +#include "common/windows_headers.h" +#endif + +namespace GameList { enum : u32 { GAME_LIST_CACHE_SIGNATURE = 0x45434C47, - GAME_LIST_CACHE_VERSION = 32 + GAME_LIST_CACHE_VERSION = 32, + + PLAYED_TIME_SERIAL_LENGTH = 32, + PLAYED_TIME_LAST_TIME_LENGTH = 20, // uint64 + PLAYED_TIME_TOTAL_TIME_LENGTH = 20, // uint64 + PLAYED_TIME_LINE_LENGTH = + PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1 + PLAYED_TIME_TOTAL_TIME_LENGTH, }; -namespace GameList { -using CacheMap = std::unordered_map; +struct PlayedTimeEntry +{ + std::time_t last_played_time; + std::time_t total_played_time; +}; + +using CacheMap = UnorderedStringMap; +using PlayedTimeMap = UnorderedStringMap; static bool GetExeListEntry(const std::string& path, Entry* entry); static bool GetPsfListEntry(const std::string& path, Entry* entry); @@ -40,9 +58,11 @@ static bool GetDiscListEntry(const std::string& path, Entry* entry); static bool GetGameListEntryFromCache(const std::string& path, Entry* entry); static void ScanDirectory(const char* path, bool recursive, bool only_cache, - const std::vector& excluded_paths, ProgressCallback* progress); -static bool AddFileFromCache(const std::string& path, std::time_t timestamp); -static bool ScanFile(std::string path, std::time_t timestamp); + const std::vector& excluded_paths, const PlayedTimeMap& played_time_map, + ProgressCallback* progress); +static bool AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map); +static bool ScanFile(std::string path, std::time_t timestamp, std::unique_lock& lock, + const PlayedTimeMap& played_time_map); static std::string GetCacheFilename(); static void LoadCache(); @@ -51,9 +71,16 @@ static bool OpenCacheForWriting(); static bool WriteEntryToCache(const Entry* entry); static void CloseCacheFileStream(); static void DeleteCacheFile(); + +static std::string GetPlayedTimeFile(); +static bool ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry); +static std::string MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry); +static PlayedTimeMap LoadPlayedTimeMap(const std::string& path); +static PlayedTimeEntry UpdatePlayedTimeFile(const std::string& path, const std::string& serial, std::time_t last_time, + std::time_t add_time); } // namespace GameList -static std::vector m_entries; +static std::vector s_entries; static std::recursive_mutex s_mutex; static GameList::CacheMap m_cache_map; static std::unique_ptr m_cache_write_stream; @@ -423,7 +450,8 @@ static bool IsPathExcluded(const std::vector& excluded_paths, const } void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, - const std::vector& excluded_paths, ProgressCallback* progress) + const std::vector& excluded_paths, const PlayedTimeMap& played_time_map, + ProgressCallback* progress) { Log_InfoPrintf("Scanning %s%s", path, recursive ? " (recursively)" : ""); @@ -452,17 +480,15 @@ void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, continue; } + std::unique_lock lock(s_mutex); + if (GetEntryForPath(ffd.FileName.c_str()) || + AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map) || only_cache) { - std::unique_lock lock(s_mutex); - if (GetEntryForPath(ffd.FileName.c_str()) || AddFileFromCache(ffd.FileName, ffd.ModificationTime) || only_cache) - { - continue; - } + continue; } - // ownership of fp is transferred progress->SetFormattedStatusText("Scanning '%s'...", FileSystem::GetDisplayNameFromPath(ffd.FileName).c_str()); - ScanFile(std::move(ffd.FileName), ffd.ModificationTime); + ScanFile(std::move(ffd.FileName), ffd.ModificationTime, lock, played_time_map); progress->SetProgressValue(files_scanned); } @@ -470,24 +496,29 @@ void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, progress->PopState(); } -bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp) +bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map) { - if (std::any_of(m_entries.begin(), m_entries.end(), [&path](const Entry& other) { return other.path == path; })) - { - // already exists - return true; - } - Entry entry; if (!GetGameListEntryFromCache(path, &entry) || entry.last_modified_time != timestamp) return false; - m_entries.push_back(std::move(entry)); + auto iter = UnorderedStringMapFind(played_time_map, entry.serial); + if (iter != played_time_map.end()) + { + entry.last_played_time = iter->second.last_played_time; + entry.total_played_time = iter->second.total_played_time; + } + + s_entries.push_back(std::move(entry)); return true; } -bool GameList::ScanFile(std::string path, std::time_t timestamp) +bool GameList::ScanFile(std::string path, std::time_t timestamp, std::unique_lock& lock, + const PlayedTimeMap& played_time_map) { + // don't block UI while scanning + lock.unlock(); + Log_DevPrintf("Scanning '%s'...", path.c_str()); Entry entry; @@ -503,8 +534,15 @@ bool GameList::ScanFile(std::string path, std::time_t timestamp) Log_WarningPrintf("Failed to write entry '%s' to cache", entry.path.c_str()); } - std::unique_lock lock(s_mutex); - m_entries.push_back(std::move(entry)); + auto iter = UnorderedStringMapFind(played_time_map, entry.serial); + if (iter != played_time_map.end()) + { + entry.last_played_time = iter->second.last_played_time; + entry.total_played_time = iter->second.total_played_time; + } + + lock.lock(); + s_entries.push_back(std::move(entry)); return true; } @@ -515,13 +553,13 @@ std::unique_lock GameList::GetLock() const GameList::Entry* GameList::GetEntryByIndex(u32 index) { - return (index < m_entries.size()) ? &m_entries[index] : nullptr; + return (index < s_entries.size()) ? &s_entries[index] : nullptr; } const GameList::Entry* GameList::GetEntryForPath(const char* path) { const size_t path_length = std::strlen(path); - for (const Entry& entry : m_entries) + for (const Entry& entry : s_entries) { if (entry.path.size() == path_length && StringUtil::Strcasecmp(entry.path.c_str(), path) == 0) return &entry; @@ -532,7 +570,7 @@ const GameList::Entry* GameList::GetEntryForPath(const char* path) const GameList::Entry* GameList::GetEntryBySerial(const std::string_view& serial) { - for (const Entry& entry : m_entries) + for (const Entry& entry : s_entries) { if (entry.serial.length() == serial.length() && StringUtil::Strncasecmp(entry.serial.c_str(), serial.data(), serial.length()) == 0) @@ -546,7 +584,7 @@ const GameList::Entry* GameList::GetEntryBySerial(const std::string_view& serial u32 GameList::GetEntryCount() { - return static_cast(m_entries.size()); + return static_cast(s_entries.size()); } void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* progress /* = nullptr */) @@ -565,12 +603,13 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* std::vector old_entries; { std::unique_lock lock(s_mutex); - old_entries.swap(m_entries); + old_entries.swap(s_entries); } const std::vector excluded_paths(Host::GetStringListSetting("GameList", "ExcludedPaths")); const std::vector dirs(Host::GetStringListSetting("GameList", "Paths")); const std::vector recursive_dirs(Host::GetStringListSetting("GameList", "RecursivePaths")); + const PlayedTimeMap played_time(LoadPlayedTimeMap(GetPlayedTimeFile())); if (!dirs.empty() || !recursive_dirs.empty()) { @@ -584,7 +623,7 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* if (progress->IsCancelled()) break; - ScanDirectory(dir.c_str(), false, only_cache, excluded_paths, progress); + ScanDirectory(dir.c_str(), false, only_cache, excluded_paths, played_time, progress); progress->SetProgressValue(++directory_counter); } for (const std::string& dir : recursive_dirs) @@ -592,7 +631,7 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* if (progress->IsCancelled()) break; - ScanDirectory(dir.c_str(), true, only_cache, excluded_paths, progress); + ScanDirectory(dir.c_str(), true, only_cache, excluded_paths, played_time, progress); progress->SetProgressValue(++directory_counter); } } @@ -690,6 +729,241 @@ size_t GameList::Entry::GetReleaseDateString(char* buffer, size_t buffer_size) c return std::strftime(buffer, buffer_size, "%d %B %Y", &date_tm); } +std::string GameList::GetPlayedTimeFile() +{ + return Path::Combine(EmuFolders::DataRoot, "playtime.dat"); +} + +bool GameList::ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry) +{ + size_t len = std::strlen(line); + if (len != (PLAYED_TIME_LINE_LENGTH + 1)) // \n + { + Log_WarningPrintf("Malformed line: '%s'", line); + return false; + } + + const std::string_view serial_tok(StringUtil::StripWhitespace(std::string_view(line, PLAYED_TIME_SERIAL_LENGTH))); + const std::string_view total_played_time_tok( + StringUtil::StripWhitespace(std::string_view(line + PLAYED_TIME_SERIAL_LENGTH + 1, PLAYED_TIME_LAST_TIME_LENGTH))); + const std::string_view last_played_time_tok(StringUtil::StripWhitespace(std::string_view( + line + PLAYED_TIME_SERIAL_LENGTH + 1 + PLAYED_TIME_LAST_TIME_LENGTH + 1, PLAYED_TIME_TOTAL_TIME_LENGTH))); + + const std::optional total_played_time(StringUtil::FromChars(total_played_time_tok)); + const std::optional last_played_time(StringUtil::FromChars(last_played_time_tok)); + if (serial_tok.empty() || !last_played_time.has_value() || !total_played_time.has_value()) + { + Log_WarningPrintf("Malformed line: '%s'", line); + return false; + } + + serial = serial_tok; + entry.last_played_time = static_cast(last_played_time.value()); + entry.total_played_time = static_cast(total_played_time.value()); + return true; +} + +std::string GameList::MakePlayedTimeLine(const std::string& serial, const PlayedTimeEntry& entry) +{ + return fmt::format("{:<{}} {:<{}} {:<{}}\n", serial, static_cast(PLAYED_TIME_SERIAL_LENGTH), + entry.total_played_time, static_cast(PLAYED_TIME_TOTAL_TIME_LENGTH), + entry.last_played_time, static_cast(PLAYED_TIME_LAST_TIME_LENGTH)); +} + +GameList::PlayedTimeMap GameList::LoadPlayedTimeMap(const std::string& path) +{ + PlayedTimeMap ret; + + auto fp = FileSystem::OpenManagedCFile(path.c_str(), "rb"); + +#ifdef _WIN32 + // On Windows, the file is implicitly locked. + while (!fp && GetLastError() == ERROR_SHARING_VIOLATION) + { + Sleep(10); + fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b"); + } +#endif + + if (fp) + { +#ifndef _WIN32 + FileSystem::POSIXLock flock(fp.get()); +#endif + + char line[256]; + while (std::fgets(line, sizeof(line), fp.get())) + { + std::string serial; + PlayedTimeEntry entry; + if (!ParsePlayedTimeLine(line, serial, entry)) + continue; + + if (UnorderedStringMapFind(ret, serial) != ret.end()) + { + Log_WarningPrintf("Duplicate entry: '%s'", serial.c_str()); + continue; + } + + ret.emplace(std::move(serial), entry); + } + } + + return ret; +} + +GameList::PlayedTimeEntry GameList::UpdatePlayedTimeFile(const std::string& path, const std::string& serial, + std::time_t last_time, std::time_t add_time) +{ + const PlayedTimeEntry new_entry{last_time, add_time}; + + auto fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b"); + +#ifdef _WIN32 + // On Windows, the file is implicitly locked. + while (!fp && GetLastError() == ERROR_SHARING_VIOLATION) + { + Sleep(10); + fp = FileSystem::OpenManagedCFile(path.c_str(), "r+b"); + } +#endif + + // Doesn't exist? Create it. + if (!fp && errno == ENOENT) + fp = FileSystem::OpenManagedCFile(path.c_str(), "w+b"); + + if (!fp) + { + Log_ErrorPrintf("Failed to open '%s' for update.", path.c_str()); + return new_entry; + } + +#ifndef _WIN32 + FileSystem::POSIXLock flock(fp.get()); +#endif + + for (;;) + { + char line[256]; + const s64 line_pos = FileSystem::FTell64(fp.get()); + if (!std::fgets(line, sizeof(line), fp.get())) + break; + + std::string line_serial; + PlayedTimeEntry line_entry; + if (!ParsePlayedTimeLine(line, line_serial, line_entry)) + continue; + + if (line_serial != serial) + continue; + + // found it! + line_entry.last_played_time = last_time; + line_entry.total_played_time += add_time; + + std::string new_line(MakePlayedTimeLine(serial, line_entry)); + if (FileSystem::FSeek64(fp.get(), line_pos, SEEK_SET) != 0 || + std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1) + { + Log_ErrorPrintf("Failed to update '%s'.", path.c_str()); + } + + return line_entry; + } + + // new entry. + std::string new_line(MakePlayedTimeLine(serial, new_entry)); + if (FileSystem::FSeek64(fp.get(), 0, SEEK_END) != 0 || + std::fwrite(new_line.data(), new_line.length(), 1, fp.get()) != 1) + { + Log_ErrorPrintf("Failed to write '%s'.", path.c_str()); + } + + return new_entry; +} + +void GameList::AddPlayedTimeForSerial(const std::string& serial, std::time_t last_time, std::time_t add_time) +{ + if (serial.empty()) + return; + + const PlayedTimeEntry pt(UpdatePlayedTimeFile(GetPlayedTimeFile(), serial, last_time, add_time)); + Log_VerbosePrintf("Add %u seconds play time to %s -> now %u", static_cast(add_time), serial.c_str(), + static_cast(pt.total_played_time)); + + std::unique_lock lock(s_mutex); + for (GameList::Entry& entry : s_entries) + { + if (entry.serial != serial) + continue; + + entry.last_played_time = pt.last_played_time; + entry.total_played_time = pt.total_played_time; + } +} + +TinyString GameList::FormatTimestamp(std::time_t timestamp) +{ + TinyString ret; + + if (timestamp == 0) + { + ret = Host::TranslateString("GameList", "Never"); + } + else + { + struct tm ctime = {}; + struct tm ttime = {}; + const std::time_t ctimestamp = std::time(nullptr); +#ifdef _MSC_VER + localtime_s(&ctime, &ctimestamp); + localtime_s(&ttime, ×tamp); +#else + localtime_r(&ctimestamp, &ctime); + localtime_r(×tamp, &ttime); +#endif + + if (ctime.tm_year == ttime.tm_year && ctime.tm_yday == ttime.tm_yday) + { + ret = Host::TranslateString("GameList", "Today"); + } + else if ((ctime.tm_year == ttime.tm_year && ctime.tm_yday == (ttime.tm_yday + 1)) || + (ctime.tm_yday == 0 && (ctime.tm_year - 1) == ttime.tm_year)) + { + ret = Host::TranslateString("GameList", "Yesterday"); + } + else + { + char buf[128]; + std::strftime(buf, std::size(buf), "%x", &ttime); + ret.Assign(buf); + } + } + + return ret; +} + +TinyString GameList::FormatTimespan(std::time_t timespan) +{ + const u32 hours = static_cast(timespan / 3600); + const u32 minutes = static_cast((timespan % 3600) / 60); + const u32 seconds = static_cast((timespan % 3600) % 60); + + TinyString ret; + if (hours >= 100) + ret.Fmt(Host::TranslateString("GameList", "{}h {}m").GetCharArray(), hours, minutes); + else if (hours > 0) + ret.Fmt(Host::TranslateString("GameList", "{}h {}m {}s").GetCharArray(), hours, minutes, seconds); + else if (minutes > 0) + ret.Fmt(Host::TranslateString("GameList", "{}m {}s").GetCharArray(), minutes, seconds); + else if (seconds > 0) + ret.Fmt(Host::TranslateString("GameList", "{}s").GetCharArray(), seconds); + else + ret = Host::TranslateString("GameList", "None"); + + return ret; +} + bool GameList::DownloadCovers(const std::vector& url_templates, bool use_serial, ProgressCallback* progress, std::function save_callback) { @@ -717,7 +991,7 @@ bool GameList::DownloadCovers(const std::vector& url_templates, boo std::vector> download_urls; { std::unique_lock lock(s_mutex); - for (const GameList::Entry& entry : m_entries) + for (const GameList::Entry& entry : s_entries) { const std::string existing_path(GetCoverImagePathForEntry(&entry)); if (!existing_path.empty()) @@ -778,8 +1052,8 @@ bool GameList::DownloadCovers(const std::vector& url_templates, boo // we could actually do a few in parallel here... std::string filename(Common::HTTPDownloader::URLDecode(url)); downloader->CreateRequest( - std::move(url), [use_serial, &save_callback, entry_path = std::move(entry_path), - filename = std::move(filename)](s32 status_code, std::string content_type, Common::HTTPDownloader::Request::Data data) { + std::move(url), [use_serial, &save_callback, entry_path = std::move(entry_path), filename = std::move(filename)]( + s32 status_code, std::string content_type, Common::HTTPDownloader::Request::Data data) { if (status_code != Common::HTTPDownloader::HTTP_OK || data.empty()) return; diff --git a/src/frontend-common/game_list.h b/src/frontend-common/game_list.h index b0d6edc25..e73e48c2e 100644 --- a/src/frontend-common/game_list.h +++ b/src/frontend-common/game_list.h @@ -1,4 +1,5 @@ #pragma once +#include "common/string.h" #include "core/game_database.h" #include "core/types.h" #include "util/cd_image.h" @@ -35,6 +36,8 @@ struct Entry std::string developer; u64 total_size = 0; std::time_t last_modified_time = 0; + std::time_t last_played_time = 0; + std::time_t total_played_time = 0; u64 release_date = 0; u32 supported_controllers = ~static_cast(0); @@ -73,6 +76,15 @@ bool IsGameListLoaded(); /// If only_cache is set, no new files will be scanned, only those present in the cache. void Refresh(bool invalidate_cache, bool only_cache = false, ProgressCallback* progress = nullptr); +/// Add played time for the specified serial. +void AddPlayedTimeForSerial(const std::string& serial, std::time_t last_time, std::time_t add_time); + +/// Formats a timestamp to something human readable (e.g. Today, Yesterday, 10/11/12). +TinyString FormatTimestamp(std::time_t timestamp); + +/// Formats a timespan to something human readable (e.g. 1h2m3s). +TinyString FormatTimespan(std::time_t timespan); + std::string GetCoverImagePathForEntry(const Entry* entry); std::string GetCoverImagePath(const std::string& path, const std::string& serial, const std::string& title); std::string GetNewCoverImagePathForEntry(const Entry* entry, const char* new_filename, bool use_serial);