Skip to content

Commit c0aee29

Browse files
authored
Merge pull request #21 from sjdonado/base64-id-ssr-link-preview
Base64 id ssr link preview
2 parents db81549 + f35960e commit c0aee29

25 files changed

+185
-162
lines changed

.github/workflows/tests.yml

-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
name: Run tests
22

33
on:
4-
push:
5-
branches: [master]
64
pull_request:
75
branches: [master]
86

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "idonthavespotify",
3-
"version": "1.2.0",
3+
"version": "1.2.1",
44
"scripts": {
55
"dev": "concurrently \"bun run build:dev\" \"bun run --watch www/bin.ts\"",
66
"build:dev": "vite build --mode=development --watch",

public/assets/entry.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/config/default.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,5 @@ export const metadata = {
3232

3333
export const cache = {
3434
databasePath: Bun.env.DATABASE_PATH!,
35-
expTime: 60 * 60 * 24, // 1 day in seconds
35+
expTime: 60 * 60 * 24 * 7, // 1 week in seconds
3636
};

src/parsers/link.ts

+14-15
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { NotFoundError, ParseError } from 'elysia';
1+
import { ParseError } from 'elysia';
22

33
import { SPOTIFY_LINK_REGEX, YOUTUBE_LINK_REGEX } from '~/config/constants';
44
import { ServiceType } from '~/config/enum';
5+
import { getSourceFromId } from '~/utils/encoding';
56

67
import { logger } from '~/utils/logger';
7-
import { cacheSearchService, getCachedSearchService } from '~/services/cache';
88

99
export type SearchService = {
1010
id: string;
@@ -13,41 +13,40 @@ export type SearchService = {
1313
};
1414

1515
export const getSearchService = async (link?: string, searchId?: string) => {
16-
const cached = searchId ? await getCachedSearchService(searchId) : null;
17-
if (cached) {
18-
logger.info(`[${getSearchService.name}] (${searchId}) cache hit`);
19-
return cached;
20-
}
16+
const decodedSource = searchId ? getSourceFromId(searchId) : undefined;
17+
18+
let source = link;
2119

22-
if (!link && searchId) {
23-
throw new NotFoundError('SearchId does not exist');
20+
if (searchId && decodedSource) {
21+
logger.info(
22+
`[${getSearchService.name}] (${searchId}) source decoded: ${decodedSource}`
23+
);
24+
source = decodedSource;
2425
}
2526

2627
let id, type;
2728

28-
const spotifyId = link!.match(SPOTIFY_LINK_REGEX)?.[3];
29+
const spotifyId = source!.match(SPOTIFY_LINK_REGEX)?.[3];
2930
if (spotifyId) {
3031
id = spotifyId;
3132
type = ServiceType.Spotify;
3233
}
3334

34-
const youtubeId = link!.match(YOUTUBE_LINK_REGEX)?.[1];
35+
const youtubeId = source!.match(YOUTUBE_LINK_REGEX)?.[1];
3536
if (youtubeId) {
3637
id = youtubeId;
3738
type = ServiceType.YouTube;
3839
}
3940

4041
if (!id || !type) {
41-
throw new ParseError('Service id could not be extracted from link.');
42+
throw new ParseError('Service id could not be extracted from source.');
4243
}
4344

4445
const searchService = {
4546
id,
4647
type,
47-
source: link,
48+
source,
4849
} as SearchService;
4950

50-
await cacheSearchService(id, searchService);
51-
5251
return searchService;
5352
};

src/routes/page.tsx

+30-11
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { ERROR_CODE, Elysia, ValidationError, redirect } from 'elysia';
1+
import { Elysia } from 'elysia';
22

33
import { logger } from '~/utils/logger';
44

5-
import { searchPayloadValidator } from '~/validations/search';
5+
import { searchPayloadValidator, searchQueryValidator } from '~/validations/search';
66

77
import { search } from '~/services/search';
88

@@ -32,17 +32,36 @@ export const pageRouter = new Elysia()
3232

3333
return <ErrorMessage message="Something went wrong, try again later." />;
3434
})
35-
.get('/', async () => {
36-
return (
37-
<MainLayout>
38-
<Home />
39-
</MainLayout>
40-
);
41-
})
35+
.get(
36+
'/',
37+
async ({ query: { id }, redirect }) => {
38+
try {
39+
const searchResult = id ? await search(undefined, id) : undefined;
40+
41+
return (
42+
<MainLayout
43+
title={searchResult?.title}
44+
description={searchResult?.description}
45+
image={searchResult?.image}
46+
>
47+
<Home source={searchResult?.source}>
48+
{searchResult && <SearchCard searchResult={searchResult} />}
49+
</Home>
50+
</MainLayout>
51+
);
52+
} catch (err) {
53+
logger.error(err);
54+
return redirect('/', 404);
55+
}
56+
},
57+
{
58+
query: searchQueryValidator,
59+
}
60+
)
4261
.post(
4362
'/search',
44-
async ({ body: { link, searchId } }) => {
45-
const searchResult = await search(link, searchId);
63+
async ({ body: { link } }) => {
64+
const searchResult = await search(link);
4665
return <SearchCard searchResult={searchResult} />;
4766
},
4867
{

src/services/cache.ts

-10
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import * as config from '~/config/default';
33
const sqliteStore = require('cache-manager-sqlite');
44
const cacheManager = require('cache-manager');
55

6-
import { SearchService } from '~/parsers/link';
7-
86
import { SearchMetadata, SearchResultLink } from './search';
97

108
export const cacheStore = cacheManager.caching({
@@ -13,14 +11,6 @@ export const cacheStore = cacheManager.caching({
1311
path: config.cache.databasePath,
1412
});
1513

16-
export const cacheSearchService = async (id: string, searchService: SearchService) => {
17-
await cacheStore.set(`service:${id}`, searchService);
18-
};
19-
20-
export const getCachedSearchService = async (id: string) => {
21-
return cacheStore.get(`service:${id}`) as SearchService;
22-
};
23-
2414
export const cacheSearchResultLink = async (
2515
url: URL,
2616
searchResultLink: SearchResultLink

src/services/search.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getDeezerLink } from '~/adapters/deezer';
1212
import { getSoundCloudLink } from '~/adapters/soundcloud';
1313
import { getTidalLink } from '~/adapters/tidal';
1414
import { getSpotifyLink } from '~/adapters/spotify';
15+
import { generateId } from '~/utils/encoding';
1516

1617
export type SearchMetadata = {
1718
title: string;
@@ -80,7 +81,7 @@ export const search = async (link?: string, searchId?: string) => {
8081
}
8182

8283
const searchResult: SearchResult = {
83-
id: searchService.id,
84+
id: generateId(searchService.source),
8485
type: metadata.type,
8586
title: metadata.title,
8687
description: metadata.description,

src/utils/encoding.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export const generateId = (source: string) => {
2+
const parsedUrl = new URL(source);
3+
4+
// Remove the protocol
5+
const hostname = parsedUrl.hostname;
6+
const pathname = parsedUrl.pathname;
7+
const search = parsedUrl.search || '';
8+
9+
// Get the first query parameter
10+
const queryParams = new URLSearchParams(search);
11+
const firstParam = queryParams.entries().next().value;
12+
13+
let idString = `${hostname}${pathname}`;
14+
if (firstParam) {
15+
idString += `?${firstParam[0]}=${firstParam[1]}`;
16+
}
17+
18+
return encodeURIComponent(Buffer.from(idString).toString('base64'));
19+
};
20+
21+
export const getSourceFromId = (id: string) => {
22+
const decoded = decodeURIComponent(Buffer.from(id, 'base64').toString('utf8'));
23+
24+
return `https://${decoded}`;
25+
};

src/validations/search.ts

+12-20
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,18 @@
11
import { t } from 'elysia';
22
import { SPOTIFY_LINK_REGEX, YOUTUBE_LINK_REGEX } from '~/config/constants';
33

4-
export const searchPayloadValidator = t.Union(
5-
[
6-
t.Object({
7-
link: t.RegExp(
8-
new RegExp(`${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}`),
9-
{
10-
error: 'Invalid link, please try again or open an issue on Github.',
11-
}
12-
),
13-
searchId: t.Optional(t.String()),
14-
}),
15-
t.Object({
16-
link: t.Optional(t.String()),
17-
searchId: t.String({ error: 'Invalid searchId' }),
18-
}),
19-
],
20-
{
21-
error: 'Invalid link, please try with Spotify or Youtube links.',
22-
}
23-
);
4+
export const searchQueryValidator = t.Object({
5+
id: t.Optional(t.String({ minLength: 1, error: 'Invalid search id' })),
6+
});
7+
8+
export const searchPayloadValidator = t.Object({
9+
link: t.RegExp(
10+
new RegExp(`${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}`),
11+
{
12+
error: 'Invalid link, please try with Spotify or Youtube links.',
13+
}
14+
),
15+
});
2416

2517
export const apiVersionValidator = t.Object({
2618
v: t.String({

src/views/components/search-bar.tsx

+28-32
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,32 @@
1-
export default function SearchBar() {
1+
export default function SearchBar({ source }: { source?: string }) {
22
return (
3-
<>
4-
<form
5-
id="search-form"
6-
hx-post="/search"
7-
hx-target="#search-results"
8-
hx-swap="innerHTML"
9-
hx-indicator="#loading-indicator"
10-
hx-request='\"timeout\":6000'
11-
class="flex w-full max-w-3xl items-center justify-center"
3+
<form
4+
id="search-form"
5+
hx-post="/search"
6+
hx-target="#search-results"
7+
hx-swap="innerHTML"
8+
hx-indicator="#loading-indicator"
9+
hx-request='\"timeout\":6000'
10+
class="flex w-full max-w-3xl items-center justify-center px-2"
11+
>
12+
<label for="song-link" class="sr-only">
13+
Search
14+
</label>
15+
<input
16+
type="text"
17+
id="song-link"
18+
name="link"
19+
class="flex-1 rounded-lg border bg-white p-2.5 text-sm font-normal text-black placeholder:text-gray-400 lg:text-base"
20+
placeholder="https://open.spotify.com/track/7A8MwSsu9efJXP6xvZfRN3?si=d4f1e2eb324c43df"
21+
value={source}
22+
/>
23+
<button
24+
type="submit"
25+
class="ml-2 rounded-lg border border-green-500 bg-green-500 p-2.5 text-sm font-medium text-white focus:outline-none focus:ring-1 focus:ring-white"
1226
>
13-
<label for="song-link" class="sr-only">
14-
Search
15-
</label>
16-
<input
17-
type="text"
18-
id="song-link"
19-
name="link"
20-
class="flex-1 rounded-lg border bg-white p-2.5 text-sm font-normal text-black placeholder:text-gray-400 lg:text-base"
21-
placeholder="https://open.spotify.com/track/7A8MwSsu9efJXP6xvZfRN3?si=d4f1e2eb324c43df"
22-
/>
23-
<button
24-
type="submit"
25-
class="ml-2 rounded-lg border border-green-500 bg-green-500 p-2.5 text-sm font-medium text-white focus:outline-none focus:ring-1 focus:ring-white"
26-
>
27-
<i class="fas fa-search p-1 text-black" />
28-
<span class="sr-only">Search</span>
29-
</button>
30-
</form>
31-
<div class="my-4">
32-
<div id="search-results" />
33-
</div>
34-
</>
27+
<i class="fas fa-search p-1 text-black" />
28+
<span class="sr-only">Search</span>
29+
</button>
30+
</form>
3531
);
3632
}

src/views/components/search-card.tsx

+7-5
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ export default function SearchCard(props: { searchResult: SearchResult }) {
99
data-id={props.searchResult.id}
1010
class="m-4 flex max-w-2xl flex-wrap items-start justify-center rounded-lg border border-white md:p-4"
1111
>
12-
<img
13-
class="m-4 w-48"
14-
src={props.searchResult.image}
15-
alt={props.searchResult.title}
16-
/>
12+
<div class="w-full m-4 md:w-44">
13+
<img
14+
class="mx-auto w-28 md:w-44"
15+
src={props.searchResult.image}
16+
alt={props.searchResult.title}
17+
/>
18+
</div>
1719
<div class="mb-2 flex-1 flex-col items-start p-2 md:mr-6">
1820
<div class="mb-2 hyphens-auto text-center text-2xl font-normal md:text-start">
1921
{props.searchResult.title}

src/views/components/search-link.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const SEARCH_LINK_DICT = {
77
},
88
[ServiceType.YouTube]: {
99
icon: 'fab fa-youtube',
10-
label: 'Listen on YouTube',
10+
label: 'Listen on YouTube Music',
1111
},
1212
[ServiceType.Deezer]: {
1313
icon: 'fab fa-deezer',

src/views/css/index.css

-4
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@ html {
1616
animation: loading 2s linear infinite;
1717
}
1818

19-
.min-screen-3 {
20-
min-height: calc(100vh - 3rem);
21-
}
22-
2319
@keyframes loading {
2420
0% {
2521
left: -100%;

src/views/js/entry.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './search-bar';

src/views/js/search-bar.js

+2-12
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ const getSpotifyLinkFromClipboard = async () => {
1717
if (
1818
clipboardText.match(
1919
new RegExp(`${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}`)
20-
)
20+
) &&
21+
!searchParams.get('id')
2122
) {
2223
submitSearch({ link: clipboardText });
2324
}
@@ -43,16 +44,5 @@ document.addEventListener('htmx:timeout', function () {
4344
});
4445

4546
document.addEventListener('DOMContentLoaded', async () => {
46-
const searchId = searchParams.get('id');
47-
if (searchId) {
48-
htmx.ajax('POST', '/search', {
49-
source: '#search-form',
50-
values: {
51-
searchId,
52-
},
53-
});
54-
return;
55-
}
56-
5747
await getSpotifyLinkFromClipboard();
5848
});

0 commit comments

Comments
 (0)