@naxodev/opencode-music-player 0.2.5 → 0.2.6

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.
package/README.md CHANGED
@@ -16,6 +16,10 @@ Read the [music session architecture field guide](../../docs/music-session-archi
16
16
 
17
17
  The daemon performs the bounded native `media-control get --now` read and validates the complete recording identity before and after the read. OpenCode uses those bytes when available, then keeps iTunes Search fallback, image downloads, conversion, cache/job ownership, and terminal rendering locally. Artwork failure never blocks playback state.
18
18
 
19
+ Catalog matching, bounded downloads, cancellation, and transient retries use the [shared host-side acquisition policy](../music-core/README.md#host-side-catalog-artwork). OpenCode keeps image conversion and its shared presentation cache/jobs. Track changes remove only the changing view's interest; another view can keep the same job alive.
20
+
21
+ Run `/music-artwork`, or select **Music → Refresh artwork** in the command palette, to recover artwork for the current track after connectivity returns. Automatic recovery stops after three transient attempts. A settled mismatch does not retry on playback snapshots. Refresh starts a new bounded attempt set; repeated refreshes while a job is active share that job. This action does not change playback.
22
+
19
23
  Ghostty and other terminals with Kitty graphics support display the cover as a native image. Other terminals receive a true-color half-block rendering of the same cover.
20
24
 
21
25
  Terminal multiplexers must pass Kitty graphics through to use native images. The player uses the half-block rendering when the host does not expose that support.
package/artwork.ts CHANGED
@@ -3,226 +3,33 @@ import { tmpdir } from "node:os"
3
3
  import { join } from "node:path"
4
4
  import { PNG } from "pngjs"
5
5
  import type { Artwork } from "./types.ts"
6
+ import {
7
+ acquireCatalogArtwork,
8
+ MAX_ARTWORK_BYTES,
9
+ selectCatalogResolution,
10
+ type CatalogTarget as TrackIdentity,
11
+ type ArtworkFetcher as Fetcher,
12
+ } from "@naxodev/music-core"
13
+ export {
14
+ selectCatalogResolution,
15
+ selectCatalogTrack,
16
+ readLimitedResponse,
17
+ downloadCatalogImage,
18
+ type CatalogTrack,
19
+ } from "@naxodev/music-core"
6
20
 
7
- const MAX_ARTWORK_BYTES = 3_000_000
8
- const MAX_CATALOG_RESPONSE_BYTES = 512_000
9
21
  const MAX_CONVERTED_PNG_BYTES = 1_000_000
10
22
  const MAX_IMAGE_DIMENSION = 4_096
11
23
  const MAX_IMAGE_PIXELS = 12_000_000
12
- const FETCH_TIMEOUT_MS = 4_000
13
24
  const CONVERSION_TIMEOUT_MS = 3_000
14
- // MediaRemote may expose whole seconds while catalogs retain milliseconds.
15
- const DURATION_TOLERANCE_MS = 1_000
16
- const MAX_CATALOG_DURATION_MS = 24 * 60 * 60 * 1_000
17
-
18
- type TrackIdentity = {
19
- title: string
20
- artist: string
21
- album: string
22
- duration_ms: number
23
- }
24
-
25
- export type CatalogTrack = {
26
- trackName?: string
27
- artistName?: string
28
- collectionName?: string
29
- trackTimeMillis?: number
30
- artworkUrl100?: string
31
- }
32
-
33
- type Fetcher = (
34
- input: string | URL | Request,
35
- init?: RequestInit,
36
- ) => Promise<Response>
37
-
38
- function normalized(value: string | undefined): string {
39
- return (value ?? "")
40
- .normalize("NFKC")
41
- .replace(/’/g, "'")
42
- .replace(/\s+/g, " ")
43
- .trim()
44
- .toLowerCase()
45
- }
46
25
 
47
26
  export function selectArtworkUrl(
48
27
  target: TrackIdentity,
49
- results: CatalogTrack[],
28
+ results: import("@naxodev/music-core").CatalogTrack[],
50
29
  ): string | null {
51
30
  return selectCatalogResolution(target, results).artworkUrl
52
31
  }
53
32
 
54
- function matchingCatalogTracks(
55
- target: TrackIdentity,
56
- results: CatalogTrack[],
57
- ): CatalogTrack[] {
58
- const title = normalized(target.title)
59
- const artist = normalized(target.artist)
60
- const album = normalized(target.album)
61
- if (!title || !artist) return []
62
-
63
- let matches = results.filter(
64
- (item) =>
65
- normalized(item.trackName) === title &&
66
- normalized(item.artistName) === artist,
67
- )
68
- if (album) {
69
- matches = matches.filter(
70
- (item) => normalized(item.collectionName) === album,
71
- )
72
- }
73
- return matches
74
- }
75
-
76
- function validCatalogDuration(value: number | undefined): value is number {
77
- return (
78
- typeof value === "number" &&
79
- Number.isFinite(value) &&
80
- value > 0 &&
81
- value <= MAX_CATALOG_DURATION_MS
82
- )
83
- }
84
-
85
- function selectCatalogCandidate(
86
- target: TrackIdentity,
87
- results: CatalogTrack[],
88
- ): CatalogTrack | null {
89
- let matches = matchingCatalogTracks(target, results)
90
- if (target.duration_ms > 0) {
91
- matches = matches.filter(
92
- (item) =>
93
- validCatalogDuration(item.trackTimeMillis) &&
94
- Math.abs(item.trackTimeMillis! - target.duration_ms) <=
95
- DURATION_TOLERANCE_MS,
96
- )
97
- } else if (matches.length !== 1) {
98
- return null
99
- }
100
-
101
- return matches[0] ?? null
102
- }
103
-
104
- export function selectCatalogTrack(
105
- target: TrackIdentity,
106
- results: CatalogTrack[],
107
- ): CatalogTrack | null {
108
- const match = selectCatalogCandidate(target, results)
109
- return validCatalogDuration(match?.trackTimeMillis) ? match : null
110
- }
111
-
112
- export function selectCatalogResolution(
113
- target: TrackIdentity,
114
- results: CatalogTrack[],
115
- ): { artworkUrl: string | null; duration_ms: number } {
116
- const match = selectCatalogCandidate(target, results)
117
- return {
118
- artworkUrl:
119
- match?.artworkUrl100?.replace(/100x100(?=[a-z]*\.)/, "300x300") ?? null,
120
- duration_ms: validCatalogDuration(match?.trackTimeMillis)
121
- ? match.trackTimeMillis
122
- : target.duration_ms,
123
- }
124
- }
125
-
126
- function allowedCatalogImageUrl(raw: string | URL): URL | null {
127
- try {
128
- const url = new URL(raw)
129
- if (url.protocol !== "https:" || !url.hostname.endsWith(".mzstatic.com")) {
130
- return null
131
- }
132
- return url
133
- } catch {
134
- return null
135
- }
136
- }
137
-
138
- export async function readLimitedResponse(
139
- response: Response,
140
- maxBytes: number,
141
- ): Promise<Uint8Array | null> {
142
- const declared = Number(response.headers.get("content-length"))
143
- if (Number.isFinite(declared) && declared > maxBytes) return null
144
- if (!response.body) return new Uint8Array()
145
-
146
- const bytes = new Uint8Array(maxBytes)
147
- const reader = response.body.getReader()
148
- let total = 0
149
- try {
150
- while (true) {
151
- const { done, value } = await reader.read()
152
- if (done) break
153
- if (total + value.byteLength > maxBytes) {
154
- await reader.cancel("artwork exceeds byte limit")
155
- return null
156
- }
157
- bytes.set(value, total)
158
- total += value.byteLength
159
- }
160
- } finally {
161
- reader.releaseLock()
162
- }
163
-
164
- return bytes.slice(0, total)
165
- }
166
-
167
- export async function downloadCatalogImage(
168
- rawUrl: string,
169
- fetcher: Fetcher = fetch,
170
- ): Promise<Uint8Array | null> {
171
- const url = allowedCatalogImageUrl(rawUrl)
172
- if (!url) return null
173
- try {
174
- const response = await fetcher(url, {
175
- redirect: "error",
176
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
177
- })
178
- if (!response.ok || response.redirected) return null
179
- if (response.url && !allowedCatalogImageUrl(response.url)) return null
180
- return readLimitedResponse(response, MAX_ARTWORK_BYTES)
181
- } catch {
182
- return null
183
- }
184
- }
185
-
186
- async function catalogArtwork(
187
- target: TrackIdentity,
188
- fetcher: Fetcher = fetch,
189
- ): Promise<{ bytes: Uint8Array | null; duration_ms: number } | null> {
190
- if (!target.title || !target.artist) return null
191
- const term = [target.artist, target.title, target.album]
192
- .filter(Boolean)
193
- .join(" ")
194
- const url = new URL("https://itunes.apple.com/search")
195
- url.searchParams.set("term", term)
196
- url.searchParams.set("entity", "song")
197
- url.searchParams.set("limit", "10")
198
-
199
- try {
200
- const result = await fetcher(url, {
201
- redirect: "error",
202
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
203
- })
204
- if (!result.ok || result.redirected) return null
205
- const responseBytes = await readLimitedResponse(
206
- result,
207
- MAX_CATALOG_RESPONSE_BYTES,
208
- )
209
- if (!responseBytes) return null
210
- const payload = JSON.parse(new TextDecoder().decode(responseBytes)) as {
211
- results?: CatalogTrack[]
212
- }
213
- const results = payload.results ?? []
214
- const resolution = selectCatalogResolution(target, results)
215
- const bytes = resolution.artworkUrl
216
- ? await downloadCatalogImage(resolution.artworkUrl, fetcher)
217
- : null
218
- if (!resolution.artworkUrl && resolution.duration_ms === target.duration_ms)
219
- return null
220
- return { bytes, duration_ms: resolution.duration_ms }
221
- } catch {
222
- return null
223
- }
224
- }
225
-
226
33
  export function imageDimensionsAreSafe(width: number, height: number): boolean {
227
34
  return (
228
35
  Number.isInteger(width) &&
@@ -356,7 +163,9 @@ export async function resolveArtworkDetails(
356
163
  nativeBase64: string | null,
357
164
  legacyId = id,
358
165
  fetcher: Fetcher = fetch,
166
+ signal?: AbortSignal,
359
167
  ): Promise<ArtworkResolution> {
168
+ if (signal?.aborted) return { artwork: null, duration_ms: target.duration_ms }
360
169
  const candidates: Uint8Array[] = []
361
170
  if (nativeBase64) {
362
171
  try {
@@ -390,8 +199,15 @@ export async function resolveArtworkDetails(
390
199
  }
391
200
  }
392
201
 
393
- const catalog = await catalogArtwork(target, fetcher)
394
- if (!catalog) return { artwork: null, duration_ms: target.duration_ms }
202
+ const catalog = await acquireCatalogArtwork(target, {
203
+ fetch: fetcher,
204
+ signal,
205
+ })
206
+ if (catalog.kind !== "available" || signal?.aborted)
207
+ return {
208
+ artwork: null,
209
+ duration_ms: catalog.duration_ms ?? target.duration_ms,
210
+ }
395
211
  if (!catalog.bytes) {
396
212
  return { artwork: null, duration_ms: catalog.duration_ms }
397
213
  }
package/index.tsx CHANGED
@@ -26,6 +26,7 @@ export type Controller = {
26
26
  subscribe: (listener: (session: SessionStore) => void) => () => void
27
27
  openApp: () => Promise<void>
28
28
  refreshAll: () => Promise<void>
29
+ refreshArtwork: () => Promise<void>
29
30
  playPause: () => Promise<void>
30
31
  seek: (positionMs: number) => Promise<void>
31
32
  next: () => Promise<void>
@@ -321,6 +322,14 @@ export function createController(
321
322
  return () => sessionListeners.delete(listener)
322
323
  },
323
324
  refreshAll,
325
+ refreshArtwork: async () => {
326
+ if (!isActive()) return
327
+ try {
328
+ await media.refreshArtwork()
329
+ } catch (error) {
330
+ if (isActive()) setTransportError(errMsg(error))
331
+ }
332
+ },
324
333
  async openApp() {
325
334
  if (!isActive()) return
326
335
  try {
@@ -396,6 +405,14 @@ function AppHost(props: { context: Context; ctrl: Controller }) {
396
405
  slash: { name: "music-app" },
397
406
  run: () => void ctrl.openApp(),
398
407
  },
408
+ {
409
+ id: "music.refresh-artwork",
410
+ title: "Refresh artwork",
411
+ group: "Music",
412
+ palette: true,
413
+ slash: { name: "music-artwork" },
414
+ run: () => void ctrl.refreshArtwork(),
415
+ },
399
416
  ],
400
417
  }))
401
418
  return (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/opencode-music-player",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "macOS system Now Playing controls for the OpenCode 2 TUI",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -68,7 +68,7 @@
68
68
  "validate": "bun run format:check && bun run check && bun run test && bun run pack:check"
69
69
  },
70
70
  "dependencies": {
71
- "@naxodev/music-core": "^0.1.1",
71
+ "@naxodev/music-core": "^0.1.4",
72
72
  "@opencode-ai/plugin": "0.0.0-next-17444",
73
73
  "@opentui/core": "0.5.4",
74
74
  "@opentui/solid": "0.5.4",
package/system-media.ts CHANGED
@@ -27,8 +27,7 @@ type ArtworkCacheEntry = {
27
27
  duration_ms: number
28
28
  resolved: boolean
29
29
  pending: boolean
30
- attempts: number
31
- retry_at: number
30
+ abort: AbortController
32
31
  interests: Map<PresentationHost, ArtworkIdentity>
33
32
  }
34
33
  const MAX_ARTWORK_ENTRIES = 32
@@ -192,6 +191,7 @@ function removeArtworkInterests(host: PresentationHost) {
192
191
  // A host owns only its interest. The physical job keeps its slot until it settles.
193
192
  for (const entry of artworkJobs.values()) {
194
193
  entry.interests.delete(host)
194
+ if (!entry.interests.size) entry.abort.abort()
195
195
  }
196
196
  }
197
197
 
@@ -205,9 +205,10 @@ function artworkForTrack(
205
205
  identity: ArtworkIdentity,
206
206
  now: () => number,
207
207
  ): { artwork: Artwork | null; duration_ms: number; loading: boolean } {
208
- let entry = artworkCache.get(key) ?? artworkJobs.get(key)
208
+ let entry = artworkJobs.get(key) ?? artworkCache.get(key)
209
+ if (entry?.abort.signal.aborted) entry = undefined
209
210
  if (!entry) {
210
- if (artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
211
+ if (artworkJobs.has(key) || artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
211
212
  removeWaitingInterest(host)
212
213
  let deferred = waitingArtwork.get(key)
213
214
  if (!deferred && waitingArtwork.size < MAX_ARTWORK_ENTRIES) {
@@ -238,22 +239,15 @@ function artworkForTrack(
238
239
  duration_ms: target.duration_ms,
239
240
  resolved: false,
240
241
  pending: false,
241
- attempts: 0,
242
- retry_at: 0,
242
+ abort: new AbortController(),
243
243
  interests: new Map(),
244
244
  }
245
245
  }
246
246
  // A state change that reached admission supersedes any older deferred
247
247
  // identity owned by this adapter generation.
248
248
  removeWaitingInterest(host)
249
- if (
250
- !entry.pending &&
251
- entry.attempts < 3 &&
252
- now() >= entry.retry_at &&
253
- (!entry.resolved || entry.value === null)
254
- ) {
249
+ if (!entry.pending && !entry.resolved) {
255
250
  entry.pending = true
256
- entry.attempts++
257
251
  artworkJobs.set(key, entry)
258
252
  const activeEntry = entry
259
253
  void (async () => {
@@ -263,15 +257,24 @@ function artworkForTrack(
263
257
  } catch {
264
258
  // Session artwork failure is transient; host catalog fallback remains.
265
259
  }
266
- return resolver(key, target, data, legacyKey)
260
+ return resolver(
261
+ key,
262
+ target,
263
+ data,
264
+ legacyKey,
265
+ undefined,
266
+ activeEntry.abort.signal,
267
+ )
267
268
  })().then(
268
269
  (resolution) => {
269
270
  activeEntry.value = resolution.artwork
270
271
  activeEntry.duration_ms = resolution.duration_ms
271
272
  activeEntry.resolved = true
272
273
  activeEntry.pending = false
273
- if (!resolution.artwork)
274
- activeEntry.retry_at = now() + 2_000 * 2 ** (activeEntry.attempts - 1)
274
+ if (activeEntry.abort.signal.aborted) {
275
+ releaseArtworkSlot(key)
276
+ return
277
+ }
275
278
  settleArtworkEntry(key, activeEntry)
276
279
  publishArtworkCompletion(activeEntry, resolution.artwork)
277
280
  },
@@ -279,7 +282,10 @@ function artworkForTrack(
279
282
  activeEntry.value = null
280
283
  activeEntry.resolved = true
281
284
  activeEntry.pending = false
282
- activeEntry.retry_at = now() + 2_000 * 2 ** (activeEntry.attempts - 1)
285
+ if (activeEntry.abort.signal.aborted) {
286
+ releaseArtworkSlot(key)
287
+ return
288
+ }
283
289
  settleArtworkEntry(key, activeEntry)
284
290
  publishArtworkCompletion(activeEntry, null)
285
291
  },
@@ -308,6 +314,7 @@ export function createSessionSystemMedia(
308
314
  const acquisitionAbort = new AbortController()
309
315
  let disposed = false
310
316
  let currentArtworkIdentity: string | null = null
317
+ let currentArtworkKey: string | null = null
311
318
  let client: ReconnectingMusicSessionClient | undefined
312
319
  let installed = false
313
320
  let latest: RevisionedState | undefined
@@ -388,6 +395,11 @@ export function createSessionSystemMedia(
388
395
  emit(event)
389
396
  }
390
397
  const project = (state: RevisionedState | undefined): PlayerState | null => {
398
+ const nextKey = state?.state.track
399
+ ? artworkCacheKey(identityFromTrack(state.state.track))
400
+ : null
401
+ if (nextKey !== currentArtworkKey) removeArtworkInterests(host)
402
+ currentArtworkKey = nextKey
391
403
  if (!state) {
392
404
  currentArtworkIdentity = null
393
405
  return null
@@ -490,6 +502,16 @@ export function createSessionSystemMedia(
490
502
  await activeClient()
491
503
  return project(latest)
492
504
  },
505
+ async refreshArtwork() {
506
+ await activeClient()
507
+ const track = latest?.state.track
508
+ if (!track) return
509
+ const key = artworkCacheKey(identityFromTrack(track))
510
+ // Repeated refreshes join active work; no new slot or retry budget.
511
+ if (artworkJobs.has(key)) return
512
+ artworkCache.delete(key)
513
+ emit({ type: "snapshot", state: project(latest)! })
514
+ },
493
515
  async play() {
494
516
  await (await activeClient()).play()
495
517
  },
package/types.ts CHANGED
@@ -68,6 +68,7 @@ export type SessionMediaDisposer = () => void
68
68
  /** The OpenCode controller's session-only media contract. */
69
69
  export type SessionMedia = {
70
70
  player: () => Promise<PlayerState | null>
71
+ refreshArtwork: () => Promise<void>
71
72
  play: () => Promise<unknown>
72
73
  pause: () => Promise<unknown>
73
74
  next: () => Promise<unknown>