Final commit

This commit is contained in:
Giovanni 2025-08-02 13:28:10 +02:00
parent 91fffb3294
commit c35e0716af
372 changed files with 16591 additions and 1 deletions

BIN
backend/.DS_Store vendored Normal file

Binary file not shown.

1
backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

View file

@ -0,0 +1,40 @@
{
"lsp": {
"deno": {
"settings": {
"deno": {
"enable": true
}
}
}
},
"languages": {
"JavaScript": {
"language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint"
],
"formatter": "language_server"
},
"TypeScript": {
"language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint"
],
"formatter": "language_server"
},
"TSX": {
"language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint"
],
"formatter": "language_server"
}
}
}

16
backend/.zed/tasks.json Normal file
View file

@ -0,0 +1,16 @@
[
{
"label": "Run Deno Backend",
"command": "cd .. && docker build -f backend/Dockerfile -t deno-backend ./ && docker run --rm -it -p 8000:8000 deno-backend",
"env": {},
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
"reveal_target": "dock",
"hide": "never"
},
{
"label": "Run Deno Test in line",
"command": "deno test --env-file=${ZED_WORKTREE_ROOT}/.env --allow-all --filter \"$(sed -n '${ZED_ROW}p' $ZED_FILE | grep -o 'Deno\\.test(\"[^\"]*\"' | sed 's/Deno\\.test(\"//' | sed 's/\"$//')\" $ZED_FILE"
}
]

13
backend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM denoland/deno:latest
# Create working directory
WORKDIR /app
# Copy source
COPY backend/ .
# Compile the main app
RUN deno cache main.ts
# Run the app
CMD ["deno", "run", "--allow-net", "--allow-env", "main.ts"]

48
backend/ai-selector.ts Normal file
View file

@ -0,0 +1,48 @@
import { GoogleGenAI } from "npm:@google/genai@1.12.0";
import { assert } from "https://deno.land/std@0.224.0/assert/mod.ts";
import schema from "./schemas/AiSuggestionsOutput.json" with { type: "json" };
import { AiSuggestionsOutput } from "./types/ai.ts";
const ai = new GoogleGenAI({});
// Generate songs using AI
export async function generateSongs(
userPrompt: string,
rules: string,
): Promise<AiSuggestionsOutput> {
const rulesText = rules && rules.trim().length > 0
? `Make sure that they follow the following set of rules: "${rules}".`
: "";
const aiPrompt = `
Based on this music request: "${userPrompt}", generate 15 suggestion for songs.
Make sure they are real, popular songs that exist on Spotify.
Make sure to follow the following set of costraints in order to suggest songs but bear in mind
to return an error only in extreme cases. Ypu can be a little open even if genres clash with eah other a little bit.
The rules are: "${rulesText}"
The type should be either succes or error.
Make the error as user friendly as possible ans concise, and don't make it happen too often.
`;
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: aiPrompt,
config: { responseMimeType: "application/json", responseSchema: schema },
});
const text = response.candidates?.at(0)?.content?.parts?.at(0)?.text;
if (!text) {
throw Error("Text not found");
}
return JSON.parse(text) as AiSuggestionsOutput;
}
Deno.test("generateSongs basic", async () => {
const prompt = "Give me 5 songs by the queens";
const rules = "Songs must vool";
const songs = await generateSongs(prompt, rules);
console.log(songs);
assert(songs.type === "success");
});

65
backend/ai-suggestion.ts Normal file
View file

@ -0,0 +1,65 @@
import { GoogleGenAI } from "npm:@google/genai@1.12.0";
import { assert } from "https://deno.land/std@0.224.0/assert/mod.ts";
import schema from "./schemas/AiSnewuggestionOutput.json" with { type: "json" };
import { GetNewSuggestInput, GetNewSuggestOutput } from "./types/api.ts";
import { AiSnewuggestionOutput } from "./types/ai.ts";
const ai = new GoogleGenAI({});
// Generate songs using AI
export async function generateVibes(
input: GetNewSuggestInput,
): Promise<AiSnewuggestionOutput> {
const { rules } = input;
let aiPrompt = "";
let contextText = "";
if (input.type === "scratch-suggestion") {
contextText =
`The music request is for a place: "${input.room_name}". Your job is to find 15 songs that match the vibe of this place.`;
} else if (input.type === "from-songs-suggestion") {
const songList = input.songs.map((s, i) => `${i + 1}. ${s.song}`).join(
"\n",
);
contextText =
`The music request is based on the last 10 songs:\n${songList}\nYour job is to 15 find songs that match the vibe of these songs.`;
}
const rulesText = rules && rules.trim().length > 0
? `Make sure that they follow the following set of rules: "${rules}".`
: "";
aiPrompt = `
${contextText}
Make sure they are real, popular songs that exist on Spotify.
${rulesText}
`;
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: aiPrompt,
config: { responseMimeType: "application/json", responseSchema: schema },
});
const text = response.candidates?.at(0)?.content?.parts?.at(0)?.text;
if (!text) {
throw Error("Text not found");
}
return JSON.parse(text) as AiSnewuggestionOutput;
}
Deno.test("generateVibes with array of songs", async () => {
const input: GetNewSuggestInput = {
type: "from-songs-suggestion" as const,
rules: "The songs must be great for food",
songs: [
{ song: "Something - The Beatles" },
{ song: "Come Together - The Beatles" },
{ song: "Help! - The Beatles" },
],
};
const result = await generateVibes(input);
console.log(result);
});

