feat: cloudflare -> netlify

This commit is contained in:
Cory Dransfeldt 2024-11-29 15:12:47 -08:00
parent a7e0b36d82
commit a74ea3644d
No known key found for this signature in database
41 changed files with 2021 additions and 2395 deletions

204
functions/artist-import.js Normal file
View file

@ -0,0 +1,204 @@
import slugify from "slugify";
import countries from "i18n-iso-countries";
import enLocale from "i18n-iso-countries/langs/en.json" assert { type: "json" };
countries.registerLocale(enLocale);
function sanitizeMediaString(str) {
return slugify(
str
.normalize("NFD")
.replace(/[\u0300-\u036f\u2010\-\.\?\(\)\[\]\{\}]/g, "")
.replace(/\.{3}/g, ""),
{
replacement: "-",
remove: /[#,&,+()$~%.'":*?<>{}]/g,
lower: true,
},
);
}
export async function handler(event, context) {
const {
DIRECTUS_URL,
DIRECTUS_API_TOKEN,
ARTIST_IMPORT_TOKEN,
ARTIST_FLOW_ID,
ALBUM_FLOW_ID,
} = process.env;
const placeholderImageId = "4cef75db-831f-4f5d-9333-79eaa5bb55ee";
const requestUrl = new URL(event.rawUrl);
const providedToken = requestUrl.searchParams.get("token");
if (!providedToken || providedToken !== ARTIST_IMPORT_TOKEN) {
return {
statusCode: 401,
body: "Unauthorized",
};
}
async function saveToDirectus(endpoint, payload) {
try {
const response = await fetch(`${DIRECTUS_URL}/items/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${DIRECTUS_API_TOKEN}`,
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
throw new Error(
data.errors?.[0]?.message || "Failed to save to Directus",
);
}
return data.data;
} catch (error) {
console.error(`Error saving to ${endpoint}:`, error.message);
throw error;
}
}
async function findGenreIdByName(genreName) {
try {
const response = await fetch(
`${DIRECTUS_URL}/items/genres?filter[name][_eq]=${encodeURIComponent(
genreName.toLowerCase(),
)}`,
{ headers: { Authorization: `Bearer ${DIRECTUS_API_TOKEN}` } },
);
const data = await response.json();
return data.data?.[0]?.id || null;
} catch (error) {
console.error("Error fetching genre ID:", error.message);
return null;
}
}
const artistId = requestUrl.searchParams.get("artist_id");
if (!artistId) {
return {
statusCode: 400,
body: "artist_id parameter is required",
};
}
let artistData;
try {
const artistResponse = await fetch(
`${DIRECTUS_URL}/flows/trigger/${ARTIST_FLOW_ID}?artist_id=${artistId}&import_token=${ARTIST_IMPORT_TOKEN}`,
{ headers: { Authorization: `Bearer ${DIRECTUS_API_TOKEN}` } },
);
const artistResult = await artistResponse.json();
artistData =
artistResult.get_artist_data?.data?.MediaContainer?.Metadata?.[0];
if (!artistData) throw new Error("Artist data not found");
} catch (error) {
console.error("Error fetching artist data:", error.message);
return {
statusCode: 500,
body: "Error fetching artist data",
};
}
const artistName = artistData.title || "";
const artistKey = sanitizeMediaString(artistName);
const countryName = artistData.Country?.[0]?.tag || "";
const countryIsoCode = countries.getAlpha2Code(countryName, "en") || "";
const slug = `/music/artists/${artistKey}-${countryName.toLowerCase()}`;
const description = artistData.summary || "";
const mbid = artistData.Guid?.[0]?.id?.replace("mbid://", "") || "";
const genreNames = artistData.Genre?.map((g) => g.tag.toLowerCase()) || [];
let genreId = null;
for (const genreName of genreNames) {
genreId = await findGenreIdByName(genreName);
if (genreId) break;
}
const artistPayload = {
name: artistName,
name_string: artistName,
slug,
description,
mbid,
tentative: true,
genres: genreId,
country: countryIsoCode,
art: placeholderImageId,
};
let insertedArtist;
try {
insertedArtist = await saveToDirectus("artists", artistPayload);
} catch (error) {
console.error("Error saving artist:", error.message);
return {
statusCode: 500,
body: "Error saving artist",
};
}
let albumData;
try {
const albumResponse = await fetch(
`${DIRECTUS_URL}/flows/trigger/${ALBUM_FLOW_ID}?artist_id=${artistId}&import_token=${ARTIST_IMPORT_TOKEN}`,
{ headers: { Authorization: `Bearer ${DIRECTUS_API_TOKEN}` } },
);
const albumResult = await albumResponse.json();
albumData =
albumResult.get_album_data?.data?.MediaContainer?.Metadata || [];
} catch (error) {
console.error("Error fetching album data:", error.message);
return {
statusCode: 500,
body: "Error fetching album data",
};
}
for (const album of albumData) {
const albumName = album.title || "";
const albumKey = `${artistKey}-${sanitizeMediaString(albumName)}`;
const albumSlug = `/music/albums/${albumKey}`;
const albumDescription = album.summary || "";
const albumReleaseDate = album.originallyAvailableAt || "";
const albumReleaseYear = albumReleaseDate
? new Date(albumReleaseDate).getFullYear()
: null;
const albumGenres = album.Genre?.map((g) => g.tag) || [];
const albumMbid = album.Guid?.[0]?.id?.replace("mbid://", "") || null;
const albumPayload = {
name: albumName,
key: albumKey,
slug: albumSlug,
mbid: albumMbid,
description: albumDescription,
release_year: albumReleaseYear,
artist: insertedArtist.id,
artist_name: artistName,
genres: albumGenres,
art: placeholderImageId,
tentative: true,
};
try {
await saveToDirectus("albums", albumPayload);
} catch (error) {
console.error("Error saving album:", error.message);
}
}
return {
statusCode: 200,
body: "Artist and albums synced successfully",
};
}

166
functions/mastodon.js Normal file
View file

@ -0,0 +1,166 @@
import { XMLParser } from "fast-xml-parser";
import { createClient } from "@supabase/supabase-js";
const BASE_URL = "https://coryd.dev";
async function addMastodonUrlToPost(supabase, slug, mastodonPostUrl) {
const { error } = await supabase
.from("posts")
.update({ mastodon_url: mastodonPostUrl })
.eq("slug", slug);
if (error) {
console.error("Error updating post in 'posts' table:", error.message);
} else {
console.log(`Updated post with Mastodon URL: ${mastodonPostUrl}`);
}
}
function truncateContent(title, description, link, maxLength) {
const baseLength = `${title}\n\n${link}`.length;
const availableSpace = maxLength - baseLength - 4;
let truncatedDescription = description;
if (description.length > availableSpace)
truncatedDescription =
description
.substring(0, availableSpace)
.split(" ")
.slice(0, -1)
.join(" ") + "...";
truncatedDescription = truncatedDescription.replace(/\s+([.,!?;:])/g, "$1");
return `${title}\n\n${truncatedDescription}\n\n${link}`;
}
async function fetchRSSFeed(rssFeedUrl) {
const response = await fetch(rssFeedUrl);
const rssText = await response.text();
const parser = new XMLParser();
const rssData = parser.parse(rssText);
const items = rssData.rss.channel.item;
return items.map((item) => ({
title: item.title,
link: item.link,
description: item.description,
}));
}
async function postToMastodon(apiUrl, accessToken, content) {
const response = await fetch(apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ status: content }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Error posting to Mastodon: ${response.statusText} - ${errorText}`,
);
}
const responseData = await response.json();
return responseData.url;
}
async function fetchFromSupabase(supabase, link) {
const { data, error } = await supabase
.from("mastodon_posts")
.select("link")
.eq("link", link)
.single();
if (error && error.code !== "PGRST116") {
console.error("Error fetching from Supabase:", error.message);
return null;
}
return data;
}
async function storeInSupabase(supabase, link, timestamp) {
const { error } = await supabase.from("mastodon_posts").insert([
{
link,
created_at: timestamp,
},
]);
if (error) {
console.error("Error storing in Supabase:", error.message);
} else {
console.log("Successfully stored processed link in 'mastodon_posts'");
}
}
async function handleMastodonPost(env, supabase) {
const mastodonApiUrl = "https://follow.coryd.dev/api/v1/statuses";
const accessToken = env.MASTODON_ACCESS_TOKEN;
const rssFeedUrl = "https://coryd.dev/feeds/syndication.xml";
try {
const latestItems = await fetchRSSFeed(rssFeedUrl);
for (let i = latestItems.length - 1; i >= 0; i--) {
const item = latestItems[i];
const existingPost = await fetchFromSupabase(supabase, item.link);
if (existingPost) continue;
const title = item.title;
const link = item.link;
const maxLength = 500;
const cleanedDescription = item.description.replace(/\s+/g, " ").trim();
const content = truncateContent(
title,
cleanedDescription,
link,
maxLength,
);
const mastodonPostUrl = await postToMastodon(
mastodonApiUrl,
accessToken,
content,
);
const timestamp = new Date().toISOString();
await storeInSupabase(supabase, item.link, timestamp);
if (link.includes("coryd.dev/posts")) {
const slug = link.replace(BASE_URL, "");
await addMastodonUrlToPost(supabase, slug, mastodonPostUrl);
}
console.log(`Posted and stored URL: ${link}`);
}
console.log("RSS processed successfully");
} catch (error) {
console.error("Error in Mastodon posting process:", error);
}
}
export async function handler(event, context) {
const env = process.env;
try {
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY);
await handleMastodonPost(env, supabase);
return {
statusCode: 200,
body: "RSS processed successfully",
};
} catch (error) {
console.error("Error in Netlify function:", error);
return {
statusCode: 500,
body: "Internal Server Error",
};
}
}

288
functions/scrobble.js Normal file
View file

@ -0,0 +1,288 @@
import { createClient } from "@supabase/supabase-js";
import slugify from "slugify";
const sanitizeMediaString = (str) => {
const sanitizedString = str
.normalize("NFD")
.replace(/[\u0300-\u036f\u2010\-\.\?\(\)\[\]\{\}]/g, "")
.replace(/\.{3}/g, "");
return slugify(sanitizedString, {
replacement: "-",
remove: /[#,&,+()$~%.'":*?<>{}]/g,
lower: true,
});
};
const sendEmail = async (subject, text, authHeader, maxRetries = 3) => {
const emailData = new URLSearchParams({
from: "coryd.dev <hi@admin.coryd.dev>",
to: "hi@coryd.dev",
subject: subject,
text: text,
}).toString();
let attempt = 0;
let success = false;
while (attempt < maxRetries && !success) {
attempt++;
try {
const response = await fetch("https://api.forwardemail.net/v1/emails", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: authHeader,
},
body: emailData,
});
if (!response.ok) {
const responseText = await response.text();
console.error(
`Attempt ${attempt}: Email API response error:`,
response.status,
responseText,
);
throw new Error(`Failed to send email: ${responseText}`);
}
console.log("Email sent successfully on attempt", attempt);
success = true;
} catch (error) {
console.error(`Attempt ${attempt}: Error sending email.`);
if (attempt < maxRetries) {
console.log(
`Retrying email send (attempt ${attempt + 1}/${maxRetries})...`,
);
} else {
console.error("All attempts to send email failed.");
}
}
}
return success;
};
const parseMultipartFormData = (body, boundary) => {
const parts = body.split(`--${boundary}`).filter((part) => part.trim());
const formData = {};
parts.forEach((part) => {
const [headers, value] = part.split("\r\n\r\n");
const nameMatch = headers.match(/name="(.+?)"/);
if (nameMatch) {
const name = nameMatch[1];
formData[name] = value.trim().replace(/\r\n$/, "");
}
});
return formData;
};
export async function handler(event, context) {
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_KEY;
const FORWARDEMAIL_API_KEY = process.env.FORWARDEMAIL_API_KEY;
const ACCOUNT_ID_PLEX = process.env.ACCOUNT_ID_PLEX;
const authHeader = "Basic " + btoa(`${FORWARDEMAIL_API_KEY}:`);
const supabase = createClient(supabaseUrl, supabaseKey);
let id;
try {
const queryParams = new URLSearchParams(event.queryStringParameters || {});
id = queryParams.get("id");
if (!id || id !== ACCOUNT_ID_PLEX)
return {
statusCode: 403,
body: JSON.stringify({
status: "Forbidden",
message: "Invalid or missing ID",
}),
};
} catch (error) {
console.error("Error parsing query parameters.");
return {
statusCode: 400,
body: JSON.stringify({
status: "Bad request",
message: "Oops! Bad request.",
}),
};
}
const contentType = event.headers["content-type"] || "";
if (!contentType.includes("multipart/form-data"))
return {
statusCode: 400,
body: JSON.stringify({
status: "Bad request",
message: "Invalid Content-Type. Expected multipart/form-data.",
}),
};
try {
const boundary = contentType.split("boundary=")[1];
if (!boundary) throw new Error("Missing boundary in Content-Type");
const rawBody = Buffer.from(event.body, "base64").toString("utf-8");
const formData = parseMultipartFormData(rawBody, boundary);
const payload = JSON.parse(formData.payload);
console.log("Parsed payload:", payload);
if (payload?.event === "media.scrobble") {
const artistName = payload["Metadata"]["grandparentTitle"];
const albumName = payload["Metadata"]["parentTitle"];
const trackName = payload["Metadata"]["title"];
const listenedAt = Math.floor(Date.now() / 1000);
const artistKey = sanitizeMediaString(artistName);
const albumKey = `${artistKey}-${sanitizeMediaString(albumName)}`;
let { data: artistData, error: artistError } = await supabase
.from("artists")
.select("*")
.ilike("name_string", artistName)
.single();
if (artistError && artistError.code === "PGRST116") {
const { error: insertArtistError } = await supabase
.from("artists")
.insert([
{
mbid: null,
art: "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
name: artistName,
slug: "/music",
tentative: true,
total_plays: 0,
},
]);
if (insertArtistError) {
console.error("Error inserting artist.");
return {
statusCode: 500,
body: JSON.stringify({
status: "error",
message: "Error inserting artist.",
}),
};
}
await sendEmail(
"New tentative artist record",
`A new tentative artist record was inserted:\n\nArtist: ${artistName}\nKey: ${artistKey}`,
authHeader,
);
({ data: artistData, error: artistError } = await supabase
.from("artists")
.select("*")
.ilike("name_string", artistName)
.single());
}
if (artistError) {
console.error("Artist not found or created.");
return {
statusCode: 500,
body: JSON.stringify({
status: "error",
message: "Artist not found",
}),
};
}
let { data: albumData, error: albumError } = await supabase
.from("albums")
.select("*")
.ilike("key", albumKey)
.single();
if (albumError && albumError.code === "PGRST116") {
console.log("Inserting new album:", albumName);
const { error: insertAlbumError } = await supabase
.from("albums")
.insert([
{
mbid: null,
art: "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
key: albumKey,
name: albumName,
tentative: true,
total_plays: 0,
artist: artistData.id,
},
]);
if (insertAlbumError) {
console.error("Error inserting album.");
return {
statusCode: 500,
body: JSON.stringify({
status: "error",
message: "Error inserting album.",
}),
};
}
await sendEmail(
"New tentative album record",
`A new tentative album record was inserted:\n\nAlbum: ${albumName}\nKey: ${albumKey}\nArtist: ${artistName}`,
authHeader,
);
({ data: albumData, error: albumError } = await supabase
.from("albums")
.select("*")
.ilike("key", albumKey)
.single());
}
if (albumError) {
console.error("Album not found or created.");
return {
statusCode: 500,
body: JSON.stringify({ status: "error", message: "Album not found" }),
};
}
const { error: listenError } = await supabase.from("listens").insert([
{
artist_name: artistData.name_string || artistName,
album_name: albumData.name || albumName,
track_name: trackName,
listened_at: listenedAt,
album_key: albumKey,
},
]);
if (listenError) {
console.error("Error inserting listen.");
return {
statusCode: 500,
body: JSON.stringify({
status: "error",
message: "Error inserting listen.",
}),
};
}
console.log("Listen record inserted successfully");
}
return {
statusCode: 200,
body: JSON.stringify({ status: "success" }),
};
} catch (e) {
console.error("Error occurred during request processing:", e.message, e.stack);
return {
statusCode: 500,
body: JSON.stringify({ status: "error", message: "Oops! Error." }),
};
}
}

53
functions/search.js Normal file
View file

@ -0,0 +1,53 @@
import { createClient } from "@supabase/supabase-js";
export async function handler(event, context) {
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
try {
const searchParams = event.queryStringParameters || {};
const query = searchParams.q?.trim() || "";
const rawTypes = searchParams.type || "";
const types = rawTypes ? rawTypes.split(",") : null;
const page = parseInt(searchParams.page || "1", 10);
const pageSize = parseInt(searchParams.pageSize || "10", 10);
const offset = (page - 1) * pageSize;
if (!query) throw new Error("Missing or invalid 'q' parameter");
const { data, error } = await supabase.rpc("search_optimized_index", {
search_query: query,
page_size: pageSize,
page_offset: offset,
types: types && types.length ? types : null,
});
if (error) {
console.error("Error fetching search data:", error.message);
return {
statusCode: 500,
body: JSON.stringify({
results: [],
total: 0,
error: error.message,
}),
};
}
const total = data.length > 0 ? data[0].total_count : 0;
const results = data.map(({ total_count, ...item }) => item);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ results, total, page, pageSize }),
};
} catch (error) {
console.error("Unexpected error:", error.message);
return {
statusCode: error.message.includes("Missing or invalid") ? 400 : 500,
body: JSON.stringify({ error: error.message }),
};
}
}