├── requirements.txt ├── development_guide.md ├── icons ├── add.png ├── oce.ico ├── oce.png ├── osu.png ├── up.png ├── blank.png ├── down.png ├── remove.png ├── rename.png ├── add_map.png ├── bloodcat.png ├── internet.png ├── oce_logo.png ├── options.png ├── warning.png ├── add_mapset.png ├── remove_map.png ├── remove_set.png └── remove_mapset.png ├── tests ├── gui │ └── pyqt_test.py └── util │ ├── osu_api_test.py │ ├── collections_parser_test.py │ ├── osudb_parser_test.py │ ├── osu_parser_test.py │ └── collection_beatmap_matcher_test.py ├── logging.conf ├── gui_controller ├── about.py ├── loading_addsong.py ├── beatmapitem.py ├── missing_maps.py ├── loading.py ├── startup.py ├── settings.py └── loading_api.py ├── util ├── osu_api.py ├── song_collection_matcher.py ├── osudb_format.py ├── collections_parser.py ├── osu_parser.py ├── oce_models.py └── osudb_parser.py ├── release_guide.md ├── ui_designs ├── loading.ui ├── notification.ui ├── question.ui ├── beatmapitem.ui ├── about.ui ├── startup.ui ├── missing_maps.ui ├── addsongs.ui └── settings.ui ├── gui ├── loading.py ├── about.py ├── beatmapitem.py ├── missing_maps.py ├── startup.py ├── addsongs.py └── settings.py ├── oce.py ├── .gitignore ├── settings.py ├── oce.spec └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | PyQt5 -------------------------------------------------------------------------------- /development_guide.md: -------------------------------------------------------------------------------- 1 | This file is under construction -------------------------------------------------------------------------------- /icons/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/add.png -------------------------------------------------------------------------------- /icons/oce.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/oce.ico -------------------------------------------------------------------------------- /icons/oce.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/oce.png -------------------------------------------------------------------------------- /icons/osu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/osu.png -------------------------------------------------------------------------------- /icons/up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/up.png -------------------------------------------------------------------------------- /icons/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/blank.png -------------------------------------------------------------------------------- /icons/down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/down.png -------------------------------------------------------------------------------- /icons/remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/remove.png -------------------------------------------------------------------------------- /icons/rename.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/rename.png -------------------------------------------------------------------------------- /icons/add_map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/add_map.png -------------------------------------------------------------------------------- /icons/bloodcat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/bloodcat.png -------------------------------------------------------------------------------- /icons/internet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/internet.png -------------------------------------------------------------------------------- /icons/oce_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/oce_logo.png -------------------------------------------------------------------------------- /icons/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/options.png -------------------------------------------------------------------------------- /icons/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/warning.png -------------------------------------------------------------------------------- /icons/add_mapset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/add_mapset.png -------------------------------------------------------------------------------- /icons/remove_map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/remove_map.png -------------------------------------------------------------------------------- /icons/remove_set.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/remove_set.png -------------------------------------------------------------------------------- /icons/remove_mapset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurocon/Osu-Collections-Editor/HEAD/icons/remove_mapset.png -------------------------------------------------------------------------------- /tests/gui/pyqt_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PyQt5.QtWidgets import QApplication, QMainWindow 3 | 4 | if __name__ == "__main__": 5 | app = QApplication(sys.argv) 6 | window = QMainWindow() 7 | window.show() 8 | sys.exit(app.exec_()) -------------------------------------------------------------------------------- /tests/util/osu_api_test.py: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | 3 | from util.osu_api import get_beatmap_by_hash 4 | 5 | if __name__ == "__main__": 6 | map_hash = "921f3ed9bd3af0d960d11108b61a9dcb" 7 | 8 | print("Trying to find beatmap with hash {}".format(map_hash)) 9 | map_info = get_beatmap_by_hash(map_hash) 10 | 11 | pprint(map_info) 12 | -------------------------------------------------------------------------------- /logging.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root 3 | 4 | [handlers] 5 | keys=consoleHandler,fileHandler 6 | 7 | [formatters] 8 | keys=simpleFormatter 9 | 10 | [logger_root] 11 | level=DEBUG 12 | handlers=consoleHandler,fileHandler 13 | 14 | [handler_consoleHandler] 15 | class=StreamHandler 16 | level=INFO 17 | formatter=simpleFormatter 18 | args=(sys.stdout,) 19 | 20 | [handler_fileHandler] 21 | class=FileHandler 22 | level=INFO 23 | formatter=simpleFormatter 24 | args=["oce.log", "w"] 25 | 26 | [formatter_simpleFormatter] 27 | format=%(created)f - %(thread)d (%(name)s) - [%(levelname)s] %(message)s 28 | 29 | datefmt= -------------------------------------------------------------------------------- /gui_controller/about.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PyQt5 import QtWidgets 4 | import gui.about 5 | 6 | 7 | class About(QtWidgets.QDialog): 8 | def __init__(self): 9 | super(About, self).__init__() 10 | self.log = logging.getLogger(__name__) 11 | 12 | self.ui = gui.about.Ui_AboutDialog() 13 | self.ui.setupUi(self) 14 | 15 | # Set the version string according to the current version 16 | from oce import __version__ as oce_version 17 | from oce import __build__ as oce_build 18 | self.ui.version_text.setText("Version {}, Build {}".format(oce_version, oce_build)) 19 | -------------------------------------------------------------------------------- /util/osu_api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | 5 | from settings import Settings 6 | 7 | """ 8 | Interface for the Osu! API. 9 | """ 10 | 11 | 12 | def get_beatmap_by_hash(map_hash): 13 | settings = Settings.get_instance() 14 | payload = { 15 | 'k': settings.get_setting("osu_api_key"), 16 | 'h': map_hash 17 | } 18 | r = requests.get('https://osu.ppy.sh/api/get_beatmaps', params=payload) 19 | result = r.json() 20 | 21 | log = logging.getLogger(__name__) 22 | 23 | if result: 24 | log.debug("Matched {} to {} - {} [{}] ({})".format(map_hash, 25 | result[0]['artist'], 26 | result[0]['title'], 27 | result[0]['version'], 28 | result[0]['creator'])) 29 | 30 | else: 31 | log.debug("Could not match {}".format(map_hash)) 32 | 33 | return r.json() 34 | -------------------------------------------------------------------------------- /tests/util/collections_parser_test.py: -------------------------------------------------------------------------------- 1 | import util.collections_parser 2 | 3 | if __name__ == "__main__": 4 | 5 | default_path = "/data/OwnCloud/Osu Program/collection.db" 6 | 7 | print("------------------------------------------------------------------") 8 | print(" Osu Collections DB Parser Test") 9 | print("------------------------------------------------------------------") 10 | print("Input the path to your OSU! collection.db, or enter to use the default.") 11 | print("The default is \"{}\"".format(default_path)) 12 | path = input("Path: ") 13 | 14 | if not path: 15 | path = default_path 16 | 17 | print("") 18 | print("You have typed {} as the path.".format(path)) 19 | print("------------------------------------------------------------------") 20 | 21 | collections = util.collections_parser.parse_collections(path) 22 | ccount = collections.collection_count 23 | print("There are {} collection{} in this database:".format(ccount, "" if ccount == 1 else "s")) 24 | 25 | for c in collections.collections: 26 | print("- {} ({} map{})".format(c.name, c.beatmap_count, "" if c.beatmap_count == 1 else "s")) 27 | -------------------------------------------------------------------------------- /tests/util/osudb_parser_test.py: -------------------------------------------------------------------------------- 1 | import util.osudb_parser 2 | from logging.config import fileConfig, logging 3 | 4 | if __name__ == "__main__": 5 | fileConfig('../../logging.conf') 6 | log = logging.getLogger(__name__) 7 | log.debug("Debugging mode enabled...") 8 | log.info("osu!DB test starting...") 9 | 10 | default_path = "/data/OwnCloud/Osu Program/osu!.db" 11 | 12 | print("------------------------------------------------------------------") 13 | print(" Osu! DB Parser Test") 14 | print("------------------------------------------------------------------") 15 | print("Input the path to your osu!.db, or enter to use the default.") 16 | print("The default is \"{}\"".format(default_path)) 17 | path = input("Path: ") 18 | 19 | if not path: 20 | path = default_path 21 | 22 | print("") 23 | print("You have typed {} as the path.".format(path)) 24 | print("------------------------------------------------------------------") 25 | 26 | songs = util.osudb_parser.load_osudb(path) 27 | scount = len(songs.songs) 28 | print("There are {} song{} in this database:".format(scount, "" if scount == 1 else "s")) 29 | 30 | for s in songs.songs: 31 | print("- {}".format(s)) 32 | -------------------------------------------------------------------------------- /release_guide.md: -------------------------------------------------------------------------------- 1 | OCE Release Guide 2 | ----------------- 3 | 4 | How to release a new version of OCE: 5 | 6 | 1. Increase the version and build number accordingly in oce.py. 7 | 2. Make sure everything you want to release is pushed to git. 8 | 3. Create a new tag on the git repository with the new version number. 9 | 4. Clone a new copy of the repository in another directory and switch to the new tag. 10 | 5. Install PyInstaller into the same python environment as you develop OCE in. 11 | (pip install pyinstaller) 12 | 6. Open a terminal in your OCE development folder and activate your python environment. 13 | 7. Run the following command: "pyinstaller oce.spec" to generate the executables from the specifications file. 14 | This will create two directories, "build" and "dist". "build" is a temporary directory for build files. "dist" is the output dir. 15 | In the "dist" directory you will find directories for the normal version of OCE and the portable version. 16 | 8. Test the application by running oce in each of the directories in "dist" 17 | 9. Zip both of the versions of the application and name them "OCE__vx.x" for the normal build and "OCE__Portable_vx.x" for the portable build, where is the operating system the build is for (e.g. Linux), and vx.x is the version (e.g. v1.1b4) 18 | 10. Upload the archives as releases to the repository 19 | -------------------------------------------------------------------------------- /ui_designs/loading.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | LoadingDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 424 10 | 83 11 | 12 | 13 | 14 | Loading... 15 | 16 | 17 | 18 | icons/oce.pngicons/oce.png 19 | 20 | 21 | 22 | 23 | 24 | 25 | 75 26 | true 27 | 28 | 29 | 30 | Loading loading dialog... 31 | 32 | 33 | 34 | 35 | 36 | 37 | ... 38 | 39 | 40 | 41 | 42 | 43 | 44 | 0 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /gui_controller/loading_addsong.py: -------------------------------------------------------------------------------- 1 | from logging.config import logging 2 | from PyQt5 import QtWidgets, QtCore, QtGui 3 | import gui.loading 4 | import settings 5 | import util.collections_parser as cp 6 | import util.osu_parser as op 7 | 8 | 9 | class LoadingAddSong(QtWidgets.QDialog): 10 | progress = QtCore.pyqtSignal(int) 11 | current = QtCore.pyqtSignal(str) 12 | text = QtCore.pyqtSignal(str) 13 | done = QtCore.pyqtSignal() 14 | 15 | def __init__(self): 16 | super(LoadingAddSong, self).__init__() 17 | self.log = logging.getLogger(__name__) 18 | 19 | self.ui = gui.loading.Ui_LoadingDialog() 20 | self.ui.setupUi(self) 21 | 22 | self.setModal(True) 23 | self.setFixedSize(self.width(), self.height()) 24 | self.setWindowFlags(QtCore.Qt.Dialog | QtCore.Qt.WindowTitleHint | QtCore.Qt.CustomizeWindowHint) 25 | 26 | self.progress.connect(self.update_precentage) 27 | self.current.connect(self.update_current) 28 | self.text.connect(self.update_text) 29 | self.done.connect(self.dismiss) 30 | 31 | self.ui.progressbar.setRange(0, 100) 32 | 33 | def keyPressEvent(self, event): 34 | if event.key() not in [QtCore.Qt.Key_Escape, QtCore.Qt.Key_Alt, QtCore.Qt.Key_AltGr, QtCore.Qt.Key_F4]: 35 | super(LoadingAddSong, self).keyPressEvent(event) 36 | 37 | def update_precentage(self, percentage): 38 | self.ui.progressbar.setValue(percentage) 39 | QtWidgets.qApp.processEvents() 40 | 41 | def update_text(self, text): 42 | if len(text) > 33: 43 | text = text[:40] + "..." 44 | self.ui.loading_label.setText(text) 45 | 46 | def update_current(self, text): 47 | if len(text) > 33: 48 | text = text[:40] + "..." 49 | self.ui.loading_current_label.setText(text) 50 | 51 | def open(self): 52 | super(LoadingAddSong, self).open() 53 | 54 | def dismiss(self): 55 | self.hide() 56 | -------------------------------------------------------------------------------- /gui/loading.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'loading.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.5.1 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | from PyQt5 import QtCore, QtGui, QtWidgets 10 | 11 | class Ui_LoadingDialog(object): 12 | def setupUi(self, LoadingDialog): 13 | LoadingDialog.setObjectName("LoadingDialog") 14 | LoadingDialog.resize(424, 83) 15 | icon = QtGui.QIcon() 16 | icon.addPixmap(QtGui.QPixmap("icons/oce.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 17 | LoadingDialog.setWindowIcon(icon) 18 | self.verticalLayout = QtWidgets.QVBoxLayout(LoadingDialog) 19 | self.verticalLayout.setObjectName("verticalLayout") 20 | self.loading_label = QtWidgets.QLabel(LoadingDialog) 21 | font = QtGui.QFont() 22 | font.setBold(True) 23 | font.setWeight(75) 24 | self.loading_label.setFont(font) 25 | self.loading_label.setObjectName("loading_label") 26 | self.verticalLayout.addWidget(self.loading_label) 27 | self.loading_current_label = QtWidgets.QLabel(LoadingDialog) 28 | self.loading_current_label.setObjectName("loading_current_label") 29 | self.verticalLayout.addWidget(self.loading_current_label) 30 | self.progressbar = QtWidgets.QProgressBar(LoadingDialog) 31 | self.progressbar.setProperty("value", 0) 32 | self.progressbar.setObjectName("progressbar") 33 | self.verticalLayout.addWidget(self.progressbar) 34 | 35 | self.retranslateUi(LoadingDialog) 36 | QtCore.QMetaObject.connectSlotsByName(LoadingDialog) 37 | 38 | def retranslateUi(self, LoadingDialog): 39 | _translate = QtCore.QCoreApplication.translate 40 | LoadingDialog.setWindowTitle(_translate("LoadingDialog", "Loading...")) 41 | self.loading_label.setText(_translate("LoadingDialog", "Loading loading dialog...")) 42 | self.loading_current_label.setText(_translate("LoadingDialog", "...")) 43 | 44 | -------------------------------------------------------------------------------- /gui_controller/beatmapitem.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PyQt5 import QtWidgets, QtGui 4 | import gui.beatmapitem 5 | 6 | 7 | class BeatmapItem(QtWidgets.QWidget): 8 | def __init__(self, difficulty): 9 | super(BeatmapItem, self).__init__() 10 | self.log = logging.getLogger(__name__) 11 | 12 | self.ui = gui.beatmapitem.Ui_BeatmapItem() 13 | self.ui.setupUi(self) 14 | self.difficulty = difficulty 15 | 16 | def string_representation(self): 17 | return "{} - {} ({}) [{}]".format(self.difficulty.artist, self.difficulty.name, 18 | self.difficulty.mapper, self.difficulty.difficulty) 19 | 20 | def set_name(self, name, artist=None): 21 | if artist: 22 | self.ui.name_label.setText("{} - {}".format(artist, name)) 23 | else: 24 | self.ui.name_label.setText("{}".format(name)) 25 | 26 | def set_artist(self, mapper): 27 | self.ui.mapper_label.setText("({})".format(mapper)) 28 | 29 | def set_difficulty(self, difficulty): 30 | self.ui.difficulty_label.setText(difficulty) 31 | 32 | def set_stars(self, stars): 33 | self.ui.star_label.setText("({})".format(stars)) 34 | 35 | def set_unmatched(self): 36 | self.ui.warning_label.setPixmap(QtGui.QPixmap("icons/warning.png")) 37 | self.ui.warning_label.setStatusTip("You do not have this song in your songs directory.") 38 | self.ui.warning_label.setToolTip("You do not have this song in your songs directory.") 39 | 40 | def set_from_internet(self): 41 | self.ui.warning_label.setPixmap(QtGui.QPixmap("icons/internet.png")) 42 | self.ui.warning_label.setStatusTip("You do not have this song. Its details were loaded from the internet.") 43 | self.ui.warning_label.setToolTip("You do not have this song. Its details were loaded from the internet.") 44 | 45 | def set_local(self): 46 | self.ui.warning_label.setPixmap(QtGui.QPixmap("icons/local.png")) 47 | self.ui.warning_label.setStatusTip("This is a local beatmap.") 48 | -------------------------------------------------------------------------------- /ui_designs/notification.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | NotificationDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 100 11 | 12 | 13 | 14 | Notification 15 | 16 | 17 | 18 | icons/oce.pngicons/oce.png 19 | 20 | 21 | 22 | 23 | 24 | Something happened. 25 | 26 | 27 | true 28 | 29 | 30 | 31 | 32 | 33 | 34 | Qt::Horizontal 35 | 36 | 37 | QDialogButtonBox::Ok 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | buttonBox 47 | accepted() 48 | NotificationDialog 49 | accept() 50 | 51 | 52 | 248 53 | 254 54 | 55 | 56 | 157 57 | 274 58 | 59 | 60 | 61 | 62 | buttonBox 63 | rejected() 64 | NotificationDialog 65 | reject() 66 | 67 | 68 | 316 69 | 260 70 | 71 | 72 | 286 73 | 274 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /ui_designs/question.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | QuestionDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 100 11 | 12 | 13 | 14 | Question 15 | 16 | 17 | 18 | icons/oce.pngicons/oce.png 19 | 20 | 21 | 22 | 23 | 24 | Would you like to do something? 25 | 26 | 27 | true 28 | 29 | 30 | 31 | 32 | 33 | 34 | Qt::Horizontal 35 | 36 | 37 | QDialogButtonBox::No|QDialogButtonBox::Yes 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | buttonBox 47 | accepted() 48 | QuestionDialog 49 | accept() 50 | 51 | 52 | 248 53 | 254 54 | 55 | 56 | 157 57 | 274 58 | 59 | 60 | 61 | 62 | buttonBox 63 | rejected() 64 | QuestionDialog 65 | reject() 66 | 67 | 68 | 316 69 | 260 70 | 71 | 72 | 286 73 | 274 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /tests/util/osu_parser_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pprint import pprint 3 | 4 | from util.osu_parser import find_songs 5 | from util.oce_models import Difficulty2, Song 6 | 7 | if __name__ == "__main__": 8 | 9 | default_path = "/data/OwnCloud/Osu Songs/" 10 | 11 | print("------------------------------------------------------------------") 12 | print(" Osu Parser Test") 13 | print("------------------------------------------------------------------") 14 | print("Input your OSU! music directory path, or enter to use the default.") 15 | print("The default is \"{}\"".format(default_path)) 16 | path = input("Path: ") 17 | 18 | if not path: 19 | path = default_path 20 | 21 | print("") 22 | print("You have typed {} as the path.".format(path)) 23 | print("------------------------------------------------------------------") 24 | 25 | # Find first song 26 | song_dirs = find_songs(path) 27 | sorted_song_dirs = sorted(song_dirs) 28 | 29 | song = None 30 | difficulty = None 31 | song_id = 0 32 | while not difficulty: 33 | if len(sorted_song_dirs) > song_id: 34 | song = sorted_song_dirs[song_id] 35 | else: 36 | print("There are no usable songs in this directory. Please restart and use a proper dir.") 37 | sys.exit(1) 38 | 39 | diffs = song_dirs.get(song) 40 | sorted_diffs = sorted(diffs) 41 | if len(sorted_diffs) > 0: 42 | difficulty = sorted_diffs[0] 43 | else: 44 | song_id += 1 45 | print("Cannot use song {}, no difficulties.".format(song)) 46 | 47 | print("------------------------------------------------------------------") 48 | print("Using song: {}".format(song)) 49 | print("Using difficulty: {}".format(difficulty)) 50 | 51 | beatmap_diff = Difficulty2.from_file("/".join([path, song, difficulty])) 52 | beatmap = Song() 53 | beatmap.add_difficulty(beatmap_diff) 54 | 55 | print("------------------------------------------------------------------") 56 | print("Beatmap: {}".format(beatmap_diff.get_path())) 57 | pprint(beatmap_diff.get_data()) 58 | -------------------------------------------------------------------------------- /oce.py: -------------------------------------------------------------------------------- 1 | import signal 2 | from logging.config import fileConfig, logging 3 | 4 | from PyQt5.QtCore import QTimer 5 | from PyQt5.QtWidgets import QApplication, QMessageBox 6 | 7 | from gui_controller.startup import Startup 8 | from gui_controller.main import MainWindow 9 | import sys 10 | 11 | # Version is in the format {release}.{subrelease}{a|b|g}{a,b,g number} 12 | # Where a is alpha, b is beta, g is gamma, nothing is release 13 | # a,b,g number is the number of the alpha/beta/gamma release. 14 | # Releases don't need to have a number, they can just be version 1.0, 2.4, etc. 15 | __version__ = "1.1.2" 16 | __build__ = 103 17 | 18 | 19 | def main(): 20 | # Add Interrupt handler 21 | signal.signal(signal.SIGINT, sigint_handler) 22 | 23 | # Create application 24 | app = QApplication(sys.argv) 25 | 26 | # Add timer to make time to handle interrupts 27 | timer = QTimer() 28 | timer.start(500) 29 | timer.timeout.connect(lambda: None) # Let the interpreter run 30 | 31 | # Show the window and start the app 32 | form = MainWindow() 33 | form.show() 34 | app.exec_() 35 | 36 | 37 | def startup(): 38 | app = QApplication(sys.argv) 39 | form = Startup() 40 | form.show() 41 | app.exec_() 42 | 43 | 44 | def sigint_handler(*args): 45 | """Handler for the SIGINT signal.""" 46 | sys.stderr.write('\r') 47 | 48 | from settings import Settings 49 | s = Settings.get_instance() 50 | 51 | try: 52 | show_dialog = bool(s.get_setting("show_shutdown_dialog", True)) 53 | except ValueError: 54 | s.set_setting("show_shutdown_dialog", True) 55 | show_dialog = True 56 | 57 | if show_dialog: 58 | if QMessageBox.question(None, 'Stopping', "Are you sure you want to quit?", 59 | QMessageBox.Yes | QMessageBox.No, 60 | QMessageBox.No) == QMessageBox.Yes: 61 | QApplication.quit() 62 | else: 63 | QApplication.quit() 64 | 65 | if __name__ == "__main__": 66 | fileConfig('logging.conf') 67 | log = logging.getLogger(__name__) 68 | log.debug("Debugging mode enabled...") 69 | log.info("osu! Collection Editor starting...") 70 | 71 | main() 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 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 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | ### JetBrains template 62 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 63 | 64 | *.iml 65 | 66 | ## Directory-based project format: 67 | .idea/ 68 | # if you remove the above rule, at least ignore the following: 69 | 70 | # User-specific stuff: 71 | # .idea/workspace.xml 72 | # .idea/tasks.xml 73 | # .idea/dictionaries 74 | 75 | # Sensitive or high-churn files: 76 | # .idea/dataSources.ids 77 | # .idea/dataSources.xml 78 | # .idea/sqlDataSources.xml 79 | # .idea/dynamic.xml 80 | # .idea/uiDesigner.xml 81 | 82 | # Gradle: 83 | # .idea/gradle.xml 84 | # .idea/libraries 85 | 86 | # Mongo Explorer plugin: 87 | # .idea/mongoSettings.xml 88 | 89 | ## File-based project format: 90 | *.ipr 91 | *.iws 92 | 93 | ## Plugin-specific files: 94 | 95 | # IntelliJ 96 | /out/ 97 | 98 | # mpeltonen/sbt-idea plugin 99 | .idea_modules/ 100 | 101 | # JIRA plugin 102 | atlassian-ide-plugin.xml 103 | 104 | # Crashlytics plugin (for Android Studio and IntelliJ) 105 | com_crashlytics_export_strings.xml 106 | crashlytics.properties 107 | crashlytics-build.properties 108 | 109 | 110 | ## 111 | # Project specific gitignores 112 | ## 113 | 114 | # Icons in the UI designs folder 115 | ui_designs/icons/ 116 | 117 | # Local settings file 118 | settings.json -------------------------------------------------------------------------------- /util/song_collection_matcher.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def match_songs_to_collections(songs, collections): 5 | """ 6 | Match songs to collections 7 | :param songs: Songs object 8 | :type songs: Songs 9 | :param collections: Collections object 10 | :type collections: Collections 11 | :return: Tuple of matched collections, number matched, number unmatched, and a list of unmatched maps 12 | :rtype: tuple(Collections, int, int, list[CollectionMap]) 13 | """ 14 | 15 | log = logging.getLogger(__name__) 16 | log.debug("Matching {} songs and {} collections".format(len(songs.songs), len(collections.collections))) 17 | 18 | # Construct dictionary of {"hash": ("beatmap", "mapset")} 19 | lookup_dict = {} 20 | for song in songs.songs: 21 | # Create a deep copy of the song 22 | song_dc = song.deep_copy() 23 | for diff in song_dc.difficulties: 24 | lookup_dict[diff.hash] = (diff, song) 25 | 26 | matched_count = 0 27 | unmatched_count = 0 28 | unmatched_maps = [] 29 | 30 | for collection in collections.collections: 31 | for diff in collection.beatmaps: 32 | try: 33 | diff.difficulty, diff.mapset = lookup_dict[diff.hash] 34 | if diff.mapset not in collection.mapsets: 35 | collection.mapsets.append(diff.mapset) 36 | matched_count += 1 37 | except KeyError: 38 | collection.unmatched.append(diff) 39 | unmatched_maps.append(diff) 40 | unmatched_count += 1 41 | 42 | # Get a list of all diffs in all mapsets of this collection 43 | col_songs = [] 44 | for mapset in collection.mapsets: 45 | for diff in mapset.difficulties: 46 | col_songs.append(diff) 47 | 48 | # # Remove all of the difficulties actually present from the list of diffs 49 | # for collmap in collection.beatmaps: 50 | # if collmap.difficulty in col_songs: 51 | # col_songs.remove(collmap.difficulty) 52 | # 53 | # # Remove all left over songs in the list from the mapsets in the collection 54 | # for m in collection.mapsets: 55 | # diffs = m.difficulties 56 | # for d in diffs: 57 | # if d in col_songs: 58 | # m.difficulties.remove(d) 59 | 60 | log.debug("Matching done. {} matched and {} unmatched.".format(matched_count, unmatched_count)) 61 | 62 | return collections, matched_count, unmatched_count, unmatched_maps 63 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import os 4 | 5 | 6 | class Settings: 7 | """ 8 | Storage class for application settings 9 | """ 10 | 11 | _instance = None 12 | 13 | # Static variables that are not user-changeable 14 | 15 | # Osu beatmap lookup URL ({0} will be replaced with the beatmap id) 16 | OSU_BEATMAP_URL = "https://osu.ppy.sh/b/{0}" 17 | # Bloodcat beatmap search URL ({0} will be replaced with the beatmap id) 18 | BLOODCAT_SEARCH_URL = "http://bloodcat.com/osu/?q={0}&c=b&s=&m=" 19 | 20 | # Dynamic settings loading from file 21 | def __init__(self): 22 | self.log = logging.getLogger(__name__) 23 | self.default_settings = { 24 | 'osu_api_key': "", 25 | 'download_from_api': 0, 26 | 27 | 'default_loadfrom': 0, 28 | 'default_osudb': "", 29 | 'default_songs_folder': "", 30 | 'default_collectiondb': "", 31 | 32 | 'show_shutdown_dialog': True, 33 | 'show_api_explanation_dialog': True, 34 | 'show_collection_delete_dialog': True, 35 | 'show_remove_song_dialog': True, 36 | 'show_remove_mapset_dialog': True, 37 | } 38 | 39 | needs_save = False 40 | 41 | if os.path.exists('settings.json'): 42 | with open('settings.json', 'r', encoding='utf8') as f: 43 | self.settings = json.load(f) 44 | else: 45 | self.settings = {} 46 | needs_save = True 47 | 48 | # Check if any default settings are missing and set them. 49 | for key, value in self.default_settings.items(): 50 | if key not in self.settings.keys(): 51 | self.settings[key] = value 52 | needs_save = True 53 | 54 | if needs_save: 55 | with open('settings.json', 'w', encoding='utf8') as f: 56 | json.dump(self.settings, f, sort_keys=True, indent=4) 57 | 58 | def get_setting(self, name, default=None): 59 | res = self.settings.get(name, default) 60 | self.log.debug("Getting setting {}: {}".format(name, res)) 61 | return res 62 | 63 | def set_setting(self, name, value): 64 | self.log.debug("Setting setting {} to {}".format(name, value)) 65 | self.settings[name] = value 66 | 67 | def remove_setting(self, name): 68 | if name in self.settings.keys(): 69 | self.log.debug("Removing setting {}".format(name)) 70 | del self.settings[name] 71 | else: 72 | self.log.debug("Could not remove setting {}, it does not exist".format(name)) 73 | 74 | @classmethod 75 | def get_instance(cls): 76 | """ 77 | :return: Settings instance 78 | :rtype: Settings 79 | """ 80 | if not cls._instance: 81 | cls._instance = cls() 82 | return cls._instance 83 | -------------------------------------------------------------------------------- /gui_controller/missing_maps.py: -------------------------------------------------------------------------------- 1 | from logging.config import logging 2 | from PyQt5 import QtWidgets, QtGui, QtCore 3 | import gui.missing_maps 4 | from settings import Settings 5 | import webbrowser 6 | 7 | 8 | class MissingMaps(QtWidgets.QDialog): 9 | 10 | def __init__(self, api, unmatched): 11 | super(MissingMaps, self).__init__() 12 | self.log = logging.getLogger(__name__) 13 | 14 | self.ui = gui.missing_maps.Ui_MissingMapsDialog() 15 | self.ui.setupUi(self) 16 | 17 | self.setModal(True) 18 | # self.setWindowFlags(QtCore.Qt.Dialog | QtCore.Qt.WindowTitleHint | QtCore.Qt.CustomizeWindowHint) 19 | 20 | # Fix the column sizes so the last two colums (links) are small and fixed, and the mapper colum resizes 21 | header = QtWidgets.QHeaderView(QtCore.Qt.Horizontal, self.ui.api_table) 22 | self.ui.api_table.setHorizontalHeader(header) 23 | header.setSectionResizeMode(0, QtWidgets.QHeaderView.Interactive) 24 | header.setSectionResizeMode(1, QtWidgets.QHeaderView.Interactive) 25 | header.setSectionResizeMode(2, QtWidgets.QHeaderView.Interactive) 26 | header.setSectionResizeMode(3, QtWidgets.QHeaderView.Stretch) 27 | header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) 28 | header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) 29 | 30 | # Set initial sizes for interactive columns 31 | for i in range(0, 3): 32 | header.resizeSection(i, 160) 33 | 34 | # Set fixed sizes of icon columns 35 | for i in range(4, 6): 36 | header.resizeSection(i, 40) 37 | 38 | self.api_maps = api if api is not None else [] 39 | self.unmatched_maps = unmatched if unmatched is not None else [] 40 | self.link_lookup = {} 41 | 42 | # Connect open_link function to table clicked signal 43 | self.ui.api_table.cellClicked.connect(self.open_link) 44 | 45 | # Load icons 46 | self.osu_icon = QtGui.QIcon() 47 | self.osu_icon.addPixmap(QtGui.QPixmap("icons/osu.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 48 | self.bloodcat_icon = QtGui.QIcon() 49 | self.bloodcat_icon.addPixmap(QtGui.QPixmap("icons/bloodcat.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 50 | 51 | def exec_(self): 52 | 53 | # Add api maps 54 | self.ui.api_table.setRowCount(len(self.api_maps)) 55 | for i, map in enumerate(self.api_maps): 56 | self.log.debug("Setting row {} to {}".format(i, map.difficulty)) 57 | artist = QtWidgets.QTableWidgetItem(map.difficulty.artist) 58 | title = QtWidgets.QTableWidgetItem(map.difficulty.name) 59 | mapper = QtWidgets.QTableWidgetItem(map.difficulty.mapper) 60 | difficulty = QtWidgets.QTableWidgetItem(map.difficulty.difficulty) 61 | 62 | self.link_lookup[i] = [Settings.OSU_BEATMAP_URL.format(map.difficulty.beatmap_id), 63 | Settings.BLOODCAT_SEARCH_URL.format(map.difficulty.beatmap_id)] 64 | 65 | osu = QtWidgets.QTableWidgetItem("") 66 | osu.setIcon(self.osu_icon) 67 | bloodcat = QtWidgets.QTableWidgetItem("") 68 | bloodcat.setIcon(self.bloodcat_icon) 69 | 70 | for n, item in enumerate([artist, title, mapper, difficulty, osu, bloodcat]): 71 | self.ui.api_table.setItem(i, n, item) 72 | item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) 73 | 74 | # Add unmatched maps 75 | for map in self.unmatched_maps: 76 | self.ui.unmatched_textbox.append("{}".format(map.hash)) 77 | 78 | # Remove empty ui elements 79 | if len(self.api_maps) != 0 or len(self.unmatched_maps) != 0: 80 | self.ui.no_missing_label.hide() 81 | 82 | if len(self.api_maps) == 0: 83 | self.ui.api_box.hide() 84 | 85 | if len(self.unmatched_maps) == 0: 86 | self.ui.unmatched_box.hide() 87 | 88 | super(MissingMaps, self).exec_() 89 | 90 | def open_link(self, row, column): 91 | if column == 4: 92 | webbrowser.open(self.link_lookup[row][0]) 93 | elif column == 5: 94 | webbrowser.open(self.link_lookup[row][1]) 95 | 96 | def dismiss(self): 97 | self.hide() 98 | -------------------------------------------------------------------------------- /gui/about.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'about.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.5.1 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | from PyQt5 import QtCore, QtGui, QtWidgets 10 | 11 | class Ui_AboutDialog(object): 12 | def setupUi(self, AboutDialog): 13 | AboutDialog.setObjectName("AboutDialog") 14 | AboutDialog.setEnabled(True) 15 | AboutDialog.resize(427, 160) 16 | icon = QtGui.QIcon() 17 | icon.addPixmap(QtGui.QPixmap("icons/oce.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 18 | AboutDialog.setWindowIcon(icon) 19 | self.verticalLayout = QtWidgets.QVBoxLayout(AboutDialog) 20 | self.verticalLayout.setSizeConstraint(QtWidgets.QLayout.SetFixedSize) 21 | self.verticalLayout.setSpacing(0) 22 | self.verticalLayout.setObjectName("verticalLayout") 23 | self.app_title = QtWidgets.QLabel(AboutDialog) 24 | font = QtGui.QFont() 25 | font.setPointSize(18) 26 | font.setBold(True) 27 | font.setWeight(75) 28 | self.app_title.setFont(font) 29 | self.app_title.setAlignment(QtCore.Qt.AlignCenter) 30 | self.app_title.setObjectName("app_title") 31 | self.verticalLayout.addWidget(self.app_title) 32 | self.license_text = QtWidgets.QLabel(AboutDialog) 33 | font = QtGui.QFont() 34 | font.setPointSize(8) 35 | self.license_text.setFont(font) 36 | self.license_text.setAlignment(QtCore.Qt.AlignCenter) 37 | self.license_text.setOpenExternalLinks(True) 38 | self.license_text.setObjectName("license_text") 39 | self.verticalLayout.addWidget(self.license_text) 40 | spacerItem = QtWidgets.QSpacerItem(20, 10, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.MinimumExpanding) 41 | self.verticalLayout.addItem(spacerItem) 42 | self.developer_text = QtWidgets.QLabel(AboutDialog) 43 | self.developer_text.setAlignment(QtCore.Qt.AlignCenter) 44 | self.developer_text.setOpenExternalLinks(True) 45 | self.developer_text.setObjectName("developer_text") 46 | self.verticalLayout.addWidget(self.developer_text) 47 | spacerItem1 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.MinimumExpanding) 48 | self.verticalLayout.addItem(spacerItem1) 49 | self.version_text = QtWidgets.QLabel(AboutDialog) 50 | self.version_text.setObjectName("version_text") 51 | self.verticalLayout.addWidget(self.version_text) 52 | self.button_box = QtWidgets.QDialogButtonBox(AboutDialog) 53 | self.button_box.setOrientation(QtCore.Qt.Horizontal) 54 | self.button_box.setStandardButtons(QtWidgets.QDialogButtonBox.Ok) 55 | self.button_box.setCenterButtons(False) 56 | self.button_box.setObjectName("button_box") 57 | self.verticalLayout.addWidget(self.button_box) 58 | 59 | self.retranslateUi(AboutDialog) 60 | self.button_box.accepted.connect(AboutDialog.accept) 61 | self.button_box.rejected.connect(AboutDialog.reject) 62 | QtCore.QMetaObject.connectSlotsByName(AboutDialog) 63 | 64 | def retranslateUi(self, AboutDialog): 65 | _translate = QtCore.QCoreApplication.translate 66 | AboutDialog.setWindowTitle(_translate("AboutDialog", "About osu! Collection Editor")) 67 | self.app_title.setText(_translate("AboutDialog", "osu! Collection Editor")) 68 | self.license_text.setText(_translate("AboutDialog", "