0
backend/deno.json Normal file
View file

209
backend/deno.lock generated Normal file
View file

@ -0,0 +1,209 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@*": "1.0.13",
"jsr:@std/internal@^1.0.6": "1.0.10",
"npm:@google/genai@1.12.0": "1.12.0"
},
"jsr": {
"@std/assert@1.0.13": {
"integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.10": {
"integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7"
}
},
"npm": {
"@google/genai@1.12.0": {
"integrity": "sha512-JBkQsULVexdM9zY4iXbm3A2dJ7El/hSPGCnxuRWPJNgeqcfYuyUnPTSy+I/v+MvTbz/occVmONSD6wn+17QLkg==",
"dependencies": [
"google-auth-library",
"ws"
]
},
"agent-base@7.1.4": {
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
},
"base64-js@1.5.1": {
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"bignumber.js@9.3.1": {
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="
},
"buffer-equal-constant-time@1.0.1": {
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"debug@4.4.1": {
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dependencies": [
"ms"
]
},
"ecdsa-sig-formatter@1.0.11": {
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": [
"safe-buffer"
]
},
"extend@3.0.2": {
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"gaxios@6.7.1": {
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
"dependencies": [
"extend",
"https-proxy-agent",
"is-stream",
"node-fetch",
"uuid"
]
},
"gcp-metadata@6.1.1": {
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
"dependencies": [
"gaxios",
"google-logging-utils",
"json-bigint"
]
},
"google-auth-library@9.15.1": {
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
"dependencies": [
"base64-js",
"ecdsa-sig-formatter",
"gaxios",
"gcp-metadata",
"gtoken",
"jws"
]
},
"google-logging-utils@0.0.2": {
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="
},
"gtoken@7.1.0": {
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
"dependencies": [
"gaxios",
"jws"
]
},
"https-proxy-agent@7.0.6": {
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dependencies": [
"agent-base",
"debug"
]
},
"is-stream@2.0.1": {
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="
},
"json-bigint@1.0.0": {
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"dependencies": [
"bignumber.js"
]
},
"jwa@2.0.1": {
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"dependencies": [
"buffer-equal-constant-time",
"ecdsa-sig-formatter",
"safe-buffer"
]
},
"jws@4.0.0": {
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"dependencies": [
"jwa",
"safe-buffer"
]
},
"ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node-fetch@2.7.0": {
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": [
"whatwg-url"
]
},
"safe-buffer@5.2.1": {
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
},
"tr46@0.0.3": {
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"uuid@9.0.1": {
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"bin": true
},
"webidl-conversions@3.0.1": {
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"whatwg-url@5.0.0": {
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": [
"tr46",
"webidl-conversions"
]
},
"ws@8.18.3": {
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
}
},
"remote": {
"https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975",
"https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834",
"https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293",
"https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7",
"https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74",
"https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd",
"https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff",
"https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46",
"https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b",
"https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c",
"https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491",
"https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68",
"https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3",
"https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7",
"https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29",
"https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a",
"https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a",
"https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8",
"https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693",
"https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31",
"https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5",
"https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8",
"https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb",
"https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917",
"https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47",
"https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68",
"https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3",
"https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73",
"https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19",
"https://deno.land/std@0.224.0/dotenv/load.ts": "587b342f0f6a3df071331fe6ba1c823729ab68f7d53805809475e486dd4161d7",
"https://deno.land/std@0.224.0/dotenv/mod.ts": "0180eaeedaaf88647318811cdaa418cc64dc51fb08354f91f5f480d0a1309f7d",
"https://deno.land/std@0.224.0/dotenv/parse.ts": "09977ff88dfd1f24f9973a338f0f91bbdb9307eb5ff6085446e7c423e4c7ba0c",
"https://deno.land/std@0.224.0/dotenv/stringify.ts": "275da322c409170160440836342eaa7cf012a1d11a7e700d8ca4e7f2f8aa4615",
"https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5",
"https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6",
"https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2",
"https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e",
"https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea",
"https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef",
"https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe",
"https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c",
"https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7",
"https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1",
"https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7",
"https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e",
"https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774",
"https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268",
"https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c",
"https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4",
"https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e"
}
}

