mik-automator 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mik_automator-1.1.0/LICENSE +21 -0
- mik_automator-1.1.0/PKG-INFO +77 -0
- mik_automator-1.1.0/README.md +49 -0
- mik_automator-1.1.0/pyproject.toml +48 -0
- mik_automator-1.1.0/setup.cfg +4 -0
- mik_automator-1.1.0/src/mik_automator/__init__.py +0 -0
- mik_automator-1.1.0/src/mik_automator/mik_automator.py +170 -0
- mik_automator-1.1.0/src/mik_automator/mik_db.py +98 -0
- mik_automator-1.1.0/src/mik_automator.egg-info/PKG-INFO +77 -0
- mik_automator-1.1.0/src/mik_automator.egg-info/SOURCES.txt +11 -0
- mik_automator-1.1.0/src/mik_automator.egg-info/dependency_links.txt +1 -0
- mik_automator-1.1.0/src/mik_automator.egg-info/requires.txt +8 -0
- mik_automator-1.1.0/src/mik_automator.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 bvandrc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mik-automator
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Windows automator and database tools for Mixed In Key 11
|
|
5
|
+
Author-email: bvandrc <bvandercar@outlook.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/bvandrc/mik-automator
|
|
8
|
+
Project-URL: Issues, https://github.com/bvandrc/mik-automator/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/bvandrc/mik-automator/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: mixed in key,mixedinkey,mik,dj,key detection,harmonic mixing,automation
|
|
11
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pywinauto
|
|
21
|
+
Requires-Dist: pywin32
|
|
22
|
+
Requires-Dist: comtypes
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: black; extra == "dev"
|
|
25
|
+
Requires-Dist: pylint; extra == "dev"
|
|
26
|
+
Requires-Dist: pyright; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
Windows automator and database tools for Mixed In Key 11.
|
|
30
|
+
|
|
31
|
+
# Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install mik-automator
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Windows only, Python 3.10+.
|
|
38
|
+
|
|
39
|
+
If you installed it from GitHub as `mixed-in-key-automator`, uninstall that first (`pip uninstall mixed-in-key-automator`): both install the `mik_automator` package, so keeping both leaves them overwriting each other's files.
|
|
40
|
+
|
|
41
|
+
# Usage
|
|
42
|
+
|
|
43
|
+
## `mik_automator`
|
|
44
|
+
|
|
45
|
+
Opens Mixed In Key (or connects to it if it's already open), adds a folder, waits for the analysis to finish, then closes it.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from mik_automator import mik_automator
|
|
49
|
+
|
|
50
|
+
mik_automator.run(r"C:\Users\me\Music\DJ Tracks")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It knows the analysis is done once Mixed In Key's database stops changing and the window closes without asking to confirm.
|
|
54
|
+
|
|
55
|
+
## `mik_db`
|
|
56
|
+
|
|
57
|
+
Reads and edits Mixed In Key's database (`MIKStore.db`) directly.
|
|
58
|
+
|
|
59
|
+
- `rename_file(src, dest, names=None)`: points a song at its renamed file, so it keeps its analysis.
|
|
60
|
+
- `remove_files(filepaths)` and `remove_missing_files()`: remove songs, so they're analyzed again when added, or because their file is gone.
|
|
61
|
+
- `get_song_files()` and `get_files_missing_from_db(dir)`: read-only, safe while Mixed In Key is running.
|
|
62
|
+
|
|
63
|
+
Run `python -m mik_automator.mik_db` to remove the songs whose files no longer exist.
|
|
64
|
+
|
|
65
|
+
# Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python -m venv .venv
|
|
69
|
+
source .venv/Scripts/activate
|
|
70
|
+
pip install -e ".[dev]"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Lint with `pylint $(git ls-files '*.py')` and `pyright`, and format with `black .`. CI runs all three on pull requests.
|
|
74
|
+
|
|
75
|
+
# License
|
|
76
|
+
|
|
77
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Windows automator and database tools for Mixed In Key 11.
|
|
2
|
+
|
|
3
|
+
# Installation
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install mik-automator
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Windows only, Python 3.10+.
|
|
10
|
+
|
|
11
|
+
If you installed it from GitHub as `mixed-in-key-automator`, uninstall that first (`pip uninstall mixed-in-key-automator`): both install the `mik_automator` package, so keeping both leaves them overwriting each other's files.
|
|
12
|
+
|
|
13
|
+
# Usage
|
|
14
|
+
|
|
15
|
+
## `mik_automator`
|
|
16
|
+
|
|
17
|
+
Opens Mixed In Key (or connects to it if it's already open), adds a folder, waits for the analysis to finish, then closes it.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from mik_automator import mik_automator
|
|
21
|
+
|
|
22
|
+
mik_automator.run(r"C:\Users\me\Music\DJ Tracks")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It knows the analysis is done once Mixed In Key's database stops changing and the window closes without asking to confirm.
|
|
26
|
+
|
|
27
|
+
## `mik_db`
|
|
28
|
+
|
|
29
|
+
Reads and edits Mixed In Key's database (`MIKStore.db`) directly.
|
|
30
|
+
|
|
31
|
+
- `rename_file(src, dest, names=None)`: points a song at its renamed file, so it keeps its analysis.
|
|
32
|
+
- `remove_files(filepaths)` and `remove_missing_files()`: remove songs, so they're analyzed again when added, or because their file is gone.
|
|
33
|
+
- `get_song_files()` and `get_files_missing_from_db(dir)`: read-only, safe while Mixed In Key is running.
|
|
34
|
+
|
|
35
|
+
Run `python -m mik_automator.mik_db` to remove the songs whose files no longer exist.
|
|
36
|
+
|
|
37
|
+
# Development
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python -m venv .venv
|
|
41
|
+
source .venv/Scripts/activate
|
|
42
|
+
pip install -e ".[dev]"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Lint with `pylint $(git ls-files '*.py')` and `pyright`, and format with `black .`. CI runs all three on pull requests.
|
|
46
|
+
|
|
47
|
+
# License
|
|
48
|
+
|
|
49
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mik-automator"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Windows automator and database tools for Mixed In Key 11"
|
|
9
|
+
requires-python = ">= 3.10"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "bvandrc", email = "bvandercar@outlook.com" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["mixed in key", "mixedinkey", "mik", "dj", "key detection", "harmonic mixing", "automation"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Environment :: Win32 (MS Windows)",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"Operating System :: Microsoft :: Windows",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
24
|
+
]
|
|
25
|
+
# Windows only: pywinauto drives the app through UI Automation, and pywin32 sends it window messages
|
|
26
|
+
dependencies = ["pywinauto", "pywin32", "comtypes"]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["black", "pylint", "pyright"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/bvandrc/mik-automator"
|
|
33
|
+
Issues = "https://github.com/bvandrc/mik-automator/issues"
|
|
34
|
+
Changelog = "https://github.com/bvandrc/mik-automator/blob/main/CHANGELOG.md"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
include = ["mik_automator*"]
|
|
39
|
+
|
|
40
|
+
[tool.black]
|
|
41
|
+
line-length = 120
|
|
42
|
+
target-version = ["py310"]
|
|
43
|
+
|
|
44
|
+
[tool.pyright]
|
|
45
|
+
# Pyright doesn't read .gitignore, so a stale build/ left behind by `pip install .`
|
|
46
|
+
# gets type-checked as a second copy of the source. Setting exclude replaces
|
|
47
|
+
# Pyright's defaults, hence __pycache__ and dotted dirs are restated here.
|
|
48
|
+
exclude = ["build", "dist", "**/__pycache__", "**/.*"]
|
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import ctypes
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
import win32con
|
|
10
|
+
import win32gui
|
|
11
|
+
from comtypes import COMError
|
|
12
|
+
from pywinauto.application import Application, WindowSpecification
|
|
13
|
+
from pywinauto.base_wrapper import BaseWrapper
|
|
14
|
+
from pywinauto.findwindows import find_windows
|
|
15
|
+
from pywinauto.remote_memory_block import RemoteMemoryBlock
|
|
16
|
+
from pywinauto.timings import TimeoutError as WaitTimeoutError, wait_until, wait_until_passes
|
|
17
|
+
|
|
18
|
+
from mik_automator import mik_db
|
|
19
|
+
|
|
20
|
+
MIK_TITLE_RE = "Mixed In Key.*"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_exe_file():
|
|
24
|
+
root_dir = os.getenv("LOCALAPPDATA")
|
|
25
|
+
assert root_dir, "local appdata"
|
|
26
|
+
exe_files = glob.glob(
|
|
27
|
+
"Programs/Mixed In Key/Mixed In Key*/MixedInKey.exe",
|
|
28
|
+
root_dir=root_dir,
|
|
29
|
+
recursive=True,
|
|
30
|
+
)
|
|
31
|
+
assert len(exe_files) != 0, "mik .exe not found"
|
|
32
|
+
assert len(exe_files) == 1, "more than 1 mik .exe found"
|
|
33
|
+
return os.path.join(root_dir, exe_files[0])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Create window (or connect to existing)
|
|
37
|
+
def _get_mik_window(create_new_if_not_open: bool):
|
|
38
|
+
app = Application(backend="uia")
|
|
39
|
+
|
|
40
|
+
for i in range(3):
|
|
41
|
+
try:
|
|
42
|
+
try:
|
|
43
|
+
app.connect(title_re=MIK_TITLE_RE)
|
|
44
|
+
except: # pylint: disable=bare-except
|
|
45
|
+
if create_new_if_not_open:
|
|
46
|
+
app.start(_get_exe_file())
|
|
47
|
+
|
|
48
|
+
window = app.top_window()
|
|
49
|
+
window.wait("ready", timeout=20)
|
|
50
|
+
window.wait("visible", timeout=20)
|
|
51
|
+
return window, app
|
|
52
|
+
except: # pylint: disable=bare-except
|
|
53
|
+
if i == 2:
|
|
54
|
+
if create_new_if_not_open: # otherwise, not finding it just means that it closed
|
|
55
|
+
logging.error("Could not open MIK")
|
|
56
|
+
raise
|
|
57
|
+
time.sleep(3)
|
|
58
|
+
raise Exception("Could not open MIK")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_mik_open():
|
|
62
|
+
return len(find_windows(title_re=MIK_TITLE_RE)) > 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _select_folder(dialog: BaseWrapper, dir: str):
|
|
66
|
+
"""Selects a folder in a Browse For Folder dialog, which has no box to type a path in. The message's path is read
|
|
67
|
+
by the dialog's process, so it has to be written into that process's memory."""
|
|
68
|
+
path = ctypes.create_unicode_buffer(dir)
|
|
69
|
+
memory = RemoteMemoryBlock(dialog, ctypes.sizeof(path))
|
|
70
|
+
try:
|
|
71
|
+
memory.Write(path)
|
|
72
|
+
# 0x0467 is BFFM_SETSELECTIONW, the Windows message that selects a folder. True says it's given a path string.
|
|
73
|
+
win32gui.SendMessage(dialog.handle, 0x0467, True, memory.Address())
|
|
74
|
+
finally:
|
|
75
|
+
memory.CleanUp()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _add_folder(window: WindowSpecification, dir: str):
|
|
79
|
+
# Add Files button has no identifier, so open its dialog with the keyboard shortcut.
|
|
80
|
+
window.set_focus()
|
|
81
|
+
window.type_keys("^o")
|
|
82
|
+
|
|
83
|
+
# Add Folder button in dialog
|
|
84
|
+
add_folder_btn = window.child_window(best_match="ADD FOLDERButton")
|
|
85
|
+
add_folder_btn.click()
|
|
86
|
+
|
|
87
|
+
folder_browser_popup = window.child_window(best_match="Browse For Folder")
|
|
88
|
+
folder_browser_popup.wait("visible", timeout=20)
|
|
89
|
+
dialog = folder_browser_popup.wrapper_object()
|
|
90
|
+
_select_folder(dialog, os.path.abspath(dir))
|
|
91
|
+
time.sleep(1)
|
|
92
|
+
|
|
93
|
+
# OK after selected folder.
|
|
94
|
+
win32gui.PostMessage(dialog.handle, win32con.WM_COMMAND, win32con.IDOK, 0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _retry_on_com_error(wait: Callable[[], object]):
|
|
98
|
+
"""Runs a wait on the window, again if it fails with a COMError, which reading a control as it closes can raise."""
|
|
99
|
+
wait_until_passes(60 * 5, 1, wait, COMError)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _wait_for_files_added(window: WindowSpecification):
|
|
103
|
+
adding_files = window.child_window(best_match="Adding files to queue")
|
|
104
|
+
try:
|
|
105
|
+
_retry_on_com_error(lambda: adding_files.wait("visible", timeout=30))
|
|
106
|
+
except WaitTimeoutError:
|
|
107
|
+
pass # a quick add's popup can be gone before we look
|
|
108
|
+
_retry_on_com_error(lambda: adding_files.wait_not("visible", timeout=60 * 5))
|
|
109
|
+
_retry_on_com_error(lambda: window.child_window(control_type="ProgressBar").wait_not("visible", timeout=60 * 5))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _get_db_state():
|
|
113
|
+
stat = os.stat(mik_db.db_file)
|
|
114
|
+
return stat.st_size, stat.st_mtime_ns
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _wait_for_db_to_go_quiet(quiet_seconds: int):
|
|
118
|
+
"""Waits until the database stops changing. It's written to as each track finishes, so changes mean it's analyzing."""
|
|
119
|
+
state = _get_db_state()
|
|
120
|
+
last_change_time = time.time()
|
|
121
|
+
while time.time() - last_change_time < quiet_seconds:
|
|
122
|
+
time.sleep(1)
|
|
123
|
+
new_state = _get_db_state()
|
|
124
|
+
if new_state != state:
|
|
125
|
+
state = new_state
|
|
126
|
+
last_change_time = time.time()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run(dir: str):
|
|
130
|
+
window, app = _get_mik_window(create_new_if_not_open=True)
|
|
131
|
+
# window.print_control_identifiers()
|
|
132
|
+
|
|
133
|
+
# Maximize window to make coordinate clicking consistent.
|
|
134
|
+
try:
|
|
135
|
+
maximize_btn = window.child_window(auto_id="MaximizeButton")
|
|
136
|
+
maximize_btn.click()
|
|
137
|
+
except: # pylint: disable=bare-except
|
|
138
|
+
pass # already maximized
|
|
139
|
+
|
|
140
|
+
window.child_window(control_type="ProgressBar").wait_not("visible", timeout=60 * 5)
|
|
141
|
+
|
|
142
|
+
_add_folder(window, dir)
|
|
143
|
+
_wait_for_files_added(window)
|
|
144
|
+
|
|
145
|
+
# Attempt to close once the database is quiet. If window closes, analysis done. If confirmation popup appears, a long
|
|
146
|
+
# analysis is still going, so wait for quiet again.
|
|
147
|
+
while True:
|
|
148
|
+
_wait_for_db_to_go_quiet(quiet_seconds=20)
|
|
149
|
+
close_button = window.child_window(auto_id="CloseButton")
|
|
150
|
+
close_button.click()
|
|
151
|
+
try:
|
|
152
|
+
# Window item gets all weird here, sometimes can't find the NO button. Re-connect to the window.
|
|
153
|
+
del app
|
|
154
|
+
window, app = _get_mik_window(create_new_if_not_open=False)
|
|
155
|
+
no_button = window.child_window(best_match="NOButton")
|
|
156
|
+
no_button.wait("visible", timeout=5)
|
|
157
|
+
except KeyboardInterrupt:
|
|
158
|
+
input("press any key to continue checking mik")
|
|
159
|
+
break
|
|
160
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
161
|
+
# No confirmation popup, so it should be closing. Fails if it stays open, rather than leaving it analyzing.
|
|
162
|
+
wait_until(30, 1, _is_mik_open, False)
|
|
163
|
+
break
|
|
164
|
+
no_button.click()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
dir = os.path.join(Path.home(), "Music\\DJ Tracks")
|
|
169
|
+
print("adding dir: ", dir)
|
|
170
|
+
run(dir)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import glob
|
|
3
|
+
import hashlib
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import closing, contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
|
|
9
|
+
appdata_dir = os.getenv("LOCALAPPDATA")
|
|
10
|
+
assert appdata_dir, "local appdata"
|
|
11
|
+
db_file = os.path.join(appdata_dir, "Mixed In Key", "Mixed In Key", "11.0", "MIKStore.db")
|
|
12
|
+
|
|
13
|
+
AUDIO_EXTENSIONS = (".mp3", ".wav", ".flac", ".aif", ".aiff", ".m4a")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ArtistTitle(TypedDict):
|
|
17
|
+
artist: str
|
|
18
|
+
title: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def connect():
|
|
23
|
+
"""Opens the MIK database, committing on success and always closing."""
|
|
24
|
+
connection = sqlite3.connect(db_file)
|
|
25
|
+
try:
|
|
26
|
+
with connection:
|
|
27
|
+
yield connection
|
|
28
|
+
finally:
|
|
29
|
+
connection.close()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_song_files() -> set[str]:
|
|
33
|
+
"""Returns every song's path, lowercased. Opens read-only, so it's safe while Mixed In Key is running."""
|
|
34
|
+
with closing(sqlite3.connect(f"{Path(db_file).as_uri()}?mode=ro", uri=True)) as connection:
|
|
35
|
+
return {row[0].lower() for row in connection.execute("SELECT File FROM Song")}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_files_missing_from_db(dir: str) -> list[str]:
|
|
39
|
+
song_files = get_song_files()
|
|
40
|
+
files = (f for f in glob.iglob(os.path.join(dir, "**", "*"), recursive=True) if os.path.isfile(f))
|
|
41
|
+
return [f for f in files if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS and f.lower() not in song_files]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_file_path_hash(filepath: str, disk_serial: str) -> str:
|
|
45
|
+
"""Same as Mixed In Key's: paths are tied to the disk, so they still match if the drive letter changes."""
|
|
46
|
+
id_string = f"{disk_serial}:{filepath.split(':', 1)[1]}" if disk_serial.strip() else filepath
|
|
47
|
+
return hashlib.md5(id_string.lower().encode("utf-8")).hexdigest().upper()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def rename_file(src: str, dest: str, names: ArtistTitle | None = None) -> bool:
|
|
51
|
+
"""Points a song at its renamed file, so it keeps its analysis. Returns whether the song was found.
|
|
52
|
+
|
|
53
|
+
Pass names when known, since filenames lose characters ("RÜFÜS DU SOL" is "RUFUS DU SOL"). Otherwise they come
|
|
54
|
+
from the filename, which must be "Artist - Title", as the file's tags may not be updated yet."""
|
|
55
|
+
if names is None:
|
|
56
|
+
filename = os.path.splitext(os.path.basename(dest))[0]
|
|
57
|
+
try:
|
|
58
|
+
artist, title = filename.split(" - ", 1)
|
|
59
|
+
except ValueError as e:
|
|
60
|
+
raise ValueError(f"couldn't parse {filename} as Artist - Title") from e
|
|
61
|
+
names = {"artist": artist, "title": title}
|
|
62
|
+
|
|
63
|
+
with connect() as connection:
|
|
64
|
+
row = connection.execute("SELECT DiskSerialNumber FROM Song WHERE File = ?", (src,)).fetchone()
|
|
65
|
+
if row is None:
|
|
66
|
+
return False
|
|
67
|
+
# src's file replaced whatever was at dest, so that song's row would be a duplicate
|
|
68
|
+
if src != dest:
|
|
69
|
+
connection.execute("DELETE FROM Song WHERE File = ?", (dest,))
|
|
70
|
+
connection.execute(
|
|
71
|
+
"UPDATE Song SET File = ?, FilePathHash = ?, ArtistName = ?, SongName = ? WHERE File = ?",
|
|
72
|
+
(dest, get_file_path_hash(dest, row[0]), names["artist"], names["title"], src),
|
|
73
|
+
)
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def remove_files(filepaths: list[str]):
|
|
78
|
+
if len(filepaths) == 0:
|
|
79
|
+
return
|
|
80
|
+
with connect() as connection:
|
|
81
|
+
connection.executemany("DELETE FROM Song WHERE File = ?", [(filepath,) for filepath in filepaths])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def remove_missing_files() -> list[str]:
|
|
85
|
+
"""Removes songs whose file no longer exists. Skips files on drives that aren't connected. Returns the removed paths."""
|
|
86
|
+
with connect() as connection:
|
|
87
|
+
filepaths = [row[0] for row in connection.execute("SELECT File FROM Song")]
|
|
88
|
+
|
|
89
|
+
missing = [f for f in filepaths if os.path.exists(os.path.splitdrive(f)[0] + os.sep) and not os.path.exists(f)]
|
|
90
|
+
remove_files(missing)
|
|
91
|
+
return missing
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
removed = remove_missing_files()
|
|
96
|
+
for filepath in removed:
|
|
97
|
+
print(filepath)
|
|
98
|
+
print(f"Removed {len(removed)} missing files")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mik-automator
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Windows automator and database tools for Mixed In Key 11
|
|
5
|
+
Author-email: bvandrc <bvandercar@outlook.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/bvandrc/mik-automator
|
|
8
|
+
Project-URL: Issues, https://github.com/bvandrc/mik-automator/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/bvandrc/mik-automator/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: mixed in key,mixedinkey,mik,dj,key detection,harmonic mixing,automation
|
|
11
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pywinauto
|
|
21
|
+
Requires-Dist: pywin32
|
|
22
|
+
Requires-Dist: comtypes
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: black; extra == "dev"
|
|
25
|
+
Requires-Dist: pylint; extra == "dev"
|
|
26
|
+
Requires-Dist: pyright; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
Windows automator and database tools for Mixed In Key 11.
|
|
30
|
+
|
|
31
|
+
# Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install mik-automator
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Windows only, Python 3.10+.
|
|
38
|
+
|
|
39
|
+
If you installed it from GitHub as `mixed-in-key-automator`, uninstall that first (`pip uninstall mixed-in-key-automator`): both install the `mik_automator` package, so keeping both leaves them overwriting each other's files.
|
|
40
|
+
|
|
41
|
+
# Usage
|
|
42
|
+
|
|
43
|
+
## `mik_automator`
|
|
44
|
+
|
|
45
|
+
Opens Mixed In Key (or connects to it if it's already open), adds a folder, waits for the analysis to finish, then closes it.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from mik_automator import mik_automator
|
|
49
|
+
|
|
50
|
+
mik_automator.run(r"C:\Users\me\Music\DJ Tracks")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It knows the analysis is done once Mixed In Key's database stops changing and the window closes without asking to confirm.
|
|
54
|
+
|
|
55
|
+
## `mik_db`
|
|
56
|
+
|
|
57
|
+
Reads and edits Mixed In Key's database (`MIKStore.db`) directly.
|
|
58
|
+
|
|
59
|
+
- `rename_file(src, dest, names=None)`: points a song at its renamed file, so it keeps its analysis.
|
|
60
|
+
- `remove_files(filepaths)` and `remove_missing_files()`: remove songs, so they're analyzed again when added, or because their file is gone.
|
|
61
|
+
- `get_song_files()` and `get_files_missing_from_db(dir)`: read-only, safe while Mixed In Key is running.
|
|
62
|
+
|
|
63
|
+
Run `python -m mik_automator.mik_db` to remove the songs whose files no longer exist.
|
|
64
|
+
|
|
65
|
+
# Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python -m venv .venv
|
|
69
|
+
source .venv/Scripts/activate
|
|
70
|
+
pip install -e ".[dev]"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Lint with `pylint $(git ls-files '*.py')` and `pyright`, and format with `black .`. CI runs all three on pull requests.
|
|
74
|
+
|
|
75
|
+
# License
|
|
76
|
+
|
|
77
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/mik_automator/__init__.py
|
|
5
|
+
src/mik_automator/mik_automator.py
|
|
6
|
+
src/mik_automator/mik_db.py
|
|
7
|
+
src/mik_automator.egg-info/PKG-INFO
|
|
8
|
+
src/mik_automator.egg-info/SOURCES.txt
|
|
9
|
+
src/mik_automator.egg-info/dependency_links.txt
|
|
10
|
+
src/mik_automator.egg-info/requires.txt
|
|
11
|
+
src/mik_automator.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mik_automator
|