[READ-ONLY] Mirror of https://github.com/flo-bit/skywatched-backend. backend/jetstream consumer for skywatched.app skywatched.app
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor done?

Florian (Feb 7, 2025, 4:38 PM +0100) 4e1ea5c1 819745aa

+329 -27
+10 -1
README.md
··· 1 1 # skywatched jetstream 2 2 3 - backend for [skywatched](https://skywatched.app). runs on fly.io. 3 + backend/appview for [skywatched](https://skywatched.app). runs on fly.io. 4 4 5 5 sqlite database with litefs for persistence. 6 + 7 + ## Development 8 + 9 + ``` 10 + npm i 11 + npm run dev 12 + ``` 13 + 14 + if running with the frontend, change the backend url `BACKEND_URL` (in your `.env` file) in the frontend to `http://localhost:3001`.
+13 -7
src/api.ts
··· 1 1 // server.ts 2 + import { processItemsApiCall } from "./api/items"; 2 3 import { processLikesApiCall } from "./api/likes"; 3 4 import { 4 5 backfillUserIfNecessary, ··· 123 124 } 124 125 } 125 126 127 + if (path.startsWith("/api/items")) { 128 + const response = await processItemsApiCall({ 129 + method, 130 + path, 131 + request: req, 132 + url, 133 + }); 134 + if (response) return response; 135 + } 136 + 126 137 if (path.startsWith("/api/likes")) { 127 138 const response = await processLikesApiCall({ 128 139 method, ··· 130 141 request: req, 131 142 url, 132 143 }); 133 - return ( 134 - response ?? 135 - new Response(JSON.stringify({ error: "Not Found" }), { 136 - status: 404, 137 - headers: { "Content-Type": "application/json" }, 138 - }) 139 - ); 144 + 145 + if (response) return response; 140 146 } 141 147 142 148 // Handle 404 Not Found
+2 -14
src/database.ts
··· 3 3 import { Database, SQLQueryBindings } from 'bun:sqlite'; 4 4 import { getFormattedDetails } from './tmdb'; 5 5 import { getAllRated, getProfile } from './atp'; 6 + import { createLikesTable } from "./db/likes"; 6 7 7 8 const litefsDir = process.env.NODE_ENV === 'production' ? '/var/lib/litefs' : './litefs'; 8 9 const litefsPath = join(litefsDir, 'db.sqlite'); ··· 165 166 CREATE INDEX IF NOT EXISTS idx_indexedAt ON latest_indexedAt (indexedAt); 166 167 `); 167 168 168 - db.run(` 169 - CREATE TABLE IF NOT EXISTS likes ( 170 - uri TEXT PRIMARY KEY, 171 - author_did TEXT NOT NULL, 172 - subject_cid TEXT NOT NULL, 173 - subject_uri TEXT NOT NULL, 174 - createdAt TEXT NOT NULL 175 - ); 176 - 177 - CREATE INDEX IF NOT EXISTS idx_uri ON likes (uri); 178 - CREATE INDEX IF NOT EXISTS idx_author_did ON likes (author_did); 179 - CREATE INDEX IF NOT EXISTS idx_subject_uri ON likes (subject_uri); 180 - CREATE INDEX IF NOT EXISTS idx_createdAt ON likes (createdAt); 181 - `); 169 + createLikesTable(); 182 170 } 183 171 184 172 // Function to get the latest timestamp (either creation or update)
+4 -1
src/jetstream.ts
··· 150 150 value: json.commit.record.rating.value, 151 151 createdAt: json.commit.record.rating.createdAt, 152 152 }, 153 + crosspost: { 154 + uri: json.commit.record.crosspost?.uri, 155 + }, 153 156 }, 154 157 }; 155 158 ··· 209 212 const nowMS = Date.now(); 210 213 const oneMinuteAgoMS = nowMS - 1 * 60 * 1000; 211 214 212 - // recreateTables(); 215 + recreateTables(); 213 216 214 217 let lastTimestamp = await getLatestTimestamp(); 215 218 console.log("Last timestamp:", lastTimestamp);
+166
src/tmdb-new.ts
··· 1 + import { env } from "bun"; 2 + import { getRecentRecordsByItemRef } from "./database"; 3 + 4 + export type Kind = "movie" | "tv"; 5 + 6 + export async function searchMulti(query: string) { 7 + const apiUrl = `https://api.themoviedb.org/3/search/multi?query=${query}&include_adult=false&language=en-US&page=1`; 8 + const options = { 9 + method: "GET", 10 + headers: { 11 + accept: "application/json", 12 + Authorization: `Bearer ${env.TMDB_API_KEY}`, 13 + }, 14 + }; 15 + 16 + const response = await fetch(apiUrl, options); 17 + const data = await response.json(); 18 + 19 + return data.results; 20 + } 21 + 22 + export async function getDetails(id: number, kind: Kind) { 23 + const url = `https://api.themoviedb.org/3/${kind}/${id}?language=en-US`; 24 + const options = { 25 + method: "GET", 26 + headers: { 27 + accept: "application/json", 28 + Authorization: `Bearer ${env.TMDB_API_KEY}`, 29 + }, 30 + }; 31 + 32 + const response = await fetch(url, options); 33 + const data = await response.json(); 34 + 35 + return data; 36 + } 37 + 38 + export async function getTrailer( 39 + id: number, 40 + kind: Kind 41 + ): Promise<string | null> { 42 + const url = `https://api.themoviedb.org/3/${kind}/${id}/videos?language=en-US`; 43 + const options = { 44 + method: "GET", 45 + headers: { 46 + accept: "application/json", 47 + Authorization: `Bearer ${env.TMDB_API_KEY}`, 48 + }, 49 + }; 50 + 51 + const response = await fetch(url, options); 52 + const data = await response.json(); 53 + 54 + let trailer = data.results?.find( 55 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 + (video: any) => 57 + video.site === "YouTube" && video.type === "Trailer" && video.official 58 + ); 59 + 60 + if (!trailer) { 61 + trailer = data.results?.find( 62 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 63 + (video: any) => video.site === "YouTube" && video.type === "Trailer" 64 + ); 65 + } 66 + 67 + return trailer?.key ?? null; 68 + } 69 + 70 + export async function getRecommendations(id: number, kind: Kind) { 71 + const url = `https://api.themoviedb.org/3/${kind}/${id}/recommendations?language=en-US`; 72 + const options = { 73 + method: "GET", 74 + headers: { 75 + accept: "application/json", 76 + Authorization: `Bearer ${env.TMDB_API_KEY}`, 77 + }, 78 + }; 79 + 80 + const response = await fetch(url, options); 81 + const data = await response.json(); 82 + 83 + return data.results; 84 + } 85 + 86 + export async function getWatchProviders(id: number, kind: Kind) { 87 + const url = `https://api.themoviedb.org/3/${kind}/${id}/watch/providers?language=en-US`; 88 + const options = { 89 + method: "GET", 90 + headers: { 91 + accept: "application/json", 92 + Authorization: `Bearer ${env.TMDB_API_KEY}`, 93 + }, 94 + }; 95 + 96 + const response = await fetch(url, options); 97 + const data = await response.json(); 98 + 99 + return data.results; 100 + } 101 + 102 + export async function getCast(id: number, kind: Kind) { 103 + const url = `https://api.themoviedb.org/3/${kind}/${id}/credits?language=en-US`; 104 + const options = { 105 + method: "GET", 106 + headers: { 107 + accept: "application/json", 108 + Authorization: `Bearer ${env.TMDB_API_KEY}`, 109 + }, 110 + }; 111 + 112 + const response = await fetch(url, options); 113 + const data = await response.json(); 114 + 115 + return data.cast; 116 + } 117 + 118 + export async function getItemDetails(ref: string) { 119 + const id = parseInt(ref.split("-")[1]); 120 + const kind = ref.split("-")[0].split(":")[1] === "m" ? "movie" : "tv"; 121 + 122 + const resultPromise = getDetails(id, kind); 123 + 124 + const trailerPromise = getTrailer(id, kind); 125 + 126 + const recommendationsPromise = getRecommendations(id, kind); 127 + 128 + const watchProvidersPromise = getWatchProviders(id, kind); 129 + 130 + const castPromise = getCast(id, kind); 131 + 132 + const [result, trailer, recommendations, watchProviders, cast] = 133 + await Promise.all([ 134 + resultPromise, 135 + trailerPromise, 136 + recommendationsPromise, 137 + watchProvidersPromise, 138 + castPromise, 139 + ]); 140 + 141 + if (!result || result.success === false) { 142 + throw new Error("Not found"); 143 + } 144 + 145 + const reviews = getRecentRecordsByItemRef(ref.split("-")[0], id.toString()); 146 + 147 + return { 148 + ...{ 149 + ...result, 150 + movieId: kind === "movie" ? id : undefined, 151 + showId: kind === "tv" ? id : undefined, 152 + ref: (kind === "movie" ? "tmdb:m-" : "tmdb:s-") + id, 153 + }, 154 + trailer, 155 + recommendations: recommendations.map((item) => { 156 + return { 157 + ...item, 158 + ref: (kind === "movie" ? "tmdb:m-" : "tmdb:s-") + item.id, 159 + }; 160 + }), 161 + kind, 162 + watchProviders, 163 + cast, 164 + reviews, 165 + }; 166 + }
+47
src/api/items.ts
··· 1 + import { getItemDetails } from "../tmdb-new"; 2 + import { ApiCall } from "./types"; 3 + 4 + const calls: { 5 + [key: string]: { 6 + method: string; 7 + handler: (call: ApiCall) => Promise<Response>; 8 + }; 9 + } = { 10 + "/api/items/details": { 11 + method: "GET", 12 + handler: async (call: ApiCall) => { 13 + const ref = call.url.searchParams.get("ref"); 14 + 15 + if (!ref) { 16 + return new Response(JSON.stringify({ error: "Ref is required" }), { 17 + status: 400, 18 + headers: { "Content-Type": "application/json" }, 19 + }); 20 + } 21 + 22 + try { 23 + const details = await getItemDetails(ref); 24 + 25 + return new Response(JSON.stringify(details), { 26 + status: 200, 27 + headers: { "Content-Type": "application/json" }, 28 + }); 29 + } catch (error) { 30 + return new Response(JSON.stringify({ error: "Not Found" }), { 31 + status: 404, 32 + headers: { "Content-Type": "application/json" }, 33 + }); 34 + } 35 + }, 36 + }, 37 + }; 38 + 39 + export async function processItemsApiCall( 40 + call: ApiCall 41 + ): Promise<Response | undefined> { 42 + if (calls[call.path] && calls[call.path].method === call.method) { 43 + return calls[call.path].handler(call); 44 + } 45 + 46 + return undefined; 47 + }
+21 -4
src/db/likes.ts
··· 8 8 createdAt: string; 9 9 }; 10 10 11 - export async function saveLikeToDatabase(json: LikeRecord) { 11 + export function createLikesTable() { 12 + db.run(` 13 + CREATE TABLE IF NOT EXISTS likes ( 14 + uri TEXT PRIMARY KEY, 15 + author_did TEXT NOT NULL, 16 + subject_cid TEXT NOT NULL, 17 + subject_uri TEXT NOT NULL, 18 + createdAt TEXT NOT NULL 19 + ); 20 + 21 + CREATE INDEX IF NOT EXISTS idx_uri ON likes (uri); 22 + CREATE INDEX IF NOT EXISTS idx_author_did ON likes (author_did); 23 + CREATE INDEX IF NOT EXISTS idx_subject_uri ON likes (subject_uri); 24 + CREATE INDEX IF NOT EXISTS idx_createdAt ON likes (createdAt); 25 + `); 26 + } 27 + 28 + export function saveLikeToDatabase(json: LikeRecord) { 12 29 // first check if the like already exists 13 30 const existingLike = db 14 31 .query("SELECT * FROM likes WHERE author_did = ? AND subject_uri = ?") ··· 38 55 ); 39 56 } 40 57 41 - export async function deleteLikeFromDatabase(json: LikeRecord) { 58 + export function deleteLikeFromDatabase(json: LikeRecord) { 42 59 db.query("DELETE FROM likes WHERE uri = ?").run(json.uri); 43 60 } 44 61 45 - export async function getLikesByUser(did: string) { 62 + export function getLikesByUser(did: string) { 46 63 return db 47 64 .query( 48 65 "SELECT * FROM likes WHERE author_did = ? ORDER BY createdAt DESC LIMIT 100" ··· 50 67 .all(did); 51 68 } 52 69 53 - export async function getAllUsersWithLikes() { 70 + export function getAllUsersWithLikes() { 54 71 return db 55 72 .query("SELECT DISTINCT author_did FROM likes") 56 73 .all()
+66
src/types/items.ts
··· 1 + export type Ref = `tmdb:m-${number}` | `tmdb:s-${number}`; 2 + 3 + export type Item = { 4 + id: number; 5 + 6 + ref: Ref; 7 + 8 + title: string; 9 + poster_path: string; 10 + backdrop_path: string; 11 + 12 + overview: string; 13 + 14 + release_date: string; 15 + first_air_date: string; 16 + media_type: "movie" | "tv"; 17 + 18 + genre_ids: number[]; 19 + 20 + vote_average: number; 21 + vote_count: number; 22 + 23 + popularity: number; 24 + 25 + original_title: string; 26 + original_language: string; 27 + 28 + video: boolean; 29 + }; 30 + 31 + export type WatchProviders = Record< 32 + string, 33 + { 34 + link: string; 35 + flatrate: { 36 + logo_path: string; 37 + provider_name: string; 38 + provider_id: number; 39 + display_priority: number; 40 + }[]; 41 + } 42 + >; 43 + 44 + export type Cast = { 45 + adult: boolean; 46 + gender: number; 47 + id: number; 48 + known_for_department: string; 49 + name: string; 50 + original_name: string; 51 + popularity: number; 52 + profile_path: string; 53 + character: string; 54 + credit_id: string; 55 + order: number; 56 + }; 57 + 58 + export type DetailedItem = Item & { 59 + trailer_url?: string; 60 + 61 + recommendations: Item[]; 62 + 63 + watch_providers: WatchProviders; 64 + 65 + cast: Cast[]; 66 + };