21
backend/generate-schemas.sh Executable file
View file

@ -0,0 +1,21 @@
#!/bin/bash
generate_schema_for_type() {
yes | npx typescript-json-schema --noExtraProps true --required --refs false --out "temp_schemas/$1.json" "./types/**.ts" "$1"
}
generate_schemas() {
rm -rf temp_schemas
mkdir temp_schemas
generate_schema_for_type "AiSuggestionsOutput" &
generate_schema_for_type "AiSnewuggestionOutput" &
wait
rm -rf ./schemas
mv temp_schemas schemas
}
# Run the schema generation
generate_schemas
echo "Schema generated successfully!"

190
backend/main.ts Normal file
View file

@ -0,0 +1,190 @@
const PORT = parseInt(Deno.env.get("PORT") || "8000");
import {
GetNewSuggestInput,
GetNewSuggestOutput,
GetSongsTextParams,
GetTrackInput,
GetYoutubeInput,
} from "./types/api.ts";
import { getSong } from "./routes/get-song.ts";
import { getSpotifyTrackById } from "./routes/get-track.ts";
import { getVids } from "./routes/get-video.ts";
import { getSpotifyAccessToken } from "./spotify-api.ts";
import { generateVibes } from "./ai-suggestion.ts";
import { getNewSuggestion } from "./routes/get-new-suggest.ts";
const routes = [
{
pattern: new URLPattern({ pathname: "/music" }),
handler: async (req: Request) => {
const params = await req.json();
const songs = await getSong(params);
return new Response(JSON.stringify(songs), {
status: 200,
});
},
},
{
pattern: new URLPattern({ pathname: "/health" }),
handler: (_req: Request) => {
return new Response("All good", {
status: 200,
});
},
},
{
pattern: new URLPattern({ pathname: "/video" }),
handler: async (req: Request) => {
const params: GetYoutubeInput = await req.json();
const vids = await getVids(params);
return new Response(JSON.stringify(vids), {
status: 200,
});
},
},
{
pattern: new URLPattern({ pathname: "/track" }),
handler: async (req: Request) => {
const params: GetTrackInput = await req.json();
const track = await getSpotifyTrackById(params.trackId);
return new Response(JSON.stringify(track), {
status: 200,
});
},
},
{
pattern: new URLPattern({ pathname: "/newSuggestions" }),
handler: async (req: Request) => {
const params: GetNewSuggestInput = await req.json();
const suggestions: GetNewSuggestOutput = await getNewSuggestion(params);
return new Response(JSON.stringify(suggestions), {
status: 200,
});
},
},
];
const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
const handler = async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: cors,
});
}
let response: Response | undefined;
try {
for (const route of routes) {
const match = route.pattern.exec(req.url);
if (match) {
response = await route.handler(req);
break;
}
}
} catch (error: any) {
console.error("Server error:", error);
response = new Response(error.toString() || "Internal server error", {
status: 500,
});
}
response ??= new Response("Not found", { status: 404 });
for (const [key, value] of Object.entries(cors)) {
response.headers.set(key, value);
}
return response;
};
Deno.serve({ hostname: "0.0.0.0", port: PORT }, handler);
Deno.test("music API complete workflow", async () => {
const spotify_api_key = getSpotifyAccessToken();
if (!spotify_api_key) throw Error("Testing token not found");
const testInput: GetSongsTextParams = {
prompt: "i want summer feeling music",
rules: "make it fun and suitable for children",
};
const res = await fetch(`http://localhost:${PORT}/music`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(testInput),
});
if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`);
const json = await res.json();
console.log("API Response:", json);
});
Deno.test("video API complete workflow", async () => {
const yt_api_key = Deno.env.get("YT_API_KEY");
if (!yt_api_key) throw Error("Testing token not found");
const input: GetYoutubeInput = {
title: "Here Comes The Sun",
artist: "The Beatles",
};
const res = await fetch(`http://localhost:${PORT}/video`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`);
const json = await res.json();
console.log("Video API Response:", json);
});
Deno.test("track API complete workflow", async () => {
const spotify_api_key = getSpotifyAccessToken();
if (!spotify_api_key) throw Error("Testing token not found");
const input: GetTrackInput = { trackId: "6dGnYIeXmHdcikdzNNDMm2" };
const res = await fetch(`http://localhost:${PORT}/track`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`);
const json = await res.json();
console.log("Track API Response:", json);
});
Deno.test("new suggestion API complete workflow", async () => {
const testInput: GetNewSuggestInput = {
type: "from-songs-suggestion",
rules: "The songs should be upbeat and suitable for a party",
songs: [
{ song: "Thriller - Michael Jackson" },
{ song: "Uptown Funk - Bruno Mars" },
{ song: "24K Magic - Bruno Mars" },
],
};
const res = await fetch(`http://localhost:${PORT}/newSuggestions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(testInput),
});
if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`);
const json = await res.json();
console.log("New Suggestion API Response:", json);
// Validate that the response contains a songs array.
if (!json.songs || !Array.isArray(json.songs)) {
throw new Error("Response does not contain a valid songs array");
}
});

View file

@ -0,0 +1,15 @@
import { searchSpotifyTracks } from "../spotify-api.ts";
import { GetNewSuggestInput, GetNewSuggestOutput } from "../types/api.ts";
import { generateVibes } from "../ai-suggestion.ts";
export async function getNewSuggestion(
params: GetNewSuggestInput,
): Promise<GetNewSuggestOutput> {
const ai_suggestion = await generateVibes(params);
const suggestionResults = await searchSpotifyTracks(
ai_suggestion.songs,
);
return { songs: suggestionResults };
}

View file

@ -0,0 +1,18 @@
import { generateSongs } from "../ai-selector.ts";
import { searchSpotifyTracks } from "../spotify-api.ts";
import { GetSongsTextOutput, GetSongsTextParams } from "../types/api.ts";
export async function getSong(
params: GetSongsTextParams,
): Promise<GetSongsTextOutput> {
const ai_response = await generateSongs(params.prompt, params.rules);
if (ai_response.type == "error") {
return ai_response;
}
const spotifyResults = await searchSpotifyTracks(
ai_response.songs,
);
return { type: "success", songs: spotifyResults };
}

View file

@ -0,0 +1,87 @@
import { SpotifyTrack } from "../types/spotify.ts";
import { getSpotifyAccessToken } from "../spotify-api.ts";
import { assert } from "jsr:@std/assert/assert";
// Simple in-memory cache for track objects by ID
const trackCache = new Map<string, SpotifyTrack>();
/**
* Inner function that performs the actual API call without retries.
*/
async function fetchSpotifyTrackById(trackId: string): Promise<SpotifyTrack> {
const accessToken = await getSpotifyAccessToken();
const url = `https://api.spotify.com/v1/tracks/${trackId}?market=US`;
const response = await fetch(url, {
headers: {
"Authorization": `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch track: ${response.status} ${await response.text()}`,
);
}
const track: SpotifyTrack = await response.json();
// Cache the result
trackCache.set(trackId, track);
return track;
}
/**
* Fetches a Spotify track by its ID, with caching.
* Returns the full SpotifyTrack object.
*/
export async function getSpotifyTrackById(
trackId: string,
): Promise<SpotifyTrack> {
// Check cache first
if (trackCache.has(trackId)) {
return trackCache.get(trackId)!;
}
const maxRetries = 10;
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fetchSpotifyTrackById(trackId);
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) {
throw Error(`Attempt ${attempt}: ${lastError}`);
}
// Optional: add delay between retries
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
throw lastError!;
}
Deno.test("getSpotifyTrackById basic", async () => {
// Example: Blinding Lights by The Weeknd
const trackId = "2EqlS6tkEnglzr7tkKAAYD";
// First call (should fetch from API)
const track1 = await getSpotifyTrackById(trackId);
assert(track1.id === trackId);
assert(typeof track1.name === "string");
assert(Array.isArray(track1.artists));
// Second call (should hit cache)
const start = performance.now();
const track2 = await getSpotifyTrackById(trackId);
const elapsed = performance.now() - start;
console.log(track2);
console.log("Second call elapsed:", elapsed, "ms");
assert(track2.id === trackId);
assert(typeof track2.name === "string");
assert(Array.isArray(track2.artists));
assert(elapsed < 10, `Second call took too long: ${elapsed}ms`);
});

