Skip to content

Commit

Permalink
Merge pull request #21 from sjdonado/base64-id-ssr-link-preview
Browse files Browse the repository at this point in the history
Base64 id ssr link preview
  • Loading branch information
sjdonado authored Jul 10, 2024
2 parents db81549 + f35960e commit c0aee29
Show file tree
Hide file tree
Showing 25 changed files with 185 additions and 162 deletions.
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

0 comments on commit c0aee29

Please sign in to comment.