diff --git a/.env.template b/.env.template index 0d54d1c..168d621 100644 --- a/.env.template +++ b/.env.template @@ -7,7 +7,8 @@ DB_USER=postgres DB_PASSWORD=supersecurepassword123987 DB_NAME=teal DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}" -DOCKER_DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:5432/${DB_NAME}" +DOCKER_DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}" +REDIS_URL="redis://garnet:6379" # `cargo run --bin teal gen-key` to generate a new pubkey DID_WEB_PUBKEY=zQ3sheEnMKhEK87PSu4P2mjAevViqHcjKmgxBWsDQPjLRM9wP CLIENT_ADDRESS= # A publicly accessible host for amethyst like amethyst.teal.fm @@ -16,10 +17,20 @@ PUBLIC_DID_WEB= # did:web:{aqua's PUBLIC_URL goes here after did:web:} # amethyst EXPO_PUBLIC_DID_WEB= # same as PUBLIC_DID_WEB EXPO_PUBLIC_BASE_URL= # same as CLIENT_ADDRESS but with http scheme like https://amethyst.teal.fm +EXPO_PUBLIC_AQUA_URL= # public Aqua URL, e.g. https://aqua.teal.fm SQLX_OFFLINE=true SQLX_OFFLINE_DIR="./.sqlx" +# Cadet ATProto stream ingestion +CADET_STREAM_MODE=jetstream +JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe +CADET_CURSOR_REDIS_KEY=cadet:jetstream:cursor + +# Optional dev tunnel: +# docker compose -f compose.dev.yml --profile tunnel up +# Set EXPO_PUBLIC_BASE_URL to the Cloudflare URL for ATProto OAuth callback testing. + # Last.fm eval (scripts/eval/evaluate.ts) # Get your API key at https://www.last.fm/api/account/create LASTFM_API_KEY= diff --git a/apps/amethyst/Caddyfile b/apps/amethyst/Caddyfile index ff458bd..58c1373 100644 --- a/apps/amethyst/Caddyfile +++ b/apps/amethyst/Caddyfile @@ -1,5 +1,8 @@ {env.CLIENT_ADDRESS} { + handle /xrpc/* { + reverse_proxy aqua-api:3000 + } + try_files {path} /index.html file_server } - diff --git a/apps/amethyst/Dockerfile b/apps/amethyst/Dockerfile index ce270af..94a15aa 100644 --- a/apps/amethyst/Dockerfile +++ b/apps/amethyst/Dockerfile @@ -1,63 +1,44 @@ FROM node:22-slim AS builder -ARG CLIENT_ADDRESS + +ARG CLIENT_ADDRESS=localhost +ARG EXPO_PUBLIC_BASE_URL +ARG EXPO_PUBLIC_AQUA_URL=http://localhost:3000 +ARG EXPO_PUBLIC_DID_WEB ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -# Set working directory -WORKDIR /app - -# Copy root workspace files -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ - -# Copy turbo.json -COPY turbo.json ./ - -# Copy workspace packages -COPY packages/db/ ./packages/db/ -COPY packages/lexicons/ ./packages/lexicons/ -COPY packages/tsconfig/ ./packages/tsconfig/ - -# Copy lexicons source data -COPY lexicons/ ./lexicons/ +ENV EXPO_PUBLIC_BASE_URL=${EXPO_PUBLIC_BASE_URL} +ENV EXPO_PUBLIC_AQUA_URL=${EXPO_PUBLIC_AQUA_URL} +ENV EXPO_PUBLIC_DID_WEB=${EXPO_PUBLIC_DID_WEB} -# Copy the aqua app -COPY apps/amethyst/ ./apps/amethyst/ +RUN corepack enable -# Copy .env -COPY ../../.env ./apps/amethyst/.env +WORKDIR /app -# Install dependencies and generate lexicons -RUN cd tools/lexicon-cli && pnpm build +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json tsconfig.json ./ +COPY packages ./packages +COPY tools ./tools +COPY lexicons ./lexicons +COPY apps/amethyst ./apps/amethyst -# Generate lexicons before building amethyst +RUN pnpm install --frozen-lockfile --ignore-scripts +RUN pnpm rebuild esbuild unrs-resolver RUN pnpm lex:gen-server -RUN pnpm install - -# Build the amethyst app WORKDIR /app/apps/amethyst RUN pnpm run build:web -#create the client-json -RUN echo '{ \ - "redirect_uris": ["https://'"${CLIENT_ADDRESS}"'/auth/callback"], \ - "response_types": ["code"], \ - "grant_types": ["authorization_code", "refresh_token"], \ - "scope": "atproto transition:generic", \ - "token_endpoint_auth_method": "none", \ - "application_type": "web", \ - "client_id": "https://'"${CLIENT_ADDRESS}"'/client-metadata.json", \ - "client_name": "teal", \ - "client_uri": "https://'"${CLIENT_ADDRESS}"'", \ - "dpop_bound_access_tokens": true \ - }' > /app/client-metadata.json - - -FROM caddy:2.1.0-alpine AS caddy +RUN node -e 'const fs=require("fs"); const host=process.env.CLIENT_ADDRESS || "'"${CLIENT_ADDRESS}"'"; const base=host.startsWith("http") ? host : `https://${host}`; const metadata={redirect_uris:[`${base}/auth/callback`],response_types:["code"],grant_types:["authorization_code","refresh_token"],scope:"atproto transition:generic",token_endpoint_auth_method:"none",application_type:"web",client_id:`${base}/client-metadata.json`,client_name:"teal",client_uri:base,dpop_bound_access_tokens:true}; fs.writeFileSync("/app/client-metadata.json", JSON.stringify(metadata, null, 2));' + +FROM caddy:2.8-alpine + +ARG CLIENT_ADDRESS=:80 +ENV CLIENT_ADDRESS=${CLIENT_ADDRESS} + EXPOSE 80 EXPOSE 443 EXPOSE 443/udp -COPY /apps/amethyst/Caddyfile /etc/caddy/Caddyfile + +COPY apps/amethyst/Caddyfile /etc/caddy/Caddyfile COPY --from=builder /app/apps/amethyst/build /srv COPY --from=builder /app/client-metadata.json /srv/client-metadata.json diff --git a/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/[track].tsx b/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/[track].tsx new file mode 100644 index 0000000..4aed314 --- /dev/null +++ b/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/[track].tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react"; +import { ActivityIndicator, Image, View } from "react-native"; +import { Stack, useLocalSearchParams } from "expo-router"; +import PlayFeedCard from "@/components/songish/PlayFeedCard"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; +import { Text } from "@/components/ui/text"; +import { coverArtUrl, displayArtists, getLatestPlays, getPlayByUri } from "@/lib/teal/api"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; + +export default function MusicDetail() { + const params = useLocalSearchParams(); + const uri = Array.isArray(params.uri) ? params.uri[0] : params.uri; + const [play, setPlay] = useState(null); + const [related, setRelated] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + async function load() { + try { + const selected = uri + ? (await getPlayByUri(uri)).play + : (await getLatestPlays(1)).plays[0]; + const latest = await getLatestPlays(20); + if (!mounted) return; + setPlay(selected); + setRelated( + latest.plays.filter((candidate) => + selected?.trackName + ? candidate.trackName === selected.trackName || + candidate.releaseMbId === selected.releaseMbId + : false, + ), + ); + } catch (e) { + if (mounted) setError(e instanceof Error ? e.message : String(e)); + } + } + load(); + return () => { + mounted = false; + }; + }, [uri]); + + return ( + }> + + {!play && !error && ( + + + + )} + {error && ( + + Could not load music detail: {error} + + )} + {play && ( + <> + + + {coverArtUrl(play.releaseMbId, 500) && ( + + )} + + + {coverArtUrl(play.releaseMbId) ? ( + + ) : ( + + )} + + + {play.trackName} + + + {displayArtists(play) || "Unknown artist"} + + {play.releaseName && ( + {play.releaseName} + )} + + + + Plays + {(related.length ? related : [play]).map((item, index) => ( + + ))} + + )} + + ); +} diff --git a/apps/amethyst/app/(tabs)/_layout.tsx b/apps/amethyst/app/(tabs)/_layout.tsx index 6703ad3..08b05f0 100644 --- a/apps/amethyst/app/(tabs)/_layout.tsx +++ b/apps/amethyst/app/(tabs)/_layout.tsx @@ -2,7 +2,6 @@ import React from "react"; import { Pressable } from "react-native"; import { Link, Tabs } from "expo-router"; import useIsMobile from "@/hooks/useIsMobile"; -//import useIsMobile from "@/hooks/useIsMobile"; import { useStore } from "@/stores/mainStore"; import { FilePen, @@ -16,7 +15,6 @@ import { useColorScheme } from "nativewind"; import Colors from "../../constants/Colors"; import { Icon, iconWithClassName } from "../../lib/icons/iconWithClassName"; -import AuthOptions from "../auth/options"; function TabBarIcon(props: { name: LucideIcon; color: string }) { const Name = props.name; @@ -28,17 +26,7 @@ export default function TabLayout() { const { colorScheme } = useColorScheme(); const authStatus = useStore((state) => state.status); const isMobile = useIsMobile(); - // if we are on web but not native and web width is greater than 1024px - const hideTabBar = authStatus !== "loggedIn"; // || useIsMobile() - - const j = useStore((state) => state.status); - // @me - const agent = useStore((state) => state.pdsAgent); - const profile = useStore((state) => state.profiles[agent?.did ?? ""]); - - if (j !== "loggedIn") { - return ; - } + const hideTabBar = !isMobile; return ( , }} /> + ( ), @@ -100,7 +96,7 @@ export default function TabLayout() { name="settings/index" options={{ title: "Settings", - + href: authStatus === "loggedIn" ? undefined : null, tabBarIcon: ({ color }) => ( ), diff --git a/apps/amethyst/app/(tabs)/index.tsx b/apps/amethyst/app/(tabs)/index.tsx index 3b029d8..5625be2 100644 --- a/apps/amethyst/app/(tabs)/index.tsx +++ b/apps/amethyst/app/(tabs)/index.tsx @@ -1,97 +1,63 @@ -import * as React from "react"; import { useEffect, useState } from "react"; -import { ActivityIndicator, ScrollView, View } from "react-native"; -import { Redirect, Stack, useRouter } from "expo-router"; -import ActorView from "@/components/actor/actorView"; -import { useStore } from "@/stores/mainStore"; - -import { Record as ProfileStatusRecord } from "@teal/lexicons/src/types/fm/teal/alpha/actor/profileStatus"; - -import AuthOptions from "../auth/options"; - -export default function Screen() { - const router = useRouter(); - const j = useStore((state) => state.status); - // @me - const agent = useStore((state) => state.pdsAgent); - const profile = useStore((state) => state.profiles[agent?.did ?? ""]); - const tealDid = useStore((state) => state.tealDid); - const [profileStatus, setProfileStatus] = useState(null); - const [statusLoading, setStatusLoading] = useState(true); +import { ActivityIndicator, View } from "react-native"; +import { Stack } from "expo-router"; +import PlayFeedCard from "@/components/songish/PlayFeedCard"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; +import { Text } from "@/components/ui/text"; +import { getLatestPlays } from "@/lib/teal/api"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; + +export default function HomeScreen() { + const [plays, setPlays] = useState(null); + const [error, setError] = useState(null); useEffect(() => { - let isMounted = true; - - const fetchProfileStatus = async () => { - try { - if (!agent) return; - - const res = await agent.call("com.atproto.repo.getRecord", { - repo: agent.did, - collection: "fm.teal.alpha.actor.profileStatus", - rkey: "self", - }); - - if (isMounted) { - setProfileStatus(res.data.value as ProfileStatusRecord); - } - } catch (error) { - if (isMounted) { - // If no record exists, user hasn't completed onboarding - setProfileStatus(null); - } - console.error("Error fetching profile status:", error); - if ( - error instanceof Error && - error.message.includes("could not resolve proxy did") - ) { - router.replace("/offline"); + let mounted = true; + getLatestPlays(50) + .then((res) => { + if (mounted) setPlays(res.plays); + }) + .catch((e) => { + if (mounted) { + setError(e instanceof Error ? e.message : String(e)); + setPlays([]); } - } finally { - if (isMounted) { - setStatusLoading(false); - } - } - }; - - fetchProfileStatus(); - + }); return () => { - isMounted = false; + mounted = false; }; - }, [agent, router]); - - if (j !== "loggedIn") { - return ; - } - - if (!statusLoading && (!profileStatus || profileStatus.completedOnboarding === "none")) { - return ( - - - - ); - } - - // TODO: replace with skeleton - if (!profile || !agent || statusLoading) { - return ( - - - - ); - } + }, []); return ( - - - - + }> + + {!plays && ( + + + + )} + {error && ( + + + Could not load the Teal play feed: {error} + + + )} + {plays?.length === 0 && !error && ( + + No plays indexed yet. + + Cadet will fill this feed as ATProto firehose records arrive. + + + )} + {plays?.map((play, index) => ( + + ))} + ); } diff --git a/apps/amethyst/app/(tabs)/notifications.tsx b/apps/amethyst/app/(tabs)/notifications.tsx new file mode 100644 index 0000000..3a6424c --- /dev/null +++ b/apps/amethyst/app/(tabs)/notifications.tsx @@ -0,0 +1,31 @@ +import { View } from "react-native"; +import { Link, Stack } from "expo-router"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { useStore } from "@/stores/mainStore"; + +export default function Notifications() { + const status = useStore((state) => state.status); + + return ( + }> + + + + {status === "loggedIn" + ? "Notifications are coming later." + : "You must be signed in to view your notifications."} + + {status !== "loggedIn" && ( + + + + )} + + + ); +} diff --git a/apps/amethyst/app/(tabs)/profile/[handle].tsx b/apps/amethyst/app/(tabs)/profile/[handle].tsx index 801f0bc..33cd201 100644 --- a/apps/amethyst/app/(tabs)/profile/[handle].tsx +++ b/apps/amethyst/app/(tabs)/profile/[handle].tsx @@ -1,44 +1,97 @@ import { useEffect, useState } from "react"; -import { ActivityIndicator, ScrollView, View } from "react-native"; +import { ActivityIndicator, Image, View } from "react-native"; import { Stack, useLocalSearchParams } from "expo-router"; -import ActorView from "@/components/actor/actorView"; +import PlayFeedCard from "@/components/songish/PlayFeedCard"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; import { Text } from "@/components/ui/text"; import { resolveHandle } from "@/lib/atp/pid"; -import { useStore } from "@/stores/mainStore"; +import { getActorFeed, getProfile } from "@/lib/teal/api"; +import type { ProfileView } from "@teal/lexicons/src/types/fm/teal/alpha/actor/defs"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; -export default function Handle() { - let { handle } = useLocalSearchParams(); - - let agent = useStore((state) => state.pdsAgent); - - // resolve handle +export default function ProfileScreen() { + const { handle } = useLocalSearchParams(); + const actor = Array.isArray(handle) ? handle[0] : handle; const [did, setDid] = useState(null); + const [profile, setProfile] = useState(null); + const [plays, setPlays] = useState([]); + const [error, setError] = useState(null); + useEffect(() => { - const fetchAgent = async () => { - const agent = await resolveHandle( - typeof handle === "string" ? handle : handle[0] && handle[0], - ); - setDid(agent); + let mounted = true; + async function load() { + if (!actor) return; + try { + const resolved = actor.startsWith("did:") ? actor : await resolveHandle(actor); + if (!mounted) return; + setDid(resolved); + const [profileRes, feedRes] = await Promise.all([ + getProfile(resolved), + getActorFeed(resolved, 50), + ]); + if (!mounted) return; + setProfile(profileRes.profile); + setPlays(feedRes.plays); + } catch (e) { + if (mounted) setError(e instanceof Error ? e.message : String(e)); + } + } + load(); + return () => { + mounted = false; }; - if (handle !== "undefined") fetchAgent(); - }, [handle]); - - if (handle === "undefined") { - return Handle is undefined; - } - - if (!did) return ; + }, [actor]); return ( - - - - + }> + + {!did && !error && ( + + + + )} + {error && ( + + Could not load profile: {error} + + )} + {did && ( + <> + + + {profile?.banner && ( + + )} + + + + + {(profile?.displayName || actor || "T").slice(0, 1)} + + + + {profile?.displayName || actor} + + {did} + {profile?.description && ( + {profile.description} + )} + + + Plays + {plays.length === 0 ? ( + No indexed plays yet. + ) : ( + plays.map((play, index) => ( + + )) + )} + + )} + ); } diff --git a/apps/amethyst/app/(tabs)/search/index.tsx b/apps/amethyst/app/(tabs)/search/index.tsx index 3388a16..6550eca 100644 --- a/apps/amethyst/app/(tabs)/search/index.tsx +++ b/apps/amethyst/app/(tabs)/search/index.tsx @@ -1,109 +1,16 @@ -import React, { useEffect, useState } from "react"; -import { ScrollView, View } from "react-native"; -import { Link, Stack } from "expo-router"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Input } from "@/components/ui/input"; +import { Stack } from "expo-router"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; import { Text } from "@/components/ui/text"; -import getImageCdnLink from "@/lib/atp/getImageCdnLink"; -import { useStore } from "@/stores/mainStore"; - -import { MiniProfileView } from "@teal/lexicons/src/types/fm/teal/alpha/actor/defs"; -import { OutputSchema as SearchActorsOutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/actor/searchActors"; - -export default function Search() { - const [searchQuery, setSearchQuery] = React.useState(""); - const [searchResults, setSearchResults] = useState([]); - - const tealDid = useStore((state) => state.tealDid); - const agent = useStore((state) => state.pdsAgent); - - useEffect(() => { - let isMounted = true; - - const fetchResults = async () => { - if (!agent || !searchQuery) { - // Don't fetch if searchQuery is empty - setSearchResults([]); // Clear results when searchQuery is empty - return; - } - try { - let res = await agent.call( - "fm.teal.alpha.actor.searchActors", - { q: searchQuery }, - {}, - { headers: { "atproto-proxy": tealDid + "#teal_fm_appview" } }, - ); - if (isMounted) { - setSearchResults( - res.data["actors"] as SearchActorsOutputSchema["actors"], - ); - } - } catch (error) { - console.error("Error fetching profile:", error); - } - }; - - fetchResults(); - - return () => { - isMounted = false; - }; - }, [agent, tealDid, searchQuery]); +export default function Explore() { return ( - - - - - - - {searchResults.map((user) => ( - - - - - - {user.displayName?.substring(0, 1) ?? - user.handle?.substring(0, 1) ?? - "R"} - - - - - {user.displayName} - - {user.handle?.replace("at://", "@")} - - - - ))} - - + }> + + Events + + No events at the moment. + + ); } diff --git a/apps/amethyst/app/_layout.tsx b/apps/amethyst/app/_layout.tsx index 38004b5..1981abd 100644 --- a/apps/amethyst/app/_layout.tsx +++ b/apps/amethyst/app/_layout.tsx @@ -86,10 +86,8 @@ export default function RootLayout() { } return ( - - - - + + ); } diff --git a/apps/amethyst/components/songish/PlayFeedCard.tsx b/apps/amethyst/components/songish/PlayFeedCard.tsx new file mode 100644 index 0000000..bc7d136 --- /dev/null +++ b/apps/amethyst/components/songish/PlayFeedCard.tsx @@ -0,0 +1,99 @@ +import { Image, Pressable, View } from "react-native"; +import { Link } from "expo-router"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { cn, timeAgo } from "@/lib/utils"; +import { coverArtUrl, displayArtists } from "@/lib/teal/api"; +import { Disc3, Headphones, MoreVertical, Play } from "lucide-react-native"; + +import { Text } from "../ui/text"; + +type PlayFeedCardProps = { + play: PlayView; + compact?: boolean; +}; + +function routePart(value?: string) { + return encodeURIComponent( + (value || "unknown") + .toLowerCase() + .replace(/^mbid:/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "unknown", + ); +} + +export function musicHref(play: PlayView) { + return `/:o/music/${routePart(displayArtists(play))}/${routePart(play.releaseName)}/${routePart(play.trackName)}?uri=${encodeURIComponent(play.uri || "")}`; +} + +export default function PlayFeedCard({ play, compact }: PlayFeedCardProps) { + const art = coverArtUrl(play.releaseMbId); + const author = play.authorDid || "unknown listener"; + const when = play.playedTime ? timeAgo(new Date(play.playedTime)) : "recently"; + + return ( + + + + + + + + + + + {author} + + listened {when} + + + + + + + + {play.trackName} + + + {displayArtists(play) || "Unknown artist"} + + + + {art ? ( + + ) : ( + + + + )} + + + + + + + + + + {play.releaseName ? `from ${play.releaseName}` : "a fresh Teal play"} + + + + + ♡ 0 + ◼ 0 + + + + + ); +} diff --git a/apps/amethyst/components/songish/RightRail.tsx b/apps/amethyst/components/songish/RightRail.tsx new file mode 100644 index 0000000..01670df --- /dev/null +++ b/apps/amethyst/components/songish/RightRail.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import type { ArtistView, ReleaseView } from "@teal/lexicons/src/types/fm/teal/alpha/stats/defs"; +import { getTopArtists, getTopReleases } from "@/lib/teal/api"; + +import { Text } from "../ui/text"; + +export default function RightRail() { + const [artists, setArtists] = useState([]); + const [releases, setReleases] = useState([]); + + useEffect(() => { + let mounted = true; + Promise.all([getTopArtists(3), getTopReleases(5)]) + .then(([artistRes, releaseRes]) => { + if (!mounted) return; + setArtists(artistRes.artists); + setReleases(releaseRes.releases); + }) + .catch(() => { + if (!mounted) return; + setArtists([]); + setReleases([]); + }); + return () => { + mounted = false; + }; + }, []); + + return ( + + + Featured Listeners + + + + {artists[0]?.name || "Teal listeners"} + + + {artists[0]?.playCount ? `${artists[0].playCount} plays indexed` : "live from ATProto"} + + + + + Trending Releases + + {releases.length === 0 ? ( + Waiting for plays. + ) : ( + releases.map((release) => ( + + + {release.name} + + + {release.playCount} + + + )) + )} + + + ); +} diff --git a/apps/amethyst/components/songish/SongishShell.tsx b/apps/amethyst/components/songish/SongishShell.tsx new file mode 100644 index 0000000..f2339b2 --- /dev/null +++ b/apps/amethyst/components/songish/SongishShell.tsx @@ -0,0 +1,197 @@ +import { ReactNode } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { Link, usePathname } from "expo-router"; +import useIsMobile from "@/hooks/useIsMobile"; +import { useStore } from "@/stores/mainStore"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { Bell, Home, LogIn, Search, UserCircle } from "lucide-react-native"; + +import { Text } from "../ui/text"; + +type SongishShellProps = { + children: ReactNode; + rightRail?: ReactNode; + title?: string; +}; + +function RecordLogo() { + return ( + + + + + + + + Teal + + + ); +} + +function NavItem({ + href, + icon, + label, + active, +}: { + href: string; + icon: any; + label: string; + active: boolean; +}) { + return ( + + + + + {label} + + + + ); +} + +function LeftRail() { + const pathname = usePathname(); + const status = useStore((state) => state.status); + const agent = useStore((state) => state.pdsAgent); + + return ( + + + + + + + + + + + + + + + {status === "loggedIn" ? "Profile" : "Login"} + + + → + + + + ); +} + +function MobileNav() { + const pathname = usePathname(); + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default function SongishShell({ + children, + rightRail, + title, +}: SongishShellProps) { + const isMobile = useIsMobile(); + + return ( + + + + teal is in active development: expect bugs, missing features, and regular index rebuilds + + + + + + {title && ( + + {title} + + )} + {children} + + + {!isMobile && ( + + {rightRail} + + )} + + + + ); +} diff --git a/apps/amethyst/lib/teal/api.ts b/apps/amethyst/lib/teal/api.ts new file mode 100644 index 0000000..7246ea1 --- /dev/null +++ b/apps/amethyst/lib/teal/api.ts @@ -0,0 +1,155 @@ +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; +import type { ProfileView } from "@teal/lexicons/src/types/fm/teal/alpha/actor/defs"; +import type { ArtistView, ReleaseView } from "@teal/lexicons/src/types/fm/teal/alpha/stats/defs"; + +const rawBase = + process.env.EXPO_PUBLIC_AQUA_URL || + process.env.EXPO_PUBLIC_APPVIEW_URL || + ""; +const demoFallbackEnabled = process.env.EXPO_PUBLIC_ENABLE_DEMO_FALLBACK === "true"; + +const requestBase = + rawBase || (typeof window === "undefined" ? "http://localhost:3000" : window.location.origin); +const xrpcBase = requestBase.endsWith("/xrpc") + ? requestBase + : `${requestBase.replace(/\/$/, "")}/xrpc`; + +const demoPlays: PlayView[] = [ + { + uri: "at://did:plc:tealpreview/fm.teal.alpha.feed.play/3demo001", + cid: "bafyreitealpreview001", + authorDid: "did:plc:tealpreview", + rkey: "3demo001", + trackName: "Everything In Its Right Place", + artists: [{ artistName: "Radiohead" }], + releaseName: "Kid A", + musicServiceBaseDomain: "music.apple.com", + submissionClientAgent: "teal-preview/0.1", + playedTime: new Date(Date.now() - 7 * 60 * 1000).toISOString(), + }, + { + uri: "at://did:plc:amethystpreview/fm.teal.alpha.feed.play/3demo002", + cid: "bafyreitealpreview002", + authorDid: "did:plc:amethystpreview", + rkey: "3demo002", + trackName: "Archie, Marry Me", + artists: [{ artistName: "Alvvays" }], + releaseName: "Alvvays", + musicServiceBaseDomain: "spotify.com", + submissionClientAgent: "teal-preview/0.1", + playedTime: new Date(Date.now() - 22 * 60 * 1000).toISOString(), + }, + { + uri: "at://did:plc:cadetpreview/fm.teal.alpha.feed.play/3demo003", + cid: "bafyreitealpreview003", + authorDid: "did:plc:cadetpreview", + rkey: "3demo003", + trackName: "A Walk", + artists: [{ artistName: "Tycho" }], + releaseName: "Dive", + musicServiceBaseDomain: "tidal.com", + submissionClientAgent: "teal-preview/0.1", + playedTime: new Date(Date.now() - 48 * 60 * 1000).toISOString(), + }, +]; + +const demoArtists: ArtistView[] = [ + { name: "Radiohead", playCount: 128 }, + { name: "Alvvays", playCount: 96 }, + { name: "Tycho", playCount: 74 }, +]; + +const demoReleases: ReleaseView[] = [ + { name: "Kid A", playCount: 42 }, + { name: "Alvvays", playCount: 31 }, + { name: "Dive", playCount: 26 }, +]; + +function demoResponse(method: string): T | undefined { + if (!demoFallbackEnabled) return undefined; + + if (method === "fm.teal.alpha.stats.getLatest") return { plays: demoPlays } as T; + if (method === "fm.teal.alpha.stats.getTopArtists") return { artists: demoArtists } as T; + if (method === "fm.teal.alpha.stats.getTopReleases") return { releases: demoReleases } as T; + + return undefined; +} + +async function getXrpc( + method: string, + params: Record = {}, +): Promise { + const url = new URL(`${xrpcBase}/${method}`); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + }); + + try { + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`${method} failed with ${response.status}`); + } + const result = (await response.json()) as T; + const fallback = demoResponse(method); + if ( + fallback && + typeof result === "object" && + result !== null && + Object.values(result).some((value) => Array.isArray(value) && value.length === 0) + ) { + return fallback; + } + return result; + } catch (error) { + const fallback = demoResponse(method); + if (fallback) return fallback; + throw error; + } +} + +export function getLatestPlays(limit = 50) { + return getXrpc<{ plays: PlayView[] }>("fm.teal.alpha.stats.getLatest", { + limit, + }); +} + +export function getActorFeed(authorDID: string, limit = 50) { + return getXrpc<{ plays: PlayView[] }>("fm.teal.alpha.feed.getActorFeed", { + authorDID, + limit, + }); +} + +export function getPlayByUri(uri: string) { + return getXrpc<{ play: PlayView }>("fm.teal.alpha.feed.getPlay", { uri }); +} + +export function getProfile(actor: string) { + return getXrpc<{ profile: ProfileView }>("fm.teal.alpha.actor.getProfile", { + actor, + }); +} + +export function getTopArtists(limit = 5) { + return getXrpc<{ artists: ArtistView[] }>("fm.teal.alpha.stats.getTopArtists", { + limit, + }); +} + +export function getTopReleases(limit = 5) { + return getXrpc<{ releases: ReleaseView[] }>( + "fm.teal.alpha.stats.getTopReleases", + { limit }, + ); +} + +export function coverArtUrl(releaseMbId?: string, size = 250) { + const mbid = releaseMbId?.replace(/^mbid:/, ""); + return mbid ? `https://coverartarchive.org/release/${mbid}/front-${size}` : undefined; +} + +export function displayArtists(play: PlayView) { + return play.artists.map((artist) => artist.artistName).join(", "); +} diff --git a/apps/aqua/src/repos/feed_play.rs b/apps/aqua/src/repos/feed_play.rs index 2f95a57..6b28af6 100644 --- a/apps/aqua/src/repos/feed_play.rs +++ b/apps/aqua/src/repos/feed_play.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use jacquard_common::from_json_value; +use jacquard_common::types::string::{AtUri, Did}; use types::fm_teal::alpha::feed::{Artist, PlayView}; use super::{mbid_uri, pg::PgDataSource, utc_to_atrium_datetime}; @@ -51,6 +52,10 @@ impl FeedPlayRepo for PgDataSource { Ok(Some(PlayView { track_name: row.track_name.clone().into(), + uri: AtUri::try_from(row.uri.clone()).ok(), + cid: Some(row.cid.clone().into()), + author_did: Did::new_owned(&row.did).ok(), + rkey: Some(row.rkey.clone().into()), track_mb_id: row.recording_mbid.map(mbid_uri), recording_mb_id: row.recording_mbid.map(mbid_uri), duration: row.duration.map(|d| d as i64), @@ -110,6 +115,10 @@ impl FeedPlayRepo for PgDataSource { result.push(PlayView { track_name: row.track_name.clone().into(), + uri: AtUri::try_from(row.uri.clone()).ok(), + cid: Some(row.cid.clone().into()), + author_did: Did::new_owned(&row.did).ok(), + rkey: Some(row.rkey.clone().into()), track_mb_id: row.recording_mbid.map(mbid_uri), recording_mb_id: row.recording_mbid.map(mbid_uri), duration: row.duration.map(|d| d as i64), diff --git a/apps/aqua/src/repos/stats.rs b/apps/aqua/src/repos/stats.rs index 0049671..7e4634e 100644 --- a/apps/aqua/src/repos/stats.rs +++ b/apps/aqua/src/repos/stats.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use jacquard_common::from_json_value; +use jacquard_common::types::string::{AtUri, Did}; use types::fm_teal::alpha::feed::PlayView; use types::fm_teal::alpha::stats::{ArtistView, ReleaseView}; @@ -223,6 +224,10 @@ impl StatsRepo for PgDataSource { result.push(PlayView { track_name: row.track_name.into(), + uri: AtUri::try_from(row.uri.clone()).ok(), + cid: Some(row.cid.clone().into()), + author_did: Did::new_owned(&row.did).ok(), + rkey: Some(row.rkey.clone().into()), track_mb_id: row.recording_mbid.map(mbid_uri), recording_mb_id: row.recording_mbid.map(mbid_uri), duration: row.duration.map(|d| d as i64), diff --git a/apps/aqua/src/xrpc/feed.rs b/apps/aqua/src/xrpc/feed.rs index b9fa97f..aaaf759 100644 --- a/apps/aqua/src/xrpc/feed.rs +++ b/apps/aqua/src/xrpc/feed.rs @@ -8,12 +8,16 @@ use types::fm_teal::alpha::feed::PlayView; pub fn feed_routes() -> axum::Router { axum::Router::new() .route("/fm.teal.alpha.feed.getPlay", get(get_feed_play)) + .route("/fm.teal.alpha.feed.getActorFeed", get(get_actor_feed)) .route("/fm.teal.alpha.feed.getPlays", get(get_feed_plays)) } #[derive(Deserialize)] pub struct GetFeedPlayQuery { - pub identity: Option, + #[serde(rename = "authorDID")] + pub author_did: Option, + pub rkey: Option, + pub uri: Option, } #[derive(Serialize)] @@ -26,16 +30,20 @@ pub async fn get_feed_play( axum::extract::Query(query): axum::extract::Query, ) -> Result { let repo = &ctx.db; - let identity = &query.identity; + let uri = match (query.uri, query.author_did, query.rkey) { + (Some(uri), _, _) => uri, + (None, Some(author_did), Some(rkey)) => { + format!("at://{author_did}/fm.teal.alpha.feed.play/{rkey}") + } + _ => { + return Err(( + StatusCode::BAD_REQUEST, + "uri or authorDID and rkey are required".to_string(), + )); + } + }; - if identity.is_none() { - return Err((StatusCode::BAD_REQUEST, "identity is required".to_string())); - } - - match repo - .get_feed_play(identity.as_ref().expect("identity is not none").as_str()) - .await - { + match repo.get_feed_play(&uri).await { Ok(Some(play)) => Ok(axum::Json(GetFeedPlayResponse { play: play.into_static(), })), @@ -44,6 +52,40 @@ pub async fn get_feed_play( } } +#[derive(Deserialize)] +pub struct GetActorFeedQuery { + #[serde(rename = "authorDID")] + pub author_did: String, + pub limit: Option, + pub cursor: Option, +} + +#[derive(Serialize)] +pub struct GetActorFeedResponse { + plays: Vec, +} + +pub async fn get_actor_feed( + Extension(ctx): Extension, + axum::extract::Query(query): axum::extract::Query, +) -> Result { + let repo = &ctx.db; + + if query.author_did.is_empty() { + return Err((StatusCode::BAD_REQUEST, "authorDID is required".to_string())); + } + + // Cursor and limit are accepted for lexicon compatibility; repository pagination is deferred. + let _ = (query.limit, query.cursor); + + match repo.get_feed_plays_for_profile(&[query.author_did]).await { + Ok(plays) => Ok(axum::Json(GetActorFeedResponse { + plays: plays.into_static(), + })), + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + } +} + #[derive(Deserialize)] pub struct GetFeedPlaysQuery { pub identities: Vec, diff --git a/compose.dev.yml b/compose.dev.yml index dc68dbc..5fc8915 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -1,4 +1,22 @@ services: + amethyst: + build: + context: . + dockerfile: apps/amethyst/Dockerfile + args: + CLIENT_ADDRESS: ${CLIENT_ADDRESS:-localhost} + EXPO_PUBLIC_BASE_URL: ${EXPO_PUBLIC_BASE_URL:-http://localhost:8081} + EXPO_PUBLIC_AQUA_URL: ${EXPO_PUBLIC_AQUA_URL:-http://localhost:3000} + EXPO_PUBLIC_DID_WEB: ${EXPO_PUBLIC_DID_WEB:-did:web:localhost} + ports: + - "8081:80" + environment: + CLIENT_ADDRESS: ${CLIENT_ADDRESS:-:80} + networks: + - app_network + depends_on: + - aqua-api + aqua-api: build: context: . @@ -6,45 +24,35 @@ services: container_name: aqua-app ports: - "3000:3000" - extra_hosts: - - "host.docker.internal:host-gateway" - networks: - - app_network - depends_on: - - postgres env_file: - .env environment: - DATABASE_URL: ${DOCKER_DB_URL} - amethyst: - build: - context: . - dockerfile: apps/amethyst/Dockerfile - args: - - CLIENT_ADDRESS=${CLIENT_ADDRESS} - ports: - - "80:80" - - "443:443" - - "443:443/udp" - volumes: - - caddy_data:/data - - caddy_config:/config + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + REDIS_URL: ${REDIS_URL:-redis://garnet:6379} networks: - app_network - environment: - CLIENT_ADDRESS: ${CLIENT_ADDRESS} + depends_on: + - postgres + - garnet cadet: build: context: . dockerfile: services/cadet/Dockerfile container_name: cadet-app - ports: - - "3001:3000" + env_file: + - .env + environment: + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + REDIS_URL: ${REDIS_URL:-redis://garnet:6379} + CADET_STREAM_MODE: ${CADET_STREAM_MODE:-jetstream} + JETSTREAM_URL: ${JETSTREAM_URL:-wss://jetstream1.us-east.bsky.network/subscribe} + CADET_CURSOR_REDIS_KEY: ${CADET_CURSOR_REDIS_KEY:-cadet:jetstream:cursor} networks: - app_network depends_on: - postgres + - garnet satellite: image: ghcr.io/espeon/satellite @@ -53,24 +61,21 @@ services: env_file: - .env environment: - DATABASE_URL: ${DOCKER_DB_URL} - extra_hosts: - - "host.docker.internal:host-gateway" + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} networks: - app_network depends_on: - postgres - piper: - image: ghcr.io/teal-fm/piper:main - # Depends on your .env.air - ports: - - "8080:8080" - env_file: - - .env.air - volumes: - - piper_data:/db - - garnet + cloudflared: + image: cloudflare/cloudflared:latest + profiles: + - tunnel + command: tunnel --no-autoupdate --url http://amethyst:80 + networks: + - app_network + depends_on: + - amethyst garnet: image: ghcr.io/microsoft/garnet:latest @@ -79,48 +84,27 @@ services: - "6379:6379" volumes: - garnet_data:/data - command: --storage-tier Storage --index-size 1g networks: - app_network postgres: - image: postgres:latest + image: postgres:16 container_name: postgres_db environment: - POSTGRES_USER: ${DB_USER} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER:-teal} + POSTGRES_PASSWORD: ${DB_PASSWORD:-teal} + POSTGRES_DB: ${DB_NAME:-teal} ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - extra_hosts: - - "host.docker.internal:host-gateway" networks: - app_network - # traefik: - # image: traefik:v2.10 - # container_name: traefik - # command: - # - "--api.insecure=true" - # - "--providers.file.directory=/etc/traefik/dynamic" - # - "--providers.file.watch=true" - # - "--entrypoints.web.address=:80" - # ports: - # - "80:80" # HTTP - # - "8080:8080" # Dashboard - # volumes: - # - ./traefik/dynamic:/etc/traefik/dynamic:ro - # networks: - # - app_network - # extra_hosts: - # - "host.docker.internal:host-gateway" # This allows reaching host machine + networks: app_network: driver: bridge + volumes: - postgres_data: - caddy_data: - caddy_config: - piper_data: garnet_data: + postgres_data: diff --git a/compose.yaml b/compose.yaml index 4832975..9fb15fd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,27 @@ services: + amethyst: + build: + context: . + dockerfile: apps/amethyst/Dockerfile + args: + CLIENT_ADDRESS: ${CLIENT_ADDRESS:-localhost} + EXPO_PUBLIC_BASE_URL: ${EXPO_PUBLIC_BASE_URL:-http://localhost} + EXPO_PUBLIC_AQUA_URL: ${EXPO_PUBLIC_AQUA_URL:-http://localhost:3000} + EXPO_PUBLIC_DID_WEB: ${EXPO_PUBLIC_DID_WEB:-did:web:localhost} + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + CLIENT_ADDRESS: ${CLIENT_ADDRESS:-:80} + volumes: + - caddy_data:/data + - caddy_config:/config + networks: + - app_network + depends_on: + - aqua-api + aqua-api: build: context: . @@ -6,10 +29,79 @@ services: container_name: aqua-app ports: - "3000:3000" + env_file: + - .env + environment: + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + REDIS_URL: ${REDIS_URL:-redis://garnet:6379} + networks: + - app_network + depends_on: + - postgres + - garnet + + cadet: + build: + context: . + dockerfile: services/cadet/Dockerfile + container_name: cadet-app + env_file: + - .env + environment: + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + REDIS_URL: ${REDIS_URL:-redis://garnet:6379} + CADET_STREAM_MODE: ${CADET_STREAM_MODE:-jetstream} + JETSTREAM_URL: ${JETSTREAM_URL:-wss://jetstream1.us-east.bsky.network/subscribe} + CADET_CURSOR_REDIS_KEY: ${CADET_CURSOR_REDIS_KEY:-cadet:jetstream:cursor} networks: - app_network depends_on: - postgres - garnet + + satellite: + image: ghcr.io/espeon/satellite env_file: - .env + environment: + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + ports: + - "3132:3000" + networks: + - app_network + depends_on: + - postgres + + garnet: + image: ghcr.io/microsoft/garnet:latest + container_name: garnet + ports: + - "6379:6379" + volumes: + - garnet_data:/data + networks: + - app_network + + postgres: + image: postgres:16 + container_name: postgres_db + environment: + POSTGRES_USER: ${DB_USER:-teal} + POSTGRES_PASSWORD: ${DB_PASSWORD:-teal} + POSTGRES_DB: ${DB_NAME:-teal} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - app_network + +networks: + app_network: + driver: bridge + +volumes: + caddy_data: + caddy_config: + garnet_data: + postgres_data: diff --git a/lexicons/fm.teal.alpha/feed/defs.json b/lexicons/fm.teal.alpha/feed/defs.json index ee26698..265f0f7 100644 --- a/lexicons/fm.teal.alpha/feed/defs.json +++ b/lexicons/fm.teal.alpha/feed/defs.json @@ -7,6 +7,24 @@ "type": "object", "required": ["trackName", "artists"], "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "The AT URI for this play record" + }, + "cid": { + "type": "string", + "description": "The CID for this play record" + }, + "authorDid": { + "type": "string", + "format": "did", + "description": "The DID of the account that authored this play" + }, + "rkey": { + "type": "string", + "description": "The record key for this play" + }, "trackName": { "type": "string", "minLength": 1, diff --git a/packages/lexicons/lex-gen.sh b/packages/lexicons/lex-gen.sh index bd5e6b0..f4b43ef 100755 --- a/packages/lexicons/lex-gen.sh +++ b/packages/lexicons/lex-gen.sh @@ -1,9 +1,12 @@ #!/bin/bash set -e -# Navigate to the lexicons directory and find all .json files +# Navigate to the lexicons directory and find Teal schemas plus the upstream +# schemas referenced by Teal records. Avoid generating the full ATProto tree: +# newer upstream lexicons may use syntax unsupported by this repo's lex-cli. cd ../../lexicons -json_files=$(find . -name "*.json" -type f) +json_files=$(find ./fm.teal.alpha -name "*.json" -type f) +json_files="$json_files ./app/bsky/richtext/facet.json" # Go back to the lexicons package directory cd ../packages/lexicons diff --git a/services/cadet/src/cursor.rs b/services/cadet/src/cursor.rs index d2a35a2..186236c 100644 --- a/services/cadet/src/cursor.rs +++ b/services/cadet/src/cursor.rs @@ -1,14 +1,85 @@ +use redis::AsyncCommands; +use tracing::warn; + +fn cursor_file() -> String { + std::env::var("CURSOR_FILE").unwrap_or_else(|_| "./cursor.txt".to_string()) +} + +fn cursor_key() -> String { + std::env::var("CADET_CURSOR_REDIS_KEY").unwrap_or_else(|_| "cadet:jetstream:cursor".to_string()) +} + +async fn redis_connection() -> anyhow::Result { + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let client = redis::Client::open(redis_url)?; + Ok(client.get_multiplexed_async_connection().await?) +} + pub async fn store_cursor(cursor: u64) -> anyhow::Result<()> { - // get cursor location from env CURSOR_FILE - let cursor_file = std::env::var("CURSOR_FILE").unwrap_or_else(|_| "./cursor.txt".to_string()); - tokio::fs::write(cursor_file, cursor.to_string()).await?; - Ok(()) + match redis_connection().await { + Ok(mut conn) => { + let _: () = conn.set(cursor_key(), cursor).await?; + Ok(()) + } + Err(e) => { + warn!( + "Redis cursor store unavailable, falling back to file: {}", + e + ); + tokio::fs::write(cursor_file(), cursor.to_string()).await?; + Ok(()) + } + } } pub async fn load_cursor() -> Option { - let cursor_file = std::env::var("CURSOR_FILE").unwrap_or_else(|_| "./cursor.txt".to_string()); - tokio::fs::read_to_string(cursor_file) + if let Ok(mut conn) = redis_connection().await { + match conn.get::<_, Option>(cursor_key()).await { + Ok(Some(cursor)) => return Some(cursor), + Ok(None) => {} + Err(e) => warn!("Redis cursor load failed, falling back to file: {}", e), + } + } + + tokio::fs::read_to_string(cursor_file()) .await .ok() .and_then(|s| s.parse().ok()) } + +#[cfg(test)] +mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + fn unique_cursor_file() -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + std::env::temp_dir().join(format!("cadet-cursor-{suffix}.txt")) + } + + #[tokio::test] + async fn stores_and_loads_cursor_from_file_when_redis_is_unavailable() { + let cursor_file = unique_cursor_file(); + std::env::set_var("REDIS_URL", "redis://127.0.0.1:0"); + std::env::set_var("CURSOR_FILE", &cursor_file); + std::env::set_var("CADET_CURSOR_REDIS_KEY", "cadet:test:cursor"); + + store_cursor(42).await.expect("cursor should store"); + + let loaded = load_cursor().await; + assert_eq!(loaded, Some(42)); + + let _ = tokio::fs::remove_file(cursor_file).await; + std::env::remove_var("REDIS_URL"); + std::env::remove_var("CURSOR_FILE"); + std::env::remove_var("CADET_CURSOR_REDIS_KEY"); + } +} diff --git a/services/cadet/src/ingestors/teal/feed_play.rs b/services/cadet/src/ingestors/teal/feed_play.rs index 745b599..32e84e7 100644 --- a/services/cadet/src/ingestors/teal/feed_play.rs +++ b/services/cadet/src/ingestors/teal/feed_play.rs @@ -1576,6 +1576,10 @@ impl PlayIngestor { } async fn remove_play(&self, uri: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM play_to_artists_extended WHERE play_uri = $1") + .bind(uri) + .execute(&self.sql) + .await?; sqlx::query!("DELETE FROM play_to_artists WHERE play_uri = $1", uri) .execute(&self.sql) .await?; @@ -1609,8 +1613,9 @@ impl LexiconIngestor for PlayIngestor { } } } else { - println!("{}: Message {} deleted", message.did, commit.rkey); - self.remove_play(&message.did).await?; + let uri = assemble_at_uri(&message.did, &commit.collection, &commit.rkey); + tracing::info!("{}: Play {} deleted", message.did, uri); + self.remove_play(&uri).await?; } } else { return Err(anyhow!("Message has no commit")); diff --git a/services/cadet/src/main.rs b/services/cadet/src/main.rs index 7b7fe49..3d6be94 100644 --- a/services/cadet/src/main.rs +++ b/services/cadet/src/main.rs @@ -5,10 +5,11 @@ use std::{ use cursor::load_cursor; use metrics_exporter_prometheus::PrometheusBuilder; -use tracing::error; +use tracing::{error, info}; use rocketman::{ connection::JetstreamConnection, + endpoints::JetstreamEndpoints, handler, ingestion::{DefaultLexiconIngestor, LexiconIngestor}, options::JetstreamOptions, @@ -43,11 +44,30 @@ async fn main() { setup_tracing(); setup_metrics(); + let stream_mode = + std::env::var("CADET_STREAM_MODE").unwrap_or_else(|_| "jetstream".to_string()); + let jetstream_url = std::env::var("JETSTREAM_URL") + .unwrap_or_else(|_| "wss://jetstream1.us-east.bsky.network/subscribe".to_string()); + + if stream_mode != "jetstream" { + error!( + "Unsupported CADET_STREAM_MODE={}. subscribeRepos is reserved for a later CBOR firehose adapter; use jetstream for now.", + stream_mode + ); + std::process::exit(1); + } + + info!( + "Starting Cadet in {} mode with Jetstream endpoint {}", + stream_mode, jetstream_url + ); + let pool = db::init_pool() .await .expect("Could not get PostgreSQL pool"); let opts = JetstreamOptions::builder() + .ws_url(JetstreamEndpoints::Custom(jetstream_url.clone())) .wanted_collections( [ "fm.teal.alpha.feed.play", diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..dfaf14a --- /dev/null +++ b/todo.md @@ -0,0 +1,65 @@ +# Teal Songish Clone TODO + +This file is the working handoff for the Songish-style Teal clone. Keep it updated as implementation and QA move forward. + +## Current State + +- Amethyst has a Teal-branded Songish-style shell with desktop navigation, mobile navigation, Home, Explore, Notifications, Profile, and music detail views. +- Aqua exposes Teal XRPC routes for latest plays, individual plays, actor feeds, profiles, and stats. +- Cadet consumes Teal records from Jetstream, stores a durable cursor in Redis with file fallback, ingests profiles and plays, and deletes plays by AT URI. +- Development and production Compose files include Amethyst, Aqua, Cadet, Satellite, Postgres, and Garnet. +- Development Compose includes an optional Cloudflare Tunnel profile. +- Current temporary UI preview: `https://directory-extensive-viewer-agreement.trycloudflare.com` + - This is an account-less Cloudflare quick tunnel. It remains available while the local tunnel process is running and its hostname will change after restart. + - The preview serves the current Amethyst export and proxies `/xrpc/*` to the locally running Aqua API through the same public hostname. + - The current preview build enables a demo fallback when Aqua returns an empty feed, so the UI remains inspectable while Cadet fills the local index from Jetstream. + - The current preview build embeds `EXPO_PUBLIC_BASE_URL=https://directory-extensive-viewer-agreement.trycloudflare.com` and serves a matching `/client-metadata.json` OAuth redirect. + - OAuth callback testing still requires the stable-host work below. + +## Next: Public Demo And OAuth + +- [ ] Reserve a stable Cloudflare Tunnel hostname for development OAuth testing. Quick tunnels are useful for UI previews but their random hostnames change after restart. +- [ ] Route the stable public hostname to Amethyst and expose Aqua through a public HTTPS origin or a same-origin reverse proxy. +- [ ] Build Amethyst with `EXPO_PUBLIC_BASE_URL=https://` and `EXPO_PUBLIC_AQUA_URL=https://`. +- [ ] Serve `/client-metadata.json` with `redirect_uris=["https:///auth/callback"]`. +- [ ] Complete ATProto OAuth sign-in and callback QA through the stable public hostname. +- [ ] Document the stable tunnel token or named-tunnel setup without committing secrets. + +## Next: Firehose Ingestion + +- [ ] Add Cadet create, update, and delete integration tests for `fm.teal.alpha.feed.play`. +- [ ] Add profile ingestion integration tests for `fm.teal.alpha.actor.profile`. +- [ ] Verify Jetstream filtering against `wantedCollections=fm.teal.alpha.feed.play` in a live environment. +- [ ] Verify Cadet cursor recovery after restart with Garnet enabled. +- [ ] Verify delete handling removes the play URI from `plays`, `play_to_artists`, and `play_to_artists_extended`. +- [ ] Add a `subscribeRepos` CBOR adapter only if relay-level firehose sync becomes necessary. +- [ ] Keep CAR import as a backfill path and add regression tests for it. + +## Next: Aqua And Lexicons + +- [ ] Add pagination support for `fm.teal.alpha.feed.getActorFeed` cursor and limit parameters. +- [ ] Run SQLx prepare against the development Postgres instance and commit refreshed query cache data. +- [ ] Resolve the existing Satellite SQLx offline-cache gap so `pnpm turbo run test:rust` passes without a live Docker hostname. +- [ ] Decide whether the legacy `play_to_artists` join table can be removed after Aqua reads move fully to `play_to_artists_extended`. +- [ ] Validate the Teal lexicons and regenerate Rust and TypeScript bindings before each PR. + +## Next: Amethyst UI + +- [ ] Finish profile avatar and banner blob URL rendering. +- [ ] Add artist and release detail routes in addition to track detail. +- [ ] Render real Cover Art Archive images for recordings with MusicBrainz IDs and polished fallbacks for missing art. +- [ ] Exercise empty, loading, error, signed-out, and populated feed states at desktop and mobile widths. +- [ ] Verify SPA fallback routing in the production Caddy image for Home, Explore, Notifications, Profile, music detail, and OAuth callback routes. +- [ ] Capture final Chrome screenshots after Aqua and Cadet are running with live ingested data. + +## Verification Commands + +```bash +pnpm lex:gen-server +SQLX_OFFLINE=true cargo check -p aqua -p cadet +SQLX_OFFLINE=true cargo test -p cadet stores_and_loads_cursor_from_file_when_redis_is_unavailable +pnpm --filter=@teal/amethyst build:web +docker compose -f compose.dev.yml config +docker compose -f compose.yaml config +docker compose -f compose.dev.yml --profile tunnel up +```