Skip to content

Conversation

@dravorle
Copy link
Contributor

@dravorle dravorle commented Feb 1, 2026

Quick Overview

This PR addresses introduces a toggle that tries to keep RomM Status and favorites in sync with playnite and vice versa. It does so by applying data from RomM whenever a library update is performed (on start or pressing F5, ...) and syncing data to RomM by subscribing to the ItemUpdated event from playnite.
This implements #59

Caveats

  • RomM Status is currently a little weird as it is essentially stored in multiple locations. This could be simplified if they would be merged, but works even if not. I created an issue on the main romm repo to ask about this ([Other] Merge "backlogged" and "now playing" into status romm#2971)
  • I created a toggle (defaults to false) in the settings to activate bi-directional syncing for now. While I have tested it, I would like more people trying it and seeing if there are any unforseen problems before activating it by default, if that is even wanted

@gemini-code-assist
Copy link

Summary of Changes

Hello @dravorle, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a significant feature to enable seamless bi-directional synchronization of game favorites and completion statuses between RomM and Playnite. It provides users with a new setting to control this sync, ensuring that game metadata remains consistent across both platforms. The implementation involves new API calls to RomM and event handling within Playnite to capture and apply changes.

Highlights

  • Bi-directional Sync for Favorites: Introduced functionality to synchronize game favorites between RomM and Playnite. Changes made in Playnite (marking a game as favorite or unfavorite) will be reflected in RomM, and vice-versa during library updates.
  • Bi-directional Sync for Completion Status: Implemented synchronization for game completion statuses. Playnite's completion status changes will update RomM, and RomM's status will update Playnite during library updates, mapping various statuses like 'Playing', 'Plan to Play', 'Beaten', etc.
  • New Sync Toggle: Added a new setting, 'Keep RomM Synced', which defaults to false. This toggle allows users to enable or disable the bi-directional synchronization of favorites and completion statuses.
  • Enhanced RomM Data Models: New data models (RomMCollection and RomMRomUser) have been added to accurately represent RomM's collection and user-specific ROM data, enabling more comprehensive data exchange.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a feature to synchronize game status and favorites between RomM and Playnite. The implementation adds a setting to enable this bi-directional sync. Data is synced from RomM during library updates and to RomM via an event handler for game item updates in Playnite.

The changes are well-structured, but there are several areas for improvement regarding correctness, performance, and robustness. My review highlights a critical bug that could cause a crash, potential race conditions leading to data loss, and inefficient code patterns that should be refactored. Addressing these points will significantly improve the quality and reliability of the new synchronization feature.

Favorite = favorites.Exists(f => f == item.Id),
LastActivity = item.RomUser.LastPlayed,
UserScore = item.RomUser.Rating * 10, //RomM-Rating is 1-10, Playnite 1-100, so it can unfortunately only by synced one direction without loosing decimals
CompletionStatus = new MetadataNameProperty(PlayniteApi.Database.CompletionStatuses.FirstOrDefault(cs => cs.Name == completionStatus).Name) ?? null,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This line will throw a NullReferenceException if FirstOrDefault returns null (i.e., no completion status with the given name is found). Accessing .Name on a null reference will crash the import process. This is a critical bug.

You should handle this case gracefully. For example:

var status = PlayniteApi.Database.CompletionStatuses.FirstOrDefault(cs => cs.Name == completionStatus);
var completionStatusProperty = status != null ? new MetadataNameProperty(status.Name) : null;
// ... then in the metadata object initializer:
CompletionStatus = completionStatusProperty,

Comment on lines +149 to +169
internal async void UpdateFavorites(RomMCollection favoriteCollection, List<int> romIds)
{
if (favoriteCollection == null)
{
Logger.Error($"Can't update favorites, collection is null");
return;
}

string apiCollectionUrl = CombineUrl(Settings.RomMHost, "api/collections");
try
{
var formData = new MultipartFormDataContent();
formData.Add(new StringContent(JsonConvert.SerializeObject(romIds)), "rom_ids");
HttpResponseMessage putResponse = HttpClientSingleton.Instance.PutAsync($"{apiCollectionUrl}/{favoriteCollection.Id}", formData).GetAwaiter().GetResult();
putResponse.EnsureSuccessStatusCode();
}
catch (HttpRequestException e)
{
Logger.Error($"Request exception: {e.Message}");
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This method is async void, which should generally be avoided except for event handlers. It makes error handling and awaiting completion impossible for the caller. It should be changed to return async Task.

Additionally, this method and others (FetchFavorites, CreateFavorites, Games_ItemUpdated) use blocking calls like .GetAwaiter().GetResult() on asynchronous HTTP calls. This can lead to deadlocks and should be replaced with await.

Example for this method:

internal async Task UpdateFavoritesAsync(RomMCollection favoriteCollection, List<int> romIds)
{
    if (favoriteCollection == null)
    {
        Logger.Error($"Can't update favorites, collection is null");
        return;
    }

    string apiCollectionUrl = CombineUrl(Settings.RomMHost, "api/collections");
    try
    {
        var formData = new MultipartFormDataContent();
        formData.Add(new StringContent(JsonConvert.SerializeObject(romIds)), "rom_ids");
        HttpResponseMessage putResponse = await HttpClientSingleton.Instance.PutAsync($"{apiCollectionUrl}/{favoriteCollection.Id}", formData);
        putResponse.EnsureSuccessStatusCode();
    }
    catch (HttpRequestException e)
    { 
        Logger.Error($"Request exception: {e.Message}");
        // Consider re-throwing or returning a result to indicate failure
    }
}

You would then need to await the call to this method in Games_ItemUpdated.

Comment on lines +573 to +581
try
{
ignoredGameIds.TryAdd(game.Id, 0);
Playnite.Database.Games.Update(game);
}
finally
{
Task.Delay(500).ContinueWith(_t => ignoredGameIds.TryRemove(game.Id, out _));
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The use of Task.Delay(500) to temporarily ignore game updates introduces a race condition. If a user modifies a game within this 500ms window, that change will be ignored by the Games_ItemUpdated event handler because the game's ID will be in ignoredGameIds. This will prevent the user's change from being synced to RomM, leading to data inconsistency.

The 500ms delay is arbitrary and doesn't guarantee correctness. A more robust, non-time-based mechanism should be used to prevent sync loops while not dropping user-initiated changes. One possible approach is to have the Games_ItemUpdated handler remove the ID from ignoredGameIds if it finds it, rather than using a delayed task.


internal RomMCollection CreateFavorites()
{
string apiCollectionUrl = CombineUrl(Settings.RomMHost, "api/collections?is_favorite=true&is_public=false");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The query parameter here is is_favorite=true (snake_case), while in FetchFavorites (line 110) it is isFavorite=true (camelCase). This inconsistency might indicate a bug. Please verify with the RomM API documentation which format is correct for both endpoints to ensure consistent behavior.

{
backlogged = status == "Plan to Play",
now_playing = status == "Playing",
status = RomMRomUser.CompletionStatusMap.FirstOrDefault((kv) => kv.Value == status && kv.Value != "Playing" && kv.Value != "Plan to Play" && kv.Value != "Not Played").Key

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using FirstOrDefault for a reverse lookup on the CompletionStatusMap is inefficient, especially within an event handler that could be triggered frequently. A more performant and readable approach is to create a pre-computed reverse dictionary.

Consider adding a reverse map to RomMRomUser:

// In RomMRomUser.cs
public static readonly Dictionary<string, string> PlayniteToRomMStatusMap = 
    CompletionStatusMap.ToDictionary(kv => kv.Value, kv => kv.Key);

Then you can simplify this line to a more efficient lookup. The current filtering logic (kv.Value != "Playing" && ...) is complex and seems to exclude valid statuses like "Not Played", which may be a bug. Using a reverse map would simplify this greatly.

if (Settings.KeepRomMSynced == true)
{
game.Favorite = favorites.Exists(f => f == item.Id);
game.CompletionStatusId = Playnite.Database.CompletionStatuses.FirstOrDefault(cs => cs.Name == completionStatus).Id;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling FirstOrDefault on Playnite.Database.CompletionStatuses inside a loop (foreach (var item in allRoms)) is inefficient and can lead to performance degradation when processing a large number of games.

It's better to fetch and map the completion statuses to a dictionary once before the loop for O(1) lookups.

Example:

// Before the loop
var completionStatusMap = Playnite.Database.CompletionStatuses.ToDictionary(cs => cs.Name, cs => cs.Id);

// Inside the loop
if (completionStatusMap.TryGetValue(completionStatus, out var statusId))
{
    game.CompletionStatusId = statusId;
}

@gantoine gantoine self-requested a review February 1, 2026 15:55
@matthew-pye
Copy link
Contributor

matthew-pye commented Feb 1, 2026

Personally I would add some code to GetGames at about line 551 as you are adding a new member to the gameID so that users don't have to reimport there entire library, something like this:

var oldinfo = new RomMGameInfo
{
MappingId = mapping.MappingId,
FileName = item.FileName,
DownloadUrl = CombineUrl(Settings.RomMHost, $"api/roms/{item.Id}/content/{fileName}"),
HasMultipleFiles = item.HasMultipleFiles
};
var oldgameId = info.AsGameId();

// Check if the game is already installed with an oldID
if (Playnite.Database.Games.Any(g => g.GameId == oldgameId))
{
var game = Playnite.Database.Games.Where(g => g.GameId == oldgameId).FirstOrDefault();
game.GameId = gameId;
Playnite.Database.Games.Update(game);
continue;
}

Although the var oldinfo = new RomMGameInfo would have to be changed to something like var oldinfo = new OldRomMGameInfo

@dravorle
Copy link
Contributor Author

dravorle commented Feb 1, 2026

Good Point! I will check the AI responses tomorrow and think about how to better store the id, maybe we don't need to use in the RomMGameInfo. But it would be useful.
If we do that, I agree we need to consider old libraries with the code to update it accordingly, thanks!

@matthew-pye
Copy link
Contributor

No problem, I noticed when I was doing some metadata changes of my own that when I edited the HasMultipleFiles member that it causes duplicates to appear as it has a different ID, the only thing I haven't solved is why updating the ID seems to remove the activity data

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants