diff --git a/server/lib/orcasite/radio/feed.ex b/server/lib/orcasite/radio/feed.ex index 5723cd5a..2fb0b0df 100644 --- a/server/lib/orcasite/radio/feed.ex +++ b/server/lib/orcasite/radio/feed.ex @@ -35,7 +35,7 @@ defmodule Orcasite.Radio.Feed do attribute :intro_html, :string, default: "", public?: true attribute :image_url, :string, default: "", public?: true attribute :visible, :boolean, default: true, public?: true - attribute :bucket, :string, public?: true + attribute :bucket, :string, public?: true, allow_nil?: false attribute :bucket_region, :string, public?: true attribute :cloudfront_url, :string, public?: true attribute :dataplicity_id, :string, public?: true diff --git a/server/priv/repo/migrations/20240909214708_update_feed_bucket_non_null.exs b/server/priv/repo/migrations/20240909214708_update_feed_bucket_non_null.exs new file mode 100644 index 00000000..7ed62b39 --- /dev/null +++ b/server/priv/repo/migrations/20240909214708_update_feed_bucket_non_null.exs @@ -0,0 +1,21 @@ +defmodule Orcasite.Repo.Migrations.UpdateFeedBucketNonNull do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:feeds) do + modify :bucket, :text, null: false + end + end + + def down do + alter table(:feeds) do + modify :bucket, :text, null: true + end + end +end diff --git a/server/priv/resource_snapshots/repo/feeds/20240909214708.json b/server/priv/resource_snapshots/repo/feeds/20240909214708.json new file mode 100644 index 00000000..d8086d70 --- /dev/null +++ b/server/priv/resource_snapshots/repo/feeds/20240909214708.json @@ -0,0 +1,317 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "node_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "location_point", + "type": "geometry" + }, + { + "allow_nil?": true, + "default": "\"\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "intro_html", + "type": "text" + }, + { + "allow_nil?": true, + "default": "\"\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "image_url", + "type": "text" + }, + { + "allow_nil?": true, + "default": "true", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "visible", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "bucket", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "bucket_region", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "cloudfront_url", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "dataplicity_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "orcahello_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "name" + ], + "fields": [ + { + "type": "atom", + "value": "name" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + }, + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "node_name" + ], + "fields": [ + { + "type": "atom", + "value": "node_name" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + }, + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "visible" + ], + "fields": [ + { + "type": "atom", + "value": "visible" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + }, + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "slug" + ], + "fields": [ + { + "type": "atom", + "value": "slug" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + }, + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "dataplicity_id" + ], + "fields": [ + { + "type": "atom", + "value": "dataplicity_id" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + }, + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "orcahello_id" + ], + "fields": [ + { + "type": "atom", + "value": "orcahello_id" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "7FDF1C3B2AAFBFB7E5DFBE36CD16249F0B95A8F78F8927EE095669FC0F259BB4", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "feeds_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Orcasite.Repo", + "schema": null, + "table": "feeds" +} \ No newline at end of file diff --git a/server/test/orcasite_web/graphql/moderator_test.exs b/server/test/orcasite_web/graphql/moderator_test.exs index 642d3a86..7bf17c0f 100644 --- a/server/test/orcasite_web/graphql/moderator_test.exs +++ b/server/test/orcasite_web/graphql/moderator_test.exs @@ -29,6 +29,7 @@ defmodule OrcasiteWeb.ModeratorTest do lat_lng_string: "48.5583362, -123.1735774", name: "Orcasound Lab (Haro Strait)", node_name: "rpi_orcasound_lab", + bucket: "streaming-orcasound-net", slug: "orcasound-lab" } ) diff --git a/server/test/orcasite_web/graphql/radio_test.exs b/server/test/orcasite_web/graphql/radio_test.exs index d940405b..603cd7ed 100644 --- a/server/test/orcasite_web/graphql/radio_test.exs +++ b/server/test/orcasite_web/graphql/radio_test.exs @@ -10,6 +10,7 @@ defmodule OrcasiteWeb.RadioTest do lat_lng_string: "48.5583362, -123.1735774", name: "Orcasound Lab (Haro Strait)", node_name: "rpi_orcasound_lab", + bucket: "streaming-orcasound-net", slug: "orcasound-lab" } ) diff --git a/ui/src/components/DetectionsTable.tsx b/ui/src/components/DetectionsTable.tsx index 1637497a..8ba673ed 100644 --- a/ui/src/components/DetectionsTable.tsx +++ b/ui/src/components/DetectionsTable.tsx @@ -42,7 +42,7 @@ export default function DetectionsTable({ onDetectionUpdate, }: { detections: Detection[]; - feed: Pick; + feed: Pick; candidate: Pick; onDetectionUpdate: () => void; }) { diff --git a/ui/src/components/Player/DetectionsPlayer.tsx b/ui/src/components/Player/DetectionsPlayer.tsx index f42f2a3e..b0a8161f 100644 --- a/ui/src/components/Player/DetectionsPlayer.tsx +++ b/ui/src/components/Player/DetectionsPlayer.tsx @@ -24,7 +24,7 @@ export function DetectionsPlayer({ endOffset, onAudioPlay, }: { - feed: Pick; + feed: Pick; marks: { label: string; value: number }[]; timestamp: number; startOffset: number; @@ -38,7 +38,7 @@ export function DetectionsPlayer({ const sliderMax = endOffset - startOffset; const sliderValue = playerTime - startOffset; - const hlsURI = getHlsURI(feed.nodeName, timestamp); + const hlsURI = getHlsURI(feed.bucket, feed.nodeName, timestamp); const playerOptions = useMemo( () => ({ diff --git a/ui/src/components/Player/Player.tsx b/ui/src/components/Player/Player.tsx index d672dbc9..f9aea1eb 100644 --- a/ui/src/components/Player/Player.tsx +++ b/ui/src/components/Player/Player.tsx @@ -33,13 +33,23 @@ export default function Player({ }: { currentFeed?: Pick< Feed, - "id" | "slug" | "nodeName" | "name" | "latLng" | "imageUrl" | "thumbUrl" + | "id" + | "slug" + | "nodeName" + | "name" + | "latLng" + | "imageUrl" + | "thumbUrl" + | "bucket" >; }) { const [playerStatus, setPlayerStatus] = useState("idle"); const playerRef = useRef(null); - const { timestamp, hlsURI } = useTimestampFetcher(currentFeed?.nodeName); + const { timestamp, hlsURI } = useTimestampFetcher( + currentFeed?.bucket, + currentFeed?.nodeName, + ); const feedPresence = useFeedPresence(currentFeed?.slug); const listenerCount = feedPresence?.metas.length ?? 0; diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index 759d4d21..76493db2 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -22,6 +22,8 @@ const feedFromSlug = (feedSlug: string) => ({ name: feedSlug, slug: feedSlug, nodeName: feedSlug, + // TODO: pass in bucket from dynamic feed instead of env/hardcoding + bucket: process.env.NEXT_PUBLIC_S3_BUCKET ?? "audio-orcasound-net", // TODO: figure out which coordinates to use for dynamic feeds latLng: { lat: 47.6, lng: -122.3 }, }); diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 48c19287..3b5862df 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -438,9 +438,10 @@ export type DetectionSortInput = { export type Feed = { __typename?: "Feed"; - bucket?: Maybe; + bucket: Scalars["String"]["output"]; bucketRegion?: Maybe; cloudfrontUrl?: Maybe; + dataplicityId?: Maybe; feedSegments: Array; feedStreams: Array; id: Scalars["ID"]["output"]; @@ -452,6 +453,7 @@ export type Feed = { name: Scalars["String"]["output"]; nodeName: Scalars["String"]["output"]; online?: Maybe; + orcahelloId?: Maybe; slug: Scalars["String"]["output"]; thumbUrl?: Maybe; visible?: Maybe; @@ -476,7 +478,7 @@ export type FeedFilterBucket = { greaterThan?: InputMaybe; greaterThanOrEqual?: InputMaybe; ilike?: InputMaybe; - in?: InputMaybe>>; + in?: InputMaybe>; isNil?: InputMaybe; lessThan?: InputMaybe; lessThanOrEqual?: InputMaybe; @@ -510,6 +512,19 @@ export type FeedFilterCloudfrontUrl = { notEq?: InputMaybe; }; +export type FeedFilterDataplicityId = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + ilike?: InputMaybe; + in?: InputMaybe>>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + like?: InputMaybe; + notEq?: InputMaybe; +}; + export type FeedFilterId = { isNil?: InputMaybe; }; @@ -532,6 +547,7 @@ export type FeedFilterInput = { bucket?: InputMaybe; bucketRegion?: InputMaybe; cloudfrontUrl?: InputMaybe; + dataplicityId?: InputMaybe; feedSegments?: InputMaybe; feedStreams?: InputMaybe; id?: InputMaybe; @@ -543,6 +559,7 @@ export type FeedFilterInput = { not?: InputMaybe>; online?: InputMaybe; or?: InputMaybe>; + orcahelloId?: InputMaybe; slug?: InputMaybe; visible?: InputMaybe; }; @@ -601,6 +618,19 @@ export type FeedFilterOnline = { notEq?: InputMaybe; }; +export type FeedFilterOrcahelloId = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + ilike?: InputMaybe; + in?: InputMaybe>>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + like?: InputMaybe; + notEq?: InputMaybe; +}; + export type FeedFilterSlug = { eq?: InputMaybe; greaterThan?: InputMaybe; @@ -851,6 +881,7 @@ export type FeedSortField = | "BUCKET" | "BUCKET_REGION" | "CLOUDFRONT_URL" + | "DATAPLICITY_ID" | "ID" | "IMAGE_URL" | "INTRO_HTML" @@ -858,6 +889,7 @@ export type FeedSortField = | "NAME" | "NODE_NAME" | "ONLINE" + | "ORCAHELLO_ID" | "SLUG" | "VISIBLE"; @@ -1876,6 +1908,7 @@ export type CandidateQuery = { slug: string; name: string; nodeName: string; + bucket: string; }; detections: Array<{ __typename?: "Detection"; @@ -1923,6 +1956,7 @@ export type FeedQuery = { thumbUrl?: string | null; imageUrl?: string | null; mapUrl?: string | null; + bucket: string; latLng: { __typename?: "LatLng"; lat: number; lng: number }; }; }; @@ -2591,6 +2625,7 @@ export const CandidateDocument = ` slug name nodeName + bucket } detections { id @@ -2708,6 +2743,7 @@ export const FeedDocument = ` thumbUrl imageUrl mapUrl + bucket } } `; diff --git a/ui/src/graphql/queries/getCandidate.graphql b/ui/src/graphql/queries/getCandidate.graphql index 53772904..c8e70924 100644 --- a/ui/src/graphql/queries/getCandidate.graphql +++ b/ui/src/graphql/queries/getCandidate.graphql @@ -11,6 +11,7 @@ query candidate($id: ID!) { slug name nodeName + bucket } detections { id diff --git a/ui/src/graphql/queries/getFeed.graphql b/ui/src/graphql/queries/getFeed.graphql index 7bb68bcd..93a9d36a 100644 --- a/ui/src/graphql/queries/getFeed.graphql +++ b/ui/src/graphql/queries/getFeed.graphql @@ -12,5 +12,6 @@ query feed($slug: String!) { thumbUrl imageUrl mapUrl + bucket } } diff --git a/ui/src/hooks/useTimestampFetcher.ts b/ui/src/hooks/useTimestampFetcher.ts index 2fa88106..f66be190 100644 --- a/ui/src/hooks/useTimestampFetcher.ts +++ b/ui/src/hooks/useTimestampFetcher.ts @@ -3,11 +3,17 @@ import { useEffect, useState } from "react"; if (!process.env.NEXT_PUBLIC_S3_BUCKET) { throw new Error("NEXT_PUBLIC_S3_BUCKET is not set"); } -const S3_BUCKET = process.env.NEXT_PUBLIC_S3_BUCKET; -const S3_BUCKET_BASE = `https://s3-us-west-2.amazonaws.com/${S3_BUCKET}`; -export const getHlsURI = (nodeName: string, timestamp: number) => - `${S3_BUCKET_BASE}/${nodeName}/hls/${timestamp}/live.m3u8`; +const getBucketBase = (bucket: string) => `https://${bucket}.s3.amazonaws.com`; + +const getTimestampURI = (bucket: string, nodeName: string) => + `${getBucketBase(bucket)}/${nodeName}/latest.txt`; + +export const getHlsURI = ( + bucket: string, + nodeName: string, + timestamp: number, +) => `${getBucketBase(bucket)}/${nodeName}/hls/${timestamp}/live.m3u8`; /** * @typedef {Object} TimestampFetcherOptions @@ -19,35 +25,37 @@ export const getHlsURI = (nodeName: string, timestamp: number) => * @typedef {Object} TimestampFetcherResult * @property {number} timestamp The latest timestamp * @property {string} hlsURI The URI to the latest HLS stream - * @property {string} awsConsoleUri The URI to the AWS console for the latest HLS stream + * @property {string} awsConsoleURI The URI to the AWS console for the latest HLS stream */ /** * Starts a timer that fetches the latest timestamp from a feed + * @param {string} bucket The bucket name of the node * @param {string} nodeName The name of the feed to fetch from, as defined in the S3 bucket * @param {TimestampFetcherOptions} options Callbacks for when the fetcher starts and stops * @returns {TimestampFetcherResult} The latest timestamp, HLS URI, and AWS console URI */ export function useTimestampFetcher( + bucket?: string, nodeName?: string, { onStart, onStop }: { onStart?: () => void; onStop?: () => void } = {}, ) { const [timestamp, setTimestamp] = useState(); const hlsURI = - nodeName && timestamp ? getHlsURI(nodeName, timestamp) : undefined; - const awsConsoleUri = - nodeName && timestamp - ? `https://s3.console.aws.amazon.com/s3/buckets/${S3_BUCKET}/${nodeName}/hls/${timestamp}/` + nodeName && bucket && timestamp + ? getHlsURI(bucket, nodeName, timestamp) + : undefined; + const awsConsoleURI = + nodeName && bucket && timestamp + ? `https://s3.console.aws.amazon.com/s3/buckets/${bucket}/${nodeName}/hls/${timestamp}/` : undefined; useEffect(() => { let currentXhr: XMLHttpRequest | undefined; let intervalId: NodeJS.Timeout | undefined; - const fetchTimestamp = (feed: string) => { - const timestampURI = `${S3_BUCKET_BASE}/${feed}/latest.txt`; - + const fetchTimestamp = (timestampURI: string) => { const xhr = new XMLHttpRequest(); currentXhr = xhr; xhr.open("GET", timestampURI); @@ -63,11 +71,15 @@ export function useTimestampFetcher( }; const startFetcher = () => { - if (!nodeName) return; + if (!nodeName || !bucket) return; + const timestampURI = getTimestampURI(bucket, nodeName); onStart?.(); - fetchTimestamp(nodeName); - const newIntervalId = setInterval(() => fetchTimestamp(nodeName), 10000); + fetchTimestamp(timestampURI); + const newIntervalId = setInterval( + () => fetchTimestamp(timestampURI), + 10000, + ); intervalId = newIntervalId; }; @@ -82,7 +94,7 @@ export function useTimestampFetcher( stopFetcher(); onStop?.(); }; - }, [nodeName, onStart, onStop]); + }, [nodeName, onStart, onStop, bucket]); - return { timestamp, hlsURI, awsConsoleUri }; + return { timestamp, hlsURI, awsConsoleURI }; }