Distributed under the GNU General Public License Version 3

")) 69 | self.developer_text.setText(_translate("AboutDialog", "

Created by Kevin Alberts (Kurocon)
Source on GitHub

")) 70 | self.version_text.setText(_translate("AboutDialog", "Version 0.1.dev1, Build 1")) 71 | 72 | -------------------------------------------------------------------------------- /tests/util/collection_beatmap_matcher_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pprint import pprint 3 | 4 | import util.osu_parser 5 | import util.collections_parser 6 | 7 | from util.collections_parser import parse_collections 8 | from util.osu_parser import find_songs, OsuBeatmapVersionTooOldException, OsuFileFormatException 9 | from util.oce_models import Difficulty2, Song, Songs 10 | 11 | if __name__ == "__main__": 12 | 13 | default_collection_path = "/data/OwnCloud/Osu Program/collection.db" 14 | default_songs_path = "/data/OwnCloud/Osu Songs" 15 | 16 | print("------------------------------------------------------------------") 17 | print(" Osu Collection<->Beatmap matcher Test") 18 | print("------------------------------------------------------------------") 19 | print("Input the path to your OSU! collection.db, or enter to use the default.") 20 | print("The default is \"{}\"".format(default_collection_path)) 21 | 22 | collection_path = input("Path: ") 23 | 24 | if not collection_path: 25 | collection_path = default_collection_path 26 | 27 | print("Input your OSU! music directory path, or enter to use the default.") 28 | print("The default is \"{}\"".format(default_songs_path)) 29 | 30 | songs_path = input("Path: ") 31 | 32 | if not songs_path: 33 | songs_path = default_songs_path 34 | 35 | print("") 36 | print("Using {} as the collections path.".format(collection_path)) 37 | print("Using {} as the songs path.".format(songs_path)) 38 | print("------------------------------------------------------------------") 39 | print("") 40 | print("Loading collection database...") 41 | 42 | collections = parse_collections(collection_path) 43 | ccount = collections.collection_count 44 | scount = sum([i.beatmap_count for i in collections.collections]) 45 | 46 | print("There are {} collection{} with a total of {} song{} in this database.".format(ccount, 47 | "" if ccount == 1 else "s", 48 | scount, 49 | "" if scount == 1 else "s")) 50 | print("") 51 | print("------------------------------------------------------------------") 52 | print("") 53 | print("Loading songs...") 54 | 55 | song_dirs = find_songs(songs_path) 56 | sorted_song_dirs = sorted(song_dirs) 57 | 58 | songs = Songs() 59 | 60 | for song_str in sorted_song_dirs: 61 | song = Song() 62 | difficulties = song_dirs.get(song_str) 63 | sorted_difficulties = sorted(difficulties) 64 | 65 | for difficulty_str in sorted_difficulties: 66 | try: 67 | difficulty = Difficulty2.from_file("/".join([songs_path, song_str, difficulty_str])) 68 | song.add_difficulty(difficulty) 69 | except OsuBeatmapVersionTooOldException or OsuFileFormatException: 70 | pass 71 | 72 | songs.add_song(song) 73 | 74 | print("There are {} song{} in the songs directory.".format(len(songs.songs), "" if len(songs.songs) == 1 else "s")) 75 | print("") 76 | print("------------------------------------------------------------------") 77 | print("") 78 | print("Matching songs in collection to songs in song folder...") 79 | 80 | # Construct dictionary of {"hash": "beatmap"} 81 | lookup_dict = {} 82 | for song in songs.songs: 83 | for diff in song.difficulties: 84 | lookup_dict[diff.hash] = diff 85 | 86 | matched = 0 87 | unmatched = 0 88 | 89 | for collection in collections.collections: 90 | for diff in collection.beatmaps: 91 | try: 92 | diff.difficulty = lookup_dict[diff.hash] 93 | matched += 1 94 | except KeyError: 95 | unmatched += 1 96 | 97 | print("Matched {} hashes to songs, could not match {} hashes.".format(matched, unmatched)) 98 | print("") 99 | print("------------------------------------------------------------------") 100 | print("") 101 | for collection in collections.collections: 102 | print("Collection {}:".format(collection.name)) 103 | for song in collection.beatmaps: 104 | if song.difficulty: 105 | print("|- {}".format(song.difficulty.name)) 106 | else: 107 | print("|- {} [UNMATCHED]".format(song.hash)) 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /gui_controller/loading.py: -------------------------------------------------------------------------------- 1 | from logging.config import logging 2 | from PyQt5 import QtWidgets, QtCore, QtGui 3 | import gui.loading 4 | import settings 5 | import os 6 | import util.collections_parser as cp 7 | import util.osu_parser as op 8 | import util.osudb_parser as odp 9 | 10 | 11 | class Loading(QtWidgets.QDialog): 12 | progress = QtCore.pyqtSignal(int) 13 | current = QtCore.pyqtSignal(str) 14 | text = QtCore.pyqtSignal(str) 15 | done = QtCore.pyqtSignal() 16 | 17 | def __init__(self, collectionfile, songdb): 18 | super(Loading, self).__init__() 19 | self.log = logging.getLogger(__name__) 20 | 21 | self.ui = gui.loading.Ui_LoadingDialog() 22 | self.ui.setupUi(self) 23 | 24 | self.setModal(True) 25 | self.setFixedSize(self.width(), self.height()) 26 | self.setWindowFlags(QtCore.Qt.Dialog | QtCore.Qt.WindowTitleHint | QtCore.Qt.CustomizeWindowHint) 27 | 28 | self.collections = None 29 | self.songs = None 30 | self.collection_file = collectionfile 31 | self.song_db = songdb 32 | self.db_is_directory = os.path.isdir(self.song_db) 33 | 34 | self.log.debug("Loading songdb {}{} and collectiondb {}".format(self.song_db, " (dir)" if self.db_is_directory else "", self.collection_file)) 35 | 36 | self.progress.connect(self.update_precentage) 37 | self.current.connect(self.update_current) 38 | self.text.connect(self.update_text) 39 | self.done.connect(self.dismiss) 40 | 41 | self.ui.progressbar.setRange(0, 100) 42 | 43 | self.thread = QtCore.QThread() 44 | 45 | def keyPressEvent(self, event): 46 | if event.key() not in [QtCore.Qt.Key_Escape, QtCore.Qt.Key_Alt, QtCore.Qt.Key_AltGr, QtCore.Qt.Key_F4]: 47 | super(Loading, self).keyPressEvent(event) 48 | 49 | def update_precentage(self, percentage): 50 | self.ui.progressbar.setValue(percentage) 51 | QtWidgets.qApp.processEvents() 52 | 53 | def update_text(self, text): 54 | if len(text) > 33: 55 | text = text[:40] + "..." 56 | self.ui.loading_label.setText(text) 57 | 58 | def update_current(self, text): 59 | if len(text) > 33: 60 | text = text[:40] + "..." 61 | self.ui.loading_current_label.setText(text) 62 | 63 | def exec_(self): 64 | w = LoadTask(self.collection_file, self.song_db, self.db_is_directory, self) 65 | w.moveToThread(self.thread) 66 | self.thread.started.connect(w.work) 67 | self.thread.start() 68 | super(Loading, self).exec_() 69 | 70 | def dismiss(self): 71 | self.hide() 72 | 73 | 74 | class LoadTask(QtCore.QObject): 75 | def __init__(self, cf, sd, sd_isdir, dialog): 76 | super(LoadTask, self).__init__() 77 | self.collection_file = cf 78 | self.song_db = sd 79 | self.db_is_directory = sd_isdir 80 | self.dialog = dialog 81 | self.settings = settings.Settings.get_instance() 82 | self.log = logging.getLogger(__name__) 83 | 84 | def work(self): 85 | # Load collections from file 86 | self.log.debug("Loading collections...") 87 | self.dialog.text.emit("Loading collections...") 88 | try: 89 | self.dialog.collections = cp.parse_collections_gui(self.collection_file, self.dialog) 90 | except Exception as e: 91 | self.log.error("Error while parsing collections.db: {}".format(e)) 92 | import traceback 93 | traceback.print_exc() 94 | self.dialog.collections = None 95 | 96 | # Load songs from dir 97 | self.log.debug("Loading songs...") 98 | self.dialog.text.emit("Loading songs...") 99 | if self.db_is_directory: 100 | try: 101 | self.dialog.songs = op.load_songs_from_dir_gui(self.song_db, self.dialog) 102 | except Exception as e: 103 | self.log.error("Error while parsing Song folder: {}".format(e)) 104 | import traceback 105 | traceback.print_exc() 106 | self.dialog.songs = None 107 | else: 108 | try: 109 | self.dialog.songs = odp.load_osudb_gui(self.song_db, self.dialog) 110 | except Exception as e: 111 | self.log.error("Error while parsing osu!.db: {}".format(e)) 112 | import traceback 113 | traceback.print_exc() 114 | self.dialog.songs = None 115 | 116 | # Notify we're done. 117 | self.dialog.done.emit() 118 | -------------------------------------------------------------------------------- /oce.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | import os 3 | from glob import glob 4 | 5 | print("#################################") 6 | print("# Building OsuCollectionsEditor #") 7 | print("#################################") 8 | 9 | block_cipher = None 10 | 11 | # Add staticfiles to build 12 | static_files = [ 13 | ('logging.conf', '.'), 14 | ('icons', 'icons') 15 | ] 16 | 17 | 18 | # Build the exe with libraries 19 | 20 | print("") 21 | print("Building OCE with separate libraries") 22 | print("####################################") 23 | print("") 24 | print("Analysing...") 25 | print("") 26 | 27 | a = Analysis(['oce.py'], 28 | pathex=[SPECPATH], 29 | binaries=static_files, 30 | datas=None, 31 | hiddenimports=[], 32 | hookspath=[], 33 | runtime_hooks=[], 34 | excludes=[], 35 | win_no_prefer_redirects=False, 36 | win_private_assemblies=False, 37 | cipher=block_cipher) 38 | 39 | print("") 40 | print("Archiving") 41 | print("") 42 | 43 | pyz = PYZ(a.pure, a.zipped_data, 44 | cipher=block_cipher) 45 | 46 | print("") 47 | print("Compiling executable") 48 | print("") 49 | 50 | exe = EXE(pyz, 51 | a.scripts, 52 | exclude_binaries=True, 53 | name='oce', 54 | debug=False, 55 | strip=False, 56 | upx=True, 57 | console=False, 58 | icon='icons\oce.ico' ) 59 | 60 | print("") 61 | print("Collecting libraries") 62 | print("") 63 | 64 | coll = COLLECT(exe, 65 | a.binaries, 66 | a.zipfiles, 67 | a.datas, 68 | strip=False, 69 | upx=True, 70 | name='oce') 71 | 72 | 73 | # Build the portable exe 74 | 75 | print("") 76 | print("Building OCE portable (with included libraries)") 77 | print("###############################################") 78 | print("") 79 | print("Analysing") 80 | print("") 81 | 82 | a = Analysis(['oce.py'], 83 | pathex=[SPECPATH], 84 | binaries=static_files, 85 | datas=static_files, 86 | hiddenimports=[], 87 | hookspath=[], 88 | runtime_hooks=[], 89 | excludes=[], 90 | win_no_prefer_redirects=False, 91 | win_private_assemblies=False, 92 | cipher=block_cipher) 93 | 94 | print("") 95 | print("Archiving") 96 | print("") 97 | 98 | pyz = PYZ(a.pure, a.zipped_data, 99 | cipher=block_cipher) 100 | 101 | print("") 102 | print("Building executable") 103 | print("") 104 | 105 | portable_exe = EXE(pyz, 106 | a.scripts, 107 | a.binaries, 108 | a.zipfiles, 109 | a.datas, 110 | name='oce_p', 111 | debug=False, 112 | strip=False, 113 | upx=True, 114 | console=False, 115 | icon='icons\oce.ico' ) 116 | 117 | print("") 118 | print("Collecting static files") 119 | print("") 120 | 121 | # Copy static files to portable dir 122 | a.datas += [('logging.conf', 'logging.conf', 'DATA')] 123 | for i in glob(os.path.join('icons', '*')): 124 | a.datas += [(i, i, 'DATA')] 125 | 126 | portable_coll = COLLECT(portable_exe, 127 | a.datas, 128 | strip=False, 129 | upx=True, 130 | name="oce_portable" 131 | ) 132 | 133 | print("") 134 | print("Cleaning up") 135 | print("") 136 | 137 | # Remove portable executable on linux 138 | if os.path.isfile(os.path.join(DISTPATH, "oce_p")): 139 | try: 140 | os.remove(os.path.join(DISTPATH, "oce_p")) 141 | except OSError: 142 | pass 143 | 144 | # Remove portable executable on windows 145 | if os.path.isfile(os.path.join(DISTPATH, "oce_p.exe")): 146 | try: 147 | os.remove(os.path.join(DISTPATH, "oce_p.exe")) 148 | except OSError: 149 | pass 150 | 151 | # Rename the exe in the portable dir on Linux 152 | if os.path.isfile(os.path.join(DISTPATH, "oce_portable", "oce_p")): 153 | try: 154 | os.rename(os.path.join(DISTPATH, "oce_portable", "oce_p"), os.path.join(DISTPATH, "oce_portable", "oce")) 155 | except WindowsError: 156 | os.remove(os.path.join(DISTPATH, "oce_portable", "oce")) 157 | os.rename(os.path.join(DISTPATH, "oce_portable", "oce_p"), os.path.join(DISTPATH, "oce_portable", "oce")) 158 | 159 | # Rename the exe in the portable dir on Windows 160 | if os.path.isfile(os.path.join(DISTPATH, "oce_portable", "oce_p.exe")): 161 | try: 162 | os.rename(os.path.join(DISTPATH, "oce_portable", "oce_p.exe"), os.path.join(DISTPATH, "oce_portable", "oce.exe")) 163 | except WindowsError: 164 | os.remove(os.path.join(DISTPATH, "oce_portable", "oce.exe")) 165 | os.rename(os.path.join(DISTPATH, "oce_portable", "oce_p.exe"), os.path.join(DISTPATH, "oce_portable", "oce.exe")) 166 | -------------------------------------------------------------------------------- /gui/beatmapitem.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'beatmapitem.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.5.1 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | from PyQt5 import QtCore, QtGui, QtWidgets 10 | 11 | class Ui_BeatmapItem(object): 12 | def setupUi(self, BeatmapItem): 13 | BeatmapItem.setObjectName("BeatmapItem") 14 | BeatmapItem.resize(232, 37) 15 | icon = QtGui.QIcon() 16 | icon.addPixmap(QtGui.QPixmap("icons/oce.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 17 | BeatmapItem.setWindowIcon(icon) 18 | self.gridLayout = QtWidgets.QGridLayout(BeatmapItem) 19 | self.gridLayout.setContentsMargins(0, 0, 0, 0) 20 | self.gridLayout.setSpacing(0) 21 | self.gridLayout.setObjectName("gridLayout") 22 | self.horizontalLayout_2 = QtWidgets.QHBoxLayout() 23 | self.horizontalLayout_2.setObjectName("horizontalLayout_2") 24 | self.difficulty_label = QtWidgets.QLabel(BeatmapItem) 25 | self.difficulty_label.setObjectName("difficulty_label") 26 | self.horizontalLayout_2.addWidget(self.difficulty_label) 27 | self.star_label = QtWidgets.QLabel(BeatmapItem) 28 | self.star_label.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) 29 | self.star_label.setObjectName("star_label") 30 | self.horizontalLayout_2.addWidget(self.star_label) 31 | self.gridLayout.addLayout(self.horizontalLayout_2, 2, 0, 1, 1) 32 | self.horizontalLayout = QtWidgets.QHBoxLayout() 33 | self.horizontalLayout.setObjectName("horizontalLayout") 34 | self.name_label = QtWidgets.QLabel(BeatmapItem) 35 | self.name_label.setObjectName("name_label") 36 | self.horizontalLayout.addWidget(self.name_label) 37 | self.mapper_label = QtWidgets.QLabel(BeatmapItem) 38 | self.mapper_label.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) 39 | self.mapper_label.setObjectName("mapper_label") 40 | self.horizontalLayout.addWidget(self.mapper_label) 41 | self.gridLayout.addLayout(self.horizontalLayout, 1, 0, 1, 1) 42 | self.line = QtWidgets.QFrame(BeatmapItem) 43 | palette = QtGui.QPalette() 44 | brush = QtGui.QBrush(QtGui.QColor(0, 0, 0)) 45 | brush.setStyle(QtCore.Qt.SolidPattern) 46 | palette.setBrush(QtGui.QPalette.Active, QtGui.QPalette.Light, brush) 47 | brush = QtGui.QBrush(QtGui.QColor(0, 0, 0)) 48 | brush.setStyle(QtCore.Qt.SolidPattern) 49 | palette.setBrush(QtGui.QPalette.Inactive, QtGui.QPalette.Light, brush) 50 | brush = QtGui.QBrush(QtGui.QColor(0, 0, 0)) 51 | brush.setStyle(QtCore.Qt.SolidPattern) 52 | palette.setBrush(QtGui.QPalette.Disabled, QtGui.QPalette.Light, brush) 53 | self.line.setPalette(palette) 54 | self.line.setAutoFillBackground(False) 55 | self.line.setFrameShadow(QtWidgets.QFrame.Plain) 56 | self.line.setFrameShape(QtWidgets.QFrame.HLine) 57 | self.line.setObjectName("line") 58 | self.gridLayout.addWidget(self.line, 3, 0, 1, 2) 59 | self.horizontalLayout_3 = QtWidgets.QHBoxLayout() 60 | self.horizontalLayout_3.setSpacing(0) 61 | self.horizontalLayout_3.setObjectName("horizontalLayout_3") 62 | self.warning_label = QtWidgets.QLabel(BeatmapItem) 63 | sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) 64 | sizePolicy.setHorizontalStretch(0) 65 | sizePolicy.setVerticalStretch(0) 66 | sizePolicy.setHeightForWidth(self.warning_label.sizePolicy().hasHeightForWidth()) 67 | self.warning_label.setSizePolicy(sizePolicy) 68 | self.warning_label.setMinimumSize(QtCore.QSize(32, 32)) 69 | self.warning_label.setMaximumSize(QtCore.QSize(32, 32)) 70 | self.warning_label.setText("") 71 | self.warning_label.setPixmap(QtGui.QPixmap("icons/internet.png")) 72 | self.warning_label.setAlignment(QtCore.Qt.AlignCenter) 73 | self.warning_label.setObjectName("warning_label") 74 | self.horizontalLayout_3.addWidget(self.warning_label) 75 | self.gridLayout.addLayout(self.horizontalLayout_3, 1, 1, 2, 1) 76 | 77 | self.retranslateUi(BeatmapItem) 78 | QtCore.QMetaObject.connectSlotsByName(BeatmapItem) 79 | 80 | def retranslateUi(self, BeatmapItem): 81 | _translate = QtCore.QCoreApplication.translate 82 | BeatmapItem.setWindowTitle(_translate("BeatmapItem", "BeatmapItem")) 83 | self.difficulty_label.setText(_translate("BeatmapItem", "Difficulty")) 84 | self.star_label.setText(_translate("BeatmapItem", "(AR?, CS?, HP?, OD?)")) 85 | self.name_label.setText(_translate("BeatmapItem", "Artist - Song")) 86 | self.mapper_label.setText(_translate("BeatmapItem", "(Mapper)")) 87 | self.warning_label.setStatusTip(_translate("BeatmapItem", "This song\'s details were loaded from the internet.")) 88 | 89 | -------------------------------------------------------------------------------- /gui_controller/startup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PyQt5 import QtWidgets 4 | import gui.startup 5 | import settings 6 | 7 | 8 | class Startup(QtWidgets.QDialog): 9 | def __init__(self): 10 | super(Startup, self).__init__() 11 | self.log = logging.getLogger(__name__) 12 | 13 | self.ui = gui.startup.Ui_LoadDialog() 14 | self.ui.setupUi(self) 15 | 16 | # Get settings instance 17 | self.settings = settings.Settings.get_instance() 18 | 19 | # Set default values from settings 20 | self.ui.loadfrom_dropdown.setCurrentIndex(self.settings.get_setting("default_loadfrom")) 21 | self.ui.osudb_edit.setText(self.settings.get_setting("default_osudb")) 22 | self.ui.songfolder_edit.setText(self.settings.get_setting("default_songs_folder")) 23 | self.ui.collectiondb_edit.setText(self.settings.get_setting("default_collectiondb")) 24 | 25 | self.loadfrom = self.settings.get_setting("default_loadfrom") 26 | self.osudb = self.settings.get_setting("default_osudb") 27 | self.songfolder = self.settings.get_setting("default_songs_folder") 28 | self.collectiondb = self.settings.get_setting("default_collectiondb") 29 | 30 | # Setup handlers for buttons 31 | self.ui.osudb_button.clicked.connect(self.browse_osudb) 32 | self.ui.songfolder_button.clicked.connect(self.browse_songfolder) 33 | self.ui.collectiondb_button.clicked.connect(self.browse_collectiondb) 34 | 35 | self.ui.osudb_edit.textChanged.connect(self.osudb_text_changed) 36 | self.ui.songfolder_edit.textChanged.connect(self.songfolder_text_changed) 37 | self.ui.collectiondb_edit.textChanged.connect(self.collectiondb_text_changed) 38 | 39 | # Connect on_dropdown_changed function to dropdown's currentIndexChanged signal 40 | self.ui.loadfrom_dropdown.currentIndexChanged.connect(self.on_dropdown_changed) 41 | 42 | # Hide unneeded UI elements based on dropdown value 43 | if self.ui.loadfrom_dropdown.currentIndex() == 0: 44 | # Hide songfolder selector 45 | self.ui.songsfolder_label.setVisible(False) 46 | self.ui.songfolder_edit.setVisible(False) 47 | self.ui.songfolder_button.setVisible(False) 48 | else: 49 | # Hide osudb selector 50 | self.ui.osudb_label.setVisible(False) 51 | self.ui.osudb_edit.setVisible(False) 52 | self.ui.osudb_button.setVisible(False) 53 | 54 | def on_dropdown_changed(self, index): 55 | self.log.debug("New dropdown value: {}".format(index)) 56 | self.loadfrom = index 57 | # Hide unneeded UI elements based on new dropdown value 58 | if index == 0: 59 | # Hide songfolder selector 60 | self.ui.songsfolder_label.setVisible(False) 61 | self.ui.songfolder_edit.setVisible(False) 62 | self.ui.songfolder_button.setVisible(False) 63 | # Show osudb selector 64 | self.ui.osudb_label.setVisible(True) 65 | self.ui.osudb_edit.setVisible(True) 66 | self.ui.osudb_button.setVisible(True) 67 | else: 68 | # Hide osudb selector 69 | self.ui.osudb_label.setVisible(False) 70 | self.ui.osudb_edit.setVisible(False) 71 | self.ui.osudb_button.setVisible(False) 72 | # Show songfolder selector 73 | self.ui.songsfolder_label.setVisible(True) 74 | self.ui.songfolder_edit.setVisible(True) 75 | self.ui.songfolder_button.setVisible(True) 76 | 77 | def osudb_text_changed(self, text): 78 | self.log.debug("New osudb: {}".format(text)) 79 | self.osudb = text 80 | 81 | def songfolder_text_changed(self, text): 82 | self.log.debug("New songfolder: {}".format(text)) 83 | self.songfolder = text 84 | 85 | def collectiondb_text_changed(self, text): 86 | self.log.debug("New collectiondb: {}".format(text)) 87 | self.collectiondb = text 88 | 89 | def browse_osudb(self): 90 | file = QtWidgets.QFileDialog.getOpenFileName(self, "Pick your osu!.db file", self.ui.osudb_edit.text(), "osu!.db (osu!.db);;All files (*)") 91 | 92 | if file: 93 | self.ui.osudb_edit.setText(file[0]) 94 | self.osudb = file[0] 95 | 96 | self.log.debug("New osudb: {}".format(self.ui.osudb_edit.text())) 97 | 98 | def browse_songfolder(self): 99 | directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Pick your osu! Song folder", 100 | self.ui.songfolder_edit.text()) 101 | 102 | if directory: 103 | self.ui.songfolder_edit.setText(directory) 104 | self.songfolder = directory 105 | 106 | self.log.debug("New songfolder: {}".format(self.ui.songfolder_edit.text())) 107 | 108 | def browse_collectiondb(self): 109 | file = QtWidgets.QFileDialog.getOpenFileName(self, "Pick your collection.db file", self.ui.collectiondb_edit.text(), "collection.db (collection.db);;All files (*)") 110 | 111 | if file: 112 | self.ui.collectiondb_edit.setText(file[0]) 113 | self.collectiondb = file[0] 114 | 115 | self.log.debug("New collectiondb: {}".format(self.ui.collectiondb_edit.text())) 116 | -------------------------------------------------------------------------------- /ui_designs/beatmapitem.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | BeatmapItem 4 | 5 | 6 | 7 | 0 8 | 0 9 | 232 10 | 37 11 | 12 | 13 | 14 | BeatmapItem 15 | 16 | 17 | 18 | icons/oce.pngicons/oce.png 19 | 20 | 21 | 22 | 0 23 | 24 | 25 | 0 26 | 27 | 28 | 0 29 | 30 | 31 | 0 32 | 33 | 34 | 0 35 | 36 | 37 | 38 | 39 | 40 | 41 | Difficulty 42 | 43 | 44 | 45 | 46 | 47 | 48 | (AR?, CS?, HP?, OD?) 49 | 50 | 51 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | Artist - Song 63 | 64 | 65 | 66 | 67 | 68 | 69 | (Mapper) 70 | 71 | 72 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 0 87 | 0 88 | 0 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 0 98 | 0 99 | 0 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 0 109 | 0 110 | 0 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | false 119 | 120 | 121 | QFrame::Plain 122 | 123 | 124 | Qt::Horizontal 125 | 126 | 127 | 128 | 129 | 130 | 131 | 0 132 | 133 | 134 | 135 | 136 | 137 | 0 138 | 0 139 | 140 | 141 | 142 | 143 | 32 144 | 32 145 | 146 | 147 | 148 | 149 | 32 150 | 32 151 | 152 | 153 | 154 | This song's details were loaded from the internet. 155 | 156 | 157 | 158 | 159 | 160 | icons/internet.png 161 | 162 | 163 | Qt::AlignCenter 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | -------------------------------------------------------------------------------- /gui_controller/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pprint 3 | 4 | from PyQt5 import QtWidgets 5 | import gui.settings 6 | import settings 7 | import json 8 | 9 | 10 | class Settings(QtWidgets.QDialog): 11 | def __init__(self): 12 | super(Settings, self).__init__() 13 | self.log = logging.getLogger(__name__) 14 | 15 | self.ui = gui.settings.Ui_SettingsDialog() 16 | self.ui.setupUi(self) 17 | 18 | # Get settings instance 19 | self.settings = settings.Settings.get_instance() 20 | 21 | # Set current values from settings 22 | self.ui.api_key_line.setText(self.settings.get_setting("osu_api_key")) 23 | self.ui.download_api_combobox.setCurrentIndex(self.settings.get_setting("download_from_api")) 24 | 25 | self.ui.loadfrom_dropdown.setCurrentIndex(self.settings.get_setting("default_loadfrom")) 26 | self.ui.default_osudb_line.setText(self.settings.get_setting("default_osudb")) 27 | self.ui.default_songs_line.setText(self.settings.get_setting("default_songs_folder")) 28 | self.ui.default_collection_line.setText(self.settings.get_setting("default_collectiondb")) 29 | 30 | self.ui.shutdown_dialog_checkbox.setChecked(self.settings.get_setting("show_shutdown_dialog")) 31 | self.ui.api_explanation_dialog.setChecked(self.settings.get_setting("show_api_explanation_dialog")) 32 | self.ui.collection_delete_dialog.setChecked(self.settings.get_setting("show_collection_delete_dialog")) 33 | self.ui.song_remove_dialog.setChecked(self.settings.get_setting("show_remove_song_dialog")) 34 | self.ui.mapset_remove_dialog.setChecked(self.settings.get_setting("show_remove_mapset_dialog")) 35 | 36 | # Setup handlers for buttons 37 | self.ui.default_osudb_button.clicked.connect(self.browse_osudb) 38 | self.ui.default_songs_button.clicked.connect(self.browse_songsdir) 39 | self.ui.default_collection_button.clicked.connect(self.browse_collectionfile) 40 | 41 | # Setup handlers for OK/Cancel/Apply buttons 42 | self.ui.button_box.accepted.connect(self.accept) 43 | self.ui.button_box.rejected.connect(self.reject) 44 | self.ui.button_box.clicked.connect(self.button_clicked) 45 | 46 | def button_clicked(self, button): 47 | if button.text() == "Apply": 48 | self.apply_settings() 49 | 50 | def accept(self): 51 | # Apply settings before leaving 52 | self.apply_settings() 53 | super(Settings, self).accept() 54 | 55 | def apply_settings(self): 56 | # Update settings 57 | self.settings.set_setting('osu_api_key', self.ui.api_key_line.text()) 58 | self.settings.set_setting('download_from_api', self.ui.download_api_combobox.currentIndex()) 59 | 60 | self.settings.set_setting('default_loadfrom', self.ui.loadfrom_dropdown.currentIndex()) 61 | self.settings.set_setting('default_osudb', self.ui.default_osudb_line.text()) 62 | self.settings.set_setting('default_songs_folder', self.ui.default_songs_line.text()) 63 | self.settings.set_setting('default_collectiondb', self.ui.default_collection_line.text()) 64 | 65 | self.settings.set_setting('show_shutdown_dialog', self.ui.shutdown_dialog_checkbox.isChecked()) 66 | self.settings.set_setting('show_api_explanation_dialog', self.ui.api_explanation_dialog.isChecked()) 67 | self.settings.set_setting('show_collection_delete_dialog', self.ui.collection_delete_dialog.isChecked()) 68 | self.settings.set_setting('show_remove_song_dialog', self.ui.song_remove_dialog.isChecked()) 69 | self.settings.set_setting('show_remove_mapset_dialog', self.ui.mapset_remove_dialog.isChecked()) 70 | 71 | # Export settings to file 72 | with open('settings.json', 'w', encoding='utf8') as f: 73 | json.dump(self.settings.settings, f, sort_keys=True, indent=4) 74 | 75 | self.log.info("Settings were applied.") 76 | 77 | def browse_osudb(self): 78 | file = QtWidgets.QFileDialog.getOpenFileName(self, "Pick your osu!.db file", 79 | self.ui.default_osudb_line.text(), 80 | "osu!.db (osu!.db);;All files (*)") 81 | 82 | if file: 83 | self.ui.default_osudb_line.setText(file[0]) 84 | 85 | self.log.debug("New default osu!.db file: {}".format(self.ui.default_osudb_line.text())) 86 | 87 | def browse_songsdir(self): 88 | directory = QtWidgets.QFileDialog.getExistingDirectory(self, 89 | "Pick your osu! Song folder", 90 | self.ui.default_songs_line.text()) 91 | 92 | if directory: 93 | self.ui.default_songs_line.setText(directory) 94 | 95 | self.log.debug("New default song dir: {}".format(self.ui.default_songs_line.text())) 96 | 97 | def browse_collectionfile(self): 98 | file = QtWidgets.QFileDialog.getOpenFileName(self, "Pick your osu! collection.db file", 99 | self.ui.default_collection_line.text(), 100 | "collection.db (collection.db);;All files (*)") 101 | 102 | if file: 103 | self.ui.default_collection_line.setText(file[0]) 104 | 105 | self.log.debug("New default collection file: {}".format(self.ui.default_collection_line.text())) 106 | -------------------------------------------------------------------------------- /ui_designs/about.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | AboutDialog 4 | 5 | 6 | true 7 | 8 | 9 | 10 | 0 11 | 0 12 | 427 13 | 160 14 | 15 | 16 | 17 | About osu! Collection Editor 18 | 19 | 20 | 21 | icons/oce.pngicons/oce.png 22 | 23 | 24 | 25 | 0 26 | 27 | 28 | QLayout::SetFixedSize 29 | 30 | 31 | 32 | 33 | 34 | 18 35 | 75 36 | true 37 | 38 | 39 | 40 | osu! Collection Editor 41 | 42 | 43 | Qt::AlignCenter 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 8 52 | 53 | 54 | 55 | <p>Distributed under the <a href="http://www.gnu.org/licenses/gpl-3.0.en.html"><span style=" text-decoration: underline; color:#0000ff;">GNU General Public License Version 3</span></a></p> 56 | 57 | 58 | Qt::AlignCenter 59 | 60 | 61 | true 62 | 63 | 64 | 65 | 66 | 67 | 68 | Qt::Vertical 69 | 70 | 71 | QSizePolicy::MinimumExpanding 72 | 73 | 74 | 75 | 20 76 | 10 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | <p>Created by <a href="http://www.kevinalberts.nl/"><span style=" text-decoration: underline; color:#0000ff;">Kevin Alberts</span></a> (<a href="http://osu.ppy.sh/u/Kurocon"><span style=" text-decoration: underline; color:#0000ff;">Kurocon</span></a>)<br />Source on <a href="https://github.com/Kurocon/Osu-Collections-Editor"><span style=" text-decoration: underline; color:#0000ff;">GitHub</span></a></p> 85 | 86 | 87 | Qt::AlignCenter 88 | 89 | 90 | true 91 | 92 | 93 | 94 | 95 | 96 | 97 | Qt::Vertical 98 | 99 | 100 | QSizePolicy::MinimumExpanding 101 | 102 | 103 | 104 | 20 105 | 20 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | Version 0.1.dev1, Build 1 114 | 115 | 116 | 117 | 118 | 119 | 120 | Qt::Horizontal 121 | 122 | 123 | QDialogButtonBox::Ok 124 | 125 | 126 | false 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | button_box 136 | accepted() 137 | AboutDialog 138 | accept() 139 | 140 | 141 | 248 142 | 254 143 | 144 | 145 | 157 146 | 274 147 | 148 | 149 | 150 | 151 | button_box 152 | rejected() 153 | AboutDialog 154 | reject() 155 | 156 | 157 | 316 158 | 260 159 | 160 | 161 | 286 162 | 274 163 | 164 | 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /util/osudb_format.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import logging 3 | 4 | # Define byte lengths for different data types 5 | OSU_BYTE = 1 6 | OSU_SHORT = 2 7 | OSU_INT = 4 8 | OSU_LONG = 8 9 | OSU_SINGLE = 4 10 | OSU_DOUBLE = 8 11 | OSU_BOOLEAN = 1 12 | OSU_DATETIME = 8 13 | 14 | 15 | def parse_string(fileobj): 16 | """ 17 | Get an OSU string from the file object 18 | :param fileobj: The file object 19 | :type fileobj: FileIO[bytes] 20 | :return: The string 21 | """ 22 | log = logging.getLogger(__name__) 23 | 24 | # Get next byte, this one indicates what the rest of the string is 25 | indicator = fileobj.read(1) 26 | 27 | if ord(indicator) == 0: 28 | # The next two parts are not present. 29 | # log.log(5, "Read empty STRING") 30 | return "" 31 | elif ord(indicator) == 11: 32 | # The next two parts are present. 33 | # The first part is a ULEB128. Get that. 34 | uleb = parse_uleb128(fileobj) 35 | # log.log(5, "Read {} as ULEB128".format(uleb)) 36 | s = fileobj.read(uleb).decode('utf-8') 37 | # log.log(5, "Read {} as STRING".format(s)) 38 | return s 39 | else: 40 | log.debug("Could not read valid STRING from the .db file. Probably something is going wrong in parsing.") 41 | return 42 | 43 | 44 | def parse_uleb128(fileobj): 45 | """ 46 | Get an Unsigned Little Endian Base 128 integer from the file object 47 | :param fileobj: The file object 48 | :type fileobj: FileIO[bytes] 49 | :return: The integer 50 | """ 51 | 52 | result = 0 53 | shift = 0 54 | while True: 55 | byte = fileobj.read(1)[0] 56 | result |= (byte & 0x7F) << shift 57 | 58 | if ((byte & 0x80) >> 7) == 0: 59 | break 60 | 61 | shift += 7 62 | 63 | return result 64 | 65 | 66 | def print_as_bits(byte): 67 | return "{0:b}".format(byte) 68 | 69 | 70 | def get_int(integer): 71 | return struct.pack("I", integer) 72 | 73 | 74 | def get_string(string): 75 | if not string: 76 | # If the string is empty, the string consists of just this byte 77 | return bytes([0x00]) 78 | else: 79 | # Else, it starts with 0x0b 80 | result = bytes([0x0b]) 81 | 82 | # Followed by the length of the string as an ULEB128 83 | result += get_uleb128(len(string)) 84 | 85 | # Followed by the string in UTF-8 86 | result += string.encode('utf-8') 87 | return result 88 | 89 | 90 | def get_uleb128(integer): 91 | cont_loop = True 92 | result = b'' 93 | 94 | while cont_loop: 95 | byte = integer & 0x7F 96 | integer >>= 7 97 | if integer != 0: 98 | byte |= 0x80 99 | result += bytes([byte]) 100 | cont_loop = integer != 0 101 | 102 | return result 103 | 104 | 105 | def read_type(type, fobj): 106 | log = logging.getLogger(__name__) 107 | 108 | try: 109 | 110 | if type == "Int": 111 | bs = fobj.read(OSU_INT) 112 | # log.log(5, "Read {} as INT".format(bs)) 113 | return int.from_bytes(bs, byteorder='little') 114 | elif type == "String": 115 | s = parse_string(fobj) 116 | return s 117 | elif type == "Byte": 118 | bs = fobj.read(OSU_BYTE) 119 | # log.log(5, "Read {} as BYTE".format(bs)) 120 | return bs 121 | elif type == "Short": 122 | bs = fobj.read(OSU_SHORT) 123 | # log.log(5, "Read {} as SHORT".format(bs)) 124 | return int.from_bytes(bs, byteorder='little') 125 | elif type == "Long": 126 | bs = fobj.read(OSU_LONG) 127 | # log.log(5, "Read {} as LONG".format(bs)) 128 | return int.from_bytes(bs, byteorder='little') 129 | elif type == "Single": # Also known as float 130 | bs = fobj.read(OSU_SINGLE) 131 | # log.log(5, "Read {} as SINGLE".format(bs)) 132 | return struct.unpack('f', bs) 133 | elif type == "Double": 134 | bs = fobj.read(OSU_DOUBLE) 135 | # log.log(5, "Read {} as DOUBLE".format(bs)) 136 | return struct.unpack('d', bs) 137 | elif type == "Boolean": 138 | bs = fobj.read(OSU_BOOLEAN) 139 | # log.log(5, "Read {} as BOOL".format(bs)) 140 | return bs != b'\x00' 141 | elif type == "IntDoublepair": 142 | oxo8 = fobj.read(OSU_BYTE) 143 | # log.log(5, "Read {} as BYTE".format(oxo8)) 144 | bs1 = fobj.read(OSU_INT) 145 | # log.log(5, "Read {} as INT".format(bs1)) 146 | i = int.from_bytes(bs1, byteorder='little') 147 | oxob = fobj.read(OSU_BYTE) 148 | # log.log(5, "Read {} as BYTE".format(oxob)) 149 | bs2 = fobj.read(OSU_DOUBLE) 150 | # log.log(5, "Read {} as DOUBLE".format(bs2)) 151 | d = struct.unpack('d', bs2) 152 | return i, d 153 | elif type == "Timingpoint": 154 | bs1 = fobj.read(OSU_DOUBLE) 155 | # log.log(5, "Read {} as DOUBLE".format(bs1)) 156 | bpm = struct.unpack('d', bs1) 157 | bs2 = fobj.read(OSU_DOUBLE) 158 | # log.log(5, "Read {} as DOUBLE".format(bs2)) 159 | offset = struct.unpack('d', bs2) 160 | bs3 = fobj.read(OSU_BOOLEAN) 161 | # log.log(5, "Read {} as BOOL".format(bs3)) 162 | uninherited = bs3 != b'\x00' 163 | return bpm, offset, uninherited 164 | elif type == "DateTime": 165 | bs = fobj.read(OSU_DATETIME) 166 | # log.log(5, "Read {} as DATETIME".format(bs)) 167 | return int.from_bytes(bs, byteorder='little') 168 | else: 169 | log.warn("Error while reading .db file. I don't know how to read {}".format(type)) 170 | 171 | except struct.error as e: 172 | log.warn("Error while parsing .db file. {}".format(e)) 173 | raise e 174 | -------------------------------------------------------------------------------- /ui_designs/startup.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | LoadDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 646 10 | 244 11 | 12 | 13 | 14 | Open collection 15 | 16 | 17 | 18 | icons/oce.pngicons/oce.png 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Osu Collections Editor can load your songs from two different places. You can load from the 27 | osu!.db file which osu! itself also uses, or you can load directly from your Songs folder. 28 | 29 | Loading from the osu! database will be much faster, but loading directly from your Songs folder 30 | can be handy if you do not have an osu!.db at the ready. 31 | 32 | 33 | 34 | 35 | 36 | 37 | 6 38 | 39 | 40 | 0 41 | 42 | 43 | 44 | 45 | Load from 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | osu!.db file 54 | 55 | 56 | 57 | 58 | Songs folder 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | osu!.db 67 | 68 | 69 | 70 | 71 | 72 | 73 | 0 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Browse 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | Songs folder 91 | 92 | 93 | 94 | 95 | 96 | 97 | 0 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | Browse 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | collection.db 115 | 116 | 117 | 118 | 119 | 120 | 121 | 0 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | Browse 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | Qt::Horizontal 143 | 144 | 145 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | button_box 155 | accepted() 156 | LoadDialog 157 | accept() 158 | 159 | 160 | 248 161 | 254 162 | 163 | 164 | 157 165 | 274 166 | 167 | 168 | 169 | 170 | button_box 171 | rejected() 172 | LoadDialog 173 | reject() 174 | 175 | 176 | 316 177 | 260 178 | 179 | 180 | 286 181 | 274 182 | 183 | 184 | 185 | 186 | 187 | -------------------------------------------------------------------------------- /util/collections_parser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | 4 | from util.osudb_format import get_int, get_string, get_uleb128, parse_string, parse_uleb128, print_as_bits 5 | from util.osudb_format import OSU_BOOLEAN, OSU_BYTE, OSU_DOUBLE, OSU_INT, OSU_LONG, OSU_SHORT, OSU_SINGLE 6 | from util.oce_models import Collections, Collection, CollectionMap, Difficulty2 7 | 8 | 9 | # collection.db format 10 | # Data type Description 11 | # Int Version (e.g. 20150203) 12 | # Int Number of collections 13 | # 14 | # The following will be repeated for the total number of collections. 15 | # String Name of the collection 16 | # Int Number of beatmaps in the collection 17 | # String* Beatmap MD5 hash. Repeated for as many beatmaps as are in the collection. 18 | 19 | 20 | def parse_collections(path): 21 | log = logging.getLogger(__name__) 22 | log.debug("Opening file {}".format(path)) 23 | fobj = open("{}".format(path), 'rb') 24 | 25 | colls = Collections() 26 | 27 | # Try to parse the file as a collection db. 28 | # First the version, which is an int 29 | version = int.from_bytes(fobj.read(OSU_INT), byteorder='little') 30 | colls.version = version 31 | log.debug("CollectionDB version {}".format(version)) 32 | 33 | # Then the number of collections, also an int 34 | collection_count = int.from_bytes(fobj.read(OSU_INT), byteorder='little') 35 | log.debug("There are {} collections in this DB".format(collection_count)) 36 | 37 | # Then, for each collection: 38 | for i in range(0, collection_count): 39 | c = Collection() 40 | log.debug("Parsing collection {}".format(i)) 41 | 42 | # The first part of the collection is the name of it. 43 | collection_name = parse_string(fobj) 44 | c.name = collection_name 45 | log.debug("Collection: {}".format(collection_name)) 46 | 47 | # Then there is the number of beatmaps in the collection 48 | collection_beatmap_count = int.from_bytes(fobj.read(OSU_INT), byteorder='little') 49 | log.debug("{} maps".format(collection_beatmap_count)) 50 | 51 | # Then, for each beatmap in the collection: 52 | for j in range(0, collection_beatmap_count): 53 | # The MD5 hash for the song 54 | cm = CollectionMap() 55 | cm.hash = parse_string(fobj) 56 | c.beatmaps.append(cm) 57 | 58 | colls.collections.append(c) 59 | 60 | return colls 61 | 62 | 63 | def parse_collections_gui(path, dialog): 64 | log = logging.getLogger(__name__) 65 | log.debug("Opening file {}".format(path)) 66 | fobj = open("{}".format(path), 'rb') 67 | 68 | colls = Collections() 69 | 70 | # Try to parse the file as a collection db. 71 | # First the version, which is an int 72 | version = int.from_bytes(fobj.read(OSU_INT), byteorder='little') 73 | colls.version = version 74 | log.debug("CollectionDB version {}".format(version)) 75 | 76 | # Then the number of collections, also an int 77 | collection_count = int.from_bytes(fobj.read(OSU_INT), byteorder='little') 78 | log.debug("There are {} collections in this DB".format(collection_count)) 79 | 80 | collections_done = 0 81 | 82 | # Then, for each collection: 83 | for i in range(0, collection_count): 84 | dialog.progress.emit(int((collections_done/collection_count)*100)) 85 | c = Collection() 86 | log.debug("Parsing collection {}".format(i)) 87 | 88 | # The first part of the collection is the name of it. 89 | collection_name = parse_string(fobj) 90 | c.name = collection_name 91 | log.debug("Collection: {}".format(collection_name)) 92 | dialog.current.emit(collection_name) 93 | 94 | # Then there is the number of beatmaps in the collection 95 | collection_beatmap_count = int.from_bytes(fobj.read(OSU_INT), byteorder='little') 96 | log.debug("{} maps".format(collection_beatmap_count)) 97 | 98 | # Then, for each beatmap in the collection: 99 | for j in range(0, collection_beatmap_count): 100 | # The MD5 hash for the song 101 | cm = CollectionMap() 102 | cm.hash = parse_string(fobj) 103 | c.beatmaps.append(cm) 104 | 105 | colls.collections.append(c) 106 | collections_done += 1 107 | 108 | return colls 109 | 110 | ## 111 | # Save functions 112 | ## 113 | 114 | # collection.db format 115 | # Data type Description 116 | # Int Version (e.g. 20150203) 117 | # Int Number of collections 118 | # 119 | # The following will be repeated for the total number of collections. 120 | # String Name of the collection 121 | # Int Number of beatmaps in the collection 122 | # String* Beatmap MD5 hash. Repeated for as many beatmaps as are in the collection. 123 | 124 | 125 | def save_collection(collection, location): 126 | """ 127 | Save the given collection database to the given location 128 | :param collection: The collection to save 129 | :type collection: Collections 130 | :param location: The file path to save the collection to 131 | :type location: str 132 | """ 133 | 134 | # Open the output file 135 | log = logging.getLogger(__name__) 136 | log.debug("Saving CollectionDB to {}".format(location)) 137 | log.debug("Opening file {}".format(location)) 138 | fobj = open("{}".format(location), 'wb') 139 | 140 | # First write the collection version integer 141 | fobj.write(get_int(collection.version)) 142 | 143 | # Then write the number of collections 144 | fobj.write(get_int(len(collection.collections))) 145 | 146 | # Then, for each collection 147 | for col in collection.collections: 148 | 149 | log.debug("Writing collection {}".format(col.name)) 150 | 151 | # Write the collection name 152 | fobj.write(get_string(col.name)) 153 | 154 | # Write the number of beatmaps in this collection 155 | fobj.write(get_int(len(col.beatmaps))) 156 | 157 | # Then for all beatmaps in the collection, write the MD5 hashes. 158 | for m in col.beatmaps: 159 | fobj.write(get_string(m.hash)) 160 | 161 | # Close the file 162 | fobj.close() 163 | 164 | log.debug("Done saving collections.") 165 | -------------------------------------------------------------------------------- /gui/missing_maps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'missing_maps.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.5.1 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | from PyQt5 import QtCore, QtGui, QtWidgets 10 | 11 | class Ui_MissingMapsDialog(object): 12 | def setupUi(self, MissingMapsDialog): 13 | MissingMapsDialog.setObjectName("MissingMapsDialog") 14 | MissingMapsDialog.resize(865, 434) 15 | icon = QtGui.QIcon() 16 | icon.addPixmap(QtGui.QPixmap("icons/oce.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 17 | MissingMapsDialog.setWindowIcon(icon) 18 | self.verticalLayout = QtWidgets.QVBoxLayout(MissingMapsDialog) 19 | self.verticalLayout.setObjectName("verticalLayout") 20 | self.container = QtWidgets.QVBoxLayout() 21 | self.container.setObjectName("container") 22 | self.api_box = QtWidgets.QGroupBox(MissingMapsDialog) 23 | self.api_box.setObjectName("api_box") 24 | self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.api_box) 25 | self.verticalLayout_4.setContentsMargins(0, -1, 0, 0) 26 | self.verticalLayout_4.setObjectName("verticalLayout_4") 27 | self.api_table = QtWidgets.QTableWidget(self.api_box) 28 | self.api_table.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) 29 | self.api_table.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContentsOnFirstShow) 30 | self.api_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) 31 | self.api_table.setRowCount(0) 32 | self.api_table.setColumnCount(6) 33 | self.api_table.setObjectName("api_table") 34 | item = QtWidgets.QTableWidgetItem() 35 | self.api_table.setHorizontalHeaderItem(0, item) 36 | item = QtWidgets.QTableWidgetItem() 37 | self.api_table.setHorizontalHeaderItem(1, item) 38 | item = QtWidgets.QTableWidgetItem() 39 | self.api_table.setHorizontalHeaderItem(2, item) 40 | item = QtWidgets.QTableWidgetItem() 41 | self.api_table.setHorizontalHeaderItem(3, item) 42 | item = QtWidgets.QTableWidgetItem() 43 | icon1 = QtGui.QIcon() 44 | icon1.addPixmap(QtGui.QPixmap("icons/osu.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 45 | item.setIcon(icon1) 46 | self.api_table.setHorizontalHeaderItem(4, item) 47 | item = QtWidgets.QTableWidgetItem() 48 | icon2 = QtGui.QIcon() 49 | icon2.addPixmap(QtGui.QPixmap("icons/bloodcat.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 50 | item.setIcon(icon2) 51 | self.api_table.setHorizontalHeaderItem(5, item) 52 | self.api_table.horizontalHeader().setVisible(True) 53 | self.api_table.horizontalHeader().setCascadingSectionResizes(False) 54 | self.api_table.horizontalHeader().setDefaultSectionSize(160) 55 | self.api_table.horizontalHeader().setSortIndicatorShown(False) 56 | self.api_table.horizontalHeader().setStretchLastSection(True) 57 | self.api_table.verticalHeader().setVisible(False) 58 | self.verticalLayout_4.addWidget(self.api_table) 59 | self.container.addWidget(self.api_box) 60 | self.unmatched_box = QtWidgets.QGroupBox(MissingMapsDialog) 61 | self.unmatched_box.setObjectName("unmatched_box") 62 | self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.unmatched_box) 63 | self.verticalLayout_3.setContentsMargins(0, -1, 0, 0) 64 | self.verticalLayout_3.setObjectName("verticalLayout_3") 65 | self.unmatched_textbox = QtWidgets.QTextEdit(self.unmatched_box) 66 | self.unmatched_textbox.setReadOnly(True) 67 | self.unmatched_textbox.setObjectName("unmatched_textbox") 68 | self.verticalLayout_3.addWidget(self.unmatched_textbox) 69 | self.container.addWidget(self.unmatched_box) 70 | self.no_missing_label = QtWidgets.QLabel(MissingMapsDialog) 71 | sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) 72 | sizePolicy.setHorizontalStretch(0) 73 | sizePolicy.setVerticalStretch(0) 74 | sizePolicy.setHeightForWidth(self.no_missing_label.sizePolicy().hasHeightForWidth()) 75 | self.no_missing_label.setSizePolicy(sizePolicy) 76 | self.no_missing_label.setAlignment(QtCore.Qt.AlignCenter) 77 | self.no_missing_label.setObjectName("no_missing_label") 78 | self.container.addWidget(self.no_missing_label) 79 | self.verticalLayout.addLayout(self.container) 80 | self.button_box = QtWidgets.QDialogButtonBox(MissingMapsDialog) 81 | self.button_box.setOrientation(QtCore.Qt.Horizontal) 82 | self.button_box.setStandardButtons(QtWidgets.QDialogButtonBox.Ok) 83 | self.button_box.setObjectName("button_box") 84 | self.verticalLayout.addWidget(self.button_box) 85 | 86 | self.retranslateUi(MissingMapsDialog) 87 | self.button_box.accepted.connect(MissingMapsDialog.accept) 88 | self.button_box.rejected.connect(MissingMapsDialog.reject) 89 | QtCore.QMetaObject.connectSlotsByName(MissingMapsDialog) 90 | 91 | def retranslateUi(self, MissingMapsDialog): 92 | _translate = QtCore.QCoreApplication.translate 93 | MissingMapsDialog.setWindowTitle(_translate("MissingMapsDialog", "Missing Maps")) 94 | self.api_box.setTitle(_translate("MissingMapsDialog", "Missing maps identified with the osu! API")) 95 | item = self.api_table.horizontalHeaderItem(0) 96 | item.setText(_translate("MissingMapsDialog", "Artist")) 97 | item = self.api_table.horizontalHeaderItem(1) 98 | item.setText(_translate("MissingMapsDialog", "Title")) 99 | item = self.api_table.horizontalHeaderItem(2) 100 | item.setText(_translate("MissingMapsDialog", "Mapper")) 101 | item = self.api_table.horizontalHeaderItem(3) 102 | item.setText(_translate("MissingMapsDialog", "Difficulty")) 103 | item = self.api_table.horizontalHeaderItem(4) 104 | item.setToolTip(_translate("MissingMapsDialog", "Open this map\'s osu! page")) 105 | item = self.api_table.horizontalHeaderItem(5) 106 | item.setToolTip(_translate("MissingMapsDialog", "Search for this map on Bloodcat")) 107 | self.unmatched_box.setTitle(_translate("MissingMapsDialog", "Hashes of unidentified missing maps")) 108 | self.no_missing_label.setText(_translate("MissingMapsDialog", "You have no missing maps!")) 109 | 110 | -------------------------------------------------------------------------------- /gui/startup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'startup.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.5.1 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | from PyQt5 import QtCore, QtGui, QtWidgets 10 | 11 | class Ui_LoadDialog(object): 12 | def setupUi(self, LoadDialog): 13 | LoadDialog.setObjectName("LoadDialog") 14 | LoadDialog.resize(646, 244) 15 | icon = QtGui.QIcon() 16 | icon.addPixmap(QtGui.QPixmap("icons/oce.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 17 | LoadDialog.setWindowIcon(icon) 18 | self.verticalLayout = QtWidgets.QVBoxLayout(LoadDialog) 19 | self.verticalLayout.setObjectName("verticalLayout") 20 | self.container = QtWidgets.QVBoxLayout() 21 | self.container.setObjectName("container") 22 | self.help_label = QtWidgets.QLabel(LoadDialog) 23 | self.help_label.setObjectName("help_label") 24 | self.container.addWidget(self.help_label) 25 | self.form_layout = QtWidgets.QFormLayout() 26 | self.form_layout.setHorizontalSpacing(6) 27 | self.form_layout.setVerticalSpacing(0) 28 | self.form_layout.setObjectName("form_layout") 29 | self.loadfrom_label = QtWidgets.QLabel(LoadDialog) 30 | self.loadfrom_label.setObjectName("loadfrom_label") 31 | self.form_layout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.loadfrom_label) 32 | self.loadfrom_dropdown = QtWidgets.QComboBox(LoadDialog) 33 | self.loadfrom_dropdown.setObjectName("loadfrom_dropdown") 34 | self.loadfrom_dropdown.addItem("") 35 | self.loadfrom_dropdown.addItem("") 36 | self.form_layout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.loadfrom_dropdown) 37 | self.osudb_label = QtWidgets.QLabel(LoadDialog) 38 | self.osudb_label.setObjectName("osudb_label") 39 | self.form_layout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.osudb_label) 40 | self.osudb_fields = QtWidgets.QHBoxLayout() 41 | self.osudb_fields.setSpacing(0) 42 | self.osudb_fields.setObjectName("osudb_fields") 43 | self.osudb_edit = QtWidgets.QLineEdit(LoadDialog) 44 | self.osudb_edit.setObjectName("osudb_edit") 45 | self.osudb_fields.addWidget(self.osudb_edit) 46 | self.osudb_button = QtWidgets.QPushButton(LoadDialog) 47 | self.osudb_button.setObjectName("osudb_button") 48 | self.osudb_fields.addWidget(self.osudb_button) 49 | self.form_layout.setLayout(1, QtWidgets.QFormLayout.FieldRole, self.osudb_fields) 50 | self.songsfolder_label = QtWidgets.QLabel(LoadDialog) 51 | self.songsfolder_label.setObjectName("songsfolder_label") 52 | self.form_layout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.songsfolder_label) 53 | self.songfolder_fields = QtWidgets.QHBoxLayout() 54 | self.songfolder_fields.setSpacing(0) 55 | self.songfolder_fields.setObjectName("songfolder_fields") 56 | self.songfolder_edit = QtWidgets.QLineEdit(LoadDialog) 57 | self.songfolder_edit.setObjectName("songfolder_edit") 58 | self.songfolder_fields.addWidget(self.songfolder_edit) 59 | self.songfolder_button = QtWidgets.QPushButton(LoadDialog) 60 | self.songfolder_button.setObjectName("songfolder_button") 61 | self.songfolder_fields.addWidget(self.songfolder_button) 62 | self.form_layout.setLayout(2, QtWidgets.QFormLayout.FieldRole, self.songfolder_fields) 63 | self.collectiondb_label = QtWidgets.QLabel(LoadDialog) 64 | self.collectiondb_label.setObjectName("collectiondb_label") 65 | self.form_layout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.collectiondb_label) 66 | self.collectiondb_fields = QtWidgets.QHBoxLayout() 67 | self.collectiondb_fields.setSpacing(0) 68 | self.collectiondb_fields.setObjectName("collectiondb_fields") 69 | self.collectiondb_edit = QtWidgets.QLineEdit(LoadDialog) 70 | self.collectiondb_edit.setObjectName("collectiondb_edit") 71 | self.collectiondb_fields.addWidget(self.collectiondb_edit) 72 | self.collectiondb_button = QtWidgets.QPushButton(LoadDialog) 73 | self.collectiondb_button.setObjectName("collectiondb_button") 74 | self.collectiondb_fields.addWidget(self.collectiondb_button) 75 | self.form_layout.setLayout(3, QtWidgets.QFormLayout.FieldRole, self.collectiondb_fields) 76 | self.container.addLayout(self.form_layout) 77 | self.verticalLayout.addLayout(self.container) 78 | self.button_box = QtWidgets.QDialogButtonBox(LoadDialog) 79 | self.button_box.setOrientation(QtCore.Qt.Horizontal) 80 | self.button_box.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) 81 | self.button_box.setObjectName("button_box") 82 | self.verticalLayout.addWidget(self.button_box) 83 | 84 | self.retranslateUi(LoadDialog) 85 | self.button_box.accepted.connect(LoadDialog.accept) 86 | self.button_box.rejected.connect(LoadDialog.reject) 87 | QtCore.QMetaObject.connectSlotsByName(LoadDialog) 88 | 89 | def retranslateUi(self, LoadDialog): 90 | _translate = QtCore.QCoreApplication.translate 91 | LoadDialog.setWindowTitle(_translate("LoadDialog", "Open collection")) 92 | self.help_label.setText(_translate("LoadDialog", "Osu Collections Editor can load your songs from two different places. You can load from the\n" 93 | "osu!.db file which osu! itself also uses, or you can load directly from your Songs folder.\n" 94 | "\n" 95 | "Loading from the osu! database will be much faster, but loading directly from your Songs folder \n" 96 | "can be handy if you do not have an osu!.db at the ready.")) 97 | self.loadfrom_label.setText(_translate("LoadDialog", "Load from")) 98 | self.loadfrom_dropdown.setItemText(0, _translate("LoadDialog", "osu!.db file")) 99 | self.loadfrom_dropdown.setItemText(1, _translate("LoadDialog", "Songs folder")) 100 | self.osudb_label.setText(_translate("LoadDialog", "osu!.db")) 101 | self.osudb_button.setText(_translate("LoadDialog", "Browse")) 102 | self.songsfolder_label.setText(_translate("LoadDialog", "Songs folder")) 103 | self.songfolder_button.setText(_translate("LoadDialog", "Browse")) 104 | self.collectiondb_label.setText(_translate("LoadDialog", "collection.db")) 105 | self.collectiondb_button.setText(_translate("LoadDialog", "Browse")) 106 | 107 | -------------------------------------------------------------------------------- /ui_designs/missing_maps.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MissingMapsDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 865 10 | 434 11 | 12 | 13 | 14 | Missing Maps 15 | 16 | 17 | 18 | icons/oce.pngicons/oce.png 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Missing maps identified with the osu! API 27 | 28 | 29 | 30 | 0 31 | 32 | 33 | 0 34 | 35 | 36 | 0 37 | 38 | 39 | 40 | 41 | Qt::ScrollBarAlwaysOff 42 | 43 | 44 | QAbstractScrollArea::AdjustToContentsOnFirstShow 45 | 46 | 47 | QAbstractItemView::SelectRows 48 | 49 | 50 | 0 51 | 52 | 53 | 6 54 | 55 | 56 | true 57 | 58 | 59 | false 60 | 61 | 62 | 160 63 | 64 | 65 | false 66 | 67 | 68 | true 69 | 70 | 71 | false 72 | 73 | 74 | 75 | Artist 76 | 77 | 78 | 79 | 80 | Title 81 | 82 | 83 | 84 | 85 | Mapper 86 | 87 | 88 | 89 | 90 | Difficulty 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | Open this map's osu! page 99 | 100 | 101 | 102 | icons/osu.pngicons/osu.png 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | Search for this map on Bloodcat 111 | 112 | 113 | 114 | icons/bloodcat.pngicons/bloodcat.png 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | Hashes of unidentified missing maps 126 | 127 | 128 | 129 | 0 130 | 131 | 132 | 0 133 | 134 | 135 | 0 136 | 137 | 138 | 139 | 140 | true 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 0 152 | 0 153 | 154 | 155 | 156 | You have no missing maps! 157 | 158 | 159 | Qt::AlignCenter 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | Qt::Horizontal 169 | 170 | 171 | QDialogButtonBox::Ok 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | button_box 181 | accepted() 182 | MissingMapsDialog 183 | accept() 184 | 185 | 186 | 248 187 | 254 188 | 189 | 190 | 157 191 | 274 192 | 193 | 194 | 195 | 196 | button_box 197 | rejected() 198 | MissingMapsDialog 199 | reject() 200 | 201 | 202 | 316 203 | 260 204 | 205 | 206 | 286 207 | 274 208 | 209 | 210 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /util/osu_parser.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import re 4 | import logging 5 | import traceback 6 | 7 | from util.oce_models import Difficulty2, Song, Songs 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | def find_songs(songs_directory): 13 | log.debug("Using song directory {}".format(songs_directory)) 14 | songs = {} 15 | for folder in [d for d in os.listdir(songs_directory) if os.path.isdir(songs_directory + "/"+d)]: 16 | log.debug("Checking subfolder {}".format(folder)) 17 | files = [f for f in os.listdir(songs_directory + "/" + folder) if f.endswith(".osu")] 18 | log.debug("Found {} difficulties in folder {}.".format(len(files), folder)) 19 | songs[folder] = files 20 | 21 | return songs 22 | 23 | 24 | sectionPattern = re.compile(r'^\[([a-zA-Z0-9]+)\]$') 25 | keyvalPattern = re.compile(r'^([a-zA-Z0-9]+)\s*:\s*(.*)$') 26 | osuversionPattern = re.compile(r'^[\s\ufeff\x7f]*osu file format v([0-9]+)\s*$') 27 | blanklinePattern = re.compile(r'^[\s\ufeff\x7f]*$') 28 | 29 | 30 | class OsuFileFormatException(Exception): 31 | pass 32 | 33 | 34 | class OsuBeatmapVersionTooOldException(Exception): 35 | pass 36 | 37 | 38 | def parse_osu_file(path): 39 | log.debug("Reading .osu file {}".format(path)) 40 | log.debug("Opening file {}".format(path)) 41 | fobj = open("{}".format(path), 'r', encoding='utf8') 42 | 43 | valid = False 44 | data = {} 45 | sectiondata = {} 46 | sectionlist = [] 47 | currentsection = "" 48 | 49 | for line in fobj: 50 | 51 | # Ignore blank lines, skip to the next iteration 52 | if line == "\n": 53 | continue 54 | 55 | # Ignore empty lines, skip to the next iteration 56 | blank = blanklinePattern.findall(line) 57 | if len(blank) > 0: 58 | continue 59 | 60 | if data == {} and not valid: 61 | 62 | version = osuversionPattern.findall(line) 63 | if len(version) > 0: 64 | valid = True 65 | 66 | data['version'] = int(version[0]) 67 | log.debug("Osu file format version: {}".format(data['version'])) 68 | 69 | if data['version'] < 4: 70 | raise OsuBeatmapVersionTooOldException 71 | continue 72 | else: 73 | log.error("{} is not a valid .osu file.".format(path)) 74 | log.debug("The line was: {}".format(line)) 75 | raise OsuFileFormatException 76 | elif not valid: 77 | log.error("Something went wrong. {} is not a properly formatted .osu file.".format(path)) 78 | log.debug("The line was: {}".format(line)) 79 | raise OsuFileFormatException 80 | 81 | section = sectionPattern.findall(line) 82 | if len(section) > 0: 83 | if currentsection != "": 84 | data[currentsection] = sectionlist if sectionlist != [] else sectiondata 85 | 86 | sectiondata = {} 87 | sectionlist = [] 88 | currentsection = section[0] 89 | continue 90 | 91 | # Ignore some sections 92 | if currentsection in ["Colours", "HitObjects", "TimingPoints", "Events", "General", "Editor"]: 93 | continue 94 | 95 | # Parse key-value entries 96 | keyvalue = keyvalPattern.findall(line) 97 | if len(keyvalue) > 0: 98 | key, value = keyvalue[0] 99 | 100 | # Parse Difficulty values 101 | if currentsection == "Difficulty": 102 | if key in ["ApproachRate", "CircleSize", "HPDrainRate", 103 | "OverallDifficulty", "SliderMultiplier", "SliderTickRate"]: 104 | sectiondata[key] = float(value) 105 | else: 106 | sectiondata[key] = value 107 | 108 | # Parse metadata values 109 | elif currentsection == "Metadata": 110 | if key in ["BeatmapID", "BeatmapSetID"]: 111 | sectiondata[key] = int(value) 112 | elif key == "Tags": 113 | sectiondata[key] = value.split() 114 | else: 115 | sectiondata[key] = value 116 | 117 | # Parse other key-values 118 | else: 119 | sectiondata[key] = value 120 | continue 121 | 122 | log.warning("Unknown line: {}".format(line)) 123 | 124 | # Save the last section if applicable 125 | if currentsection != "" and (sectiondata != {} or sectionlist != []): 126 | data[currentsection] = sectionlist if sectionlist != [] else sectiondata 127 | 128 | log.debug("Parsing of {} completed.".format(path)) 129 | log.debug("data: {}".format(data)) 130 | return data 131 | 132 | 133 | def md5(fname): 134 | h = hashlib.md5() 135 | with open(fname, "rb") as f: 136 | for chunk in iter(lambda: f.read(4096), b""): 137 | h.update(chunk) 138 | return h.hexdigest() 139 | 140 | 141 | def load_songs_from_dir(directory): 142 | song_dirs = find_songs(directory) 143 | sorted_song_dirs = sorted(song_dirs) 144 | 145 | songs = Songs() 146 | 147 | for song_str in sorted_song_dirs: 148 | song = Song() 149 | difficulties = song_dirs.get(song_str) 150 | sorted_difficulties = sorted(difficulties) 151 | 152 | if not difficulties: 153 | log.warning("Song {} has no difficulties, skipping!".format(song_str)) 154 | continue 155 | 156 | for difficulty_str in sorted_difficulties: 157 | try: 158 | difficulty = Difficulty2.from_file("/".join([directory, song_str, difficulty_str])) 159 | song.add_difficulty(difficulty) 160 | except OsuBeatmapVersionTooOldException or OsuFileFormatException as e: 161 | log.warning("Something was wrong with the beatmap {}. The error was: {}".format(difficulty_str, e)) 162 | 163 | songs.add_song(song) 164 | 165 | return songs 166 | 167 | 168 | def load_songs_from_dir_gui(directory, dialog): 169 | song_dirs = find_songs(directory) 170 | sorted_song_dirs = sorted(song_dirs) 171 | 172 | songs = Songs() 173 | 174 | num_songdirs = len(sorted_song_dirs) 175 | num_done = 0 176 | 177 | for song_str in sorted_song_dirs: 178 | dialog.progress.emit(int((num_done/num_songdirs)*100)) 179 | dialog.current.emit(song_str) 180 | 181 | song = Song() 182 | difficulties = song_dirs.get(song_str) 183 | sorted_difficulties = sorted(difficulties) 184 | 185 | if not difficulties: 186 | log.warning("Song {} has no difficulties, skipping!".format(song_str)) 187 | continue 188 | 189 | for difficulty_str in sorted_difficulties: 190 | try: 191 | difficulty = Difficulty2.from_file("/".join([directory, song_str, difficulty_str])) 192 | song.add_difficulty(difficulty) 193 | except OsuBeatmapVersionTooOldException or OsuFileFormatException as e: 194 | log.warning("Something was wrong with the beatmap {}. The error was: {}".format(difficulty_str, e)) 195 | 196 | if not song.difficulties: 197 | log.warning("Song {} has no difficulties, skipping!".format(song_str)) 198 | continue 199 | 200 | songs.add_song(song) 201 | num_done += 1 202 | 203 | return songs 204 | -------------------------------------------------------------------------------- /gui_controller/loading_api.py: -------------------------------------------------------------------------------- 1 | from logging.config import logging 2 | from PyQt5 import QtWidgets, QtCore, QtGui 3 | import gui.loading 4 | import settings 5 | import util.osu_api as oa 6 | 7 | from util.oce_models import Difficulty2, Song 8 | 9 | 10 | class LoadingApi(QtWidgets.QDialog): 11 | progress = QtCore.pyqtSignal(int) 12 | current = QtCore.pyqtSignal(str) 13 | text = QtCore.pyqtSignal(str) 14 | done = QtCore.pyqtSignal() 15 | 16 | def __init__(self, collections, unmatched_maps): 17 | super(LoadingApi, self).__init__() 18 | self.log = logging.getLogger(__name__) 19 | 20 | self.ui = gui.loading.Ui_LoadingDialog() 21 | self.ui.setupUi(self) 22 | 23 | self.setModal(True) 24 | self.setFixedSize(self.width(), self.height()) 25 | self.setWindowFlags(QtCore.Qt.Dialog | QtCore.Qt.WindowTitleHint | QtCore.Qt.CustomizeWindowHint) 26 | 27 | self.collections = collections 28 | self.unmatched_maps = unmatched_maps 29 | self.api_matched_maps = [] 30 | 31 | self.progress.connect(self.update_precentage) 32 | self.current.connect(self.update_current) 33 | self.text.connect(self.update_text) 34 | self.done.connect(self.dismiss) 35 | 36 | self.ui.progressbar.setRange(0, 100) 37 | 38 | self.thread = QtCore.QThread() 39 | 40 | def keyPressEvent(self, event): 41 | if event.key() not in [QtCore.Qt.Key_Escape, QtCore.Qt.Key_Alt, QtCore.Qt.Key_AltGr, QtCore.Qt.Key_F4]: 42 | super(LoadingApi, self).keyPressEvent(event) 43 | 44 | def update_precentage(self, percentage): 45 | self.ui.progressbar.setValue(percentage) 46 | QtWidgets.qApp.processEvents() 47 | 48 | def update_text(self, text): 49 | if len(text) > 33: 50 | text = text[:30] + "..." 51 | self.ui.loading_label.setText(text) 52 | 53 | def update_current(self, text): 54 | if len(text) > 33: 55 | text = text[:30] + "..." 56 | self.ui.loading_current_label.setText(text) 57 | 58 | def exec_(self): 59 | w = LoadApiTask(self.collections, self.unmatched_maps, self) 60 | w.moveToThread(self.thread) 61 | self.thread.started.connect(w.work) 62 | self.thread.start() 63 | super(LoadingApi, self).exec_() 64 | 65 | def dismiss(self): 66 | self.hide() 67 | 68 | 69 | class LoadApiTask(QtCore.QObject): 70 | def __init__(self, cols, umaps, dialog): 71 | super(LoadApiTask, self).__init__() 72 | self.collections = cols 73 | self.unmatched_maps = umaps 74 | self.api_matched_maps = [] 75 | self.dialog = dialog 76 | self.settings = settings.Settings.get_instance() 77 | self.log = logging.getLogger(__name__) 78 | 79 | def work(self): 80 | # Load maps from API 81 | self.log.debug("Loading from API...") 82 | self.dialog.text.emit("Loading from API...") 83 | 84 | # Try to look up every unmatched beatmap 85 | umaps = self.unmatched_maps[:] 86 | mmaps = [] 87 | identified_count = 0 88 | progress = 0 89 | total_progress = len(umaps) 90 | for umap in umaps: 91 | 92 | # Update progressbar 93 | self.log.debug("Processing {}, progress: {}/{}={}%".format(umap.hash, progress, total_progress, int((progress/total_progress)*100))) 94 | self.dialog.progress.emit(int((progress/total_progress)*100)) 95 | self.dialog.current.emit(umap.hash) 96 | progress += 1 97 | 98 | res = oa.get_beatmap_by_hash(umap.hash) 99 | 100 | if res: 101 | details = res[0] 102 | # Create a difficulty for the map 103 | diff = Difficulty2("api") 104 | diff.name = details['title'] 105 | diff.artist = details['artist'] 106 | diff.mapper = details['creator'] 107 | diff.difficulty = details['version'] 108 | diff.ar = float(details['diff_approach']) 109 | diff.cs = float(details['diff_size']) 110 | diff.hp = float(details['diff_drain']) 111 | diff.od = float(details['diff_overall']) 112 | diff.hash = umap.hash 113 | diff.from_api = True 114 | umap.from_api = True 115 | diff.beatmap_id = details['beatmap_id'] 116 | 117 | self.dialog.current.emit("{} found!".format(umap.hash)) 118 | 119 | # Try to set the mapset of the beatmap and create a new one if we fail 120 | for m in mmaps: 121 | if hasattr(m.mapset, 'beatmapset_id') and m.mapset.beatmapset_id == int(details['beatmapset_id']): 122 | self.log.debug("Linked beatpam {} - {} [{}] to mapset {}".format(diff.artist, diff.name, diff.difficulty, m.mapset.beatmapset_id)) 123 | # There is a mapset! Add it to the mapset and use this mapset 124 | m.mapset.add_difficulty(diff) 125 | umap.mapset = m.mapset 126 | umap.difficulty = diff 127 | mmaps.append(umap) 128 | break 129 | # If the for loop ended without breaking, create a mapset for this map 130 | else: 131 | umap.mapset = Song() 132 | umap.mapset.add_difficulty(diff) 133 | umap.mapset.beatmapset_id = int(details['beatmapset_id']) 134 | self.log.debug("Created new mapset for beatpam {} - {} [{}] (beatmapset_id {})".format(diff.artist, diff.name, diff.difficulty, umap.mapset.beatmapset_id)) 135 | umap.difficulty = diff 136 | mmaps.append(umap) 137 | # Remove the map from the unmatched maps, it is now matched. 138 | identified_count += 1 139 | self.unmatched_maps.remove(umap) 140 | self.api_matched_maps.append(umap) 141 | 142 | # Add all maps that are now matched to the collection properly. 143 | self.log.debug("Adding found maps to collections...") 144 | self.dialog.text.emit("Adding found maps to collections...") 145 | self.dialog.progress.emit(99) 146 | 147 | for col in self.collections.collections: 148 | unm = col.unmatched[:] 149 | for um in unm: 150 | self.dialog.current.emit(um.hash) 151 | # If this map is now matched, remove it from the unmatched maps and add the mapset. 152 | replacement = next((x for x in mmaps if x.hash == um.hash), None) 153 | if replacement: 154 | self.log.debug("Found replacement for {} in collection {}".format(um.hash, col.name)) 155 | col.unmatched.remove(um) 156 | # Add the mapset if it isn't already in the list 157 | if um.mapset not in col.mapsets: 158 | col.mapsets.append(replacement.mapset) 159 | 160 | self.dialog.progress.emit(100) 161 | 162 | self.dialog.identified_count = identified_count 163 | self.dialog.collections = self.collections 164 | self.dialog.unmatched_maps = self.unmatched_maps 165 | self.dialog.api_matched_maps = self.api_matched_maps 166 | 167 | # Notify we're done. 168 | self.dialog.done.emit() 169 | -------------------------------------------------------------------------------- /gui/addsongs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'addsongs.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.5.1 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | from PyQt5 import QtCore, QtGui, QtWidgets 10 | 11 | class Ui_AddSongs(object): 12 | def setupUi(self, AddSongs): 13 | AddSongs.setObjectName("AddSongs") 14 | AddSongs.resize(1000, 480) 15 | icon = QtGui.QIcon() 16 | icon.addPixmap(QtGui.QPixmap("icons/oce.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 17 | AddSongs.setWindowIcon(icon) 18 | self.centralwidget = QtWidgets.QWidget(AddSongs) 19 | self.centralwidget.setObjectName("centralwidget") 20 | self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.centralwidget) 21 | self.verticalLayout_2.setContentsMargins(6, 6, 6, 6) 22 | self.verticalLayout_2.setObjectName("verticalLayout_2") 23 | self.global_layout = QtWidgets.QVBoxLayout() 24 | self.global_layout.setObjectName("global_layout") 25 | self.inner_layout = QtWidgets.QHBoxLayout() 26 | self.inner_layout.setObjectName("inner_layout") 27 | self.allsongs_groupbox = QtWidgets.QGroupBox(self.centralwidget) 28 | self.allsongs_groupbox.setObjectName("allsongs_groupbox") 29 | self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.allsongs_groupbox) 30 | self.verticalLayout_4.setSpacing(0) 31 | self.verticalLayout_4.setObjectName("verticalLayout_4") 32 | self.allsongs_scrollarea = QtWidgets.QScrollArea(self.allsongs_groupbox) 33 | self.allsongs_scrollarea.setWidgetResizable(True) 34 | self.allsongs_scrollarea.setObjectName("allsongs_scrollarea") 35 | self.allsongs_scrollarea_contents = QtWidgets.QWidget() 36 | self.allsongs_scrollarea_contents.setGeometry(QtCore.QRect(0, 0, 442, 367)) 37 | self.allsongs_scrollarea_contents.setObjectName("allsongs_scrollarea_contents") 38 | self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.allsongs_scrollarea_contents) 39 | self.verticalLayout_5.setContentsMargins(0, 0, 0, 0) 40 | self.verticalLayout_5.setSpacing(0) 41 | self.verticalLayout_5.setObjectName("verticalLayout_5") 42 | self.allsongs_list = QtWidgets.QTreeWidget(self.allsongs_scrollarea_contents) 43 | self.allsongs_list.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) 44 | self.allsongs_list.setObjectName("allsongs_list") 45 | self.allsongs_list.headerItem().setText(0, "1") 46 | self.allsongs_list.header().setVisible(False) 47 | self.verticalLayout_5.addWidget(self.allsongs_list) 48 | self.allsongs_scrollarea.setWidget(self.allsongs_scrollarea_contents) 49 | self.verticalLayout_4.addWidget(self.allsongs_scrollarea) 50 | self.allsongs_search_layout = QtWidgets.QHBoxLayout() 51 | self.allsongs_search_layout.setObjectName("allsongs_search_layout") 52 | self.allsongs_search_field = QtWidgets.QLineEdit(self.allsongs_groupbox) 53 | self.allsongs_search_field.setObjectName("allsongs_search_field") 54 | self.allsongs_search_layout.addWidget(self.allsongs_search_field) 55 | self.verticalLayout_4.addLayout(self.allsongs_search_layout) 56 | self.inner_layout.addWidget(self.allsongs_groupbox) 57 | self.button_layout = QtWidgets.QVBoxLayout() 58 | self.button_layout.setObjectName("button_layout") 59 | self.add_mapset_button = QtWidgets.QToolButton(self.centralwidget) 60 | self.add_mapset_button.setEnabled(False) 61 | self.add_mapset_button.setText("") 62 | icon1 = QtGui.QIcon() 63 | icon1.addPixmap(QtGui.QPixmap("icons/add_mapset.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 64 | self.add_mapset_button.setIcon(icon1) 65 | self.add_mapset_button.setObjectName("add_mapset_button") 66 | self.button_layout.addWidget(self.add_mapset_button) 67 | self.add_beatmap_button = QtWidgets.QToolButton(self.centralwidget) 68 | self.add_beatmap_button.setEnabled(False) 69 | self.add_beatmap_button.setText("") 70 | icon2 = QtGui.QIcon() 71 | icon2.addPixmap(QtGui.QPixmap("icons/add_map.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 72 | self.add_beatmap_button.setIcon(icon2) 73 | self.add_beatmap_button.setObjectName("add_beatmap_button") 74 | self.button_layout.addWidget(self.add_beatmap_button) 75 | self.remove_beatmap_button = QtWidgets.QToolButton(self.centralwidget) 76 | self.remove_beatmap_button.setEnabled(False) 77 | self.remove_beatmap_button.setText("") 78 | icon3 = QtGui.QIcon() 79 | icon3.addPixmap(QtGui.QPixmap("icons/remove_map.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 80 | self.remove_beatmap_button.setIcon(icon3) 81 | self.remove_beatmap_button.setObjectName("remove_beatmap_button") 82 | self.button_layout.addWidget(self.remove_beatmap_button) 83 | self.remove_mapset_button = QtWidgets.QToolButton(self.centralwidget) 84 | self.remove_mapset_button.setEnabled(False) 85 | self.remove_mapset_button.setText("") 86 | icon4 = QtGui.QIcon() 87 | icon4.addPixmap(QtGui.QPixmap("icons/remove_mapset.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 88 | self.remove_mapset_button.setIcon(icon4) 89 | self.remove_mapset_button.setObjectName("remove_mapset_button") 90 | self.button_layout.addWidget(self.remove_mapset_button) 91 | self.inner_layout.addLayout(self.button_layout) 92 | self.addsongs_groupbox = QtWidgets.QGroupBox(self.centralwidget) 93 | self.addsongs_groupbox.setObjectName("addsongs_groupbox") 94 | self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.addsongs_groupbox) 95 | self.verticalLayout_3.setSpacing(0) 96 | self.verticalLayout_3.setObjectName("verticalLayout_3") 97 | self.addsongs_scrollarea = QtWidgets.QScrollArea(self.addsongs_groupbox) 98 | self.addsongs_scrollarea.setWidgetResizable(True) 99 | self.addsongs_scrollarea.setObjectName("addsongs_scrollarea") 100 | self.addsongs_scrollarea_contents = QtWidgets.QWidget() 101 | self.addsongs_scrollarea_contents.setGeometry(QtCore.QRect(0, 0, 442, 394)) 102 | self.addsongs_scrollarea_contents.setObjectName("addsongs_scrollarea_contents") 103 | self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.addsongs_scrollarea_contents) 104 | self.verticalLayout_6.setContentsMargins(0, 0, 0, 0) 105 | self.verticalLayout_6.setSpacing(0) 106 | self.verticalLayout_6.setObjectName("verticalLayout_6") 107 | self.addsongs_list = QtWidgets.QTreeWidget(self.addsongs_scrollarea_contents) 108 | self.addsongs_list.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) 109 | self.addsongs_list.setObjectName("addsongs_list") 110 | self.addsongs_list.headerItem().setText(0, "1") 111 | self.addsongs_list.header().setVisible(False) 112 | self.verticalLayout_6.addWidget(self.addsongs_list) 113 | self.addsongs_scrollarea.setWidget(self.addsongs_scrollarea_contents) 114 | self.verticalLayout_3.addWidget(self.addsongs_scrollarea) 115 | self.inner_layout.addWidget(self.addsongs_groupbox) 116 | self.global_layout.addLayout(self.inner_layout) 117 | self.confirmation_buttons = QtWidgets.QDialogButtonBox(self.centralwidget) 118 | self.confirmation_buttons.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) 119 | self.confirmation_buttons.setObjectName("confirmation_buttons") 120 | self.global_layout.addWidget(self.confirmation_buttons) 121 | self.verticalLayout_2.addLayout(self.global_layout) 122 | AddSongs.setCentralWidget(self.centralwidget) 123 | 124 | self.retranslateUi(AddSongs) 125 | QtCore.QMetaObject.connectSlotsByName(AddSongs) 126 | 127 | def retranslateUi(self, AddSongs): 128 | _translate = QtCore.QCoreApplication.translate 129 | AddSongs.setWindowTitle(_translate("AddSongs", "Add songs to collection")) 130 | self.allsongs_groupbox.setTitle(_translate("AddSongs", "All songs in your songs folder")) 131 | self.allsongs_search_field.setPlaceholderText(_translate("AddSongs", "Filter songs...")) 132 | self.add_mapset_button.setToolTip(_translate("AddSongs", "Add all maps in the mapset of selected map.")) 133 | self.add_beatmap_button.setToolTip(_translate("AddSongs", "Add selected beatmap or mapset.")) 134 | self.remove_beatmap_button.setToolTip(_translate("AddSongs", "Remove selected beatmap or mapset.")) 135 | self.remove_mapset_button.setToolTip(_translate("AddSongs", "Remove all maps in the mapset of selected map.")) 136 | self.addsongs_groupbox.setTitle(_translate("AddSongs", "Songs to add to collection")) 137 | 138 | -------------------------------------------------------------------------------- /ui_designs/addsongs.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | AddSongs 4 | 5 | 6 | 7 | 0 8 | 0 9 | 1000 10 | 480 11 | 12 | 13 | 14 | Add songs to collection 15 | 16 | 17 | 18 | icons/oce.pngicons/oce.png 19 | 20 | 21 | 22 | 23 | 6 24 | 25 | 26 | 6 27 | 28 | 29 | 6 30 | 31 | 32 | 6 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | All songs in your songs folder 42 | 43 | 44 | 45 | 0 46 | 47 | 48 | 49 | 50 | true 51 | 52 | 53 | 54 | 55 | 0 56 | 0 57 | 442 58 | 367 59 | 60 | 61 | 62 | 63 | 0 64 | 65 | 66 | 0 67 | 68 | 69 | 0 70 | 71 | 72 | 0 73 | 74 | 75 | 0 76 | 77 | 78 | 79 | 80 | QAbstractItemView::ExtendedSelection 81 | 82 | 83 | false 84 | 85 | 86 | 87 | 1 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | Filter songs... 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | false 116 | 117 | 118 | Add all maps in the mapset of selected map. 119 | 120 | 121 | 122 | 123 | 124 | 125 | icons/add_mapset.pngicons/add_mapset.png 126 | 127 | 128 | 129 | 130 | 131 | 132 | false 133 | 134 | 135 | Add selected beatmap or mapset. 136 | 137 | 138 | 139 | 140 | 141 | 142 | icons/add_map.pngicons/add_map.png 143 | 144 | 145 | 146 | 147 | 148 | 149 | false 150 | 151 | 152 | Remove selected beatmap or mapset. 153 | 154 | 155 | 156 | 157 | 158 | 159 | icons/remove_map.pngicons/remove_map.png 160 | 161 | 162 | 163 | 164 | 165 | 166 | false 167 | 168 | 169 | Remove all maps in the mapset of selected map. 170 | 171 | 172 | 173 | 174 | 175 | 176 | icons/remove_mapset.pngicons/remove_mapset.png 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | Songs to add to collection 186 | 187 | 188 | 189 | 0 190 | 191 | 192 | 193 | 194 | true 195 | 196 | 197 | 198 | 199 | 0 200 | 0 201 | 442 202 | 394 203 | 204 | 205 | 206 | 207 | 0 208 | 209 | 210 | 0 211 | 212 | 213 | 0 214 | 215 | 216 | 0 217 | 218 | 219 | 0 220 | 221 | 222 | 223 | 224 | QAbstractItemView::ExtendedSelection 225 | 226 | 227 | false 228 | 229 | 230 | 231 | 1 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /ui_designs/settings.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | SettingsDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 624 10 | 488 11 | 12 | 13 | 14 | Settings 15 | 16 | 17 | 18 | icons/oce.pngicons/oce.png 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | true 27 | 28 | 29 | OCE can try to download information for missing songs via the osu! API. 30 | 31 | 32 | Osu! API 33 | 34 | 35 | false 36 | 37 | 38 | false 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | Osu! API Key 47 | 48 | 49 | 50 | 51 | 52 | 53 | <html><head/><body><p>(<a href="https://osu.ppy.sh/p/api"><span style=" text-decoration: underline; color:#0000ff;">Get one</span></a>)</p></body></html> 54 | 55 | 56 | true 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | Download missing song info from API? 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | Ask 79 | 80 | 81 | 82 | 83 | Always 84 | 85 | 86 | 87 | 88 | Never 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | These values will be the default values if you open a new collection. 102 | 103 | 104 | Default Folders 105 | 106 | 107 | 108 | 109 | 110 | Load from 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | osu!.db file 119 | 120 | 121 | 122 | 123 | Songs folder 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | Default osu!.db 132 | 133 | 134 | 135 | 136 | 137 | 138 | Default Songs folder 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | Browse 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | Default collection.db 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | Browse 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | Browse 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | These settings can disable confirmation dialogs across the application. 198 | 199 | 200 | Dialog Settings 201 | 202 | 203 | 204 | 205 | 206 | Show shutdown dialog when I exit the program. 207 | 208 | 209 | true 210 | 211 | 212 | 213 | 214 | 215 | 216 | Show API icon explanation dialogs. 217 | 218 | 219 | true 220 | 221 | 222 | 223 | 224 | 225 | 226 | Show collection delete confirmation. 227 | 228 | 229 | true 230 | 231 | 232 | 233 | 234 | 235 | 236 | Show remove song from collection confirmation. 237 | 238 | 239 | true 240 | 241 | 242 | 243 | 244 | 245 | 246 | Show remove mapset from collection confirmation. 247 | 248 | 249 | true 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | Qt::Horizontal 262 | 263 | 264 | QDialogButtonBox::Apply|QDialogButtonBox::Cancel|QDialogButtonBox::Ok 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | -------------------------------------------------------------------------------- /gui/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'settings.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.5.1 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | from PyQt5 import QtCore, QtGui, QtWidgets 10 | 11 | class Ui_SettingsDialog(object): 12 | def setupUi(self, SettingsDialog): 13 | SettingsDialog.setObjectName("SettingsDialog") 14 | SettingsDialog.resize(624, 488) 15 | icon = QtGui.QIcon() 16 | icon.addPixmap(QtGui.QPixmap("icons/oce.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 17 | SettingsDialog.setWindowIcon(icon) 18 | self.verticalLayout = QtWidgets.QVBoxLayout(SettingsDialog) 19 | self.verticalLayout.setObjectName("verticalLayout") 20 | self.settings_layout = QtWidgets.QVBoxLayout() 21 | self.settings_layout.setObjectName("settings_layout") 22 | self.api_box = QtWidgets.QGroupBox(SettingsDialog) 23 | self.api_box.setEnabled(True) 24 | self.api_box.setCheckable(False) 25 | self.api_box.setChecked(False) 26 | self.api_box.setObjectName("api_box") 27 | self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.api_box) 28 | self.verticalLayout_3.setObjectName("verticalLayout_3") 29 | self.api_key_layout = QtWidgets.QHBoxLayout() 30 | self.api_key_layout.setObjectName("api_key_layout") 31 | self.api_key_label = QtWidgets.QLabel(self.api_box) 32 | self.api_key_label.setObjectName("api_key_label") 33 | self.api_key_layout.addWidget(self.api_key_label) 34 | self.api_key_get_link = QtWidgets.QLabel(self.api_box) 35 | self.api_key_get_link.setOpenExternalLinks(True) 36 | self.api_key_get_link.setObjectName("api_key_get_link") 37 | self.api_key_layout.addWidget(self.api_key_get_link) 38 | self.api_key_line = QtWidgets.QLineEdit(self.api_box) 39 | self.api_key_line.setObjectName("api_key_line") 40 | self.api_key_layout.addWidget(self.api_key_line) 41 | self.verticalLayout_3.addLayout(self.api_key_layout) 42 | self.download_api_layout = QtWidgets.QHBoxLayout() 43 | self.download_api_layout.setObjectName("download_api_layout") 44 | self.download_api_label = QtWidgets.QLabel(self.api_box) 45 | self.download_api_label.setObjectName("download_api_label") 46 | self.download_api_layout.addWidget(self.download_api_label) 47 | self.download_api_combobox = QtWidgets.QComboBox(self.api_box) 48 | self.download_api_combobox.setObjectName("download_api_combobox") 49 | self.download_api_combobox.addItem("") 50 | self.download_api_combobox.addItem("") 51 | self.download_api_combobox.addItem("") 52 | self.download_api_layout.addWidget(self.download_api_combobox) 53 | self.verticalLayout_3.addLayout(self.download_api_layout) 54 | self.settings_layout.addWidget(self.api_box) 55 | self.default_folders_box = QtWidgets.QGroupBox(SettingsDialog) 56 | self.default_folders_box.setObjectName("default_folders_box") 57 | self.formLayout_2 = QtWidgets.QFormLayout(self.default_folders_box) 58 | self.formLayout_2.setObjectName("formLayout_2") 59 | self.loadfrom_label = QtWidgets.QLabel(self.default_folders_box) 60 | self.loadfrom_label.setObjectName("loadfrom_label") 61 | self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.loadfrom_label) 62 | self.loadfrom_dropdown = QtWidgets.QComboBox(self.default_folders_box) 63 | self.loadfrom_dropdown.setObjectName("loadfrom_dropdown") 64 | self.loadfrom_dropdown.addItem("") 65 | self.loadfrom_dropdown.addItem("") 66 | self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.loadfrom_dropdown) 67 | self.default_osudb_label = QtWidgets.QLabel(self.default_folders_box) 68 | self.default_osudb_label.setObjectName("default_osudb_label") 69 | self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.default_osudb_label) 70 | self.default_songs_label = QtWidgets.QLabel(self.default_folders_box) 71 | self.default_songs_label.setObjectName("default_songs_label") 72 | self.formLayout_2.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.default_songs_label) 73 | self.default_songs_layout = QtWidgets.QHBoxLayout() 74 | self.default_songs_layout.setObjectName("default_songs_layout") 75 | self.default_songs_line = QtWidgets.QLineEdit(self.default_folders_box) 76 | self.default_songs_line.setObjectName("default_songs_line") 77 | self.default_songs_layout.addWidget(self.default_songs_line) 78 | self.default_songs_button = QtWidgets.QPushButton(self.default_folders_box) 79 | self.default_songs_button.setObjectName("default_songs_button") 80 | self.default_songs_layout.addWidget(self.default_songs_button) 81 | self.formLayout_2.setLayout(2, QtWidgets.QFormLayout.FieldRole, self.default_songs_layout) 82 | self.default_collection_label = QtWidgets.QLabel(self.default_folders_box) 83 | self.default_collection_label.setObjectName("default_collection_label") 84 | self.formLayout_2.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.default_collection_label) 85 | self.default_collection_layout = QtWidgets.QHBoxLayout() 86 | self.default_collection_layout.setObjectName("default_collection_layout") 87 | self.default_collection_line = QtWidgets.QLineEdit(self.default_folders_box) 88 | self.default_collection_line.setObjectName("default_collection_line") 89 | self.default_collection_layout.addWidget(self.default_collection_line) 90 | self.default_collection_button = QtWidgets.QPushButton(self.default_folders_box) 91 | self.default_collection_button.setObjectName("default_collection_button") 92 | self.default_collection_layout.addWidget(self.default_collection_button) 93 | self.formLayout_2.setLayout(3, QtWidgets.QFormLayout.FieldRole, self.default_collection_layout) 94 | self.default_osudb_layout = QtWidgets.QHBoxLayout() 95 | self.default_osudb_layout.setObjectName("default_osudb_layout") 96 | self.default_osudb_line = QtWidgets.QLineEdit(self.default_folders_box) 97 | self.default_osudb_line.setObjectName("default_osudb_line") 98 | self.default_osudb_layout.addWidget(self.default_osudb_line) 99 | self.default_osudb_button = QtWidgets.QPushButton(self.default_folders_box) 100 | self.default_osudb_button.setObjectName("default_osudb_button") 101 | self.default_osudb_layout.addWidget(self.default_osudb_button) 102 | self.formLayout_2.setLayout(1, QtWidgets.QFormLayout.FieldRole, self.default_osudb_layout) 103 | self.settings_layout.addWidget(self.default_folders_box) 104 | self.dialog_settings_box = QtWidgets.QGroupBox(SettingsDialog) 105 | self.dialog_settings_box.setObjectName("dialog_settings_box") 106 | self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.dialog_settings_box) 107 | self.verticalLayout_2.setObjectName("verticalLayout_2") 108 | self.shutdown_dialog_checkbox = QtWidgets.QCheckBox(self.dialog_settings_box) 109 | self.shutdown_dialog_checkbox.setChecked(True) 110 | self.shutdown_dialog_checkbox.setObjectName("shutdown_dialog_checkbox") 111 | self.verticalLayout_2.addWidget(self.shutdown_dialog_checkbox) 112 | self.api_explanation_dialog = QtWidgets.QCheckBox(self.dialog_settings_box) 113 | self.api_explanation_dialog.setChecked(True) 114 | self.api_explanation_dialog.setObjectName("api_explanation_dialog") 115 | self.verticalLayout_2.addWidget(self.api_explanation_dialog) 116 | self.collection_delete_dialog = QtWidgets.QCheckBox(self.dialog_settings_box) 117 | self.collection_delete_dialog.setChecked(True) 118 | self.collection_delete_dialog.setObjectName("collection_delete_dialog") 119 | self.verticalLayout_2.addWidget(self.collection_delete_dialog) 120 | self.song_remove_dialog = QtWidgets.QCheckBox(self.dialog_settings_box) 121 | self.song_remove_dialog.setChecked(True) 122 | self.song_remove_dialog.setObjectName("song_remove_dialog") 123 | self.verticalLayout_2.addWidget(self.song_remove_dialog) 124 | self.mapset_remove_dialog = QtWidgets.QCheckBox(self.dialog_settings_box) 125 | self.mapset_remove_dialog.setChecked(True) 126 | self.mapset_remove_dialog.setObjectName("mapset_remove_dialog") 127 | self.verticalLayout_2.addWidget(self.mapset_remove_dialog) 128 | self.settings_layout.addWidget(self.dialog_settings_box) 129 | self.verticalLayout.addLayout(self.settings_layout) 130 | self.button_box = QtWidgets.QDialogButtonBox(SettingsDialog) 131 | self.button_box.setOrientation(QtCore.Qt.Horizontal) 132 | self.button_box.setStandardButtons(QtWidgets.QDialogButtonBox.Apply|QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) 133 | self.button_box.setObjectName("button_box") 134 | self.verticalLayout.addWidget(self.button_box) 135 | 136 | self.retranslateUi(SettingsDialog) 137 | QtCore.QMetaObject.connectSlotsByName(SettingsDialog) 138 | 139 | def retranslateUi(self, SettingsDialog): 140 | _translate = QtCore.QCoreApplication.translate 141 | SettingsDialog.setWindowTitle(_translate("SettingsDialog", "Settings")) 142 | self.api_box.setToolTip(_translate("SettingsDialog", "OCE can try to download information for missing songs via the osu! API.")) 143 | self.api_box.setTitle(_translate("SettingsDialog", "Osu! API")) 144 | self.api_key_label.setText(_translate("SettingsDialog", "Osu! API Key")) 145 | self.api_key_get_link.setText(_translate("SettingsDialog", "