View file

@ -0,0 +1,9 @@
import { GetYoutubeInput, GetYoutubeOutput } from "../types/api.ts";
import { searchYTVideo } from "../youtube-api.ts";
export async function getVids(
params: GetYoutubeInput,
): Promise<GetYoutubeOutput> {
const videoId = await searchYTVideo(params);
return { videoId };
}

View file

@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"songs": {
"items": {
"additionalProperties": false,
"properties": {
"artist": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
"artist",
"title"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"songs"
],
"type": "object"
}

View file

@ -0,0 +1,56 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"additionalProperties": false,
"properties": {
"songs": {
"items": {
"additionalProperties": false,
"properties": {
"artist": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
"artist",
"title"
],
"type": "object"
},
"type": "array"
},
"type": {
"const": "success",
"type": "string"
}
},
"required": [
"songs",
"type"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"error": {
"type": "string"
},
"type": {
"const": "error",
"type": "string"
}
},
"required": [
"error",
"type"
],
"type": "object"
}
]
}

136
backend/spotify-api.ts Normal file
View file

@ -0,0 +1,136 @@
import { assert } from "jsr:@std/assert/assert";
import { SpotifySearchResponse, SpotifyTrack } from "./types/spotify.ts";
import { SongSearch } from "./types/ai.ts";
export async function searchSpotifyTracks(
songs: SongSearch[],
): Promise<SpotifyTrack[]> {
const results: SpotifyTrack[] = [];
const trackPromises = songs.map(async (song) => {
try {
const query = `track:"${song.title}" artist:"${song.artist}"`;
const params = new URLSearchParams({
q: query,
type: "track",
market: "US",
limit: "1",
offset: "0",
});
const url = `https://api.spotify.com/v1/search?${params.toString()}`;
const response = await fetch(url, {
headers: {
"Authorization": "Bearer " + await getSpotifyAccessToken(),
},
});
if (!response.ok) {
throw Error("Unsuccesful response");
}
const data: SpotifySearchResponse = await response.json();
const track = data.tracks.items.at(0);
if (track) {
return track;
}
} catch (error) {
console.error(error);
}
return undefined;
});
const tracks = await Promise.all(trackPromises);
for (const track of tracks) {
if (track) {
results.push(track);
}
}
return results;
}
/**
* Get Spotify access token using Client Credentials Flow.
* Reads credentials from environment variables, caches token in memory with expiry.
* Throws if credentials are missing or request fails.
*/
let spotifyTokenCache: { token: string; expiresAt: number } | null = null;
export async function getSpotifyAccessToken(): Promise<string> {
const clientId = Deno.env.get("SPOTIFY_CLIENT_ID");
const clientSecret = Deno.env.get("SPOTIFY_CLIENT_SECRET");
if (!clientId || !clientSecret) {
throw new Error("Spotify credentials not found in environment variables.");
}
// If cached and not expired, return cached token
if (
spotifyTokenCache &&
spotifyTokenCache.expiresAt > Date.now()
) {
return spotifyTokenCache.token;
}
const tokenResponse = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " +
btoa(`${clientId}:${clientSecret}`),
},
body: "grant_type=client_credentials",
});
if (!tokenResponse.ok) {
throw new Error(
`Failed to fetch Spotify access token: ${tokenResponse.status} ${await tokenResponse
.text()}`,
);
}
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
const expiresIn = tokenData.expires_in; // seconds
if (!accessToken || !expiresIn) {
throw new Error("Invalid token response from Spotify.");
}
// Cache token with expiry
spotifyTokenCache = {
token: accessToken,
expiresAt: Date.now() + expiresIn * 1000 - 10_000, // 10s early
};
return accessToken;
}
Deno.test("getSpotifyAccessToken caching", async () => {
await getSpotifyAccessToken();
const start2 = performance.now();
const token2 = await getSpotifyAccessToken();
const elapsed2 = performance.now() - start2;
assert(elapsed2 < 10, `Second call took too long: ${elapsed2}ms`);
});
Deno.test("searchSpotifyTracks basic", async () => {
const songs = [
{ title: "Blinding Lights", artist: "The Weeknd" },
{ title: "Levitating", artist: "Dua Lipa" },
];
const spotify_api_key = getSpotifyAccessToken();
if (!spotify_api_key) throw Error("Testing token not found");
const tracks = await searchSpotifyTracks(songs);
console.log(tracks);
assert(tracks.length > 0);
});

