Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Base64 id ssr link preview #21

Merged
merged 11 commits into from
Jul 10, 2024
2 changes: 0 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
name: Run tests

on:
push:
branches: [master]
pull_request:
branches: [master]

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "idonthavespotify",
"version": "1.2.0",
"version": "1.2.1",
"scripts": {
"dev": "concurrently \"bun run build:dev\" \"bun run --watch www/bin.ts\"",
"build:dev": "vite build --mode=development --watch",
Expand Down
1 change: 1 addition & 0 deletions public/assets/entry.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/config/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ export const metadata = {

export const cache = {
databasePath: Bun.env.DATABASE_PATH!,
expTime: 60 * 60 * 24, // 1 day in seconds
expTime: 60 * 60 * 24 * 7, // 1 week in seconds
};
29 changes: 14 additions & 15 deletions src/parsers/link.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { NotFoundError, ParseError } from 'elysia';
import { ParseError } from 'elysia';

import { SPOTIFY_LINK_REGEX, YOUTUBE_LINK_REGEX } from '~/config/constants';
import { ServiceType } from '~/config/enum';
import { getSourceFromId } from '~/utils/encoding';

import { logger } from '~/utils/logger';
import { cacheSearchService, getCachedSearchService } from '~/services/cache';

export type SearchService = {
id: string;
Expand All @@ -13,41 +13,40 @@ export type SearchService = {
};

export const getSearchService = async (link?: string, searchId?: string) => {
const cached = searchId ? await getCachedSearchService(searchId) : null;
if (cached) {
logger.info(`[${getSearchService.name}] (${searchId}) cache hit`);
return cached;
}
const decodedSource = searchId ? getSourceFromId(searchId) : undefined;

let source = link;

if (!link && searchId) {
throw new NotFoundError('SearchId does not exist');
if (searchId && decodedSource) {
logger.info(
`[${getSearchService.name}] (${searchId}) source decoded: ${decodedSource}`
);
source = decodedSource;
}

let id, type;

const spotifyId = link!.match(SPOTIFY_LINK_REGEX)?.[3];
const spotifyId = source!.match(SPOTIFY_LINK_REGEX)?.[3];
if (spotifyId) {
id = spotifyId;
type = ServiceType.Spotify;
}

const youtubeId = link!.match(YOUTUBE_LINK_REGEX)?.[1];
const youtubeId = source!.match(YOUTUBE_LINK_REGEX)?.[1];
if (youtubeId) {
id = youtubeId;
type = ServiceType.YouTube;
}

if (!id || !type) {
throw new ParseError('Service id could not be extracted from link.');
throw new ParseError('Service id could not be extracted from source.');
}

const searchService = {
id,
type,
source: link,
source,
} as SearchService;

await cacheSearchService(id, searchService);

return searchService;
};
41 changes: 30 additions & 11 deletions src/routes/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ERROR_CODE, Elysia, ValidationError, redirect } from 'elysia';
import { Elysia } from 'elysia';

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

import { searchPayloadValidator } from '~/validations/search';
import { searchPayloadValidator, searchQueryValidator } from '~/validations/search';

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

Expand Down Expand Up @@ -32,17 +32,36 @@ export const pageRouter = new Elysia()

return <ErrorMessage message="Something went wrong, try again later." />;
})
.get('/', async () => {
return (
<MainLayout>
<Home />
</MainLayout>
);
})
.get(
'/',
async ({ query: { id }, redirect }) => {
try {
const searchResult = id ? await search(undefined, id) : undefined;

return (
<MainLayout
title={searchResult?.title}
description={searchResult?.description}
image={searchResult?.image}
>
<Home source={searchResult?.source}>
{searchResult && <SearchCard searchResult={searchResult} />}
</Home>
</MainLayout>
);
} catch (err) {
logger.error(err);
return redirect('/', 404);
}
},
{
query: searchQueryValidator,
}
)
.post(
'/search',
async ({ body: { link, searchId } }) => {
const searchResult = await search(link, searchId);
async ({ body: { link } }) => {
const searchResult = await search(link);
return <SearchCard searchResult={searchResult} />;
},
{
Expand Down
10 changes: 0 additions & 10 deletions src/services/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import * as config from '~/config/default';
const sqliteStore = require('cache-manager-sqlite');
const cacheManager = require('cache-manager');

import { SearchService } from '~/parsers/link';

import { SearchMetadata, SearchResultLink } from './search';

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

export const cacheSearchService = async (id: string, searchService: SearchService) => {
await cacheStore.set(`service:${id}`, searchService);
};

export const getCachedSearchService = async (id: string) => {
return cacheStore.get(`service:${id}`) as SearchService;
};

export const cacheSearchResultLink = async (
url: URL,
searchResultLink: SearchResultLink
Expand Down
3 changes: 2 additions & 1 deletion src/services/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getDeezerLink } from '~/adapters/deezer';
import { getSoundCloudLink } from '~/adapters/soundcloud';
import { getTidalLink } from '~/adapters/tidal';
import { getSpotifyLink } from '~/adapters/spotify';
import { generateId } from '~/utils/encoding';

export type SearchMetadata = {
title: string;
Expand Down Expand Up @@ -80,7 +81,7 @@ export const search = async (link?: string, searchId?: string) => {
}

const searchResult: SearchResult = {
id: searchService.id,
id: generateId(searchService.source),
type: metadata.type,
title: metadata.title,
description: metadata.description,
Expand Down
25 changes: 25 additions & 0 deletions src/utils/encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const generateId = (source: string) => {
const parsedUrl = new URL(source);

// Remove the protocol
const hostname = parsedUrl.hostname;
const pathname = parsedUrl.pathname;
const search = parsedUrl.search || '';

// Get the first query parameter
const queryParams = new URLSearchParams(search);
const firstParam = queryParams.entries().next().value;

let idString = `${hostname}${pathname}`;
if (firstParam) {
idString += `?${firstParam[0]}=${firstParam[1]}`;
}

return encodeURIComponent(Buffer.from(idString).toString('base64'));
};

export const getSourceFromId = (id: string) => {
const decoded = decodeURIComponent(Buffer.from(id, 'base64').toString('utf8'));

return `https://${decoded}`;
};
32 changes: 12 additions & 20 deletions src/validations/search.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
import { t } from 'elysia';
import { SPOTIFY_LINK_REGEX, YOUTUBE_LINK_REGEX } from '~/config/constants';

export const searchPayloadValidator = t.Union(
[
t.Object({
link: t.RegExp(
new RegExp(`${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}`),
{
error: 'Invalid link, please try again or open an issue on Github.',
}
),
searchId: t.Optional(t.String()),
}),
t.Object({
link: t.Optional(t.String()),
searchId: t.String({ error: 'Invalid searchId' }),
}),
],
{
error: 'Invalid link, please try with Spotify or Youtube links.',
}
);
export const searchQueryValidator = t.Object({
id: t.Optional(t.String({ minLength: 1, error: 'Invalid search id' })),
});

export const searchPayloadValidator = t.Object({
link: t.RegExp(
new RegExp(`${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}`),
{
error: 'Invalid link, please try with Spotify or Youtube links.',
}
),
});

export const apiVersionValidator = t.Object({
v: t.String({
Expand Down
60 changes: 28 additions & 32 deletions src/views/components/search-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,32 @@
export default function SearchBar() {
export default function SearchBar({ source }: { source?: string }) {
return (
<>
<form
id="search-form"
hx-post="/search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-indicator="#loading-indicator"
hx-request='\"timeout\":6000'
class="flex w-full max-w-3xl items-center justify-center"
<form
id="search-form"
hx-post="/search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-indicator="#loading-indicator"
hx-request='\"timeout\":6000'
class="flex w-full max-w-3xl items-center justify-center px-2"
>
<label for="song-link" class="sr-only">
Search
</label>
<input
type="text"
id="song-link"
name="link"
class="flex-1 rounded-lg border bg-white p-2.5 text-sm font-normal text-black placeholder:text-gray-400 lg:text-base"
placeholder="https://open.spotify.com/track/7A8MwSsu9efJXP6xvZfRN3?si=d4f1e2eb324c43df"
value={source}
/>
<button
type="submit"
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"
>
<label for="song-link" class="sr-only">
Search
</label>
<input
type="text"
id="song-link"
name="link"
class="flex-1 rounded-lg border bg-white p-2.5 text-sm font-normal text-black placeholder:text-gray-400 lg:text-base"
placeholder="https://open.spotify.com/track/7A8MwSsu9efJXP6xvZfRN3?si=d4f1e2eb324c43df"
/>
<button
type="submit"
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"
>
<i class="fas fa-search p-1 text-black" />
<span class="sr-only">Search</span>
</button>
</form>
<div class="my-4">
<div id="search-results" />
</div>
</>
<i class="fas fa-search p-1 text-black" />
<span class="sr-only">Search</span>
</button>
</form>
);
}
12 changes: 7 additions & 5 deletions src/views/components/search-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ export default function SearchCard(props: { searchResult: SearchResult }) {
data-id={props.searchResult.id}
class="m-4 flex max-w-2xl flex-wrap items-start justify-center rounded-lg border border-white md:p-4"
>
<img
class="m-4 w-48"
src={props.searchResult.image}
alt={props.searchResult.title}
/>
<div class="w-full m-4 md:w-44">
<img
class="mx-auto w-28 md:w-44"
src={props.searchResult.image}
alt={props.searchResult.title}
/>
</div>
<div class="mb-2 flex-1 flex-col items-start p-2 md:mr-6">
<div class="mb-2 hyphens-auto text-center text-2xl font-normal md:text-start">
{props.searchResult.title}
Expand Down
2 changes: 1 addition & 1 deletion src/views/components/search-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const SEARCH_LINK_DICT = {
},
[ServiceType.YouTube]: {
icon: 'fab fa-youtube',
label: 'Listen on YouTube',
label: 'Listen on YouTube Music',
},
[ServiceType.Deezer]: {
icon: 'fab fa-deezer',
Expand Down
4 changes: 0 additions & 4 deletions src/views/css/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ html {
animation: loading 2s linear infinite;
}

.min-screen-3 {
min-height: calc(100vh - 3rem);
}

@keyframes loading {
0% {
left: -100%;
Expand Down
1 change: 1 addition & 0 deletions src/views/js/entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './search-bar';
14 changes: 2 additions & 12 deletions src/views/js/search-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const getSpotifyLinkFromClipboard = async () => {
if (
clipboardText.match(
new RegExp(`${SPOTIFY_LINK_REGEX.source}|${YOUTUBE_LINK_REGEX.source}`)
)
) &&
!searchParams.get('id')
) {
submitSearch({ link: clipboardText });
}
Expand All @@ -43,16 +44,5 @@ document.addEventListener('htmx:timeout', function () {
});

document.addEventListener('DOMContentLoaded', async () => {
const searchId = searchParams.get('id');
if (searchId) {
htmx.ajax('POST', '/search', {
source: '#search-form',
values: {
searchId,
},
});
return;
}

await getSpotifyLinkFromClipboard();
});
Loading
Loading