diff --git a/.dockerignore b/.dockerignore index 416e1ab9..1b9e2012 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,6 +14,7 @@ dist/ .next/ # Development and cache files +.git/ .gitignore **/.DS_Store *.log diff --git a/.env.template b/.env.template index 0d54d1c0..168d6211 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/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 1b9ca3f3..264b1620 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -17,7 +17,7 @@ inputs: node-version: description: "Node.js version to use" required: false - default: "20" + default: "22" lexicons-only-rust: description: "Generate only Rust lexicons" required: false diff --git a/.github/workflows/aqua.yml b/.github/workflows/aqua.yml index 2b4cc47e..df096c4a 100644 --- a/.github/workflows/aqua.yml +++ b/.github/workflows/aqua.yml @@ -34,6 +34,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + submodules: recursive - name: Log in to Container Registry if: github.event_name != 'pull_request' diff --git a/.github/workflows/cadet.yml b/.github/workflows/cadet.yml index b90c6534..cf4971cc 100644 --- a/.github/workflows/cadet.yml +++ b/.github/workflows/cadet.yml @@ -32,6 +32,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + submodules: recursive - name: Log in to Container Registry if: github.event_name != 'pull_request' diff --git a/.gitignore b/.gitignore index bf7e2195..44e8c78e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ yarn-error.log* # vercel .vercel .turbo +.teal-tap/ +.teal-lightrail-backfill-*.txt +.codex-run/ **/*.trace **/*.zip diff --git a/.sqlx/query-03831a5c2d4578f2c9c3dfc4684a9ef73340b1fd011b1e147d43d46987f9f0e0.json b/.sqlx/query-03831a5c2d4578f2c9c3dfc4684a9ef73340b1fd011b1e147d43d46987f9f0e0.json new file mode 100644 index 00000000..9393b01b --- /dev/null +++ b/.sqlx/query-03831a5c2d4578f2c9c3dfc4684a9ef73340b1fd011b1e147d43d46987f9f0e0.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT r.mbid, r.name, COUNT(DISTINCT p.uri) AS play_count\n FROM releases r\n LEFT JOIN plays p ON r.mbid = p.release_mbid\n WHERE r.name ILIKE $1 ESCAPE E'\\\\'\n AND ($5::text IS NULL OR p.did = $5)\n GROUP BY r.mbid, r.name\n ORDER BY\n CASE\n WHEN LOWER(r.name) = LOWER($2) THEN 0\n WHEN r.name ILIKE $3 ESCAPE E'\\\\' THEN 1\n ELSE 2\n END,\n play_count DESC,\n r.name\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8", + "Text" + ] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "03831a5c2d4578f2c9c3dfc4684a9ef73340b1fd011b1e147d43d46987f9f0e0" +} diff --git a/.sqlx/query-08f2f4778882d946cc1d82f9ca7dd3bfe1bff6cab962a119d0a721e332d98d51.json b/.sqlx/query-08f2f4778882d946cc1d82f9ca7dd3bfe1bff6cab962a119d0a721e332d98d51.json new file mode 100644 index 00000000..77abd444 --- /dev/null +++ b/.sqlx/query-08f2f4778882d946cc1d82f9ca7dd3bfe1bff6cab962a119d0a721e332d98d51.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ae.id, ae.mbid, ae.name, COUNT(DISTINCT ptae.play_uri) AS play_count\n FROM artists_extended ae\n LEFT JOIN play_to_artists_extended ptae ON ae.id = ptae.artist_id\n WHERE ($1::uuid IS NOT NULL AND ae.mbid = $1)\n OR ($1::uuid IS NULL AND LOWER(ae.name) = LOWER($2))\n GROUP BY ae.id, ae.mbid, ae.name\n ORDER BY play_count DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "mbid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + true, + false, + null + ] + }, + "hash": "08f2f4778882d946cc1d82f9ca7dd3bfe1bff6cab962a119d0a721e332d98d51" +} diff --git a/.sqlx/query-098c1e752ec1e822d32a76fdfbae5d8bfae01c8e4942518a15bc16f7fa5e025b.json b/.sqlx/query-098c1e752ec1e822d32a76fdfbae5d8bfae01c8e4942518a15bc16f7fa5e025b.json new file mode 100644 index 00000000..5accd30d --- /dev/null +++ b/.sqlx/query-098c1e752ec1e822d32a76fdfbae5d8bfae01c8e4942518a15bc16f7fa5e025b.json @@ -0,0 +1,139 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n profile.did AS \"profile_did?\", profile.handle AS profile_handle,\n profile.display_name AS profile_display_name, profile.avatar AS profile_avatar,\n COALESCE(\n json_agg(\n json_build_object(\n 'artistMbId', ae.mbid,\n 'artistName', ptae.artist_name\n )\n ) FILTER (WHERE ptae.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays p\n LEFT JOIN profiles profile ON p.did = profile.did\n LEFT JOIN play_to_artists_extended as ptae ON p.uri = ptae.play_uri\n LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id\n WHERE p.did = $1\n AND ($2::timestamptz IS NULL OR (p.processed_time, p.uri) < ($2, $3))\n GROUP BY p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n profile.did, profile.handle, profile.display_name, profile.avatar\n ORDER BY p.processed_time DESC, p.uri DESC\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "uri", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "rkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "cid", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "isrc", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "duration", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "track_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "played_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "processed_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "release_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "release_name", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "recording_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 12, + "name": "submission_client_agent", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "music_service_base_domain", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "origin_url", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "profile_did?", + "type_info": "Text" + }, + { + "ordinal": 16, + "name": "profile_handle", + "type_info": "Text" + }, + { + "ordinal": 17, + "name": "profile_display_name", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "profile_avatar", + "type_info": "Text" + }, + { + "ordinal": 19, + "name": "artists", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Text", + "Timestamptz", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null + ] + }, + "hash": "098c1e752ec1e822d32a76fdfbae5d8bfae01c8e4942518a15bc16f7fa5e025b" +} diff --git a/.sqlx/query-0e053ba402c8b769b697f60d189675eceb89f1d14e52174bda67dc65cc68d273.json b/.sqlx/query-0e053ba402c8b769b697f60d189675eceb89f1d14e52174bda67dc65cc68d273.json deleted file mode 100644 index 8bda3ba8..00000000 --- a/.sqlx/query-0e053ba402c8b769b697f60d189675eceb89f1d14e52174bda67dc65cc68d273.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n pta.artist_mbid as mbid,\n pta.artist_name as name,\n COUNT(*) as play_count\n FROM plays p\n INNER JOIN play_to_artists pta ON p.uri = pta.play_uri\n WHERE p.did = $1\n AND pta.artist_mbid IS NOT NULL\n AND pta.artist_name IS NOT NULL\n GROUP BY pta.artist_mbid, pta.artist_name\n ORDER BY play_count DESC\n LIMIT $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mbid", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "play_count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8" - ] - }, - "nullable": [ - false, - true, - null - ] - }, - "hash": "0e053ba402c8b769b697f60d189675eceb89f1d14e52174bda67dc65cc68d273" -} diff --git a/.sqlx/query-0ff59e15ce4faa50bb4b9996ae7877681060ed462a7905012f8097c9545f60b1.json b/.sqlx/query-0ff59e15ce4faa50bb4b9996ae7877681060ed462a7905012f8097c9545f60b1.json deleted file mode 100644 index de2e1a25..00000000 --- a/.sqlx/query-0ff59e15ce4faa50bb4b9996ae7877681060ed462a7905012f8097c9545f60b1.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url,\n COALESCE(\n json_agg(\n json_build_object(\n 'artist_mbid', pta.artist_mbid,\n 'artist_name', pta.artist_name\n )\n ) FILTER (WHERE pta.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays\n LEFT JOIN play_to_artists as pta ON uri = pta.play_uri\n WHERE did = ANY($1)\n GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url\n ORDER BY processed_time desc\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "uri", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "rkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "cid", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "isrc", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "duration", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "track_name", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "played_time", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "processed_time", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "release_mbid", - "type_info": "Uuid" - }, - { - "ordinal": 10, - "name": "release_name", - "type_info": "Text" - }, - { - "ordinal": 11, - "name": "recording_mbid", - "type_info": "Uuid" - }, - { - "ordinal": 12, - "name": "submission_client_agent", - "type_info": "Text" - }, - { - "ordinal": 13, - "name": "music_service_base_domain", - "type_info": "Text" - }, - { - "ordinal": 14, - "name": "origin_url", - "type_info": "Text" - }, - { - "ordinal": 15, - "name": "artists", - "type_info": "Json" - } - ], - "parameters": { - "Left": [ - "TextArray" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - null - ] - }, - "hash": "0ff59e15ce4faa50bb4b9996ae7877681060ed462a7905012f8097c9545f60b1" -} diff --git a/.sqlx/query-1b2d407b075babff7413b711a4992a7cd17024872e2b622e66aeb8a48ca8f6dd.json b/.sqlx/query-1b2d407b075babff7413b711a4992a7cd17024872e2b622e66aeb8a48ca8f6dd.json new file mode 100644 index 00000000..9ee51a1d --- /dev/null +++ b/.sqlx/query-1b2d407b075babff7413b711a4992a7cd17024872e2b622e66aeb8a48ca8f6dd.json @@ -0,0 +1,136 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n profile.did AS \"profile_did?\", profile.handle AS profile_handle,\n profile.display_name AS profile_display_name, profile.avatar AS profile_avatar,\n COALESCE(\n json_agg(\n json_build_object(\n 'artistMbId', ae.mbid,\n 'artistName', ptae.artist_name\n )\n ) FILTER (WHERE ptae.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays p\n LEFT JOIN profiles profile ON p.did = profile.did\n LEFT JOIN play_to_artists_extended as ptae ON p.uri = ptae.play_uri\n LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id\n WHERE p.did = ANY($1)\n GROUP BY p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n profile.did, profile.handle, profile.display_name, profile.avatar\n ORDER BY p.processed_time desc\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "uri", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "rkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "cid", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "isrc", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "duration", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "track_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "played_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "processed_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "release_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "release_name", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "recording_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 12, + "name": "submission_client_agent", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "music_service_base_domain", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "origin_url", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "profile_did?", + "type_info": "Text" + }, + { + "ordinal": 16, + "name": "profile_handle", + "type_info": "Text" + }, + { + "ordinal": 17, + "name": "profile_display_name", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "profile_avatar", + "type_info": "Text" + }, + { + "ordinal": 19, + "name": "artists", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null + ] + }, + "hash": "1b2d407b075babff7413b711a4992a7cd17024872e2b622e66aeb8a48ca8f6dd" +} diff --git a/.sqlx/query-306856b0002f166d68d27449a1561df41945aed55858810c2f3425dcfd3048ee.json b/.sqlx/query-306856b0002f166d68d27449a1561df41945aed55858810c2f3425dcfd3048ee.json new file mode 100644 index 00000000..2d24ab09 --- /dev/null +++ b/.sqlx/query-306856b0002f166d68d27449a1561df41945aed55858810c2f3425dcfd3048ee.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH grouped AS (\n SELECT\n p.uri,\n p.track_name,\n p.release_name,\n p.release_mbid,\n p.recording_mbid,\n p.processed_time,\n COALESCE(\n STRING_AGG(DISTINCT ptae.artist_name, ', ' ORDER BY ptae.artist_name),\n 'Unknown artist'\n ) AS artist_name,\n COALESCE(\n STRING_AGG(DISTINCT LOWER(ptae.artist_name), chr(31) ORDER BY LOWER(ptae.artist_name)),\n ''\n ) AS artist_key\n FROM plays p\n LEFT JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n WHERE p.track_name ILIKE $1 ESCAPE E'\\\\'\n AND ($5::text IS NULL OR p.did = $5)\n GROUP BY p.uri, p.track_name, p.release_name, p.release_mbid, p.processed_time,\n p.recording_mbid\n ), matched AS (\n SELECT\n uri,\n track_name,\n release_name,\n release_mbid,\n recording_mbid,\n processed_time,\n artist_name,\n COUNT(*) OVER (\n PARTITION BY LOWER(track_name), COALESCE(recording_mbid::text, artist_key)\n ) AS play_count,\n ROW_NUMBER() OVER (\n PARTITION BY LOWER(track_name), COALESCE(recording_mbid::text, artist_key)\n ORDER BY processed_time DESC, uri\n ) AS row_number\n FROM grouped\n )\n SELECT uri, track_name, release_name, release_mbid, recording_mbid, artist_name, play_count\n FROM matched\n WHERE row_number = 1\n ORDER BY\n CASE\n WHEN LOWER(track_name) = LOWER($2) THEN 0\n WHEN track_name ILIKE $3 ESCAPE E'\\\\' THEN 1\n ELSE 2\n END,\n play_count DESC,\n track_name\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "uri", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "track_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "release_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "release_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "recording_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "artist_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + null, + null + ] + }, + "hash": "306856b0002f166d68d27449a1561df41945aed55858810c2f3425dcfd3048ee" +} diff --git a/.sqlx/query-348a15835fbf1e1f62a09e5f94287193483ca5ba76659baf8b18b35cab952e0d.json b/.sqlx/query-348a15835fbf1e1f62a09e5f94287193483ca5ba76659baf8b18b35cab952e0d.json new file mode 100644 index 00000000..0730cafd --- /dev/null +++ b/.sqlx/query-348a15835fbf1e1f62a09e5f94287193483ca5ba76659baf8b18b35cab952e0d.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ae.mbid,\n ptae.artist_name as name,\n COUNT(*) as play_count\n FROM plays p\n INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n INNER JOIN artists_extended ae ON ptae.artist_id = ae.id\n WHERE ptae.artist_name IS NOT NULL\n GROUP BY ae.mbid, ptae.artist_name\n ORDER BY play_count DESC\n LIMIT $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true, + false, + null + ] + }, + "hash": "348a15835fbf1e1f62a09e5f94287193483ca5ba76659baf8b18b35cab952e0d" +} diff --git a/.sqlx/query-504c321eec8f39add55b4baba18de9e4f0f4e43cebdf62bf010a8a9094decc50.json b/.sqlx/query-504c321eec8f39add55b4baba18de9e4f0f4e43cebdf62bf010a8a9094decc50.json new file mode 100644 index 00000000..62343d55 --- /dev/null +++ b/.sqlx/query-504c321eec8f39add55b4baba18de9e4f0f4e43cebdf62bf010a8a9094decc50.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT uri FROM plays WHERE did = $1 AND cid = $2 LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "uri", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "504c321eec8f39add55b4baba18de9e4f0f4e43cebdf62bf010a8a9094decc50" +} diff --git a/.sqlx/query-5e6b6ec603ed32de54da8236a54708f0c27e66cd04780fa3fd733efc329801f0.json b/.sqlx/query-5e6b6ec603ed32de54da8236a54708f0c27e66cd04780fa3fd733efc329801f0.json new file mode 100644 index 00000000..5ddb0ee8 --- /dev/null +++ b/.sqlx/query-5e6b6ec603ed32de54da8236a54708f0c27e66cd04780fa3fd733efc329801f0.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ae.mbid, ae.name, COUNT(ptae.play_uri) AS play_count\n FROM artists_extended ae\n LEFT JOIN play_to_artists_extended ptae ON ae.id = ptae.artist_id\n WHERE ae.name ILIKE $1\n GROUP BY ae.id, ae.mbid, ae.name\n ORDER BY\n CASE\n WHEN LOWER(ae.name) = LOWER($2) THEN 0\n WHEN ae.name ILIKE $3 THEN 1\n ELSE 2\n END,\n play_count DESC,\n ae.name\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + true, + false, + null + ] + }, + "hash": "5e6b6ec603ed32de54da8236a54708f0c27e66cd04780fa3fd733efc329801f0" +} diff --git a/.sqlx/query-651c94b4edd5afa55c3679a5f8c1ef1cbe53f7dac01b050ec7ad9100950527c0.json b/.sqlx/query-651c94b4edd5afa55c3679a5f8c1ef1cbe53f7dac01b050ec7ad9100950527c0.json deleted file mode 100644 index f0b773f5..00000000 --- a/.sqlx/query-651c94b4edd5afa55c3679a5f8c1ef1cbe53f7dac01b050ec7ad9100950527c0.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url,\n COALESCE(\n json_agg(\n json_build_object(\n 'artist_mbid', pta.artist_mbid,\n 'artist_name', pta.artist_name\n )\n ) FILTER (WHERE pta.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays\n LEFT JOIN play_to_artists as pta ON uri = pta.play_uri\n WHERE uri = $1\n GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url\n ORDER BY processed_time desc\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "uri", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "rkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "cid", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "isrc", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "duration", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "track_name", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "played_time", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "processed_time", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "release_mbid", - "type_info": "Uuid" - }, - { - "ordinal": 10, - "name": "release_name", - "type_info": "Text" - }, - { - "ordinal": 11, - "name": "recording_mbid", - "type_info": "Uuid" - }, - { - "ordinal": 12, - "name": "submission_client_agent", - "type_info": "Text" - }, - { - "ordinal": 13, - "name": "music_service_base_domain", - "type_info": "Text" - }, - { - "ordinal": 14, - "name": "origin_url", - "type_info": "Text" - }, - { - "ordinal": 15, - "name": "artists", - "type_info": "Json" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - null - ] - }, - "hash": "651c94b4edd5afa55c3679a5f8c1ef1cbe53f7dac01b050ec7ad9100950527c0" -} diff --git a/.sqlx/query-72c85b603c19cf793ce9dc22834038629954e56aa6fde2260ae459014269bd08.json b/.sqlx/query-72c85b603c19cf793ce9dc22834038629954e56aa6fde2260ae459014269bd08.json new file mode 100644 index 00000000..aefbf0c2 --- /dev/null +++ b/.sqlx/query-72c85b603c19cf793ce9dc22834038629954e56aa6fde2260ae459014269bd08.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ae.id, ae.mbid, ae.name, COUNT(DISTINCT ptae.play_uri) AS play_count\n FROM artists_extended ae\n LEFT JOIN play_to_artists_extended ptae ON ae.id = ptae.artist_id\n WHERE ($1::uuid IS NOT NULL AND ae.mbid = $1)\n OR (\n $1::uuid IS NULL\n AND (\n LOWER(ae.name) = LOWER($2)\n OR TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(ae.name), '[^a-z0-9]+', '-', 'g')) = LOWER($2)\n )\n )\n GROUP BY ae.id, ae.mbid, ae.name\n ORDER BY play_count DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "mbid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + true, + false, + null + ] + }, + "hash": "72c85b603c19cf793ce9dc22834038629954e56aa6fde2260ae459014269bd08" +} diff --git a/.sqlx/query-75d93f44f432f071ee5a829a68e3d4b1d04b1eb8af5a518a0a746de4c5ae4e67.json b/.sqlx/query-75d93f44f432f071ee5a829a68e3d4b1d04b1eb8af5a518a0a746de4c5ae4e67.json new file mode 100644 index 00000000..5bb447ea --- /dev/null +++ b/.sqlx/query-75d93f44f432f071ee5a829a68e3d4b1d04b1eb8af5a518a0a746de4c5ae4e67.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT did, handle, display_name, avatar\n FROM profiles\n WHERE display_name ILIKE $1 OR handle ILIKE $1\n ORDER BY\n CASE\n WHEN LOWER(handle) = LOWER($2) OR LOWER(display_name) = LOWER($2) THEN 0\n WHEN handle ILIKE $3 OR display_name ILIKE $3 THEN 1\n ELSE 2\n END,\n display_name NULLS LAST,\n handle NULLS LAST\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "avatar", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + true, + true, + true + ] + }, + "hash": "75d93f44f432f071ee5a829a68e3d4b1d04b1eb8af5a518a0a746de4c5ae4e67" +} diff --git a/.sqlx/query-78d9f0eb3a550928cccd0a8c0faf3c9176a354238bf32b97a73a7bb41014b5eb.json b/.sqlx/query-78d9f0eb3a550928cccd0a8c0faf3c9176a354238bf32b97a73a7bb41014b5eb.json deleted file mode 100644 index b4dd24d8..00000000 --- a/.sqlx/query-78d9f0eb3a550928cccd0a8c0faf3c9176a354238bf32b97a73a7bb41014b5eb.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n p.did,\n p.track_name,\n -- TODO: replace with actual\n STRING_AGG(pa.artist_name || '|' || TEXT(pa.artist_mbid), ',') AS artists,\n p.release_name,\n p.duration,\n p.uri,\n p.recording_mbid,\n p.release_mbid\n\n FROM plays AS p\n LEFT JOIN play_to_artists AS pa ON pa.play_uri = p.uri\n GROUP BY p.did, p.track_name, p.release_name, p.played_time, p.duration, p.uri, p.recording_mbid, p.release_mbid\n ORDER BY p.played_time DESC\n LIMIT $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "track_name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "artists", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "release_name", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "duration", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "uri", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "recording_mbid", - "type_info": "Uuid" - }, - { - "ordinal": 7, - "name": "release_mbid", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - null, - true, - true, - false, - true, - true - ] - }, - "hash": "78d9f0eb3a550928cccd0a8c0faf3c9176a354238bf32b97a73a7bb41014b5eb" -} diff --git a/.sqlx/query-7b59e08731ba68a06d740c7715258bce8f914f9a705f61c79e7d8f9fb0edce54.json b/.sqlx/query-7b59e08731ba68a06d740c7715258bce8f914f9a705f61c79e7d8f9fb0edce54.json new file mode 100644 index 00000000..d08e7803 --- /dev/null +++ b/.sqlx/query-7b59e08731ba68a06d740c7715258bce8f914f9a705f61c79e7d8f9fb0edce54.json @@ -0,0 +1,139 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n profile.did AS \"profile_did?\", profile.handle AS profile_handle,\n profile.display_name AS profile_display_name, profile.avatar AS profile_avatar,\n COALESCE(\n json_agg(\n json_build_object(\n 'artistMbId', ae.mbid,\n 'artistName', ptae.artist_name\n )\n ) FILTER (WHERE ptae.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays p\n LEFT JOIN profiles profile ON p.did = profile.did\n LEFT JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n LEFT JOIN artists_extended ae ON ptae.artist_id = ae.id\n WHERE p.release_mbid = $1\n AND ($2::timestamptz IS NULL OR (p.processed_time, p.uri) < ($2, $3))\n GROUP BY p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n profile.did, profile.handle, profile.display_name, profile.avatar\n ORDER BY p.processed_time DESC, p.uri DESC\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "uri", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "rkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "cid", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "isrc", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "duration", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "track_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "played_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "processed_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "release_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "release_name", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "recording_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 12, + "name": "submission_client_agent", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "music_service_base_domain", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "origin_url", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "profile_did?", + "type_info": "Text" + }, + { + "ordinal": 16, + "name": "profile_handle", + "type_info": "Text" + }, + { + "ordinal": 17, + "name": "profile_display_name", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "profile_avatar", + "type_info": "Text" + }, + { + "ordinal": 19, + "name": "artists", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null + ] + }, + "hash": "7b59e08731ba68a06d740c7715258bce8f914f9a705f61c79e7d8f9fb0edce54" +} diff --git a/.sqlx/query-8758f5bb57feedca6cd65831f36aabc811e8b7072dc6bdbfd4a49242e5d7c946.json b/.sqlx/query-8758f5bb57feedca6cd65831f36aabc811e8b7072dc6bdbfd4a49242e5d7c946.json deleted file mode 100644 index 49183377..00000000 --- a/.sqlx/query-8758f5bb57feedca6cd65831f36aabc811e8b7072dc6bdbfd4a49242e5d7c946.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO statii (uri, did, rkey, cid, record)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (uri) DO UPDATE SET\n cid = EXCLUDED.cid,\n record = EXCLUDED.record,\n indexed_at = NOW();\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "8758f5bb57feedca6cd65831f36aabc811e8b7072dc6bdbfd4a49242e5d7c946" -} diff --git a/.sqlx/query-8cb46e2270b1ce1f6b85ac3d35f9fa484c12e0fa5693d5f37a0f169189c3c4e7.json b/.sqlx/query-8cb46e2270b1ce1f6b85ac3d35f9fa484c12e0fa5693d5f37a0f169189c3c4e7.json new file mode 100644 index 00000000..fd38ae7a --- /dev/null +++ b/.sqlx/query-8cb46e2270b1ce1f6b85ac3d35f9fa484c12e0fa5693d5f37a0f169189c3c4e7.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH release_variants AS (\n SELECT\n LOWER(p.release_name) AS normalized_name,\n p.release_mbid AS mbid,\n MAX(p.release_name) AS name,\n COUNT(DISTINCT p.uri) AS play_count,\n MAX(p.played_time) AS last_played\n FROM plays p\n INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n WHERE ptae.artist_id = $1\n AND p.release_mbid IS NOT NULL\n AND p.release_name IS NOT NULL\n GROUP BY LOWER(p.release_name), p.release_mbid\n )\n ,release_usage AS (\n SELECT\n release_variants.*,\n COUNT(*) OVER (PARTITION BY mbid) AS release_name_count\n FROM release_variants\n )\n ,selected_releases AS (\n SELECT DISTINCT ON (normalized_name)\n normalized_name,\n mbid,\n name,\n SUM(play_count) OVER (PARTITION BY normalized_name)::bigint AS play_count,\n MAX(last_played) OVER (PARTITION BY normalized_name) AS last_played\n FROM release_usage\n ORDER BY normalized_name,\n (release_name_count = 1) DESC,\n release_usage.play_count DESC,\n last_played DESC NULLS LAST,\n mbid\n )\n SELECT mbid AS \"mbid!\", name AS \"name!\", play_count AS \"play_count!\"\n FROM selected_releases\n ORDER BY last_played DESC NULLS LAST, name\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + true, + null, + null + ] + }, + "hash": "8cb46e2270b1ce1f6b85ac3d35f9fa484c12e0fa5693d5f37a0f169189c3c4e7" +} diff --git a/.sqlx/query-92e4f3870acee7850e23f821e72c7b6cc10df7e31b8cfef76dc35186b73a4614.json b/.sqlx/query-92e4f3870acee7850e23f821e72c7b6cc10df7e31b8cfef76dc35186b73a4614.json new file mode 100644 index 00000000..45f80620 --- /dev/null +++ b/.sqlx/query-92e4f3870acee7850e23f821e72c7b6cc10df7e31b8cfef76dc35186b73a4614.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ae.mbid, ae.name, COUNT(DISTINCT p.uri) AS play_count\n FROM artists_extended ae\n LEFT JOIN play_to_artists_extended ptae ON ae.id = ptae.artist_id\n LEFT JOIN plays p ON p.uri = ptae.play_uri\n WHERE ae.name ILIKE $1 ESCAPE E'\\\\'\n AND ($5::text IS NULL OR p.did = $5)\n GROUP BY ae.id, ae.mbid, ae.name\n ORDER BY\n CASE\n WHEN LOWER(ae.name) = LOWER($2) THEN 0\n WHEN ae.name ILIKE $3 ESCAPE E'\\\\' THEN 1\n ELSE 2\n END,\n play_count DESC,\n ae.name\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8", + "Text" + ] + }, + "nullable": [ + true, + false, + null + ] + }, + "hash": "92e4f3870acee7850e23f821e72c7b6cc10df7e31b8cfef76dc35186b73a4614" +} diff --git a/.sqlx/query-9b4652178f3c57066ea516b9972c99161ebf6c771232d631a89b14425caea9bc.json b/.sqlx/query-9b4652178f3c57066ea516b9972c99161ebf6c771232d631a89b14425caea9bc.json new file mode 100644 index 00000000..3c0e4dcb --- /dev/null +++ b/.sqlx/query-9b4652178f3c57066ea516b9972c99161ebf6c771232d631a89b14425caea9bc.json @@ -0,0 +1,136 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n profile.did AS \"profile_did?\", profile.handle AS profile_handle,\n profile.display_name AS profile_display_name, profile.avatar AS profile_avatar,\n COALESCE(\n json_agg(\n json_build_object(\n 'artistMbId', ae.mbid,\n 'artistName', ptae.artist_name\n )\n ) FILTER (WHERE ptae.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays p\n LEFT JOIN profiles profile ON p.did = profile.did\n LEFT JOIN play_to_artists_extended as ptae ON p.uri = ptae.play_uri\n LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id\n WHERE p.uri = $1\n GROUP BY p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n profile.did, profile.handle, profile.display_name, profile.avatar\n ORDER BY p.processed_time desc\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "uri", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "rkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "cid", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "isrc", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "duration", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "track_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "played_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "processed_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "release_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "release_name", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "recording_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 12, + "name": "submission_client_agent", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "music_service_base_domain", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "origin_url", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "profile_did?", + "type_info": "Text" + }, + { + "ordinal": 16, + "name": "profile_handle", + "type_info": "Text" + }, + { + "ordinal": 17, + "name": "profile_display_name", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "profile_avatar", + "type_info": "Text" + }, + { + "ordinal": 19, + "name": "artists", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null + ] + }, + "hash": "9b4652178f3c57066ea516b9972c99161ebf6c771232d631a89b14425caea9bc" +} diff --git a/.sqlx/query-9bac472357fa38a6e3bb38d02ebb56a6e11c85d4aff91096f8ea68f1196e8bd3.json b/.sqlx/query-9bac472357fa38a6e3bb38d02ebb56a6e11c85d4aff91096f8ea68f1196e8bd3.json deleted file mode 100644 index c95bb164..00000000 --- a/.sqlx/query-9bac472357fa38a6e3bb38d02ebb56a6e11c85d4aff91096f8ea68f1196e8bd3.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO play_to_artists_extended (play_uri, artist_id, artist_name) VALUES\n ($1, $2, $3)\n ON CONFLICT (play_uri, artist_id) DO NOTHING;\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Int4", - "Text" - ] - }, - "nullable": [] - }, - "hash": "9bac472357fa38a6e3bb38d02ebb56a6e11c85d4aff91096f8ea68f1196e8bd3" -} diff --git a/.sqlx/query-ad5ea929fd5a2a779a71a03c446fdc93ee31467053d7dac1ea035f453ba7df91.json b/.sqlx/query-ad5ea929fd5a2a779a71a03c446fdc93ee31467053d7dac1ea035f453ba7df91.json new file mode 100644 index 00000000..b402a804 --- /dev/null +++ b/.sqlx/query-ad5ea929fd5a2a779a71a03c446fdc93ee31467053d7dac1ea035f453ba7df91.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT did, handle, display_name, avatar\n FROM profiles\n WHERE display_name ILIKE $1 ESCAPE E'\\\\' OR handle ILIKE $1 ESCAPE E'\\\\'\n ORDER BY\n CASE\n WHEN LOWER(handle) = LOWER($2) OR LOWER(display_name) = LOWER($2) THEN 0\n WHEN handle ILIKE $3 ESCAPE E'\\\\' OR display_name ILIKE $3 ESCAPE E'\\\\' THEN 1\n ELSE 2\n END,\n display_name NULLS LAST,\n handle NULLS LAST\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "avatar", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + true, + true, + true + ] + }, + "hash": "ad5ea929fd5a2a779a71a03c446fdc93ee31467053d7dac1ea035f453ba7df91" +} diff --git a/.sqlx/query-af5c1fdabaee1cbc49f89d1df92e13cbb4a0837e3c644de9c7cf8e33e170d2e3.json b/.sqlx/query-af5c1fdabaee1cbc49f89d1df92e13cbb4a0837e3c644de9c7cf8e33e170d2e3.json deleted file mode 100644 index 9c181785..00000000 --- a/.sqlx/query-af5c1fdabaee1cbc49f89d1df92e13cbb4a0837e3c644de9c7cf8e33e170d2e3.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n p.release_mbid as mbid,\n p.release_name as name,\n COUNT(*) as play_count\n FROM plays p\n WHERE p.did = $1\n AND p.release_mbid IS NOT NULL\n AND p.release_name IS NOT NULL\n GROUP BY p.release_mbid, p.release_name\n ORDER BY play_count DESC\n LIMIT $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mbid", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "play_count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8" - ] - }, - "nullable": [ - true, - true, - null - ] - }, - "hash": "af5c1fdabaee1cbc49f89d1df92e13cbb4a0837e3c644de9c7cf8e33e170d2e3" -} diff --git a/.sqlx/query-b23dc54aec3e2bee85f1e5874df7ad4cbaeb15ca043b244bbce224dfc26d8b56.json b/.sqlx/query-b23dc54aec3e2bee85f1e5874df7ad4cbaeb15ca043b244bbce224dfc26d8b56.json deleted file mode 100644 index ab5056f1..00000000 --- a/.sqlx/query-b23dc54aec3e2bee85f1e5874df7ad4cbaeb15ca043b244bbce224dfc26d8b56.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE artists_extended SET name = $1, updated_at = NOW() WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "b23dc54aec3e2bee85f1e5874df7ad4cbaeb15ca043b244bbce224dfc26d8b56" -} diff --git a/.sqlx/query-b4e829c20bb78b9db20eccd9827e0d2f7bdbeedbaa39f6b40d1ae8a1045d6837.json b/.sqlx/query-b4e829c20bb78b9db20eccd9827e0d2f7bdbeedbaa39f6b40d1ae8a1045d6837.json deleted file mode 100644 index 9b287180..00000000 --- a/.sqlx/query-b4e829c20bb78b9db20eccd9827e0d2f7bdbeedbaa39f6b40d1ae8a1045d6837.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n p.avatar,\n p.banner,\n p.created_at,\n p.description,\n p.description_facets,\n p.did,\n p.display_name,\n s.record as status\n FROM profiles p\n LEFT JOIN statii s ON p.did = s.did AND s.rkey = 'self'\n WHERE (p.did = ANY($1))\n OR (p.handle = ANY($2))", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "avatar", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "banner", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "description", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "description_facets", - "type_info": "Jsonb" - }, - { - "ordinal": 5, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "status", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "TextArray", - "TextArray" - ] - }, - "nullable": [ - true, - true, - true, - true, - true, - false, - true, - true - ] - }, - "hash": "b4e829c20bb78b9db20eccd9827e0d2f7bdbeedbaa39f6b40d1ae8a1045d6837" -} diff --git a/.sqlx/query-b8bf07c21c04acf3b4d908b2db93643e497db9a1f01d4d51b99dfdbddd2d4c0e.json b/.sqlx/query-b8bf07c21c04acf3b4d908b2db93643e497db9a1f01d4d51b99dfdbddd2d4c0e.json deleted file mode 100644 index 7d29a7f8..00000000 --- a/.sqlx/query-b8bf07c21c04acf3b4d908b2db93643e497db9a1f01d4d51b99dfdbddd2d4c0e.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n pta.artist_mbid as mbid,\n pta.artist_name as name,\n COUNT(*) as play_count\n FROM plays p\n INNER JOIN play_to_artists pta ON p.uri = pta.play_uri\n WHERE pta.artist_mbid IS NOT NULL\n AND pta.artist_name IS NOT NULL\n GROUP BY pta.artist_mbid, pta.artist_name\n ORDER BY play_count DESC\n LIMIT $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mbid", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "play_count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - true, - null - ] - }, - "hash": "b8bf07c21c04acf3b4d908b2db93643e497db9a1f01d4d51b99dfdbddd2d4c0e" -} diff --git a/.sqlx/query-b920fc9e7b2c81158d6f2cdc9dbf618eda19f519102c0973ce8e71b382fcface.json b/.sqlx/query-b920fc9e7b2c81158d6f2cdc9dbf618eda19f519102c0973ce8e71b382fcface.json new file mode 100644 index 00000000..b514f1ec --- /dev/null +++ b/.sqlx/query-b920fc9e7b2c81158d6f2cdc9dbf618eda19f519102c0973ce8e71b382fcface.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT r.mbid, r.name, COUNT(p.uri) AS play_count\n FROM releases r\n LEFT JOIN plays p ON r.mbid = p.release_mbid\n WHERE r.name ILIKE $1\n GROUP BY r.mbid, r.name\n ORDER BY\n CASE\n WHEN LOWER(r.name) = LOWER($2) THEN 0\n WHEN r.name ILIKE $3 THEN 1\n ELSE 2\n END,\n play_count DESC,\n r.name\n LIMIT $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "b920fc9e7b2c81158d6f2cdc9dbf618eda19f519102c0973ce8e71b382fcface" +} diff --git a/.sqlx/query-b9ca1a73cba5a29665e5f996fd33410054936bbd74cfd611767bf6a7893ebded.json b/.sqlx/query-b9ca1a73cba5a29665e5f996fd33410054936bbd74cfd611767bf6a7893ebded.json deleted file mode 100644 index 6bc46517..00000000 --- a/.sqlx/query-b9ca1a73cba5a29665e5f996fd33410054936bbd74cfd611767bf6a7893ebded.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO profiles (did, handle, display_name, description, description_facets, avatar, banner, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (did) DO UPDATE SET\n display_name = EXCLUDED.display_name,\n description = EXCLUDED.description,\n description_facets = EXCLUDED.description_facets,\n avatar = EXCLUDED.avatar,\n banner = EXCLUDED.banner,\n created_at = EXCLUDED.created_at;\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "Jsonb", - "Text", - "Text", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "b9ca1a73cba5a29665e5f996fd33410054936bbd74cfd611767bf6a7893ebded" -} diff --git a/.sqlx/query-c06c1576352568580ef9a4284b4607311fe73a06606146de92f109d2ce5dedb6.json b/.sqlx/query-c06c1576352568580ef9a4284b4607311fe73a06606146de92f109d2ce5dedb6.json new file mode 100644 index 00000000..a787266a --- /dev/null +++ b/.sqlx/query-c06c1576352568580ef9a4284b4607311fe73a06606146de92f109d2ce5dedb6.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n p.release_mbid AS \"mbid!\",\n COALESCE(MAX(p.release_name), 'Unknown release') AS \"name!\",\n COUNT(DISTINCT p.uri) AS \"play_count!\",\n COALESCE(\n (ARRAY_AGG(ptae.artist_name ORDER BY ptae.artist_name)\n FILTER (WHERE ptae.artist_name IS NOT NULL))[1],\n 'Unknown artist'\n ) AS \"artist_name!\",\n (ARRAY_AGG(ae.mbid ORDER BY ptae.artist_name)\n FILTER (WHERE ae.mbid IS NOT NULL))[1] AS artist_mbid\n FROM plays p\n LEFT JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n LEFT JOIN artists_extended ae ON ptae.artist_id = ae.id\n WHERE p.release_mbid = $1\n GROUP BY p.release_mbid\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count!", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "artist_name!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "artist_mbid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + null, + null, + null, + null + ] + }, + "hash": "c06c1576352568580ef9a4284b4607311fe73a06606146de92f109d2ce5dedb6" +} diff --git a/.sqlx/query-f224b252a34a67a71266caca5affc5022e74dc42496aef9e61cec0e86d80f9d0.json b/.sqlx/query-f224b252a34a67a71266caca5affc5022e74dc42496aef9e61cec0e86d80f9d0.json deleted file mode 100644 index 0dfa066c..00000000 --- a/.sqlx/query-f224b252a34a67a71266caca5affc5022e74dc42496aef9e61cec0e86d80f9d0.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url,\n COALESCE(\n json_agg(\n json_build_object(\n 'artist_mbid', pta.artist_mbid,\n 'artist_name', pta.artist_name\n )\n ) FILTER (WHERE pta.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays p\n LEFT JOIN play_to_artists as pta ON p.uri = pta.play_uri\n GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url\n ORDER BY processed_time DESC\n LIMIT $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "uri", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "rkey", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "cid", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "isrc", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "duration", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "track_name", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "played_time", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "processed_time", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "release_mbid", - "type_info": "Uuid" - }, - { - "ordinal": 10, - "name": "release_name", - "type_info": "Text" - }, - { - "ordinal": 11, - "name": "recording_mbid", - "type_info": "Uuid" - }, - { - "ordinal": 12, - "name": "submission_client_agent", - "type_info": "Text" - }, - { - "ordinal": 13, - "name": "music_service_base_domain", - "type_info": "Text" - }, - { - "ordinal": 14, - "name": "origin_url", - "type_info": "Text" - }, - { - "ordinal": 15, - "name": "artists", - "type_info": "Json" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - null - ] - }, - "hash": "f224b252a34a67a71266caca5affc5022e74dc42496aef9e61cec0e86d80f9d0" -} diff --git a/.sqlx/query-fd5f376dac5f38005efa3217c9614e377703c681e1510fc0c6539b1edee289b7.json b/.sqlx/query-fd5f376dac5f38005efa3217c9614e377703c681e1510fc0c6539b1edee289b7.json deleted file mode 100644 index f9aba1b4..00000000 --- a/.sqlx/query-fd5f376dac5f38005efa3217c9614e377703c681e1510fc0c6539b1edee289b7.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n ae.id,\n ae.name\n FROM artists_extended ae\n WHERE ae.mbid_type = 'musicbrainz'\n AND (\n LOWER(TRIM(ae.name)) = $1\n OR LOWER(TRIM(ae.name)) LIKE '%' || $1 || '%'\n OR $1 LIKE '%' || LOWER(TRIM(ae.name)) || '%'\n OR similarity(LOWER(TRIM(ae.name)), $1) > 0.6\n )\n ORDER BY similarity(LOWER(TRIM(ae.name)), $1) DESC\n LIMIT 10\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "fd5f376dac5f38005efa3217c9614e377703c681e1510fc0c6539b1edee289b7" -} diff --git a/.sqlx/query-ff363fbb10f5c81d1ff61629724b4a1edce78892e26b82f3b295c3120ca0411d.json b/.sqlx/query-ff363fbb10f5c81d1ff61629724b4a1edce78892e26b82f3b295c3120ca0411d.json new file mode 100644 index 00000000..4b7ced19 --- /dev/null +++ b/.sqlx/query-ff363fbb10f5c81d1ff61629724b4a1edce78892e26b82f3b295c3120ca0411d.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH track_plays AS (\n SELECT\n p.uri,\n p.recording_mbid,\n p.track_name,\n p.processed_time,\n COALESCE(\n STRING_AGG(DISTINCT ptae.artist_name, ', ' ORDER BY ptae.artist_name),\n 'Unknown artist'\n ) AS artist_name\n FROM plays p\n LEFT JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n WHERE p.release_mbid = $1\n GROUP BY p.uri, p.recording_mbid, p.track_name, p.processed_time\n )\n SELECT DISTINCT ON (LOWER(track_name))\n uri,\n recording_mbid,\n track_name,\n artist_name,\n COUNT(*) OVER (\n PARTITION BY LOWER(track_name)\n ) AS \"play_count!\"\n FROM track_plays\n ORDER BY LOWER(track_name), processed_time DESC, uri\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "uri", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "recording_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "track_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "artist_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "play_count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + false, + null, + null + ] + }, + "hash": "ff363fbb10f5c81d1ff61629724b4a1edce78892e26b82f3b295c3120ca0411d" +} diff --git a/.sqlx/query-ffe0c9d638664eefd0bed5d15d51428e167d7e765e274d9743672b36705e7950.json b/.sqlx/query-ffe0c9d638664eefd0bed5d15d51428e167d7e765e274d9743672b36705e7950.json new file mode 100644 index 00000000..468eed5e --- /dev/null +++ b/.sqlx/query-ffe0c9d638664eefd0bed5d15d51428e167d7e765e274d9743672b36705e7950.json @@ -0,0 +1,144 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH page AS (\n SELECT\n p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name,\n p.played_time, p.processed_time, p.release_mbid, p.release_name,\n p.recording_mbid, p.submission_client_agent,\n p.music_service_base_domain, p.origin_url,\n COALESCE(p.played_time, p.processed_time) AS sort_time\n FROM plays p\n WHERE (\n $1::timestamptz IS NULL\n OR (COALESCE(p.played_time, p.processed_time), p.uri) < ($1, $2)\n )\n ORDER BY COALESCE(p.played_time, p.processed_time) DESC, p.uri DESC\n LIMIT $3\n )\n SELECT\n p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n p.sort_time AS \"sort_time?\",\n profile.did AS \"profile_did?\", profile.handle AS profile_handle,\n profile.display_name AS profile_display_name, profile.avatar AS profile_avatar,\n COALESCE(\n json_agg(\n json_build_object(\n 'artistMbId', ae.mbid,\n 'artistName', ptae.artist_name\n )\n ) FILTER (WHERE ptae.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM page p\n LEFT JOIN profiles profile ON p.did = profile.did\n LEFT JOIN play_to_artists_extended as ptae ON p.uri = ptae.play_uri\n LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id\n GROUP BY p.uri, p.did, p.rkey, p.cid, p.isrc, p.duration, p.track_name, p.played_time,\n p.processed_time, p.release_mbid, p.release_name, p.recording_mbid,\n p.submission_client_agent, p.music_service_base_domain, p.origin_url,\n p.sort_time, profile.did, profile.handle, profile.display_name, profile.avatar\n ORDER BY p.sort_time DESC, p.uri DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "uri", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "rkey", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "cid", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "isrc", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "duration", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "track_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "played_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "processed_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "release_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 10, + "name": "release_name", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "recording_mbid", + "type_info": "Uuid" + }, + { + "ordinal": 12, + "name": "submission_client_agent", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "music_service_base_domain", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "origin_url", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "sort_time?", + "type_info": "Timestamptz" + }, + { + "ordinal": 16, + "name": "profile_did?", + "type_info": "Text" + }, + { + "ordinal": 17, + "name": "profile_handle", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "profile_display_name", + "type_info": "Text" + }, + { + "ordinal": 19, + "name": "profile_avatar", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "artists", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Timestamptz", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + null, + false, + true, + true, + true, + null + ] + }, + "hash": "ffe0c9d638664eefd0bed5d15d51428e167d7e765e274d9743672b36705e7950" +} diff --git a/AGENTS.md b/AGENTS.md index fbcda2e1..fcb8dc84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,16 @@ pnpm lex:diff # Show changes - **Commit size**: Small, focused commits - one logical change per commit - **Message body**: Include WHY for non-obvious changes, reference issues with `#123` - **Breaking changes**: Mark with `BREAKING:` in commit body, update migration docs +- **Checkpointing**: Create focused checkpoint commits after each meaningful, verified unit of work +- **Push after commit**: After each checkpoint commit, push the current feature branch to its remote unless explicitly told not to +- **Status hygiene**: Before committing, inspect `git status --short` and leave unrelated user files unstaged +- **Handoff**: Report the commit hash after each checkpoint commit + +## Working Handoff +- Read `todo.md` before starting implementation work. +- Update `todo.md` whenever implementation state, blockers, public preview URLs, or next steps change. +- Keep future work concrete: name the service, route, test, or deployment action that remains. +- Do not commit secrets, tunnel tokens, OAuth codes, or private credentials. ## Code Standards - **TypeScript**: Biome format (`pnpm fix`), explicit types @@ -149,6 +159,25 @@ cargo test # Service tests cargo tarpaulin # Coverage ``` +## Minimum Verification +Run the relevant subset before checkpoint commits: + +```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 +``` + +For public-preview changes, also verify: + +```bash +curl --fail https:///client-metadata.json +curl --fail "https:///xrpc/fm.teal.stats.getLatest?limit=5" +``` + ## Deployment ```bash # Environment vars: DATABASE_URL, REDIS_URL, AT_PROTOCOL_JWT_SECRET @@ -172,6 +201,59 @@ docker compose exec aqua-api pnpm db:migrate - **Type-safe**: SQLx compile-time query verification - **Async-first**: Tokio runtime throughout +## Public Preview Workflow +- Use OrbStack for local Docker services when available. +- Start Postgres and Garnet before Aqua and Cadet: + +```bash +open -a OrbStack +docker compose -f compose.dev.yml up -d postgres garnet +DATABASE_URL=postgres://teal:teal@127.0.0.1:5432/teal pnpm db:migrate +``` + +- Aqua should run against local Postgres and Garnet. +- Cadet should run continuously with Jetstream ingestion enabled: + +```bash +DATABASE_URL=postgres://teal:teal@127.0.0.1:5432/teal \ +REDIS_URL=redis://127.0.0.1:6379 \ +CADET_STREAM_MODE=jetstream \ +JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe \ +SQLX_OFFLINE=true cargo run -p cadet +``` + +- Serve Amethyst and proxy `/xrpc/*` to Aqua through the same public hostname. +- After completing a new feature or overhaul, publish the updated build to the stable public preview at `https://sigilyph.teal.fm` through the Cloudflare tunnel. +- For temporary demos, a Cloudflare quick tunnel is acceptable. Record the active URL in `todo.md`. +- Treat quick-tunnel URLs as ephemeral. A tunnel restart changes the hostname. + +## OAuth Tunnel Rule +- Before testing ATProto OAuth, rebuild Amethyst with the active public origin: + +```bash +EXPO_PUBLIC_BASE_URL=https:// \ +pnpm --filter=@teal/amethyst build:web +``` + +- Serve `/client-metadata.json` from the same public hostname. +- Ensure the metadata uses: + +```text +client_id=https:///client-metadata.json +redirect_uris=["https:///auth/callback"] +``` + +- Verify the deployed web bundle contains the active tunnel hostname. +- Verify `/client-metadata.json` publicly before initiating login. +- Never test public OAuth with a bundle that falls back to `localhost` or `127.0.0.1`. + +## Live Feed Rule +- The production data path is Cadet Jetstream ingestion into Postgres, surfaced by Aqua XRPC. +- Keep `/xrpc/*` same-origin through the Amethyst reverse proxy. +- Never add backup, seeded, mocked, or demo play data to Amethyst. +- If Aqua is unavailable, show an error state. +- If Aqua has not indexed plays yet, show an empty state. + ## Rust Anti-Patterns to Avoid - **DON'T**: Use `unwrap()` or `expect()` in production code - use proper error handling - **DON'T**: Clone unnecessarily - prefer borrowing with `&` or using `Arc` diff --git a/Cargo.lock b/Cargo.lock index a7176dde..349039f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,10 +30,19 @@ dependencies = [ ] [[package]] -name = "aliasable" -version = "0.1.3" +name = "alloc-no-stdlib" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] [[package]] name = "allocator-api2" @@ -102,9 +111,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "aqua" @@ -140,9 +149,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -155,9 +164,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-compression" @@ -179,7 +188,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -231,15 +240,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -247,14 +256,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -268,7 +278,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -300,7 +310,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body", "http-body-util", "mime", @@ -319,7 +329,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -353,6 +363,12 @@ dependencies = [ "match-lookup", ] +[[package]] +name = "base45" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" + [[package]] name = "base64" version = "0.21.7" @@ -373,9 +389,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -427,18 +443,18 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] [[package]] name = "bon" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" dependencies = [ "bon-macros", "rustversion", @@ -446,9 +462,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ "darling 0.23.0", "ident_case", @@ -456,7 +472,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -467,14 +483,35 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "borsh" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ "bytes", "cfg_aliases", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bs58" version = "0.5.1" @@ -486,9 +523,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -498,9 +535,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -543,9 +580,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" dependencies = [ "serde_core", ] @@ -584,9 +621,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -606,11 +643,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -688,10 +736,10 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -754,6 +802,7 @@ version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ + "brotli", "compression-core", "flate2", "memchr", @@ -889,27 +938,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -979,7 +1028,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -992,7 +1041,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1003,7 +1052,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1014,7 +1063,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1054,7 +1103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1073,9 +1122,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_builder" @@ -1095,7 +1141,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1105,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1125,7 +1171,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -1153,7 +1199,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", ] @@ -1180,13 +1226,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1478,7 +1524,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1512,9 +1558,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ "cc", "cfg-if", @@ -1565,15 +1611,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1589,16 +1636,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.4.2", "indexmap", "slab", "tokio", @@ -1672,12 +1719,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -1730,9 +1771,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1745,7 +1786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -1756,7 +1797,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body", "pin-project-lite", ] @@ -1775,25 +1816,25 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", - "http 1.4.0", + "http 1.4.2", "http-body", "httparse", "httpdate", @@ -1810,15 +1851,15 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", + "http 1.4.2", "hyper", "hyper-util", - "rustls 0.23.40", - "rustls-native-certs 0.8.3", + "rustls 0.23.41", + "rustls-native-certs 0.8.4", "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -1831,7 +1872,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body", "hyper", "ipnet", @@ -1952,12 +1993,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1993,8 +2028,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -2053,9 +2086,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jacquard-common" -version = "0.12.0-beta.2" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e830579811d60e29209c9466d034225d5e045ecdc2b3c55282709bd07da97869" +checksum = "fec8b6661e6fcfb4d8b9eb1132503be71bc3e3c6f6ff15fa5500ec62655f6da7" dependencies = [ "base64 0.22.1", "bon", @@ -2069,7 +2102,7 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "hashbrown 0.15.5", - "http 1.4.0", + "http 1.4.2", "ipld-core", "k256", "maitake-sync", @@ -2077,7 +2110,6 @@ dependencies = [ "multibase", "multihash", "n0-future", - "ouroboros", "oxilangtag", "p256", "phf", @@ -2106,26 +2138,26 @@ dependencies = [ [[package]] name = "jacquard-derive" -version = "0.12.0-beta.2" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f83b8049e4e7916e0f6764c3deaf5e55a7ffbab26c379415e9b1d4d645d957" +checksum = "e41b82a9a72c0d2b907a5e0734e0692615e717cda903502c6c034ae6f3ddddf7" dependencies = [ - "heck 0.5.0", + "heck", "jacquard-lexicon", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "jacquard-lexicon" -version = "0.12.0-beta.2" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64935ef85dd24f60f467082c21ad52f739a02dd402a2adf40e5794e3de949e1f" +checksum = "160049c269e3d7ec56f130736d25d61953fc6216da660c8b5e102f6b052dd3a1" dependencies = [ "cid", "dashmap", - "heck 0.5.0", + "heck", "inventory", "jacquard-common", "miette", @@ -2140,30 +2172,29 @@ dependencies = [ "serde_repr", "serde_with", "sha2 0.10.9", - "syn 2.0.117", + "syn 2.0.118", "thiserror 2.0.18", "unicode-segmentation", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2200,12 +2231,6 @@ dependencies = [ "spin 0.9.8", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -2220,14 +2245,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.9.0", ] [[package]] @@ -2263,9 +2288,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom" @@ -2307,7 +2332,7 @@ checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2337,9 +2362,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "metrics" @@ -2417,7 +2442,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2444,9 +2469,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2462,7 +2487,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http 1.4.2", "httparse", "memchr", "mime", @@ -2472,12 +2497,13 @@ dependencies = [ [[package]] name = "multibase" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" dependencies = [ "base-x", "base256emoji", + "base45", "data-encoding", "data-encoding-macro", ] @@ -2529,7 +2555,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -2599,9 +2625,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2651,11 +2677,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2718,35 +2743,11 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ouroboros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" -dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", -] - -[[package]] -name = "ouroboros_macro" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.117", -] - [[package]] name = "oxilangtag" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f3f87617a86af77fa3691e6350483e7154c2ead9f1261b75130e21ca0f8acb" +checksum = "5d3b4eb570abd4a1dcb062c31fd37b832264d9dc7292c3e69acfe926c87b063f" dependencies = [ "serde", ] @@ -2837,7 +2838,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2866,7 +2867,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2958,7 +2959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2988,19 +2989,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "version_check", - "yansi", -] - [[package]] name = "quanta" version = "0.12.6" @@ -3018,9 +3006,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3028,7 +3016,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.40", + "rustls 0.23.41", "socket2", "thiserror 2.0.18", "tokio", @@ -3038,17 +3026,18 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", - "rustls 0.23.40", + "rustls 0.23.41", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -3059,23 +3048,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3113,6 +3102,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3151,6 +3151,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -3162,9 +3177,9 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ "rustversion", ] @@ -3214,9 +3229,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ "bitflags", ] @@ -3249,14 +3264,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -3266,9 +3281,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -3283,9 +3298,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -3299,7 +3314,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.0", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -3311,7 +3326,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls 0.23.41", "rustls-pki-types", "serde", "serde_json", @@ -3328,7 +3343,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -3410,9 +3425,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3450,9 +3465,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", @@ -3477,9 +3492,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", @@ -3498,9 +3513,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -3530,9 +3545,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -3692,7 +3707,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3762,7 +3777,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3779,9 +3794,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -3795,14 +3810,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3876,9 +3891,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3926,9 +3941,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -3945,9 +3960,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4015,7 +4030,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls 0.23.40", + "rustls 0.23.41", "serde", "serde_json", "sha2 0.10.9", @@ -4040,7 +4055,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4051,7 +4066,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "once_cell", "proc-macro2", @@ -4063,7 +4078,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.117", + "syn 2.0.118", "tokio", "url", ] @@ -4186,12 +4201,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "stringprep" version = "0.1.5" @@ -4228,9 +4237,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -4254,7 +4263,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4327,7 +4336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4359,7 +4368,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4370,7 +4379,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4384,12 +4393,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -4401,15 +4409,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -4480,7 +4488,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4499,7 +4507,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.40", + "rustls 0.23.41", "tokio", ] @@ -4538,8 +4546,8 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", - "rustls 0.23.40", - "rustls-native-certs 0.8.3", + "rustls 0.23.41", + "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -4554,7 +4562,7 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", - "rustls 0.23.40", + "rustls 0.23.41", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -4570,10 +4578,10 @@ checksum = "e21a5c399399c3db9f08d8297ac12b500e86bca82e930253fdc62eaf9c0de6ae" dependencies = [ "futures-channel", "futures-util", - "http 1.4.0", + "http 1.4.2", "httparse", "js-sys", - "rustls 0.23.40", + "rustls 0.23.41", "thiserror 1.0.69", "tokio", "tokio-tungstenite 0.24.0", @@ -4606,9 +4614,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime", @@ -4652,7 +4660,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body", "http-body-util", "pin-project-lite", @@ -4696,7 +4704,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4746,7 +4754,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4784,11 +4792,11 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "rand 0.8.6", - "rustls 0.23.40", + "rustls 0.23.41", "rustls-pki-types", "sha1 0.10.6", "thiserror 1.0.69", @@ -4803,11 +4811,11 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "rand 0.9.4", - "rustls 0.23.40", + "rustls 0.23.41", "rustls-pki-types", "sha1 0.10.6", "thiserror 2.0.18", @@ -4815,9 +4823,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "types" @@ -4862,9 +4870,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -4928,11 +4936,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -5015,20 +5023,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -5039,9 +5038,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -5052,9 +5051,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5062,9 +5061,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5072,48 +5071,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -5127,23 +5104,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -5171,14 +5136,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -5258,7 +5223,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5269,7 +5234,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5280,7 +5245,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5291,7 +5256,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5365,15 +5330,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5407,30 +5363,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5443,12 +5382,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5461,12 +5394,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5479,24 +5406,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5509,12 +5424,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5527,12 +5436,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5545,12 +5448,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5563,12 +5460,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "1.0.3" @@ -5578,117 +5469,23 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5703,28 +5500,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5744,15 +5541,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -5784,7 +5581,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 17ba4cb9..5cfe4087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,9 +20,9 @@ sqlx = { version = "0.8", features = [ "chrono", "tls-rustls", ] } -jacquard-common = { version = "0.12.0-beta.2", features = ["reqwest-client"] } -jacquard-derive = { version = "0.12.0-beta.2" } -jacquard-lexicon = { version = "0.12.0-beta.2" } +jacquard-common = { version = "0.12.1", features = ["reqwest-client"] } +jacquard-derive = { version = "0.12.1" } +jacquard-lexicon = { version = "0.12.1" } serde = { version = "1.0", features = ["derive"] } anyhow = "1.0" serde_json = "1.0" diff --git a/LLM_WRITING/songish-teal-clone-plan.md b/LLM_WRITING/songish-teal-clone-plan.md new file mode 100644 index 00000000..b456aa60 --- /dev/null +++ b/LLM_WRITING/songish-teal-clone-plan.md @@ -0,0 +1,83 @@ +# Teal Songish Clone With ATProto Firehose Ingestion + +## Summary + +Build the Teal-branded Songish-style UI in `apps/amethyst`, backed by Teal lexicons and Rust services. The feed will show real `fm.teal.alpha.feed.play` records ingested from the ATProto network stream, not direct social posts. + +ATProto docs confirm that appviews commonly consume repo events from `com.atproto.sync.subscribeRepos`, and that Jetstream is the lower-bandwidth JSON stream built on the firehose with collection filtering. Sources: [Streaming Data](https://atproto.com/guides/streaming-data), [Sync Spec](https://atproto.com/specs/sync). + +## Firehose Ingestion + +- Make `services/cadet` the canonical Rust ingestion service for new plays. +- Keep Jetstream as the default ingestion transport because Cadet already uses it and it supports `wanted_collections=["fm.teal.alpha.feed.play"]`. +- Add a clear config boundary: + - `CADET_STREAM_MODE=jetstream` default. + - `JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe` default. + - Reserve `CADET_STREAM_MODE=subscribeRepos` for a later full-CBOR firehose adapter if strict relay-level sync is required. +- Cadet must ingest: + - creates/updates for `fm.teal.alpha.feed.play` into `plays` and artist join tables. + - deletes by removing the correct play URI, fixing the current delete path if it deletes by DID instead of URI. + - profiles from `fm.teal.alpha.actor.profile`. +- Persist stream cursor durably: + - Use Redis or Postgres cursor storage instead of only `cursor.txt`. + - Keep file cursor as local fallback. +- Remove `POST /api/plays/ingest` as the primary ingestion path. +- Add only a dev-only seed path if needed: + - `POST /api/dev/plays/seed`, guarded by `TEAL_DEV_SEED_TOKEN`, disabled unless `ENABLE_DEV_SEED_ENDPOINT=true`. + +## App And API Changes + +- Amethyst: + - Clone Songish’s layout: desktop left rail, mobile bottom nav, translucent center feed, right stats rail. + - Home feed calls `fm.teal.alpha.stats.getLatest`. + - Profile pages call `getProfile` and `getActorFeed`. + - Music pages render from play fields, MusicBrainz IDs, and Cover Art Archive. + - Notifications/Explore render Songish-like empty or signed-out states; social features remain deferred. +- Aqua: + - Align routes with lexicons, especially `fm.teal.alpha.feed.getActorFeed`. + - Extend `playView` with optional `uri`, `cid`, `authorDid`, and `rkey`. + - Populate those fields from existing DB columns. + - Keep CAR import endpoints as backfill tools, not primary live ingestion. +- Lexicons: + - No new social lexicons in v1. + - Regenerate generated Rust/TS types after `playView` additions. + +## Docker And Tunnel + +- Fix `apps/amethyst/Dockerfile` so it copies all required workspace files, including `tools/lexicon-cli`. +- Production compose: + - `amethyst`, `aqua-api`, `cadet`, `satellite`, `postgres`, `garnet`. + - Cadet runs continuously as the firehose/Jetstream consumer. +- Development compose: + - Same core services plus bind mounts where useful. + - Add optional `cloudflared` profile for public OAuth testing. +- Cloudflare Tunnel: + - Route public tunnel host to Amethyst. + - Set `EXPO_PUBLIC_BASE_URL=https://`. + - Generate `/client-metadata.json` with redirect `https:///auth/callback`. + - Use this public URL for ATProto login testing. + +## Test Plan + +- Backend: + - `pnpm lex:gen-server` + - `pnpm test:rust` + - Add Cadet ingestion tests for create/update/delete `fm.teal.alpha.feed.play`. + - Add cursor persistence tests. + - Add CAR backfill regression tests. +- Docker: + - `docker compose -f compose.dev.yml --profile tunnel up` + - Verify Cadet ingests live play records into Postgres. + - Verify Aqua returns the ingested records through stats/feed XRPCs. + - Verify production compose builds cleanly. +- Browser: + - Use the Codex Chrome skill for final visual and OAuth QA. + - Test desktop and mobile Home, Profile, Music Detail, Explore, Notifications, Sign In. + - Test ATProto login through the Cloudflare Tunnel callback URL. + - Capture final screenshots from Chrome after implementation. + +## Assumptions + +- “Firehose ingestion” means Cadet consumes the ATProto network event stream; Jetstream is acceptable for v1 because it is the official app-developer-friendly firehose fanout with collection filtering. +- No direct trusted play POST is part of production ingestion. +- Social features are deferred until new Teal social lexicons are explicitly designed. diff --git a/README.md b/README.md index 41b35189..883f50cd 100644 --- a/README.md +++ b/README.md @@ -160,4 +160,4 @@ git add vendor/atproto git commit -m "Update atproto lexicons to latest" ``` -See [`tools/lexicon-cli/README.md`](tools/lexicon-cli/README.md) for detailed documentation. +The TypeScript lexicon package runs `@atproto/lex` validation before generating the compatibility server bindings consumed by the current XRPC layer. diff --git a/apps/amethyst/.eslintrc.js b/apps/amethyst/.eslintrc.js deleted file mode 100644 index 9793e31f..00000000 --- a/apps/amethyst/.eslintrc.js +++ /dev/null @@ -1,8 +0,0 @@ -// https://docs.expo.dev/guides/using-eslint/ -module.exports = { - extends: "expo", - plugins: ["eslint-plugin-react-compiler"], - rules: { - "react-compiler/react-compiler": "error", - }, -}; diff --git a/apps/amethyst/Caddyfile b/apps/amethyst/Caddyfile index ff458bde..acde5451 100644 --- a/apps/amethyst/Caddyfile +++ b/apps/amethyst/Caddyfile @@ -1,5 +1,10 @@ -{env.CLIENT_ADDRESS} { - try_files {path} /index.html - file_server -} +{$CLIENT_ADDRESS} { + handle /xrpc/* { + reverse_proxy aqua-api:3000 + } + handle { + try_files {path} /index.html + file_server + } +} diff --git a/apps/amethyst/Dockerfile b/apps/amethyst/Dockerfile index ce270af4..3483b46c 100644 --- a/apps/amethyst/Dockerfile +++ b/apps/amethyst/Dockerfile @@ -1,63 +1,56 @@ 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 +ARG EXPO_PUBLIC_GIT_BRANCH=unknown +ARG EXPO_PUBLIC_GIT_COMMIT=unknown ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -# Set working directory -WORKDIR /app +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} +ENV EXPO_PUBLIC_GIT_BRANCH=${EXPO_PUBLIC_GIT_BRANCH} +ENV EXPO_PUBLIC_GIT_COMMIT=${EXPO_PUBLIC_GIT_COMMIT} -# 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/ +RUN corepack enable -# Copy lexicons source data -COPY lexicons/ ./lexicons/ +WORKDIR /app -# Copy the aqua app -COPY apps/amethyst/ ./apps/amethyst/ +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json tsconfig.json ./ +COPY apps/amethyst/package.json ./apps/amethyst/package.json +COPY packages/lexicons/package.json ./packages/lexicons/package.json +COPY packages/tsconfig/package.json ./packages/tsconfig/package.json +COPY tools/lexicon-cli/package.json ./tools/lexicon-cli/package.json -# Copy .env -COPY ../../.env ./apps/amethyst/.env +RUN pnpm install --frozen-lockfile --ignore-scripts -# Install dependencies and generate lexicons -RUN cd tools/lexicon-cli && pnpm build +COPY packages ./packages +COPY tools ./tools +COPY lexicons ./lexicons +COPY vendor/atproto/lexicons/app/bsky/richtext/facet.json ./vendor/atproto/lexicons/app/bsky/richtext/facet.json +COPY vendor/atproto/lexicons/com/atproto/repo/strongRef.json ./vendor/atproto/lexicons/com/atproto/repo/strongRef.json +COPY apps/amethyst ./apps/amethyst -# Generate lexicons before building amethyst +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 base=process.env.EXPO_PUBLIC_BASE_URL || "http://localhost:8081"; 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)/(stamp)/stamp/index.tsx b/apps/amethyst/app/(tabs)/(stamp)/stamp/index.tsx index a887cc08..a1f9bf1b 100644 --- a/apps/amethyst/app/(tabs)/(stamp)/stamp/index.tsx +++ b/apps/amethyst/app/(tabs)/(stamp)/stamp/index.tsx @@ -1,13 +1,11 @@ import React, { useContext, useEffect, useRef, useState } from "react"; -import { - FlatList, - Image, - ScrollView, - TouchableOpacity, - View, -} from "react-native"; +import { FlatList, Image, TouchableOpacity, View } from "react-native"; import { Link, Stack, useRouter } from "expo-router"; import { ExternalLink } from "@/components/ExternalLink"; +import RightRail from "@/components/teal/RightRail"; +import TealShell, { + SectionHeading, +} from "@/components/teal/TealShell"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import SheetBackdrop, { SheetHandle } from "@/components/ui/sheetBackdrop"; @@ -78,16 +76,21 @@ export default function StepOne() { }; return ( - + }> - {/* Search Form */} - - Search for a track + + + Search for a track {/* Search Results */} - + {searchResults.length > 0 ? ( @@ -218,7 +221,7 @@ export default function StepOne() { )} - + ); } @@ -299,7 +302,7 @@ export function SearchResult({ > - + Release: @@ -369,7 +372,7 @@ export function SearchResult({ {result.releases?.map((release) => ( { @@ -380,21 +383,21 @@ export function SearchResult({ {release.title} {release.date && ( - {release.date} + {release.date} )} {release.country && ( - + {release.country} )} {release.status && ( - + {release.status} )} {release.disambiguation && ( - + {release.disambiguation} )} diff --git a/apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx b/apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx index 0d663127..753e7833 100644 --- a/apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx +++ b/apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx @@ -6,7 +6,7 @@ import VerticalPlayView from "@/components/play/verticalPlayView"; import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; import { Textarea } from "@/components/ui/textarea"; -import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp"; +import { PlaySubmittedData } from "@/lib/oldStamp"; import { cn } from "@/lib/utils"; import { useStore } from "@/stores/mainStore"; import { @@ -16,13 +16,10 @@ import { RichText, } from "@atproto/api"; -import { Artist } from "@teal/lexicons/src/types/fm/teal/feed/defs"; -import { - Record as PlayRecord, - validateRecord, -} from "@teal/lexicons/src/types/fm/teal/feed/play"; +import { validateRecord } from "@teal/lexicons/src/types/fm/teal/feed/play"; import { StampContext, StampContextValue, StampStep } from "./_layout"; +import { createPlayRecordFromRecording } from "@/lib/manualListens"; type CardyBResponse = { error: string; @@ -129,34 +126,6 @@ const ms2hms = (ms: number): string => { return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; }; -const createPlayRecord = (result: MusicBrainzRecording): PlayRecord => { - let artists = result["artist-credit"]?.map( - (a) => - ({ - artistName: a.artist.name, - artistMbId: a.artist.id, - }) as Artist, - ); - - console.log("artists", artists); - - return { - trackName: result.title ?? "Unknown Title", - recordingMbId: result.id ?? undefined, - duration: result.length ? Math.floor(result.length / 1000) : undefined, - artists: artists, - releaseName: result.selectedRelease?.title ?? undefined, - releaseMbId: result.selectedRelease?.id ?? undefined, - isrc: result.isrcs?.[0] ?? undefined, - // not providing unless we have a way to map to tidal/odesli/etc w/out MB - //originUrl: `https://tidal.com/browse/track/274816578?u`, - //musicServiceBaseDomain: "tidal.com", - // TODO: update this based on version/git commit hash on build - submissionClientAgent: "tealtracker/0.0.1b", - playedTime: new Date().toISOString(), - } as PlayRecord; -}; - export default function Submit() { const router = useRouter(); const agent = useStore((state) => state.pdsAgent); @@ -235,7 +204,7 @@ powered by @teal.fm`; const handleSubmit = async () => { setIsSubmitting(true); try { - let record = createPlayRecord(selectedTrack); + let record = createPlayRecordFromRecording(selectedTrack); let result = validateRecord(record); if (result.success === false) { throw new Error("Failed to validate play: " + result.error); @@ -331,7 +300,7 @@ powered by @teal.fm`; .join(", ")} releaseTitle={selectedTrack?.selectedRelease?.title} /> - + Any missing info?{" "} 150 - ? "text-gray-600 dark:text-gray-300" + ? "text-foreground" : "", blueskyPostText.length > 290 ? "text-red-500" : "", )} diff --git a/apps/amethyst/app/(tabs)/(stamp)/stamp/success.tsx b/apps/amethyst/app/(tabs)/(stamp)/stamp/success.tsx index 27255c37..91236afc 100644 --- a/apps/amethyst/app/(tabs)/(stamp)/stamp/success.tsx +++ b/apps/amethyst/app/(tabs)/(stamp)/stamp/success.tsx @@ -2,6 +2,8 @@ import { useContext, useEffect } from "react"; import { View } from "react-native"; import { Redirect, Stack, useRouter } from "expo-router"; import { ExternalLink } from "@/components/ExternalLink"; +import RightRail from "@/components/teal/RightRail"; +import TealShell from "@/components/teal/TealShell"; import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; import { Check, ExternalLinkIcon } from "lucide-react-native"; @@ -24,15 +26,18 @@ export default function StepThree() { return ; } return ( - + }> - - - Play Submitted! + + + + + Play submitted You can view your play{" "} Submit another - + ); } 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 00000000..ba90dc49 --- /dev/null +++ b/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/[track].tsx @@ -0,0 +1,240 @@ +import { useEffect, useState } from "react"; +import { ActivityIndicator, Image, Pressable, View } from "react-native"; +import { Link, Stack, useLocalSearchParams } from "expo-router"; +import PlayFeedCard from "@/components/teal/PlayFeedCard"; +import SocialPostCard from "@/components/teal/SocialPostCard"; +import RightRail from "@/components/teal/RightRail"; +import TealShell, { + SectionHeading, +} from "@/components/teal/TealShell"; +import { Text } from "@/components/ui/text"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { + coverArtUrl, + displayArtists, + getLatestPlays, + getPlayByUri, + getRecordingCoverArtUrl, + getSearchResults, + getSocialPost, +} from "@/lib/teal/api"; +import { musicAlbumHref, musicArtistHref, routePart } from "@/lib/teal/routes"; +import { Disc3 } from "lucide-react-native"; + +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/feed/defs"; +import type { SocialPostView } from "@/lib/teal/api"; + +function sameTrack(candidate: PlayView, selected: PlayView) { + if (candidate.recordingMbId && selected.recordingMbId) { + return candidate.recordingMbId === selected.recordingMbId; + } + + return ( + routePart(candidate.trackName) === routePart(selected.trackName) && + routePart(displayArtists(candidate)) === routePart(displayArtists(selected)) && + routePart(candidate.releaseName) === routePart(selected.releaseName) + ); +} + +export default function MusicDetail() { + const params = useLocalSearchParams(); + const uri = Array.isArray(params.uri) ? params.uri[0] : params.uri; + const postUri = Array.isArray(params.postUri) + ? params.postUri[0] + : params.postUri; + const artistSlug = Array.isArray(params.artist) + ? params.artist[0] + : params.artist; + const releaseSlug = Array.isArray(params.release) + ? params.release[0] + : params.release; + const trackSlug = Array.isArray(params.track) ? params.track[0] : params.track; + const [play, setPlay] = useState(null); + const [related, setRelated] = useState([]); + const [sourcePost, setSourcePost] = useState(null); + const [error, setError] = useState(null); + const [artFailed, setArtFailed] = useState(false); + const [recordingArt, setRecordingArt] = useState(); + + useEffect(() => { + let mounted = true; + async function resolvePlayFromSlugs() { + if (!trackSlug) { + throw new Error("Missing track slug"); + } + const query = trackSlug.replace(/-/g, " "); + const results = await getSearchResults(query, 25); + const match = results.songs.find((song) => { + const trackMatches = routePart(song.trackName) === trackSlug; + const releaseMatches = + !releaseSlug || routePart(song.releaseName) === releaseSlug; + const artistMatches = + !artistSlug || routePart(song.artistName) === artistSlug; + return trackMatches && releaseMatches && artistMatches; + }); + + if (!match) { + throw new Error("No indexed play matches this music URL"); + } + + return (await getPlayByUri(match.uri)).play; + } + + async function load() { + try { + const selected = uri + ? (await getPlayByUri(uri)).play + : await resolvePlayFromSlugs(); + const [latest, postResult] = await Promise.all([ + getLatestPlays(50), + postUri ? getSocialPost(postUri) : Promise.resolve(null), + ]); + if (!mounted) return; + setPlay(selected); + setRelated(latest.plays.filter((candidate) => sameTrack(candidate, selected))); + setSourcePost(postResult?.post || null); + } catch (e) { + if (mounted) setError(e instanceof Error ? e.message : String(e)); + } + } + load(); + return () => { + mounted = false; + }; + }, [artistSlug, postUri, releaseSlug, trackSlug, uri]); + + const releaseArt = coverArtUrl(play?.releaseMbId, 500); + const art = artFailed ? undefined : releaseArt || recordingArt; + + useEffect(() => { + let mounted = true; + setArtFailed(false); + if (releaseArt || !play?.recordingMbId) { + setRecordingArt(undefined); + return; + } + getRecordingCoverArtUrl(play.recordingMbId, 500).then((url) => { + if (mounted) setRecordingArt(url); + }); + return () => { + mounted = false; + }; + }, [play?.recordingMbId, releaseArt]); + + return ( + }> + + {!play && !error && ( + + + + )} + {error && ( + + + Could not load music detail: {error} + + + )} + {play && ( + <> + + + {art && ( + setArtFailed(true)} + /> + )} + + + {art ? ( + setArtFailed(true)} + /> + ) : ( + + + + )} + + + {play.trackName} + + {play.artists[0]?.artistMbId ? ( + + + + {displayArtists(play) || "Unknown artist"} + + + + ) : ( + + {displayArtists(play) || "Unknown artist"} + + )} + {play.releaseName && + (play.releaseMbId ? ( + + + + {play.releaseName} + + + + ) : ( + + {play.releaseName} + + ))} + + + + + {(related.length ? related : [play]).map((item, index) => ( + + ))} + {sourcePost && ( + + + + + )} + + )} + + ); +} diff --git a/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/index.tsx b/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/index.tsx new file mode 100644 index 00000000..ad028733 --- /dev/null +++ b/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/index.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Image, + Pressable, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; +import { Link, Stack, useLocalSearchParams } from "expo-router"; +import PlayFeedCard from "@/components/teal/PlayFeedCard"; +import RightRail from "@/components/teal/RightRail"; +import TealShell, { + SectionHeading, +} from "@/components/teal/TealShell"; +import { Text } from "@/components/ui/text"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { coverArtUrl, getAlbum } from "@/lib/teal/api"; +import { musicArtistHref, musicTrackHref } from "@/lib/teal/routes"; +import { ChevronRight, Disc3, Music2 } from "lucide-react-native"; + +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/feed/defs"; +import type { AlbumView } from "@teal/lexicons/src/types/fm/teal/music/defs"; + +export default function AlbumDetail() { + const params = useLocalSearchParams(); + const mbid = Array.isArray(params.mbid) ? params.mbid[0] : params.mbid; + const [album, setAlbum] = useState(null); + const [plays, setPlays] = useState([]); + const [cursor, setCursor] = useState(); + const [error, setError] = useState(); + const [artFailed, setArtFailed] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const loadingMoreRef = useRef(false); + + useEffect(() => { + let mounted = true; + if (!mbid) { + setError("Missing MusicBrainz release ID"); + return; + } + getAlbum(mbid, 30) + .then((page) => { + if (!mounted) return; + setAlbum(page.album); + setPlays(page.plays); + setCursor(page.cursor); + }) + .catch((loadError) => { + if (mounted) { + setError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + } + }); + return () => { + mounted = false; + }; + }, [mbid]); + + const loadMore = useCallback(() => { + if (!mbid || !cursor || loadingMoreRef.current) return; + loadingMoreRef.current = true; + setLoadingMore(true); + getAlbum(mbid, 30, cursor) + .then((page) => { + setPlays((current) => { + const knownUris = new Set(current.map((play) => play.uri)); + return [ + ...current, + ...page.plays.filter( + (play) => !play.uri || !knownUris.has(play.uri), + ), + ]; + }); + setCursor(page.cursor); + }) + .catch((loadError) => { + setError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + }) + .finally(() => { + loadingMoreRef.current = false; + setLoadingMore(false); + }); + }, [cursor, mbid]); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = + event.nativeEvent; + if ( + contentSize.height - (contentOffset.y + layoutMeasurement.height) < + 800 + ) { + loadMore(); + } + }, + [loadMore], + ); + + const art = artFailed ? undefined : coverArtUrl(album?.mbid, 500); + + return ( + } onScroll={handleScroll}> + + {!album && !error && ( + + + + )} + {error && ( + + + Could not load album: {error} + + + )} + {album && ( + <> + + + {art && ( + setArtFailed(true)} + /> + )} + + + + {art ? ( + setArtFailed(true)} + /> + ) : ( + + )} + + + + Album + + + {album.name} + + {album.artistMbid ? ( + + + + {album.artistName} + + + + ) : ( + + {album.artistName} + + )} + + {album.playCount} indexed listens + + + + + + + + {album.tracks.map((track) => ( + + + + + + + + {track.name} + + + {track.artistName} + + + + {track.playCount} + + + + + ))} + + + + {plays.map((play, index) => ( + + ))} + {loadingMore && ( + + + + )} + {plays.length > 0 && !cursor && ( + + You reached the beginning of this album's indexed listens. + + )} + + )} + + ); +} diff --git a/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx b/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx new file mode 100644 index 00000000..2ba6cc88 --- /dev/null +++ b/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx @@ -0,0 +1,320 @@ +import { useEffect, useState } from "react"; +import { ActivityIndicator, Image, Pressable, View } from "react-native"; +import { Link, Stack, useLocalSearchParams } from "expo-router"; +import { + ArtistLeaderboardList, + ArtistListenerPeriodTabs, + normalizeArtistListenerPeriod, +} from "@/components/teal/ArtistLeaderboard"; +import RightRail from "@/components/teal/RightRail"; +import TealShell, { + SectionHeading, +} from "@/components/teal/TealShell"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { + coverArtUrl, + getArtist, + getArtistImageUrl, + getArtistListeners, + type ArtistListenerPeriod, +} from "@/lib/teal/api"; +import { musicAlbumHref, musicArtistListenersHref } from "@/lib/teal/routes"; +import { ChevronRight, Disc3, Mic2, Trophy } from "lucide-react-native"; + +import type { ArtistView } from "@teal/lexicons/src/types/fm/teal/music/defs"; +import type { ArtistListenerView } from "@teal/lexicons/src/types/fm/teal/music/defs"; + +export default function ArtistDetail() { + const params = useLocalSearchParams(); + const artistSlug = Array.isArray(params.artist) + ? params.artist[0] + : params.artist; + const mbid = Array.isArray(params.mbid) ? params.mbid[0] : params.mbid; + const name = Array.isArray(params.name) ? params.name[0] : params.name; + const artistLookup = name || artistSlug; + const initialPeriod = Array.isArray(params.period) + ? params.period[0] + : params.period; + const [artist, setArtist] = useState(null); + const [listeners, setListeners] = useState([]); + const [listenerPeriod, setListenerPeriod] = useState( + normalizeArtistListenerPeriod(initialPeriod), + ); + const [error, setError] = useState(); + const [listenerError, setListenerError] = useState(); + const [listenersLoading, setListenersLoading] = useState(false); + const [artFailed, setArtFailed] = useState(false); + const [artistImage, setArtistImage] = useState(); + + useEffect(() => { + let mounted = true; + getArtist(mbid, artistLookup) + .then(({ artist }) => { + if (mounted) setArtist(artist); + }) + .catch((loadError) => { + if (mounted) { + setError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + } + }); + return () => { + mounted = false; + }; + }, [artistLookup, mbid]); + + useEffect(() => { + let mounted = true; + setListenersLoading(true); + setListenerError(undefined); + getArtistListeners(mbid, artistLookup, listenerPeriod, 5) + .then(({ listeners }) => { + if (mounted) setListeners(listeners); + }) + .catch((loadError) => { + if (mounted) { + setListeners([]); + setListenerError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + } + }) + .finally(() => { + if (mounted) setListenersLoading(false); + }); + return () => { + mounted = false; + }; + }, [artistLookup, mbid, listenerPeriod]); + + useEffect(() => { + let mounted = true; + setArtistImage(undefined); + if (!artist?.mbid) return; + getArtistImageUrl(artist.mbid, 500).then((imageUrl) => { + if (mounted) setArtistImage(imageUrl); + }); + return () => { + mounted = false; + }; + }, [artist?.mbid]); + + const representativeArt = coverArtUrl(artist?.albums[0]?.mbid, 500); + const heroArt = artistImage || (artFailed ? undefined : representativeArt); + const handleHeroArtError = () => { + if (artistImage) { + setArtistImage(undefined); + } else { + setArtFailed(true); + } + }; + + const renderRelease = (album: ArtistView["albums"][number]) => { + const art = coverArtUrl(album.mbid); + return ( + + + + {art ? ( + + ) : ( + + )} + + + + {album.name} + + + {album.playCount} listens + + + + + + ); + }; + + const discographyGroups: Array<{ + title: string; + releases: ArtistView["albums"]; + }> = artist + ? [ + { + title: "Albums", + releases: artist.albums.filter((release) => release.releaseType === "album"), + }, + { + title: "EPs", + releases: artist.albums.filter((release) => release.releaseType === "ep"), + }, + { + title: "Singles", + releases: artist.albums.filter((release) => release.releaseType === "single"), + }, + { + title: "Other releases", + releases: artist.albums.filter( + (release) => + release.releaseType !== "album" && + release.releaseType !== "ep" && + release.releaseType !== "single", + ), + }, + ] + : []; + const albumCount = + artist?.albums.filter((release) => release.releaseType === "album").length ?? 0; + + return ( + }> + + {!artist && !error && ( + + + + )} + {error && ( + + + Could not load artist: {error} + + + )} + {artist && ( + <> + + + {heroArt && ( + + )} + + + + {heroArt ? ( + + ) : ( + + )} + + + + Artist + + + {artist.name} + + + {artist.playCount} indexed listens + + + + + + + + + + + {listenersLoading && listeners.length === 0 ? ( + + + + ) : listenerError ? ( + + + Could not load listeners: {listenerError} + + + ) : listeners.length === 0 ? ( + + + + No indexed listeners for this period yet. + + + ) : ( + <> + + + + + + )} + + + + {discographyGroups.map(({ title, releases }) => + releases.length > 0 ? ( + + + {title} + + + {releases.map(renderRelease)} + + + ) : null, + )} + + )} + + ); +} diff --git a/apps/amethyst/app/(tabs)/:o/music/[artist]/listeners.tsx b/apps/amethyst/app/(tabs)/:o/music/[artist]/listeners.tsx new file mode 100644 index 00000000..ede5dac0 --- /dev/null +++ b/apps/amethyst/app/(tabs)/:o/music/[artist]/listeners.tsx @@ -0,0 +1,249 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Image, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { + ArtistLeaderboardList, + ArtistListenerPeriodTabs, + normalizeArtistListenerPeriod, +} from "@/components/teal/ArtistLeaderboard"; +import RightRail from "@/components/teal/RightRail"; +import TealShell, { SectionHeading } from "@/components/teal/TealShell"; +import { Text } from "@/components/ui/text"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { + coverArtUrl, + getArtist, + getArtistImageUrl, + getArtistListeners, + type ArtistListenerPeriod, +} from "@/lib/teal/api"; +import { Mic2, Trophy } from "lucide-react-native"; + +import type { + ArtistListenerView, + ArtistView, +} from "@teal/lexicons/src/types/fm/teal/music/defs"; + +export default function ArtistListenersScreen() { + const params = useLocalSearchParams(); + const artistSlug = Array.isArray(params.artist) + ? params.artist[0] + : params.artist; + const mbid = Array.isArray(params.mbid) ? params.mbid[0] : params.mbid; + const name = Array.isArray(params.name) ? params.name[0] : params.name; + const artistLookup = name || artistSlug; + const periodParam = Array.isArray(params.period) + ? params.period[0] + : params.period; + const [artist, setArtist] = useState(null); + const [listeners, setListeners] = useState([]); + const [period, setPeriod] = useState( + normalizeArtistListenerPeriod(periodParam), + ); + const [cursor, setCursor] = useState(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [artistImage, setArtistImage] = useState(); + const [artFailed, setArtFailed] = useState(false); + const loadingMoreRef = useRef(false); + + useEffect(() => { + let mounted = true; + setError(undefined); + setArtist(null); + getArtist(mbid, artistLookup) + .then(({ artist }) => { + if (mounted) setArtist(artist); + }) + .catch((loadError) => { + if (mounted) { + setError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + } + }); + return () => { + mounted = false; + }; + }, [artistLookup, mbid]); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(undefined); + setListeners([]); + setCursor(undefined); + getArtistListeners(mbid, artistLookup, period, 50) + .then((page) => { + if (!mounted) return; + setListeners(page.listeners); + setCursor(page.cursor); + }) + .catch((loadError) => { + if (mounted) { + setError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + } + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { + mounted = false; + }; + }, [artistLookup, mbid, period]); + + useEffect(() => { + let mounted = true; + setArtistImage(undefined); + setArtFailed(false); + if (!artist?.mbid) return; + getArtistImageUrl(artist.mbid, 500).then((imageUrl) => { + if (mounted) setArtistImage(imageUrl); + }); + return () => { + mounted = false; + }; + }, [artist?.mbid]); + + const loadMore = useCallback(() => { + if (!cursor || loadingMoreRef.current) return; + loadingMoreRef.current = true; + setLoadingMore(true); + getArtistListeners(mbid, artistLookup, period, 50, cursor) + .then((page) => { + setListeners((current) => { + const knownDids = new Set( + current.map((listener) => listener.actor.did).filter(Boolean), + ); + return [ + ...current, + ...page.listeners.filter( + (listener) => + !listener.actor.did || !knownDids.has(listener.actor.did), + ), + ]; + }); + setCursor(page.cursor); + }) + .catch((loadError) => { + setError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + }) + .finally(() => { + loadingMoreRef.current = false; + setLoadingMore(false); + }); + }, [artistLookup, cursor, mbid, period]); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = + event.nativeEvent; + const remaining = + contentSize.height - (contentOffset.y + layoutMeasurement.height); + if (remaining < 800) loadMore(); + }, + [loadMore], + ); + + const representativeArt = coverArtUrl(artist?.albums[0]?.mbid, 500); + const heroArt = artistImage || (artFailed ? undefined : representativeArt); + const title = artist?.name || name || "Artist"; + + return ( + } onScroll={handleScroll}> + + + + {heroArt && ( + setArtFailed(true)} + /> + )} + + + + {heroArt ? ( + setArtFailed(true)} + /> + ) : ( + + )} + + + + Artist leaderboard + + + {title} + + {artist && ( + + {artist.playCount} indexed listens + + )} + + + + + + + + + + {loading ? ( + + + + ) : error ? ( + + + Could not load leaderboard: {error} + + + ) : listeners.length === 0 ? ( + + + + No indexed listeners for this period yet. + + + ) : ( + <> + + {loadingMore && ( + + + + )} + {!cursor && ( + + You reached the end of this artist leaderboard. + + )} + + )} + + ); +} diff --git a/apps/amethyst/app/(tabs)/_layout.tsx b/apps/amethyst/app/(tabs)/_layout.tsx index f35fa7e2..ce8031db 100644 --- a/apps/amethyst/app/(tabs)/_layout.tsx +++ b/apps/amethyst/app/(tabs)/_layout.tsx @@ -1,11 +1,11 @@ import React from "react"; -import { Pressable, type ColorValue } from "react-native"; +import { Platform, Pressable, type ColorValue } 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, + Disc3, Home, LogOut, Search, @@ -16,7 +16,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: ColorValue }) { const Name = props.name; @@ -28,17 +27,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 || Platform.OS === "web"; return ( , }} /> + , + }} + /> + ( ), @@ -100,7 +105,7 @@ export default function TabLayout() { name="settings/index" options={{ title: "Settings", - + href: authStatus === "loggedIn" ? undefined : null, tabBarIcon: ({ color }) => ( ), @@ -115,6 +120,27 @@ export default function TabLayout() { ), }} /> + + + ); } diff --git a/apps/amethyst/app/(tabs)/at:/[...uri].tsx b/apps/amethyst/app/(tabs)/at:/[...uri].tsx new file mode 100644 index 00000000..da7a9cc6 --- /dev/null +++ b/apps/amethyst/app/(tabs)/at:/[...uri].tsx @@ -0,0 +1,50 @@ +import { useEffect, useMemo } from "react"; +import { View } from "react-native"; +import { Redirect, Stack, useLocalSearchParams } from "expo-router"; +import RightRail from "@/components/teal/RightRail"; +import TealShell from "@/components/teal/TealShell"; +import { Text } from "@/components/ui/text"; +import { atUriFromRoutePath, hrefFromAtUri } from "@/lib/teal/routes"; + +function atUriFromParams(param: string | string[] | undefined) { + if (typeof window !== "undefined") { + const atUri = atUriFromRoutePath(window.location.pathname); + if (atUri) return atUri; + } + + const parts = Array.isArray(param) ? param : param ? [param] : []; + return parts.length > 0 ? `at://${parts.join("/")}` : undefined; +} + +export default function AtUriResolver() { + const params = useLocalSearchParams(); + const atUri = useMemo(() => atUriFromParams(params.uri), [params.uri]); + const href = hrefFromAtUri(atUri); + + useEffect(() => { + if (!href && atUri) { + console.warn("Unsupported Teal AT-URI route", atUri); + } + }, [atUri, href]); + + if (href) { + return ; + } + + return ( + }> + + + + AT URI + + + Unsupported Teal link + + + Teal can open profile, listen, and post AT-URIs. + + + + ); +} diff --git a/apps/amethyst/app/(tabs)/index.tsx b/apps/amethyst/app/(tabs)/index.tsx index 3b31c6c9..ae891d59 100644 --- a/apps/amethyst/app/(tabs)/index.tsx +++ b/apps/amethyst/app/(tabs)/index.tsx @@ -1,107 +1,257 @@ -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 { useCallback, useEffect, useRef, useState } from "react"; import { - LEGACY_PROFILE_STATUS_COLLECTION, - readRepoRecordWithLegacyFallback, - STABLE_PROFILE_STATUS_COLLECTION, -} from "@/lib/atp/onboardingRecords"; + ActivityIndicator, + Pressable, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; +import { Stack } from "expo-router"; +import PlayFeedCard from "@/components/teal/PlayFeedCard"; +import RightRail from "@/components/teal/RightRail"; +import SocialComposer from "@/components/teal/SocialComposer"; +import SocialPostCard from "@/components/teal/SocialPostCard"; +import TealShell, { + SectionHeading, +} from "@/components/teal/TealShell"; +import { Text } from "@/components/ui/text"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { + getLatestPlays, + getSocialFeed, + type SocialPostView, +} from "@/lib/teal/api"; import { useStore } from "@/stores/mainStore"; +import { MessageCircle, Music2 } from "lucide-react-native"; -import { Record as ProfileStatusRecord } from "@teal/lexicons/src/types/fm/teal/actor/profileStatus"; - -import AuthOptions from "../auth/options"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/feed/defs"; -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); +type HomeFeed = "posts" | "listens"; - useEffect(() => { - let isMounted = true; +function HomeFeedTabButton({ + active, + icon, + label, + onPress, +}: { + active: boolean; + icon: typeof Music2; + label: string; + onPress: () => void; +}) { + return ( + + + + {label} + + + ); +} - const fetchProfileStatus = async () => { - try { - if (!agent) return; +export default function HomeScreen() { + const [plays, setPlays] = useState(null); + const [socialPosts, setSocialPosts] = useState([]); + const [activeFeed, setActiveFeed] = useState("posts"); + const [playCursor, setPlayCursor] = useState(); + const [postCursor, setPostCursor] = useState(); + const [error, setError] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const loadingMoreRef = useRef(false); + const viewerDid = useStore((state) => state.pdsAgent?.did); - const result = - await readRepoRecordWithLegacyFallback( - agent, - STABLE_PROFILE_STATUS_COLLECTION, - LEGACY_PROFILE_STATUS_COLLECTION, - ); + useEffect(() => { + let mounted = true; + Promise.allSettled([ + getLatestPlays(30), + getSocialFeed(30, undefined, viewerDid), + ]).then(([playResult, postResult]) => { + if (!mounted) return; - if (isMounted) { - setProfileStatus(result?.record ?? null); - } - } 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"); - } - } finally { - if (isMounted) { - setStatusLoading(false); - } + const errors: string[] = []; + if (playResult.status === "fulfilled") { + setPlays(playResult.value.plays); + setPlayCursor(playResult.value.cursor); + } else { + setPlays([]); + errors.push( + `Could not load listens: ${errorMessage(playResult.reason)}`, + ); } - }; - fetchProfileStatus(); + if (postResult.status === "fulfilled") { + setSocialPosts(postResult.value.items); + setPostCursor(postResult.value.cursor); + } else { + errors.push(`Could not load posts: ${errorMessage(postResult.reason)}`); + } + setError(errors.length > 0 ? errors.join(" ") : null); + }); return () => { - isMounted = false; + mounted = false; }; - }, [agent, router]); + }, [viewerDid]); - if (j !== "loggedIn") { - return ; - } + const visiblePlays = plays || []; + const activeItemsCount = + activeFeed === "posts" ? socialPosts.length : visiblePlays.length; + const activeCursor = activeFeed === "posts" ? postCursor : playCursor; - if ( - !statusLoading && - (!profileStatus || profileStatus.completedOnboarding === "none") - ) { - return ( - - - - ); - } + const loadMore = useCallback(() => { + const cursor = activeFeed === "posts" ? postCursor : playCursor; + if (!cursor || loadingMoreRef.current) return; + loadingMoreRef.current = true; + setLoadingMore(true); + const request = + activeFeed === "posts" + ? getSocialFeed(30, cursor, viewerDid) + : getLatestPlays(30, cursor); + request + .then((res) => { + if (activeFeed === "posts") { + const postRes = res as Awaited>; + setSocialPosts((current) => { + const knownUris = new Set(current.map((post) => post.uri)); + return [ + ...current, + ...postRes.items.filter((post) => !knownUris.has(post.uri)), + ]; + }); + setPostCursor(postRes.cursor); + return; + } - // TODO: replace with skeleton - if (!profile || !agent || statusLoading) { - return ( - - - - ); - } + const playRes = res as Awaited>; + setPlays((current) => { + const knownUris = new Set(current?.map((play) => play.uri) || []); + return [ + ...(current || []), + ...playRes.plays.filter( + (play) => !play.uri || !knownUris.has(play.uri), + ), + ]; + }); + setPlayCursor(playRes.cursor); + }) + .catch((e) => { + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + loadingMoreRef.current = false; + setLoadingMore(false); + }); + }, [activeFeed, playCursor, postCursor, viewerDid]); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = + event.nativeEvent; + const remaining = + contentSize.height - (contentOffset.y + layoutMeasurement.height); + if (remaining < 800) loadMore(); + }, + [loadMore], + ); return ( - - } onScroll={handleScroll}> + + - - + + { + setSocialPosts((current) => [post, ...current]); + setActiveFeed("posts"); + }} + /> + + + + setActiveFeed("posts")} + /> + setActiveFeed("listens")} + /> + + + {!plays && ( + + + + )} + {error && ( + + + {error} + + + )} + {plays && activeItemsCount === 0 && !error && ( + + + {activeFeed === "posts" + ? "No posts indexed yet." + : "No listens indexed yet."} + + + Cadet will fill this view as ATProto records arrive. + + + )} + {activeFeed === "posts" && + socialPosts.map((post) => ( + + + + ))} + {activeFeed === "listens" && + visiblePlays.map((play, index) => ( + + ))} + {loadingMore && ( + + + + )} + {plays && activeItemsCount > 0 && !activeCursor && ( + + You reached the beginning of this feed. + + )} + ); } + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/amethyst/app/(tabs)/listen/[did]/[rkey].tsx b/apps/amethyst/app/(tabs)/listen/[did]/[rkey].tsx new file mode 100644 index 00000000..91f8c305 --- /dev/null +++ b/apps/amethyst/app/(tabs)/listen/[did]/[rkey].tsx @@ -0,0 +1,417 @@ +import { useEffect, useState } from "react"; +import { ActivityIndicator, Image, View } from "react-native"; +import { Link, Stack, useLocalSearchParams } from "expo-router"; +import PlayFeedCard, { musicHref } from "@/components/teal/PlayFeedCard"; +import RightRail from "@/components/teal/RightRail"; +import TealShell, { SectionHeading } from "@/components/teal/TealShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Text } from "@/components/ui/text"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { + coverArtUrl, + displayArtists, + getPlayByAuthorRkey, + getRecordingCoverArtUrl, +} from "@/lib/teal/api"; +import { + actorProfileHref, + displayActorName, + getCachedBlueskyProfile, + type DisplayActor, +} from "@/lib/teal/actors"; +import { + applyEditableFields, + artistTextFromRecord, + deletePlayRecord, + loadPlayRecord, + putPlayRecord, + type EditablePlayRecord, +} from "@/lib/teal/playRecords"; +import { timeAgo } from "@/lib/utils"; +import { useStore } from "@/stores/mainStore"; +import { + Disc3, + ExternalLink, + Pencil, + Save, + Trash2, + UserRound, + X, +} from "lucide-react-native"; + +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/feed/defs"; + +export default function ListenDetail() { + const params = useLocalSearchParams(); + const did = Array.isArray(params.did) ? params.did[0] : params.did; + const rkey = Array.isArray(params.rkey) ? params.rkey[0] : params.rkey; + const [play, setPlay] = useState(null); + const [blueskyAuthor, setBlueskyAuthor] = useState(); + const [recordingArt, setRecordingArt] = useState(); + const [artFailed, setArtFailed] = useState(false); + const [error, setError] = useState(null); + const [deleted, setDeleted] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [sourceRecord, setSourceRecord] = useState( + null, + ); + const [swapRecord, setSwapRecord] = useState(); + const [trackName, setTrackName] = useState(""); + const [artistsText, setArtistsText] = useState(""); + const [releaseName, setReleaseName] = useState(""); + const [playedTime, setPlayedTime] = useState(""); + const [editBusy, setEditBusy] = useState(false); + const [deleteArmed, setDeleteArmed] = useState(false); + const agent = useStore((state) => state.pdsAgent); + + useEffect(() => { + let mounted = true; + async function load() { + if (!did || !rkey) return; + try { + setError(null); + setDeleted(false); + const response = await getPlayByAuthorRkey(did, rkey); + if (mounted) setPlay(response.play); + } catch (e) { + if (mounted) setError(e instanceof Error ? e.message : String(e)); + } + } + load(); + return () => { + mounted = false; + }; + }, [did, rkey]); + + const indexedAuthor = play?.author as DisplayActor | undefined; + const authorProfile = indexedAuthor + ? { + ...blueskyAuthor, + ...indexedAuthor, + avatar: indexedAuthor.avatar || blueskyAuthor?.avatar, + displayName: indexedAuthor.displayName || blueskyAuthor?.displayName, + handle: indexedAuthor.handle || blueskyAuthor?.handle, + } + : blueskyAuthor; + const authorDid = authorProfile?.did || play?.authorDid || did; + const authorName = displayActorName(authorProfile, authorDid); + const authorHref = actorProfileHref(authorProfile, authorDid); + const releaseArt = coverArtUrl(play?.releaseMbId, 500); + const art = artFailed ? undefined : releaseArt || recordingArt; + const when = play?.playedTime + ? timeAgo(new Date(play.playedTime)) + : play + ? "recently" + : ""; + const canManage = !!agent?.did && !!did && agent.did === did && !!rkey; + + async function openEditor() { + if (!agent || !did || !rkey || !play) return; + setEditBusy(true); + setError(null); + try { + const loaded = await loadPlayRecord(agent, did, rkey, play); + setSourceRecord(loaded.record); + setSwapRecord(loaded.swapRecord); + setTrackName(loaded.record.trackName); + setArtistsText(artistTextFromRecord(loaded.record)); + setReleaseName(loaded.record.releaseName || ""); + setPlayedTime( + loaded.record.playedTime + ? loaded.record.playedTime.slice(0, 16) + : "", + ); + setDeleteArmed(false); + setEditOpen(true); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setEditBusy(false); + } + } + + async function saveEdit() { + if (!agent || !did || !rkey || !sourceRecord) return; + setEditBusy(true); + setError(null); + try { + const record = applyEditableFields(sourceRecord, { + trackName, + artistsText, + releaseName, + playedTime, + }); + await putPlayRecord(agent, did, rkey, record, swapRecord); + setSourceRecord(record); + setSwapRecord(undefined); + setPlay((current) => + current + ? { + ...current, + trackName: record.trackName, + artists: record.artists || [], + releaseName: record.releaseName, + playedTime: record.playedTime, + } + : current, + ); + setEditOpen(false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setEditBusy(false); + } + } + + async function deleteListen() { + if (!agent || !did || !rkey) return; + if (!deleteArmed) { + setDeleteArmed(true); + return; + } + setEditBusy(true); + setError(null); + try { + await deletePlayRecord(agent, did, rkey); + setPlay(null); + setDeleted(true); + setEditOpen(false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setEditBusy(false); + } + } + + useEffect(() => { + let mounted = true; + const needsBlueskyFallback = + !indexedAuthor?.displayName || !indexedAuthor?.handle; + if (!authorDid || !needsBlueskyFallback) { + setBlueskyAuthor(undefined); + return; + } + getCachedBlueskyProfile(authorDid).then((profile) => { + if (mounted) setBlueskyAuthor(profile); + }); + return () => { + mounted = false; + }; + }, [authorDid, indexedAuthor]); + + useEffect(() => { + let mounted = true; + setArtFailed(false); + if (releaseArt || !play?.recordingMbId) { + setRecordingArt(undefined); + return; + } + getRecordingCoverArtUrl(play.recordingMbId, 500).then((url) => { + if (mounted) setRecordingArt(url); + }); + return () => { + mounted = false; + }; + }, [play?.recordingMbId, releaseArt]); + + return ( + }> + + {!play && !error && !deleted && ( + + + + )} + {deleted && ( + + + Listen deleted. + + + Cadet will remove it from public feeds after the PDS update is + indexed. + + + )} + {error && ( + + + Could not load listen: {error} + + + )} + {play && ( + <> + + + {art ? ( + setArtFailed(true)} + /> + ) : null} + + + + {art ? ( + setArtFailed(true)} + /> + ) : ( + + + + )} + + + Listen + + + {play.trackName} + + + {displayArtists(play) || "Unknown artist"} + + + + + + + + + + + + {canManage && ( + + )} + + + + {authorName} listened {when} + {play.releaseName ? ` from ${play.releaseName}` : ""}. + + + {editOpen && canManage && ( + + + + + Edit record + + + Changes are written to your PDS and reindexed by Cadet. + + + + + + + Track + + + + + + Artists + + + + + + Release + + + + + + Played time + + + + + + + + + )} + + + + + + + )} + + ); +} diff --git a/apps/amethyst/app/(tabs)/manual-listens.tsx b/apps/amethyst/app/(tabs)/manual-listens.tsx new file mode 100644 index 00000000..6cefbf82 --- /dev/null +++ b/apps/amethyst/app/(tabs)/manual-listens.tsx @@ -0,0 +1,460 @@ +import { useMemo, useState } from "react"; +import { + ActivityIndicator, + Image, + Pressable, + View, +} from "react-native"; +import { Link, Redirect, Stack } from "expo-router"; +import RightRail from "@/components/teal/RightRail"; +import TealShell, { SectionHeading } from "@/components/teal/TealShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Text } from "@/components/ui/text"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { coverArtUrl } from "@/lib/teal/api"; +import { useStore } from "@/stores/mainStore"; +import { + AlertCircle, + CalendarClock, + Check, + ChevronRight, + Disc3, + LoaderCircle, + Search, +} from "lucide-react-native"; + +import { + buildListenTimeline, + buildManualListenRecords, + effectiveDurationSeconds, + formatDuration, + getMusicBrainzRelease, + type ListenTimestampMode, + type MusicBrainzAlbumRelease, + type MusicBrainzAlbumTrack, + searchMusicBrainzReleases, + submitManualListenRecords, +} from "@/lib/manualListens"; + +function localDateTimeValue(date = new Date()) { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function parseLocalDateTime(value: string) { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; +} + +function releaseArtist(release: MusicBrainzAlbumRelease) { + return release.artistCredit?.map((credit) => credit.name || credit.artist.name).join(", ") || "Unknown artist"; +} + +function releaseDetails(release: MusicBrainzAlbumRelease) { + return [release.date?.slice(0, 4), release.country, release.status] + .filter(Boolean) + .join(" · "); +} + +function flattenTracks(release: MusicBrainzAlbumRelease) { + return (release.media || []).flatMap((medium) => medium.tracks || []); +} + +function TrackRow({ + track, + selected, + onPress, +}: { + track: MusicBrainzAlbumTrack; + selected: boolean; + onPress: () => void; +}) { + const duration = effectiveDurationSeconds(track); + const actualDuration = track.length ?? track.recording.length; + return ( + + + {selected && } + + + {track.number || track.position || "—"} + + + + {track.recording.title || track.title || "Untitled track"} + + + {track.recording["artist-credit"]?.map((credit) => credit.name || credit.artist.name).join(", ") || "Album artist"} + + + + {formatDuration(duration)} + {!actualDuration && "*"} + + + ); +} + +export default function ManualListensPage() { + const status = useStore((state) => state.status); + const agent = useStore((state) => state.pdsAgent); + const [albumQuery, setAlbumQuery] = useState(""); + const [artistQuery, setArtistQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [hasSearched, setHasSearched] = useState(false); + const [selectedRelease, setSelectedRelease] = useState(); + const [tracks, setTracks] = useState([]); + const [selectedTrackKeys, setSelectedTrackKeys] = useState>(new Set()); + const [timestampMode, setTimestampMode] = useState("now"); + const [customStart, setCustomStart] = useState(localDateTimeValue()); + const [loading, setLoading] = useState(false); + const [loadingRelease, setLoadingRelease] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [submittedCount, setSubmittedCount] = useState(); + const [error, setError] = useState(); + + const selectedTracks = useMemo( + () => tracks.filter((track) => selectedTrackKeys.has(track.key)), + [selectedTrackKeys, tracks], + ); + const customDate = parseLocalDateTime(customStart); + const preview = useMemo(() => { + if (selectedTracks.length === 0) return []; + try { + return buildListenTimeline( + selectedTracks, + timestampMode, + customDate, + new Date(), + ); + } catch { + return []; + } + }, [customDate, selectedTracks, timestampMode]); + + if (status === "start") { + return ( + + + + ); + } + if (status !== "loggedIn" || !agent) { + return ; + } + + async function search() { + if (!albumQuery.trim()) { + setError("Enter an album name to search."); + return; + } + setLoading(true); + setError(undefined); + setHasSearched(true); + setSelectedRelease(undefined); + setTracks([]); + setSelectedTrackKeys(new Set()); + setSubmittedCount(undefined); + try { + setSearchResults(await searchMusicBrainzReleases(albumQuery, artistQuery)); + } catch (searchError) { + setSearchResults([]); + setError(searchError instanceof Error ? searchError.message : "Album search failed."); + } finally { + setLoading(false); + } + } + + async function chooseRelease(release: MusicBrainzAlbumRelease) { + setLoadingRelease(true); + setError(undefined); + try { + const details = await getMusicBrainzRelease(release.id); + const nextTracks = flattenTracks(details); + if (nextTracks.length === 0) { + throw new Error("MusicBrainz did not return any tracks for this release."); + } + setSelectedRelease(details); + setTracks(nextTracks); + setSelectedTrackKeys(new Set(nextTracks.map((track) => track.key))); + setSubmittedCount(undefined); + } catch (releaseError) { + setError(releaseError instanceof Error ? releaseError.message : "Could not load that release."); + } finally { + setLoadingRelease(false); + } + } + + function toggleTrack(key: string) { + setSelectedTrackKeys((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + setSubmittedCount(undefined); + } + + function selectAllTracks() { + setSelectedTrackKeys(new Set(tracks.map((track) => track.key))); + setSubmittedCount(undefined); + } + + function clearTracks() { + setSelectedTrackKeys(new Set()); + setSubmittedCount(undefined); + } + + async function submit() { + if (!agent || !selectedRelease || selectedTracks.length === 0) { + setError("Select at least one track."); + return; + } + if (timestampMode === "custom" && !customDate) { + setError("Enter a valid starting date and time."); + return; + } + + setSubmitting(true); + setError(undefined); + try { + const records = buildManualListenRecords( + selectedRelease, + selectedTracks, + timestampMode, + customDate, + new Date(), + ); + setSubmittedCount(await submitManualListenRecords(agent, records)); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "Could not save these listens."); + } finally { + setSubmitting(false); + } + } + + return ( + }> + + + + + + + + + + + Put the needle down. We’ll place the tracks in time. + + + + + + + + + + + + {error && ( + + + {error} + + )} + + {searchResults.length > 0 && !selectedRelease && ( + + + + Choose a pressing + MusicBrainz has several releases for many albums. + + {searchResults.length} results + + + {searchResults.map((release) => ( + chooseRelease(release)} + className="flex-row items-center gap-3 border-b border-border p-3 last:border-b-0 web:hover:bg-accent/30" + > + + + {release.title} + {releaseArtist(release)} + {releaseDetails(release) || "Release details unavailable"} + + + + ))} + + + )} + + {hasSearched && !loading && searchResults.length === 0 && !error && ( + + + No releases found + + Try a shorter album title, or add the artist name to narrow the search. + + + )} + + {loadingRelease && ( + + + Loading tracklist… + + )} + + {selectedRelease && tracks.length > 0 && ( + + + + + + Selected release + {selectedRelease.title} + {releaseArtist(selectedRelease)} + {releaseDetails(selectedRelease)} · {tracks.length} tracks + + + + { + setSelectedRelease(undefined); + setTracks([]); + setSelectedTrackKeys(new Set()); + setSubmittedCount(undefined); + }} + > + Choose a different release + + + + {selectedTracks.length} of {tracks.length} selected + + Select all + Clear + + + + {tracks.map((track) => ( + toggleTrack(track.key)} + /> + ))} + + * no duration in MusicBrainz; timing uses 3:00 + + + + + + + When did you listen? + Choose how to place the selected tracks in your listening history. + + + + {(["now", "custom"] as ListenTimestampMode[]).map((mode) => ( + setTimestampMode(mode)} + className={`flex-1 rounded-md border px-3 py-3 ${timestampMode === mode ? "border-primary bg-accent" : "border-border bg-background"}`} + > + {mode === "now" ? "Use current time" : "Choose a time"} + {mode === "now" ? "Newest selected track starts now" : "First selected track starts at this time"} + + ))} + + {timestampMode === "custom" && ( + + First track start time + Use your local date and time. + + + )} + {preview.length > 0 && ( + + Preview + + {preview.map(({ track, playedTime }) => ( + + {track.number || track.position || "—"} + {track.recording.title} + {new Date(playedTime).toLocaleString()} + + ))} + + + )} + + + + )} + + {submittedCount !== undefined && ( + + Listening history updated. + Added {submittedCount} listen{submittedCount === 1 ? "" : "s"} as one atomic repository update. + + + + + + + + )} + + ); +} diff --git a/apps/amethyst/app/(tabs)/notifications.tsx b/apps/amethyst/app/(tabs)/notifications.tsx new file mode 100644 index 00000000..b262662d --- /dev/null +++ b/apps/amethyst/app/(tabs)/notifications.tsx @@ -0,0 +1,443 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Image, + Pressable, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; +import { Link, Stack } from "expo-router"; +import RightRail from "@/components/teal/RightRail"; +import TealShell, { SectionHeading } from "@/components/teal/TealShell"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { + coverArtUrl, + displayArtists, + getNotifications, + getSocialPost, + type SocialNotificationView, + type SocialPostView, +} from "@/lib/teal/api"; +import { + actorAvatarUrl, + actorProfileHref, + displayActorName, +} from "@/lib/teal/actors"; +import { postHrefFromUri } from "@/lib/teal/routes"; +import { trackViewToPlayView } from "@/lib/teal/social"; +import { useStore } from "@/stores/mainStore"; +import { + Bell, + ChevronRight, + Disc3, + Heart, + MessageCircle, + Repeat2, +} from "lucide-react-native"; + +function reasonLabel(reason: string) { + switch (reason) { + case "like": + return "liked your post"; + case "repost": + return "reposted your post"; + case "reply": + return "replied to your post"; + case "follow": + return "followed you"; + case "badgeAssignment": + return "gave you a badge"; + default: + return reason; + } +} + +function reasonIcon(reason: string) { + switch (reason) { + case "like": + return Heart; + case "repost": + return Repeat2; + case "reply": + return MessageCircle; + default: + return Bell; + } +} + +type NotificationGroup = { + key: string; + reason: string; + notifications: SocialNotificationView[]; + contextUri?: string; +}; + +const contextualReasons = new Set(["like", "repost", "reply"]); + +function groupNotifications(items: SocialNotificationView[]) { + const groups: NotificationGroup[] = []; + const grouped = new Map(); + + for (const notification of items) { + const contextUri = + notification.reason === "reply" + ? notification.recordUri + : notification.subjectUri; + const canGroup = contextualReasons.has(notification.reason) && contextUri; + const key = canGroup + ? `${notification.reason}:${contextUri}` + : `notification:${notification.id}`; + const existing = grouped.get(key); + + if (existing) { + existing.notifications.push(notification); + continue; + } + + const group = { + key, + reason: notification.reason, + notifications: [notification], + contextUri, + }; + grouped.set(key, group); + groups.push(group); + } + + return groups; +} + +function compactTimeAgo(value: string) { + const seconds = Math.max( + 0, + Math.floor((Date.now() - new Date(value).getTime()) / 1000), + ); + if (seconds < 60) return "now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d`; + return `${Math.floor(seconds / 604800)}w`; +} + +function reasonIconClass(reason: string) { + switch (reason) { + case "like": + return "text-rose-500"; + case "repost": + return "text-primary"; + case "reply": + return "text-bsky"; + default: + return "text-muted-foreground"; + } +} + +function ActorAvatar({ notification }: { notification: SocialNotificationView }) { + const actor = notification.actor; + const actorDid = actor?.did || notification.actorDid; + const label = displayActorName(actor, actorDid); + const avatar = actorAvatarUrl(actor, actorDid); + const href = actorProfileHref(actor, actorDid); + + return ( + + + {avatar ? ( + + ) : ( + + {label.slice(0, 1).toUpperCase()} + + )} + + + ); +} + +function PostPreview({ post }: { post: SocialPostView }) { + const play = trackViewToPlayView(post.track); + const art = coverArtUrl(play.releaseMbId); + + return ( + + {post.text ? ( + + {post.text} + + ) : ( + + Shared a track + + )} + + + {art ? ( + + ) : ( + + )} + + + + {play.trackName} + + + {displayArtists(play) || "Unknown artist"} + + + + + ); +} + +function NotificationRow({ group }: { group: NotificationGroup }) { + const notifications = group.notifications; + const first = notifications[0]; + const IconForReason = reasonIcon(group.reason); + const postHref = group.contextUri + ? postHrefFromUri(group.contextUri) + : undefined; + const [postState, setPostState] = useState<{ + uri: string; + post: SocialPostView | null; + }>(); + const post = + group.contextUri && contextualReasons.has(group.reason) + ? postState?.uri === group.contextUri + ? postState.post + : undefined + : null; + + useEffect(() => { + if (!group.contextUri || !contextualReasons.has(group.reason)) return; + + let mounted = true; + getSocialPost(group.contextUri) + .then((response) => { + if (mounted) { + setPostState({ uri: group.contextUri!, post: response.post }); + } + }) + .catch(() => { + if (mounted) { + setPostState({ uri: group.contextUri!, post: null }); + } + }); + return () => { + mounted = false; + }; + }, [group.contextUri, group.reason]); + + const actorLabel = displayActorName(first.actor, first.actorDid); + const otherCount = notifications.length - 1; + + return ( + + + + + + + + + {notifications.slice(0, 3).map((notification, index) => ( + 0 ? "-ml-2" : undefined} + style={{ zIndex: 3 - index }} + > + + + ))} + + {postHref && ( + + )} + + + {actorLabel} + {otherCount > 0 + ? ` and ${otherCount} ${otherCount === 1 ? "other" : "others"}` + : ""}{" "} + {reasonLabel(group.reason)} + + {` · ${compactTimeAgo(first.createdAt)}`} + + + + {postHref && post === undefined && ( + + + + + )} + {postHref && post && ( + + + + + + )} + {postHref && post === null && ( + + The original post is no longer available. + + )} + + + + ); +} + +export default function Notifications() { + const status = useStore((state) => state.status); + const pdsAgent = useStore((state) => state.pdsAgent); + const actorDid = pdsAgent?.did; + const [items, setItems] = useState(null); + const [cursor, setCursor] = useState(); + const [error, setError] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const loadingMoreRef = useRef(false); + + useEffect(() => { + let mounted = true; + if (status !== "loggedIn" || !actorDid) { + return; + } + setItems(null); + setError(null); + getNotifications(actorDid, 30) + .then((res) => { + if (!mounted) return; + setItems(res.items); + setCursor(res.cursor); + }) + .catch((e) => { + if (!mounted) return; + setError(e instanceof Error ? e.message : String(e)); + setItems([]); + }); + return () => { + mounted = false; + }; + }, [actorDid, status]); + + const loadMore = useCallback(() => { + if (!actorDid || !cursor || loadingMoreRef.current) return; + loadingMoreRef.current = true; + setLoadingMore(true); + getNotifications(actorDid, 30, cursor) + .then((res) => { + setItems((current) => { + const knownIds = new Set(current?.map((item) => item.id) || []); + return [ + ...(current || []), + ...res.items.filter((item) => !knownIds.has(item.id)), + ]; + }); + setCursor(res.cursor); + }) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + .finally(() => { + loadingMoreRef.current = false; + setLoadingMore(false); + }); + }, [actorDid, cursor]); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = + event.nativeEvent; + const remaining = + contentSize.height - (contentOffset.y + layoutMeasurement.height); + if (remaining < 600) loadMore(); + }, + [loadMore], + ); + const groups = items ? groupNotifications(items) : []; + + if (status !== "loggedIn") { + return ( + }> + + + + + + + Sign in to view notifications. + + + Likes, reposts, replies, and playlist collaboration + activity will appear here. + + + + + + + ); + } + + return ( + } onScroll={handleScroll}> + + + {!items && ( + + + + )} + {error && ( + + + Could not load notifications: {error} + + + )} + {items?.length === 0 && !error && ( + + + No notifications yet. + + + Social activity indexed by Aqua will appear here. + + + )} + + {groups.map((group) => ( + + ))} + + {loadingMore && ( + + + + )} + + ); +} diff --git a/apps/amethyst/app/(tabs)/playlist/[name].tsx b/apps/amethyst/app/(tabs)/playlist/[name].tsx new file mode 100644 index 00000000..dd2325ed --- /dev/null +++ b/apps/amethyst/app/(tabs)/playlist/[name].tsx @@ -0,0 +1,340 @@ +import { useEffect, useState } from "react"; +import { ActivityIndicator, Image, Pressable, View } from "react-native"; +import * as ImagePicker from "expo-image-picker"; +import { Link, Stack, useLocalSearchParams } from "expo-router"; +import { musicHref } from "@/components/teal/PlayFeedCard"; +import { + AddCurrentTrackButton, + PlaylistTrackPicker, +} from "@/components/teal/PlaylistControls"; +import RichText from "@/components/teal/RichText"; +import RightRail from "@/components/teal/RightRail"; +import TealShell, { SectionHeading } from "@/components/teal/TealShell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Text } from "@/components/ui/text"; +import { Textarea } from "@/components/ui/textarea"; +import getImageCdnLink from "@/lib/atp/getImageCdnLink"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { + displayArtists, + getPlaylist, + getProfile, + type SocialPlaylistItemView, + type SocialPlaylistView, +} from "@/lib/teal/api"; +import { trackViewToPlayView } from "@/lib/teal/social"; +import { useStore } from "@/stores/mainStore"; +import { RichText as AtprotoRichText } from "@atproto/api"; +import { ListMusic, Music2 } from "lucide-react-native"; + +function rkeyFromUri(uri: string) { + return uri.split("/").pop() || ""; +} + +export default function PlaylistDetailScreen() { + const params = useLocalSearchParams(); + const uri = Array.isArray(params.uri) ? params.uri[0] : params.uri; + const [playlist, setPlaylist] = useState(null); + const [items, setItems] = useState([]); + const [currentTrack, setCurrentTrack] = useState(null); + const [editing, setEditing] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [authorsText, setAuthorsText] = useState(""); + const [coverUri, setCoverUri] = useState(); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [addingItem, setAddingItem] = useState(false); + const pdsAgent = useStore((state) => state.pdsAgent); + + useEffect(() => { + let mounted = true; + if (!uri) return; + getPlaylist(uri) + .then((res) => { + if (!mounted) return; + setPlaylist(res.playlist); + setName(res.playlist.name); + setDescription(res.playlist.description || ""); + setAuthorsText(res.playlist.authors.join("\n")); + setCoverUri( + res.playlist.coverCid + ? getImageCdnLink({ + did: res.playlist.authorDid, + hash: res.playlist.coverCid, + }) + : undefined, + ); + setItems(res.items); + }) + .catch((e) => { + if (mounted) setError(e instanceof Error ? e.message : String(e)); + }); + return () => { + mounted = false; + }; + }, [uri]); + + useEffect(() => { + let mounted = true; + setCurrentTrack(null); + if (!pdsAgent?.did) { + return; + } + getProfile(pdsAgent.did) + .then((res) => { + if (mounted) setCurrentTrack(res.profile.status?.item || null); + }) + .catch(() => { + if (mounted) setCurrentTrack(null); + }); + return () => { + mounted = false; + }; + }, [pdsAgent?.did]); + + const canAddItems = Boolean( + pdsAgent?.did && playlist?.authors.includes(pdsAgent.did), + ); + const canManage = Boolean( + pdsAgent?.did && playlist?.authorDid === pdsAgent.did, + ); + + function appendItem(item: SocialPlaylistItemView) { + setItems((current) => [...current, item]); + setPlaylist((current) => + current ? { ...current, itemCount: current.itemCount + 1 } : current, + ); + } + + async function saveMetadata() { + if (!playlist || !pdsAgent?.did || saving) return; + setSaving(true); + try { + const authors = Array.from( + new Set( + authorsText + .split(/[\n,]/) + .map((author) => author.trim()) + .filter(Boolean), + ), + ); + if (!authors.includes(playlist.authorDid)) + authors.unshift(playlist.authorDid); + const current: { cover?: unknown } = await pdsAgent + .call("com.atproto.repo.getRecord", { + repo: playlist.authorDid, + collection: "fm.teal.feed.social.playlist", + rkey: rkeyFromUri(playlist.uri), + }) + .then((res) => res.data.value as { cover?: unknown }) + .catch(() => ({})); + let cover = current.cover; + if (coverUri && !coverUri.startsWith("http")) { + const data = await fetch(coverUri).then((response) => response.blob()); + const fileType = data.type || "image/jpeg"; + cover = (await pdsAgent.uploadBlob(data, { encoding: fileType })).data + .blob; + } + let descriptionFacets: unknown[] | undefined; + if (description.trim()) { + const rt = new AtprotoRichText({ text: description.trim() }); + await rt.detectFacets(pdsAgent); + descriptionFacets = rt.facets; + } + await pdsAgent.call( + "com.atproto.repo.putRecord", + {}, + { + repo: playlist.authorDid, + collection: "fm.teal.feed.social.playlist", + rkey: rkeyFromUri(playlist.uri), + record: { + $type: "fm.teal.feed.social.playlist", + name: name.trim(), + description: description.trim() || undefined, + descriptionFacets, + authors, + cover, + createdAt: playlist.createdAt, + }, + }, + ); + setPlaylist({ + ...playlist, + name: name.trim(), + description: description.trim() || undefined, + descriptionFacets, + authors, + }); + setEditing(false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + } + + async function pickCover() { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: true, + aspect: [1, 1], + quality: 0.9, + }); + if (!result.canceled) setCoverUri(result.assets[0].uri); + } + + return ( + }> + + {error && ( + + {error} + + )} + {!playlist && !error && ( + + + + )} + {playlist && ( + <> + + + + {coverUri ? ( + + ) : ( + + + + )} + + + {editing ? ( + + +