├── icon.ico ├── dependencies.bat ├── Config file └── example.config.yml ├── LICENSE ├── README.md └── syncstory.py /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Verssgn/Sync-Story/HEAD/icon.ico -------------------------------------------------------------------------------- /dependencies.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | pip install pyyaml 4 | pip install configparser 5 | pip install psutil -------------------------------------------------------------------------------- /Config file/example.config.yml: -------------------------------------------------------------------------------- 1 | settings: # If you want to use this file rename the example.config.yml to config.uml 2 | Playnite location: # location of your playnite folder 3 | Playnite exe: # location of the exe that starts playnite usually inside the playnite folder 4 | Restart after sync: false # 5 | libraries: 6 | GOLDBERG (Original) [Shipped]: 7 | status: true # You can disable this library if you want to 8 | path: APPDATA\Goldberg SteamEmu Saves # Edit this path if you are using fork 9 | folder data type: steam_id 10 | data file: achievements.json 11 | name type: apiname 12 | time type: unix timestamp 13 | ignore time: 0 14 | time line: 'earned_time' 15 | RUNE [Shipped]: 16 | status: true # You can disable this library if you want to 17 | path: C:\Users\Public\Documents\Steam\RUNE 18 | folder data type: steam_id 19 | data file: achievements.ini 20 | name type: apiname 21 | time type: unix timestamp 22 | ignore time: 0 23 | time line: 'UnlockTime' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Verssgn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest)](https://img.shields.io/github/v/release/Verssgn/Sync-Story?cacheSeconds=5000&logo=github)](https://github.com/Verssgn/Sync-Story/releases/latest) 2 | [![Downloads](https://img.shields.io/github/downloads/Verssgn/Sync-Story/total.svg)]() 3 | 4 | > [!TIP] 5 | > # HOW TO GET ACHIEVEMENTS ON NON-STEAM GAMES 6 | > The script is now archived, it should still work, but there is a better way. 7 | > Please check out **eFMann's fork [(LINK)](https://github.com/eFMann/playnite-successstory-plugin)** of a success story that has integrated support for many emulators (Right-click an entry -> Force SteamAppID) 8 | 9 | > 10 | > # Background 11 | >Thanks to the user u/Korieb98 for letting me know of the fork. When I started working on it the goal was to bring achievements from Goldberg to a success story, while the script was/is not great as I am not a programmer. I always said once there is a better solution I would retire it. I still hope that it was useful. If the fork does not work for you can still use this script. Just know there might be issues. 12 | 13 | --- 14 | # SYNC STORY (ARCHIVED) 15 | 16 | [Success story](https://github.com/Lacro59/playnite-successstory-plugin) is a plugin for playnite that allows you to use achievements. 17 | This is a simple script that fetches achievement data from a preconfigured library and syncs it to the Success Story extension. 18 | 19 | You can get the script in the [github releases](https://github.com/Verssgn/Sync-Story/releases). 20 | 21 | Make sure you check out the [setup tutorial](https://github.com/Verssgn/Sync-Story/wiki/Setup). 22 | 23 | > [!NOTE] 24 | > ## Important 25 | > Success Story actually supports emu games, there is no documentation on the Git Hub, but if you go to the success story extension data folder and go to the config.json you can Enable local achievements. I was not able to get this to work (The reason why this repo is not archived). 26 | > 27 | > In theory, using this method would be way more consistent, from the code it seems like it can scan Goldberg and other emus but from what I've seen all the people who were able to get it to work added games manually, meaning it would require you to add all games to the config manually. The advantage of my script is that it does all of it automatically, but at the same time, it is also pretty scuffed. 28 | 29 | > [!TIP] 30 | > If the script is not loading a specific game, check if the game's json file in Playnite\ExtensionsData\cebe6d32-8c46-4459-b993-5a5189d60788\SuccessStory\ has "IsManual": true 31 | 32 | ## Maintaining and troubleshooting 33 | Originally, I made this script for myself, but since there haven’t been any other scripts that function like this, I decided to share it. I am pointing this out because I am not a programmer, a lot of the script is written badly to the point I had to use AI at parts to fix some of it. There are many ways this script could be improved, and I wholeheartedly support anyone making forks and changes to this script. You can always ask for help in the thread. 34 | 35 | ## Quarks and customization 36 | You can setup your own library that works by importing data from diffferent emulators. 37 | 38 | The issue is that the way the script currently gets data is very rigid, for example emulators that save data in their game folder will not work, the script also uses a very specific way to parse data so if the emulator uses prefixes or suffixes for the achievement names it will not work. 39 | 40 | The biggest issue so far is the fact that the script doesn't have conflict managment meaning if you have 2 same games in different emulators they will currently overwrite each other. 41 | 42 | --- 43 | ### Current support (When configured): 44 | | Emualtor | Supported | 45 | | ------------- | ------------- | 46 | | Goldberg Emulator [Original] (tested - Ships with script) | ✅ | 47 | | RUNE (tested - Ships with script) | ✅ | 48 | | Goldberg forks (should work but you will need to change the path) | 🟧 | 49 | | Scripts that use a central folder should work (Skidrow, Empress) | 🟧 | 50 | | Scripts that use game folders for storing data or use different layout for storing achievements | 🟥 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /syncstory.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import yaml 4 | import configparser 5 | import logging 6 | from datetime import datetime, timezone 7 | import psutil 8 | import subprocess 9 | import time 10 | 11 | logging.basicConfig(filename='sync_story.log', level=logging.INFO, format='%(asctime)s %(message)s') 12 | logger = logging.getLogger() 13 | 14 | def log_and_print(message): 15 | print(message) 16 | logger.info(message) 17 | 18 | def find_folders(library_path): 19 | return [f for f in os.listdir(library_path) if os.path.isdir(os.path.join(library_path, f))] 20 | 21 | def data_file_exists(folder_path, data_file): 22 | return find_data_file(folder_path, data_file) is not None 23 | 24 | def convert_timestamp(timestamp, time_type): 25 | if time_type == "unix timestamp": 26 | return datetime.fromtimestamp(int(timestamp), timezone.utc).strftime('%Y-%m-%dT%H:%M:%S') 27 | else: 28 | format_parts = [] 29 | format_string = "" 30 | for part in time_type.split(): 31 | if part in ["yyyy", "mm", "dd", "hh", "nn", "ss"]: 32 | format_parts.append(part) 33 | format_string += "%Y" if part == "yyyy" else "%m" if part == "mm" else "%d" if part == "dd" else "%H" if part == "hh" else "%M" if part == "nn" else "%S" 34 | else: 35 | format_string += part 36 | 37 | try: 38 | dt = datetime.strptime(timestamp, format_string) 39 | return dt.strftime('%Y-%m-%dT%H:%M:%S') 40 | except ValueError: 41 | log_and_print(f"ERROR: parsing timestamp: {timestamp} with format: {format_string}") 42 | return None 43 | 44 | def find_data_file(folder_path, data_file): 45 | for root, dirs, files in os.walk(folder_path): 46 | if data_file in files: 47 | return os.path.join(root, data_file) 48 | return None 49 | 50 | def find_matching_achievements(game_data, data_file_path, name_type, time_type, ignore_time, time_line): 51 | matching_achievements = [] 52 | skipped_achievements = [] 53 | 54 | if data_file_path.endswith('.json'): 55 | try: 56 | with open(data_file_path, 'r') as file: 57 | data_file_content = json.load(file) 58 | except FileNotFoundError: 59 | log_and_print(f"ERROR: Data file not found: {data_file_path}") 60 | return matching_achievements, skipped_achievements 61 | except json.JSONDecodeError: 62 | log_and_print(f"ERROR: While decoding file in data file: {data_file_path}") 63 | return matching_achievements, skipped_achievements 64 | 65 | def find_time_value(data, time_line): 66 | if isinstance(data, dict): 67 | if time_line in data: 68 | return data[time_line] 69 | for key, value in data.items(): 70 | result = find_time_value(value, time_line) 71 | if result is not None: 72 | return result 73 | return None 74 | 75 | for item in game_data.get('Items', []): 76 | search_key = item.get('Name' if name_type == 'name' else 'ApiName') 77 | if search_key in data_file_content: 78 | achievement_data = data_file_content[search_key] 79 | if isinstance(achievement_data, dict): 80 | original_time = find_time_value(achievement_data, time_line) 81 | if original_time is not None: 82 | if str(original_time) == str(ignore_time): 83 | matching_achievements.append({ 84 | 'name': search_key, 85 | 'original_time': original_time, 86 | 'converted_time': '0001-01-01T00:00:00' 87 | }) 88 | else: 89 | converted_time = convert_timestamp(original_time, time_type) 90 | if converted_time: 91 | matching_achievements.append({ 92 | 'name': search_key, 93 | 'original_time': original_time, 94 | 'converted_time': converted_time 95 | }) 96 | 97 | elif data_file_path.endswith('.ini'): 98 | config = configparser.ConfigParser() 99 | config.read(data_file_path) 100 | 101 | for item in game_data.get('Items', []): 102 | search_key = item.get('Name' if name_type == 'name' else 'ApiName') 103 | if search_key in config.sections(): 104 | if config.has_option(search_key, time_line): 105 | original_time = config.get(search_key, time_line) 106 | if str(original_time) == str(ignore_time): 107 | matching_achievements.append({ 108 | 'name': search_key, 109 | 'original_time': original_time, 110 | 'converted_time': '0001-01-01T00:00:00' 111 | }) 112 | else: 113 | converted_time = convert_timestamp(original_time, time_type) 114 | if converted_time: 115 | matching_achievements.append({ 116 | 'name': search_key, 117 | 'original_time': original_time, 118 | 'converted_time': converted_time 119 | }) 120 | 121 | return matching_achievements, skipped_achievements 122 | 123 | 124 | def update_game_json(json_path, matching_achievements): 125 | try: 126 | with open(json_path, 'r') as file: 127 | game_data = json.load(file) 128 | 129 | items_updated = 0 130 | for item in game_data.get('Items', []): 131 | for achievement in matching_achievements: 132 | if item['ApiName'] == achievement['name']: 133 | item['DateUnlocked'] = achievement['converted_time'] 134 | items_updated += 1 135 | 136 | with open(json_path, 'w') as file: 137 | json.dump(game_data, file, indent=4) 138 | 139 | return items_updated 140 | except Exception as e: 141 | log_and_print(f"ERROR: updating game JSON: {str(e)}") 142 | return 0 143 | 144 | config_path = os.path.join('Config file', 'config.yml') 145 | 146 | def expand_path(path): 147 | if path.upper().startswith('APPDATA'): 148 | return path.replace('APPDATA', os.environ['APPDATA'], 1) 149 | elif path.upper().startswith('LOCALAPPDATA'): 150 | return path.replace('LOCALAPPDATA', os.environ['LOCALAPPDATA'], 1) 151 | elif path.upper().startswith('PUBLIC'): 152 | return path.replace('PUBLIC', os.environ['PUBLIC'], 1) 153 | return path 154 | 155 | config_path = os.path.join('Config file', 'config.yml') 156 | 157 | try: 158 | with open(config_path, 'r') as file: 159 | config = yaml.safe_load(file) 160 | 161 | if not config or 'settings' not in config: 162 | raise KeyError('The settings section is missing in the configuration.') 163 | 164 | settings = config['settings'] 165 | playnite_path = expand_path(settings.get('Playnite location', '')) 166 | playnite_exe = settings.get('Playnite exe', '') 167 | restart_after_sync = settings.get('Restart after sync', False) 168 | 169 | if not playnite_path: 170 | raise KeyError('The Playnite location is missing/not set up properly.') 171 | 172 | libraries = config.get('libraries', {}) 173 | 174 | logger.info("SCRIPT INITIATED") 175 | print("Sync Story") 176 | print("- Version: 2.0") 177 | print(f"- Running: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") 178 | logger.info("Sync Story - Version 2.2") 179 | 180 | log_and_print(f"- Playnite folder: {playnite_path}") 181 | log_and_print(f"- Playnite executable: {playnite_exe}") 182 | log_and_print(f"- Restart after sync: {restart_after_sync}") 183 | 184 | success_story_path = os.path.join(playnite_path, 'ExtensionsData', 'cebe6d32-8c46-4459-b993-5a5189d60788', 'SuccessStory') 185 | 186 | json_files = [f for f in os.listdir(success_story_path) if f.endswith('.json')] 187 | json_file_count = len(json_files) 188 | 189 | if json_file_count == 0: 190 | log_and_print("ERROR: no json files in success story") 191 | exit() 192 | 193 | folder_associations = [] 194 | 195 | for json_file in json_files: 196 | json_path = os.path.join(success_story_path, json_file) 197 | with open(json_path, 'r') as file: 198 | data = json.load(file) 199 | 200 | if data.get('IsManual', False): 201 | game_name = data.get('SourcesLink', {}).get('GameName', 'Unknown') 202 | steam_id = data.get('SourcesLink', {}).get('Url', '').split('/')[-2] 203 | items = data.get('Items', []) 204 | number_of_achievements = len(items) 205 | 206 | unlocked_count = sum(1 for item in items if item.get('DateUnlocked', '0001-01-01T00:00:00') != '0001-01-01T00:00:00') 207 | locked_count = number_of_achievements - unlocked_count 208 | 209 | for library_name, library_details in libraries.items(): 210 | if 'path' in library_details: 211 | library_details['path'] = expand_path(library_details['path']) 212 | library_path = library_details.get('path', '') 213 | folder_data_type = library_details.get('folder data type', 'steam_id') 214 | data_file = library_details.get('data file', 'achievement.json') 215 | name_type = library_details.get('name type', 'name') 216 | time_type = library_details.get('time type', 'unix timestamp') 217 | ignore_time = library_details.get('ignore time', None) 218 | time_line = library_details.get('time line', 'earned_time') 219 | 220 | if not os.path.isdir(library_path): 221 | log_and_print(f"Error: Library path not found: {library_path}") 222 | continue 223 | 224 | folder_names = find_folders(library_path) 225 | 226 | folder_name = steam_id if folder_data_type == 'steam_id' else game_name 227 | folder_found = folder_name in folder_names 228 | folder_path = os.path.join(library_path, folder_name) if folder_found else None 229 | data_file_exists_flag = data_file_exists(folder_path, data_file) if folder_found else False 230 | 231 | matching_achievements = [] 232 | skipped_achievements = [] 233 | if data_file_exists_flag: 234 | data_file_path = find_data_file(folder_path, data_file) 235 | if data_file_path is None: 236 | log_and_print(f"ERROR: Data file '{data_file}' not found in {folder_path}") 237 | continue 238 | matching_achievements, skipped_achievements = find_matching_achievements(data, data_file_path, name_type, time_type, ignore_time, time_line) 239 | 240 | items_updated = update_game_json(json_path, matching_achievements) 241 | 242 | folder_associations.append({ 243 | 'library': library_name, 244 | 'number_of_achievements': number_of_achievements, 245 | 'unlocked_count': unlocked_count, 246 | 'locked_count': locked_count, 247 | 'json_file': json_file, 248 | 'folder': folder_name, 249 | 'game_name': game_name, 250 | 'steam_id': steam_id, 251 | 'folder_found': folder_found, 252 | 'data_file_exists': data_file_exists_flag, 253 | 'matching_achievements': matching_achievements, 254 | 'skipped_achievements': skipped_achievements, 255 | 'time_type': time_type 256 | }) 257 | 258 | print("\nLibraries:") 259 | for library_name, library_details in libraries.items(): 260 | if library_details.get('status', False): 261 | library_path = library_details.get('path', 'Not specified') 262 | time_type = library_details.get('time type', 'Not specified') 263 | log_and_print(f"- Name: {library_name} (Time format: {time_type})") 264 | log_and_print(f"- Path: {library_path} \n") 265 | 266 | print("\nSync setup:") 267 | if folder_associations: 268 | for association in folder_associations: 269 | print(f"{association['game_name']}") 270 | print(f" - Success story id: {association['json_file']}") 271 | print(f" - Library: {association['library']}") 272 | print(f" - Folder: {association['folder']}") 273 | print(f" - Steam ID: {association['steam_id']}") 274 | print(f" - Folder Found: {association['folder_found']}") 275 | print(f" - Data File Exists: {association['data_file_exists']}") 276 | 277 | if association['data_file_exists']: 278 | if association['matching_achievements']: 279 | print(" - Syncing achievements:") 280 | for achievement in association['matching_achievements']: 281 | print(f" - {achievement['name']}: {achievement['original_time']} -> {achievement['converted_time']}") 282 | else: 283 | print(" - No matching achievements found") 284 | 285 | ignored_achievements = [ach for ach in association['matching_achievements'] if ach['converted_time'] == '0001-01-01T00:00:00'] 286 | 287 | else: 288 | print(" - No achievements or times (data file not found or empty)") 289 | 290 | print() 291 | 292 | logger.info(f"Syncing {association['game_name']}\n Library: {association['library']},\n Folder: {association['folder']},\n Steam ID: {association['steam_id']},\n Folder Found: {association['folder_found']},\n Data File Exists: {association['data_file_exists']},\n Synced Achievements: {association['matching_achievements']},\n Skipped Achievements: {association['skipped_achievements']}") 293 | else: 294 | log_and_print("ERROR: No matching folders found for any JSON files.") 295 | 296 | if restart_after_sync: 297 | log_and_print("\nAttempting to restart Playnite...") 298 | 299 | def is_process_running(process_name): 300 | return process_name.lower() in (p.name().lower() for p in psutil.process_iter(['name'])) 301 | 302 | def kill_process(process_name): 303 | for proc in psutil.process_iter(['name']): 304 | if proc.name().lower() == process_name.lower(): 305 | proc.kill() 306 | log_and_print(f"Killed process: {process_name}") 307 | 308 | if is_process_running("Playnite.DesktopApp.exe"): 309 | kill_process("Playnite.DesktopApp.exe") 310 | if is_process_running("Playnite.FullscreenApp.exe"): 311 | kill_process("Playnite.FullscreenApp.exe") 312 | 313 | time.sleep(2) 314 | 315 | if playnite_exe: 316 | full_path = os.path.join(playnite_path, playnite_exe) 317 | full_path = expand_path(full_path) 318 | if os.path.exists(full_path): 319 | subprocess.Popen(full_path) 320 | log_and_print(f"Started Playnite: {full_path}") 321 | else: 322 | log_and_print(f"ERROR: Playnite executable not found at {full_path}") 323 | else: 324 | log_and_print("ERROR: Playnite executable not specified in configuration") 325 | 326 | log_and_print("\nSync Story operation completed.") 327 | 328 | except FileNotFoundError as e: 329 | error_message = f"File not found: {str(e)}" 330 | log_and_print(error_message) 331 | except KeyError as e: 332 | error_message = f"Configuration error: {str(e)}" 333 | log_and_print(error_message) 334 | except Exception as e: 335 | error_message = f"An unexpected error occurred: {str(e)}" 336 | log_and_print(error_message) --------------------------------------------------------------------------------