19
backend/types/ai.ts Normal file
View file

@ -0,0 +1,19 @@
export type AiSuggestionsOutput = {
type: "success";
songs: {
title: string;
artist: string;
}[];
} | {
type: "error";
error: string;
};
export interface SongSearch {
title: string;
artist: string;
}
export interface AiSnewuggestionOutput {
songs: SongSearch[];
}

47
backend/types/api.ts Normal file
View file

@ -0,0 +1,47 @@
import { SpotifyTrack } from "./spotify.ts";
export interface GetSongsTextParams {
prompt: string;
rules: string;
}
export type GetSongsTextOutput = {
type: "success";
songs: SpotifyTrack[];
} | {
type: "error";
error: string;
};
export interface GetYoutubeInput {
title: string;
artist: string;
}
export interface GetYoutubeOutput {
videoId?: string;
}
export interface GetTrackInput {
trackId: string;
}
export interface GetTrackOutput {
track: SpotifyTrack;
}
export type GetNewSuggestInput =
& {
rules: string;
}
& ({
type: "scratch-suggestion";
room_name: string;
} | {
type: "from-songs-suggestion";
songs: { song: string }[];
});
export type GetNewSuggestOutput = {
songs: SpotifyTrack[];
};

97
backend/types/spotify.ts Normal file
View file

