From 52d964d3c8df14660b028b8522316bddc69e80d6 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Fri, 20 Dec 2024 10:06:08 -0500 Subject: [PATCH 1/5] feat: get public api endpoint --- .env.example | 27 ------------- .env.test | 2 + src/config/env.ts | 3 +- src/routes/api.ts | 16 ++++++-- src/routes/page.tsx | 6 +-- src/utils/url-shortener.ts | 4 +- src/validations/search.ts | 77 ++++++++++++++++++++++++++------------ tests/utils/shared.ts | 2 +- 8 files changed, 77 insertions(+), 60 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index 049c3d5..0000000 --- a/.env.example +++ /dev/null @@ -1,27 +0,0 @@ -LOG_LEVEL=debug - -APP_URL=http://localhost:3000 -DATABASE_PATH=./sqlite/db.sqlite - -SPOTIFY_API_URL=https://api.spotify.com/v1/search -SPOTIFY_AUTH_URL=https://accounts.spotify.com/api/token -SPOTIFY_CLIENT_ID= -SPOTIFY_CLIENT_SECRET= -SPOTIFY_CLIENT_VERSION= - -TIDAL_BASE_URL=https://tidal.com/browse -TIDAL_API_URL=https://openapi.tidal.com/v2/searchresults -TIDAL_AUTH_URL=https://auth.tidal.com/v1/oauth2/token -TIDAL_CLIENT_ID= -TIDAL_CLIENT_SECRET= - -YOUTUBE_API_URL=https://content-youtube.googleapis.com/youtube/v3/search -YOUTUBE_MUSIC_BASE_URL=https://music.youtube.com -YOUTUBE_API_KEY= - -DEEZER_API_URL=https://api.deezer.com/search -APPLE_MUSIC_API_URL=https://music.apple.com/us -SOUNDCLOUD_BASE_URL=https://soundcloud.com - -URL_SHORTENER_API_URL=http://localhost:4000/api/links -URL_SHORTENER_API_KEY=secure_12345 diff --git a/.env.test b/.env.test index 20fa584..ffeeea3 100644 --- a/.env.test +++ b/.env.test @@ -25,3 +25,5 @@ SOUNDCLOUD_BASE_URL=https://soundcloud.com URL_SHORTENER_API_URL=http://localhost:4000/api/links URL_SHORTENER_API_KEY=url_shortener_api_key + +IDHS_API_KEY_BETA=idhs_api_key diff --git a/src/config/env.ts b/src/config/env.ts index 41dccf6..4285c64 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -31,7 +31,7 @@ export const ENV = { baseUrl: Bun.env.SOUNDCLOUD_BASE_URL!, }, }, - utils: { + services: { urlShortener: { apiUrl: Bun.env.URL_SHORTENER_API_URL!, apiKey: Bun.env.URL_SHORTENER_API_KEY!, @@ -40,6 +40,7 @@ export const ENV = { app: { url: Bun.env.APP_URL!, version: version, + apiKeyBeta: Bun.env.IDHS_API_KEY_BETA!, }, cache: { databasePath: Bun.env.DATABASE_PATH ?? ':memory:', diff --git a/src/routes/api.ts b/src/routes/api.ts index a53db65..77d5d0c 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -3,7 +3,7 @@ import { Elysia } from 'elysia'; import { Adapter } from '~/config/enum'; import { search } from '~/services/search'; import { logger } from '~/utils/logger'; -import { apiVersionValidator, searchPayloadValidator } from '~/validations/search'; +import { apiV2Validator, legacyApiV1Validator } from '~/validations/search'; export const apiRouter = new Elysia().group('/api', app => app @@ -14,6 +14,16 @@ export const apiRouter = new Elysia().group('/api', app => message: error.message, }; }) + .get( + '/search', + async ({ query: { link, adapters } }) => { + const searchResult = await search({ link, adapters: adapters as Adapter[] }); + return searchResult; + }, + { + query: apiV2Validator.query, + } + ) .post( '/search', async ({ body: { link, adapters } }) => { @@ -21,8 +31,8 @@ export const apiRouter = new Elysia().group('/api', app => return searchResult; }, { - body: searchPayloadValidator, - query: apiVersionValidator, + query: legacyApiV1Validator.query, + body: legacyApiV1Validator.body, } ) ); diff --git a/src/routes/page.tsx b/src/routes/page.tsx index 622d499..ec425d2 100644 --- a/src/routes/page.tsx +++ b/src/routes/page.tsx @@ -3,7 +3,7 @@ import { Elysia, InternalServerError } from 'elysia'; import { search } from '~/services/search'; import { logger } from '~/utils/logger'; -import { searchPayloadValidator, searchQueryValidator } from '~/validations/search'; +import { legacyApiV1Validator, webValidator } from '~/validations/search'; import ErrorMessage from '~/views/components/error-message'; import SearchCard from '~/views/components/search-card'; import MainLayout from '~/views/layouts/main'; @@ -61,7 +61,7 @@ export const pageRouter = new Elysia() } }, { - query: searchQueryValidator, + query: webValidator.query, } ) .post( @@ -71,6 +71,6 @@ export const pageRouter = new Elysia() return ; }, { - body: searchPayloadValidator, + body: legacyApiV1Validator.body, } ); diff --git a/src/utils/url-shortener.ts b/src/utils/url-shortener.ts index 275c39a..d53b508 100644 --- a/src/utils/url-shortener.ts +++ b/src/utils/url-shortener.ts @@ -23,14 +23,14 @@ export async function shortenLink(link: string) { try { const response = await HttpClient.post( - ENV.utils.urlShortener.apiUrl, + ENV.services.urlShortener.apiUrl, { url: link, }, { headers: { 'Content-Type': 'application/json', - 'X-Api-Key': ENV.utils.urlShortener.apiKey, + 'X-Api-Key': ENV.services.urlShortener.apiKey, }, } ); diff --git a/src/validations/search.ts b/src/validations/search.ts index e719371..7e0108c 100644 --- a/src/validations/search.ts +++ b/src/validations/search.ts @@ -2,33 +2,64 @@ import { t } from 'elysia'; import { ALLOWED_LINKS_REGEX } from '~/config/constants'; import { Adapter } from '~/config/enum'; +import { ENV } from '~/config/env'; const allowedAdapters = Object.values(Adapter); -export const searchQueryValidator = t.Object({ - id: t.Optional(t.String({ minLength: 1, error: 'Invalid search id' })), -}); +export const webValidator = { + query: t.Object({ + id: t.Optional(t.String({ minLength: 1, error: 'Invalid search id' })), + }), +}; -export const searchPayloadValidator = t.Object({ - link: t.RegExp(new RegExp(ALLOWED_LINKS_REGEX), { - error: 'Invalid link, please try with Spotify or Youtube links.', +export const legacyApiV1Validator = { + query: t.Object({ + v: t.String({ + pattern: '1', + error: 'Unsupported API version', + }), + }), + body: t.Object({ + link: t.RegExp(new RegExp(ALLOWED_LINKS_REGEX), { + error: 'Invalid link, please try with Spotify or Youtube links.', + }), + adapters: t.Optional( + t.Array( + t.String({ + validate: (value: string) => allowedAdapters.includes(value as Adapter), + error: 'Invalid adapter, please use one of the allowed adapters.', + }), + { + error: 'Invalid adapters array, please provide an array of adapter types.', + } + ) + ), }), - adapters: t.Optional( - t.Array( - t.String({ - validate: (value: string) => allowedAdapters.includes(value as Adapter), - error: 'Invalid adapter, please use one of the allowed adapters.', - }), - { - error: 'Invalid adapters array, please provide an array of adapter types.', - } - ) - ), -}); +}; -export const apiVersionValidator = t.Object({ - v: t.String({ - pattern: '1', - error: 'Unsupported API version', +export const apiV2Validator = { + query: t.Object({ + link: t.RegExp(new RegExp(ALLOWED_LINKS_REGEX), { + error: 'Invalid link, please try with Spotify or Youtube links.', + }), + adapters: t.Optional( + t.Array( + t.String({ + validate: (value: string) => allowedAdapters.includes(value as Adapter), + error: 'Invalid adapter, please use one of the allowed adapters.', + }), + { + error: 'Invalid adapters array, please provide an array of adapter types.', + } + ) + ), + key: t.String({ + validate: (value: string) => value === ENV.app.apiKeyBeta, + error: 'Invalid API key. Request one from the administrator.', + }), + v: t.String({ + pattern: '2', + error: 'Unsupported API version', + }), }), -}); +}; diff --git a/tests/utils/shared.ts b/tests/utils/shared.ts index 9625e8c..8aeaf48 100644 --- a/tests/utils/shared.ts +++ b/tests/utils/shared.ts @@ -6,7 +6,7 @@ import { ENV } from '~/config/env'; export const API_ENDPOINT = 'http://localhost/api'; export const API_SEARCH_ENDPOINT = `${API_ENDPOINT}/search?v=1`; -export const urlShortenerLink = ENV.utils.urlShortener.apiUrl; +export const urlShortenerLink = ENV.services.urlShortener.apiUrl; export const urlShortenerResponseMock = { data: { id: '6ce3f2d6-d73c-4c7f-b622-3b30e34d70dd', From 255b04fc11a58f609bb99bfda03efa72a71af4dd Mon Sep 17 00:00:00 2001 From: sjdonado Date: Fri, 20 Dec 2024 11:49:02 -0500 Subject: [PATCH 2/5] fix: private adapters variable schema transform --- src/routes/api.ts | 22 +++++++++++++--------- src/services/search.ts | 29 ++++++++++++++++------------- src/validations/search.ts | 19 ++++++++++++++----- vite.config.js | 3 +-- 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index 77d5d0c..689c419 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -14,25 +14,29 @@ export const apiRouter = new Elysia().group('/api', app => message: error.message, }; }) - .get( - '/search', - async ({ query: { link, adapters } }) => { + .post( + '/search', // TODO: remove after new Raycast version is released + async ({ body: { link, adapters } }) => { const searchResult = await search({ link, adapters: adapters as Adapter[] }); return searchResult; }, { - query: apiV2Validator.query, + query: legacyApiV1Validator.query, + body: legacyApiV1Validator.body, } ) - .post( + .get( '/search', - async ({ body: { link, adapters } }) => { - const searchResult = await search({ link, adapters: adapters as Adapter[] }); + async ({ query }) => { + const searchResult = await search({ + link: query.link, + adapters: query._adapters as Adapter[], + }); return searchResult; }, { - query: legacyApiV1Validator.query, - body: legacyApiV1Validator.body, + query: apiV2Validator.query, + transform: apiV2Validator.transform, } ) ); diff --git a/src/services/search.ts b/src/services/search.ts index 7b1a1b8..d3bb9cc 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -61,6 +61,8 @@ export const search = async ({ searchId?: string; adapters?: Adapter[]; }) => { + const searchParser = getSearchParser(link, searchId); + const searchAdapters = adapters ?? [ Adapter.Spotify, Adapter.YouTube, @@ -70,7 +72,7 @@ export const search = async ({ Adapter.Tidal, ]; - const searchParser = getSearchParser(link, searchId); + logger.info(`[search] searchAdapters: ${searchAdapters}`); const metadataFetchersMap = { [Parser.Spotify]: getSpotifyMetadata, @@ -146,7 +148,7 @@ export const search = async ({ const existingAdapters = new Set(links.map(link => link.type)); let tidalLink: SearchResultLink | null = linkSearchResult; - if (parserType !== Adapter.Tidal) { + if (searchAdapters.includes(Adapter.Tidal) && parserType !== Adapter.Tidal) { tidalLink = await getTidalLink(query, metadata); existingAdapters.add(Adapter.Tidal); } @@ -171,6 +173,7 @@ export const search = async ({ if (fromTidalULink) { for (const adapterKey in fromTidalULink) { const adapter = adapterKey as Adapter; + // Only add the adapter if it's requested and not the parser type if (parserType !== adapter && fromTidalULink[adapter]) { links.push(fromTidalULink[adapter]); existingAdapters.add(adapter); @@ -213,16 +216,6 @@ export const search = async ({ ]); metadata = updatedMetadata; - links.sort((a, b) => { - // Prioritize verified links - if (a.isVerified && !b.isVerified) return -1; - if (!a.isVerified && b.isVerified) return 1; - - return a.type.localeCompare(b.type); - }); - - logger.info(`[${search.name}] (results) ${links.map(link => link?.url)}`); - const searchResult: SearchResult = { id, type: metadata.type, @@ -232,8 +225,18 @@ export const search = async ({ audio: metadata.audio, source: searchParser.source, universalLink: shortLink, - links, + links: links + .filter(link => searchAdapters.includes(link.type)) + .sort((a, b) => { + // Prioritize verified links + if (a.isVerified && !b.isVerified) return -1; + if (!a.isVerified && b.isVerified) return 1; + + return a.type.localeCompare(b.type); + }), }; + logger.info(`[${search.name}] (results) ${searchResult.links.map(link => link?.url)}`); + return searchResult; }; diff --git a/src/validations/search.ts b/src/validations/search.ts index 7e0108c..e9edcfb 100644 --- a/src/validations/search.ts +++ b/src/validations/search.ts @@ -1,4 +1,4 @@ -import { t } from 'elysia'; +import { InternalServerError, t, ValidationError } from 'elysia'; import { ALLOWED_LINKS_REGEX } from '~/config/constants'; import { Adapter } from '~/config/enum'; @@ -43,14 +43,16 @@ export const apiV2Validator = { error: 'Invalid link, please try with Spotify or Youtube links.', }), adapters: t.Optional( + t.String({ + error: 'Invalid adapters array, please provide a valid value.', + }) + ), + _adapters: t.Optional( t.Array( t.String({ validate: (value: string) => allowedAdapters.includes(value as Adapter), error: 'Invalid adapter, please use one of the allowed adapters.', - }), - { - error: 'Invalid adapters array, please provide an array of adapter types.', - } + }) ) ), key: t.String({ @@ -62,4 +64,11 @@ export const apiV2Validator = { error: 'Unsupported API version', }), }), + transform: ({ query }: { query: { adapters?: string; _adapters?: string[] } }) => { + if (query.adapters) { + if (typeof query.adapters === 'string') { + query._adapters = query.adapters.split(',').map(adapter => adapter.trim()); + } + } + }, }; diff --git a/vite.config.js b/vite.config.js index bc58d2a..c4c0b36 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,6 @@ import path from 'path'; - -import { defineConfig } from 'vite'; import copy from 'rollup-plugin-copy'; +import { defineConfig } from 'vite'; export default defineConfig({ plugins: [ From 78a5a6f281f13a1bae9c29ebbf166941ac3360e1 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Fri, 20 Dec 2024 11:50:18 -0500 Subject: [PATCH 3/5] chore: update README and version --- README.md | 9 ++++++++- package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f5c421f..4993d7e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,14 @@ Adapters represent the streaming services supported by the Web App and the Rayca ## Local Setup (Web App) -The list of environment variables is available in `.env.example`. To complete the values for `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` refer to https://developer.spotify.com/documentation/web-api, other variables related to cookies can be extracted from your browser. +The list of environment variables is available in `.env.test`. To complete the values for the following variables: + +- `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`, refer to [Spotify Web API Documentation](https://developer.spotify.com/documentation/web-api). +- `TIDAL_CLIENT_ID` and `TIDAL_CLIENT_SECRET`, refer to [TIDAL Developer Portal](https://developer.tidal.com/). +- `YOUTUBE_API_KEY`, refer to [Google Developers Console](https://console.developers.google.com/). +- `URL_SHORTENER_API_KEY`, refer to [Bit](https://github.com/sjdonado/bit) + +Ensure that the values are correctly added to your `.env` file to configure the API keys properly. To get the app up: diff --git a/package.json b/package.json index c9f9c15..2cabc42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "idonthavespotify", - "version": "1.5.0", + "version": "1.6.0-beta", "scripts": { "dev": "concurrently \"bun run build:dev\" \"bun run --watch www/bin.ts\"", "build:dev": "vite build --mode=development --watch", From 9a3a3064074fd8489aee17fea637a32b4f63b532 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Fri, 20 Dec 2024 12:15:37 -0500 Subject: [PATCH 4/5] feat: headless request return array of links plain text --- src/routes/api.ts | 1 + src/services/search.ts | 65 +++++++++++++++++++++++++-------------- src/validations/search.ts | 17 +++++----- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index 689c419..7f4c922 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -31,6 +31,7 @@ export const apiRouter = new Elysia().group('/api', app => const searchResult = await search({ link: query.link, adapters: query._adapters as Adapter[], + headless: query.headless, }); return searchResult; }, diff --git a/src/services/search.ts b/src/services/search.ts index d3bb9cc..e2020dd 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -56,10 +56,12 @@ export const search = async ({ link, searchId, adapters, + headless, }: { link?: string; searchId?: string; adapters?: Adapter[]; + headless?: boolean; }) => { const searchParser = getSearchParser(link, searchId); @@ -72,7 +74,7 @@ export const search = async ({ Adapter.Tidal, ]; - logger.info(`[search] searchAdapters: ${searchAdapters}`); + logger.info(`[search] (searchAdapters) ${searchAdapters}`); const metadataFetchersMap = { [Parser.Spotify]: getSpotifyMetadata, @@ -108,7 +110,8 @@ export const search = async ({ throw new InternalServerError('Parser not implemented yet'); } - let metadata = await metadataFetcher(searchParser.id, searchParser.source); + // Even if headless, we need initial metadata and query for link extraction + const metadata = await metadataFetcher(searchParser.id, searchParser.source); const query = queryExtractor(metadata); const parserType = searchParser.type as StreamingServiceType; @@ -129,7 +132,12 @@ export const search = async ({ searchAdapters.length === 1 && searchParser.type === (searchAdapters[0] as StreamingServiceType) ) { - logger.info(`[${search.name}] early return - adapter is equal to parser type`); + logger.info(`[${search.name}] (early return) adapter is equal to parser type`); + + // If headless, return just the link as a string, else return the full object + if (headless) { + return link; + } return { id, @@ -145,12 +153,14 @@ export const search = async ({ } const links: SearchResultLink[] = []; - const existingAdapters = new Set(links.map(link => link.type)); + const existingAdapters = new Set(); let tidalLink: SearchResultLink | null = linkSearchResult; if (searchAdapters.includes(Adapter.Tidal) && parserType !== Adapter.Tidal) { tidalLink = await getTidalLink(query, metadata); - existingAdapters.add(Adapter.Tidal); + if (tidalLink) { + existingAdapters.add(Adapter.Tidal); + } } if (tidalLink) { @@ -174,7 +184,11 @@ export const search = async ({ for (const adapterKey in fromTidalULink) { const adapter = adapterKey as Adapter; // Only add the adapter if it's requested and not the parser type - if (parserType !== adapter && fromTidalULink[adapter]) { + if ( + searchAdapters.includes(adapter) && + parserType !== adapter && + fromTidalULink[adapter] + ) { links.push(fromTidalULink[adapter]); existingAdapters.add(adapter); } @@ -203,9 +217,23 @@ export const search = async ({ .filter(Boolean) ); - // Fetch metadata audio from spotify and universal link from bit + const parsedLinks = links + .filter(link => searchAdapters.includes(link.type)) + .sort((a, b) => { + // Prioritize verified links + if (a.isVerified && !b.isVerified) return -1; + if (!a.isVerified && b.isVerified) return 1; + return a.type.localeCompare(b.type); + }); + + // If headless is true, skip updatedMetadata and universal link shortening + if (headless) { + return parsedLinks.map(link => link.url); + } + + // Fetch updated metadata (for audio) from spotify if not parser type const spotifyLink = links.find(link => link.type === Adapter.Spotify); - const [updatedMetadata, shortLink] = await Promise.all([ + const [parsedMetadata, shortLink] = await Promise.all([ parserType !== Adapter.Spotify && spotifyLink ? (async () => { const spotifySearchParser = getSearchParser(spotifyLink.url); @@ -215,25 +243,16 @@ export const search = async ({ shortenLink(universalLink), ]); - metadata = updatedMetadata; const searchResult: SearchResult = { id, - type: metadata.type, - title: metadata.title, - description: metadata.description, - image: metadata.image, - audio: metadata.audio, + type: parsedMetadata.type, + title: parsedMetadata.title, + description: parsedMetadata.description, + image: parsedMetadata.image, + audio: parsedMetadata.audio, source: searchParser.source, universalLink: shortLink, - links: links - .filter(link => searchAdapters.includes(link.type)) - .sort((a, b) => { - // Prioritize verified links - if (a.isVerified && !b.isVerified) return -1; - if (!a.isVerified && b.isVerified) return 1; - - return a.type.localeCompare(b.type); - }), + links: parsedLinks, }; logger.info(`[${search.name}] (results) ${searchResult.links.map(link => link?.url)}`); diff --git a/src/validations/search.ts b/src/validations/search.ts index e9edcfb..63391c2 100644 --- a/src/validations/search.ts +++ b/src/validations/search.ts @@ -39,6 +39,14 @@ export const legacyApiV1Validator = { export const apiV2Validator = { query: t.Object({ + key: t.String({ + validate: (value: string) => value === ENV.app.apiKeyBeta, + error: 'Invalid API key. Request one from the administrator.', + }), + v: t.String({ + pattern: '2', + error: 'Unsupported API version', + }), link: t.RegExp(new RegExp(ALLOWED_LINKS_REGEX), { error: 'Invalid link, please try with Spotify or Youtube links.', }), @@ -55,14 +63,7 @@ export const apiV2Validator = { }) ) ), - key: t.String({ - validate: (value: string) => value === ENV.app.apiKeyBeta, - error: 'Invalid API key. Request one from the administrator.', - }), - v: t.String({ - pattern: '2', - error: 'Unsupported API version', - }), + headless: t.Optional(t.Boolean()), }), transform: ({ query }: { query: { adapters?: string; _adapters?: string[] } }) => { if (query.adapters) { From da4f5e01ec6569ee27c60b0265de08a93cf7737c Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 29 Dec 2024 12:00:40 -0500 Subject: [PATCH 5/5] chore: Apple shortcuts links README --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 4993d7e..e87587e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ Adapters represent the streaming services supported by the Web App and the Rayca image +# Apple Shortcuts (beta) +- [IDHS Open In Spotify](https://www.icloud.com/shortcuts/2757d4e10fad4cc182225953c8fdf80e) +- [IDHS Open In Tidal](https://www.icloud.com/shortcuts/63716e2abdcd4cc28fb67abf72e994f9) +- [IDHS Open In Youtube Music](https://www.icloud.com/shortcuts/de21be4d0878440f85bbf10bd6ee049a) +- [IDHS Open In Apple Music](https://www.icloud.com/shortcuts/23edcdae7dab452a9adc926435eafbdc) +- [IDHS Open In Deezer](https://www.icloud.com/shortcuts/f760b0cc6b6b494a88d31c31009966f3) +- [IDHS Open In SoundCloud](https://www.icloud.com/shortcuts/972d8ac3b30b4184a6cd63abb8c5cd9b) + ## Raycast Extension ![Uptime Badge](https://uptime.sjdonado.com/api/badge/3/uptime/24?labelPrefix=API%20&labelSuffix=h) ![Uptime Badge](https://uptime.sjdonado.com/api/badge/3/ping/24?labelPrefix=API%20)