(Get one)

")) 146 | self.download_api_label.setText(_translate("SettingsDialog", "Download missing song info from API?")) 147 | self.download_api_combobox.setItemText(0, _translate("SettingsDialog", "Ask")) 148 | self.download_api_combobox.setItemText(1, _translate("SettingsDialog", "Always")) 149 | self.download_api_combobox.setItemText(2, _translate("SettingsDialog", "Never")) 150 | self.default_folders_box.setToolTip(_translate("SettingsDialog", "These values will be the default values if you open a new collection.")) 151 | self.default_folders_box.setTitle(_translate("SettingsDialog", "Default Folders")) 152 | self.loadfrom_label.setText(_translate("SettingsDialog", "Load from")) 153 | self.loadfrom_dropdown.setItemText(0, _translate("SettingsDialog", "osu!.db file")) 154 | self.loadfrom_dropdown.setItemText(1, _translate("SettingsDialog", "Songs folder")) 155 | self.default_osudb_label.setText(_translate("SettingsDialog", "Default osu!.db")) 156 | self.default_songs_label.setText(_translate("SettingsDialog", "Default Songs folder")) 157 | self.default_songs_button.setText(_translate("SettingsDialog", "Browse")) 158 | self.default_collection_label.setText(_translate("SettingsDialog", "Default collection.db")) 159 | self.default_collection_button.setText(_translate("SettingsDialog", "Browse")) 160 | self.default_osudb_button.setText(_translate("SettingsDialog", "Browse")) 161 | self.dialog_settings_box.setToolTip(_translate("SettingsDialog", "These settings can disable confirmation dialogs across the application.")) 162 | self.dialog_settings_box.setTitle(_translate("SettingsDialog", "Dialog Settings")) 163 | self.shutdown_dialog_checkbox.setText(_translate("SettingsDialog", "Show shutdown dialog when I exit the program.")) 164 | self.api_explanation_dialog.setText(_translate("SettingsDialog", "Show API icon explanation dialogs.")) 165 | self.collection_delete_dialog.setText(_translate("SettingsDialog", "Show collection delete confirmation.")) 166 | self.song_remove_dialog.setText(_translate("SettingsDialog", "Show remove song from collection confirmation.")) 167 | self.mapset_remove_dialog.setText(_translate("SettingsDialog", "Show remove mapset from collection confirmation.")) 168 | 169 | -------------------------------------------------------------------------------- /util/oce_models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class Collections: 5 | """ 6 | :type version: int 7 | :type collections: list[Collection] 8 | """ 9 | def __init__(self): 10 | self.version = 0 11 | self.collections = [] 12 | 13 | def set_collection(self, name, collection): 14 | try: 15 | c = [c for c in self.collections if c.name == name][0] 16 | except KeyError: 17 | c = None 18 | 19 | if c is not None: 20 | i = self.collections.index(c) 21 | self.collections[i] = collection 22 | else: 23 | self.collections.append(collection) 24 | 25 | def get_collection(self, name): 26 | try: 27 | c = [c for c in self.collections if c.name == name][0] 28 | except KeyError: 29 | c = None 30 | 31 | return c 32 | 33 | 34 | class Collection: 35 | """ 36 | :type name: str 37 | :type beatmaps: list[CollectionMap] 38 | :type mapsets: list[Song] 39 | :type unmatched: list[CollectionMap] 40 | """ 41 | def __init__(self): 42 | self.name = "" 43 | self.beatmaps = [] 44 | self.mapsets = [] 45 | self.unmatched = [] 46 | 47 | def get_unmatched_song(self, song_hash): 48 | """ 49 | Returns CollectionMap of the given unmatched hash 50 | :param song_hash: 51 | :return: 52 | """ 53 | for s in self.unmatched: 54 | if s.hash == song_hash: 55 | return s 56 | return None 57 | 58 | def find_collectionmap_by_difficulty(self, difficulty_or_hash): 59 | """ 60 | Finds the collectionmap object beloning to the given difficulty 61 | :param difficulty_or_hash: The difficulty or hash to search for 62 | :type difficulty_or_hash: Difficulty2|str 63 | :return: The collectionmap if found, else None 64 | :rtype: CollectionMap|None 65 | """ 66 | if isinstance(difficulty_or_hash, str): 67 | for m in self.beatmaps: 68 | if m.hash == difficulty_or_hash: 69 | return m 70 | else: 71 | return None 72 | elif isinstance(difficulty_or_hash, Difficulty2): 73 | for m in self.beatmaps: 74 | if m.difficulty == difficulty_or_hash: 75 | return m 76 | else: 77 | return None 78 | else: 79 | return None 80 | 81 | def remove_song(self, song_difficulty_or_hash): 82 | found = None 83 | for m in self.beatmaps: 84 | if isinstance(song_difficulty_or_hash, str): 85 | if m.hash == song_difficulty_or_hash: 86 | found = m 87 | break 88 | elif isinstance(song_difficulty_or_hash, Difficulty2): 89 | if m.difficulty == song_difficulty_or_hash or m.hash == song_difficulty_or_hash.hash: 90 | found = m 91 | break 92 | 93 | if found: 94 | # Remove the map from the beatmap list 95 | self.beatmaps.remove(found) 96 | 97 | # We also need to remove the map from the mapset it belongs to 98 | for m in self.mapsets: 99 | if found.difficulty.hash in [d.hash for d in m.difficulties]: 100 | # If the found difficulty is the same, remove it directly 101 | if found.difficulty in m.difficulties: 102 | m.difficulties.remove(found.difficulty) 103 | # Else, find the entry in the list by hash, then remove it. 104 | else: 105 | for d in m.difficulties: 106 | if d.hash == found.difficulty.hash: 107 | m.difficulties.remove(d) 108 | break 109 | 110 | # If there are no maps in this mapset any more, remove it 111 | if not m.difficulties: 112 | self.mapsets.remove(m) 113 | return True 114 | else: 115 | # The song was not found in this collections' beatmaps. Try to remove it from the mapsets if it is in there. 116 | if isinstance(song_difficulty_or_hash, Difficulty2): 117 | for m in self.mapsets: 118 | if song_difficulty_or_hash.hash in [d.hash for d in m.difficulties]: 119 | for d in m.difficulties: 120 | if d.hash == song_difficulty_or_hash.hash: 121 | m.difficulties.remove(d) 122 | break 123 | 124 | # If there are no maps in this mapset any more, remove it 125 | if not m.difficulties: 126 | self.mapsets.remove(m) 127 | 128 | return False 129 | 130 | 131 | class CollectionMap: 132 | """ 133 | :type hash: str 134 | :type difficulty: Difficulty2 135 | :type mapset: Song 136 | :type from_api: bool 137 | """ 138 | def __init__(self): 139 | self.hash = "" 140 | self.difficulty = None 141 | self.mapset = None 142 | self.from_api = False 143 | 144 | 145 | class Difficulty: 146 | """ 147 | :type path: str 148 | :type data: dict 149 | """ 150 | def __init__(self, path, data): 151 | self.path = path 152 | self.data = data 153 | 154 | def get_data(self): 155 | return self.data 156 | 157 | def get_path(self): 158 | return self.path 159 | 160 | def get_version(self): 161 | return self.data["version"] 162 | 163 | def get_difficulty(self, key=None): 164 | if key: 165 | return self.data["Difficulty"][key] 166 | else: 167 | return self.data["Difficulty"] 168 | 169 | def get_ar(self): 170 | return self.data["Difficulty"]["ApproachRate"] 171 | 172 | def get_cs(self): 173 | return self.data["Difficulty"]["CircleSize"] 174 | 175 | def get_hp(self): 176 | return self.data["Difficulty"]["HPDrainRate"] 177 | 178 | def get_od(self): 179 | return self.data["Difficulty"]["OverallDifficulty"] 180 | 181 | def get_slider_multiplier(self): 182 | return self.data["Difficulty"]["SliderMultiplier"] 183 | 184 | def get_slider_tick_rate(self): 185 | return self.data["Difficulty"]["SliderTickRate"] 186 | 187 | def get_editor(self, key=None): 188 | if key: 189 | return self.data["Editor"][key] 190 | else: 191 | return self.data["Editor"] 192 | 193 | def get_general(self, key=None): 194 | if key: 195 | return self.data["General"][key] 196 | else: 197 | return self.data["General"] 198 | 199 | def get_audio_filename(self): 200 | return self.data["General"]["AudioFilename"] 201 | 202 | def get_audio_path(self): 203 | return "{}/{}".format(os.path.dirname(self.path), self.data["General"]["AudioFilename"]) 204 | 205 | def get_audio_lead_in(self): 206 | return self.data["General"]["AudioLeadIn"] 207 | 208 | def get_countdown(self): 209 | return self.data["General"]["Countdown"] 210 | 211 | def get_mode(self): 212 | return self.data["General"]["Mode"] 213 | 214 | def get_artist(self): 215 | return self.data["Metadata"]["Artist"] 216 | 217 | def get_unicode_artist(self): 218 | return self.data["Metadata"]["ArtistUnicode"] 219 | 220 | def get_beatmap_id(self): 221 | return self.data["Metadata"]["BeatmapID"] 222 | 223 | def get_beatmap_set_id(self): 224 | return self.data["Metadata"]["BeatmapSetID"] 225 | 226 | def get_creator(self): 227 | return self.data["Metadata"]["Creator"] 228 | 229 | def get_source(self): 230 | return self.data["Metadata"]["Source"] 231 | 232 | def get_tags(self): 233 | return self.data["Metadata"]["Tags"] 234 | 235 | def get_title(self): 236 | return self.data["Metadata"]["Title"] 237 | 238 | def get_unicode_title(self): 239 | return self.data["Metadata"]["TitleUnicode"] 240 | 241 | def get_difficulty_string(self): 242 | return self.data["Metadata"]["Version"] 243 | 244 | def get_difficulty_multiplier(self): 245 | cs = int(self.get_cs()) 246 | od = int(self.get_od()) 247 | hp = int(self.get_hp()) 248 | 249 | return cs+od+hp 250 | 251 | def __repr__(self): 252 | return self.__str__() 253 | 254 | def __str__(self): 255 | return "Diff: {} - {} ({}) [{}]".format(self.get_artist(), self.get_title(), 256 | self.get_creator(), self.get_version()) 257 | 258 | @classmethod 259 | def from_file(cls, filepath): 260 | from util.osu_parser import parse_osu_file 261 | return cls(filepath, parse_osu_file(filepath)) 262 | 263 | 264 | class Difficulty2: 265 | """ 266 | :type path: str 267 | :type hash: str 268 | """ 269 | def __init__(self, path): 270 | self.path = path 271 | self.name = "" 272 | self.artist = "" 273 | self.mapper = "" 274 | self.difficulty = "" 275 | self.ar = 0.0 276 | self.cs = 0.0 277 | self.hp = 0.0 278 | self.od = 0.0 279 | self.hash = "" 280 | self.from_api = False 281 | self.api_beatmap_id = "" 282 | self.beatmap_id = "" 283 | self.beatmapset_id = "" 284 | 285 | def deep_copy(self): 286 | res = Difficulty2("") 287 | for attr in ["path", "name", "artist", "mapper", "difficulty", "ar", "cs", "hp", "od", "hash", "from_api"]: 288 | setattr(res, attr, getattr(self, attr)) 289 | return res 290 | 291 | def __repr__(self): 292 | return self.__str__() 293 | 294 | def __str__(self): 295 | return "Diff2: {} - {} ({}) [{}]".format(self.artist, self.name, self.mapper, self.difficulty) 296 | 297 | @classmethod 298 | def from_file(cls, path): 299 | from util.osu_parser import md5, parse_osu_file 300 | d = cls(path) 301 | d.hash = md5(path) 302 | 303 | details = parse_osu_file(path) 304 | d.name = details["Metadata"]["Title"] 305 | d.artist = details["Metadata"]["Artist"] 306 | d.mapper = details["Metadata"]["Creator"] 307 | d.difficulty = details["Metadata"]["Version"] 308 | 309 | try: 310 | d.beatmap_id = details["Metadata"]["BeatmapID"] 311 | except KeyError: 312 | pass 313 | 314 | try: 315 | d.ar = details["Difficulty"]["ApproachRate"] 316 | except KeyError: 317 | pass 318 | 319 | try: 320 | d.cs = details["Difficulty"]["CircleSize"] 321 | except KeyError: 322 | pass 323 | 324 | try: 325 | d.hp = details["Difficulty"]["HPDrainRate"] 326 | except KeyError: 327 | pass 328 | 329 | try: 330 | d.od = details["Difficulty"]["OverallDifficulty"] 331 | except KeyError: 332 | pass 333 | 334 | return d 335 | 336 | 337 | class Song: 338 | """ 339 | :type difficulties: list[Difficulty|Difficulty2] 340 | """ 341 | def __init__(self): 342 | self.difficulties = [] 343 | 344 | def add_difficulty(self, difficulty): 345 | self.difficulties.append(difficulty) 346 | 347 | def deep_copy(self): 348 | """ 349 | :rtype: Song 350 | """ 351 | res = Song() 352 | 353 | if len(self.difficulties) == 0: 354 | print("Copying song with 0 diffs!") 355 | 356 | # Create deep copies of the diffs and add them 357 | for d in self.difficulties: 358 | d_dc = d.deep_copy() 359 | res.add_difficulty(d_dc) 360 | 361 | return res 362 | 363 | def __repr__(self): 364 | return self.__str__() 365 | 366 | def __str__(self): 367 | return "Song ({} difficulties): {}".format(len(self.difficulties), self.difficulties) 368 | 369 | 370 | class Songs: 371 | """ 372 | :type songs: list[Song] 373 | """ 374 | def __init__(self): 375 | self.songs = [] 376 | self.log = logging.getLogger(__name__) 377 | 378 | def add_song(self, song): 379 | if not song.difficulties: 380 | self.log.warning("An empty song was added!") 381 | self.songs.append(song) 382 | 383 | def get_song(self, song_hash): 384 | """ 385 | Returns the song with the given hash 386 | :param song_hash: Hash of the song to get 387 | :type song_hash: str 388 | :return: Mapset and song difficulty, or None if nothing was found 389 | :rtype: tuple(Song, Difficulty2) | None 390 | """ 391 | for song in self.songs: 392 | for diff in song.difficulties: 393 | if diff.hash == song_hash: 394 | return song, diff 395 | 396 | return None 397 | 398 | def __repr__(self): 399 | return self.__str__() 400 | 401 | def __str__(self): 402 | return "Songs: {}".format(self.songs) 403 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | OCE: The Osu Collections Editor 2 | =============================== 3 | 4 | ![Image of OCE](http://oce.kurocon.nl/images/oce.png) 5 | 6 | OCE is a small program to easily edit osu! collections. The program can edit the collection.db file from the game directly, using your own osu! song database. The program can find any songs that are missing from your song database, but are present in your collections! These songs do not show up in game, but are still in your collections. OCE can see these songs, and try to identify them using the osu! API. This way you can find that one song that you know was in your collection somewhere, but you lost in the game. 7 | 8 | Table of contents 9 | ================= 10 | * [OCE: The Osu Collections Editor](#oce-the-osu-collections-editor) 11 | * [Table of contents](#table-of-contents) 12 | * [Installation](#installation) 13 | * [Usage](#usage) 14 | * [Opening your collections](#opening-your-collections) 15 | * [Adding/Removing/Renaming collections](#addingremovingrenaming-collections) 16 | * [Adding/Removing songs to a collection](#addingremoving-songs-to-a-collection) 17 | * [The Add Songs Dialog](#the-add-songs-dialog) 18 | * [Saving your changes](#saving-your-changes) 19 | * [Finding missing songs](#finding-missing-songs) 20 | * [Common problems](#common-problems) 21 | * [Contributing](#contributing) 22 | * [Reporting bugs](#reporting-bugs) 23 | * [Contributing code](#contributing-code) 24 | * [Contributing art/translations/etc.](#contributing-arttranslationsetc) 25 | 26 | Installation 27 | ============ 28 | There are two ways of installing OCE. Using the precompiled release binaries, or running the application from source. 29 | 30 | Release binaries 31 | ---------------- 32 | You can download the releases from the GitHub releases page [here](https://github.com/Kurocon/Osu-Collections-Editor/releases). Two types of releases are available, standard and portable. 33 | 34 | The standard release is the recommended one. It contains all dependencies of OCE and is meant to run from a folder on your computer. 35 | 36 | The portable release is a little bit slower then the normal release, but has the added benefit of having all dependencies and the application packed into one executable with a few support files. This release is meant to be put on something like a USB stick or something. This way, it is possible to have both the Linux and Windows versions of the program in one place. 37 | 38 | The installation for both releases is the same, aside from the archive you download. The installation procedure is as follows 39 | 40 | 1. Download the archive for your OS and desired version. (e.g. "OCE_Windows_v1.0.zip" for the standard release on Windows or "OCE_Linux_Portable_v1.0.tar.gz" for the portable release on Linux) 41 | 3. Extract the downloaded archive somewhere. It does not really matter where. 42 | 2. In the extracted folder, run `oce.exe` on Windows or `oce` on Linux. 43 | 44 | The application should now start up normally. If it does not, see [Common problems](#common-problems) below. 45 | 46 | Usage 47 | ===== 48 | 49 | Opening your collections 50 | ------------------------ 51 | When you start up the program, the first thing you want to do is open up your collections database so you can get to editing! To do that, click `File`, followed by `Open`. (Or press `Ctrl+O`) 52 | 53 | ![Open collection dialog](http://oce.kurocon.nl/images/open.png) 54 | 55 | A popup opens asking you for the location of your osu! songs directory and your collection.db file. There are normally both found in your osu! installation folder (Typically `C\Program Files\osu!` or `C:\Users\(your_username)\AppData\Local\osu!`) but you can also open collection databases downloaded from others or archived song directories. 56 | 57 | You can set defaults for both of these values in the [Settings](#settings) so you don't have to fill them in each time! 58 | 59 | After giving OCE the location of your songs folder and collection.db, click `OK` and OCE will begin to load your songs and collections! 60 | 61 | ![API download message](http://oce.kurocon.nl/images/api.png) 62 | 63 | When OCE is finished loading your songs and collections, it might have found some maps in your collections database which you do not have in your songs folder. If you have an osu! API key set in the [Settings](#settings), it will ask you if you want to look the information for these songs up via the osu! API. This way, you can find out what these songs are and re-download them via either the osu! song page or look for them on bloodcat if they are no longer available. 64 | 65 | Please note that OCE might not be able to find information for all of your missing maps, especially if they have been removed from the osu! website. You can hide these popups or disable API lookup in the [Settings](#settings). 66 | 67 | Maps which info was downloaded from the osu! API will be marked with a blue icon. Maps which are completely unknown are marked with a yellow icon. 68 | 69 | ![API and missing icons](http://oce.kurocon.nl/images/main_api_missing.png) 70 | 71 | When everything is done, the main screen will show two columns. The left column will contain all your collections (if you have any), and the right will show you the songs in the currently selected collection, if you have one selected. The buttons at the bottom let you add, remove or rename collections and add or remove songs or mapsets. 72 | 73 | Adding/Removing/Renaming collections 74 | ------------------------------------ 75 | ![Collection management](http://oce.kurocon.nl/images/collection_buttons.png) 76 | 77 | To add, remove or rename collections, use the buttons underneath the left part of the screen. 78 | 79 | The add button lets you create new collections. 80 | 81 | The remove button removes the currently selected collection. 82 | 83 | The edit button lets you rename the selected collection. 84 | 85 | The up and down buttons let you move collections up or down. This will affect how they show up in the osu! client, even if they are not sorted alphabetically. Osu! willreset the order of the collections if you add, remove or rename a collection in the osu! client itself, so if you want to keep your ordering, edit them using this program, and not the osu! client. 86 | 87 | The options menu contains all of the above actions. This menu can also be found when right-clicking a collection. 88 | 89 | Adding/Removing songs to a collection 90 | ------------------------------------- 91 | ![Song management](http://oce.kurocon.nl/images/song_buttons.png) 92 | 93 | When you have a collection selected in the left part of the screen, the songs in that collection will show up on the right part. To add or remove songs or mapsets, use the buttons below the list. 94 | 95 | The add button will open a popup in which you can choose which songs to add. More information on the Add Songs dialog is [below](#the-add-songs-dialog). 96 | 97 | The remove button will remove all selected songs from the collection. If a mapset is selected along with normal songs, all songs inside will be removed. 98 | 99 | The remove set button will remove all songs in the selected mapsets. If a normal song is selected, all songs in its mapset will also be removed! 100 | 101 | The options menu contains all of the above actions, plus two more. This menu can also be found when right-clicking a song or mapset. 102 | 103 | The `Open download page` option in the menu will open the download page of the song on the osu! website, if OCE knows about it. 104 | 105 | The `Search map on bloodcat` option in the menu will open the bloodcat search page, with the beatmap ID filled in. This allows you to try and find the beatmap on bloodcat if the beatmap is removed from the osu! website or if you cannot download from the normal site for some reason. 106 | 107 | The Add Songs Dialog 108 | -------------------- 109 | ![Add Songs dialog](http://oce.kurocon.nl/images/add_songs.png) 110 | 111 | When you click the `Add song` button in the main window, OCE will load all of the songs in your song directory into a list, in which you can pick and choose the songs or mapsets you want to add to the currently selected collection. You can search in the list by typing into the search box below the list. 112 | 113 | By using the buttons in the center to add songs to the left list, you pick which songs are going to be added. The double-arrow buttons add or remove the entire mapset of the song selected, and the single arrows only add the selected songs, not the entire mapset. 114 | 115 | When you're done picking songs, click `OK` and the maps will be added to the collection. Don't forget to save! 116 | 117 | Saving your changes 118 | ------------------- 119 | To save all of the changes you made to your collections, click `File` followed by either `Save` or `Save as...`. `Save` will directly save and overwrite the openend collection.db file, and `Save as...` lets you pick where you want to save the database and how to name it. 120 | 121 | Finding missing songs 122 | --------------------- 123 | One of the features of OCE is to show you a list of maps which you have in one of your collections, but do not have in your songs folder. You can show this list by pressing the `Tools` menu, followed by `Missing beatmaps`. This dialog looks different depending on what kinds of missing songs you have. 124 | 125 | ![Missing unidentified beatmaps](http://oce.kurocon.nl/images/missing_unidentified_maps.png) 126 | 127 | If you only have unidentified missing maps (maps OCE knows nothing about), then it will show a list of the md5 hashes of those maps. 128 | 129 | ![Missing identified beatmaps](http://oce.kurocon.nl/images/missing_identified_maps.png) 130 | 131 | If you only have identified missing maps (maps OCE found via the osu! API) then it will show you the map details, and will give you buttons to open the osu! beatmap page for the map or search for the map on bloodcat. 132 | 133 | ![Missing both types of beatmaps](http://oce.kurocon.nl/images/missing_maps.png) 134 | 135 | If you have both types of missing songs, the dialog will show both lists. 136 | 137 | ![No missing beatmaps](http://oce.kurocon.nl/images/no_missing_maps.png) 138 | 139 | If you have no missing maps, the dialog will show that, too. 140 | You can try to look up beatmaps using the osu! API when you load your collection, or do it manually via the `Tools`->`Match with osu! API` menu option. 141 | 142 | Common problems 143 | =============== 144 | **OCE is not starting.** 145 | 146 | The primary cause of OCE not starting is missing libraries or resource files. The best fix is to redownload and re-extract the application. Make sure that everything that is in the archive is in the same directory as `oce.exe` or `oce`. If that does not fix your issue, check the `oce.log` file in the application directory, that might give some clues. If the log file is empty, you can try to follow the basic [bug reporting](#reporting-bugs) guide to change your log level to debug mode. This shows more information in the log file. If you still cannot start the application, please let me know via a [bug report](#reporting-bugs). 147 | 148 | Contributing 149 | ============ 150 | I appreciate any contribution you want to make to OCE. You are awesome. I only ask that you try to follow the steps below if you want to help. 151 | 152 | Reporting bugs 153 | -------------- 154 | This software probably contains a bunch of bugs which I did not notice during programming. If the program crashes on you for some reason, or does not work as you expect it to, you can do the following. 155 | 156 | The program generates a logfile, called `oce.log` in the application directory. Whenever something strange happens, this is probably the file that's going to tell me what went wrong. By default though, the file does not contain much. If you can reproduce the strange behaviour by doing what you did to make it happen again, then set the log level of the application to debug as described below, then make the bug happen. Afterwards send me an email at `oce_bugs@kurocon.nl` with exactly how to make the bug happen, what you think should happen, and the log file as an attachment. If you cannot reproduce the bug, sending me the first log file might be able to help me as well. I'll try to respond to all bug reports, but it might take me a while. 157 | 158 | **Lowering the log level to 'DEBUG'** 159 | 160 | In the application directory, edit the file `logging.conf` with your favourite editor, like notepad. In the file, there are three lines which say either `level=INFO` or `level=DEBUG`. Change all those to say `level=DEBUG` and save the file to lower the log level. 161 | 162 | Requesting new features, sharing ideas, etc. 163 | -------------------------------------------- 164 | If you think you have a good idea for the application, I'm ready to hear it. Drop me an email on `oce_ideas@kurocon.nl` telling me about your idea in as much detail as you can. I will try to respond to all ideas, but it might take a while. 165 | 166 | Contributing code 167 | ----------------- 168 | If you want to implement a new feature for yourself, or fix a bug in the code, you are free to do so. You can fork the repository to your own GitHub account and edit whatever you like, then submit a pull request so I can take a look at what you've built. If I think it looks good, I'll merge it into the code and add you as a contributor to the application's about page. For more instructions on how to setup your development environment, see the developing readme, `development_guide.md`. 169 | 170 | Contributing art/translations/etc. 171 | ---------------------------------- 172 | Any art, translations, whatever, you want to share for the application are gladly accepted, you can drop me an email at `oce@kurocon.nl` with all your contributions or questions, and I'll try to respond to as much as I can. 173 | -------------------------------------------------------------------------------- /util/osudb_parser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from util.oce_models import Difficulty2, Songs, Song 3 | from util.osudb_format import read_type 4 | 5 | # osu!.db format 6 | # Data type Description 7 | # Int osu! version (e.g. 20150203) 8 | # Int Folder Count 9 | # Bool AccountUnlocked (only false when account is locked or banned in any way) 10 | # DateTime Date the account will be unlocked 11 | # String Player name 12 | # Int Number of beatmaps 13 | # Beatmaps* The beatmaps themselves 14 | 15 | # Beatmap Format 16 | # String Artist name 17 | # String Artist name, Unicode 18 | # String Song title 19 | # String Song title, Unicode 20 | # String Creator name 21 | # String Difficulty name 22 | # String Audio file name 23 | # String MD5 hash of map 24 | # String Name of .osu file for map 25 | # Byte Ranked status (4=ranked, 5=approved, 2=pending/graveyard) 26 | # Short Number of hitcircles 27 | # Short Number of sliders 28 | # Short Number of spinners 29 | # Long Last modification time in windows ticks 30 | # Byte/Single Approach rate (Byte if version is less than 20140609, Single otherwise) 31 | # Byte/Single Circle size (Byte if version is less than 20140609, Single otherwise) 32 | # Byte/Single HP drain (Byte if version is less than 20140609, Single otherwise) 33 | # Byte/Single Overall Difficulty (Byte if version is less than 20140609, Single otherwise) 34 | # Double Slider velocity 35 | # Int-Doublepair* An int indicating the number of following Int-Double pairs, then the pairs themselves. 36 | # Star rating info for osu!standard. The int is the mod combination, the Double is the star rating. 37 | # Only present if version greater or equal to 20140609. 38 | # Int-Doublepair* An int indicating the number of following Int-Double pairs, then the pairs themselves. 39 | # Star rating info for Taiko. The int is the mod combination, the Double is the star rating. 40 | # Only present if version greater or equal to 20140609. 41 | # Int-Doublepair* An int indicating the number of following Int-Double pairs, then the pairs themselves. 42 | # Star rating info for CTB. The int is the mod combination, the Double is the star rating. 43 | # Only present if version greater or equal to 20140609. 44 | # Int-Doublepair* An int indicating the number of following Int-Double pairs, then the pairs themselves. 45 | # Star rating info for osu!mania. The int is the mod combination, the Double is the star rating. 46 | # Only present if version greater or equal to 20140609. 47 | # Int Drain time in seconds 48 | # Int Total time in milliseconds 49 | # Int Time when audio preview starts in ms 50 | # Timingpoint+ An int indicating the number of Timingpoints, then the timingpoints. 51 | # Int Beatmap ID 52 | # Int Beatmap set ID 53 | # Int Thread ID 54 | # Byte Grade achieved in osu!Standard 55 | # Byte Grade achieved in Taiko 56 | # Byte Grade achieved in CTB 57 | # Byte Grade achieved in osu!Mania 58 | # Short Local beatmap offset 59 | # Single Stack leniency 60 | # Byte Osu gameplay mode. 0x00=standard, 0x01=Taiko, 0x02=CTB, 0x03=Mania 61 | # String Song source 62 | # String Song tags 63 | # Short Online offset 64 | # String Font used for the title of the song 65 | # Boolean Is the beatmap unplayed 66 | # Long Last time played 67 | # Boolean Is beatmap osz2 68 | # String Folder name of beatmap, relative to Songs folder 69 | # Long Last time when map was checked with osu! repo 70 | # Boolean Ignore beatmap sounds 71 | # Boolean Ignore beatmap skin 72 | # Boolean Disable storyboard 73 | # Boolean Disable video 74 | # Boolean Visual override 75 | # Short? Unknown. Only present when version less than 20140609 76 | # Int Unknown. Some last modification time or something 77 | # Byte Mania scroll speed 78 | 79 | 80 | def parse_beatmap(fobj, version): 81 | log = logging.getLogger(__name__) 82 | 83 | # If the database is of a newer version than 20150422, we need to read an int here 84 | if version > 20151026: 85 | some_int = read_type("Int", fobj) 86 | 87 | # First, the trivial data of the beatmap 88 | data = [] 89 | for type in ["String"]*9 + ["Byte"] + ["Short"]*3 + ["Long"]: 90 | data.append(read_type(type, fobj)) 91 | artist, artist_u, song, song_u, creator, difficulty, audio_file, md5, osu_file, ranked_status, num_hitcircles, num_sliders, num_spinners, last_modified = data 92 | 93 | log.log(5, "artist:{}, artist_u:{}, song:{}, song_u:{}, creator:{}, difficulty:{}, audio_file:{}, " 94 | "md5:{}, osu_file:{}, ranked_status:{}, num_hitcircles:{}, num_sliders:{}, num_spinners:{}, " 95 | "last_modified:{}".format(artist, artist_u, song, song_u, creator, difficulty, audio_file, 96 | md5, osu_file, ranked_status, num_hitcircles, num_sliders, num_spinners, 97 | last_modified)) 98 | 99 | # Then, the ar, cs, hp and od. If the version is less than 20140609, we need to read 4 bytes, else, 4 singles. 100 | data = [] 101 | 102 | if version < 20140609: 103 | types = ["Byte"]*4 104 | else: 105 | types = ["Single"]*4 106 | 107 | for type in types: 108 | data.append(read_type(type, fobj)) 109 | 110 | ar, cs, hp, od = data 111 | 112 | # Then, the slider velocity 113 | slider_velocity = read_type("Double", fobj) 114 | 115 | log.log(5, "ar:{}, cs:{}, hp:{}, od:{}, slider_velocity:{}".format(ar, cs, hp, od, slider_velocity)) 116 | 117 | # Then the star ratings. These are an int, followed by that many Int-Double pairs. 118 | data = [] 119 | for type in ["IntDoublepair"]*4: 120 | num_idp = read_type("Int", fobj) 121 | idps = [] 122 | for _ in range(num_idp): 123 | idps.append(read_type(type, fobj)) 124 | data.append(idps) 125 | stars_standard, stars_taiko, stars_ctb, stars_mania = data 126 | 127 | log.log(5, "stars_standard:{}, stars_taiko:{}, stars_ctb:{}, stars_mania:{}".format(stars_standard, stars_taiko, 128 | stars_ctb, stars_mania)) 129 | 130 | # Then, the drain time, total time and preview times 131 | drain_time = read_type("Int", fobj) 132 | total_time = read_type("Int", fobj) 133 | preview_time = read_type("Int", fobj) 134 | 135 | log.log(5, "draintime:{}, totaltime:{}, previewtime:{}".format(drain_time, total_time, preview_time)) 136 | 137 | # Then, the timing points. These are an int followed by that many Timingpoints. 138 | num_timingpoints = read_type("Int", fobj) 139 | log.debug("There are {} timingpoints.".format(num_timingpoints)) 140 | timingpoints = [] 141 | for _ in range(num_timingpoints): 142 | timingpoints.append(read_type("Timingpoint", fobj)) 143 | 144 | log.log(5, "timing_points: {}".format(timingpoints)) 145 | 146 | # Then some more trivial data 147 | data = [] 148 | for type in ["Int"]*3 + ["Byte"]*4 + ["Short", "Single", "Byte"] + ["String"]*2 + ["Short", "String", "Boolean", "Long", "Boolean", "String", "Long"] + ["Boolean"]*5: 149 | data.append(read_type(type, fobj)) 150 | beatmap_id, beatmap_set_id, thread_id, grade_standard, grade_taiko, grade_ctb, grade_mania, local_offset, stack_leniency, gameplay_mode, source, tags, online_offset, font, unplayed, last_played, is_osz2, beatmap_folder, last_checked, ignore_sounds, ignore_skin, disable_storyboard, disable_video, visual_override = data 151 | 152 | log.log(5, "beatmap_id:{}, beatmap_set_id:{}, thread_id:{}, grade_standard:{}, grade_taiko:{}, grade_ctb:{}, " 153 | "grade_mania:{}, local_offset:{}, stack_leniency:{}, gameplay_mode:{}, source:{}, tags:{}, " 154 | "online_offset:{}, font:{}, unplayed:{}, last_played:{}, is_osz2:{}, beatmap_folder:{}, " 155 | "last_checked:{}, ignore_sounds:{}, ignore_skin:{}, disable_storyboard:{}, disable_video:{}, " 156 | "visual_override:{}".format(beatmap_id, beatmap_set_id, thread_id, grade_standard, grade_taiko, grade_ctb, 157 | grade_mania, local_offset, stack_leniency, gameplay_mode, source, tags, 158 | online_offset, font, unplayed, last_played, is_osz2, beatmap_folder, 159 | last_checked, ignore_sounds, ignore_skin, disable_storyboard, disable_video, 160 | visual_override)) 161 | 162 | # Then a short which is only there if the version is less than 20140609 163 | if version < 20140609: 164 | some_short = read_type("Short", fobj) 165 | log.log(5, "some_short:{}".format(some_short)) 166 | 167 | # Then, lastly, some last modification time and the mania scroll speed 168 | unknown_int_modified = read_type("Int", fobj) 169 | mania_scroll_speed = read_type("Byte", fobj) 170 | 171 | log.log(5, "unknown_int_modified:{}, mania_scroll_speed:{}".format(unknown_int_modified, mania_scroll_speed)) 172 | 173 | # Now, extract only the parts we need from the beatmap and construct a nice Difficulty2 object. 174 | beatmap = Difficulty2("") 175 | beatmap.path = osu_file 176 | beatmap.name = song 177 | beatmap.artist = artist 178 | beatmap.mapper = creator 179 | beatmap.difficulty = difficulty 180 | beatmap.ar = ar 181 | beatmap.cs = cs 182 | beatmap.hp = hp 183 | beatmap.od = od 184 | beatmap.hash = md5 185 | beatmap.from_api = False 186 | beatmap.api_beatmap_id = beatmap_id 187 | beatmap.beatmap_id = beatmap_id 188 | beatmap.beatmapset_id = beatmap_set_id 189 | 190 | log.debug("Loaded {}: {} - {} [{}] by {}".format(beatmap.beatmap_id, beatmap.artist, 191 | beatmap.name, beatmap.difficulty, beatmap.mapper)) 192 | 193 | if None in [osu_file, song, artist, creator, difficulty, ar, cs, hp, od, md5, beatmap_id, beatmap_id, beatmap_set_id]: 194 | log.warn("One of the values found for this beatmap is not set. Probably something is going wrong in parsing!") 195 | 196 | return beatmap 197 | 198 | 199 | def load_osudb(path): 200 | log = logging.getLogger(__name__) 201 | log.debug("Opening file {}".format(path)) 202 | fobj = open("{}".format(path), 'rb') 203 | 204 | songs = Songs() 205 | 206 | # Try to parse the file as an osu db. 207 | # First we have some primitive simple types we can just read in one bunch 208 | data = [] 209 | for type in ["Int", "Int", "Boolean", "DateTime", "String", "Int"]: 210 | data.append(read_type(type, fobj)) 211 | 212 | version, num_folders, unlocked, unlock_time, player_name, num_maps = data 213 | 214 | log.debug("osu!DB version {}. {} maps".format(version, num_maps)) 215 | log.log(5, "num_folders: {}, unlocked: {}, unlock_time: {}, player_name: {}".format( 216 | num_folders, unlocked, unlock_time, player_name 217 | )) 218 | 219 | # Then, for each beatmap, we need to read the beatmap 220 | beatmaps = [] 221 | for _ in range(num_maps): 222 | beatmap = parse_beatmap(fobj, version) 223 | 224 | # Check if the beatmap was parsed correctly, abourt parsing if not 225 | if None in [beatmap.path, beatmap.name, beatmap.artist, beatmap.mapper, beatmap.difficulty, beatmap.ar, 226 | beatmap.cs, beatmap.hp, beatmap.od, beatmap.hash, beatmap.from_api, beatmap.api_beatmap_id, 227 | beatmap.beatmap_id, beatmap.beatmapset_id]: 228 | log.warn("Parse error detected. Aborting parsing.") 229 | return None 230 | 231 | beatmaps.append(beatmap) 232 | 233 | # Now, group the beatmaps by their mapset id, to group them into Songs for the songs list. 234 | mapsets = {} 235 | for map in beatmaps: 236 | if map.beatmapset_id in mapsets.keys(): 237 | mapsets[map.beatmapset_id].append(map) 238 | else: 239 | mapsets[map.beatmapset_id] = [map] 240 | 241 | # Create Songs from the mapsets. 242 | for mapset in mapsets.values(): 243 | s = Song() 244 | s.difficulties = mapset 245 | songs.add_song(s) 246 | 247 | return songs 248 | 249 | 250 | def load_osudb_gui(path, dialog): 251 | log = logging.getLogger(__name__) 252 | log.debug("Opening file {}".format(path)) 253 | fobj = open("{}".format(path), 'rb') 254 | 255 | songs = Songs() 256 | num_done = 0 257 | 258 | dialog.progress.emit(0) 259 | dialog.current.emit("Parsing metadata...") 260 | 261 | # Try to parse the file as an osu db. 262 | # First we have some primitive simple types we can just read in one bunch 263 | data = [] 264 | for type in ["Int", "Int", "Boolean", "DateTime", "String", "Int"]: 265 | data.append(read_type(type, fobj)) 266 | 267 | version, num_folders, unlocked, unlock_time, player_name, num_maps = data 268 | 269 | log.debug("osu!DB version {}. {} maps".format(version, num_maps)) 270 | log.log(5, "num_folders: {}, unlocked: {}, unlock_time: {}, player_name: {}".format( 271 | num_folders, unlocked, unlock_time, player_name 272 | )) 273 | 274 | # Then, for each beatmap, we need to read the beatmap 275 | beatmaps = [] 276 | dialog.current.emit("Parsing beatmaps...") 277 | progress = 0 278 | for _ in range(num_maps): 279 | 280 | # Only update the progress bar if there is at least one percent more progress 281 | if progress < int((num_done / num_maps) * 100): 282 | progress = int((num_done / num_maps) * 100) 283 | dialog.progress.emit(progress) 284 | 285 | beatmap = parse_beatmap(fobj, version) 286 | 287 | # Check if the beatmap was parsed correctly, abourt parsing if not 288 | if None in [beatmap.path, beatmap.name, beatmap.artist, beatmap.mapper, beatmap.difficulty, beatmap.ar, 289 | beatmap.cs, beatmap.hp, beatmap.od, beatmap.hash, beatmap.from_api, beatmap.api_beatmap_id, 290 | beatmap.beatmap_id, beatmap.beatmapset_id]: 291 | log.warn("Parse error detected. Aborting parsing.") 292 | return None 293 | 294 | num_done += 1 295 | beatmaps.append(beatmap) 296 | 297 | dialog.current.emit("Loading mapsets into OCE...") 298 | 299 | # Now, group the beatmaps by their mapset id, to group them into Songs for the songs list. 300 | mapsets = {} 301 | for map in beatmaps: 302 | if map.beatmapset_id in mapsets.keys(): 303 | mapsets[map.beatmapset_id].append(map) 304 | else: 305 | mapsets[map.beatmapset_id] = [map] 306 | 307 | # Create Songs from the mapsets. 308 | for mapset in mapsets.values(): 309 | s = Song() 310 | s.difficulties = mapset 311 | songs.add_song(s) 312 | 313 | return songs 314 | --------------------------------------------------------------------------------