├── src └── rsrtools │ ├── songlists │ ├── __init__.py │ ├── config.py │ ├── scanner.py │ ├── configclasses.py │ └── songlists.py │ ├── files │ ├── __init__.py │ ├── config.py │ ├── exceptions.py │ ├── steam.py │ ├── steamcache.py │ └── savefile.py │ ├── __init__.py │ ├── utils.py │ └── importrsm.py ├── LICENSE ├── pyproject.toml └── .gitignore /src/rsrtools/songlists/__init__.py: -------------------------------------------------------------------------------- 1 | """Song list classes.""" 2 | -------------------------------------------------------------------------------- /src/rsrtools/files/__init__.py: -------------------------------------------------------------------------------- 1 | """File handling for rsrtools.""" 2 | -------------------------------------------------------------------------------- /src/rsrtools/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools for creating Rocksmith 2014 songlists and managing Rocksmith save files.""" 2 | 3 | __version__ = '1.1.0' 4 | -------------------------------------------------------------------------------- /src/rsrtools/files/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Provide shared constants and definitions for Rocksmith files and profiles.""" 3 | 4 | from enum import Enum 5 | 6 | MAX_SONG_LIST_COUNT = 6 7 | 8 | 9 | class ProfileKey(Enum): 10 | """Provides a list of Rocksmith profile key strings.""" 11 | 12 | FAVORITES_LIST = "FavoritesList" 13 | FAVORITES_LIST_ROOT = "FavoritesListRoot" 14 | PLAY_NEXTS = "Playnexts" # cSpell:disable-line 15 | PLAYED_COUNT = "PlayedCount" 16 | SONGS = "Songs" 17 | SONG_LISTS = "SongLists" 18 | SONG_LISTS_ROOT = "SongListsRoot" 19 | SONG_SA = "SongsSA" 20 | STATS = "Stats" # cSpell:disable-line 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 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 | -------------------------------------------------------------------------------- /src/rsrtools/files/exceptions.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | """File exceptions for rsrtools.""" 3 | 4 | from typing import Optional 5 | 6 | 7 | class RsrError(Exception): 8 | """Super class for rsrtools errors.""" 9 | 10 | def __init__(self, message: Optional[str] = None) -> None: 11 | """Minimal constructor for RsrErrors. 12 | 13 | Keyword Arguments: 14 | message {str} -- Custom error text. If no message is supplied (default), 15 | the exception will supply a not very informative message. 16 | (default: {None}) 17 | 18 | """ 19 | if message is None: 20 | message = "An unspecified error has occurred in rsrtools." 21 | super().__init__(message) 22 | 23 | 24 | class RSFileFormatError(RsrError): 25 | """Exception for errors in file format or data in a Rocksmith file.""" 26 | 27 | def __init__(self, message: str) -> None: 28 | """Minimal constructor for rsrtools file format errors. 29 | 30 | Arguments: 31 | message {[type]} -- Custom error text. 32 | """ 33 | super().__init__( 34 | f"{message}" 35 | f"\nPossible reasons:\n" 36 | f"- This may not be a Rocksmith file.\n" 37 | f"- This file may be corrupt.\n" 38 | f"- Ubisoft may have changed the file format (requires" 39 | f" update to rsrtools).", 40 | ) 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "rsrtools" 7 | dynamic = ["version"] 8 | authors = [ 9 | {name="BuongiornoTexas"}, 10 | ] 11 | description = "Tools for managing Rocksmith 2014 songlists and save files." 12 | readme = "README.rst" 13 | requires-python = ">=3.12" 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Intended Audience :: End Users/Desktop", 17 | "Environment :: Console", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: Microsoft :: Windows :: Windows 10", 20 | "Operating System :: Microsoft :: Windows :: Windows 11", 21 | "Operating System :: MacOS :: MacOS X", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3.12", 24 | "Topic :: Games/Entertainment", 25 | ] 26 | license = "MIT" 27 | keywords = [ 28 | "Rocksmith", 29 | "Songlists", 30 | ] 31 | dependencies = [ 32 | "pycryptodome >= 3.20.0", 33 | "simplejson >= 3.19.2", 34 | "tomli-w >= 1.0.0", 35 | "pydantic >= 2.6.1", 36 | ] 37 | 38 | [project.optional-dependencies] 39 | dev = [ 40 | "types-simplejson", 41 | ] 42 | 43 | [project.scripts] 44 | songlists = "rsrtools.songlists.songlists:main" 45 | profilemanager = "rsrtools.files.profilemanager:main" 46 | importrsm = "rsrtools.importrsm:main" 47 | welder = "rsrtools.files.welder:main" 48 | 49 | [project.urls] 50 | "Homepage" = "https://github.com/BuongiornoTexas/rsrtools" 51 | "Bug Tracker" = "https://github.com/BuongiornoTexas/rsrtools/issues" 52 | 53 | [tool.hatch.version] 54 | path = "src/rsrtools/__init__.py" 55 | 56 | [tool.hatch.envs.default] 57 | platforms = ["windows", "macos"] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # mypy stubs 107 | out/ 108 | 109 | # vs code 110 | .vscode 111 | 112 | # Temporary testing 113 | test/ 114 | -------------------------------------------------------------------------------- /src/rsrtools/songlists/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Provide type aliases, shared strings and JSON schemas used by song list creator.""" 4 | 5 | from enum import Enum 6 | from typing import Optional 7 | 8 | 9 | # We use some SQL field information in constants, so declare these here (may move to a 10 | # config later if used by other modules as well). 11 | class SQLField(Enum): 12 | """Provide for abuse of the Enum class to set standard field types.""" 13 | 14 | @classmethod 15 | def get_sub_class(cls, value: str) -> "SQLField": 16 | """Create a subclass Enum value from a string value. 17 | 18 | This assumes that a) all SQLField subclass constants are strings and b) there 19 | are no repeated strings between the subclasses. 20 | """ 21 | # Jan 2024 - I don't think this method is used at all? Safe to delete? 22 | # for now, have made the method name consistent with other methods. 23 | # Previous version had no underscores. 24 | ret_val: Optional[SQLField] = None 25 | for field_class in cls.__subclasses__(): 26 | try: 27 | ret_val = field_class(value) 28 | # found it if we got here! 29 | break 30 | except ValueError: 31 | # skip to the next subclass 32 | pass 33 | 34 | if ret_val is None: 35 | raise ValueError(f"{value} is a not a valid subclass of SQLField") 36 | 37 | return ret_val 38 | 39 | @classmethod 40 | def report_field_values(cls) -> None: 41 | """Print a list of the Enum values, which correspond to database field names.""" 42 | print() 43 | print("Field names:") 44 | for field in cls: 45 | print(f" {field.value}") 46 | print() 47 | 48 | 49 | class ListField(SQLField): 50 | """Provide Enum of list type SQL fields that can be used as filters.""" 51 | 52 | # list types. Automatically validated before use. 53 | # RSSongId was the name I came up with for rsrtools. May need to migrate to SongKey 54 | SONG_KEY = "SongKey" 55 | TUNING = "Tuning" 56 | ARRANGEMENT_NAME = "ArrangementName" 57 | ARRANGEMENT_ID = "ArrangementId" 58 | ARTIST = "Artist" 59 | TITLE = "Title" 60 | ALBUM = "Album" 61 | # Lead, Rhythm or Bass 62 | PATH = "Path" 63 | # Representative, Bonus or Alternate 64 | SUB_PATH = "SubPath" 65 | 66 | 67 | class RangeField(SQLField): 68 | """Provide Enum of numerical type SQL fields names that can be used as filters.""" 69 | 70 | # list types. Automatically validated before use. 71 | # numerical types. Range filters can be applied to these. 72 | PITCH = "Pitch" 73 | TEMPO = "Tempo" 74 | NOTE_COUNT = "NoteCount" 75 | YEAR = "Year" 76 | PLAYED_COUNT = "PlayedCount" 77 | MASTERY_PEAK = "MasteryPeak" 78 | SA_EASY_COUNT = "SAEasyCount" 79 | SA_MEDIUM_COUNT = "SAMediumCount" 80 | SA_HARD_COUNT = "SAHardCount" 81 | SA_MASTER_COUNT = "SAMasterCount" 82 | SA_PLAYED_COUNT = "SAPlayedCount" 83 | SA_EASY_BADGES = "SAEasyBadges" 84 | SA_MEDIUM_BADGES = "SAMediumBadges" 85 | SA_HARD_BADGES = "SAHardBadges" 86 | SA_MASTER_BADGES = "SAMasterBadges" 87 | SONG_LENGTH = "SongLength" 88 | # Modified time of the underlying psarc 89 | LAST_MODIFIED = "LastModified" 90 | -------------------------------------------------------------------------------- /src/rsrtools/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Provide utilities for rsrtools.""" 3 | 4 | # cSpell:ignore HKEY, rsrpad 5 | 6 | # Not even trying to get stubs for winreg 7 | from typing import List, Optional, Tuple, Union, Any, Sequence 8 | 9 | 10 | def rsrpad(data: bytes, block_size_bytes: int) -> bytes: 11 | """Return data padded to match the specified block_size_bytes. 12 | 13 | Arguments: 14 | data {bytes} -- Data string to pad. 15 | block_size_bytes {int} -- Block size for padding. 16 | 17 | Returns: 18 | bytes -- Padded data. 19 | 20 | The first byte of the padding is null to match the rocksmith standard, 21 | the remainder fill with the count of bytes padded (RS appears to use 22 | random chars). 23 | 24 | """ 25 | padding = (block_size_bytes - len(data)) % block_size_bytes 26 | if padding > 0: 27 | null_bytes = 1 28 | pad_byte = chr(padding).encode() 29 | padding -= 1 30 | else: 31 | null_bytes = 0 32 | pad_byte = b"\x00" 33 | 34 | return data + b"\x00" * null_bytes + pad_byte * padding 35 | 36 | 37 | def double_quote(raw_string: str) -> str: 38 | """Return raw_string after stripping white space and double quoting.""" 39 | raw_string = raw_string.strip('"') 40 | return '"' + raw_string + '"' 41 | 42 | 43 | def choose( 44 | options: Sequence[Union[str, Tuple[str, Any]]], 45 | header: Optional[str] = None, 46 | allow_multi: bool = False, 47 | no_action: Optional[str] = None, 48 | help_text: Optional[str] = None, 49 | ) -> Optional[List[Any]]: 50 | """Return user selection or multi-selection from a list of options. 51 | 52 | Arguments: 53 | options {Sequence[Union[str, Tuple[str, Any]]]} -- A list of options, where 54 | each may be either: 55 | * a string, where string is returned if the option is selected; or 56 | * a of tuple (option_description, return_value), where the return_value 57 | is returned if the option is selected. 58 | 59 | Keyword Arguments: 60 | header {str} -- Header/description text for the selection (default: {None}) 61 | allow_multi {bool} -- Allow multi-selection if True (default: {False}) 62 | no_action {str} -- Description value for no selection. If this argument is not 63 | set or is None, the user must select from the options list. 64 | (default: {None}) 65 | help_text {str} -- Detailed help text. (default: {None}) 66 | 67 | Returns: 68 | Optional[List[Any]] -- List of return values. If the allow_multi is False, the 69 | list will contain only one element. If the user has selected no_action, the 70 | return value is None. 71 | 72 | To select multiple options, enter a comma separated list of values. 73 | 74 | """ 75 | opt_tuple_list = list() 76 | for this_opt in options: 77 | if isinstance(this_opt, tuple): 78 | opt_tuple_list.append(this_opt) 79 | else: 80 | opt_tuple_list.append((this_opt, this_opt)) 81 | 82 | while True: 83 | print() 84 | if header is not None: 85 | print(header) 86 | print() 87 | 88 | for i, (key, value) in enumerate(opt_tuple_list): 89 | print(f"{i + 1: 3d}) {key}") 90 | if no_action is not None: 91 | print(" 0) " + no_action) 92 | if help_text is not None: 93 | print(" h) Help.") 94 | 95 | values: Any = "" 96 | try: 97 | # splits on commas, strips white space from values, converts to int 98 | print() 99 | values = input("Choose> ").strip() 100 | values = list(map(lambda x: int(x.strip()) - 1, values.split(","))) 101 | 102 | if no_action is not None and -1 in values: 103 | return None 104 | 105 | values = [ 106 | opt_tuple_list[value][1] 107 | for value in values 108 | if 0 <= value < len(opt_tuple_list) 109 | ] 110 | 111 | if not values: 112 | continue 113 | 114 | if allow_multi: 115 | return values 116 | 117 | if len(values) == 1: 118 | return values[0:1] 119 | 120 | except (ValueError, IndexError): 121 | if help_text is not None and values == "h": 122 | print("-" * 80) 123 | print() 124 | print(help_text) 125 | print() 126 | print("-" * 80) 127 | continue 128 | else: 129 | print("Invalid input, please try again") 130 | 131 | 132 | def yes_no_dialog(prompt: str) -> bool: 133 | """Return true/false based on prompt string and Y/y or N/n response.""" 134 | while True: 135 | print() 136 | print(prompt) 137 | print() 138 | 139 | response = input("Y/n to confirm, N/n to reject >") 140 | if response in ("Y", "y"): 141 | return True 142 | 143 | if response in ("N", "n"): 144 | return False 145 | -------------------------------------------------------------------------------- /src/rsrtools/files/steam.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Provide steam related utilities for rsrtools.""" 3 | 4 | # Consider creating separate classes if these become extensive 5 | 6 | # cSpell:ignore HKEY, isdigit, remotecache, loginusers 7 | 8 | from sys import platform 9 | from pathlib import Path 10 | from collections import abc 11 | from typing import Any, Dict, Iterator, List, Mapping, NamedTuple, Optional, Tuple 12 | 13 | from rsrtools.utils import double_quote 14 | 15 | if platform == "win32": 16 | import winreg # type: ignore 17 | 18 | ACCOUNT_MASK = 0xFFFFFFFF 19 | VDF_SECTION_START = "{" 20 | VDF_SECTION_END = "}" 21 | VDF_INDENT = "\t" 22 | VDF_SEPARATOR = "\t\t" 23 | RS_APP_ID = "221680" 24 | STEAM_REMOTE_DIR = "remote" 25 | REMOTE_CACHE_NAME = "remotecache.vdf" 26 | 27 | 28 | class SteamMetadataError(Exception): 29 | """Base class for Steam metadata handling errors.""" 30 | 31 | def __init__(self, message: Optional[str] = None) -> None: 32 | """Minimal constructor. 33 | 34 | Keyword Arguments: 35 | message {str} -- Custom error text. If no message is supplied (default), 36 | the exception will supply a not very informative message. 37 | (default: {None}) 38 | """ 39 | if message is None: 40 | message = "An unspecified Steam cloud metadata handling error had occurred." 41 | 42 | super().__init__(message) 43 | 44 | 45 | def load_vdf(vdf_path: Path, strip_quotes: bool = False) -> Dict[Any, Any]: 46 | """Load a Steam .vdf file into a dictionary. 47 | 48 | Arguments: 49 | vdf_path {Path} -- The path to the .vdf file. 50 | 51 | Keyword Arguments: 52 | strip_quotes {bool} -- If True, strips leading and trailing double quotes from 53 | both key and values. If False, returns keys and values as read - typically 54 | both are double quoted. (default: {False}) 55 | 56 | Returns: 57 | Dict[Any, Any] -- The .vdf contents as a dictionary. 58 | 59 | This is a primitive .vdf loader. It makes minimal assumptions about .vdf file 60 | structure and attempts to parse a dictionary based on this structure. It is suitable 61 | for use in rsrtools, but should not be relied on more broadly. 62 | 63 | """ 64 | vdf_dict: Dict[Any, Any] = dict() 65 | 66 | # ugly custom parser, cos Steam doesn't do standard file formats 67 | # node needs a type to stop mypy collapsing during the walk 68 | node: Dict[Any, Any] = vdf_dict 69 | section_label = "" 70 | branches = list() 71 | with vdf_path.open("rt") as file_handle: 72 | for line in file_handle: 73 | line_str = line.strip() 74 | try: 75 | (key, value) = line_str.split() 76 | except ValueError: 77 | if line_str == VDF_SECTION_START: 78 | node[section_label] = dict() 79 | branches.append(node) 80 | node = node[section_label] 81 | section_label = "" 82 | elif line_str == VDF_SECTION_END: 83 | node = branches.pop() 84 | else: 85 | section_label = line_str 86 | if strip_quotes: 87 | section_label = line_str.strip('"') 88 | else: 89 | if strip_quotes: 90 | key = key.strip('"') 91 | value = value.strip('"') 92 | node[key] = value 93 | 94 | # sense check 95 | if branches: 96 | raise SteamMetadataError( 97 | "Incomplete Steam metadata file: at least one section is not " 98 | 'terminated.\n (Missing "}".)' 99 | ) 100 | 101 | return vdf_dict 102 | 103 | 104 | def _iter_vdf_tree(tree: Mapping[Any, Any]) -> Iterator[Tuple[Any, Any]]: 105 | """Iterate (walk) the Steam vdf tree. 106 | 107 | Arguments: 108 | tree {dict} -- A node in a steam .vdf dictionary. 109 | 110 | Helper method for write_vdf_file. 111 | 112 | """ 113 | for key, value in tree.items(): 114 | if isinstance(value, abc.Mapping): 115 | yield key, VDF_SECTION_START 116 | for inner_key, inner_value in _iter_vdf_tree(value): 117 | yield inner_key, inner_value 118 | yield key, VDF_SECTION_END 119 | else: 120 | yield key, value 121 | 122 | 123 | def save_vdf( 124 | vdf_dict: Dict[Any, Any], vdf_path: Path, add_quotes: bool = False 125 | ) -> None: 126 | """Write a vdf dictionary to file. 127 | 128 | Arguments: 129 | vdf_dict {Dict[Any, Any]} -- The vdf dictionary to write. 130 | vdf_path {Path} -- The save path for the file. 131 | 132 | Keyword Arguments: 133 | add_quotes {bool} -- If True, adds double quotes to all keys and 134 | values before writing. If False, writes the keys and values as they 135 | appear in the vdf_dict (default: {False}) 136 | 137 | """ 138 | indent = "" 139 | file_lines = list() 140 | for key, value in _iter_vdf_tree(vdf_dict): 141 | if add_quotes: 142 | key = double_quote(key) 143 | 144 | if value == VDF_SECTION_START: 145 | file_lines.append(f"{indent}{key}\n") 146 | file_lines.append(f"{indent}{VDF_SECTION_START}\n") 147 | indent = indent + VDF_INDENT 148 | elif value == VDF_SECTION_END: 149 | indent = indent[:-1] 150 | file_lines.append(f"{indent}{VDF_SECTION_END}\n") 151 | else: 152 | if add_quotes: 153 | value = double_quote(value) 154 | file_lines.append(f"{indent}{key}{VDF_SEPARATOR}{value}\n") 155 | 156 | with vdf_path.open("wt") as file_handle: 157 | file_handle.writelines(file_lines) 158 | 159 | 160 | class SteamAccountInfo(NamedTuple): 161 | """Provide Steam account information.""" 162 | 163 | name: str 164 | persona: str 165 | description: str 166 | path: Optional[Path] 167 | valid: bool 168 | 169 | 170 | class SteamAccounts: 171 | """Provide data on Steam user accounts on the local machine. 172 | 173 | Public methods: 174 | account_ids -- Return a list Steam account ids in string form (integer 175 | account ids as strings). 176 | account_info -- Return a SteamAccountInfo named tuple for a specific account. 177 | """ 178 | 179 | # instance variables 180 | # Steam data is unlikely to change for the duration of the program, so 181 | # we could use class variables here. But it'll be a lot effort, as this class 182 | # probably only be instantiated once or twice. 183 | # Path to the Steam application. 184 | _steam_path: Optional[Path] 185 | _account_info: Dict[str, SteamAccountInfo] 186 | 187 | def __init__(self) -> None: 188 | """Initialise steam account data for use.""" 189 | try: 190 | self._steam_path = self.get_steam_path() 191 | except (FileNotFoundError, OSError): 192 | self._steam_path = None 193 | 194 | self._account_info = self._find_info() 195 | 196 | @staticmethod 197 | def get_steam_path() -> Path: 198 | """Return Steam installation path.""" 199 | # At the moment, this is the only OS dependent code in the package 200 | if platform == "win32": 201 | try: 202 | with winreg.OpenKey( 203 | winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam" 204 | ) as steam_key: 205 | str_path, _ = winreg.QueryValueEx(steam_key, "SteamPath") 206 | 207 | ret_val = Path(str_path).resolve(strict=True) 208 | except (OSError, FileNotFoundError): 209 | # Looks like we have no steam installation? 210 | # Up to the user to decide what to do here. 211 | print("I can't find the Steam installation path.") 212 | raise 213 | 214 | elif platform == "darwin": 215 | # I believe this should work. 216 | try: 217 | ret_val = ( 218 | Path.home() 219 | .joinpath("Library/Application Support/Steam") 220 | .resolve(strict=True) 221 | ) 222 | except FileNotFoundError: 223 | # Looks like we have no steam installation? 224 | # Up to the user to decide what to do here. 225 | print("I can't find the Steam installation path.") 226 | raise 227 | 228 | else: 229 | raise OSError( 230 | f"rsrtools doesn't know how to find Steam folder on {platform}" 231 | ) 232 | 233 | return ret_val 234 | 235 | def _find_info(self) -> Dict[str, SteamAccountInfo]: 236 | """Find and record Steam account information on local machine.""" 237 | info: Dict[str, SteamAccountInfo] = dict() 238 | 239 | if self._steam_path is not None: 240 | # create info based on both login vdf file and user data folder names. 241 | user_dirs = self._user_data_dirs() 242 | user_config = self._login_info() 243 | 244 | user_path: Optional[Path] 245 | for account, user_path in user_dirs.items(): 246 | if account not in user_config: 247 | # user folder with no account information. 248 | info[account] = SteamAccountInfo( 249 | name="", 250 | persona="", 251 | description=f"{account}, no Steam account info.", 252 | path=user_path, 253 | valid=False, 254 | ) 255 | 256 | for account, login_info in user_config.items(): 257 | if account in user_dirs: 258 | # We have account info and a user data folder exists 259 | user_path = user_dirs[account] 260 | valid = True 261 | else: 262 | # Account with no path. Shouldn't happen, but who knows ... 263 | user_path = None 264 | valid = False 265 | 266 | for key in login_info.keys(): 267 | # Deal with variable case in Steam keys. Joy. 268 | loki = key.lower() 269 | if loki == "accountname": # cspell: disable-line 270 | name = login_info[key] 271 | elif loki == "personaname": # cspell: disable-line 272 | persona = login_info[key] 273 | elif loki == "mostrecent": # cspell: disable-line 274 | if login_info[key] == "1": 275 | most_recent = ", most recent Steam login" 276 | else: 277 | most_recent = "" 278 | 279 | info[account] = SteamAccountInfo( 280 | name=name, 281 | persona=persona, 282 | description=f"'{account}', ({persona}[{name}]){most_recent}.", 283 | path=user_path, 284 | valid=valid, 285 | ) 286 | 287 | return info 288 | 289 | def _login_info(self) -> Dict[str, Dict[str, str]]: 290 | """Read the Steam loginusers.vdf file and returns a dictionary of account data. 291 | 292 | Returns: 293 | Dict[str, Dict[str, str]] -- A dictionary of: 294 | Dict[Account id, Dict[Account Field, Field value]. 295 | 296 | """ 297 | info: Dict[str, Dict[str, str]] = dict() 298 | if self._steam_path is not None: 299 | vdf_dict = load_vdf( 300 | self._steam_path.joinpath("config/loginusers.vdf"), strip_quotes=True 301 | ) 302 | 303 | for steam_id, data in vdf_dict["users"].items(): 304 | # account id is low 32 bits of steam id. 305 | account_id = str(int(steam_id) & ACCOUNT_MASK) 306 | 307 | info[account_id] = data 308 | 309 | return info 310 | 311 | def _user_data_dirs(self) -> Dict[str, Path]: 312 | """Return a dictionary of user data directories found in the Steam directory. 313 | 314 | The dictionary keys are local Steam account ids as strings, values are the 315 | directory paths. Returns an empty dict if no user data directories are found. 316 | 317 | """ 318 | ret_val = dict() 319 | 320 | users_dir = self._steam_path 321 | if users_dir is not None: 322 | # noinspection SpellCheckingInspection 323 | users_dir = users_dir.joinpath("userdata") # cspell: disable-line 324 | 325 | if users_dir.is_dir(): 326 | for child in users_dir.iterdir(): 327 | if child.is_dir(): 328 | if child.name.isdigit(): 329 | # Expecting an integer account id. 330 | # Removed 8 digit check as issue #15 confirms ids can be 331 | # longer. Changing this for normal use should not be an 332 | # issue, as rsrtools requires both a valid account and 333 | # a valid folder before modifying data. 334 | ret_val[child.name] = child 335 | 336 | return ret_val 337 | 338 | def account_ids(self, only_valid: bool = True) -> List[str]: 339 | """Return list of Steam account ids found on the local machine. 340 | 341 | Keyword Arguments: 342 | only_valid {bool} -- If True, the list will contain only account ids that 343 | have user data folder and account details in the loginusers.vdf file. 344 | If False, the list will also contain account ids with partial 345 | information (either user data folder or login information is missing). 346 | (default: {True}) 347 | 348 | Returns: 349 | List[str] -- A list of integer account ids in string form. 350 | 351 | """ 352 | if only_valid: 353 | ids = [x for x, account in self._account_info.items() if account.valid] 354 | else: 355 | ids = list(self._account_info.keys()) 356 | 357 | return ids 358 | 359 | def account_info(self, account_id: str) -> SteamAccountInfo: 360 | """Return the account info for the specified Steam account id. 361 | 362 | Arguments: 363 | account_id {str} -- An integer steam id in string form. 364 | 365 | Raises: 366 | KeyError -- If account_id doesn't exist. 367 | 368 | Returns: 369 | SteamAccountInfo -- The account information object for account_id. 370 | 371 | """ 372 | return self._account_info[account_id] 373 | 374 | def find_account_id(self, test_value: str, only_valid: bool = True) -> str: 375 | """Convert test value into an 32 bit Steam account id. 376 | 377 | Arguments: 378 | test_value {str} -- This may be a 32 bit Steam ID, an 32 bit steam account 379 | ID, a Steam account name, or a Steam profile alias. 380 | only_valid {bool} -- If True, the method will only return the account id 381 | for an account with a user data directory and a loginusers.vdf entry. 382 | If False, it will return an account id for records which have partial 383 | data (either a user data directory or login information is missing). 384 | (default: {True}) 385 | 386 | Raises: 387 | KeyError -- If the account id does not exist on the local machine, or if 388 | a partial record is found and only_valid is True. 389 | 390 | Returns: 391 | str -- A 32 bit Steam account id. 392 | 393 | """ 394 | if not test_value: 395 | raise KeyError("Empty string is not a valid Steam account ID.") 396 | 397 | account_id = "" 398 | if test_value in self._account_info: 399 | # Maybe we have been given what we need. 400 | account_id = test_value 401 | 402 | else: 403 | if len(test_value) == 17 and test_value.isdigit(): 404 | # test 64 bit id. 405 | test_id = str(int(test_value) & ACCOUNT_MASK) 406 | if test_id in self._account_info: 407 | account_id = test_id 408 | 409 | upper_value = test_value.upper() 410 | if not account_id: 411 | # lastly check the account dictionary. 412 | for test_id, info in self._account_info.items(): 413 | if ( 414 | info.name.upper() == upper_value 415 | or info.persona.upper() == upper_value 416 | ): 417 | account_id = test_id 418 | break 419 | 420 | if not account_id: 421 | raise KeyError(f"No Steam account id found for {test_value}.") 422 | 423 | elif only_valid and not self._account_info[account_id].valid: 424 | raise KeyError(f"No valid Steam account id found for {test_value}.") 425 | 426 | return account_id 427 | -------------------------------------------------------------------------------- /src/rsrtools/files/steamcache.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Provides classes for managing Steam cloud metadata files (remotecache.vdf). 3 | 4 | Refer to class SteamMetadata for further detail/definitions. 5 | """ 6 | 7 | # cSpell:ignore PRFLDB, remotecache, steamcache 8 | # I'd prefer to not ignore these, but stupid steam keywords are stupid. 9 | # cspell: ignore platformstosync, remotetime, syncstate, persiststate 10 | 11 | from os import fsdecode 12 | from pathlib import Path 13 | from hashlib import sha1 14 | from enum import Enum 15 | import argparse 16 | from typing import Dict, Optional 17 | 18 | from rsrtools.utils import double_quote 19 | from rsrtools.files.steam import load_vdf, save_vdf, STEAM_REMOTE_DIR, REMOTE_CACHE_NAME 20 | 21 | BLOCK_SIZE = 65536 22 | 23 | 24 | class SteamMetadataKey(Enum): 25 | """Provides a list of writeable metadata keys for Steam cloud files.""" 26 | 27 | SIZE = '"size"' 28 | LOCALTIME = '"localtime"' 29 | TIME = '"time"' 30 | SHA = '"sha"' 31 | 32 | 33 | class SteamMetadata: 34 | r"""Finds and loads Steam cloud metadata file and manages updates to this file. 35 | 36 | Public methods: 37 | Constructor: Finds and loads a Steam cloud metadata file from a specified 38 | directory. 39 | metadata_exists: Returns True if a metadata set exists for a Steam cloud file 40 | (app_id/file_path pair). 41 | update_metadata_set: Updates writeable metadata values for a Steam cloud file. 42 | write_metadata_file: Saves the instance metadata to file. 43 | file_path: Returns the path to the underlying Steam metadata file. 44 | 45 | Key Terms: 46 | Steam cloud file: A file automatically backed up by Steam to a remote server. 47 | A Steam cloud file has both a path and an associated app_id. The path is 48 | typically of the form: 49 | \\\remote\. 50 | app_id: A Steam game or application identifier. 51 | Steam cloud file metadata set or cloud file metadata set: Metadata describing a 52 | single Steam cloud file, such as modification time, hashes, etc. 53 | Steam metadata file: A file containing metadata about Steam cloud files for a 54 | game or Steam application. Typically named remotecache.vdf and found in the 55 | same directory as the remote directory. This file contains a set of metadata 56 | for each Steam cloud file in the remote directory. 57 | 58 | The following extract from a Steam remotecache.vdf for a Rocksmith 59 | LocalProfiles.json file shows the typical metadata for a Steam cloud file. 60 | 61 | { 62 | "root" "0" 63 | "size" "244" 64 | "localtime" "1551482347" 65 | "time" "1551482345" 66 | "remotetime" "1551482345" 67 | "sha" "685..." 68 | "syncstate" "1" 69 | "persiststate" "0" 70 | "platformstosync2" "-1" 71 | } 72 | 73 | Warning: The class does not validate the Steam cloud files and does not validate 74 | the file locations. These are caller responsibilities. 75 | 76 | Implementation notes for the Steam metadata dictionary (self._steam_metadata): 77 | - This is a dictionary with one entry: 78 | key = Steam app_id 79 | value = file dictionary 80 | - The file dictionary has one entry per Steam cloud file, where each entry: 81 | consists of: 82 | key = Steam cloud file name 83 | value = A dictionary containing the metadata for the Steam cloud file. 84 | 85 | """ 86 | 87 | # instance variables 88 | # Path to Steam metadata file. 89 | _metadata_path: Path 90 | # Instance version of the Steam metadata 91 | _steam_metadata: Dict[str, Dict[str, Dict[str, str]]] 92 | _is_dirty: bool 93 | 94 | def _read_steam_metadata(self) -> None: 95 | """Read Steam metadata file and load metadata dictionary.""" 96 | self._steam_metadata = load_vdf(self._metadata_path, strip_quotes=False) 97 | 98 | @staticmethod 99 | def _update_metadata_key_value( 100 | metadata_set: Dict, key: SteamMetadataKey, value: str 101 | ) -> None: 102 | """Update value for the specified key in the Steam cloud file metadata set. 103 | 104 | Arguments: 105 | metadata_set {dict} -- Metadata set for a Steam cloud (remote) file 106 | (see _cloud_file_metadata_set). 107 | key {SteamMetadataKey} -- Key to update in the metadata set. Must be a 108 | member of SteamMetadataKey enum. The key must already exist in the 109 | metadata dict (no creating new keys). 110 | value {str} -- New value to be assigned to the key. The caller is 111 | responsible for value formatting per Steam standards. 112 | 113 | Raises: 114 | ValueError -- Raised for an invalid key. 115 | KeyError -- Raised for a valid key that is not in the dictionary. 116 | 117 | Implementation detail: This a static method as it operates on the metadata 118 | dictionary for a specific file, rather than on self._steam_metadata, which 119 | which contains metadata for a *set* of Steam cloud files, and the method does 120 | not reference any other instance data. 121 | 122 | """ 123 | # pylint - may cause problems with enum? 124 | # if type(key) is not SteamMetadataKey: 125 | if not isinstance(key, SteamMetadataKey): 126 | raise ValueError(f"Invalid Steam metadata key {key} specified") 127 | 128 | # watch out for the need to use the key value twice, otherwise create a 129 | # new entry in dict. 130 | if key.value in metadata_set: 131 | metadata_set[key.value] = double_quote(value) 132 | else: 133 | # this really, really shouldn't happen implies a corrupt Steam cache 134 | # file/file with missing keys. 135 | # Actually, not quite true - Steam can be a bit inconsistent on keyword 136 | # casing - if this error is thrown, it should be the first thing to 137 | # investigate. However, I've sampled about 15 remotecache.vdf files 138 | # and they do all seem to use lower case keys. So for now, I'm 139 | # just going to assume this will keep working. 140 | raise KeyError(f"Steam metadata entry does not exist for key {key.name}.") 141 | 142 | def _cloud_file_metadata_set(self, app_id: str, file_path: Path) -> Dict: 143 | """Return the Steam cloud file metadata set for the app_id/file_path pair. 144 | 145 | Arguments: 146 | app_id {str} -- Steam app id for the Steam cloud file. 147 | file_path {pathlib.Path} -- Path to the Steam cloud file. Warning: this 148 | method extracts the filename from the path and ignores all other path 149 | information. It is the caller responsibility to ensure the path points 150 | to the correct Steam cloud file. 151 | 152 | Raises: 153 | KeyError -- Raised if the Steam metadata does not contain an entry for the 154 | app_id/file_path parameters. 155 | 156 | Returns: 157 | dict -- Steam cloud file metadata set for the app_id/file_path pair. 158 | 159 | Note: This method returns the metadata dictionary for a single Steam cloud 160 | file. It does **not** return self._steam_metadata (see Class help for 161 | definition). 162 | 163 | """ 164 | file_metadata = None 165 | # This will throw a key error if the metadata dictionary doesn't contain 166 | # entries for app_id. Otherwise returns a dictionary of dicts containing 167 | # metadata for *ALL* of the Steam cloud files associated with the app_id. Need 168 | # to search this dict to find the sub-dictionary for the target file. 169 | file_dict = self._steam_metadata[double_quote(app_id)] 170 | 171 | # As I've seen weird case stuff for file names in remotecache.vdf, assume we 172 | # need to do a case insensitive check for the filename. Should be OK for 173 | # windows, may break on OSX/Linux 174 | find_name = double_quote(file_path.name.upper()) 175 | for check_name in file_dict.keys(): 176 | if check_name.upper() == find_name: 177 | file_metadata = file_dict[check_name] 178 | break 179 | 180 | if file_metadata is None: 181 | raise KeyError( 182 | f"No Steam metadata entry for file {find_name} in app {app_id}" 183 | ) 184 | 185 | return file_metadata 186 | 187 | def metadata_exists(self, app_id: str, file_path: Path) -> bool: 188 | """Return True if a metadata set exists for a Steam cloud file. 189 | 190 | Arguments: 191 | app_id {str} -- Steam app id for the Steam cloud file. 192 | file_path {pathlib.Path} -- Path to the Steam cloud file. Warning: this 193 | method extracts the filename from the path and ignores all other path 194 | information. It is the caller responsibility to ensure the path points 195 | to the correct Steam cloud file. 196 | 197 | """ 198 | ret_val = True 199 | 200 | try: 201 | self._cloud_file_metadata_set(app_id, file_path) 202 | except KeyError: 203 | ret_val = False 204 | 205 | return ret_val 206 | 207 | def update_metadata_set( 208 | self, app_id: str, file_path: Path, data: Optional[bytes] = None 209 | ) -> None: 210 | """Update all writeable metadata for a Steam cloud file. 211 | 212 | Arguments: 213 | app_id {str} -- Steam app id for the Steam cloud file. 214 | file_path {pathlib.Path} -- Path to the Steam cloud file. Warning: this 215 | method extracts the filename from the path to identify the metadata set 216 | and updates metadata based on the file properties. It is the caller 217 | responsibility to ensure the path points to the correct Steam cloud 218 | file (i.e. this method does not validate that the file is valid 219 | Steam cloud file in a valid location). 220 | 221 | Keyword Arguments: 222 | data {bytes} -- Binary Steam cloud file held in memory 223 | (default: {None}) 224 | 225 | By default, this method will determine the writeable Steam cloud metadata 226 | values (hash, size and modification times) directly from the file on disk. 227 | However, if the optional data argument is supplied, the hash and size values 228 | will be calculated from the contents of data. 229 | 230 | This method does nothing if the metadata set for the Steam cloud file does not 231 | exist. 232 | 233 | """ 234 | try: 235 | cache_dict = self._cloud_file_metadata_set(app_id, file_path) 236 | except KeyError: 237 | # file metadata set dict not found, so do nothing. 238 | return 239 | 240 | hasher = sha1() 241 | if data is None: 242 | with file_path.open("rb") as file_handle: 243 | buffer = file_handle.read(BLOCK_SIZE) 244 | while buffer: 245 | hasher.update(buffer) 246 | buffer = file_handle.read(BLOCK_SIZE) 247 | 248 | # st_size works on windows 249 | file_size = file_path.stat().st_size # cSpell:disable-line 250 | else: 251 | hasher.update(data) 252 | file_size = len(data) 253 | 254 | self._update_metadata_key_value( 255 | cache_dict, SteamMetadataKey.SIZE, str(file_size) 256 | ) 257 | 258 | self._update_metadata_key_value( 259 | cache_dict, SteamMetadataKey.SHA, hasher.hexdigest().lower() 260 | ) 261 | 262 | # st_mtime appears gives the right (UTC since jan 1 1970) values on Windows, 263 | # probably also OK on OSX, Linux? 264 | self._update_metadata_key_value( 265 | cache_dict, 266 | SteamMetadataKey.LOCALTIME, 267 | str(int(file_path.stat().st_mtime)), # cSpell:disable-line 268 | ) 269 | self._update_metadata_key_value( 270 | cache_dict, 271 | SteamMetadataKey.TIME, 272 | str(int(file_path.stat().st_mtime)), # cSpell:disable-line 273 | ) 274 | 275 | # instance contents out of sync with metadata file. 276 | self._is_dirty = True 277 | 278 | def write_metadata_file(self, save_dir: Optional[Path]) -> None: 279 | """Write Steam metadata file if instance data differs from the original file. 280 | 281 | Arguments: 282 | save_dir {Optional[pathlib.Path]} -- Save directory for the Steam metadata 283 | file or None. 284 | 285 | If save_dir is specified as None, the original Steam metadata file will be 286 | overwritten, and the instance marked as clean. 287 | 288 | Otherwise the updated file is written to save_dir with the original file name. 289 | Further, the object instance remains marked as dirty, as the object data is out 290 | of sync with the original source files loaded by the object. (this is only an 291 | issue if the caller intent is to update original files later). 292 | 293 | Note: The Steam metadata file will likely contain metadata for more than one 294 | Steam cloud file. This is typically the desired state. 295 | """ 296 | if self._is_dirty: 297 | if save_dir is None: 298 | save_path = self._metadata_path 299 | else: 300 | save_path = save_dir.joinpath(self._metadata_path.name) 301 | 302 | save_vdf(self._steam_metadata, save_path, add_quotes=False) 303 | 304 | if save_dir is None: 305 | # original source file and instance now in sync 306 | self._is_dirty = False 307 | 308 | def __init__(self, search_dir: Path) -> None: 309 | """Locate Steam metadata file in search_dir and load it. 310 | 311 | Arguments: 312 | search_dir {pathlib.Path} -- Search directory for metadata file. 313 | 314 | Raises: 315 | FileNotFoundError -- Metadata file not found. 316 | 317 | """ 318 | # see if we can find a path to remotecache.vdf. 319 | self._is_dirty = False 320 | 321 | target_path = search_dir.joinpath(REMOTE_CACHE_NAME) 322 | 323 | if target_path.is_file(): 324 | self._metadata_path = target_path.resolve() 325 | else: 326 | raise FileNotFoundError( 327 | f"Steam metadata file {REMOTE_CACHE_NAME} expected but not found in:" 328 | f"\n {fsdecode(search_dir)}" 329 | ) 330 | 331 | self._read_steam_metadata() 332 | 333 | @property 334 | def file_path(self) -> Path: 335 | """Get the path to the underlying Steam metadata file. 336 | 337 | Gets: 338 | pathlib.Path -- Path to metadata file. 339 | 340 | """ 341 | return self._metadata_path 342 | 343 | 344 | def self_test() -> None: 345 | """Limited self test for SteamMetadata. 346 | 347 | Run with: 348 | py -m rsrtools.files.steamcache test_directory 349 | 350 | test_directory must contain a remotecache.vdf file and remote directory containing 351 | one or more Rocksmith files referenced in remotecache.vdf (*.crd, 352 | LocalProfiles.json, *_PRFLDB). I'd suggest setting up a profile manager working 353 | directory and running this self test on it. 354 | 355 | I'd strongly recommend you **do not** run this script on any of your Steam 356 | directories. 357 | """ 358 | parser = argparse.ArgumentParser( 359 | description="Runs a self test of SteamMetadata on a specified directory." 360 | ) 361 | parser.add_argument( 362 | "test_directory", 363 | help="A directory containing remotecache.vdf and Steam remote directory that " 364 | "will be used for the self test.", 365 | ) 366 | test_path = Path(parser.parse_args().test_directory).resolve(True) 367 | 368 | metadata = SteamMetadata(test_path) 369 | 370 | # get a separate copy of the cache for comparison 371 | remote_cache = load_vdf(test_path.joinpath(REMOTE_CACHE_NAME), strip_quotes=False) 372 | 373 | test_passed = True 374 | for app_id, app_dict in remote_cache.items(): 375 | print(f"\nTesting steam app: {app_id}.") 376 | for cache_file in app_dict.keys(): 377 | print(f"\n Test results for file: {cache_file}.") 378 | filepath = test_path.joinpath( 379 | STEAM_REMOTE_DIR + "/" + cache_file.strip('"') 380 | ) 381 | if not filepath.exists(): 382 | print(" File not found.") 383 | 384 | else: 385 | metadata.update_metadata_set(app_id, filepath) 386 | 387 | # reach into object for updated data set - break protection for 388 | # self test. 389 | # pylint: disable-next=protected-access 390 | calculated_metadata = metadata._cloud_file_metadata_set( 391 | app_id, filepath 392 | ) 393 | 394 | for steam_key in SteamMetadataKey: 395 | # report on differences/matches between original data and calculated 396 | # versions 397 | orig_value = app_dict[cache_file][steam_key.value] 398 | new_value = calculated_metadata[steam_key.value] 399 | if orig_value == new_value: 400 | outcome = "OK value:" 401 | else: 402 | outcome = "Bad value:" 403 | test_passed = False 404 | 405 | print( 406 | f" {outcome} {steam_key.value} - remotecache.vdf: " 407 | f"{orig_value}, calculated: {new_value}." 408 | ) 409 | 410 | if test_passed: 411 | print( 412 | "\nTest passed. All values calculated from the source files match" 413 | "\nthe values in the remotecache.vdf file.\n" 414 | ) 415 | else: 416 | print( 417 | "\nTest failed. At least one value calculated the source files does not " 418 | "\nmatch the corresponding value in the remotecache.vdf file." 419 | "\nNote that a 1 second difference in time values is not a cause for " 420 | "concern.\n" 421 | ) 422 | 423 | 424 | if __name__ == "__main__": 425 | self_test() 426 | -------------------------------------------------------------------------------- /src/rsrtools/songlists/scanner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Provide database scanner for PSARC files.""" 4 | 5 | # cSpell:ignore steamapps, stat, rpartition, isdigit, CGCGGE, CGCGBE, firekorn 6 | # cSpell:ignore DADGAD 7 | # cSpell:ignore CACGCE, EADG, etudes, libraryfolders, levelbreak, chordnamestress 8 | 9 | from sys import platform 10 | 11 | from pathlib import Path 12 | from typing import Any, Dict, Iterator, Optional 13 | 14 | import simplejson 15 | 16 | from rsrtools.songlists.config import ListField, RangeField 17 | from rsrtools.files.steam import SteamAccounts, load_vdf 18 | from rsrtools.files.welder import Welder 19 | 20 | LIBRARY_VDF = "steamapps/libraryfolders.vdf" 21 | LIBRARY_FOLDERS = "libraryfolders" 22 | LIBRARY_PATH = "path" 23 | ROCKSMITH_PATH = "steamapps/common/Rocksmith2014" 24 | DLC_PATH = "dlc" 25 | MAC_PSARC = "_m.psarc" 26 | WIN_PSARC = "_p.psarc" 27 | RS_COMPATIBILITY = "rs1compatibility" 28 | RS_INTERNAL = "Rocksmith Internal" 29 | INTERNAL_SONGS = ( 30 | "manifests/songs/rs2tails_combo.json" 31 | "manifests/songs/rs2levelbreak_combo4.json" 32 | "manifests/songs/rs2levelbreak_combo3.json" 33 | "manifests/songs/rs2levelbreak_combo2.json" 34 | "manifests/songs/rs2levelbreak_combo.json" 35 | "manifests/songs/rs2chordnamestress_rhythm.json" 36 | "manifests/songs/rs2arpeggios_combo.json" 37 | ) 38 | 39 | track_types = {"_lead": "Lead", "_bass": "Bass", "_rhythm": "Rhythm", "_combo": "Combo"} 40 | 41 | # E standard repeated here, as Rocksmith can be internally 42 | # inconsistent. 43 | # Tuning names taken from Rocksmith Song Custom Toolkit (Thanks firekorn) 44 | # https://github.com/rscustom/rocksmith-custom-song-toolkit/blob/master/ ... 45 | # RocksmithToolkitLib/RocksmithToolkitLib.TuningDefinition.xml 46 | tuning_db = { 47 | # Standards 48 | "000000": "E Standard", 49 | "-200000": "Drop D", 50 | "-1-1-1-1-1-1": "Eb Standard", 51 | "-3-1-1-1-1-1": "Eb Drop Db", 52 | "-2-2-2-2-2-2": "D Standard", 53 | "-4-2-2-2-2-2": "D Drop C", 54 | "-3-3-3-3-3-3": "C# Standard", 55 | "-4-4-4-4-4-4": "C Standard", 56 | "-200-1-2-2": "Open D", 57 | "002220": "Open A", 58 | "-2-2000-2": "Open G", 59 | "022100": "Open E", 60 | # Custom tunings 61 | "111111": "F Standard", 62 | "-5-5-5-5-5-5": "B Standard", 63 | "-6-6-6-6-6-6": "Bb Standard", 64 | "-7-7-7-7-7-7": "A Standard", 65 | # Toolkit had -7 for this, think it should be -8 66 | "-8-8-8-8-8-8": "Ab Standard", 67 | # Drop tunings 68 | "-5-3-3-3-3-3": "C# Drop B", 69 | "-6-4-4-4-4-4": "C Drop A#", 70 | "-7-5-5-5-5-5": "B Drop A", 71 | "-8-6-6-6-6-6": "Bb Drop Ab", 72 | "-9-7-7-7-7-7": "A Drop G", 73 | # Double drops 74 | "-20000-2": "Double Drop D", 75 | "-3-1-1-1-1-3": "Double Drop Db", 76 | "-4-2-2-2-2-4": "Double Drop C", 77 | "-5-3-3-3-3-5": "Double Drop B", 78 | "-6-4-4-4-4-6": "Double Drop A#", 79 | # Open tuning 80 | "-200-21-2": "Open Dm7", 81 | "-4-2-20-40": "CGCGGE", 82 | "-4-2-2000": "CGCGBE", 83 | "0-2000-2": "Open Em9", 84 | "-4-4-2-2-2-4": "Open F", 85 | "-2000-2-2": "DADGAD", 86 | "-40-2010": "CACGCE", 87 | "-20023-2": "DADADd", 88 | # Variation around std tuning 89 | "0000-20": "EADGAe", 90 | "000012": "All Fourth", 91 | # 6 Strings Bass tuning 92 | "-5-5-5-5-4-4": "B standard 6 Strings Bass", 93 | "-6-6-6-6-5-5": "Bb standard 6 Strings Bass", 94 | "-7-7-7-7-6-6": "A standard 6 Strings Bass", 95 | "-8-8-8-8-7-7": "Ab standard 6 Strings Bass", 96 | "-7-5-5-5-4-4": "B Drop A 6 Strings Bass", 97 | "-8-6-6-6-5-5": "Bb Drop Ab 6 Strings Bass", 98 | "-9-7-7-7-6-6": "A Drop G 6 Strings Bass", 99 | } 100 | 101 | 102 | def rocksmith_path() -> Path: 103 | """Return the Rocksmith path.""" 104 | steam_path = SteamAccounts.get_steam_path() 105 | 106 | try: 107 | rs_path: Optional[Path] = steam_path.joinpath(ROCKSMITH_PATH).resolve(True) 108 | except FileNotFoundError: 109 | # So it's not in the default location. 110 | rs_path = None 111 | 112 | if rs_path is None: 113 | try: 114 | library_info: Optional[Dict[Any, Any]] = load_vdf( 115 | steam_path.joinpath(LIBRARY_VDF), True 116 | ) 117 | except FileNotFoundError: 118 | library_info = None 119 | 120 | if library_info is not None: 121 | for data_value in library_info[LIBRARY_FOLDERS].values(): 122 | path_str = "" 123 | if isinstance(data_value, dict): 124 | path_str = data_value.get(LIBRARY_PATH, "") 125 | 126 | if path_str: 127 | try: 128 | rs_path = Path(path_str).joinpath(ROCKSMITH_PATH).resolve(True) 129 | break 130 | except FileNotFoundError: 131 | pass 132 | 133 | if rs_path is None: 134 | raise FileNotFoundError("Can't find Rocksmith installation.") 135 | 136 | return rs_path 137 | 138 | 139 | def newer_songs(last_modified: float) -> bool: 140 | """Check Rocksmith dlc against a timestamp and return True if new songs found. 141 | 142 | Arguments: 143 | last_modified {float} -- The timestamp to check. 144 | 145 | Returns: 146 | bool -- True if at least one song file (psarc) has a timestamp newer than 147 | last_modified. 148 | 149 | """ 150 | if platform == "win32": 151 | find_files = WIN_PSARC 152 | elif platform == "darwin": 153 | find_files = MAC_PSARC 154 | 155 | for scan_path in rocksmith_path().joinpath(DLC_PATH).glob("**/*" + find_files): 156 | if scan_path.stat().st_mtime > last_modified: 157 | return True 158 | 159 | return False 160 | 161 | 162 | class Scanner: 163 | """Provide arrangement iterator for filling the arrangements table in ArrangementDB. 164 | 165 | This class has one public member, db_entries, which provides an iterator that 166 | scans Rocksmith psarc files to yield value dictionaries for the arrangements table. 167 | """ 168 | 169 | # Rocksmith install path 170 | _rs_path: Path 171 | # The file being scanned by _arrangement_rows 172 | _current_psarc: Path 173 | 174 | def __init__(self) -> None: 175 | """Get the Rocksmith directory for scanner iterator.""" 176 | self._rs_path = rocksmith_path() 177 | 178 | @staticmethod 179 | def _get_tuning(attributes: Dict[str, Any]) -> str: 180 | """Return tuning from "Attributes" dictionary. 181 | 182 | Arguments: 183 | attributes {dict} -- The attributes dictionary that will be checked for 184 | the path. 185 | """ 186 | # can't rely on standard tuning - ignore this and rely on strings. 187 | # std_tuning = (attributes["ArrangementProperties"]["standardTuning"] == 1) 188 | sub_dict = attributes["Tuning"] 189 | tuning = "".join( 190 | [ 191 | str(sub_dict[x]) 192 | for x in ( 193 | "string0", 194 | "string1", 195 | "string2", 196 | "string3", 197 | "string4", 198 | "string5", 199 | ) 200 | ] 201 | ) 202 | 203 | if tuning in tuning_db: 204 | return tuning_db[tuning] 205 | else: 206 | return f"Unknown Custom Tuning ({tuning})" 207 | 208 | @staticmethod 209 | def _get_sub_path(attributes: Dict[str, Any]) -> str: 210 | """Return sub_path from "Attributes" dictionary. 211 | 212 | Arguments: 213 | attributes {dict} -- The attributes dictionary that will be checked for 214 | the path. 215 | """ 216 | sub_dict = attributes["ArrangementProperties"] 217 | represent = sub_dict["represent"] == 1 218 | bonus = sub_dict["bonusArr"] == 1 219 | 220 | if represent and not bonus: 221 | return "Representative" 222 | 223 | if not represent and bonus: 224 | return "Bonus" 225 | 226 | if not represent and not bonus: 227 | return "Alternative" 228 | 229 | raise ValueError("Invalid sub-path (both representative and bonus?).") 230 | 231 | @staticmethod 232 | def _get_path(attributes: Dict[str, Any]) -> str: 233 | """Return path from "Attributes" dictionary. 234 | 235 | Arguments: 236 | attributes {dict} -- The attributes dictionary that will be checked for 237 | the path. 238 | """ 239 | # Note that the "ArrangementName" is completely misleading here. E.g. A Rhythm 240 | # arrangement could have a lead path! 241 | sub_dict = attributes["ArrangementProperties"] 242 | 243 | # Deal with special cases: 244 | if (sub_dict["routeMask"] == 7) and (attributes["SongKey"] == "RS2LevelBreak"): 245 | # Not sure where it is used, but this is a Rocksmith internal 246 | return "All" 247 | 248 | lead = (sub_dict["pathLead"] == 1) and (sub_dict["routeMask"] == 1) 249 | rhythm = (sub_dict["pathRhythm"] == 1) and (sub_dict["routeMask"] == 2) 250 | bass = (sub_dict["pathBass"] == 1) and (sub_dict["routeMask"] == 4) 251 | 252 | if int(lead) + int(rhythm) + int(bass) != 1: 253 | raise ValueError( 254 | "Arrangement either specifies no path or more than one path." 255 | ) 256 | 257 | if lead: 258 | return "Lead" 259 | if rhythm: 260 | return "Rhythm" 261 | 262 | return "Bass" 263 | 264 | def _internal_data(self, data: bytes, last_modified: float) -> Dict[str, Any]: 265 | """Read attribute from json data, create Rocksmith internal arrangement row. 266 | 267 | Arguments: 268 | data {bytes} -- Arrangement JSON data string in bytes format. 269 | last_modified {float} -- Last modification time of the underlying psarc 270 | file. 271 | 272 | Returns: 273 | Dict[str, Any] -- Arrangement values - see db_entries. 274 | 275 | This is hard coded given the rows have a small number of entries and 276 | doing the mapping is irritating. 277 | 278 | """ 279 | arrangement: Dict[str, Any] = dict() 280 | json = simplejson.loads(data.decode()) 281 | 282 | sub_dict = json["Entries"] 283 | if len(sub_dict) != 1: 284 | raise IndexError( 285 | "Arrangement file with no or multiple Entries (must have one only)." 286 | ) 287 | 288 | # Use iterator to grab first key 289 | arr_id = next(iter(sub_dict.keys())) 290 | 291 | arrangement[ListField.ARRANGEMENT_ID.value] = arr_id 292 | 293 | # Provide something modestly useful in song key, title in case anyone goes 294 | # digging. Cross check arr_id. 295 | if json["InsertRoot"].startswith("Static.SessionMode"): 296 | # Session mode entries don't have a persistent ID, so can't cross check. 297 | arrangement[ListField.SONG_KEY.value] = "Session Mode Entry" 298 | 299 | else: 300 | sub_dict = sub_dict[arr_id]["Attributes"] 301 | 302 | if arr_id != sub_dict["PersistentID"]: 303 | raise ValueError("Inconsistent persistent id data in Arrangement file.") 304 | 305 | if json["InsertRoot"].startswith("Static.Guitars"): 306 | arrangement[ListField.SONG_KEY.value] = "Guitar Entry" 307 | else: 308 | try: 309 | arrangement[ListField.SONG_KEY.value] = sub_dict["SongKey"] 310 | except KeyError: 311 | arrangement[ListField.SONG_KEY.value] = sub_dict["LessonKey"] 312 | 313 | arrangement[ListField.TITLE.value] = arrangement[ListField.SONG_KEY.value] 314 | 315 | # Dummy entries from here - the goal is just to know if RS is 316 | # using an arrangement id or not. 317 | arrangement[ListField.ARRANGEMENT_NAME.value] = RS_INTERNAL 318 | arrangement[ListField.PATH.value] = RS_INTERNAL 319 | arrangement[ListField.SUB_PATH.value] = RS_INTERNAL 320 | arrangement[ListField.TUNING.value] = RS_INTERNAL 321 | arrangement[RangeField.PITCH.value] = 440.0 322 | arrangement[ListField.ARTIST.value] = RS_INTERNAL 323 | arrangement[ListField.ALBUM.value] = RS_INTERNAL 324 | arrangement[RangeField.YEAR.value] = 0.0 325 | arrangement[RangeField.TEMPO.value] = 0.0 326 | arrangement[RangeField.NOTE_COUNT.value] = 0 327 | arrangement[RangeField.SONG_LENGTH.value] = 0 328 | arrangement[RangeField.LAST_MODIFIED.value] = last_modified 329 | 330 | return arrangement 331 | 332 | def _arrangement_data( 333 | self, data: bytes, expect_name: str, last_modified: float 334 | ) -> Dict[str, Any]: 335 | """Convert json data into dictionary suitable for loading into arrangement row. 336 | 337 | Arguments: 338 | data {bytes} -- Arrangement JSON data string in bytes format. 339 | expect_name {str} -- The expected name for the arrangement. An error will 340 | raised if this name is not found in the JSON. 341 | last_modified {float} -- Last modification time of the underlying psarc 342 | file. 343 | 344 | Returns: 345 | Dict[str, Any] -- Arrangement values - see db_entries. 346 | 347 | This is hard coded given the rows have a small number of entries and 348 | doing the mapping is irritating. 349 | 350 | """ 351 | arrangement: Dict[str, Any] = dict() 352 | json = simplejson.loads(data.decode()) 353 | 354 | sub_dict = json["Entries"] 355 | if len(sub_dict) != 1: 356 | raise IndexError( 357 | "Arrangement file with no or multiple Entries (must have one only)." 358 | ) 359 | 360 | # Use iterator to grab first key 361 | arr_id = next(iter(sub_dict.keys())) 362 | 363 | sub_dict = sub_dict[arr_id]["Attributes"] 364 | 365 | if arr_id != sub_dict["PersistentID"]: 366 | raise ValueError("Inconsistent persistent id data in Arrangement file.") 367 | arrangement[ListField.ARRANGEMENT_ID.value] = arr_id 368 | 369 | if not sub_dict["FullName"].endswith(expect_name): 370 | err_str = sub_dict["FullName"] 371 | raise ValueError( 372 | f"Arrangement FullName suffix in JSON does not match JSON arrangement" 373 | "name in file." 374 | f"\n JSON FullName: {err_str}" 375 | f"\n Expected suffix: {expect_name}" 376 | ) 377 | # This could be Lead[1-9], Rhythm[1-9], Bass[1-9] or Combo[1-9] 378 | arrangement[ListField.ARRANGEMENT_NAME.value] = expect_name 379 | 380 | # This should be Rhythm, Lead or Bass only. 381 | arrangement[ListField.PATH.value] = self._get_path(sub_dict) 382 | 383 | arrangement[ListField.SUB_PATH.value] = self._get_sub_path(sub_dict) 384 | arrangement[ListField.TUNING.value] = self._get_tuning(sub_dict) 385 | 386 | pitch = sub_dict["CentOffset"] 387 | if abs(pitch) < 0.1: 388 | pitch = 440.0 389 | else: 390 | pitch = round(440.0 * 2.0 ** (pitch / 1200.0), 2) 391 | arrangement[RangeField.PITCH.value] = pitch 392 | 393 | arrangement[ListField.SONG_KEY.value] = sub_dict["SongKey"] 394 | arrangement[ListField.ARTIST.value] = sub_dict["ArtistName"] 395 | arrangement[ListField.TITLE.value] = sub_dict["SongName"] 396 | arrangement[ListField.ALBUM.value] = sub_dict["AlbumName"] 397 | arrangement[RangeField.YEAR.value] = sub_dict["SongYear"] 398 | arrangement[RangeField.TEMPO.value] = sub_dict["SongAverageTempo"] 399 | arrangement[RangeField.NOTE_COUNT.value] = max( 400 | sub_dict["NotesHard"], sub_dict["Score_MaxNotes"] 401 | ) 402 | arrangement[RangeField.SONG_LENGTH.value] = sub_dict["SongLength"] 403 | arrangement[RangeField.LAST_MODIFIED.value] = last_modified 404 | 405 | return arrangement 406 | 407 | def _arrangement_rows( 408 | self, psarc_path: Path, internal: bool = False 409 | ) -> Iterator[Dict[str, Any]]: 410 | """Return an iterator of arrangement row entries in a psarc file. 411 | 412 | Arguments: 413 | psarc_path {Path} -- The psarc file to scan for arrangement data. 414 | internal {bool} -- If specified as True, treats every arrangement found 415 | as a Rocksmith internal (provided for the etudes scan). Note that 416 | some files in songs.psarc will also be treated as internal. 417 | (default: False) 418 | 419 | Returns: 420 | Iterator[Dict[str, Any]] -- Iterator of arrangement values - see db_entries. 421 | 422 | """ 423 | self._current_psarc = psarc_path 424 | last_modified = psarc_path.stat().st_mtime 425 | with Welder(psarc_path, "r") as psarc: 426 | for index in psarc: 427 | name = psarc.arc_name(index) 428 | if name.endswith(".json"): 429 | if name.endswith("_vocals.json"): 430 | continue 431 | 432 | if internal: 433 | # Cut out the middleman, fill an internal entry 434 | yield self._internal_data(psarc.arc_data(index), last_modified) 435 | elif name in INTERNAL_SONGS: 436 | # There are a couple of internal tracks in songs.psarc. 437 | # This traps them. 438 | yield self._internal_data(psarc.arc_data(index), last_modified) 439 | 440 | else: 441 | name = name.replace(".json", "") 442 | for sep in track_types: 443 | _unused, found, suffix = name.rpartition(sep) 444 | if found and (not suffix or suffix.isdigit()): 445 | # We are looking for a successful partition and either 446 | # no suffix or digit only suffix 447 | # To get here, this should be real song song. 448 | expect_name = track_types[found] + suffix 449 | yield self._arrangement_data( 450 | psarc.arc_data(index), expect_name, last_modified 451 | ) 452 | # Prevent fall through to exception 453 | break 454 | else: 455 | continue 456 | 457 | # Not an arrangement file? 458 | raise ValueError( 459 | f"Unexpected json file found {psarc.arc_name(index)} " 460 | f"in {psarc_path.name}." 461 | ) 462 | 463 | def db_entries( 464 | self, last_modified: Optional[float] = None 465 | ) -> Iterator[Dict[str, Any]]: 466 | """Provide an arrangement iterator for the arrangements table in ArrangementDB. 467 | 468 | Arguments: 469 | last_modified {Optional[float]} -- If specified, the iterator will only 470 | return arrangements for psarc files more recent than last_modified 471 | (per Path.stat().st_mtime). (default: None) 472 | 473 | Yields: 474 | Iterator[Dict[str, Any]] -- Iterator of dictionary of values for writing to 475 | the Arrangements table. This dictionary is intended to be used as the 476 | values argument for ArrangementDB._arrangement_sql.write_row(). 477 | 478 | """ 479 | if last_modified is None: 480 | scan_all = True 481 | last_modified = -1 482 | else: 483 | scan_all = False 484 | 485 | # Gather paths to files we will scan for songs 486 | if platform == "win32": 487 | find_files = WIN_PSARC 488 | elif platform == "darwin": 489 | find_files = MAC_PSARC 490 | 491 | if scan_all: 492 | # Scan etudes, guitars, session mode first - happy to have these 493 | # overwritten by any actual song arrangements that clash. 494 | for arrangement in self._arrangement_rows( 495 | self._rs_path.joinpath("etudes.psarc"), True 496 | ): 497 | yield arrangement 498 | 499 | for arrangement in self._arrangement_rows( 500 | self._rs_path.joinpath("guitars.psarc"), True 501 | ): 502 | yield arrangement 503 | 504 | for arrangement in self._arrangement_rows( 505 | self._rs_path.joinpath("session.psarc"), True 506 | ): 507 | yield arrangement 508 | 509 | # Scan the core songs. 510 | for arrangement in self._arrangement_rows( 511 | self._rs_path.joinpath("songs.psarc"), False 512 | ): 513 | yield arrangement 514 | 515 | for scan_path in self._rs_path.joinpath(DLC_PATH).glob("**/*" + find_files): 516 | if not scan_all: 517 | if scan_path.name.startswith(RS_COMPATIBILITY): 518 | # Only scan RS1 compatibility on full scan. 519 | continue 520 | elif scan_path.stat().st_mtime < last_modified: 521 | # skip anything older than last modified time. 522 | continue 523 | 524 | for arrangement in self._arrangement_rows(scan_path, False): 525 | yield arrangement 526 | 527 | 528 | if __name__ == "__main__": 529 | pass 530 | -------------------------------------------------------------------------------- /src/rsrtools/files/savefile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Provides a class (RSSaveFile) for loading and saving Rocksmith save files. 3 | 4 | I do not recommend using this class on its own, as it does not provide any protection 5 | from overwriting save files (no backups) and doesn't manage interactions between the 6 | Rocksmith files and Steam. 7 | 8 | Instead, use the profile manager class, RSProfileManager, which handles these issues 9 | automatically. RSSaveFile is a service provider for RSProfileManager. 10 | 11 | Usage: 12 | See RSSaveFile class help. 13 | 14 | Implements: 15 | RSSaveFile: A class for reading & writing Rocksmith user save files, credential 16 | files, and LocalProfiles.json files (*_PRFLDB, *.crd, LocalFiles.json). 17 | self_test(): A limited self test method. 18 | 19 | Calling this module as a script runs the self test (__name__=="__main__"). This can be 20 | run from the command line by: 21 | 22 | python -m rsrtools.files.savefile 23 | 24 | Where test_dir is the name of a directory containing one or more files that can be used 25 | for testing (I wouldn't run this on any files in the Steam directory though ...) 26 | """ 27 | 28 | # cSpell:ignore PRFLDB, pycryptodome, rsrpad, savefile, reconstructability 29 | 30 | import struct # cSpell:disable-line 31 | import zlib 32 | import argparse 33 | from os import fsdecode 34 | from pathlib import Path 35 | from typing import Optional, Tuple, Dict, Any 36 | 37 | import simplejson 38 | 39 | # pycryptodome provides Crypto replacement 40 | # noinspection PyPackageRequirements 41 | from Crypto.Cipher import AES 42 | 43 | from rsrtools.files.exceptions import RSFileFormatError 44 | from rsrtools.utils import rsrpad 45 | 46 | # aliases for first level of Rocksmith json tree types. 47 | # mypy doesn't do recursion at the moment, and may never, 48 | # casting to any makes life a whole lot easier in handling the json structure. 49 | RSJsonRoot = Dict[str, Any] 50 | 51 | # Encryption key for Rocksmith save files 52 | SAVE_FILE_KEY: bytes = bytes.fromhex( 53 | "728B369E24ED0134768511021812AFC0A3C25D02065F166B4BCC58CD2644F29E" 54 | ) 55 | 56 | HEADER_BYTES = 16 57 | PAYLOAD_SIZE_BYTES = 4 58 | FIRST_PAYLOAD_BYTE: int = HEADER_BYTES + PAYLOAD_SIZE_BYTES 59 | ECB_BLOCK_SIZE = 16 60 | 61 | 62 | class RSSaveFile: 63 | """File manager for Rocksmith save files. Loads, writes and exposes file data. 64 | 65 | Rocksmith save files (*_PRFLDB, *.crd, and LocalProfiles.json) should be managed 66 | with RSProfileManager, which automatically handles interactions between Rocksmith 67 | files and Steam (not managed by this class). 68 | 69 | Based on routines from 0x0L. 70 | 71 | The key class methods are: 72 | Constructor: loads a file and exposes the data as a simplejson member 73 | self.json_tree. 74 | write_save_file: Saves file based on data in simplejson object self.json_tree. 75 | Does not make backups, **use at your own risk**. Can either overwrite 76 | original file or save to new file. 77 | save_json_file: Save json file to text. Includes any Ubisoft formatting. 78 | generate_save_file: Returns a byte object containing the save file 79 | corresponding the current json tree. 80 | 81 | The object exposes json_tree, which is the JSON data dictionary loaded from the 82 | Rocksmith profile. 83 | 84 | The Constructor performs limited self checks when loading a file. These checks try 85 | to reconstruct the original source file from the simplejson tree. If the self check 86 | fails, it implies that the class implementation is not consistent with the source 87 | file. This could be because of Ubisoft changing file structure or because of a 88 | corrupt file. In either instance, this tool should not be used for changing save 89 | file data, as it is almost certain to result in file that is unreadable by 90 | Rocksmith and may corrupt the installation. 91 | """ 92 | 93 | # instance variables 94 | _debug: bool 95 | _json_debug_path: Optional[Path] 96 | _file_path: Path 97 | _check_padding: bytes 98 | _original_file_data: bytes 99 | _header: bytes 100 | # too hard to figure out how factory annotation works today. 101 | # for now make _cipher explicitly dynamic with an Any type 102 | # TODO: more reading another time. # pylint: disable=fixme 103 | _cipher: Any 104 | _debug_z_payload: bytes # compressed payload 105 | _debug_payload: bytes # null terminated payload 106 | json_tree: RSJsonRoot 107 | 108 | def __init__( 109 | self, 110 | rs_file_path: Path, 111 | json_debug_file: Optional[str] = None, 112 | debug: bool = False, 113 | ) -> None: 114 | """Read a file, perform reconstructability test, and expose file data as json. 115 | 116 | Arguments: 117 | rs_file_path {pathlib.Path} -- The name/path of the Rocksmith file to load. 118 | 119 | Keyword Arguments: 120 | json_debug_file {str} -- If supplied, the constructor will write the 121 | decoded json payload as text to the file/path specified by 122 | json_debug_file. This occurs before dumping to simplejson and 123 | reconstruction tests. (default: {None}) 124 | debug {bool} -- if specified as True, generates and keeps more data in the 125 | raw file/decrypted payload/decompressed payload. Primarily intended for 126 | use if/when Ubisoft change file format. It may also be useful in 127 | repairing corrupted files. (default: {False}) 128 | 129 | Developer functionality: If both _debug and json_debug_file are specified and 130 | reconstruction of the json file fails, the reconstructed json will be saved as 131 | json_debug_file + '.reconstruct'. Comparing this and the raw json may help 132 | identify format updates to be applied in _generate_json_string. 133 | 134 | Subclasses should *always* call super().__init__() for reading the file if 135 | overriding. See also docs for _apply_UBI_formats if it exists. 136 | """ 137 | self._debug = debug 138 | if json_debug_file is None: 139 | self._json_debug_path = None 140 | else: 141 | self._json_debug_path = Path(json_debug_file).resolve() 142 | 143 | self._file_path = rs_file_path.resolve() 144 | 145 | self._read_save_file() 146 | 147 | # run self check on file reconstructability, discard return 148 | self._generate_file_data(self_check=True) 149 | 150 | def _generate_json_string(self) -> str: 151 | """Generate json string and and apply Ubisoft specific formatting. 152 | 153 | Returns: 154 | str -- formatted payload string. 155 | 156 | This method allows overriding to implement specific formatting for each 157 | different file. For example, if the _PRFLDB file format is changed to something 158 | that breaks other files, we can create ._generate_json_string to 159 | handle specific formatting. 160 | 161 | As of March 2019, there is no variation between the file types (no subclassing 162 | needed). 163 | 164 | Note: any override must either generate the json dump or call this routine with 165 | super() to do so. 166 | 167 | """ 168 | # as of 14/6/18, Ubisoft separators are: (',',' : '), indent is 0 169 | payload = simplejson.dumps(self.json_tree, separators=(",", " : "), indent=0) 170 | 171 | # this pattern is needed by LocalFiles.json, *_PRFLDB, not by crd 172 | payload = payload.replace('" : [', '" : \n[') 173 | 174 | # these patterns are needed for PRFLDB, not used in others 175 | payload = payload.replace(" : {}", " : {\n}") 176 | payload = payload.replace("\n[]", "\n[\n]") 177 | payload = payload.replace("\n{}", "\n{\n}") 178 | payload = payload.replace("\n[\n[", "\n[\n\n[") 179 | payload = payload.replace("\n],\n[", "\n],\n\n[") 180 | payload = payload.replace("\n[]", "\n[\n]") 181 | # the next two deal with an annoyance where simplejson converts two unicode 182 | # chars to lower case. There no internal difference for python, but just in 183 | # case Rocksmith goes it's own way,let's make it internally consistent 184 | payload = payload.replace(r"\u00ae", r"\u00AE") 185 | payload = payload.replace(r"\u00c2", r"\u00C2") 186 | 187 | return payload 188 | 189 | def _compress_payload(self) -> Tuple[bytes, int]: 190 | """Generate and return compressed payload and uncompressed payload size. 191 | 192 | Returns: 193 | (bytes, int) -- Compressed payload, size of of uncompressed payload in 194 | bytes. 195 | 196 | Includes self checking on reconstructability if required. 197 | 198 | """ 199 | payload_str = self._generate_json_string() 200 | 201 | # convert str payload to terminated bytes 202 | # RS expects null terminated payload 203 | payload = b"".join([payload_str.encode(), b"\x00"]) 204 | 205 | z_payload = zlib.compress(payload, zlib.Z_BEST_COMPRESSION) 206 | 207 | if self._debug: 208 | if payload != self._debug_payload: 209 | if self._json_debug_path is not None: 210 | self.save_json_file( 211 | self._json_debug_path.with_suffix( 212 | self._json_debug_path.suffix + ".reconstruct" 213 | ) 214 | ) 215 | 216 | raise RSFileFormatError( 217 | f"Mismatch between original payload and self check reconstructed " 218 | f"payload in: \n\n {fsdecode(self._file_path)}" 219 | f"\n\nRun with _debug=True and json_debug_file specified to gather " 220 | f"diagnostics." 221 | ) 222 | 223 | if z_payload != self._debug_z_payload[: len(z_payload)]: 224 | # debug_z_payload may include padding, which appears to be: 225 | # \x00 + random bytes to end of 16 byte padding block. 226 | # (I'm really hoping it isn't Rocksmith data - if it is, none of this 227 | # editing will work!). So we compare without padding. 228 | raise RSFileFormatError( 229 | f"Mismatch between original compressed payload and self check " 230 | f"reconstruction in:\n\n {fsdecode(self._file_path)}\n" 231 | ) 232 | 233 | return z_payload, len(payload) 234 | 235 | def _generate_file_data(self, self_check: bool) -> bytes: 236 | """Convert simplejson data into save file as bytes object. 237 | 238 | Arguments: 239 | self_check {bool} -- If True, the call will run a self check on file 240 | reconstructability. Only meaningful in constructor. Use True in 241 | __init__, False elsewhere. 242 | 243 | Returns: 244 | bytes -- Binary file data. 245 | 246 | Includes self checking on reconstructability. 247 | 248 | """ 249 | # compress payload is a one stop shop, converting simplejson 250 | # data to bytes and compressing it. 251 | (z_payload, payload_size) = self._compress_payload() 252 | 253 | if self_check: 254 | # only for the self check, add original padding before encrypting 255 | z_payload = b"".join([z_payload, self._check_padding]) 256 | else: 257 | # create an appropriate pad which will hopefully not break Rocksmith! 258 | z_payload = rsrpad(z_payload, ECB_BLOCK_SIZE) 259 | 260 | file_data = self._cipher.encrypt(z_payload) 261 | 262 | size_array = struct.pack(" bytes: 284 | """Return a bytes object containing the save file for the current json tree. 285 | 286 | Returns: 287 | bytes -- Binary file data. 288 | 289 | This is intended for use in managing multiple save files at once. It does not 290 | provide belt and braces backups. Use at your own risk. 291 | 292 | """ 293 | return self._generate_file_data(self_check=False) 294 | 295 | def _write_save_file(self, save_path: Path, mode: str) -> None: 296 | """Save instance data to file. 297 | 298 | Keyword Arguments: 299 | save_path {pathlib.Path} -- Save file name/path. 300 | mode {str} -- File open mode. Must be a valid binary write mode. Should be 301 | either "wb" or "xb". 302 | 303 | """ 304 | file_data = self._generate_file_data(self_check=False) 305 | 306 | with save_path.open(mode) as file_handle: 307 | file_handle.write(file_data) 308 | 309 | def overwrite_original(self) -> None: 310 | """Overwrite original save file with instance data.""" 311 | self._write_save_file(self._file_path, "wb") 312 | 313 | def save_to_new_file(self, save_path: Path) -> None: 314 | """Save instance data to a new save file. 315 | 316 | Keyword Arguments: 317 | save_path {pathlib.Path} -- Save file name/path. 318 | 319 | The method will not overwrite existing files. 320 | """ 321 | self._write_save_file(save_path, "xb") 322 | 323 | def _load_file(self) -> None: 324 | """Load save file into memory and perform preliminary integrity checks.""" 325 | with self._file_path.open("rb") as file_handle: 326 | # discard self._original_file_data after validation at end of __init__ 327 | self._original_file_data = file_handle.read() 328 | 329 | self._header = self._original_file_data[0:HEADER_BYTES] 330 | 331 | found_magic = self._header[0:4] 332 | expect_magic = b"EVAS" # cSpell:disable-line 333 | if found_magic != expect_magic: 334 | raise RSFileFormatError( 335 | f"Unexpected value in in file: \n\n {fsdecode(self._file_path)}" 336 | f"\n\nExpected '{expect_magic.decode()}' as first four bytes (magic " 337 | f"number), found '{found_magic.decode()}'." 338 | ) 339 | 340 | def _decompress_payload(self, z_payload: bytes) -> bytes: 341 | """Decompress compressed payload, and return decompressed payload. 342 | 343 | Arguments: 344 | z_payload {bytes} -- Compressed payload. 345 | 346 | Raises: 347 | RSFileFormatError -- On unexpected data/file format. 348 | 349 | Returns: 350 | bytes -- Decompressed payload with no padding. 351 | 352 | """ 353 | # decompress binary 354 | # - returns only decompressed data bytes. 355 | # That is decompress discards any garbage padding bytes at the end of stream. 356 | payload = zlib.decompress(z_payload) 357 | 358 | # We need to recover the encryption padding for self test. To do this: 359 | # - reconstruct the compressed data stream 360 | # - padding must be all data in the original compressed data past the length 361 | # of the reconstructed stream. 362 | # This is really clumsy, but is needed for reconstruction of the original file 363 | actual_z_payload_len = len(zlib.compress(payload, zlib.Z_BEST_COMPRESSION)) 364 | self._check_padding = z_payload[actual_z_payload_len:] 365 | 366 | # get size of C null (\0) terminated decrypted json string from the header 367 | # if I'm reading this correctly, 4 byte little endian value 368 | # starting from byte 16 (Thanks 0x0L) 369 | size_array = self._original_file_data[HEADER_BYTES:FIRST_PAYLOAD_BYTE] 370 | expect_payload_size = struct.unpack(" None: 383 | """Read save file into memory. 384 | 385 | Reading file takes the following steps: 386 | - Read binary file. 387 | - Extract header and decompressed payload size. 388 | - Decrypt payload. 389 | - Decompress payload to bytes. 390 | - Read the payload into a simplejson object. 391 | """ 392 | self._load_file() 393 | 394 | # create cipher for decrypting/encrypting 395 | self._cipher = AES.new(SAVE_FILE_KEY, AES.MODE_ECB) 396 | 397 | # 0x0L padded the encrypted data to 16 bytes. 398 | # However, from my quick checks, this looks unnecessary - the file data already 399 | # appears to be aligned on 16 byte blocks. I've removed this pad call, but 400 | # included a check and raise an exception if needed. 401 | z_payload = self._original_file_data[FIRST_PAYLOAD_BYTE:] 402 | 403 | if (len(z_payload) % ECB_BLOCK_SIZE) != 0: 404 | raise RSFileFormatError( 405 | f"Unexpected encrypted payload in file: " 406 | f"\n\n {fsdecode(self._file_path)}" 407 | f"\n\nPayload should be multiple of {ECB_BLOCK_SIZE} bytes, " 408 | f"found {len(z_payload) % ECB_BLOCK_SIZE} unexpected bytes." 409 | ) 410 | 411 | z_payload = self._cipher.decrypt(z_payload) 412 | 413 | payload = self._decompress_payload(z_payload) 414 | 415 | # gather _debug data for future use 416 | if self._debug: 417 | self._debug_z_payload = z_payload 418 | self._debug_payload = payload 419 | 420 | # remove trailing null from payload 421 | payload = payload[:-1] 422 | 423 | if self._json_debug_path is not None: 424 | # Note: this is the raw json as loaded, not reconstructed. 425 | # See save_json_file for reconstructed json file 426 | with self._json_debug_path.open("xt", encoding="locale") as file_handle: 427 | file_handle.write(payload.decode()) 428 | 429 | # we use simplejson because it understands decimals and will preserve number 430 | # formats in the file (required for reconstructability checks). 431 | self.json_tree = simplejson.loads(payload.decode(), use_decimal=True) 432 | 433 | def save_json_file(self, file_path: Path) -> None: 434 | """Generate json string including Ubisoft formatting and save to a file. 435 | 436 | Arguments: 437 | file_path {pathlib.Path} -- File/path for saving json data. 438 | 439 | Useful for debugging and working out changes in file formats. 440 | """ 441 | payload = self._generate_json_string() 442 | with file_path.open("xt") as file_handle: 443 | file_handle.write(payload) 444 | 445 | 446 | def self_test() -> None: 447 | """Limited self test for RSSaveFile. 448 | 449 | Run with: 450 | py -m rsrtools.files.savefile test_directory 451 | 452 | test_directory must contain one or more Rocksmith files for testing (*.crd, 453 | LocalProfiles.json, *_PRFLDB). 454 | 455 | I'd strongly recommend you **do not** run this script on any of your steam 456 | directories. 457 | """ 458 | # This test deliberately breaks several style rules and has disables sprinkled 459 | # liberally throughout. 460 | parser = argparse.ArgumentParser( 461 | description="Runs a self test of RSSaveFile on a specified directory." 462 | ) 463 | parser.add_argument( 464 | "test_directory", 465 | help="A directory containing RS save files that will be used for the " 466 | "self test.", 467 | ) 468 | test_dir = Path(parser.parse_args().test_directory).resolve(True) 469 | 470 | keep_save_file = None 471 | for child in test_dir.iterdir(): 472 | if child.is_file(): 473 | try: 474 | keep_save_file = RSSaveFile(child) 475 | print( 476 | f"Successfully loaded and validated save file '{fsdecode(child)}'." 477 | ) 478 | except Exception as exc: # pylint: disable=broad-except 479 | # probably not a save file. Provide a message and move on. 480 | print( 481 | f"Failed to load and validate file '{fsdecode(child)}'." 482 | f"\nIf this file is a Rocksmith save file, there may be a problem " 483 | f"with the RSSaveFile class." 484 | f"\nError details follow." 485 | ) 486 | print(exc) 487 | 488 | if keep_save_file is not None: 489 | test_path = keep_save_file._file_path # pylint: disable=protected-access 490 | test_path = test_path.with_suffix(test_path.suffix + ".test.tmp") 491 | 492 | if test_path.exists(): 493 | print( 494 | f"File '{fsdecode(test_path)}' exists. Save and reload test not run " 495 | f"(rename/delete existing file for test)." 496 | ) 497 | else: 498 | try: 499 | keep_save_file.save_to_new_file(test_path) 500 | print(f"Saved test file '{fsdecode(test_path)}'.") 501 | 502 | try: 503 | keep_save_file = RSSaveFile(test_path) 504 | print(" Reloaded and validated test file.") 505 | except Exception as exc: # pylint: disable=broad-except 506 | print( 507 | " Failed to reload and validate test file. Error details " 508 | "follow." 509 | ) 510 | print(exc) 511 | 512 | # clean up 513 | test_path.unlink() 514 | 515 | except Exception as exc: # pylint: disable=broad-except 516 | print( 517 | f"Failed to save test file '{fsdecode(test_path)}'.\nThere " 518 | f"may be a problem with with the RSSaveFile class. Error details " 519 | f"follow." 520 | ) 521 | print(exc) 522 | 523 | 524 | if __name__ == "__main__": 525 | self_test() 526 | -------------------------------------------------------------------------------- /src/rsrtools/importrsm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Provide a basic Rocksmith importer for set lists created by rs-manager. 3 | 4 | As the functionality of this module is straightforward and linear, I have provided 5 | it as a set of linked functions rather than multiple classes. This also allows it to 6 | provide a second service as an example of how to implement a profile editor using 7 | rsrtools profile manager facility. 8 | 9 | If the module complexity increases, I may re-implement in class form. 10 | """ 11 | 12 | # cSpell:ignore CDLC, faves, isalnum, isdigit, prfldb, profilemanager, userdata 13 | 14 | import argparse 15 | from pathlib import Path 16 | from typing import Dict, List, Optional, Tuple 17 | 18 | import simplejson 19 | 20 | from rsrtools.files.profilemanager import PROFILE_DB_STR, RSProfileManager 21 | from rsrtools.files.steam import RS_APP_ID, STEAM_REMOTE_DIR, SteamAccounts 22 | from rsrtools.songlists.config import ListField 23 | from rsrtools.songlists.database import ArrangementDB 24 | from rsrtools.utils import yes_no_dialog 25 | 26 | # We are going to use the logger all over the place. 27 | logger: "SimpleLog" 28 | 29 | 30 | class SimpleLog: 31 | """Dead simply logger, which either writes to stdout or is silent. 32 | 33 | Essentially a class wrapping an if statement. Too much effort to learn how to use 34 | the python logging properly for this module! 35 | 36 | If needed, I could extend this class to write to file. 37 | 38 | Note that this class does not trap exceptions. 39 | """ 40 | 41 | _silent: bool 42 | 43 | def __init__(self, silent: bool = False) -> None: 44 | """Construct simple logger.""" 45 | self._silent = silent 46 | 47 | def log_this(self, log: str) -> None: 48 | """Write log.""" 49 | if not self._silent: 50 | print(log) 51 | 52 | 53 | def find_paths(definitions: List[List[str]], working: Path) -> Dict[str, Path]: 54 | """Return a dictionary containing the Paths to the json files containing song lists. 55 | 56 | Arguments: 57 | definitions {List[List[song_id, file_name]]} -- The list of song list 58 | definitions. Each sublist should contain two elements: the list id, which 59 | should be one of the following string values of '1', '2', ..., '6' or 'F', 60 | and the name of the file that contains the song list as a JSON list of Song 61 | Keys. E.g. ['F', 'new_faves.json'] for a favorites song list in file 62 | new_faves.json. 63 | working {Path} -- The working directory path. 64 | 65 | Returns: 66 | {Dict[str, Path]} -- The keys are the song list ids specified in definitions 67 | and the values are the resolved paths to json song list files. 68 | 69 | """ 70 | paths: Dict[str, Path] = dict() 71 | 72 | for song_list_id, file_name in definitions: 73 | if song_list_id not in ("1", "2", "3", "4", "5", "6", "F"): 74 | raise ValueError( 75 | f"Undefined list id '{song_list_id}', should be a number from '1' to " 76 | f"'6' or 'F'." 77 | ) 78 | 79 | try: 80 | song_file = working.joinpath(file_name).resolve(True) 81 | except FileNotFoundError: 82 | # try again, but allow a fail on this one 83 | song_file = Path(file_name).resolve(True) 84 | 85 | if song_list_id in paths: 86 | logger.log_this( 87 | f"WARNING: You have specified song list '{song_list_id}' more than " 88 | f"once." 89 | f"\n Are you sure you meant to to this?" 90 | ) 91 | paths[song_list_id] = song_file 92 | logger.log_this(f"Found json file for song list '{song_list_id}'") 93 | 94 | return paths 95 | 96 | 97 | def validate_song_keys(song_lists_dict: Dict[str, List[str]], working: Path) -> None: 98 | """Validate song lists against string pattern or list of available song keys. 99 | 100 | Arguments: 101 | Dict[str, List[str]] -- A dictionary of song lists, where each list of values 102 | requires validation (validation ignores the key values). 103 | working {Path} -- A working directory that will be checked for an arrangement 104 | database. 105 | 106 | Raises: 107 | ValueError -- If any value fails. 108 | 109 | """ 110 | # get a song key list from Arrangement db if possible 111 | arr = ArrangementDB(working) 112 | if arr.has_arrangement_data: 113 | key_list: Optional[List[str]] = arr.list_validator(ListField.SONG_KEY)[ 114 | ListField.SONG_KEY 115 | ] 116 | else: 117 | key_list = None 118 | 119 | if key_list is None: 120 | # do the most basic checks possible 121 | # create translation dictionary of allowed non-alphanumeric characters 122 | # allows easy extension if needed 123 | allowed = str.maketrans({"_": None, "-": None}) 124 | 125 | for song_list_id, song_list in song_lists_dict.items(): 126 | failed = [x for x in song_list if not x.translate(allowed).isalnum()] 127 | if failed: 128 | raise ValueError( 129 | f"Song Key(s) for song list '{song_list_id}' contain invalid " 130 | f"characters." 131 | f"\n {failed}" 132 | ) 133 | 134 | else: 135 | for song_list_id, song_list in song_lists_dict.items(): 136 | failed = [x for x in song_list if x not in key_list] 137 | if failed: 138 | raise ValueError( 139 | f"Song Key(s) for song list '{song_list_id}' are not in the " 140 | f"arrangement database." 141 | f"\n {failed}" 142 | ) 143 | 144 | logger.log_this("All song lists validated.") 145 | 146 | 147 | def read_lists(paths: Dict[str, Path]) -> Dict[str, List[str]]: 148 | """Return a dictionary of song lists read from file. 149 | 150 | Arguments: 151 | paths {Dict[str, Path]} -- A dictionary of type returned by find_paths. 152 | 153 | Returns: 154 | Dict[str, List[str]] -- The keys are a string song list id ('1' to '6' or 'F'), 155 | and the value lists contains the song keys to be written to that list. 156 | 157 | """ 158 | sl_dict: Dict[str, List[str]] = dict() 159 | for song_list_id, file_path in paths.items(): 160 | logger.log_this( 161 | f"Reading file '{file_path.name}'' for song list '{song_list_id}'." 162 | ) 163 | 164 | with open(file_path, "rt", encoding="locale") as file_handle: 165 | song_list = simplejson.load(file_handle) 166 | 167 | # structure checks - could have used a schema for this. 168 | # because I'm a bit lazy here, might also fail if a song key 169 | # is pure digits and has been converted to a number on the way in 170 | # We can tidy this up if it ever happens. 171 | if not isinstance(song_list, list): 172 | raise TypeError( 173 | f"Invalid format in file '{file_path.name}'." 174 | f"\n This should be a JSON list of strings, but I found " 175 | f"a {type(song_list)}." 176 | ) 177 | 178 | for val in song_list: 179 | if not isinstance(val, str): 180 | raise TypeError( 181 | f"Invalid song list member in file '{file_path.name}'." 182 | f"\n This should be a JSON list of strings, but I found " 183 | f"a member with {type(val)}." 184 | ) 185 | 186 | # just to be sure, clean out white space and empty strings silently. 187 | song_list = [x for x in song_list if x.strip() != ""] 188 | 189 | sl_dict[song_list_id] = song_list 190 | 191 | logger.log_this("All song list files passed structure tests.") 192 | return sl_dict 193 | 194 | 195 | def get_profile_manager( 196 | user_id: Optional[str], no_interactive: bool, working: Path 197 | ) -> RSProfileManager: 198 | """Get a profile manager for the importer. 199 | 200 | Arguments: 201 | user_id {str} -- User id provided by the caller, which could be any of the 202 | steam account identifiers or None. If None, an interactive process for 203 | account id selection will be triggered. 204 | no_interactive {bool} -- If true, interactive processes are disabled and an 205 | error is raised if the caller has not provided a user id. 206 | working {Path} -- Path to the working directory. 207 | 208 | Returns: 209 | RSProfileManager -- The profile manager for user id supplied, or for the user 210 | id selected interactively. 211 | 212 | """ 213 | if user_id is None: 214 | if no_interactive: 215 | raise ValueError( 216 | "A Steam account argument must be specified for silent operation." 217 | ) 218 | 219 | else: 220 | # select account interactively 221 | profile_mgr = RSProfileManager( 222 | working, auto_setup=True, flush_working_set=True 223 | ) 224 | account_id = profile_mgr.steam_account_id 225 | 226 | else: 227 | # Find the steam account and load a profile manager, throwing exceptions 228 | # as we run into problems. 229 | accounts = SteamAccounts() 230 | account_id = accounts.find_account_id(user_id) 231 | profile_mgr = RSProfileManager( 232 | working, 233 | steam_account_id=account_id, 234 | auto_setup=True, 235 | flush_working_set=True, 236 | ) 237 | 238 | return profile_mgr 239 | 240 | 241 | def select_profile( 242 | profile_mgr: RSProfileManager, input_profile: Optional[str], no_interactive: bool 243 | ) -> str: 244 | """Select the Rocksmith profile for profile updates. 245 | 246 | Arguments: 247 | input_profile {str} -- Profile name provided by the caller, which could be a 248 | Rocksmith profile name or None. If None, an interactive process for profile 249 | selection will be triggered. 250 | no_interactive {bool} -- If true, interactive processes are disabled and an 251 | error is raised if the caller has not provided a profile name. 252 | 253 | Returns: 254 | RSProfileManager -- The selected profile name. 255 | 256 | """ 257 | if input_profile is None: 258 | if no_interactive: 259 | raise ValueError( 260 | "A profile name argument must be specified for silent operation." 261 | ) 262 | 263 | else: 264 | # select account interactively 265 | profile = profile_mgr.cl_choose_profile( 266 | header_text="Select the target profile for song list importing.", 267 | no_action_text="To exit without applying updates (raises error).", 268 | ) 269 | if not profile: 270 | raise ValueError("No profile name selected for song list import.") 271 | 272 | else: 273 | if input_profile in profile_mgr.profile_names(): 274 | profile = input_profile 275 | 276 | else: 277 | raise ValueError( 278 | f"'{input_profile}' is not a valid profile name for' Steam " 279 | f"account '{profile_mgr.steam_account_id}'" 280 | ) 281 | 282 | return profile 283 | 284 | 285 | def import_faves_by_replace( 286 | profile_mgr: RSProfileManager, profile: str, song_list_dict: Dict[str, List[str]] 287 | ) -> None: 288 | """Replace favorites song list in profile. 289 | 290 | Arguments: 291 | pm {RSProfileManager} -- Profile manager for the target Steam account. 292 | profile {str} -- Target Rocksmith profile. 293 | song_list_dict {Dict[str, List[str]]} -- Dictionary of song lists as defined in 294 | read_lists. 295 | 296 | This method demonstrates how to use the RSProfileManager.set_json_subtree method for 297 | editing data in a Rocksmith profile. Note that this method edits instance data, 298 | but doesn't save or move the profiles. 299 | 300 | """ 301 | song_list = song_list_dict.get("F", None) 302 | if song_list is not None: 303 | # We have a Favorites list to update, so let's do it! 304 | # This is a easy as it gets - replace the song list in the profile, and as a 305 | # by product, set_json_subtree marks the profile as dirty for saving. 306 | # From a python object perspective, this corresponds to the following statement: 307 | # profile_json["FavoritesListRoot"]["Favorites"] = song_list 308 | profile_mgr.set_json_subtree( 309 | profile, ("FavoritesListRoot", "FavoritesList"), song_list 310 | ) 311 | 312 | 313 | def import_song_lists_by_mutable( 314 | profile_mgr: RSProfileManager, profile: str, song_list_dict: Dict[str, List[str]] 315 | ) -> None: 316 | """Replace one or more of song lists 1 to 6 in profile. 317 | 318 | Arguments: 319 | pm {RSProfileManager} -- Profile manager for the target Steam account. 320 | profile {str} -- Target Rocksmith profile. 321 | song_list_dict {Dict[str, List[str]]} -- Dictionary of song lists as defined in 322 | read_lists. 323 | 324 | This method demonstrates how to use the RSProfileManager.get_json_subtree method for 325 | editing data in a Rocksmith profile. Note that this method edits instance data, 326 | but doesn't save or move the profiles. 327 | 328 | """ 329 | list_of_song_lists = profile_mgr.get_json_subtree( 330 | profile, ("SongListsRoot", "SongLists") 331 | ) 332 | for key, song_list in song_list_dict.items(): 333 | if key.isdigit() and 1 <= int(key) <= 6: 334 | # We have a song list to update, so let's do it! 335 | # We already have the profile list of song lists. As this is a mutable, 336 | # any changes in the lists are also reflected in the profile instance data. 337 | # So all we need to do is replace the relevant sublist with a new one. 338 | # From a python object perspective, this corresponds to the following 339 | # statements: 340 | # list_of_song_lists = profile_json["SongListsRoot"]["SongLists"] 341 | # list_of_song_lists[key-1] = song_list 342 | list_of_song_lists[int(key) - 1] = song_list 343 | 344 | # While we know we have modified the instance data, the profile manager 345 | # doesn't. So we tell it explicitly. 346 | profile_mgr.mark_as_dirty(profile) 347 | 348 | 349 | def parse_prfldb_path(raw_path: str) -> Tuple[str, str]: 350 | """Convert prfldb path into an account id and profile unique id. 351 | 352 | Arguments: 353 | path {str} -- A path as described in the argument for --prfldb-path. 354 | 355 | Returns: 356 | {Tuple[str, str]} -- A steam account id and the unique profile corresponding to 357 | the file in the path. 358 | 359 | """ 360 | # Eliminate relative path elements, get the path 361 | path = Path(raw_path).resolve(False) 362 | 363 | unique_id = path.name 364 | if not unique_id.upper().endswith(PROFILE_DB_STR.upper()): 365 | raise ValueError( 366 | f"Profile db path must end with file name " 367 | f"'*{PROFILE_DB_STR}'." 368 | ) 369 | 370 | unique_id = unique_id[: -len(PROFILE_DB_STR)] 371 | 372 | logger.log_this(f"Found unique id '{unique_id}' from path.") 373 | 374 | path = path.parent 375 | for test_id in (STEAM_REMOTE_DIR, RS_APP_ID): 376 | if path.name.upper() == test_id.upper(): 377 | path = path.parent 378 | else: 379 | raise ValueError( 380 | f"Path to profile id must have the following elements:" 381 | f"\\{RS_APP_ID}\\{STEAM_REMOTE_DIR}\\." 382 | f"\nFailed on element '{test_id}'." 383 | ) 384 | 385 | account_id = path.name 386 | if len(account_id) != 8 or not account_id.isdigit(): 387 | raise ValueError( 388 | f"Steam account id must be 8 digits. '{account_id}' is invalid." 389 | ) 390 | 391 | logger.log_this(f"Found account id '{account_id}' from path.") 392 | 393 | return account_id, unique_id 394 | 395 | 396 | def main() -> None: 397 | """Provide command line entry point for rsm importer.""" 398 | parser = argparse.ArgumentParser( 399 | description="Command line interface for loading song lists/set lists generated " 400 | "by rs-manager into Rocksmith." 401 | ) 402 | parser.add_argument( 403 | "working_dir", 404 | help="Working directory for working files and arrangement database (if any).", 405 | ) 406 | 407 | parser.add_argument( 408 | "-a", 409 | "--account-id", 410 | help="Specify the steam account id for the Rocksmith profile. This can be the " 411 | "the account alias/profile (probably easiest to find - this is the name next " 412 | "to the Community menu on the steam interface), the steam account login name, " 413 | "an 8 digit account id, or a 17 digit steam id. If omitted, an interactive " 414 | "selection will be triggered.", 415 | metavar="", 416 | ) 417 | 418 | parser.add_argument( 419 | "-p", 420 | "--profile", 421 | help="The name of the profile that the song list will be written to. If " 422 | "omitted, an interactive selection will be triggered.", 423 | metavar="", 424 | ) 425 | 426 | parser.add_argument( 427 | "--prfldb-path", 428 | help="The path to a target Rocksmith profile. This option is provided as a " 429 | "helper function for rs-manager. The path should be of the form: " 430 | "/userdata//221680/remote/. Everything " 431 | "up to to ...userdata/ is optional and will be ignored. The profile name must " 432 | "end in _prfldb (case insensitive). If the path does not correspond to a valid " 433 | "account and profile, an exception will be raised. Finally, the prfldb_path " 434 | "option cannot be used with either --profile or --account-id.", 435 | metavar="", 436 | ) 437 | 438 | parser.add_argument( 439 | "--no-check", 440 | help="If specified, the importer will not check the song keys in the import " 441 | "file. Otherwise the importer will check the song keys against either: an " 442 | "arrangement database (if available), or against a basic character pattern " 443 | "(currently a-zA-Z0-9_-). This may be useful if you have CDLC that has " 444 | "accented characters or other characters outside the character pattern (if " 445 | "so, please raise a github issue or PR so we can update the pattern).", 446 | action="store_true", 447 | ) 448 | 449 | parser.add_argument( 450 | "--silent", 451 | help="If specified, the importer will not log progress and will not ask the " 452 | "you to confirm the update to the Rocksmith profile. Silent mode will also " 453 | "disable interactive account and profile selection (i.e. these arguments" 454 | "must be specified if silent is specified).", 455 | action="store_true", 456 | ) 457 | 458 | parser.add_argument( 459 | "-sl", 460 | "--song-list", 461 | nargs=2, # cSpell:disable-line 462 | action="append", 463 | help="Specifies a song list replacement. list_id specifies the song list to " 464 | "replace and must be either the letter 'F' for Favorites or a number from " 465 | "1 to 6 for a numbered song list. song_file is the file containing the song " 466 | "keys for the new song list. If the file is in the working directory, it " 467 | "should be the file name without any path information. Otherwise, it should be " 468 | "the full path to the file. This must be a text file containing a single JSON " 469 | "form list of the song keys for the song list. That is, the file contents " 470 | 'should look like ["", "", ", ..., ""]. This is ' 471 | "the structure of files exported by rs-manager. This argument can be repeated, " 472 | "so that you can update all 6 song lists and Favorites in single call. Note " 473 | "that you can repeat a list_id - if you do, the corresponding song list will " 474 | "be overwritten again without warning and order of writing cannot be " 475 | "guaranteed. Lastly, this is an optional argument, but if you don't make at " 476 | "least one song list specification, this program will do nothing.", 477 | metavar=("list_id", "song_file"), 478 | ) 479 | 480 | args = parser.parse_args() 481 | 482 | # Share the logger everywhere. 483 | global logger # pylint: disable=global-statement 484 | logger = SimpleLog(args.silent) 485 | 486 | if args.song_list is None: 487 | logger.log_this("No song lists specified, so I can't do anything.") 488 | return 489 | 490 | working = Path(args.working_dir).resolve(True) 491 | 492 | # resolve file names into paths. 493 | paths = find_paths(args.song_list, working) 494 | 495 | # read song list and do basic validation 496 | song_list_dict = read_lists(paths) 497 | 498 | if not args.no_check: 499 | # do more detailed checks on the song lists. 500 | validate_song_keys(song_list_dict, working) 501 | 502 | # Now we have a valid set of song lists, so the next thing is to get the 503 | # steam account id and set up the profile manager. 504 | if args.prfldb_path is not None and ( 505 | args.account_id is not None or args.profile is not None 506 | ): 507 | raise ValueError( 508 | "--prfldb-path can't be used at the same time as either --account-id " 509 | "or --profile.\n (--account-id and --profile can be used together.)" 510 | ) 511 | 512 | if args.prfldb_path is not None: 513 | account_id, unique_id = parse_prfldb_path(args.prfldb_path) 514 | 515 | else: 516 | account_id = args.account_id 517 | unique_id = "" 518 | 519 | profile_mgr = get_profile_manager(account_id, args.silent, working) 520 | 521 | if unique_id: 522 | profile = profile_mgr.unique_id_to_profile(unique_id) 523 | logger.log_this(f"Unique id '{unique_id}' corresponds to profile '{profile}'.") 524 | else: 525 | profile = select_profile(profile_mgr, args.profile, args.silent) 526 | 527 | logger.log_this( 528 | f"Loaded Steam account " 529 | f"{profile_mgr.steam_description(profile_mgr.steam_account_id)}" 530 | ) 531 | logger.log_this(f"Selected profile '{profile}' for song list updates.") 532 | 533 | # and now we can write the updates. 534 | # This could all be done in the one routine, but I wanted to demonstrate 535 | # the two different ways of writing data to a profile 536 | 537 | import_faves_by_replace(profile_mgr, profile, song_list_dict) 538 | 539 | import_song_lists_by_mutable(profile_mgr, profile, song_list_dict) 540 | 541 | # Save the files into the update folder in the working directory 542 | # and then move them to the Steam account 543 | dialog = ( 544 | f"Please confirm that you want to update song lists in profile '{profile}' of " 545 | f"Steam account:" 546 | f"\n{profile_mgr.steam_description(profile_mgr.steam_account_id)}" 547 | ) 548 | 549 | if args.silent or yes_no_dialog(dialog): 550 | profile_mgr.write_files() 551 | profile_mgr.move_updates_to_steam(profile_mgr.steam_account_id) 552 | 553 | 554 | if __name__ == "__main__": 555 | main() 556 | -------------------------------------------------------------------------------- /src/rsrtools/songlists/configclasses.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Provide song list creator configuration dataclasses and supporting elements.""" 4 | 5 | # cSpell: ignore pydantic, parameterise 6 | 7 | from enum import Enum 8 | from pathlib import Path 9 | from typing import Dict, List, Tuple, Union, Optional 10 | import tomllib 11 | 12 | from dataclasses import field, asdict, replace # cSpell: disable-line 13 | from pydantic import ConfigDict 14 | from pydantic.dataclasses import dataclass 15 | 16 | import tomli_w 17 | 18 | from rsrtools import __version__ as RSRTOOLS_VERSION 19 | from rsrtools.songlists.config import RangeField, ListField, SQLField 20 | 21 | # SQL Clause type alias - SQL text + Value tuple for substitution 22 | SQLClause = Tuple[str, Tuple[Union[str, int, float], ...]] 23 | 24 | # These string constants are used to parameterise the default TOML. 25 | # They should be the same as the attribute names in the dataclasses below. 26 | CONFIG_SONG_LISTS = "song_list_sets" 27 | CONFIG_FILTERS = "filters" 28 | CONFIG_BASE = "base" 29 | CONFIG_MODE = "mode" 30 | CONFIG_SUB_FILTERS = "sub_filters" 31 | CONFIG_INCLUDE = "include" 32 | CONFIG_RANGES = "ranges" 33 | CONFIG_VALUES = "values" 34 | 35 | # Set up the default TOML. In this format for readability and to test the toml loader. 36 | # Note the substitutions which are intended as helpers in case of future field renaming. 37 | # pylint directive because of issue with sub-classed enums. 38 | # pylint: disable=no-member 39 | DEFAULT_TOML = f"""\ 40 | [{CONFIG_SONG_LISTS}] 41 | "E Standard" = [ 42 | "E Std Low Plays", 43 | "E Std Mid Plays", 44 | "E Std High Plays", 45 | "E Std Non Concert", 46 | "E Std with Bonus or Alternate", 47 | "Easy E Std Plat Badge in progress", 48 | ] 49 | 50 | "Non E Std Tunings" = [ 51 | "Drop D", 52 | "Eb Standard", 53 | "Eb Drop Db", 54 | "D Standard", 55 | "D Drop C", 56 | "Other Tunings", 57 | ] 58 | 59 | "Bass or Rhythm" = [ 60 | "B or R E Low Plays", 61 | "B or R E Mid Plays", 62 | "B or R E High Plays", 63 | "", 64 | "", 65 | "Easy Plat Badge in progress", 66 | ] 67 | 68 | "Standard Tunings" = [ 69 | "Standard Low Plays", 70 | "Standard Mid Plays", 71 | "Standard High Plays", 72 | "Standard Off 440 Tunings", 73 | "With Bonus or Alternate", 74 | "Easy Plat Badge in progress", 75 | ] 76 | 77 | "Drop Tunings" = [ 78 | "Drop Low Plays", 79 | "Drop Mid Plays", 80 | "Drop High Plays", 81 | "Drop Off 440 Tunings", 82 | "Non standard Tunings", 83 | "Easy Plat Badge in progress", 84 | ] 85 | 86 | Testing = [ 87 | "Artist test", 88 | "Played Count of 0 to 15", 89 | ] 90 | 91 | "Recursive_2_test" = ["Recursive2A"] 92 | 93 | "Recursive_3_test" = ["Recursive1A"] 94 | 95 | [{CONFIG_FILTERS}."Easy Plat Badge in progress"] 96 | {CONFIG_BASE} = "" 97 | {CONFIG_MODE} = "AND" 98 | 99 | [{CONFIG_FILTERS}."Easy Plat Badge in progress".{CONFIG_SUB_FILTERS}\ 100 | .{RangeField.SA_EASY_BADGES.value}] 101 | {CONFIG_INCLUDE} = true 102 | {CONFIG_RANGES} = [ [ 1, 4] ] 103 | 104 | [{CONFIG_FILTERS}."Easy E Std Plat Badge in progress"] 105 | {CONFIG_BASE} = "Easy Plat Badge in progress" 106 | {CONFIG_MODE} = "AND" 107 | 108 | [{CONFIG_FILTERS}."Easy E Std Plat Badge in progress".{CONFIG_SUB_FILTERS}\ 109 | .{ListField.TUNING.value}] 110 | {CONFIG_INCLUDE} = true 111 | {CONFIG_VALUES} = [ "E Standard" ] 112 | 113 | [{CONFIG_FILTERS}."Med Plat Badge in progress"] 114 | {CONFIG_BASE} = "" 115 | {CONFIG_MODE} = "AND" 116 | 117 | [{CONFIG_FILTERS}."Med Plat Badge in progress".{CONFIG_SUB_FILTERS}\ 118 | .{RangeField.SA_MEDIUM_BADGES.value}] 119 | {CONFIG_INCLUDE} = true 120 | {CONFIG_RANGES} = [ [ 1, 4] ] 121 | 122 | [{CONFIG_FILTERS}."Easy Plat Badges"] 123 | {CONFIG_BASE} = "" 124 | {CONFIG_MODE} = "AND" 125 | 126 | [{CONFIG_FILTERS}."Easy Plat Badges".{CONFIG_SUB_FILTERS}\ 127 | .{RangeField.SA_EASY_BADGES.value}] 128 | {CONFIG_INCLUDE} = true 129 | {CONFIG_RANGES} = [ [ 5, 50] ] 130 | 131 | [{CONFIG_FILTERS}."Hard Plat Badges"] 132 | {CONFIG_BASE} = "" 133 | {CONFIG_MODE} = "AND" 134 | 135 | [{CONFIG_FILTERS}."Hard Plat Badges".{CONFIG_SUB_FILTERS}\ 136 | .{RangeField.SA_HARD_BADGES.value}] 137 | {CONFIG_INCLUDE} = true 138 | {CONFIG_RANGES} = [ [ 5, 50] ] 139 | 140 | [{CONFIG_FILTERS}."With Bonus or Alternate"] 141 | {CONFIG_BASE} = "Lead-ish" 142 | {CONFIG_MODE} = "AND" 143 | 144 | [{CONFIG_FILTERS}."With Bonus or Alternate".{CONFIG_SUB_FILTERS}\ 145 | .{ListField.SUB_PATH.value}] 146 | {CONFIG_INCLUDE} = false 147 | {CONFIG_VALUES} = ["Representative"] 148 | 149 | [{CONFIG_FILTERS}."E Std with Bonus or Alternate"] 150 | {CONFIG_BASE} = "Lead-ish" 151 | {CONFIG_MODE} = "AND" 152 | 153 | [{CONFIG_FILTERS}."E Standard with Bonus or Alternate".{CONFIG_SUB_FILTERS}.\ 154 | {ListField.TUNING.value}] 155 | {CONFIG_INCLUDE} = true 156 | {CONFIG_VALUES} = ["E Standard"] 157 | 158 | [{CONFIG_FILTERS}."E Std with Bonus or Alternate".{CONFIG_SUB_FILTERS}\ 159 | .{ListField.SUB_PATH.value}] 160 | {CONFIG_INCLUDE} = false 161 | {CONFIG_VALUES} = ["Representative"] 162 | 163 | [{CONFIG_FILTERS}."E Standard"] 164 | {CONFIG_BASE} = "Representative Lead-ish" 165 | {CONFIG_MODE} = "AND" 166 | 167 | [{CONFIG_FILTERS}."E Standard".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 168 | {CONFIG_INCLUDE} = true 169 | {CONFIG_VALUES} = ["E Standard"] 170 | 171 | [{CONFIG_FILTERS}."E Standard 440"] 172 | {CONFIG_BASE} = "E Standard" 173 | {CONFIG_MODE} = "AND" 174 | 175 | [{CONFIG_FILTERS}."E Standard 440".{CONFIG_SUB_FILTERS}.{RangeField.PITCH.value}] 176 | {CONFIG_INCLUDE} = true 177 | {CONFIG_RANGES} = [ [439.5, 440.5 ] ] 178 | 179 | [{CONFIG_FILTERS}."E Std Low Plays"] 180 | {CONFIG_BASE} = "E Standard 440" 181 | {CONFIG_MODE} = "AND" 182 | 183 | [{CONFIG_FILTERS}."E Std Low Plays".{CONFIG_SUB_FILTERS}\ 184 | .{RangeField.PLAYED_COUNT.value}] 185 | {CONFIG_INCLUDE} = true 186 | {CONFIG_RANGES} = [[0, 12]] 187 | 188 | [{CONFIG_FILTERS}."E Std Mid Plays"] 189 | {CONFIG_BASE} = "E Standard 440" 190 | {CONFIG_MODE} = "AND" 191 | 192 | [{CONFIG_FILTERS}."E Std Mid Plays".{CONFIG_SUB_FILTERS}\ 193 | .{RangeField.PLAYED_COUNT.value}] 194 | {CONFIG_INCLUDE} = true 195 | {CONFIG_RANGES} = [[13,27]] 196 | 197 | [{CONFIG_FILTERS}."E Std High Plays"] 198 | {CONFIG_BASE} = "E Standard 440" 199 | {CONFIG_MODE} = "AND" 200 | 201 | [{CONFIG_FILTERS}."E Std High Plays".{CONFIG_SUB_FILTERS}\ 202 | .{RangeField.PLAYED_COUNT.value}] 203 | {CONFIG_INCLUDE} = true 204 | {CONFIG_RANGES} = [[28, 5000]] 205 | 206 | [{CONFIG_FILTERS}."E Std Non Concert"] 207 | {CONFIG_BASE} = "E Standard" 208 | {CONFIG_MODE} = "AND" 209 | 210 | [{CONFIG_FILTERS}."E Std Non Concert".{CONFIG_SUB_FILTERS}.{RangeField.PITCH.value}] 211 | {CONFIG_INCLUDE} = false 212 | {CONFIG_RANGES} = [[439.5, 440.5]] 213 | 214 | [{CONFIG_FILTERS}."E Std Non Concert".{CONFIG_SUB_FILTERS}\ 215 | .{RangeField.PLAYED_COUNT.value}] 216 | {CONFIG_INCLUDE} = true 217 | {CONFIG_RANGES} = [[0,5000]] 218 | 219 | [{CONFIG_FILTERS}."Drop D"] 220 | {CONFIG_BASE} = "Representative Lead-ish" 221 | {CONFIG_MODE} = "AND" 222 | 223 | [{CONFIG_FILTERS}."Drop D".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 224 | {CONFIG_INCLUDE} = true 225 | {CONFIG_VALUES} = ["Drop D"] 226 | 227 | [{CONFIG_FILTERS}."Eb Standard"] 228 | {CONFIG_BASE} = "Representative Lead-ish" 229 | {CONFIG_MODE} = "AND" 230 | 231 | [{CONFIG_FILTERS}."Eb Standard".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 232 | {CONFIG_INCLUDE} = true 233 | {CONFIG_VALUES} = ["Eb Standard"] 234 | 235 | [{CONFIG_FILTERS}."Eb Drop Db"] 236 | {CONFIG_BASE} = "Representative Lead-ish" 237 | {CONFIG_MODE} = "AND" 238 | 239 | [{CONFIG_FILTERS}."Eb Drop Db".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 240 | {CONFIG_INCLUDE} = true 241 | {CONFIG_VALUES} = ["Eb Drop Db"] 242 | 243 | [{CONFIG_FILTERS}."D Standard"] 244 | {CONFIG_BASE} = "Representative Lead-ish" 245 | {CONFIG_MODE} = "AND" 246 | 247 | [{CONFIG_FILTERS}."D Standard".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 248 | {CONFIG_INCLUDE} = true 249 | {CONFIG_VALUES} = ["D Standard"] 250 | 251 | [{CONFIG_FILTERS}."D Drop C"] 252 | {CONFIG_BASE} = "Representative Lead-ish" 253 | {CONFIG_MODE} = "AND" 254 | 255 | [{CONFIG_FILTERS}."D Drop C".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 256 | {CONFIG_INCLUDE} = true 257 | {CONFIG_VALUES} = ["D Drop C"] 258 | 259 | [{CONFIG_FILTERS}."Other Tunings"] 260 | {CONFIG_BASE} = "Representative Lead-ish" 261 | {CONFIG_MODE} = "AND" 262 | 263 | [{CONFIG_FILTERS}."Other Tunings".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 264 | {CONFIG_INCLUDE} = false 265 | {CONFIG_VALUES} = [ 266 | "E Standard", "Drop D", "Eb Standard", "Eb Drop Db", "D Standard", "D Drop C" 267 | ] 268 | 269 | # This all the standard (non-drop) starting at E Standard and dropping from there. 270 | # Good for use with a drop tuning pedal. 271 | [{CONFIG_FILTERS}."Standard Tunings"] 272 | {CONFIG_BASE} = "Representative Lead-ish" 273 | {CONFIG_MODE} = "AND" 274 | 275 | [{CONFIG_FILTERS}."Standard Tunings".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 276 | {CONFIG_INCLUDE} = true 277 | {CONFIG_VALUES} = [ 278 | "E Standard", "Eb Standard", "D Standard", "C# Standard", "C Standard", 279 | "B Standard", "Bb Standard", "A Standard", 280 | ] 281 | 282 | [{CONFIG_FILTERS}."Standard 440 Tunings"] 283 | {CONFIG_BASE} = "Standard Tunings" 284 | {CONFIG_MODE} = "AND" 285 | 286 | [{CONFIG_FILTERS}."Standard 440 Tunings".{CONFIG_SUB_FILTERS}.{RangeField.PITCH.value}] 287 | {CONFIG_INCLUDE} = true 288 | {CONFIG_RANGES} = [ [439.5, 440.5 ] ] 289 | 290 | [{CONFIG_FILTERS}."Standard Off 440 Tunings"] 291 | {CONFIG_BASE} = "Standard Tunings" 292 | {CONFIG_MODE} = "AND" 293 | 294 | [{CONFIG_FILTERS}."Standard Off 440 Tunings".{CONFIG_SUB_FILTERS}.\ 295 | {RangeField.PITCH.value}] 296 | {CONFIG_INCLUDE} = false 297 | {CONFIG_RANGES} = [ [439.5, 440.5 ] ] 298 | 299 | # This all the standard drop tunings starting at Drop D and dropping from there. 300 | # Good for use with a drop tuning pedal. 301 | [{CONFIG_FILTERS}."Drop Tunings"] 302 | {CONFIG_BASE} = "Representative Lead-ish" 303 | {CONFIG_MODE} = "AND" 304 | 305 | [{CONFIG_FILTERS}."Drop Tunings".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 306 | {CONFIG_INCLUDE} = true 307 | {CONFIG_VALUES} = [ 308 | "Drop D", "Eb Drop Db", "D Drop C", "C# Drop B", "C Drop A#", "B Drop A", 309 | "Bb Drop Ab", "A Drop G", 310 | ] 311 | 312 | [{CONFIG_FILTERS}."Drop 440 Tunings"] 313 | {CONFIG_BASE} = "Drop Tunings" 314 | {CONFIG_MODE} = "AND" 315 | 316 | [{CONFIG_FILTERS}."Drop 440 Tunings".{CONFIG_SUB_FILTERS}.{RangeField.PITCH.value}] 317 | {CONFIG_INCLUDE} = true 318 | {CONFIG_RANGES} = [ [439.5, 440.5 ] ] 319 | 320 | [{CONFIG_FILTERS}."Drop Off 440 Tunings"] 321 | {CONFIG_BASE} = "Drop Tunings" 322 | {CONFIG_MODE} = "AND" 323 | 324 | [{CONFIG_FILTERS}."Drop Off 440 Tunings".{CONFIG_SUB_FILTERS}.{RangeField.PITCH.value}] 325 | {CONFIG_INCLUDE} = false 326 | {CONFIG_RANGES} = [ [439.5, 440.5 ] ] 327 | 328 | # This anything that doesn't fit the two "standard tunings above" 329 | # Good for use with a drop tuning pedal. 330 | [{CONFIG_FILTERS}."Non standard Tunings"] 331 | {CONFIG_BASE} = "Representative Lead-ish" 332 | {CONFIG_MODE} = "AND" 333 | 334 | [{CONFIG_FILTERS}."Non standard Tunings".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 335 | {CONFIG_INCLUDE} = false 336 | {CONFIG_VALUES} = [ 337 | "E Standard", "Eb Standard", "D Standard", "C# Standard", "C Standard", 338 | "B Standard", "Bb Standard", "A Standard", 339 | "Drop D", "Eb Drop Db", "D Drop C", "C# Drop B", "C Drop A#", "B Drop A", 340 | "Bb Drop Ab", "A Drop G", 341 | ] 342 | 343 | [{CONFIG_FILTERS}."Standard Low Plays"] 344 | {CONFIG_BASE} = "Standard 440 Tunings" 345 | {CONFIG_MODE} = "AND" 346 | 347 | [{CONFIG_FILTERS}."Standard Low Plays".{CONFIG_SUB_FILTERS}\ 348 | .{RangeField.PLAYED_COUNT.value}] 349 | {CONFIG_INCLUDE} = true 350 | {CONFIG_RANGES} = [[0, 12]] 351 | 352 | [{CONFIG_FILTERS}."Standard Mid Plays"] 353 | {CONFIG_BASE} = "Standard 440 Tunings" 354 | {CONFIG_MODE} = "AND" 355 | 356 | [{CONFIG_FILTERS}."Standard Mid Plays".{CONFIG_SUB_FILTERS}\ 357 | .{RangeField.PLAYED_COUNT.value}] 358 | {CONFIG_INCLUDE} = true 359 | {CONFIG_RANGES} = [[13, 27]] 360 | 361 | [{CONFIG_FILTERS}."Standard High Plays"] 362 | {CONFIG_BASE} = "Standard 440 Tunings" 363 | {CONFIG_MODE} = "AND" 364 | 365 | [{CONFIG_FILTERS}."Standard High Plays".{CONFIG_SUB_FILTERS}\ 366 | .{RangeField.PLAYED_COUNT.value}] 367 | {CONFIG_INCLUDE} = true 368 | {CONFIG_RANGES} = [[28, 5000]] 369 | 370 | [{CONFIG_FILTERS}."Drop Low Plays"] 371 | {CONFIG_BASE} = "Drop 440 Tunings" 372 | {CONFIG_MODE} = "AND" 373 | 374 | [{CONFIG_FILTERS}."Drop Low Plays".{CONFIG_SUB_FILTERS}\ 375 | .{RangeField.PLAYED_COUNT.value}] 376 | {CONFIG_INCLUDE} = true 377 | {CONFIG_RANGES} = [[0, 12]] 378 | 379 | [{CONFIG_FILTERS}."Drop Mid Plays"] 380 | {CONFIG_BASE} = "Drop 440 Tunings" 381 | {CONFIG_MODE} = "AND" 382 | 383 | [{CONFIG_FILTERS}."Drop Mid Plays".{CONFIG_SUB_FILTERS}\ 384 | .{RangeField.PLAYED_COUNT.value}] 385 | {CONFIG_INCLUDE} = true 386 | {CONFIG_RANGES} = [[13, 27]] 387 | 388 | [{CONFIG_FILTERS}."Drop High Plays"] 389 | {CONFIG_BASE} = "Drop 440 Tunings" 390 | {CONFIG_MODE} = "AND" 391 | 392 | [{CONFIG_FILTERS}."Drop High Plays".{CONFIG_SUB_FILTERS}\ 393 | .{RangeField.PLAYED_COUNT.value}] 394 | {CONFIG_INCLUDE} = true 395 | {CONFIG_RANGES} = [[28, 5000]] 396 | 397 | [{CONFIG_FILTERS}."Played Count of 0 to 15"] 398 | {CONFIG_BASE} = "" 399 | {CONFIG_MODE} = "AND" 400 | 401 | [{CONFIG_FILTERS}."Played Count of 0 to 15".{CONFIG_SUB_FILTERS}\ 402 | .{RangeField.PLAYED_COUNT.value}] 403 | {CONFIG_INCLUDE} = true 404 | {CONFIG_RANGES} = [[0, 15]] 405 | 406 | [{CONFIG_FILTERS}."Artist test"] 407 | {CONFIG_BASE} = "" 408 | {CONFIG_MODE} = "AND" 409 | 410 | [{CONFIG_FILTERS}."Artist test".{CONFIG_SUB_FILTERS}.{ListField.ARTIST.value}] 411 | {CONFIG_INCLUDE} = true 412 | {CONFIG_VALUES} = ["The Rolling Stones", "Franz Ferdinand"] 413 | 414 | [{CONFIG_FILTERS}."Lead-ish"] 415 | {CONFIG_BASE} = "" 416 | {CONFIG_MODE} = "OR" 417 | 418 | [{CONFIG_FILTERS}."Lead-ish".{CONFIG_SUB_FILTERS}\ 419 | .{ListField.PATH.value}] 420 | {CONFIG_INCLUDE} = true 421 | {CONFIG_VALUES} = ["Lead"] 422 | 423 | # This captures songs that have Rhythm and/or Bass, but no lead arrangement 424 | [{CONFIG_FILTERS}."Lead-ish".{CONFIG_SUB_FILTERS}\ 425 | .{ListField.TITLE.value}] 426 | {CONFIG_INCLUDE} = true 427 | {CONFIG_VALUES} = [ 428 | "Should I Stay or Should I Go", 429 | "What's Going On", 430 | "Blister in the Sun" 431 | ] 432 | 433 | [{CONFIG_FILTERS}."Representative Lead-ish"] 434 | {CONFIG_BASE} = "Lead-ish" 435 | {CONFIG_MODE} = "OR" 436 | 437 | [{CONFIG_FILTERS}."Representative Lead-ish".{CONFIG_SUB_FILTERS}\ 438 | .{ListField.SUB_PATH.value}] 439 | {CONFIG_INCLUDE} = true 440 | {CONFIG_VALUES} = ["Representative"] 441 | 442 | # basic test filters for Bass, Rhythm. 443 | [{CONFIG_FILTERS}."Bass or Rhythm"] 444 | {CONFIG_BASE} = "" 445 | {CONFIG_MODE} = "OR" 446 | 447 | [{CONFIG_FILTERS}."Bass or Rhythm".{CONFIG_SUB_FILTERS}\ 448 | .{ListField.PATH.value}] 449 | {CONFIG_INCLUDE} = true 450 | {CONFIG_VALUES} = ["Bass", "Rhythm"] 451 | 452 | [{CONFIG_FILTERS}."B or R E 440"] 453 | {CONFIG_BASE} = "Bass or Rhythm" 454 | {CONFIG_MODE} = "AND" 455 | 456 | [{CONFIG_FILTERS}."B or R E 440".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 457 | {CONFIG_INCLUDE} = true 458 | {CONFIG_VALUES} = ["E Standard"] 459 | 460 | [{CONFIG_FILTERS}."B or R E 440".{CONFIG_SUB_FILTERS}.{RangeField.PITCH.value}] 461 | {CONFIG_INCLUDE} = true 462 | {CONFIG_RANGES} = [ [439.5, 440.5 ] ] 463 | 464 | [{CONFIG_FILTERS}."B or R E Low Plays"] 465 | {CONFIG_BASE} = "B or R E 440" 466 | {CONFIG_MODE} = "AND" 467 | 468 | [{CONFIG_FILTERS}."B or R E Low Plays".{CONFIG_SUB_FILTERS}\ 469 | .{RangeField.PLAYED_COUNT.value}] 470 | {CONFIG_INCLUDE} = true 471 | {CONFIG_RANGES} = [[0, 12]] 472 | 473 | [{CONFIG_FILTERS}."B or R E Mid Plays"] 474 | {CONFIG_BASE} = "B or R E 440" 475 | {CONFIG_MODE} = "AND" 476 | 477 | [{CONFIG_FILTERS}."B or R E Mid Plays".{CONFIG_SUB_FILTERS}\ 478 | .{RangeField.PLAYED_COUNT.value}] 479 | {CONFIG_INCLUDE} = true 480 | {CONFIG_RANGES} = [[13,27]] 481 | 482 | [{CONFIG_FILTERS}."B or R E High Plays"] 483 | {CONFIG_BASE} = "B or R E 440" 484 | {CONFIG_MODE} = "AND" 485 | 486 | [{CONFIG_FILTERS}."B or R E High Plays".{CONFIG_SUB_FILTERS}\ 487 | .{RangeField.PLAYED_COUNT.value}] 488 | {CONFIG_INCLUDE} = true 489 | {CONFIG_RANGES} = [[28, 5000]] 490 | 491 | # recursion testing 492 | [{CONFIG_FILTERS}."Recursive1A"] 493 | {CONFIG_BASE} = "Recursive1B" 494 | {CONFIG_MODE} = "AND" 495 | 496 | [{CONFIG_FILTERS}."Recursive1A".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 497 | {CONFIG_INCLUDE} = true 498 | {CONFIG_VALUES} = ["D Drop C"] 499 | 500 | [{CONFIG_FILTERS}."Recursive1B"] 501 | {CONFIG_BASE} = "Recursive1C" 502 | {CONFIG_MODE} = "AND" 503 | 504 | [{CONFIG_FILTERS}."Recursive1B".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 505 | {CONFIG_INCLUDE} = true 506 | {CONFIG_VALUES} = ["D Drop C"] 507 | 508 | [{CONFIG_FILTERS}."Recursive1C"] 509 | {CONFIG_BASE} = "Recursive1A" 510 | {CONFIG_MODE} = "AND" 511 | 512 | [{CONFIG_FILTERS}."Recursive1C".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 513 | {CONFIG_INCLUDE} = true 514 | {CONFIG_VALUES} = ["D Drop C"] 515 | 516 | [{CONFIG_FILTERS}."Recursive2A"] 517 | {CONFIG_BASE} = "Recursive2B" 518 | {CONFIG_MODE} = "AND" 519 | 520 | [{CONFIG_FILTERS}."Recursive2A".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 521 | {CONFIG_INCLUDE} = true 522 | {CONFIG_VALUES} = ["D Drop C"] 523 | 524 | [{CONFIG_FILTERS}."Recursive2B"] 525 | {CONFIG_BASE} = "Recursive2A" 526 | {CONFIG_MODE} = "AND" 527 | 528 | [{CONFIG_FILTERS}."Recursive2B".{CONFIG_SUB_FILTERS}.{ListField.TUNING.value}] 529 | {CONFIG_INCLUDE} = true 530 | {CONFIG_VALUES} = ["D Drop C"] 531 | """ 532 | # pylint: enable=no-member 533 | 534 | 535 | class RSFilterError(Exception): 536 | """Provide base exception for song list filtering/SQL classes.""" 537 | 538 | def __init__(self, message: Optional[str] = None) -> None: 539 | """Provide base exception for song list filtering/SQL classes.""" 540 | if message is None: 541 | message = "An unspecified Rocksmith Filter Error has occurred." 542 | super().__init__(message) 543 | 544 | 545 | class FilterMode(Enum): 546 | """Logical mode for combining sub-filters in a filter.""" 547 | 548 | AND = "AND" 549 | OR = "OR" 550 | 551 | 552 | @dataclass 553 | class Settings: 554 | """General settings for song list creator. 555 | 556 | Public attributes: 557 | CFSM_file_path {str} -- The string form path the Customs Forge Song Manager 558 | arrangements file, or the empty string if it has not been set. Deprecated, 559 | due for removal. 560 | steam_account_id {str} -- String representation of Steam account id, or 561 | the empty string if it has not been set yet. 562 | player_profile {str} -- The player profile name. Get returns the empty string if 563 | the player profile it has not been set yet. 564 | version {str} -- Future functionality for configuration changes. 565 | dlc_mtime {float} -- The last modified time of the most recently modified dlc 566 | found in the last scan for arrangement data. Used for checks for new dlc 567 | and for scans to update (rather than rebuild) the database. 568 | 569 | Note: changes in attribute names should be reflected in default TOML. 570 | """ 571 | 572 | # instance variables 573 | CFSM_file_path: str = "" # pylint: disable=invalid-name 574 | steam_account_id: str = "" 575 | player_profile: str = "" 576 | version: str = "" 577 | dlc_mtime: float = -1.0 578 | 579 | 580 | @dataclass 581 | class SubFilter: 582 | """Super class for sub-filters. This should not be instantiated. 583 | 584 | Public attributes: 585 | include {bool} -- Inclusion/exclusion criteria for filters. See subclasses for 586 | implementation specifics. 587 | 588 | Note: changes in attribute names should be reflected in default TOML. 589 | """ 590 | 591 | # instance variables 592 | include: bool 593 | 594 | 595 | @dataclass 596 | class RangeSubFilter(SubFilter): 597 | """Range list for a range sub-filter. 598 | 599 | Public attributes/methods: 600 | ranges {List[List[float]]} -- A list of low/high value range pairs of the form: 601 | [[low1, high1], [low2, high2], ...] 602 | range_clause: returns the range clause and values tuple for the filter. 603 | 604 | The low/high pairs are used to build SQL IN BETWEEN queries. 605 | 606 | Implementation note: Pydantic will convert integer values to floats as part of 607 | constructor input validation. The range_clause method will try to convert integer 608 | values back to integer form before generating the range clause. However, if you 609 | are getting odd results from integer range queries, you may want to switch to 610 | floating values with appropriate small margins to insure integer values are 611 | captured correctly. For example [0.99, 2.01] to capture integer values in the 612 | range 1 to 2 inclusive. A future update may address this issues (requires 613 | pydantic to support dataclasses validators (0.24+?), converting the type of ranges 614 | to List[List[Union[int, float]]] and adding the appropriate validator). 615 | 616 | include implementation -- If True if the filter will return records where the 617 | field value lies int the specified ranges. If False, it will return records 618 | where the field value lies outside the specific ranges. 619 | 620 | Note: changes in attribute names should be reflected in default TOML. 621 | """ 622 | 623 | # instance variables 624 | # When pydantic supports dataclass validators, change this to 625 | # ranges: List[List[Union[int, float]]] 626 | ranges: List[List[float]] 627 | 628 | def range_clause( 629 | self, field_name: RangeField 630 | ) -> Tuple[str, List[Union[float, int]]]: 631 | """Create (positive) range field SQL clause with limited validation. 632 | 633 | Arguments: 634 | field_type {RangeField} -- The range field target for the clause. 635 | 636 | Raises: 637 | RSFilterError -- On validation errors in the ranges values. 638 | 639 | Returns: 640 | Tuple[str, List[Union[float, int]]] -- The text of the SQL clause and the 641 | list of values to be substituted into the clause. 642 | 643 | For example, for the field PLAYED_COUNT and ranges [[1,10], [25, 20]], and 644 | include of True, the clause will be: 645 | 646 | PlayedCount BETwEEN ? AND ? 647 | OR PlayedCount BETWEEN ? AND ? 648 | 649 | The returned values will be [1, 10, 20, 25]. The method does not check for 650 | overlapping ranges - weird things may happen if you try. 651 | 652 | For an include value of False, the expression changes to: 653 | 654 | PlayedCount NOT BETwEEN ? AND ? 655 | AND PlayedCount NOT BETWEEN ? AND ? 656 | 657 | """ 658 | ret_values: List[Union[float, int]] = list() 659 | text_list: List[str] = list() 660 | 661 | # Convert any constants to enum type. 662 | field_type = RangeField(field_name) 663 | 664 | if not self.ranges: 665 | raise RSFilterError( 666 | f"WHERE clause error: Ranges is empty for " 667 | f"field type {field_type.value}." 668 | ) 669 | 670 | if self.include: 671 | not_text = "" 672 | joiner = "\n OR " 673 | else: 674 | not_text = "NOT " 675 | joiner = "\n AND " 676 | 677 | for value_pair in self.ranges: 678 | if not isinstance(value_pair, list) or len(value_pair) != 2: 679 | raise RSFilterError( 680 | f"WHERE clause error: Range field type '{field_type.value}' " 681 | f"expected [low, high] pair, got {value_pair}." 682 | ) 683 | 684 | if not all(isinstance(x, (int, float)) for x in value_pair): 685 | raise RSFilterError( 686 | f"WHERE clause error: Range field type {field_type} expects " 687 | f"numeric pairs of values to define range. Got {value_pair}." 688 | ) 689 | 690 | if any(x < 0 for x in value_pair): 691 | raise ValueError( 692 | f"WHERE clause error: Range field type {field_type} expects " 693 | f"numeric pairs of values to >= 0 to define range." 694 | f"\nGot {value_pair}." 695 | ) 696 | 697 | # silent tidy. 698 | low_val = min(value_pair) 699 | high_val = max(value_pair) 700 | 701 | # temporary fix until I can validate and assign Union[int, float] correctly 702 | if low_val.is_integer() and high_val.is_integer(): 703 | low_val = int(low_val) 704 | high_val = int(high_val) 705 | 706 | # and finally the SQL 707 | text_list.append(f"{field_type.value} {not_text}BETWEEN ? AND ?") 708 | ret_values.append(low_val) 709 | ret_values.append(high_val) 710 | 711 | sql_text = joiner.join(text_list) 712 | sql_text = f"({sql_text})" 713 | 714 | return sql_text, ret_values 715 | 716 | 717 | @dataclass 718 | class ListSubFilter(SubFilter): 719 | """Value list for a value sub-filter. 720 | 721 | Public attributes/methods: 722 | values {List[str} -- A list of string values that will be used to build the 723 | filter. 724 | list_clause: returns the list clause and values tuple for the filter. 725 | 726 | The values are used to build SQL IN queries. 727 | 728 | include implementation -- If True if the filter will return records where the 729 | field value matches any of the Filter values in the list. If False, it will 730 | return records where the field value does not match any of the Filter values. 731 | 732 | Note: changes in attribute names should be reflected in default TOML. 733 | """ 734 | 735 | # instance variables 736 | values: List[str] 737 | 738 | def list_clause( 739 | self, field_name: ListField, list_validator: Dict[ListField, List[str]] 740 | ) -> Tuple[str, List[str]]: 741 | """Create SQL list field clause. 742 | 743 | Arguments: 744 | field_name {ListField} -- The list field target for the clause. 745 | list_validator {Dict[ListField, List[str]]} -- For each list field in the 746 | dictionary, a list of valid values for this field. 747 | 748 | Raises: 749 | RSFilterError -- If a filter value is not valid (doesn't appear in the 750 | database). 751 | 752 | Returns: 753 | Tuple[str, List[str]] -- The SQL clause for the field, including question 754 | marks for value substitution, and the list of values for substitution. 755 | For example, a for a field type of ARTIST, and values of ["Queen", 756 | "Big Country"] will result in the following clause (depending on the 757 | value of include): 758 | 759 | Artist IN (? ?) 760 | Artist NOT IN (? ?) 761 | 762 | """ 763 | # Convert any constants to enum type. 764 | field_type = ListField(field_name) 765 | 766 | values: List[str] = list() 767 | 768 | # Silently ignore invalid values (better then the old message of 769 | # failing unceremoniously) 770 | for value in self.values: 771 | if value in list_validator[field_type]: 772 | values.append(value) 773 | 774 | if not values: 775 | raise RSFilterError( 776 | f"WHERE clause error: Empty value list or invalid entries for " 777 | f"field type {field_type.value}." 778 | ) 779 | 780 | if len(values) > 1: 781 | q_marks = "?, " * (len(values) - 1) 782 | else: 783 | q_marks = "" 784 | 785 | if self.include: 786 | not_text = "" 787 | else: 788 | not_text = "NOT " 789 | 790 | sql_text = f"{field_type.value} {not_text}IN ({q_marks}?)" 791 | 792 | return sql_text, values 793 | 794 | 795 | @dataclass(config=ConfigDict(use_enum_values=True)) 796 | class Filter: 797 | """Provide configuration for a named filter. 798 | 799 | Public attributes: 800 | sub_filters {Dict[]} -- A dictionary of sub-filters that will be used to build 801 | the named filter. Each key/value pair should be typed as either: 802 | 803 | {RangeField: RangeSubFilter} or 804 | {ListField: ListSubFilter} 805 | 806 | The is the target database field for the sub-filter (query), and the value 807 | provides the sub-filter parameters (logic and values). 808 | 809 | base {str} -- The name of another named filter that will provides the base data 810 | for this filter. 811 | 812 | mode {FilterMode} -- Defines the logic for combining sub_filters. For 813 | FilterMode.AND, the filter will return only records that match all of the 814 | the sub-filters, while FilterMode.OR will return all records that match any 815 | of the sub-filters. 816 | 817 | Note: changes in attribute names should be reflected in default TOML. 818 | """ 819 | 820 | # instance variables 821 | sub_filters: Dict[ 822 | Union[RangeField, ListField], Union[RangeSubFilter, ListSubFilter] 823 | ] = field(default_factory=dict) 824 | base: str = "" 825 | mode: FilterMode = FilterMode.AND 826 | 827 | def where_clause(self, list_validator: Dict[ListField, List[str]]) -> SQLClause: 828 | """Return a SQL WHERE clause for the filter. 829 | 830 | Arguments: 831 | list_validator {Dict[ListField, List[str]]} -- For each list field in the 832 | dictionary, a list of valid values for this field. 833 | 834 | Raises: 835 | RSFilterError -- For validation errors in the filter definition. 836 | 837 | Returns: 838 | Tuple[str, Tuple[Union[str, int, float], ...]] -- SQLClause, the WHERE 839 | clause for the filter and the tuple of values to be substituted into the 840 | filter. 841 | 842 | """ 843 | field_type: SQLField 844 | sub_clauses: List[str] = list() 845 | where_values: List[Union[str, int, float]] = list() 846 | 847 | # work through each sub filter in the list. 848 | for field_name, sub_filter in self.sub_filters.items(): 849 | try: 850 | if isinstance(sub_filter, RangeSubFilter): 851 | try: 852 | field_type = RangeField(field_name) 853 | except ValueError as v_e: 854 | raise RSFilterError( 855 | f"WHERE clause error: Invalid field type ({field_name}) " 856 | f"for range type sub-filter.\nThis should be a member of " 857 | f"RangeField Enum. " 858 | ) from v_e 859 | 860 | sub_text, range_list = sub_filter.range_clause(field_type) 861 | where_values.extend(range_list) 862 | sub_clauses.append(sub_text) 863 | 864 | elif isinstance(sub_filter, ListSubFilter): 865 | try: 866 | field_type = ListField(field_name) 867 | except ValueError as v_e: 868 | raise RSFilterError( 869 | f"WHERE clause error: Invalid field type ({field_name}) " 870 | f"for list type sub-filter.\nThis should be a member of " 871 | f"ListField Enum." 872 | ) from v_e 873 | 874 | sub_text, value_list = sub_filter.list_clause( 875 | field_type, list_validator 876 | ) 877 | where_values.extend(value_list) 878 | sub_clauses.append(sub_text) 879 | 880 | else: 881 | raise RSFilterError( 882 | f"WHERE clause error: Unrecognised sub_filter type" 883 | f"\nGot {type(sub_filter)}, expected ListSubFilter or " 884 | f"RangeSubFilter." 885 | ) 886 | 887 | except RSFilterError as exc: 888 | raise RSFilterError( 889 | f"WHERE clause error for sub filter {field_name}.\n{exc}" 890 | ) from exc 891 | 892 | try: 893 | mode_text = FilterMode(self.mode).value 894 | except ValueError as v_e: 895 | raise RSFilterError( 896 | f"WHERE clause error: Invalid mode '{self.mode}''. Should be a member " 897 | f"of FilterMode Enum." 898 | ) from v_e 899 | 900 | # This is clumsy, but allows dumping of SQL for debugging. 901 | where_text = f"\n {mode_text} " 902 | where_text = where_text.join(sub_clauses) 903 | where_text = f" WHERE\n {where_text}" 904 | 905 | return where_text, tuple(where_values) 906 | 907 | 908 | @dataclass 909 | class Configuration: 910 | """Provide general configuration settings, filter definitions and filter sets. 911 | 912 | Public attributes: 913 | settings {Settings} -- General configuration settings. 914 | filters {Dict[str, Filter]} -- Filter definitions, where the key is the filter 915 | name. 916 | song_list_sets {Dict[str, List[str]]} -- Each key is the name for a set of song 917 | lists, and the list values for that key are the filter names for generating 918 | the song lists in the named set (up to six names per set). 919 | 920 | Note: changes in attribute names should be reflected in default TOML. 921 | """ 922 | 923 | # Always create a default instance/list/dictionary. 924 | # I believe I had a shared mutable previously (which wasn't an issue given I only 925 | # ever instanced once). However, hopefully the default factory fixes this. 926 | settings: Settings = field(default_factory=Settings) 927 | # Filters before filter sets to allow future validation of filter sets. 928 | filters: Dict[str, Filter] = field(default_factory=dict) 929 | song_list_sets: Dict[str, List[str]] = field(default_factory=dict) 930 | 931 | @classmethod 932 | def load_toml(cls, toml_path: Path) -> "Configuration": 933 | """Create a configuration instance from a TOML file. 934 | 935 | Arguments: 936 | toml_path {Path} -- Path to the toml file. 937 | 938 | This helper function will also fill in sample defaults if these are missing from 939 | the file. 940 | """ 941 | try: 942 | # tomllib, tomli-w require binary file open/close for utf-8 943 | with toml_path.open("rb") as file_handle: 944 | data_dict = tomllib.load(file_handle) 945 | except FileNotFoundError: 946 | data_dict = dict() 947 | 948 | if data_dict: 949 | # Load and validate the data we have 950 | configuration = cls(**data_dict) 951 | else: 952 | # Create a default configuration if we have no data 953 | configuration = Configuration() 954 | # And because it truly is default, set the version info. 955 | configuration.settings.version = RSRTOOLS_VERSION 956 | 957 | if not configuration.filters and not configuration.song_list_sets: 958 | # No filter configurations, so set up a default set. 959 | # This will apply if data is missing or for a default setup. 960 | # Should be OK as python strings are all in UTF-8 already? 961 | configuration = replace(configuration, **tomllib.loads(DEFAULT_TOML)) 962 | 963 | return configuration 964 | 965 | def save_toml(self, toml_path: Path) -> None: 966 | """Write configuration instance to toml file. 967 | 968 | Arguments: 969 | toml_path {Path} -- Path to the toml file. 970 | """ 971 | # Hopefully using sorted with tomli-w allows me keep filter structures 972 | # together in the toml? If not, figure out what needs to be done to 973 | # emulate the previous version using toml. 974 | with toml_path.open("wb") as file_handle: 975 | toml = dict(sorted(asdict(self).items())) 976 | tomli_w.dump(toml, file_handle) 977 | -------------------------------------------------------------------------------- /src/rsrtools/songlists/songlists.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Provide SongListCreator, which is the main class of the rsrtools package. 4 | 5 | For command line options (database setup, reporting), run: 6 | 'python -m rsrtools.songlists.songlists -h' 7 | """ 8 | 9 | # cSpell:ignore unsubscriptable, profilemanager 10 | 11 | import argparse 12 | import sys 13 | 14 | from pathlib import Path 15 | from os import fsdecode 16 | from typing import Dict, List, Optional, TextIO, Union 17 | 18 | import rsrtools.utils as utils 19 | 20 | from rsrtools.songlists.scanner import newer_songs 21 | from rsrtools.files.config import ProfileKey, MAX_SONG_LIST_COUNT 22 | from rsrtools.songlists.config import ListField, RangeField 23 | from rsrtools.songlists.configclasses import ( 24 | Configuration, 25 | Settings, 26 | Filter, 27 | RSFilterError, 28 | ) 29 | from rsrtools.songlists.database import ArrangementDB 30 | from rsrtools.files.profilemanager import RSProfileManager, RSFileSetError 31 | 32 | CONFIG_FILE = "config.toml" 33 | SONG_LIST_DEBUG_FILE = "RS_Song_Lists.txt" 34 | ARRANGEMENTS_GRID = "ArrangementsGrid.xml" 35 | # 100ms offset to deal with rounding in saved dlc_mtime 36 | DLC_MTIME_OFFSET = 0.01 37 | 38 | 39 | class SongListCreator: 40 | """Provide a command line interface for creating and writing Rocksmith song lists. 41 | 42 | Refer to the rsrtools package README documentation for more detail. 43 | 44 | The public members of this class also provide hooks that could be used to implement 45 | a GUI for creating song lists. The public members are: 46 | 47 | Constructor -- Sets up the song list creator working directory and files, and 48 | loads the configuration file. 49 | 50 | create_song_lists -- Creates song lists for the filters in a set and writes the 51 | resulting song lists back to the currently specified Steam account id/player 52 | profile. 53 | 54 | load_cfsm_arrangements -- Loads arrangement data from the CFSM file defined in 55 | self.cfsm_arrangement_file into the arrangements database. Deprecated, due 56 | for deletion. 57 | 58 | save_config -- Saves the current song list generation configuration file. 59 | 60 | song_list_cli -- Runs the command line driven song list generator. 61 | 62 | cfsm_arrangement_file -- Read/write property. Path to a CFSM arrangement file 63 | that can be used for setting up the arrangement database. Deprecated, due 64 | for deletion. 65 | 66 | steam_account_id -- Read/write property. Steam account id (8 digit decimal 67 | number found under Steam user data folder) to use for Rocksmith saves. Can 68 | be set as an int or a string representation of the int, always returns str. 69 | 70 | player_profile -- Read/write property. Rocksmith player profile name to use for 71 | song list generation. 72 | 73 | """ 74 | 75 | # instance variables 76 | _configuration: Configuration 77 | 78 | _arr_db: ArrangementDB 79 | _working_dir: Path 80 | # path to configuration file 81 | _cfg_path: Path 82 | _profile_manager: Optional[RSProfileManager] 83 | 84 | # variable only used in the CLI implementation. 85 | # This should be either: 86 | # sys.stdout - for reports to console. 87 | # A path to a reporting file that will be overwritten 88 | _cli_report_target: Union[Path, TextIO] 89 | 90 | # A lot of the properties in the following shadow config file parameters. 91 | # I could have done a lot of this with setattr/getattr, but given 92 | # the small number of properties, I've stuck with explicit definitions. 93 | # This also has the benefit of allowing validation along the way. 94 | @property 95 | def _settings(self) -> Settings: 96 | """Get general settings. 97 | 98 | Gets: 99 | rsrtools.songlists.configclasses.Settings 100 | 101 | Provide default (typically empty) settings if required. 102 | 103 | """ 104 | return self._configuration.settings 105 | 106 | @property 107 | def _song_list_sets(self) -> Dict[str, List[str]]: 108 | """Get definitions for song list sets. 109 | 110 | Gets: 111 | Dict[str, List[str]] -- Each key is the name for a set of song lists, and 112 | the list values for that key are the filter names for generating the 113 | song lists in the named set (up to six names per set). Excess values in 114 | the list will be ignored. That is: 115 | 116 | {'Set name 1': ['Filter 1', 'Filter 2', 'Filter 3'], 117 | 'Set name 2': ['Filter 2', 'Filter 3', 'Filter 4'],} 118 | 119 | """ 120 | return self._configuration.song_list_sets 121 | 122 | @property 123 | def _filter_dict(self) -> Dict[str, Filter]: 124 | """Get dictionary of filter definitions. 125 | 126 | Gets: 127 | Dict[str, Filter] -- Each key is the name of a filter, and Filter is the 128 | corresponding definition. 129 | 130 | Each filter can be used to generate a song list or as a base filter for building 131 | other filters. 132 | """ 133 | return self._configuration.filters 134 | 135 | @property 136 | def cfsm_arrangement_file(self) -> str: 137 | """Get/set the string path to a CFSM arrangements file for database setup. 138 | 139 | Gets/sets: 140 | str -- The string form path the Customs Forge Song Manager arrangements 141 | file, or the empty string if it has not been set. 142 | 143 | The setter will raise an exception if the path does not point to a file, but 144 | but does not validate the file. 145 | 146 | Deprecated, due for deletion. 147 | 148 | """ 149 | check_path = self._settings.CFSM_file_path 150 | if check_path and not Path(check_path).is_file(): 151 | # silently discard invalid path. 152 | self._settings.CFSM_file_path = "" 153 | 154 | return self._settings.CFSM_file_path 155 | 156 | @cfsm_arrangement_file.setter 157 | def cfsm_arrangement_file(self, value: str) -> None: 158 | """Set path to CFSM arrangements file.""" 159 | file_path = Path(value) 160 | if not file_path.is_file(): 161 | self._settings.CFSM_file_path = "" 162 | raise FileNotFoundError(f"CFSM arrangement file '{value}' does not exist") 163 | 164 | self._settings.CFSM_file_path = fsdecode(file_path.resolve()) 165 | 166 | # **************************************************** 167 | # Steam account id, _profile_manager, player profile and player data in database are 168 | # tightly linked. 169 | # Initialisation uses these properties and exception handlers to manage auto-loading 170 | # from json. 171 | @property 172 | def steam_account_id(self) -> str: 173 | """Get/set the Steam account id to use for song list creation. 174 | 175 | Gets/sets: 176 | str -- String representation of Steam account id, or the empty string if it 177 | has not been set yet. 178 | 179 | The Steam account id is an 8 digit number, which is also the name of Steam user 180 | data directory. 181 | 182 | Song list changes affect Rocksmith profiles in this Steam user's directories. 183 | Setting the Steam account id to the empty string triggers an interactive command 184 | line process to choose a new Steam account id and triggers the side effects 185 | below. 186 | 187 | Setting Steam account id has the following side effects: 188 | - The instance flushes and reloads Steam profile data. 189 | - The instance clears the player profile and flushes player data from the 190 | arrangement database (reset with self.player_profile) 191 | 192 | """ 193 | # could do extensive validation here, but there is already error checking in the 194 | # profile manager, and any ui will need to find valid Steam ids to load up. 195 | # So no error checking here. 196 | return self._settings.steam_account_id 197 | 198 | @steam_account_id.setter 199 | def steam_account_id(self, value: str) -> None: 200 | """Steam account id setter.""" 201 | # reset configuration value to "" in case of errors (correct at end of routine) 202 | self._settings.steam_account_id = "" 203 | 204 | # Changing Steam account id, so clear profile manager and player profile (and 205 | # implicitly, flush db as well) 206 | # Conservative assumption - flush and everything, even if the assignment 207 | # doesn't change the original Steam id 208 | self.player_profile = "" 209 | self._profile_manager = None 210 | 211 | # Create profile manager. 212 | # This will trigger an interactive file set selection if value is "" 213 | # (for command line use). I'm also disabling use of existing working set files 214 | # for the song list creator. 215 | # (Working set files are really only intended for debugging). 216 | self._profile_manager = RSProfileManager( 217 | self._working_dir, 218 | steam_account_id=value, 219 | auto_setup=True, 220 | flush_working_set=True, 221 | ) 222 | 223 | final_account_id = value 224 | if not final_account_id: 225 | # Get the Steam account id resulting from an interactive call to 226 | # RSProfileManager 227 | final_account_id = self._profile_manager.steam_account_id 228 | 229 | self._settings.steam_account_id = final_account_id 230 | 231 | @property 232 | def player_profile(self) -> str: 233 | """Get/set the Rocksmith player profile name to use for song list generation. 234 | 235 | Gets/sets: 236 | str -- The player profile name. Gets the empty string if the player profile 237 | has not been set yet. Setting to the empty string clears the 238 | current profile data. 239 | 240 | Setting the player name also deletes all profile data from the database and 241 | loads the profile data for player_profile into the database. Setting to the 242 | empty string deletes all profile data without loading new data. 243 | 244 | A Steam account id must be specified before setting the player profile. 245 | """ 246 | # can't do any useful validation without loading profile, so similar to the 247 | # Steam account id, push it down to the profile manager or up to the ui. 248 | return self._settings.player_profile 249 | 250 | @player_profile.setter 251 | def player_profile(self, value: str) -> None: 252 | """Set player profile name for song list creation.""" 253 | # new player profile, so ditch everything in player database 254 | self._arr_db.flush_player_profile() 255 | # reset to default in case of error in setter 256 | self._settings.player_profile = "" 257 | 258 | if value: 259 | if self._profile_manager is None: 260 | # shouldn't happen, but just in case 261 | raise RSFilterError( 262 | f"Attempt to set player profile to {value} before Steam user " 263 | f"id/file set has been chosen (profile_manager is None)." 264 | ) 265 | 266 | if value not in self._profile_manager.profile_names(): 267 | description = self._profile_manager.steam_description( 268 | self.steam_account_id 269 | ) 270 | raise RSFilterError( 271 | f"Rocksmith player profile '{value}' does not exist in Steam file " 272 | f"set for user:" 273 | f"\n{description}" 274 | ) 275 | 276 | self._arr_db.load_player_profile(self._profile_manager, value) 277 | 278 | # set this last in case of errors along the way. 279 | self._settings.player_profile = value 280 | 281 | # End player steam_account_id, player_profile properties block 282 | 283 | def load_cfsm_arrangements(self) -> None: 284 | """Load arrangement data from the CFSM file into the arrangement database. 285 | 286 | The CFSM file is defined in the property self.cfsm_arrangement_file. 287 | 288 | This method replaces existing data in the database. 289 | 290 | Deprecated, due for deletion. 291 | """ 292 | if self.cfsm_arrangement_file: 293 | self._arr_db.load_cfsm_arrangements(Path(self.cfsm_arrangement_file)) 294 | 295 | # start initialisation block 296 | def __init__(self, working_dir: Path) -> None: 297 | """Initialise the song list creator. 298 | 299 | Arguments: 300 | working_dir {pathlib.Path} -- Working directory path. 301 | 302 | Create working files and sub-folders in the working directory, and load the 303 | configuration file (if any) from the working folder. 304 | """ 305 | self._profile_manager = None 306 | 307 | # Default to console for reporting. 308 | self._cli_report_target = sys.stdout 309 | 310 | if not working_dir.is_dir(): 311 | raise NotADirectoryError( 312 | f"SongListCreator requires a valid working directory. Invalid argument " 313 | f"supplied:\n {fsdecode(working_dir)}" 314 | ) 315 | self._working_dir = working_dir.resolve() 316 | 317 | self._cfg_path = self._working_dir.joinpath(CONFIG_FILE) 318 | self._configuration = self._load_config() 319 | 320 | # Save the default config if we don't have one. 321 | if not self._cfg_path.exists(): 322 | self.save_config() 323 | 324 | self._arr_db = ArrangementDB(self._working_dir) 325 | 326 | # The next block is a slightly clumsy way of avoiding separate auto load code 327 | # for setting up profile manager and player database from json parameters for 328 | # Steam id and player profile name 329 | if not self.steam_account_id: 330 | # reset player profile and database (invalid with no Steam id anyway). 331 | tmp_profile = "" 332 | else: 333 | # resetting Steam id will trash player profile, so grab a temp copy 334 | tmp_profile = self.player_profile 335 | try: 336 | # this looks like a non-op, but triggers the side effect of loading the 337 | # profile manager for the Steam account id. 338 | self.steam_account_id = self.steam_account_id 339 | except RSFileSetError: 340 | # invalid Steam id, Steam id will have been reset to "". 341 | # discard player profile as well (meaningless without Steam id) 342 | tmp_profile = "" 343 | 344 | try: 345 | # this will load the database for the original self.player_profile if it 346 | # exists. 347 | self.player_profile = tmp_profile 348 | except RSFilterError: 349 | # player profile not found, player profile set to none, nothing more needed. 350 | pass 351 | # end auto load from json. 352 | 353 | def _load_config(self) -> Configuration: 354 | """Load the TOML configuration file from the working directory. 355 | 356 | Create a default configuration if no file is found. 357 | """ 358 | return Configuration.load_toml(self._cfg_path) 359 | 360 | # end initialisation block 361 | 362 | def save_config(self) -> None: 363 | """Save configuration file to working directory. 364 | 365 | This method dumps the self._configuration object to a TOML file. 366 | """ 367 | self._configuration.save_toml(self._cfg_path) 368 | 369 | # # ******************************************************************* 370 | 371 | def _cli_menu_header(self) -> str: 372 | """Create the command line interface header string.""" 373 | if not self._profile_manager: 374 | steam_str = "'not set'" 375 | else: 376 | steam_str = self._profile_manager.steam_description(self.steam_account_id) 377 | 378 | if not self.player_profile: 379 | player_str = "'not set'" 380 | else: 381 | player_str = f"'{self.player_profile}'" 382 | 383 | if isinstance(self._cli_report_target, Path): 384 | report_to = f"File '{fsdecode(self._cli_report_target)}'." 385 | else: 386 | report_to = "Standard output/console" 387 | 388 | header = ( 389 | f"Rocksmith song list generator main menu." 390 | f"\n" 391 | f"\n Steam account id: {steam_str}" 392 | f"\n Rocksmith profile: {player_str}" 393 | f"\n Reporting to: {report_to}" 394 | f"\n Working directory: {fsdecode(self._working_dir)}" 395 | f"\n" 396 | f"\nPlease choose from the following options:" 397 | ) 398 | 399 | return header 400 | 401 | def _cli_select_steam_account(self) -> None: 402 | """Select a Steam account id from a command line menu.""" 403 | # daft as it looks, this will trigger an interactive selection process 404 | self.steam_account_id = "" 405 | 406 | def _cli_select_profile(self) -> None: 407 | """Select a Rocksmith profile from a command line menu.""" 408 | # ask the user to select a profile to load. 409 | if self._profile_manager is None: 410 | # can't select a player profile until the Steam user/profile manager have 411 | # been selected 412 | return 413 | 414 | # a valid player profile will automatically refresh player database as well. 415 | choice = utils.choose( 416 | options=self._profile_manager.profile_names(), 417 | header="Select player profile for song list creation.", 418 | no_action=( 419 | "No selection (warning - you can't create song lists without " 420 | "choosing a profile)." 421 | ), 422 | ) 423 | 424 | if choice is None: 425 | self.player_profile = "" 426 | else: 427 | profile = choice[0] 428 | if not isinstance(profile, str): 429 | raise TypeError( 430 | f"Unexpected type from profile choice. Should be string, " 431 | f"got f{type(profile)}." 432 | ) 433 | self.player_profile = profile 434 | 435 | def _cli_toggle_reporting(self) -> None: 436 | """Toggles reports between stdout and the working directory report file.""" 437 | if isinstance(self._cli_report_target, Path): 438 | self._cli_report_target = sys.stdout 439 | else: 440 | self._cli_report_target = self._working_dir.joinpath(SONG_LIST_DEBUG_FILE) 441 | 442 | def _cli_single_filter_report(self) -> None: 443 | """Select and run a report on a single filter from a command line interface.""" 444 | self._cli_song_list_action(ProfileKey.FAVORITES_LIST, self._cli_report_target) 445 | 446 | def _cli_song_list_report(self) -> None: 447 | """Select and run a song list report from a command line interface.""" 448 | self._cli_song_list_action(ProfileKey.SONG_LISTS, self._cli_report_target) 449 | 450 | def _cli_write_song_lists(self) -> None: 451 | """Select a song list set and write the resulting song lists to the profile. 452 | 453 | Song list selection is by command line interface, and the method will write the 454 | song lists to the currently selected Rocksmith profile. 455 | """ 456 | self._cli_song_list_action(ProfileKey.SONG_LISTS, None) 457 | 458 | def _cli_write_favorites(self) -> None: 459 | """Select a filter and write the resulting favorites list to the profile. 460 | 461 | Song list selection is by command line interface, and the method will write the 462 | favorites lists to the currently selected Rocksmith profile. 463 | """ 464 | self._cli_song_list_action(ProfileKey.FAVORITES_LIST, None) 465 | 466 | def _cli_song_list_action( 467 | self, list_target: ProfileKey, report_target: Optional[Union[Path, TextIO]] 468 | ) -> None: 469 | """Execute song list generation, report writing and save to profiles. 470 | 471 | Arguments: 472 | list_target {ProfileKey} -- must be either ProfileKey.SONG_LISTS or 473 | ProfileKey.FAVORITES_LIST (import from rsrtools.files.config). 474 | The target for the action. 475 | report_target {Optional[Union[Path, TextIO]]} -- The reporting target for 476 | the method, which can be None, a file path or a text stream. 477 | 478 | Raises: 479 | RSFilterError -- Raised if list_target is an invalid type. 480 | 481 | The behaviour of this routine depends on the argument values: 482 | - If list_target is SONG_LISTS, the the user is asked to choose a filter 483 | set to use for song list generation/reporting (up to six song lists). 484 | - If list_target is FAVORITES_LIST, the user is asked choose a single 485 | filter for favorites generation or filter testing/reporting. 486 | - If report_target is None, the song lists will be created and written 487 | to the instance Rocksmith profile. 488 | - If report_target is not None, the song lists will be created and written 489 | to the report target, and will not be written to the Rocksmith profile. 490 | 491 | """ 492 | if not self.player_profile: 493 | # can't do anything with song lists without a player profile. 494 | return 495 | 496 | if list_target is ProfileKey.FAVORITES_LIST: 497 | # Running favorites or testing a filter, so need to select a single filter 498 | # from the dictionary of filters 499 | option_list = tuple(self._filter_dict.keys()) 500 | elif list_target is ProfileKey.SONG_LISTS: 501 | # Running song lists, so need to select from available song list sets 502 | option_list = tuple( 503 | self._song_list_sets.keys() 504 | ) 505 | else: 506 | raise RSFilterError( 507 | f"_cli_song_list_action called with invalid list target {list_target}." 508 | ) 509 | 510 | # a valid player profile will automatically refresh player database as well. 511 | choice = utils.choose( 512 | options=option_list, 513 | header="Select filter or song list set.", 514 | no_action=( 515 | "No selection (warning - you can't create song lists without choosing " 516 | "a filter/song list set)." 517 | ), 518 | ) 519 | 520 | if choice is None: 521 | return 522 | 523 | key = choice[0] 524 | if not isinstance(key, str): 525 | raise TypeError( 526 | f"Unexpected type from filter/song list set choice. Should be string, " 527 | f"got f{type(key)}." 528 | ) 529 | 530 | if list_target is ProfileKey.FAVORITES_LIST: 531 | # Creating a favorites list or testing a single filter here. 532 | # Create a synthetic song list set for this case. 533 | # choice is the name of the filter we should use. 534 | song_list_set = [key] 535 | else: 536 | # Get the selected song list set from the song_list_sets dict. No need for 537 | # extended else clause, as we have checked previously for invalid 538 | # list_target 539 | song_list_set = self._song_list_sets[ 540 | key 541 | ] 542 | 543 | confirmed = True 544 | if report_target is None: 545 | # File write only happens if report target is None. 546 | # However, because we want to be really sure about users intentions, 547 | # reconfirm write to file here. 548 | confirmed = utils.yes_no_dialog( 549 | f"Please confirm that you want to create song lists and write them " 550 | f"to profile '{self.player_profile}'" 551 | ) 552 | 553 | if confirmed: 554 | self.create_song_lists(list_target, song_list_set, report_target) 555 | 556 | def _cli_full_scan(self) -> None: 557 | """Do a full scan of song data and rebuild the arrangements table.""" 558 | mtime = self._arr_db.scan_arrangements(last_modified=None, show_progress=True) 559 | self._configuration.settings.dlc_mtime = mtime + DLC_MTIME_OFFSET 560 | 561 | def _cli_partial_scan(self) -> None: 562 | """Do a partial scan of song data and update the arrangements table.""" 563 | mtime = self._configuration.settings.dlc_mtime 564 | mtime = self._arr_db.scan_arrangements(last_modified=mtime, show_progress=True) 565 | self._configuration.settings.dlc_mtime = mtime + DLC_MTIME_OFFSET 566 | 567 | def song_list_cli(self) -> None: 568 | """Provide a command line menu for the song list generator routines.""" 569 | if not self._arr_db.has_arrangement_data: 570 | print("No arrangement data in database. Running full scan to load data.") 571 | self._cli_full_scan() 572 | elif newer_songs(self._configuration.settings.dlc_mtime): 573 | choice = utils.choose( 574 | options=[ 575 | ("Recommended: Run a full database refresh", self._cli_full_scan), 576 | ( 577 | "Scan for new songs only (faster, may not find all changes).", 578 | self._cli_partial_scan, 579 | ), 580 | ], 581 | header="It looks as though you have some new DLC.", 582 | ) 583 | if choice is not None: 584 | choice[0]() 585 | 586 | options = list() 587 | options.append( 588 | ( 589 | "Change/select Steam account id. This also clears the profile " 590 | "selection.", 591 | self._cli_select_steam_account, 592 | ) 593 | ) 594 | options.append( 595 | ("Change/select Rocksmith player profile.", self._cli_select_profile) 596 | ) 597 | options.append(("Toggle the report destination.", self._cli_toggle_reporting)) 598 | options.append( 599 | ( 600 | "Choose a single filter and create a song list report.", 601 | self._cli_single_filter_report, 602 | ) 603 | ) 604 | options.append( 605 | ( 606 | "Choose a song list set and create a song list report.", 607 | self._cli_song_list_report, 608 | ) 609 | ) 610 | options.append( 611 | ( 612 | "Choose a song list set and write the list(s) to Song Lists in the " 613 | "Rocksmith profile.", 614 | self._cli_write_song_lists, 615 | ) 616 | ) 617 | options.append( 618 | ( 619 | "Choose a filter and write the resulting song list to Favorites in the " 620 | "Rocksmith profile.", 621 | self._cli_write_favorites, 622 | ) 623 | ) 624 | options.append( 625 | ("Utilities (database reports, profile management.)", self._cli_utilities) 626 | ) 627 | 628 | help_text = ( 629 | "Help: " 630 | "\n - The Steam account id owns the Rocksmith profile." 631 | "\n - A Steam account id must be selected before a Rocksmith profile " 632 | "can be selected." 633 | "\n - Song lists are saved to a Rocksmith profile. Player data is " 634 | "extracted from this" 635 | "\n profile." 636 | "\n - You can view/test/debug song lists by printing to the console or " 637 | "saving to a file." 638 | "\n These reports contain more data about the tracks than the song " 639 | "lists actually saved" 640 | "\n into the Rocksmith profile." 641 | "\n - You can view/test song lists for a single filter or a complete " 642 | "song list set (the latter" 643 | "\n can be very long - consider saving song list set reports to file)." 644 | ) 645 | 646 | while True: 647 | header = self._cli_menu_header() 648 | choice = utils.choose( 649 | options=options, 650 | header=header, 651 | no_action="Exit program.", 652 | help_text=help_text, 653 | ) 654 | 655 | if choice is None: 656 | break 657 | 658 | # otherwise execute the action 659 | action = choice[0] 660 | if not callable(action): 661 | raise TypeError( 662 | f"Unexpected type from song list action choice. Should be " 663 | f"callable, got f{type(action)}." 664 | ) 665 | 666 | action() 667 | 668 | if utils.yes_no_dialog( 669 | "Save config file (overwrites current config even if nothing has changed)?" 670 | ): 671 | self.save_config() 672 | 673 | def create_song_lists( 674 | self, 675 | list_target: ProfileKey, 676 | song_list_set: List[str], 677 | debug_target: Optional[Union[Path, TextIO]] = None, 678 | ) -> None: 679 | """Create song lists and write them to a Steam Rocksmith profile. 680 | 681 | Arguments: 682 | list_target {ProfileKey} -- must be either ProfileKey.SONG_LISTS or 683 | ProfileKey.FAVORITES_LIST (import from rsrtools.files.config). 684 | The song list target for creating/writing. 685 | song_list_set {List[str]} -- The list of filter names that will be used to 686 | create the song lists. The list should contain up to six elements for 687 | Song Lists (an empty string entry will skip a song list), or one element 688 | for Favorites. Any excess elements will be ignored. 689 | 690 | Keyword Arguments: 691 | debug_target {Optional[Union[Path, TextIO]]} -- If set to None, the song 692 | lists will be written to the currently selected Steam account id and 693 | Rocksmith profile. Otherwise, a diagnostic report will be written to the 694 | file or stream specified by debug target. (default: {None}) 695 | 696 | Raises: 697 | RSFilterError -- Raised for an incomplete database or if the profile manager 698 | is undefined. 699 | 700 | Creates song lists for the filters in the set and writes the resulting song 701 | lists back to player profile. 702 | 703 | The song list creator will only create up to 6 song lists, and song lists for 704 | list entries with an empty string value will not be changed. Further, 705 | steam_account_id and player_profile must be set to valid values before 706 | executing. 707 | 708 | If debug target is not None, song lists are not saved to the instance profile. 709 | Instead: 710 | - If debug_target is a stream, extended song lists are written to the 711 | stream. 712 | - If debug_target is a file path, the file is opened and the extended 713 | song lists are written to this file. 714 | 715 | """ 716 | # error condition really shouldn't happen if properties are working properly. 717 | if not self._arr_db.has_arrangement_data or not self._arr_db.has_player_data: 718 | raise RSFilterError( 719 | "Database requires both song arrangement data and player data to " 720 | "generate song lists." 721 | "\nOne or both data sets are missing" 722 | ) 723 | 724 | if list_target is ProfileKey.SONG_LISTS: 725 | if len(song_list_set) > MAX_SONG_LIST_COUNT: 726 | use_set = song_list_set[0:MAX_SONG_LIST_COUNT] 727 | else: 728 | use_set = song_list_set[:] 729 | elif list_target is ProfileKey.FAVORITES_LIST: 730 | use_set = song_list_set[0:1] 731 | else: 732 | raise RSFilterError( 733 | f"Create song lists called with an invalid list target {list_target}." 734 | ) 735 | 736 | if isinstance(debug_target, Path): 737 | # Using open rather than path.open() gives a TextIO type 738 | # (Path.open() has type IO[Any], which is not what we want). 739 | with open(debug_target, "wt", encoding='locale') as file_handle: 740 | song_lists = self._arr_db.generate_song_lists( 741 | use_set, self._filter_dict, file_handle 742 | ) 743 | 744 | else: 745 | song_lists = self._arr_db.generate_song_lists( 746 | use_set, self._filter_dict, debug_target 747 | ) 748 | 749 | if debug_target is None and song_lists: 750 | # not debugging/reporting, so write song lists to profile, and move 751 | # updates to Steam. 752 | # Fail silently if song_lists is empty (nothing to do). 753 | if self._profile_manager is None: 754 | # shouldn't happen, but just in case 755 | raise RSFilterError( 756 | "Attempt to write song lists to a player profile before Steam " 757 | "user id/file set has been chosen (profile_manager is None)." 758 | ) 759 | 760 | # Song list update 761 | # Note that an empty list will flush the existing song list! 762 | # If this is a problem for people, we can add a check for an empty list 763 | # at the same place we do a None check and skip. 764 | for idx, song_list in enumerate(song_lists): 765 | if song_list is not None: 766 | # Index 0 for favorites will be cheerfully ignored. 767 | self._profile_manager.replace_song_list( 768 | self.player_profile, list_target, song_list, idx 769 | ) 770 | 771 | # Steam update 772 | self._profile_manager.write_files() 773 | self._profile_manager.move_updates_to_steam(self.steam_account_id) 774 | 775 | def _cli_utilities(self) -> None: 776 | """Provide command line utilities menu.""" 777 | while True: 778 | choice = utils.choose( 779 | header="Utility menu", 780 | no_action="Return to main menu.", 781 | options=[ 782 | ("Database reports.", self._arr_db.run_cl_reports), 783 | ( 784 | "Database string field names (for list type sub-filters).", 785 | ListField.report_field_values, 786 | ), 787 | ( 788 | "Database numeric field names (for range type sub-filters).", 789 | RangeField.report_field_values, 790 | ), 791 | ( 792 | "Clone profile. Copies source profile data into target " 793 | "profile. Replaces all target profile data." 794 | "\n - Reloads Steam user/profile after cloning.", 795 | self._cli_clone_profile, 796 | ), 797 | ( 798 | "Rescan all song data. The best way to add new songs to the " 799 | "database.", 800 | self._cli_full_scan, 801 | ), 802 | ( 803 | "Update database with new song data. Mostly robust, but may " 804 | "sometimes miss songs" 805 | "\n - Do a full scan if this happens.", 806 | self._cli_partial_scan, 807 | ), 808 | ( 809 | "Export profile to JSON (readable text format).", 810 | self._export_json, 811 | ), 812 | ], 813 | ) 814 | 815 | if choice is None: 816 | break 817 | 818 | # otherwise execute the action 819 | action = choice[0] 820 | if not callable(action): 821 | raise TypeError( 822 | f"Unexpected type from utilities choice. Should be " 823 | f"callable, got f{type(action)}." 824 | ) 825 | 826 | action() 827 | 828 | def _export_json(self) -> None: 829 | """Dump the active profile to JSON text file.""" 830 | if self._profile_manager is not None: 831 | self._profile_manager.export_json_profile( 832 | self.player_profile, 833 | self._working_dir.joinpath(self.player_profile + ".json"), 834 | ) 835 | 836 | def _cli_clone_profile(self) -> None: 837 | """Provide command line interface for cloning profiles.""" 838 | # grab temp copy of profile so we can reload after cloning and write back. 839 | profile = self.player_profile 840 | 841 | # do the clone thing. 842 | if self._profile_manager is None: 843 | # shouldn't happen, but just in case 844 | raise RSFilterError( 845 | "Attempt to clone player profile before Steam user " 846 | "id/file set has been chosen (profile_manager is None)." 847 | ) 848 | self._profile_manager.cl_clone_profile() 849 | 850 | # force a reload regardless of outcome. 851 | self.steam_account_id = self.steam_account_id 852 | # profile has been nuked, so reset with temp copy. 853 | self.player_profile = profile 854 | 855 | 856 | def main() -> None: 857 | """Provide basic command line interface to song list creator.""" 858 | parser = argparse.ArgumentParser( 859 | description="Command line interface for generating song list from config " 860 | "files. Provides minimal command line menus to support this activity." 861 | ) 862 | parser.add_argument( 863 | "working_dir", 864 | help="Working directory for database, config files, working " 865 | "sub-directories amd working files.", 866 | ) 867 | 868 | parser.add_argument( 869 | "--CFSMxml", 870 | help="Loads database with song arrangement data from CFSM xml file (replaces " 871 | "all existing data). Expects CFSM ArrangementsGrid.xml file structure. " 872 | "This is a deprecated function and will be removed in future.", 873 | metavar="CFSM_file_name", 874 | ) 875 | 876 | args = parser.parse_args() 877 | 878 | creator = SongListCreator(Path(args.working_dir).resolve(True)) 879 | 880 | if args.CFSMxml is not None: 881 | # String path by definition here. 882 | creator.cfsm_arrangement_file = args.CFSMxml 883 | creator.load_cfsm_arrangements() 884 | 885 | # run the command line interface. 886 | creator.song_list_cli() 887 | 888 | 889 | if __name__ == "__main__": 890 | main() 891 | --------------------------------------------------------------------------------