hookjump 0.1.0__py3-none-any.whl
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.
- hookjump/__init__.py +12 -0
- hookjump/__main__.py +7 -0
- hookjump/adapters/__init__.py +5 -0
- hookjump/adapters/carbon_hotkeys.py +113 -0
- hookjump/adapters/keyboard.py +65 -0
- hookjump/adapters/spotify.py +167 -0
- hookjump/adapters/storage.py +276 -0
- hookjump/adapters/turntable.py +43 -0
- hookjump/app_bundle.py +149 -0
- hookjump/booth.py +119 -0
- hookjump/cli.py +89 -0
- hookjump/core/__init__.py +7 -0
- hookjump/core/heatmap.py +55 -0
- hookjump/core/lrc.py +61 -0
- hookjump/core/models.py +121 -0
- hookjump/core/normalize.py +49 -0
- hookjump/core/seek.py +71 -0
- hookjump/core/target.py +46 -0
- hookjump/door.py +96 -0
- hookjump/face.py +89 -0
- hookjump/icon.py +89 -0
- hookjump/launcher/launcher.c +93 -0
- hookjump/menubar.py +496 -0
- hookjump/playback.py +80 -0
- hookjump/recorder.py +130 -0
- hookjump/shortcut.py +138 -0
- hookjump/sleeve.py +67 -0
- hookjump/sleeve_view.py +170 -0
- hookjump/words.py +55 -0
- hookjump-0.1.0.dist-info/METADATA +193 -0
- hookjump-0.1.0.dist-info/RECORD +34 -0
- hookjump-0.1.0.dist-info/WHEEL +4 -0
- hookjump-0.1.0.dist-info/entry_points.txt +2 -0
- hookjump-0.1.0.dist-info/licenses/LICENSE +202 -0
hookjump/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""hookjump: jump to the best part of the song that is playing.
|
|
2
|
+
|
|
3
|
+
The best part comes from repeated lines in time-synced lyrics (the chorus) and,
|
|
4
|
+
over time, from the listener's own rewinds and replays, recorded locally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("hookjump")
|
|
11
|
+
except PackageNotFoundError: # running from a source tree without installing
|
|
12
|
+
__version__ = "0+unknown"
|
hookjump/__main__.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Door 1: global hotkeys through Carbon's RegisterEventHotKey, via ctypes.
|
|
2
|
+
|
|
3
|
+
Every shortcut manager on macOS uses this call. It needs no permission (an
|
|
4
|
+
event monitor would need Accessibility), and it takes the key: the app in
|
|
5
|
+
front does not also get it. Carbon is old, so this is the only part that
|
|
6
|
+
depends on it, and door 2 (the ``hookjump jump`` command) works without it.
|
|
7
|
+
|
|
8
|
+
The callbacks run on the main thread, while the app's run loop is running.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import ctypes.util
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from hookjump.shortcut import Shortcut
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _code(text: str) -> int:
|
|
19
|
+
return int.from_bytes(text.encode("latin-1"), "big")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _HotKeyID(ctypes.Structure):
|
|
23
|
+
_fields_ = [("signature", ctypes.c_uint32), ("id", ctypes.c_uint32)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _EventTypeSpec(ctypes.Structure):
|
|
27
|
+
_fields_ = [("eventClass", ctypes.c_uint32), ("eventKind", ctypes.c_uint32)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_HANDLER = ctypes.CFUNCTYPE(
|
|
31
|
+
ctypes.c_int32, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p
|
|
32
|
+
)
|
|
33
|
+
_SIGNATURE = _code("hkjp")
|
|
34
|
+
_HOT_KEY_PRESSED = 5 # kEventHotKeyPressed
|
|
35
|
+
_NOT_HANDLED = -9874 # eventNotHandledErr
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HotkeyError(RuntimeError):
|
|
39
|
+
"""macOS refused a shortcut (15.0 and 15.1 refuse ones with ⌥ alone).
|
|
40
|
+
|
|
41
|
+
A shortcut another app has registered is not refused: macOS lets two apps
|
|
42
|
+
register the same one, so such a clash cannot be found out here.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CarbonHotkeys:
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library("Carbon"))
|
|
49
|
+
c = self._carbon
|
|
50
|
+
c.GetApplicationEventTarget.restype = ctypes.c_void_p
|
|
51
|
+
c.InstallEventHandler.argtypes = [
|
|
52
|
+
ctypes.c_void_p, _HANDLER, ctypes.c_ulong,
|
|
53
|
+
ctypes.POINTER(_EventTypeSpec), ctypes.c_void_p,
|
|
54
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
55
|
+
] # fmt: skip
|
|
56
|
+
c.RegisterEventHotKey.argtypes = [
|
|
57
|
+
ctypes.c_uint32, ctypes.c_uint32, _HotKeyID, ctypes.c_void_p,
|
|
58
|
+
ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p),
|
|
59
|
+
] # fmt: skip
|
|
60
|
+
c.UnregisterEventHotKey.argtypes = [ctypes.c_void_p]
|
|
61
|
+
c.GetEventParameter.argtypes = [
|
|
62
|
+
ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p,
|
|
63
|
+
ctypes.c_ulong, ctypes.c_void_p, ctypes.c_void_p,
|
|
64
|
+
] # fmt: skip
|
|
65
|
+
|
|
66
|
+
self._actions: dict[int, Callable[[], None]] = {}
|
|
67
|
+
self._refs: dict[int, ctypes.c_void_p] = {}
|
|
68
|
+
# Keep the C callback alive as long as this object: if Python freed it,
|
|
69
|
+
# the next key press would call freed memory and crash the app.
|
|
70
|
+
self._callback = _HANDLER(self._on_event)
|
|
71
|
+
spec = _EventTypeSpec(_code("keyb"), _HOT_KEY_PRESSED)
|
|
72
|
+
status = c.InstallEventHandler(
|
|
73
|
+
c.GetApplicationEventTarget(), self._callback, 1, ctypes.byref(spec),
|
|
74
|
+
None, ctypes.byref(ctypes.c_void_p()),
|
|
75
|
+
) # fmt: skip
|
|
76
|
+
if status != 0:
|
|
77
|
+
raise HotkeyError(f"could not listen for hotkeys (error {status})")
|
|
78
|
+
|
|
79
|
+
def bind(self, shortcut: Shortcut, action: Callable[[], None]) -> None:
|
|
80
|
+
number = len(self._actions) + 1
|
|
81
|
+
ref = ctypes.c_void_p()
|
|
82
|
+
status = self._carbon.RegisterEventHotKey(
|
|
83
|
+
shortcut.key_code, shortcut.modifiers, _HotKeyID(_SIGNATURE, number),
|
|
84
|
+
self._carbon.GetApplicationEventTarget(), 0, ctypes.byref(ref),
|
|
85
|
+
) # fmt: skip
|
|
86
|
+
if status != 0:
|
|
87
|
+
raise HotkeyError(f"{shortcut} could not be registered (error {status})")
|
|
88
|
+
self._actions[number] = action
|
|
89
|
+
self._refs[number] = ref
|
|
90
|
+
|
|
91
|
+
def unbind_all(self) -> None:
|
|
92
|
+
for ref in self._refs.values():
|
|
93
|
+
self._carbon.UnregisterEventHotKey(ref)
|
|
94
|
+
self._refs.clear()
|
|
95
|
+
self._actions.clear()
|
|
96
|
+
|
|
97
|
+
def _on_event(self, _call: object, event: object, _data: object) -> int:
|
|
98
|
+
# Never let an exception cross into C: report it and carry on.
|
|
99
|
+
try:
|
|
100
|
+
hot_key = _HotKeyID()
|
|
101
|
+
self._carbon.GetEventParameter(
|
|
102
|
+
event, _code("----"), _code("hkid"), None,
|
|
103
|
+
ctypes.sizeof(hot_key), None, ctypes.byref(hot_key),
|
|
104
|
+
) # fmt: skip
|
|
105
|
+
action = self._actions.get(hot_key.id)
|
|
106
|
+
if hot_key.signature != _SIGNATURE or action is None:
|
|
107
|
+
return _NOT_HANDLED
|
|
108
|
+
action()
|
|
109
|
+
except Exception: # noqa: BLE001
|
|
110
|
+
import traceback
|
|
111
|
+
|
|
112
|
+
traceback.print_exc()
|
|
113
|
+
return 0
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""What a key combination types on the listener's keyboard layout right now.
|
|
2
|
+
|
|
3
|
+
A shortcut recorder needs it: on a Turkish Q keyboard ⌥Q types "@", and taking
|
|
4
|
+
⌥Q as a hotkey would leave the listener unable to type "@". Asked of macOS
|
|
5
|
+
(UCKeyTranslate), so it is right for any layout, not a table we keep.
|
|
6
|
+
|
|
7
|
+
Known limit: a letter typed with ⌥ counts as rare, like ⌥F's "ƒ". On layouts
|
|
8
|
+
that type their own letters with ⌥ (Polish Pro: ⌥A is "ą"), such a shortcut
|
|
9
|
+
would still be accepted and take that letter away.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import ctypes
|
|
13
|
+
import ctypes.util
|
|
14
|
+
|
|
15
|
+
from hookjump.shortcut import Shortcut
|
|
16
|
+
|
|
17
|
+
_SHIFT_OPTION = 0x0200 | 0x0800 # the only modifiers that change typed text
|
|
18
|
+
_COMMAND_CONTROL = 0x0100 | 0x1000
|
|
19
|
+
_KEY_DOWN = 0 # kUCKeyActionDown
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def typed_by(shortcut: Shortcut) -> tuple[str | None, bool]:
|
|
23
|
+
"""(the text it types, or None; whether it starts an accent instead)."""
|
|
24
|
+
if shortcut.modifiers & _COMMAND_CONTROL:
|
|
25
|
+
return None, False # ⌘ and ⌃ combinations type nothing
|
|
26
|
+
carbon = ctypes.cdll.LoadLibrary(ctypes.util.find_library("Carbon"))
|
|
27
|
+
foundation = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))
|
|
28
|
+
carbon.TISCopyCurrentKeyboardLayoutInputSource.restype = ctypes.c_void_p
|
|
29
|
+
carbon.TISGetInputSourceProperty.restype = ctypes.c_void_p
|
|
30
|
+
carbon.TISGetInputSourceProperty.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
|
31
|
+
carbon.LMGetKbdType.restype = ctypes.c_uint8
|
|
32
|
+
foundation.CFDataGetBytePtr.restype = ctypes.c_void_p
|
|
33
|
+
foundation.CFDataGetBytePtr.argtypes = [ctypes.c_void_p]
|
|
34
|
+
foundation.CFRelease.argtypes = [ctypes.c_void_p]
|
|
35
|
+
|
|
36
|
+
source = carbon.TISCopyCurrentKeyboardLayoutInputSource()
|
|
37
|
+
try:
|
|
38
|
+
key = ctypes.c_void_p.in_dll(carbon, "kTISPropertyUnicodeKeyLayoutData")
|
|
39
|
+
data = carbon.TISGetInputSourceProperty(source, key)
|
|
40
|
+
if not data:
|
|
41
|
+
return None, False # an input method without a key layout
|
|
42
|
+
layout = foundation.CFDataGetBytePtr(data)
|
|
43
|
+
dead, length, chars = (
|
|
44
|
+
ctypes.c_uint32(0),
|
|
45
|
+
ctypes.c_ulong(0),
|
|
46
|
+
(ctypes.c_uint16 * 4)(),
|
|
47
|
+
)
|
|
48
|
+
carbon.UCKeyTranslate(
|
|
49
|
+
ctypes.c_void_p(layout),
|
|
50
|
+
ctypes.c_uint16(shortcut.key_code),
|
|
51
|
+
ctypes.c_uint16(_KEY_DOWN),
|
|
52
|
+
ctypes.c_uint32((shortcut.modifiers & _SHIFT_OPTION) >> 8),
|
|
53
|
+
ctypes.c_uint32(carbon.LMGetKbdType()),
|
|
54
|
+
ctypes.c_uint32(0), # dead keys on: an accent key reports itself
|
|
55
|
+
ctypes.byref(dead),
|
|
56
|
+
ctypes.c_ulong(len(chars)),
|
|
57
|
+
ctypes.byref(length),
|
|
58
|
+
chars,
|
|
59
|
+
)
|
|
60
|
+
finally:
|
|
61
|
+
foundation.CFRelease(source)
|
|
62
|
+
if length.value == 0 and dead.value:
|
|
63
|
+
return None, True
|
|
64
|
+
text = "".join(chr(chars[i]) for i in range(length.value))
|
|
65
|
+
return text or None, False
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The Spotify turntable for macOS: Apple events, run in-process with PyObjC.
|
|
2
|
+
|
|
3
|
+
Spotify answers one property per Apple event, at about 17 ms each, so a poll
|
|
4
|
+
asks only what changes every second: the track id, then the app's properties
|
|
5
|
+
record (state and position in a single event). Title, artist and duration are
|
|
6
|
+
asked only when the track changes. Measured on macOS 26 with Spotify 1.3.
|
|
7
|
+
|
|
8
|
+
The id and the properties come from two events, a few milliseconds apart. If
|
|
9
|
+
the track changes in between, the reading pairs the old id with the new
|
|
10
|
+
track's position, which is near 0; seek detection takes that for a restart and
|
|
11
|
+
ignores it. That is why the id is read first.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
|
|
16
|
+
from AppKit import NSWorkspace
|
|
17
|
+
from Foundation import NSAppleScript
|
|
18
|
+
|
|
19
|
+
from hookjump.adapters.turntable import Reading
|
|
20
|
+
|
|
21
|
+
BUNDLE_ID = "com.spotify.client"
|
|
22
|
+
_TIMEOUT_S = 2 # a hung Spotify must not freeze polling (the default is 2 min)
|
|
23
|
+
|
|
24
|
+
_POLL = f"""
|
|
25
|
+
if application id "{BUNDLE_ID}" is not running then return "not running"
|
|
26
|
+
tell application id "{BUNDLE_ID}"
|
|
27
|
+
with timeout of {_TIMEOUT_S} seconds
|
|
28
|
+
try
|
|
29
|
+
set trackId to id of current track
|
|
30
|
+
on error
|
|
31
|
+
set trackId to missing value
|
|
32
|
+
end try
|
|
33
|
+
set p to properties
|
|
34
|
+
return {{trackId, (player state of p) as text, player position of p}}
|
|
35
|
+
end timeout
|
|
36
|
+
end tell
|
|
37
|
+
"""
|
|
38
|
+
_TRACK = f"""
|
|
39
|
+
tell application id "{BUNDLE_ID}"
|
|
40
|
+
with timeout of {_TIMEOUT_S} seconds
|
|
41
|
+
set t to current track
|
|
42
|
+
return {{id of t, name of t, artist of t, duration of t}}
|
|
43
|
+
end timeout
|
|
44
|
+
end tell
|
|
45
|
+
"""
|
|
46
|
+
_PLACE = """
|
|
47
|
+
if application id "{bundle}" is not running then return false
|
|
48
|
+
tell application id "{bundle}"
|
|
49
|
+
with timeout of {timeout} seconds
|
|
50
|
+
if player state is stopped then return false
|
|
51
|
+
if (id of current track) is not "{track}" then return false
|
|
52
|
+
set player position to {seconds}
|
|
53
|
+
end timeout
|
|
54
|
+
end tell
|
|
55
|
+
return true
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
# Apple event error numbers.
|
|
59
|
+
_NOT_PERMITTED = -1743 # the listener has not allowed hookjump to control Spotify
|
|
60
|
+
_GONE = {-600, -609, -1712} # quit, connection lost, timed out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _code(text: str) -> int:
|
|
64
|
+
return int.from_bytes(text.encode("latin-1"), "big")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_TYPE, _MISSING = _code("type"), _code("msng")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SpotifyNotInstalled(RuntimeError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Spotify:
|
|
75
|
+
"""A turntable for the Spotify desktop app (hookjump.adapters.turntable)."""
|
|
76
|
+
|
|
77
|
+
name = "Spotify"
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
# Compiling a script for an app that is not installed can open a
|
|
81
|
+
# "Where is Spotify?" dialog; check first.
|
|
82
|
+
workspace = NSWorkspace.sharedWorkspace()
|
|
83
|
+
if workspace.URLForApplicationWithBundleIdentifier_(BUNDLE_ID) is None:
|
|
84
|
+
raise SpotifyNotInstalled("Spotify is not installed")
|
|
85
|
+
self._poll = _compile(_POLL)
|
|
86
|
+
self._track = _compile(_TRACK)
|
|
87
|
+
self._known: tuple[str, str | None, str | None, int | None] | None = None
|
|
88
|
+
|
|
89
|
+
def read(self) -> Reading | None:
|
|
90
|
+
result = _run(self._poll)
|
|
91
|
+
if result is None or result.stringValue() == "not running":
|
|
92
|
+
return None
|
|
93
|
+
track_id = _text(result.descriptorAtIndex_(1))
|
|
94
|
+
playing = _text(result.descriptorAtIndex_(2)) == "playing"
|
|
95
|
+
position = _seconds_to_ms(result.descriptorAtIndex_(3))
|
|
96
|
+
if track_id is None or track_id.startswith("spotify:ad:"):
|
|
97
|
+
# Nothing loaded, or an ad: no track to learn from or jump in.
|
|
98
|
+
return Reading(None, None, None, None, playing, None)
|
|
99
|
+
title, artist, duration = self._details(track_id)
|
|
100
|
+
return Reading(track_id, title, artist, duration, playing, position)
|
|
101
|
+
|
|
102
|
+
def place(self, track_id: str, position_ms: int) -> bool:
|
|
103
|
+
source = _PLACE.format(
|
|
104
|
+
bundle=BUNDLE_ID,
|
|
105
|
+
timeout=_TIMEOUT_S,
|
|
106
|
+
track=track_id.replace("\\", "\\\\").replace('"', '\\"'),
|
|
107
|
+
seconds=f"{position_ms / 1000:.3f}", # always a "." in source
|
|
108
|
+
)
|
|
109
|
+
result = _run(_compile(source))
|
|
110
|
+
return result is not None and result.booleanValue()
|
|
111
|
+
|
|
112
|
+
def _details(self, track_id: str) -> tuple[str | None, str | None, int | None]:
|
|
113
|
+
if self._known is None or self._known[0] != track_id:
|
|
114
|
+
result = _run(self._track)
|
|
115
|
+
if result is None or _text(result.descriptorAtIndex_(1)) != track_id:
|
|
116
|
+
return None, None, None # changed again meanwhile: next poll
|
|
117
|
+
duration = result.descriptorAtIndex_(4)
|
|
118
|
+
self._known = (
|
|
119
|
+
track_id,
|
|
120
|
+
_text(result.descriptorAtIndex_(2)),
|
|
121
|
+
_text(result.descriptorAtIndex_(3)),
|
|
122
|
+
# Spotify's dictionary says seconds; it returns milliseconds.
|
|
123
|
+
None if _missing(duration) else round(duration.doubleValue()),
|
|
124
|
+
)
|
|
125
|
+
return self._known[1:]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _compile(source: str) -> NSAppleScript:
|
|
129
|
+
script = NSAppleScript.alloc().initWithSource_(source)
|
|
130
|
+
ok, error = script.compileAndReturnError_(None)
|
|
131
|
+
if not ok:
|
|
132
|
+
raise RuntimeError(f"AppleScript did not compile: {dict(error)}")
|
|
133
|
+
return script
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _run(script: NSAppleScript): # -> NSAppleEventDescriptor | None
|
|
137
|
+
"""Run a script; None if Spotify is gone or did not answer in time."""
|
|
138
|
+
result, error = script.executeAndReturnError_(None)
|
|
139
|
+
if error is None:
|
|
140
|
+
return result
|
|
141
|
+
number = int(error.get("NSAppleScriptErrorNumber", 0))
|
|
142
|
+
if number == _NOT_PERMITTED:
|
|
143
|
+
raise PermissionError(
|
|
144
|
+
"hookjump may not control Spotify. Allow it in System Settings >"
|
|
145
|
+
" Privacy & Security > Automation."
|
|
146
|
+
)
|
|
147
|
+
if number in _GONE:
|
|
148
|
+
return None
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
f"Spotify did not answer: {error.get('NSAppleScriptErrorMessage')}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _missing(descriptor) -> bool:
|
|
155
|
+
return descriptor is None or (
|
|
156
|
+
descriptor.descriptorType() == _TYPE and descriptor.typeCodeValue() == _MISSING
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _text(descriptor) -> str | None:
|
|
161
|
+
return None if _missing(descriptor) else descriptor.stringValue()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _seconds_to_ms(descriptor) -> int | None:
|
|
165
|
+
if _missing(descriptor):
|
|
166
|
+
return None
|
|
167
|
+
return math.floor(descriptor.doubleValue() * 1000 + 0.5) # ALGORITHM.md §1
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Local SQLite storage for what hookjump learns (spec/STORAGE.md).
|
|
2
|
+
|
|
3
|
+
Raw samples are kept for 7 days; the seeks found in them and the listener's
|
|
4
|
+
pins are kept for good. The kept tables hold no dates or times.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from hookjump.core.heatmap import RECENT_LISTENS
|
|
12
|
+
from hookjump.core.models import Pin, PlaybackSample, SeekEvent
|
|
13
|
+
from hookjump.core.seek import detect
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
SAMPLE_DAYS = 7
|
|
17
|
+
_DAY_MS = 24 * 60 * 60 * 1000
|
|
18
|
+
|
|
19
|
+
_SCHEMA = """
|
|
20
|
+
CREATE TABLE pins (
|
|
21
|
+
track_id TEXT PRIMARY KEY,
|
|
22
|
+
at_ms INTEGER NOT NULL CHECK (at_ms >= 0),
|
|
23
|
+
typed INTEGER NOT NULL CHECK (typed IN (0, 1))
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE seeks (
|
|
26
|
+
track_id TEXT NOT NULL,
|
|
27
|
+
listen INTEGER NOT NULL,
|
|
28
|
+
from_ms INTEGER NOT NULL,
|
|
29
|
+
to_ms INTEGER NOT NULL
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX seeks_by_track ON seeks (track_id, listen);
|
|
32
|
+
CREATE TABLE samples (
|
|
33
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
34
|
+
at_ms INTEGER NOT NULL,
|
|
35
|
+
track_id TEXT,
|
|
36
|
+
playing INTEGER NOT NULL,
|
|
37
|
+
position_ms INTEGER,
|
|
38
|
+
own_seek INTEGER NOT NULL
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE ignored (
|
|
41
|
+
track_id TEXT PRIMARY KEY
|
|
42
|
+
);
|
|
43
|
+
CREATE TABLE settings (
|
|
44
|
+
key TEXT PRIMARY KEY,
|
|
45
|
+
value INTEGER NOT NULL
|
|
46
|
+
);
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_path() -> Path:
|
|
51
|
+
"""Application Support, or the HOOKJUMP_DB variable (for trying things out)."""
|
|
52
|
+
if path := os.environ.get("HOOKJUMP_DB"):
|
|
53
|
+
return Path(path)
|
|
54
|
+
return Path.home() / "Library" / "Application Support" / "hookjump" / "hookjump.db"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Store:
|
|
58
|
+
"""One hookjump database. Use as a context manager, or call close()."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, path: Path | str) -> None:
|
|
61
|
+
if path != ":memory:":
|
|
62
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
self._db = sqlite3.connect(path)
|
|
64
|
+
# Deleted rows are overwritten, not left readable in free pages.
|
|
65
|
+
self._db.execute("PRAGMA secure_delete = ON")
|
|
66
|
+
version = self._db.execute("PRAGMA user_version").fetchone()[0]
|
|
67
|
+
if version == 0:
|
|
68
|
+
with self._db:
|
|
69
|
+
self._db.executescript(_SCHEMA)
|
|
70
|
+
self._db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
71
|
+
elif version != SCHEMA_VERSION:
|
|
72
|
+
self._db.close()
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"{path} has schema version {version}; this hookjump reads"
|
|
75
|
+
f" version {SCHEMA_VERSION}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
self._db.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "Store":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *exc: object) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
# Samples and seeks ------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def add_sample(self, sample: PlaybackSample) -> int | None:
|
|
90
|
+
"""Record one poll; its ``at_ms`` is Unix time in milliseconds.
|
|
91
|
+
|
|
92
|
+
Nothing is written while learning is paused or for an ignored track.
|
|
93
|
+
Returns the sample's id, or None if it was not written.
|
|
94
|
+
"""
|
|
95
|
+
if self.learning_paused() or (
|
|
96
|
+
sample.track_id is not None and self.is_ignored(sample.track_id)
|
|
97
|
+
):
|
|
98
|
+
return None
|
|
99
|
+
with self._db:
|
|
100
|
+
cursor = self._db.execute(
|
|
101
|
+
"INSERT INTO samples (at_ms, track_id, playing, position_ms,"
|
|
102
|
+
" own_seek) VALUES (?, ?, ?, ?, ?)",
|
|
103
|
+
(
|
|
104
|
+
sample.at_ms,
|
|
105
|
+
sample.track_id,
|
|
106
|
+
int(sample.playing),
|
|
107
|
+
sample.position_ms,
|
|
108
|
+
int(sample.own_seek),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
return cursor.lastrowid
|
|
112
|
+
|
|
113
|
+
def process(self, before_id: int | None = None) -> int:
|
|
114
|
+
"""Find the seeks in the samples not processed yet and keep them.
|
|
115
|
+
|
|
116
|
+
Pass the id of the first sample of the listen still going on, so a
|
|
117
|
+
listen is never cut in two (it would count as two listens); pass None
|
|
118
|
+
when nothing is playing any more, such as at start-up. Returns the
|
|
119
|
+
number of seeks kept.
|
|
120
|
+
"""
|
|
121
|
+
done = self._setting("processed_sample_id")
|
|
122
|
+
rows = self._db.execute(
|
|
123
|
+
"SELECT id, at_ms, track_id, playing, position_ms, own_seek"
|
|
124
|
+
" FROM samples WHERE id > ? AND (? IS NULL OR id < ?) ORDER BY id",
|
|
125
|
+
(done, before_id, before_id),
|
|
126
|
+
).fetchall()
|
|
127
|
+
if not rows:
|
|
128
|
+
return 0
|
|
129
|
+
samples = [
|
|
130
|
+
PlaybackSample(at, track, bool(playing), position, bool(own))
|
|
131
|
+
for _, at, track, playing, position, own in rows
|
|
132
|
+
]
|
|
133
|
+
seeks = [e for e in detect(samples) if isinstance(e, SeekEvent)]
|
|
134
|
+
|
|
135
|
+
# detect() numbers listens within this batch. Kept listen numbers count
|
|
136
|
+
# per track, only for listens with seeks, and only ever grow. Each seek
|
|
137
|
+
# is inserted at once, so the next listen of the track counts on from it.
|
|
138
|
+
numbers: dict[tuple[str, int], int] = {}
|
|
139
|
+
with self._db:
|
|
140
|
+
for seek in seeks:
|
|
141
|
+
key = (seek.track_id, seek.listen)
|
|
142
|
+
if key not in numbers:
|
|
143
|
+
numbers[key] = self._last_listen(seek.track_id) + 1
|
|
144
|
+
self._db.execute(
|
|
145
|
+
"INSERT INTO seeks (track_id, listen, from_ms, to_ms)"
|
|
146
|
+
" VALUES (?, ?, ?, ?)",
|
|
147
|
+
(seek.track_id, numbers[key], seek.from_ms, seek.to_ms),
|
|
148
|
+
)
|
|
149
|
+
for track_id in {s.track_id for s in seeks}:
|
|
150
|
+
self._keep_recent(track_id)
|
|
151
|
+
self._set_setting("processed_sample_id", rows[-1][0])
|
|
152
|
+
return len(seeks)
|
|
153
|
+
|
|
154
|
+
def seeks(self, track_id: str) -> list[SeekEvent]:
|
|
155
|
+
rows = self._db.execute(
|
|
156
|
+
"SELECT listen, from_ms, to_ms FROM seeks WHERE track_id = ?"
|
|
157
|
+
" ORDER BY listen, rowid",
|
|
158
|
+
(track_id,),
|
|
159
|
+
).fetchall()
|
|
160
|
+
return [SeekEvent(track_id, *row) for row in rows]
|
|
161
|
+
|
|
162
|
+
def purge_samples(self, now_ms: int) -> int:
|
|
163
|
+
"""Delete processed samples older than SAMPLE_DAYS; returns how many."""
|
|
164
|
+
with self._db:
|
|
165
|
+
cursor = self._db.execute(
|
|
166
|
+
"DELETE FROM samples WHERE at_ms < ? AND id <= ?",
|
|
167
|
+
(now_ms - SAMPLE_DAYS * _DAY_MS, self._setting("processed_sample_id")),
|
|
168
|
+
)
|
|
169
|
+
return cursor.rowcount
|
|
170
|
+
|
|
171
|
+
# Pins -------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def pin(self, track_id: str) -> Pin | None:
|
|
174
|
+
row = self._db.execute(
|
|
175
|
+
"SELECT at_ms, typed FROM pins WHERE track_id = ?", (track_id,)
|
|
176
|
+
).fetchone()
|
|
177
|
+
return Pin(row[0], bool(row[1])) if row else None
|
|
178
|
+
|
|
179
|
+
def set_pin(self, track_id: str, pin: Pin) -> None:
|
|
180
|
+
with self._db:
|
|
181
|
+
self._db.execute(
|
|
182
|
+
"INSERT OR REPLACE INTO pins (track_id, at_ms, typed) VALUES (?, ?, ?)",
|
|
183
|
+
(track_id, pin.at_ms, int(pin.typed)),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def clear_pin(self, track_id: str) -> None:
|
|
187
|
+
with self._db:
|
|
188
|
+
self._db.execute("DELETE FROM pins WHERE track_id = ?", (track_id,))
|
|
189
|
+
|
|
190
|
+
# Settings ---------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def shortcut(self, name: str) -> int:
|
|
193
|
+
"""A stored shortcut (``Shortcut.to_int``) or 0 for the default."""
|
|
194
|
+
return self._setting(f"{name}_shortcut")
|
|
195
|
+
|
|
196
|
+
def set_shortcut(self, name: str, value: int) -> None:
|
|
197
|
+
with self._db:
|
|
198
|
+
self._set_setting(f"{name}_shortcut", value)
|
|
199
|
+
|
|
200
|
+
# Privacy ----------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def learning_paused(self) -> bool:
|
|
203
|
+
return bool(self._setting("learning_paused"))
|
|
204
|
+
|
|
205
|
+
def set_learning_paused(self, paused: bool) -> None:
|
|
206
|
+
with self._db:
|
|
207
|
+
self._set_setting("learning_paused", int(paused))
|
|
208
|
+
|
|
209
|
+
def is_ignored(self, track_id: str) -> bool:
|
|
210
|
+
row = self._db.execute(
|
|
211
|
+
"SELECT 1 FROM ignored WHERE track_id = ?", (track_id,)
|
|
212
|
+
).fetchone()
|
|
213
|
+
return row is not None
|
|
214
|
+
|
|
215
|
+
def ignore(self, track_id: str) -> None:
|
|
216
|
+
"""Stop learning from a track and forget what was learned. Pins stay."""
|
|
217
|
+
with self._db:
|
|
218
|
+
self._db.execute(
|
|
219
|
+
"INSERT OR IGNORE INTO ignored (track_id) VALUES (?)", (track_id,)
|
|
220
|
+
)
|
|
221
|
+
self._forget_learned(track_id)
|
|
222
|
+
|
|
223
|
+
def unignore(self, track_id: str) -> None:
|
|
224
|
+
with self._db:
|
|
225
|
+
self._db.execute("DELETE FROM ignored WHERE track_id = ?", (track_id,))
|
|
226
|
+
|
|
227
|
+
def forget_track(self, track_id: str) -> None:
|
|
228
|
+
"""Forget one track: its pin and everything learned from it."""
|
|
229
|
+
with self._db:
|
|
230
|
+
self._db.execute("DELETE FROM pins WHERE track_id = ?", (track_id,))
|
|
231
|
+
self._forget_learned(track_id)
|
|
232
|
+
|
|
233
|
+
def forget_learned(self) -> None:
|
|
234
|
+
"""Forget everything learned from listening; pins and settings stay."""
|
|
235
|
+
with self._db:
|
|
236
|
+
self._db.execute("DELETE FROM seeks")
|
|
237
|
+
self._db.execute("DELETE FROM samples")
|
|
238
|
+
|
|
239
|
+
def forget_all(self) -> None:
|
|
240
|
+
"""Delete everything: pins, learned data, ignored tracks, settings."""
|
|
241
|
+
with self._db:
|
|
242
|
+
for table in ("pins", "seeks", "samples", "ignored", "settings"):
|
|
243
|
+
self._db.execute(f"DELETE FROM {table}")
|
|
244
|
+
self._db.execute("VACUUM") # give the space back to the disk
|
|
245
|
+
|
|
246
|
+
# Internals --------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def _last_listen(self, track_id: str) -> int:
|
|
249
|
+
row = self._db.execute(
|
|
250
|
+
"SELECT MAX(listen) FROM seeks WHERE track_id = ?", (track_id,)
|
|
251
|
+
).fetchone()
|
|
252
|
+
return row[0] or 0
|
|
253
|
+
|
|
254
|
+
def _keep_recent(self, track_id: str) -> None:
|
|
255
|
+
# The heatmap only reads the RECENT_LISTENS latest listens with seeks.
|
|
256
|
+
self._db.execute(
|
|
257
|
+
"DELETE FROM seeks WHERE track_id = ? AND listen NOT IN ("
|
|
258
|
+
" SELECT DISTINCT listen FROM seeks WHERE track_id = ?"
|
|
259
|
+
" ORDER BY listen DESC LIMIT ?)",
|
|
260
|
+
(track_id, track_id, RECENT_LISTENS),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _forget_learned(self, track_id: str) -> None:
|
|
264
|
+
self._db.execute("DELETE FROM seeks WHERE track_id = ?", (track_id,))
|
|
265
|
+
self._db.execute("DELETE FROM samples WHERE track_id = ?", (track_id,))
|
|
266
|
+
|
|
267
|
+
def _setting(self, key: str) -> int:
|
|
268
|
+
row = self._db.execute(
|
|
269
|
+
"SELECT value FROM settings WHERE key = ?", (key,)
|
|
270
|
+
).fetchone()
|
|
271
|
+
return row[0] if row else 0
|
|
272
|
+
|
|
273
|
+
def _set_setting(self, key: str, value: int) -> None:
|
|
274
|
+
self._db.execute(
|
|
275
|
+
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value)
|
|
276
|
+
)
|