Skip to content

Commit 9edde86

Browse files
committed
Adds new search functionality to search and interacte with the Apple Music Library directly in the panel area.
1 parent 24e88bc commit 9edde86

File tree

6 files changed

+257
-37
lines changed

6 files changed

+257
-37
lines changed

index.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

index.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@
132132
],
133133

134134
'info' => [
135-
'version' => '2.1.0',
135+
'version' => '2.2.0',
136136
'homepage' => 'https://github.com/scottboms/kirby-applemusic',
137137
'license' => 'MIT',
138138
'authors' => [[ 'name' => 'Scott Boms' ]],

lib/routes.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
declare(strict_types=1);
44

55
use Kirby\Http\Response;
6+
use Kirby\Http\Remote;
7+
use Kirby\Toolkit\A;
68
use Scottboms\MusicKit\MusicKit;
79
use Scottboms\MusicKit\Auth;
810

@@ -205,4 +207,96 @@
205207
}
206208
],
207209

210+
// search
211+
[
212+
'pattern' => 'applemusic/search',
213+
'method' => 'GET',
214+
'action' => function () {
215+
$q = trim((string) (get('q') ?? ''));
216+
$limit = max(1, min((int) (get('limit') ?? 10), 25));
217+
$language = get('language') ?: 'en-US';
218+
$sfRaw = strtolower((string) (get('sf') ?? option('scottboms.applemusic.storefront') ?? 'us'));
219+
$sf = ($sfRaw === 'auto' || !preg_match('/^[a-z]{2}(?:-[A-Z]{2})?$/', $sfRaw)) ? 'us' : $sfRaw;
220+
221+
if ($q === '' || mb_strlen($q) < 2) {
222+
return Response::json(['ok' => false, 'error' => 'Query must be at least 2 characters'], 400);
223+
}
224+
225+
$opts = MusicKit::opts();
226+
if ($err = Auth::validateOptions($opts)) {
227+
return $err; // structured 4xx json
228+
}
229+
230+
$devToken = Auth::devToken($opts);
231+
if (!$devToken) {
232+
return Response::json(['ok' => false, 'error' => 'Developer token is not configured'], 500);
233+
}
234+
235+
$url = 'https://api.music.apple.com/v1/catalog/' . rawurlencode($sf) . '/search';
236+
$qs = http_build_query([
237+
'term' => $q,
238+
'types' => 'songs',
239+
'limit' => $limit,
240+
'l' => $language,
241+
], '', '&', PHP_QUERY_RFC3986);
242+
243+
try {
244+
$res = Remote::get($url . '?' . $qs, [
245+
'headers' => [
246+
'Authorization' => 'Bearer ' . $devToken,
247+
'Accept' => 'application/json',
248+
],
249+
'timeout' => 7,
250+
]);
251+
252+
if ($res->code() < 200 || $res->code() >= 300) {
253+
return Response::json(['ok' => false, 'error' => 'Apple Music search failed (HTTP ' . $res->code() . ')'], 502);
254+
}
255+
256+
$json = $res->json();
257+
$items = A::get($json, 'results.songs.data', []);
258+
259+
$results = array_map(function ($track) {
260+
$id = A::get($track, 'id');
261+
$attr = A::get($track, 'attributes', []);
262+
$name = A::get($attr, 'name', 'Untitled');
263+
$artist= A::get($attr, 'artistName', '');
264+
$art = A::get($attr, 'artwork', null);
265+
$date = A::get($attr, 'releaseDate', null);
266+
267+
$year = null;
268+
if ($date) {
269+
$ts = strtotime($date);
270+
if ($ts !== false) {
271+
$year = date('Y', $ts);
272+
}
273+
}
274+
275+
$thumb = null;
276+
if (is_array($art) && !empty($art['url'])) {
277+
$thumb = str_replace(['{w}', '{h}'], [60, 60], $art['url']);
278+
}
279+
280+
return [
281+
'id' => $id,
282+
'text' => $name . ' - ' . $artist,
283+
'info' => $year,
284+
'attr' => $attr,
285+
'image' => $thumb,
286+
'link' => 'applemusic/song/' . $id,
287+
];
288+
}, $items);
289+
290+
return Response::json([
291+
'ok' => true,
292+
'results' => $results,
293+
'count' => count($results),
294+
], 200);
295+
296+
} catch (\Throwable $e) {
297+
return Response::json(['ok' => false, 'error' => 'Search error: ' . $e->getMessage()], 500);
298+
}
299+
}
300+
],
301+
208302
];

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "scottboms/applemusic",
33
"description": "Apple Music Embed",
44
"author": "Scott Boms <[email protected]>",
5-
"version": "2.1.0",
5+
"version": "2.2.0",
66
"type": "kirby-plugin",
77
"license": "MIT",
88
"scripts": {

src/components/MusicKitHistory.vue

Lines changed: 130 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,34 @@
6363

6464
<!-- conditionally visible: only when token is present -->
6565
<template v-if="hasToken">
66+
<k-section>
67+
<k-box icon="search" theme="white" style="margin-bottom: var(--spacing-1)">
68+
<k-search-input
69+
:value="searchQuery"
70+
:disabled="!hasToken"
71+
:placeholder="'Search Apple Music...'"
72+
@input="onSearchInput"
73+
@submit="performSearch"
74+
/>
75+
</k-box>
76+
77+
<k-box v-if="searching" icon="loader" style="--width: 100%">Searching...</k-box>
78+
79+
<k-items
80+
v-else-if="searchResults.length"
81+
:items="searchResultItems"
82+
layout="list"
83+
size="small"
84+
/>
85+
86+
<k-box v-else-if="searchQuery && !searching" theme="none">No matches found</k-box>
87+
</k-section>
88+
6689
<k-section label="Recently Played Tracks">
6790
<k-button slot="options" size="xs" variant="filled" icon="refresh" @click="fetchRecent()" />
6891

6992
<k-box v-if="error" theme="negative">{{ error }}</k-box>
70-
<k-box v-else-if="loading" icon="loader">Loading</k-box>
93+
<k-box v-else-if="loading" icon="loader">Loading...</k-box>
7194

7295
<div v-else>
7396
<k-collection
@@ -96,6 +119,8 @@
96119
</template>
97120

98121
<script>
122+
import { makeTrackOptions } from '../trackOptions';
123+
99124
export default {
100125
name: 'Apple Music',
101126
props: {
@@ -116,7 +141,14 @@ export default {
116141
limit: this.songsLimit,
117142
offset: 0,
118143
language: 'en-US',
119-
storefrontInfo: null
144+
storefrontInfo: null,
145+
// search
146+
searchQuery: '',
147+
searchResults: [],
148+
searching: false,
149+
searchError: null,
150+
searchLimit: 10,
151+
_searchTimer: null
120152
};
121153
},
122154
@@ -138,6 +170,12 @@ export default {
138170
this.error = null;
139171
this.loading = false;
140172
this.storefrontInfo = null;
173+
174+
// clear search ui too
175+
this.searchQuery = '';
176+
this.searchResults = [];
177+
this.searching = false;
178+
this.searchError = null;
141179
}
142180
}
143181
},
@@ -148,9 +186,44 @@ export default {
148186
? 'Apple Music'
149187
: 'Authorize Apple Music'
150188
},
189+
190+
// map your searchResults into k-items
191+
searchResultItems() {
192+
const rawSf = (this.storefrontInfo?.id || this.storefront || 'us')
193+
.toString()
194+
.toLowerCase();
195+
const storefront = rawSf === 'auto' ? 'us' : rawSf;
196+
197+
return (this.searchResults || []).map((r) => {
198+
const base = {
199+
id: r.id,
200+
text: r.text,
201+
info: r.info,
202+
icon: 'music',
203+
...(r.image
204+
? { image: { src: r.image, ratio: '1/1', cover: true, back: 'pattern' } }
205+
: {}),
206+
...(r.link ? { link: r.link } : {})
207+
};
208+
209+
const appleMusicUrl = r?.id
210+
? `https://music.apple.com/${storefront}/song/${encodeURIComponent(r.id)}`
211+
: null;
212+
213+
const opts = makeTrackOptions({
214+
url: appleMusicUrl,
215+
onCopy: (text, msg = 'Link copied to clipboard') => this.copyToClipboard(text, msg),
216+
onEmbed: (url) => this.buildEmbedCode(url),
217+
onError: (msg) => this.notify('error', msg)
218+
});
219+
if (opts.length) base.options = opts;
220+
return base;
221+
});
222+
},
223+
151224
collectionItems() {
152225
return (this.items || []).map((item) => {
153-
const appleMusicUrl = this.trackUrl(item); // external Apple Music URL (may be null)
226+
const appleMusicUrl = this.trackUrl(item); // external apple music url if present
154227
const isInline = typeof item.id === 'string' && item.id.startsWith('i.');
155228
156229
const base = {
@@ -169,37 +242,15 @@ export default {
169242
...(isInline ? {} : { link: 'applemusic/song/' + item.id })
170243
};
171244
172-
// only add options if link is present
245+
// only add options if appleMusicUrl is present
173246
if (appleMusicUrl) {
174-
base.options = [
175-
{
176-
icon: 'headphones',
177-
text: 'Listen',
178-
click: () => {
179-
if (link) {
180-
window.open(link, '_blank');
181-
}
182-
}
183-
},
184-
'-',
185-
{
186-
icon: 'url',
187-
text: 'Copy Link',
188-
click: () => this.copyToClipboard(link, 'Link copied to clipboard')
189-
},
190-
{
191-
icon: 'code',
192-
text: 'Embed Code',
193-
click: () => {
194-
const iframe = this.buildEmbedCode(link);
195-
if (!iframe) {
196-
this.notify('error', 'Could not create embed code for this link');
197-
return;
198-
}
199-
this.copyToClipboard(iframe, 'Embed copied to clipboard')
200-
}
201-
}
202-
];
247+
const opts = makeTrackOptions({
248+
url: appleMusicUrl, // external apple music url
249+
onCopy: (text, msg = 'Link copied to clipboard') => this.copyToClipboard(text, msg),
250+
onEmbed: (url) => this.buildEmbedCode(url),
251+
onError: (msg) => this.notify('error', msg)
252+
});
253+
if (opts.length) base.options = opts;
203254
}
204255
205256
return base;
@@ -279,6 +330,52 @@ export default {
279330
},
280331
281332
methods: {
333+
// search pple music catalog
334+
onSearchInput(q) {
335+
this.searchQuery = q || '';
336+
clearTimeout(this._searchTimer);
337+
if (!this.searchQuery.trim()) {
338+
this.searchResults = [];
339+
this.searchError = null;
340+
return;
341+
}
342+
343+
// set a reasonable timeout
344+
this._searchTimer = setTimeout(() => this.performSearch(this.searchQuery), 400);
345+
},
346+
347+
async performSearch(q) {
348+
const term = (typeof q === 'string' ? q : this.searchQuery).trim();
349+
if (!term) return;
350+
try {
351+
this.searching = true;
352+
this.searchError = null;
353+
354+
const rawSf =
355+
(this.storefrontInfo?.id || this.storefront || 'us').toString().toLowerCase();
356+
const sf = rawSf === 'auto' ? 'us' : rawSf;
357+
358+
const qs = new URLSearchParams({
359+
q: term,
360+
limit: String(this.searchLimit),
361+
language: this.language,
362+
sf
363+
}).toString();
364+
365+
const res = await fetch(`/applemusic/search?${qs}`, { credentials: 'same-origin' });
366+
const data = await res.json();
367+
368+
if (!res.ok || !data.ok) throw new Error(data?.error || `HTTP ${res.status}`);
369+
this.searchResults = data.results;
370+
} catch (e) {
371+
this.searchResults = [];
372+
this.searchError = e?.message || 'Search failed';
373+
this.notify('error', this.searchError);
374+
} finally {
375+
this.searching = false;
376+
}
377+
},
378+
282379
// copy to clipboard
283380
async copyToClipboard(text, successMsg = 'Copied') {
284381
try {

src/trackOptions.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export function makeTrackOptions({ url, onCopy, onEmbed, onError }) {
2+
if (!url) return [];
3+
return [
4+
{
5+
icon: 'headphones',
6+
text: 'Listen',
7+
click: () => window.open(url, '_blank')
8+
},
9+
'-',
10+
{
11+
icon: 'url',
12+
text: 'Copy Link',
13+
click: () => onCopy?.(url)
14+
},
15+
{
16+
icon: 'code',
17+
text: 'Embed Code',
18+
click: () => {
19+
try {
20+
const embed = onEmbed?.(url);
21+
if (!embed) throw new Error('No embed');
22+
onCopy?.(embed, 'Embed copied to clipboard');
23+
} catch (e) {
24+
onError?.('Could not create embed code');
25+
}
26+
}
27+
}
28+
];
29+
}

0 commit comments

Comments
 (0)