@ -0,0 +1,97 @@
export interface SpotifyArtist {
external_urls: {
spotify: string;
};
href: string;
id: string;
name: string;
type: string;
uri: string;
}
export interface SpotifyAlbum {
album_type: string;
total_tracks: number;
available_markets: string[];
external_urls: {
spotify: string;
};
href: string;
id: string;
images: Array<{
url: string;
height: number;
width: number;
}>;
name: string;
release_date: string;
release_date_precision: string;
type: string;
uri: string;
artists: SpotifyArtist[];
is_playable?: boolean;
}
export interface SpotifyTrack {
album: SpotifyAlbum;
artists: SpotifyArtist[];
available_markets: string[];
disc_number: number;
duration_ms: number;
explicit: boolean;
external_ids: {
isrc?: string;
};
external_urls: {
spotify: string;
};
href: string;
id: string;
is_playable?: boolean;
name: string;
popularity: number;
preview_url: string | null;
track_number: number;
type: string;
uri: string;
is_local: boolean;
}
export interface SpotifySearchResponse {
tracks: {
href: string;
limit: number;
next: string | null;
offset: number;
previous: string | null;
total: number;
items: SpotifyTrack[];
};
}
export interface SearchResultTrack {
id: string;
name: string;
artists: Array<{
id: string;
name: string;
spotify_url: string;
}>;
album: {
id: string;
name: string;
release_date: string;
images: Array<{
url: string;
height: number;
width: number;
}>;
spotify_url: string;
};
duration_ms: number;
popularity: number;
preview_url: string | null;
spotify_url: string;
explicit: boolean;
available_markets: string[];
}

4
backend/types/youtube.ts Normal file
View file

@ -0,0 +1,4 @@
export interface Vids {
title: string;
artist: string;
}

46
backend/youtube-api.ts Normal file
View file

@ -0,0 +1,46 @@
import { assert } from "jsr:@std/assert/assert";
import { Vids } from "./types/youtube.ts";
export async function searchYTVideo(
videos: Vids,
): Promise<string | undefined> {
const query = `${videos.title} ${videos.artist}`;
const params = new URLSearchParams({
part: "snippet",
q: query,
type: "video",
key: Deno.env.get("YT_API_KEY")!, // Replace with your actual API key
});
const endpoint =
`https://www.googleapis.com/youtube/v3/search?${params.toString()}`;
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`YouTube API error: ${response.statusText}`);
}
const data = await response.json();
const videoId = data.items && data.items.length > 0
? data.items[0].id.videoId
: undefined;
if (!videoId) {
throw new Error("No video found for the given title and artist.");
}
return videoId;
}
Deno.test("searchYT", async () => {
const yt_api_key = Deno.env.get("YT_API_KEY");
if (!yt_api_key) throw Error("Testing token not found");
const videoId = await searchYTVideo({
title: "Here Comes The Sun",
artist: "The Beatles",
});
console.log("Here's the video ID:", videoId);
assert(typeof videoId === "string" && videoId);
});