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 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,7 @@
1
+ """``python -m hookjump``: the same as the ``hookjump`` command."""
2
+
3
+ import sys
4
+
5
+ from hookjump.cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ """Adapters: the code between the core and the world (disk, player, network).
2
+
3
+ The core computes; adapters store, read the player and fetch. The storage
4
+ format is written down in spec/STORAGE.md so other platforms can share it.
5
+ """
@@ -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
+ )