From 62b4d1701816faab935cd84aab4d728c6bd122b1 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 17:43:13 +0100 Subject: [PATCH 01/13] refactor(src): move the root modules into their domain directories Renames only, so history follows each file: auth/, library/, remote/, http/ and storage/ receive the flat modules, and media/ receives replay_gain. A module that carries unit tests moves to foo/mod.rs, ready for its tests to leave it. The tree compiles again with the next commit, which declares the new parents and rewrites the paths. Signed-off-by: InstaZDLL --- src/{ => auth}/authentication.rs | 0 src/{oauth.rs => auth/oauth/mod.rs} | 0 src/{security.rs => auth/security/mod.rs} | 0 src/{setup_token.rs => auth/setup_token/mod.rs} | 0 src/{sign_in.rs => auth/sign_in/mod.rs} | 0 src/{stream_ticket.rs => auth/stream_ticket/mod.rs} | 0 src/{public_share.rs => http/public_share/mod.rs} | 0 src/{reference.rs => http/reference/mod.rs} | 0 src/{security_headers.rs => http/security_headers/mod.rs} | 0 src/{ => http}/webui.rs | 0 src/{credit_split.rs => library/credit_split/mod.rs} | 0 src/{link_roots.rs => library/link_roots/mod.rs} | 0 src/{lyrics.rs => library/lyrics/mod.rs} | 0 src/{pid.rs => library/pid/mod.rs} | 0 src/{playlist_file.rs => library/playlist_file/mod.rs} | 0 src/{ => library}/scanner/discovery/mod.rs | 0 src/{ => library}/scanner/discovery/tests.rs | 0 src/{ => library}/scanner/extract/mod.rs | 0 src/{ => library}/scanner/extract/tests.rs | 0 src/{ => library}/scanner/job.rs | 0 src/{ => library}/scanner/mod.rs | 0 src/{tags.rs => library/tags/mod.rs} | 0 src/{ => library}/watcher/mod.rs | 0 src/{ => library}/watcher/tests.rs | 0 src/{replay_gain.rs => media/replay_gain/mod.rs} | 0 src/{enrichment.rs => remote/enrichment/mod.rs} | 0 src/{lrclib.rs => remote/lrclib/mod.rs} | 0 src/{outbound.rs => remote/outbound/mod.rs} | 0 src/{podcast.rs => remote/podcast/mod.rs} | 0 src/{portrait.rs => remote/portrait/mod.rs} | 0 src/{ => remote}/scrobblers/lastfm.rs | 0 src/{ => remote}/scrobblers/listenbrainz.rs | 0 src/{ => remote}/scrobblers/maloja.rs | 0 src/{ => remote}/scrobblers/mod.rs | 0 src/{ => storage}/catalog/apply.rs | 0 src/{ => storage}/catalog/availability.rs | 0 src/{ => storage}/catalog/credits.rs | 0 src/{ => storage}/catalog/derivations.rs | 0 src/{ => storage}/catalog/identity.rs | 0 src/{ => storage}/catalog/libraries.rs | 0 src/{ => storage}/catalog/mod.rs | 0 src/{ => storage}/catalog/overrides.rs | 0 src/{ => storage}/catalog/scan_jobs.rs | 0 src/{ => storage}/catalog/tests.rs | 0 src/{ => storage}/catalog/tracks.rs | 0 src/{ => storage}/database/accounts.rs | 0 src/{ => storage}/database/keys.rs | 0 src/{ => storage}/database/libraries.rs | 0 src/{ => storage}/database/metadata.rs | 0 src/{ => storage}/database/mod.rs | 0 src/{ => storage}/database/sessions.rs | 0 src/{ => storage}/database/subsonic_credentials.rs | 0 src/{ => storage}/database/sync_operations.rs | 0 src/{ => storage}/database/tests.rs | 0 src/{ => storage}/migration_backup.rs | 0 src/{natural_order.rs => storage/natural_order/mod.rs} | 0 src/{sync.rs => storage/sync/mod.rs} | 0 57 files changed, 0 insertions(+), 0 deletions(-) rename src/{ => auth}/authentication.rs (100%) rename src/{oauth.rs => auth/oauth/mod.rs} (100%) rename src/{security.rs => auth/security/mod.rs} (100%) rename src/{setup_token.rs => auth/setup_token/mod.rs} (100%) rename src/{sign_in.rs => auth/sign_in/mod.rs} (100%) rename src/{stream_ticket.rs => auth/stream_ticket/mod.rs} (100%) rename src/{public_share.rs => http/public_share/mod.rs} (100%) rename src/{reference.rs => http/reference/mod.rs} (100%) rename src/{security_headers.rs => http/security_headers/mod.rs} (100%) rename src/{ => http}/webui.rs (100%) rename src/{credit_split.rs => library/credit_split/mod.rs} (100%) rename src/{link_roots.rs => library/link_roots/mod.rs} (100%) rename src/{lyrics.rs => library/lyrics/mod.rs} (100%) rename src/{pid.rs => library/pid/mod.rs} (100%) rename src/{playlist_file.rs => library/playlist_file/mod.rs} (100%) rename src/{ => library}/scanner/discovery/mod.rs (100%) rename src/{ => library}/scanner/discovery/tests.rs (100%) rename src/{ => library}/scanner/extract/mod.rs (100%) rename src/{ => library}/scanner/extract/tests.rs (100%) rename src/{ => library}/scanner/job.rs (100%) rename src/{ => library}/scanner/mod.rs (100%) rename src/{tags.rs => library/tags/mod.rs} (100%) rename src/{ => library}/watcher/mod.rs (100%) rename src/{ => library}/watcher/tests.rs (100%) rename src/{replay_gain.rs => media/replay_gain/mod.rs} (100%) rename src/{enrichment.rs => remote/enrichment/mod.rs} (100%) rename src/{lrclib.rs => remote/lrclib/mod.rs} (100%) rename src/{outbound.rs => remote/outbound/mod.rs} (100%) rename src/{podcast.rs => remote/podcast/mod.rs} (100%) rename src/{portrait.rs => remote/portrait/mod.rs} (100%) rename src/{ => remote}/scrobblers/lastfm.rs (100%) rename src/{ => remote}/scrobblers/listenbrainz.rs (100%) rename src/{ => remote}/scrobblers/maloja.rs (100%) rename src/{ => remote}/scrobblers/mod.rs (100%) rename src/{ => storage}/catalog/apply.rs (100%) rename src/{ => storage}/catalog/availability.rs (100%) rename src/{ => storage}/catalog/credits.rs (100%) rename src/{ => storage}/catalog/derivations.rs (100%) rename src/{ => storage}/catalog/identity.rs (100%) rename src/{ => storage}/catalog/libraries.rs (100%) rename src/{ => storage}/catalog/mod.rs (100%) rename src/{ => storage}/catalog/overrides.rs (100%) rename src/{ => storage}/catalog/scan_jobs.rs (100%) rename src/{ => storage}/catalog/tests.rs (100%) rename src/{ => storage}/catalog/tracks.rs (100%) rename src/{ => storage}/database/accounts.rs (100%) rename src/{ => storage}/database/keys.rs (100%) rename src/{ => storage}/database/libraries.rs (100%) rename src/{ => storage}/database/metadata.rs (100%) rename src/{ => storage}/database/mod.rs (100%) rename src/{ => storage}/database/sessions.rs (100%) rename src/{ => storage}/database/subsonic_credentials.rs (100%) rename src/{ => storage}/database/sync_operations.rs (100%) rename src/{ => storage}/database/tests.rs (100%) rename src/{ => storage}/migration_backup.rs (100%) rename src/{natural_order.rs => storage/natural_order/mod.rs} (100%) rename src/{sync.rs => storage/sync/mod.rs} (100%) diff --git a/src/authentication.rs b/src/auth/authentication.rs similarity index 100% rename from src/authentication.rs rename to src/auth/authentication.rs diff --git a/src/oauth.rs b/src/auth/oauth/mod.rs similarity index 100% rename from src/oauth.rs rename to src/auth/oauth/mod.rs diff --git a/src/security.rs b/src/auth/security/mod.rs similarity index 100% rename from src/security.rs rename to src/auth/security/mod.rs diff --git a/src/setup_token.rs b/src/auth/setup_token/mod.rs similarity index 100% rename from src/setup_token.rs rename to src/auth/setup_token/mod.rs diff --git a/src/sign_in.rs b/src/auth/sign_in/mod.rs similarity index 100% rename from src/sign_in.rs rename to src/auth/sign_in/mod.rs diff --git a/src/stream_ticket.rs b/src/auth/stream_ticket/mod.rs similarity index 100% rename from src/stream_ticket.rs rename to src/auth/stream_ticket/mod.rs diff --git a/src/public_share.rs b/src/http/public_share/mod.rs similarity index 100% rename from src/public_share.rs rename to src/http/public_share/mod.rs diff --git a/src/reference.rs b/src/http/reference/mod.rs similarity index 100% rename from src/reference.rs rename to src/http/reference/mod.rs diff --git a/src/security_headers.rs b/src/http/security_headers/mod.rs similarity index 100% rename from src/security_headers.rs rename to src/http/security_headers/mod.rs diff --git a/src/webui.rs b/src/http/webui.rs similarity index 100% rename from src/webui.rs rename to src/http/webui.rs diff --git a/src/credit_split.rs b/src/library/credit_split/mod.rs similarity index 100% rename from src/credit_split.rs rename to src/library/credit_split/mod.rs diff --git a/src/link_roots.rs b/src/library/link_roots/mod.rs similarity index 100% rename from src/link_roots.rs rename to src/library/link_roots/mod.rs diff --git a/src/lyrics.rs b/src/library/lyrics/mod.rs similarity index 100% rename from src/lyrics.rs rename to src/library/lyrics/mod.rs diff --git a/src/pid.rs b/src/library/pid/mod.rs similarity index 100% rename from src/pid.rs rename to src/library/pid/mod.rs diff --git a/src/playlist_file.rs b/src/library/playlist_file/mod.rs similarity index 100% rename from src/playlist_file.rs rename to src/library/playlist_file/mod.rs diff --git a/src/scanner/discovery/mod.rs b/src/library/scanner/discovery/mod.rs similarity index 100% rename from src/scanner/discovery/mod.rs rename to src/library/scanner/discovery/mod.rs diff --git a/src/scanner/discovery/tests.rs b/src/library/scanner/discovery/tests.rs similarity index 100% rename from src/scanner/discovery/tests.rs rename to src/library/scanner/discovery/tests.rs diff --git a/src/scanner/extract/mod.rs b/src/library/scanner/extract/mod.rs similarity index 100% rename from src/scanner/extract/mod.rs rename to src/library/scanner/extract/mod.rs diff --git a/src/scanner/extract/tests.rs b/src/library/scanner/extract/tests.rs similarity index 100% rename from src/scanner/extract/tests.rs rename to src/library/scanner/extract/tests.rs diff --git a/src/scanner/job.rs b/src/library/scanner/job.rs similarity index 100% rename from src/scanner/job.rs rename to src/library/scanner/job.rs diff --git a/src/scanner/mod.rs b/src/library/scanner/mod.rs similarity index 100% rename from src/scanner/mod.rs rename to src/library/scanner/mod.rs diff --git a/src/tags.rs b/src/library/tags/mod.rs similarity index 100% rename from src/tags.rs rename to src/library/tags/mod.rs diff --git a/src/watcher/mod.rs b/src/library/watcher/mod.rs similarity index 100% rename from src/watcher/mod.rs rename to src/library/watcher/mod.rs diff --git a/src/watcher/tests.rs b/src/library/watcher/tests.rs similarity index 100% rename from src/watcher/tests.rs rename to src/library/watcher/tests.rs diff --git a/src/replay_gain.rs b/src/media/replay_gain/mod.rs similarity index 100% rename from src/replay_gain.rs rename to src/media/replay_gain/mod.rs diff --git a/src/enrichment.rs b/src/remote/enrichment/mod.rs similarity index 100% rename from src/enrichment.rs rename to src/remote/enrichment/mod.rs diff --git a/src/lrclib.rs b/src/remote/lrclib/mod.rs similarity index 100% rename from src/lrclib.rs rename to src/remote/lrclib/mod.rs diff --git a/src/outbound.rs b/src/remote/outbound/mod.rs similarity index 100% rename from src/outbound.rs rename to src/remote/outbound/mod.rs diff --git a/src/podcast.rs b/src/remote/podcast/mod.rs similarity index 100% rename from src/podcast.rs rename to src/remote/podcast/mod.rs diff --git a/src/portrait.rs b/src/remote/portrait/mod.rs similarity index 100% rename from src/portrait.rs rename to src/remote/portrait/mod.rs diff --git a/src/scrobblers/lastfm.rs b/src/remote/scrobblers/lastfm.rs similarity index 100% rename from src/scrobblers/lastfm.rs rename to src/remote/scrobblers/lastfm.rs diff --git a/src/scrobblers/listenbrainz.rs b/src/remote/scrobblers/listenbrainz.rs similarity index 100% rename from src/scrobblers/listenbrainz.rs rename to src/remote/scrobblers/listenbrainz.rs diff --git a/src/scrobblers/maloja.rs b/src/remote/scrobblers/maloja.rs similarity index 100% rename from src/scrobblers/maloja.rs rename to src/remote/scrobblers/maloja.rs diff --git a/src/scrobblers/mod.rs b/src/remote/scrobblers/mod.rs similarity index 100% rename from src/scrobblers/mod.rs rename to src/remote/scrobblers/mod.rs diff --git a/src/catalog/apply.rs b/src/storage/catalog/apply.rs similarity index 100% rename from src/catalog/apply.rs rename to src/storage/catalog/apply.rs diff --git a/src/catalog/availability.rs b/src/storage/catalog/availability.rs similarity index 100% rename from src/catalog/availability.rs rename to src/storage/catalog/availability.rs diff --git a/src/catalog/credits.rs b/src/storage/catalog/credits.rs similarity index 100% rename from src/catalog/credits.rs rename to src/storage/catalog/credits.rs diff --git a/src/catalog/derivations.rs b/src/storage/catalog/derivations.rs similarity index 100% rename from src/catalog/derivations.rs rename to src/storage/catalog/derivations.rs diff --git a/src/catalog/identity.rs b/src/storage/catalog/identity.rs similarity index 100% rename from src/catalog/identity.rs rename to src/storage/catalog/identity.rs diff --git a/src/catalog/libraries.rs b/src/storage/catalog/libraries.rs similarity index 100% rename from src/catalog/libraries.rs rename to src/storage/catalog/libraries.rs diff --git a/src/catalog/mod.rs b/src/storage/catalog/mod.rs similarity index 100% rename from src/catalog/mod.rs rename to src/storage/catalog/mod.rs diff --git a/src/catalog/overrides.rs b/src/storage/catalog/overrides.rs similarity index 100% rename from src/catalog/overrides.rs rename to src/storage/catalog/overrides.rs diff --git a/src/catalog/scan_jobs.rs b/src/storage/catalog/scan_jobs.rs similarity index 100% rename from src/catalog/scan_jobs.rs rename to src/storage/catalog/scan_jobs.rs diff --git a/src/catalog/tests.rs b/src/storage/catalog/tests.rs similarity index 100% rename from src/catalog/tests.rs rename to src/storage/catalog/tests.rs diff --git a/src/catalog/tracks.rs b/src/storage/catalog/tracks.rs similarity index 100% rename from src/catalog/tracks.rs rename to src/storage/catalog/tracks.rs diff --git a/src/database/accounts.rs b/src/storage/database/accounts.rs similarity index 100% rename from src/database/accounts.rs rename to src/storage/database/accounts.rs diff --git a/src/database/keys.rs b/src/storage/database/keys.rs similarity index 100% rename from src/database/keys.rs rename to src/storage/database/keys.rs diff --git a/src/database/libraries.rs b/src/storage/database/libraries.rs similarity index 100% rename from src/database/libraries.rs rename to src/storage/database/libraries.rs diff --git a/src/database/metadata.rs b/src/storage/database/metadata.rs similarity index 100% rename from src/database/metadata.rs rename to src/storage/database/metadata.rs diff --git a/src/database/mod.rs b/src/storage/database/mod.rs similarity index 100% rename from src/database/mod.rs rename to src/storage/database/mod.rs diff --git a/src/database/sessions.rs b/src/storage/database/sessions.rs similarity index 100% rename from src/database/sessions.rs rename to src/storage/database/sessions.rs diff --git a/src/database/subsonic_credentials.rs b/src/storage/database/subsonic_credentials.rs similarity index 100% rename from src/database/subsonic_credentials.rs rename to src/storage/database/subsonic_credentials.rs diff --git a/src/database/sync_operations.rs b/src/storage/database/sync_operations.rs similarity index 100% rename from src/database/sync_operations.rs rename to src/storage/database/sync_operations.rs diff --git a/src/database/tests.rs b/src/storage/database/tests.rs similarity index 100% rename from src/database/tests.rs rename to src/storage/database/tests.rs diff --git a/src/migration_backup.rs b/src/storage/migration_backup.rs similarity index 100% rename from src/migration_backup.rs rename to src/storage/migration_backup.rs diff --git a/src/natural_order.rs b/src/storage/natural_order/mod.rs similarity index 100% rename from src/natural_order.rs rename to src/storage/natural_order/mod.rs diff --git a/src/sync.rs b/src/storage/sync/mod.rs similarity index 100% rename from src/sync.rs rename to src/storage/sync/mod.rs From 6cbb103873e886dd45c2cb27ed70381640bcfc8a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 17:47:14 +0100 Subject: [PATCH 02/13] refactor(src): declare the domain parents and rewrite every path to them lib.rs now names fourteen modules instead of thirty-nine. Each new parent (auth, library, remote, http, storage) has a mod.rs saying what the domain holds, and media declares replay_gain. Every crate:: and waveflow_server:: path, in src/ and tests/ alike, names the module's new home; the Scalar bundle is included from its new depth. No compatibility re-export is left at the root. Signed-off-by: InstaZDLL --- src/api/access.rs | 13 +++--- src/api/auth.rs | 21 +++++---- src/api/error.rs | 10 ++-- src/api/libraries.rs | 26 ++++++----- src/api/mod.rs | 4 +- src/api/oauth.rs | 13 +++--- src/api/playback.rs | 2 +- src/api/setup.rs | 2 +- src/api/subsonic_credential.rs | 4 +- src/api/sync.rs | 41 ++++++++++------ src/api/tokens.rs | 10 ++-- src/api/tracks.rs | 16 +++---- src/api/users.rs | 6 +-- src/api/web_session.rs | 8 ++-- src/app/boot.rs | 5 +- src/app/openapi.rs | 7 ++- src/app/router.rs | 2 +- src/app/state.rs | 3 +- src/auth/authentication.rs | 6 +-- src/auth/mod.rs | 10 ++++ src/auth/oauth/mod.rs | 2 +- src/auth/security/mod.rs | 2 +- src/auth/setup_token/mod.rs | 2 +- src/auth/stream_ticket/mod.rs | 2 +- src/cli/account.rs | 7 +-- src/cli/database.rs | 2 +- src/cli/key.rs | 6 +-- src/cli/library.rs | 6 +-- src/cli/mod.rs | 4 +- src/config/access.rs | 2 +- src/config/for_data_dir.rs | 6 +-- src/config/from_env.rs | 10 ++-- src/config/metadata.rs | 16 +++---- src/config/mod.rs | 4 +- src/config/pid.rs | 13 +++--- src/config/podcasts.rs | 6 +-- src/config/scrobbling.rs | 10 ++-- src/config/tests.rs | 4 +- src/http/mod.rs | 8 ++++ src/http/public_share/mod.rs | 8 ++-- src/http/reference/mod.rs | 11 +++-- src/http/security_headers/mod.rs | 4 +- src/http/webui.rs | 10 ++-- src/lib.rs | 35 ++------------ src/library/credit_split/mod.rs | 6 +-- src/library/mod.rs | 12 +++++ src/library/scanner/discovery/mod.rs | 8 ++-- src/library/scanner/extract/mod.rs | 18 +++---- src/library/scanner/job.rs | 6 +-- src/library/scanner/mod.rs | 10 ++-- src/library/watcher/mod.rs | 8 ++-- src/library/watcher/tests.rs | 2 +- src/main.rs | 6 +-- src/media/canvas.rs | 17 ++++--- src/media/mod.rs | 9 ++-- src/media/stream.rs | 27 ++++++----- src/media/tests.rs | 20 ++++---- src/media/transcode.rs | 2 +- src/remote/enrichment/mod.rs | 10 ++-- src/remote/lrclib/mod.rs | 14 +++--- src/remote/mod.rs | 10 ++++ src/remote/podcast/mod.rs | 10 ++-- src/remote/portrait/mod.rs | 22 ++++----- src/remote/scrobblers/lastfm.rs | 30 ++++++------ src/remote/scrobblers/maloja.rs | 12 ++--- src/remote/scrobblers/mod.rs | 8 ++-- src/services/admin.rs | 2 +- src/services/artist_enrichment/mod.rs | 4 +- src/services/artist_enrichment/tests.rs | 2 +- src/services/artist_image_search/mod.rs | 4 +- src/services/artist_images.rs | 12 ++--- src/services/artwork.rs | 7 ++- src/services/canvas/mod.rs | 2 +- src/services/canvas/placement.rs | 13 +++--- src/services/catalog.rs | 2 +- src/services/credentials.rs | 15 +++--- src/services/credit_decisions.rs | 32 ++++++------- src/services/lastfm.rs | 8 ++-- src/services/library_events.rs | 2 +- src/services/library_folders/mod.rs | 4 +- src/services/library_lifecycle/edit.rs | 2 +- src/services/library_tree/mod.rs | 2 +- src/services/lyrics_uploads.rs | 6 +-- src/services/mod.rs | 47 ++++++++++--------- src/services/model/accounts.rs | 4 +- src/services/model/playlists.rs | 2 +- src/services/playlists/files.rs | 2 +- src/services/podcasts/episodes.rs | 10 ++-- src/services/podcasts/mod.rs | 2 +- src/services/quality/analysis.rs | 2 +- src/services/quality/mod.rs | 2 +- src/services/quality/reads.rs | 5 +- src/services/radio/mod.rs | 2 +- src/services/remote_artwork/mod.rs | 8 ++-- src/services/remote_lyrics/mod.rs | 2 +- src/services/remote_lyrics/tests.rs | 2 +- src/services/scan.rs | 2 +- src/services/search.rs | 2 +- src/services/shares/tests.rs | 2 +- src/services/track_metadata.rs | 31 +++++++------ src/services/uploads/negotiate.rs | 2 +- src/services/uploads/transfer.rs | 9 ++-- src/storage/catalog/apply.rs | 24 +++++----- src/storage/catalog/availability.rs | 2 +- src/storage/catalog/credits.rs | 49 ++++++++++---------- src/storage/catalog/derivations.rs | 2 +- src/storage/catalog/identity.rs | 20 ++++---- src/storage/catalog/libraries.rs | 14 +++--- src/storage/catalog/overrides.rs | 2 +- src/storage/catalog/scan_jobs.rs | 4 +- src/storage/catalog/tracks.rs | 10 ++-- src/storage/database/keys.rs | 8 ++-- src/storage/database/metadata.rs | 40 ++++++++-------- src/storage/database/mod.rs | 8 ++-- src/storage/database/sessions.rs | 2 +- src/storage/database/subsonic_credentials.rs | 2 +- src/storage/migration_backup.rs | 13 ++++-- src/storage/mod.rs | 9 ++++ src/storage/sync/mod.rs | 4 +- src/subsonic/auth.rs | 5 +- src/subsonic/lyrics.rs | 2 +- src/subsonic/media.rs | 2 +- src/subsonic/mod.rs | 8 ++-- tests/auth/main.rs | 10 ++-- tests/auth/oauth.rs | 2 +- tests/auth/sessions.rs | 2 +- tests/auth/setup.rs | 6 +-- tests/auth/subsonic_credential.rs | 4 +- tests/auth/throttling.rs | 14 +++--- tests/auth/tokens.rs | 10 ++-- tests/canvas.rs | 18 +++---- tests/catalog/credits.rs | 4 +- tests/catalog/main.rs | 8 ++-- tests/cli.rs | 16 +++---- tests/credits.rs | 6 +-- tests/discovery.rs | 20 ++++---- tests/enrichment/banners.rs | 2 +- tests/enrichment/image_search.rs | 12 ++--- tests/enrichment/lastfm.rs | 4 +- tests/enrichment/main.rs | 14 +++--- tests/identity.rs | 28 +++++------ tests/media/main.rs | 10 ++-- tests/media/stream_tickets.rs | 8 ++-- tests/migrations.rs | 20 ++++---- tests/native_api/artists.rs | 4 +- tests/native_api/folders.rs | 4 +- tests/native_api/libraries.rs | 6 +-- tests/native_api/lyrics.rs | 2 +- tests/native_api/main.rs | 10 ++-- tests/native_api/playlists.rs | 2 +- tests/podcasts/covers.rs | 8 ++-- tests/podcasts/main.rs | 6 +-- tests/quality.rs | 6 +-- tests/scanner/indexing.rs | 2 +- tests/scanner/lifecycle.rs | 2 +- tests/scanner/main.rs | 16 +++---- tests/scanner/purges.rs | 4 +- tests/scanner/scheduling.rs | 15 ++++-- tests/scrobbling/adapters.rs | 20 ++++---- tests/scrobbling/listenbrainz.rs | 4 +- tests/scrobbling/main.rs | 10 ++-- tests/service.rs | 21 +++++---- tests/smart_playlists.rs | 8 ++-- tests/subsonic_browse.rs | 10 ++-- tests/subsonic_contract/compatibility.rs | 2 +- tests/subsonic_contract/main.rs | 10 ++-- tests/subsonic_fields.rs | 14 +++--- tests/subsonic_methods/main.rs | 10 ++-- tests/subsonic_methods/transcoding.rs | 6 +-- tests/support/mod.rs | 10 ++-- tests/sync.rs | 18 +++---- tests/uploads/listings.rs | 2 +- tests/uploads/main.rs | 10 ++-- 173 files changed, 849 insertions(+), 754 deletions(-) create mode 100644 src/auth/mod.rs create mode 100644 src/http/mod.rs create mode 100644 src/library/mod.rs create mode 100644 src/remote/mod.rs create mode 100644 src/storage/mod.rs diff --git a/src/api/access.rs b/src/api/access.rs index a100fbd2..efa879bc 100644 --- a/src/api/access.rs +++ b/src/api/access.rs @@ -76,14 +76,15 @@ pub(crate) async fn authenticated( state: &AppState, headers: &HeaderMap, access: Access, -) -> Result { +) -> Result { let token = bearer_token(headers).ok_or(ApiError::Unauthorized)?; let user = state .auth .authenticate(token) .await .map_err(ApiError::from)?; - let role_ok = access != Access::Admin || user.role == crate::database::AccountRole::Admin; + let role_ok = + access != Access::Admin || user.role == crate::storage::database::AccountRole::Admin; if role_ok && access.granted_by(&user.scopes) { Ok(user) } else { @@ -97,11 +98,11 @@ pub(crate) async fn authenticated( /// [`Access::Admin`]. A route that admits a narrower credential must not let /// the account's role stand in for what the credential lacks. pub(crate) fn library_actor( - user: &crate::authentication::AuthUser, + user: &crate::auth::authentication::AuthUser, ) -> crate::services::LibraryActor { crate::services::LibraryActor { id: user.id, - admin_credential: user.role == crate::database::AccountRole::Admin + admin_credential: user.role == crate::storage::database::AccountRole::Admin && Access::Admin.granted_by(&user.scopes), } } @@ -127,11 +128,11 @@ pub(super) async fn mutation_context( state: &AppState, headers: &HeaderMap, user_id: Uuid, -) -> Result { +) -> Result { let operation_id = optional_uuid_header(headers, OPERATION_ID_HEADER)?.unwrap_or_else(Uuid::new_v4); let origin_device_id = origin_device(state, headers, user_id).await?; - Ok(crate::sync::MutationContext { + Ok(crate::storage::sync::MutationContext { operation_id, origin_device_id, }) diff --git a/src/api/auth.rs b/src/api/auth.rs index cd782567..7600f17b 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -22,7 +22,7 @@ pub struct RefreshRequest { tag = "authentication", request_body = LoginRequest, responses( - (status = 200, body = crate::authentication::AuthTokens), + (status = 200, body = crate::auth::authentication::AuthTokens), (status = 401, body = ErrorResponse), (status = 422, body = ErrorResponse), (status = 429, body = ErrorResponse, description = "Too many failed sign-ins from this address for this account (RFC-017)", @@ -34,14 +34,14 @@ pub async fn login( State(state): State, client: SignInAddress, Json(request): Json, -) -> Result, ApiError> { +) -> Result, ApiError> { state .auth .login( &request.username, &request.password, &request.device_name, - &client.on(crate::sign_in::Surface::Native), + &client.on(crate::auth::sign_in::Surface::Native), ) .await .map(Json) @@ -54,7 +54,7 @@ pub async fn login( tag = "authentication", request_body = RefreshRequest, responses( - (status = 200, body = crate::authentication::AuthTokens), + (status = 200, body = crate::auth::authentication::AuthTokens), (status = 401, body = ErrorResponse), (status = 503, body = ErrorResponse) ) @@ -62,7 +62,7 @@ pub async fn login( pub async fn refresh( State(state): State, Json(request): Json, -) -> Result, ApiError> { +) -> Result, ApiError> { state .auth .refresh(&request.refresh_token) @@ -125,7 +125,7 @@ pub async fn web_login( &request.username, &request.password, &request.device_name, - &client.on(crate::sign_in::Surface::Web), + &client.on(crate::auth::sign_in::Surface::Web), ) .await .map_err(ApiError::from)?; @@ -198,8 +198,11 @@ pub async fn web_logout( pub struct SignInAddress(String); impl SignInAddress { - fn on(self, surface: crate::sign_in::Surface) -> crate::authentication::SignInClient { - crate::authentication::SignInClient { + fn on( + self, + surface: crate::auth::sign_in::Surface, + ) -> crate::auth::authentication::SignInClient { + crate::auth::authentication::SignInClient { surface, address: self.0, } @@ -213,7 +216,7 @@ impl axum::extract::FromRequestParts for SignInAddress { parts: &mut axum::http::request::Parts, state: &AppState, ) -> Result { - Ok(Self(crate::sign_in::request_bucket( + Ok(Self(crate::auth::sign_in::request_bucket( &parts.extensions, &parts.headers, &state.trusted_proxies, diff --git a/src/api/error.rs b/src/api/error.rs index ee4d7ae4..91a015c6 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -113,12 +113,12 @@ pub(super) fn db_error(error: sqlx::Error) -> ApiError { ApiError::Unavailable } -pub(super) fn sync_error(error: crate::sync::SyncError) -> ApiError { +pub(super) fn sync_error(error: crate::storage::sync::SyncError) -> ApiError { match error { - crate::sync::SyncError::Invalid => ApiError::Validation, - crate::sync::SyncError::Conflict => ApiError::Conflict, - crate::sync::SyncError::CursorExpired => ApiError::CursorExpired, - crate::sync::SyncError::Database(error) => db_error(error), + crate::storage::sync::SyncError::Invalid => ApiError::Validation, + crate::storage::sync::SyncError::Conflict => ApiError::Conflict, + crate::storage::sync::SyncError::CursorExpired => ApiError::CursorExpired, + crate::storage::sync::SyncError::Database(error) => db_error(error), } } diff --git a/src/api/libraries.rs b/src/api/libraries.rs index 963e98fe..2fa0a749 100644 --- a/src/api/libraries.rs +++ b/src/api/libraries.rs @@ -13,7 +13,7 @@ pub struct ScanQueuedResponse { pub struct CreateLibraryRequest { pub name: String, pub path: String, - pub visibility: crate::database::LibraryVisibility, + pub visibility: crate::storage::database::LibraryVisibility, } #[derive(Debug, Serialize, ToSchema)] @@ -24,7 +24,7 @@ pub struct CreateLibraryResponse { #[derive(Debug, Deserialize, ToSchema)] pub struct SetLibraryMemberRequest { - pub role: crate::database::LibraryRole, + pub role: crate::storage::database::LibraryRole, } #[utoipa::path(post, path = "/api/v2/libraries/{library_id}/scans", tag = "catalog", params(("library_id" = Uuid, Path)), responses((status = 202, body = ScanQueuedResponse), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] @@ -42,11 +42,11 @@ pub async fn start_scan( Ok((StatusCode::ACCEPTED, Json(ScanQueuedResponse { scan_id }))) } -#[utoipa::path(get, path = "/api/v2/libraries", tag = "catalog", responses((status = 200, body = [crate::catalog::LibraryAccess]), (status = 401, body = ErrorResponse)))] +#[utoipa::path(get, path = "/api/v2/libraries", tag = "catalog", responses((status = 200, body = [crate::storage::catalog::LibraryAccess]), (status = 401, body = ErrorResponse)))] pub async fn list_libraries( State(state): State, headers: HeaderMap, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let user = authenticated(&state, &headers, Access::Read).await?; state .db @@ -80,7 +80,7 @@ pub async fn create_library( &request.name, &canonical, request.visibility, - crate::authentication::now_ms(), + crate::auth::authentication::now_ms(), ) .await .map_err(db_error)?; @@ -88,7 +88,7 @@ pub async fn create_library( let scan_id = state .scanner .trigger( - crate::catalog::LibraryRecord { + crate::storage::catalog::LibraryRecord { id: library_id, name: request.name, root_path: canonical, @@ -118,7 +118,7 @@ pub async fn set_library_member( Json(request): Json, ) -> Result { let actor = authenticated(&state, &headers, Access::Admin).await?; - if request.role == crate::database::LibraryRole::Owner + if request.role == crate::storage::database::LibraryRole::Owner || state .db .account_by_id(user_id) @@ -142,7 +142,7 @@ pub async fn set_library_member( library_id, user_id, request.role, - crate::authentication::now_ms(), + crate::auth::authentication::now_ms(), ) .await .map_err(db_error)?; @@ -162,7 +162,7 @@ pub async fn remove_library_member( actor.id, library_id, user_id, - crate::authentication::now_ms(), + crate::auth::authentication::now_ms(), ) .await .map_err(db_error)? @@ -173,12 +173,12 @@ pub async fn remove_library_member( } } -#[utoipa::path(get, path = "/api/v2/scans/{scan_id}", tag = "catalog", params(("scan_id" = Uuid, Path)), responses((status = 200, body = crate::catalog::ScanJobRecord), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +#[utoipa::path(get, path = "/api/v2/scans/{scan_id}", tag = "catalog", params(("scan_id" = Uuid, Path)), responses((status = 200, body = crate::storage::catalog::ScanJobRecord), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] pub async fn scan_status( State(state): State, Path(scan_id): Path, headers: HeaderMap, -) -> Result, ApiError> { +) -> Result, ApiError> { let user = authenticated(&state, &headers, Access::Read).await?; state .db @@ -327,7 +327,9 @@ pub async fn library_events( user.id, library_id, query.after.unwrap_or(0), - query.limit.unwrap_or(crate::sync::DEFAULT_SYNC_LIMIT), + query + .limit + .unwrap_or(crate::storage::sync::DEFAULT_SYNC_LIMIT), ) .await .map(Json) diff --git a/src/api/mod.rs b/src/api/mod.rs index 784cebd3..3a6d8533 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::{authentication::AuthError, AppState}; +use crate::{auth::authentication::AuthError, AppState}; pub(crate) mod access; mod attention; @@ -256,7 +256,7 @@ pub fn router(state: AppState) -> Router { .route( "/api/v2/artists/{artist_id}/image/{kind}", put(put_artist_image).delete(delete_artist_image).layer( - axum::extract::DefaultBodyLimit::max(crate::portrait::MAX_IMAGE_BYTES), + axum::extract::DefaultBodyLimit::max(crate::remote::portrait::MAX_IMAGE_BYTES), ), ) .route( diff --git a/src/api/oauth.rs b/src/api/oauth.rs index a01706dc..b14a7566 100644 --- a/src/api/oauth.rs +++ b/src/api/oauth.rs @@ -68,24 +68,25 @@ pub async fn oauth_authorize( Ok(Json(AuthorizeResponse { redirect_to })) } -#[utoipa::path(post, path = "/api/v2/oauth/token", tag = "authentication", request_body = TokenRequest, responses((status = 200, body = crate::authentication::AuthTokens), (status = 401, body = ErrorResponse), (status = 503, body = ErrorResponse)))] +#[utoipa::path(post, path = "/api/v2/oauth/token", tag = "authentication", request_body = TokenRequest, responses((status = 200, body = crate::auth::authentication::AuthTokens), (status = 401, body = ErrorResponse), (status = 503, body = ErrorResponse)))] pub async fn oauth_token( State(state): State, Json(request): Json, -) -> Result, ApiError> { +) -> Result, ApiError> { // Mounted without authentication by design: the code plus the verifier are // the credential. Every rejection below is the same 401 so a caller cannot // learn whether a code existed, expired, or was already spent. - let now = crate::authentication::now_ms(); + let now = crate::auth::authentication::now_ms(); let grant = state .db - .redeem_authorization(&crate::security::token_hash(&request.code), now) + .redeem_authorization(&crate::auth::security::token_hash(&request.code), now) .await .map_err(db_error)? .ok_or(ApiError::Unauthorized)?; if grant.client_id != request.client_id.trim() || grant.redirect_uri != request.redirect_uri - || crate::oauth::verify_challenge(&grant.code_challenge, &request.code_verifier).is_err() + || crate::auth::oauth::verify_challenge(&grant.code_challenge, &request.code_verifier) + .is_err() { return Err(ApiError::Unauthorized); } @@ -95,7 +96,7 @@ pub async fn oauth_token( .await .map(Json) .map_err(|error| match error { - crate::authentication::AuthError::Unavailable => ApiError::Unavailable, + crate::auth::authentication::AuthError::Unavailable => ApiError::Unavailable, _ => ApiError::Unauthorized, }) } diff --git a/src/api/playback.rs b/src/api/playback.rs index 2d8c36d5..4413aa9c 100644 --- a/src/api/playback.rs +++ b/src/api/playback.rs @@ -81,7 +81,7 @@ pub async fn list_history( ) -> Result>, ApiError> { let user = authenticated(&state, &headers, Access::Read).await?; let limit = query.limit.unwrap_or(200); - if !(1..=crate::sync::MAX_SYNC_LIMIT).contains(&limit) { + if !(1..=crate::storage::sync::MAX_SYNC_LIMIT).contains(&limit) { return Err(ApiError::Validation); } state diff --git a/src/api/setup.rs b/src/api/setup.rs index 00ef3386..c050038c 100644 --- a/src/api/setup.rs +++ b/src/api/setup.rs @@ -3,7 +3,7 @@ //! Split out of `http.rs`; `mod.rs` re-exports it, so `crate::api::*` paths are unchanged. use super::*; -use crate::setup_token::Admission; +use crate::auth::setup_token::Admission; #[derive(Debug, Serialize, ToSchema)] pub struct SetupStatusResponse { diff --git a/src/api/subsonic_credential.rs b/src/api/subsonic_credential.rs index 174ff18c..708e479d 100644 --- a/src/api/subsonic_credential.rs +++ b/src/api/subsonic_credential.rs @@ -28,11 +28,11 @@ use super::*; /// A `404` would be indistinguishable from a route that is not there, and a /// flag beside the timestamps would tell one fact twice — the body already /// says it by being absent. -#[utoipa::path(get, path = "/api/v2/subsonic-credential", tag = "account", responses((status = 200, body = Option), (status = 401, body = ErrorResponse)))] +#[utoipa::path(get, path = "/api/v2/subsonic-credential", tag = "account", responses((status = 200, body = Option), (status = 401, body = ErrorResponse)))] pub async fn own_subsonic_credential( State(state): State, headers: HeaderMap, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let user = authenticated(&state, &headers, Access::Read).await?; state .services diff --git a/src/api/sync.rs b/src/api/sync.rs index 7fca7c6b..a6b9be17 100644 --- a/src/api/sync.rs +++ b/src/api/sync.rs @@ -34,7 +34,7 @@ pub struct SyncSnapshot { tag = "sync", params(("after" = Option, Query), ("limit" = Option, Query)), responses( - (status = 200, body = crate::sync::SyncPage), + (status = 200, body = crate::storage::sync::SyncPage), (status = 401, body = ErrorResponse), ( status = 409, @@ -51,11 +51,13 @@ pub async fn sync_changes( State(state): State, headers: HeaderMap, Query(query): Query, -) -> Result, ApiError> { +) -> Result, ApiError> { let user = authenticated(&state, &headers, Access::Read).await?; let after = query.after.unwrap_or(0); - let limit = query.limit.unwrap_or(crate::sync::DEFAULT_SYNC_LIMIT); - if after < 0 || limit <= 0 || limit > crate::sync::MAX_SYNC_LIMIT { + let limit = query + .limit + .unwrap_or(crate::storage::sync::DEFAULT_SYNC_LIMIT); + if after < 0 || limit <= 0 || limit > crate::storage::sync::MAX_SYNC_LIMIT { return Err(ApiError::Validation); } state @@ -79,7 +81,7 @@ pub async fn sync_snapshot( let user = authenticated(&state, &headers, Access::Read).await?; let snapshot = state .services - .sync_snapshot(user.id, crate::sync::MAX_SYNC_LIMIT) + .sync_snapshot(user.id, crate::storage::sync::MAX_SYNC_LIMIT) .await .map_err(service_error)?; let favorites = snapshot @@ -232,9 +234,12 @@ pub(super) enum SyncNoticeAction { } pub(super) async fn sync_notice_action( - sync: &crate::sync::SyncService, + sync: &crate::storage::sync::SyncService, user_id: Uuid, - notice: Result<(Uuid, crate::sync::SyncNotice), tokio::sync::broadcast::error::RecvError>, + notice: Result< + (Uuid, crate::storage::sync::SyncNotice), + tokio::sync::broadcast::error::RecvError, + >, ) -> Result { match notice { Ok((notice_user, notice)) if notice_user == user_id => { @@ -253,8 +258,8 @@ pub(super) async fn send_sync_notice( sender: &mut futures_util::stream::SplitSink, cursor: i64, ) -> Result<(), axum::Error> { - let body = - serde_json::to_string(&crate::sync::SyncNotice { cursor }).expect("sync notice serializes"); + let body = serde_json::to_string(&crate::storage::sync::SyncNotice { cursor }) + .expect("sync notice serializes"); sender.send(Message::Text(body.into())).await } @@ -273,20 +278,22 @@ mod tests { async fn lagged_sync_socket_recovers_from_the_durable_cursor() { let temp = tempfile::tempdir().unwrap(); let config = crate::Config::for_data_dir(temp.path().join("data")); - let db = crate::database::Database::open(&config).await.unwrap(); + let db = crate::storage::database::Database::open(&config) + .await + .unwrap(); db.migrate().await.unwrap(); let user_id = db .create_account( "lagged", "hash", - crate::database::AccountRole::User, - crate::authentication::now_ms(), + crate::storage::database::AccountRole::User, + crate::auth::authentication::now_ms(), ) .await .unwrap(); - let sync = crate::sync::SyncService::new(db.clone()); + let sync = crate::storage::sync::SyncService::new(db.clone()); - let context = crate::sync::MutationContext::server_generated(); + let context = crate::storage::sync::MutationContext::server_generated(); let writer = db.writer_guard().await; let mut tx = db.begin_write(&writer).await.unwrap(); sync.claim_operation( @@ -294,7 +301,11 @@ mod tests { &mut tx, user_id, context, - crate::sync::MutationIntent::new("set-rating", "track:seed", &serde_json::json!({})), + crate::storage::sync::MutationIntent::new( + "set-rating", + "track:seed", + &serde_json::json!({}), + ), ) .await .unwrap(); diff --git a/src/api/tokens.rs b/src/api/tokens.rs index b9aa6f34..6731b49b 100644 --- a/src/api/tokens.rs +++ b/src/api/tokens.rs @@ -18,17 +18,17 @@ pub struct CreateApiTokenRequest { #[derive(Debug, Serialize, ToSchema)] pub struct CreateApiTokenResponse { #[serde(flatten)] - pub token: crate::database::ApiTokenRecord, + pub token: crate::storage::database::ApiTokenRecord, /// Shown once. Only its SHA-256 hash is stored, so it cannot be recovered. pub secret: String, } -#[utoipa::path(get, path = "/api/v2/admin/users/{username}/tokens", tag = "administration", params(("username" = String, Path)), responses((status = 200, body = [crate::database::ApiTokenRecord]), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +#[utoipa::path(get, path = "/api/v2/admin/users/{username}/tokens", tag = "administration", params(("username" = String, Path)), responses((status = 200, body = [crate::storage::database::ApiTokenRecord]), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), (status = 404, body = ErrorResponse)))] pub async fn list_api_tokens( State(state): State, Path(username): Path, headers: HeaderMap, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let actor = authenticated(&state, &headers, Access::Admin).await?; state .services @@ -104,11 +104,11 @@ pub async fn revoke_api_token( /// Until now it needed the instance's administrator. A listener could not find /// out which clients held a credential on their own library, let alone revoke /// one — the material was there and visible nowhere. -#[utoipa::path(get, path = "/api/v2/api-tokens", tag = "account", responses((status = 200, body = [crate::database::ApiTokenRecord]), (status = 401, body = ErrorResponse)))] +#[utoipa::path(get, path = "/api/v2/api-tokens", tag = "account", responses((status = 200, body = [crate::storage::database::ApiTokenRecord]), (status = 401, body = ErrorResponse)))] pub async fn list_own_api_tokens( State(state): State, headers: HeaderMap, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let user = authenticated(&state, &headers, Access::Read).await?; state .services diff --git a/src/api/tracks.rs b/src/api/tracks.rs index 0e350b9f..8e727707 100644 --- a/src/api/tracks.rs +++ b/src/api/tracks.rs @@ -11,13 +11,13 @@ pub struct TrackQuery { pub limit: Option, } -#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/tracks", tag = "catalog", params(("library_id" = Uuid, Path), ("q" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::catalog::TrackRecord]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] +#[utoipa::path(get, path = "/api/v2/libraries/{library_id}/tracks", tag = "catalog", params(("library_id" = Uuid, Path), ("q" = Option, Query), ("offset" = Option, Query), ("limit" = Option, Query)), responses((status = 200, body = [crate::storage::catalog::TrackRecord]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 422, body = ErrorResponse)))] pub async fn list_tracks( State(state): State, Path(library_id): Path, Query(query): Query, headers: HeaderMap, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let user = authenticated(&state, &headers, Access::Read).await?; if state .db @@ -92,12 +92,12 @@ pub async fn get_track( .ok_or(ApiError::NotFound) } -#[utoipa::path(get, path = "/api/v2/tracks/{track_id}/lyrics", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::lyrics::LyricsList), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +#[utoipa::path(get, path = "/api/v2/tracks/{track_id}/lyrics", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::library::lyrics::LyricsList), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] pub async fn get_track_lyrics( State(state): State, Path(track_id): Path, headers: HeaderMap, -) -> Result, ApiError> { +) -> Result, ApiError> { let user = authenticated(&state, &headers, Access::Read).await?; // The native answer: uploads included, in RFC-012's order, each sheet // naming its source. The Subsonic façade reads `lyrics` instead. @@ -109,8 +109,8 @@ pub async fn get_track_lyrics( .map_err(service_error) } -fn lyrics_format(value: &str) -> Result { - crate::lyrics::UploadFormat::parse(value).ok_or(ApiError::NotFound) +fn lyrics_format(value: &str) -> Result { + crate::library::lyrics::UploadFormat::parse(value).ok_or(ApiError::NotFound) } /// Uploads a lyrics sheet to a track (RFC-012 decision 6). @@ -120,13 +120,13 @@ fn lyrics_format(value: &str) -> Result { /// again in the same format replaces it. A sheet with nothing usable in it is /// refused with `422`. Owners and managers only; anyone else gets the `404` a /// track they could not see would give. -#[utoipa::path(put, path = "/api/v2/tracks/{track_id}/lyrics/{format}", tag = "catalog", params(("track_id" = Uuid, Path), ("format" = String, Path, description = "lrc, txt or ttml")), request_body(content = Vec, content_type = "application/octet-stream"), responses((status = 200, body = crate::lyrics::LyricsList), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 413), (status = 422, body = ErrorResponse)))] +#[utoipa::path(put, path = "/api/v2/tracks/{track_id}/lyrics/{format}", tag = "catalog", params(("track_id" = Uuid, Path), ("format" = String, Path, description = "lrc, txt or ttml")), request_body(content = Vec, content_type = "application/octet-stream"), responses((status = 200, body = crate::library::lyrics::LyricsList), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse), (status = 413), (status = 422, body = ErrorResponse)))] pub async fn put_track_lyrics( State(state): State, Path((track_id, format)): Path<(Uuid, String)>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, ApiError> { +) -> Result, ApiError> { let user = authenticated(&state, &headers, Access::Write).await?; let format = lyrics_format(&format)?; state diff --git a/src/api/users.rs b/src/api/users.rs index c86c0bb5..ae553d98 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -8,12 +8,12 @@ use super::*; pub struct CreateUserRequest { pub username: String, pub web_password: String, - pub role: crate::database::AccountRole, + pub role: crate::storage::database::AccountRole, } #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateUserRequest { - pub role: Option, + pub role: Option, pub disabled: Option, pub library_ids: Option>, pub subsonic_password: Option, @@ -81,7 +81,7 @@ pub async fn update_user( crate::services::UserUpdate { admin: request .role - .map(|role| role == crate::database::AccountRole::Admin), + .map(|role| role == crate::storage::database::AccountRole::Admin), disabled: request.disabled, folder_ids: request.library_ids.as_deref(), subsonic_password: request.subsonic_password.as_deref(), diff --git a/src/api/web_session.rs b/src/api/web_session.rs index 3f168c3e..34a4f72b 100644 --- a/src/api/web_session.rs +++ b/src/api/web_session.rs @@ -9,16 +9,16 @@ pub struct WebAuthResponse { pub access_token: String, pub token_type: &'static str, pub expires_in: u64, - pub user: crate::authentication::AuthUser, + pub user: crate::auth::authentication::AuthUser, pub device_id: Uuid, } pub(super) fn web_auth_response( state: &AppState, _headers: &HeaderMap, - tokens: crate::authentication::AuthTokens, + tokens: crate::auth::authentication::AuthTokens, ) -> Result { - let csrf_token = crate::security::generate_token("wfcsrf_"); + let csrf_token = crate::auth::security::generate_token("wfcsrf_"); let secure = secure_cookies(state); let refresh_cookie = format!( "{WEB_REFRESH_COOKIE}={}; Path=/api/v2/web/auth; HttpOnly; SameSite=Strict; Max-Age={}{}", @@ -86,7 +86,7 @@ pub(super) fn validate_web_request(state: &AppState, headers: &HeaderMap) -> Res .get(WEB_CSRF_HEADER) .and_then(|value| value.to_str().ok()) .ok_or(ApiError::Forbidden)?; - if !crate::security::constant_time_bytes_eq(cookie.as_bytes(), supplied.as_bytes()) { + if !crate::auth::security::constant_time_bytes_eq(cookie.as_bytes(), supplied.as_bytes()) { return Err(ApiError::Forbidden); } Ok(()) diff --git a/src/app/boot.rs b/src/app/boot.rs index dedabc2a..c06a4593 100644 --- a/src/app/boot.rs +++ b/src/app/boot.rs @@ -5,8 +5,9 @@ use std::{sync::Arc, time::Duration}; use super::state::{chunk_body_limit, negotiation_body_limit, AppState}; use crate::{ - authentication, database, enrichment, link_roots, lrclib, media, migration_backup, quality, - scanner, scrobblers, security, services, setup_token, sync, Config, + auth::authentication, auth::security, auth::setup_token, library::link_roots, library::scanner, + media, quality, remote::enrichment, remote::lrclib, remote::scrobblers, services, + storage::database, storage::migration_backup, storage::sync, Config, }; pub async fn initialize(config: &Config) -> anyhow::Result { diff --git a/src/app/openapi.rs b/src/app/openapi.rs index eb56d758..e87cd174 100644 --- a/src/app/openapi.rs +++ b/src/app/openapi.rs @@ -5,7 +5,10 @@ use axum::response::{IntoResponse, Response}; use utoipa::OpenApi; -use crate::{api, authentication, catalog, database, lyrics, media, services, sync}; +use crate::{ + api, auth::authentication, library::lyrics, media, services, storage::catalog, + storage::database, storage::sync, +}; pub const OPENAPI_JSON_PATH: &str = "/openapi.json"; pub const SCALAR_PATH: &str = "/reference"; @@ -339,7 +342,7 @@ pub const SCALAR_PATH: &str = "/reference"; api::UpdateUserRequest, api::SetSubsonicCredentialRequest, api::SubsonicCredentialResponse, - crate::database::SubsonicCredentialStatus, + crate::storage::database::SubsonicCredentialStatus, api::BookmarkRequest, api::RadioStationRequest, api::CreateApiTokenRequest, diff --git a/src/app/router.rs b/src/app/router.rs index c6593b5e..57b44e97 100644 --- a/src/app/router.rs +++ b/src/app/router.rs @@ -12,7 +12,7 @@ use utoipa::OpenApi; use super::openapi::{openapi_response, ApiDoc, OPENAPI_JSON_PATH}; use super::state::AppState; -use crate::{api, media, public_share, reference, subsonic, webui, Config}; +use crate::{api, http::public_share, http::reference, http::webui, media, subsonic, Config}; const REQUEST_ID_HEADER: &str = "x-request-id"; diff --git a/src/app/state.rs b/src/app/state.rs index a0e8e91d..db6272e3 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use crate::{ - authentication, config, database, media, scanner, security, services, setup_token, sync, + auth::authentication, auth::security, auth::setup_token, config, library::scanner, media, + services, storage::database, storage::sync, }; #[derive(Clone)] diff --git a/src/auth/authentication.rs b/src/auth/authentication.rs index 3f7ba2f0..d4e78f27 100644 --- a/src/auth/authentication.rs +++ b/src/auth/authentication.rs @@ -5,10 +5,10 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::{ + auth::security, + auth::sign_in::{self, Refusal, SignInGuard, Surface}, config::Config, - database::{AccountRole, Database, NewSession}, - security, - sign_in::{self, Refusal, SignInGuard, Surface}, + storage::database::{AccountRole, Database, NewSession}, }; #[derive(Clone)] diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 00000000..3a88da11 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,10 @@ +//! Who is calling: accounts and sessions, the password guard, native-client +//! OAuth, the first-run setup token, the instance key's cryptography and the +//! stream tickets browser playback is authorised with. + +pub mod authentication; +pub mod oauth; +pub mod security; +pub mod setup_token; +pub mod sign_in; +pub mod stream_ticket; diff --git a/src/auth/oauth/mod.rs b/src/auth/oauth/mod.rs index 39fcceb6..76ca43ed 100644 --- a/src/auth/oauth/mod.rs +++ b/src/auth/oauth/mod.rs @@ -82,7 +82,7 @@ pub fn verify_challenge(challenge: &str, verifier: &str) -> Result<(), GrantErro let expected = challenge_for(verifier); // The challenge is public, but constant-time keeps the comparison uniform // with the rest of the credential handling in this codebase. - if crate::security::constant_time_bytes_eq(expected.as_bytes(), challenge.as_bytes()) { + if crate::auth::security::constant_time_bytes_eq(expected.as_bytes(), challenge.as_bytes()) { Ok(()) } else { Err(GrantError::InvalidVerifier) diff --git a/src/auth/security/mod.rs b/src/auth/security/mod.rs index c5b3a362..5e49da8c 100644 --- a/src/auth/security/mod.rs +++ b/src/auth/security/mod.rs @@ -191,7 +191,7 @@ pub fn token_matches(token: &str, expected_hash: &[u8]) -> bool { /// `false` means the path was already taken. [`SecretBox::load_or_create`] /// treats that as a race it lost and re-reads the winner's key; /// [`SecretBox::create_instance_key`] treats it as a refusal, because it is -/// staging a file that must not exist. [`crate::setup_token`] writes the +/// staging a file that must not exist. [`crate::auth::setup_token`] writes the /// setup token through it too, for the owner-only mode on Unix. pub(crate) fn write_new_secret_file(path: &Path, bytes: &[u8]) -> Result { if let Some(parent) = path.parent() { diff --git a/src/auth/setup_token/mod.rs b/src/auth/setup_token/mod.rs index e4bc83d0..10fd4efe 100644 --- a/src/auth/setup_token/mod.rs +++ b/src/auth/setup_token/mod.rs @@ -27,8 +27,8 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; +use crate::auth::security; use crate::config::{Config, SETUP_TOKEN_MIN_CHARS}; -use crate::security; /// The file a drawn token is written to, inside the data directory. pub const FILE_NAME: &str = "setup-token"; diff --git a/src/auth/stream_ticket/mod.rs b/src/auth/stream_ticket/mod.rs index 90a30210..529d1610 100644 --- a/src/auth/stream_ticket/mod.rs +++ b/src/auth/stream_ticket/mod.rs @@ -18,7 +18,7 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use uuid::Uuid; -use crate::security::{SecretBox, SecurityError}; +use crate::auth::security::{SecretBox, SecurityError}; /// What a ticket opens. /// diff --git a/src/cli/account.rs b/src/cli/account.rs index 5956f1e6..120c6deb 100644 --- a/src/cli/account.rs +++ b/src/cli/account.rs @@ -8,9 +8,10 @@ use super::{ RevokeCredentialArgs, SetCredentialArgs, }; use crate::{ - authentication::now_ms, - database::{AccountRole, Database}, - security, AppState, + auth::authentication::now_ms, + auth::security, + storage::database::{AccountRole, Database}, + AppState, }; pub(super) async fn create_account( diff --git a/src/cli/database.rs b/src/cli/database.rs index 263b6140..5a1d471c 100644 --- a/src/cli/database.rs +++ b/src/cli/database.rs @@ -2,7 +2,7 @@ //! bundle. use super::{BackupArgs, RestoreArgs}; -use crate::{database::Database, security, AppState}; +use crate::{auth::security, storage::database::Database, AppState}; pub(super) async fn backup(state: &AppState, args: BackupArgs) -> anyhow::Result<()> { if args.output.exists() { diff --git a/src/cli/key.rs b/src/cli/key.rs index a1039d65..fa1f77b1 100644 --- a/src/cli/key.rs +++ b/src/cli/key.rs @@ -8,9 +8,9 @@ use sqlx::Connection; use super::{KeyArgs, KeyCommand}; use crate::{ - authentication::now_ms, - database::{Database, KeyMaterial, Rekey, SchemaState}, - security, + auth::authentication::now_ms, + auth::security, + storage::database::{Database, KeyMaterial, Rekey, SchemaState}, }; pub async fn key(config: &crate::Config, command: KeyCommand) -> anyhow::Result<()> { diff --git a/src/cli/library.rs b/src/cli/library.rs index bc6bd0f5..b6533efa 100644 --- a/src/cli/library.rs +++ b/src/cli/library.rs @@ -11,9 +11,9 @@ use super::{ SetWatchArgs, UnavailableArgs, }; use crate::{ - authentication::now_ms, - catalog::LibraryRecord, - database::{Database, LibraryRole, LibraryVisibility}, + auth::authentication::now_ms, + storage::catalog::LibraryRecord, + storage::database::{Database, LibraryRole, LibraryVisibility}, AppState, }; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 11d9d923..cfe7919f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -31,7 +31,7 @@ use self::scrobble::{ authorize_lastfm, exchange_lastfm, link_scrobble, scrobble_status, unlink_scrobble, }; use crate::{ - database::{AccountRole, Database}, + storage::database::{AccountRole, Database}, AppState, }; @@ -114,7 +114,7 @@ pub async fn execute(command: Command, state: &AppState) -> anyhow::Result<()> { async fn require_admin( db: &Database, username: &str, -) -> anyhow::Result { +) -> anyhow::Result { let account = db .account_by_username(username) .await? diff --git a/src/config/access.rs b/src/config/access.rs index 73cf8d78..a1d3434c 100644 --- a/src/config/access.rs +++ b/src/config/access.rs @@ -46,7 +46,7 @@ pub(super) fn parse_proxy_addresses(list: &str) -> anyhow::Result() .or_else(|_| entry.parse::().map(ipnet::IpNet::from)) - .map(crate::sign_in::trusted_network) + .map(crate::auth::sign_in::trusted_network) .map_err(|_| anyhow::anyhow!("invalid WAVEFLOW_TRUSTED_PROXIES entry {entry:?}")) }) .collect() diff --git a/src/config/for_data_dir.rs b/src/config/for_data_dir.rs index 77e8ad43..a351a27f 100644 --- a/src/config/for_data_dir.rs +++ b/src/config/for_data_dir.rs @@ -50,7 +50,7 @@ impl Config { // Production's default: the suite purges the way a server does. purge_grace: Duration::from_secs(168 * 60 * 60), link_roots: Vec::new(), - subsonic_replay_gain: crate::replay_gain::ReplayGainMode::Off, + subsonic_replay_gain: crate::media::replay_gain::ReplayGainMode::Off, ffmpeg_path: PathBuf::from("ffmpeg"), ffprobe_path: PathBuf::from("ffprobe"), transcode_cache_dir, @@ -140,7 +140,7 @@ impl Config { feed_timeout: Duration::from_secs(10), download_timeout: Duration::from_secs(30), dir: podcast_dir_for_tests, - addresses: crate::portrait::AddressPolicy::AllowLoopback, + addresses: crate::remote::portrait::AddressPolicy::AllowLoopback, }, // On, as production defaults, and admitting loopback for the same // reason the podcast policy above does: the image hosts the suite @@ -149,7 +149,7 @@ impl Config { remote_artwork: RemoteArtworkSettings { enabled: true, timeout: Duration::from_secs(10), - addresses: crate::portrait::AddressPolicy::AllowLoopback, + addresses: crate::remote::portrait::AddressPolicy::AllowLoopback, }, allowed_origins: Vec::new(), trusted_proxies: Vec::new(), diff --git a/src/config/from_env.rs b/src/config/from_env.rs index be238551..d11f1a9e 100644 --- a/src/config/from_env.rs +++ b/src/config/from_env.rs @@ -81,9 +81,9 @@ impl Config { let purge_grace = Duration::from_secs(purge_grace_hours.saturating_mul(60 * 60)); let link_roots = parse_link_roots(std::env::var_os("WAVEFLOW_LINK_ROOTS"))?; let subsonic_replay_gain = match std::env::var("WAVEFLOW_SUBSONIC_REPLAYGAIN") { - Err(_) => crate::replay_gain::ReplayGainMode::Off, - Ok(raw) if raw.trim().is_empty() => crate::replay_gain::ReplayGainMode::Off, - Ok(raw) => crate::replay_gain::ReplayGainMode::parse(&raw).ok_or_else(|| { + Err(_) => crate::media::replay_gain::ReplayGainMode::Off, + Ok(raw) if raw.trim().is_empty() => crate::media::replay_gain::ReplayGainMode::Off, + Ok(raw) => crate::media::replay_gain::ReplayGainMode::parse(&raw).ok_or_else(|| { anyhow::anyhow!( "invalid WAVEFLOW_SUBSONIC_REPLAYGAIN: expected off, track or album, found {raw:?}" ) @@ -243,7 +243,7 @@ impl Config { let remote_artwork = RemoteArtworkSettings { enabled: parse_bool_env("WAVEFLOW_REMOTE_ARTWORK", true)?, timeout: Duration::from_secs(20), - addresses: crate::portrait::AddressPolicy::Public, + addresses: crate::remote::portrait::AddressPolicy::Public, }; // Absent is empty; a value that is not UTF-8 refuses to boot, rather // than starting with no trusted proxy and counting every client behind @@ -279,7 +279,7 @@ impl Config { // Parsed here rather than at first use: a spec that cannot be parsed — // a recursive `albumid` above all — would otherwise re-identify the // whole catalogue silently at the next scan. Refusing to boot says so. - let pid = crate::pid::PidSpecs { + let pid = crate::library::pid::PidSpecs { album: parse_pid_spec( "WAVEFLOW_PID_ALBUM", DEFAULT_PID_ALBUM, diff --git a/src/config/metadata.rs b/src/config/metadata.rs index ef3638fd..210d1ed6 100644 --- a/src/config/metadata.rs +++ b/src/config/metadata.rs @@ -18,7 +18,7 @@ pub struct MetadataSettings { /// `WAVEFLOW_METADATA_PROVIDERS`: an ordered, comma-separated list, empty /// by default, of `lastfm`, `deezer` and `theaudiodb`. The order is the /// order fields are merged in when read. - pub providers: Vec, + pub providers: Vec, /// `WAVEFLOW_METADATA_IMAGES`, on by default, `off` to keep the lookups and /// refuse decision 8's image fetch. A narrowing only: it switches nothing /// on while `providers` is empty. @@ -95,7 +95,7 @@ impl std::fmt::Debug for MetadataSettings { #[derive(Debug, Clone)] pub struct LyricsSettings { /// `WAVEFLOW_LYRICS_PROVIDERS`: empty by default, or `lrclib`. - pub providers: Vec, + pub providers: Vec, /// `WAVEFLOW_LYRICS_TTL_DAYS`: how long lyrics found are kept. pub ttl: Duration, /// `WAVEFLOW_LYRICS_MISS_TTL_DAYS`: how long "nothing for this track" is @@ -179,14 +179,14 @@ pub(super) fn parse_lyrics_settings() -> anyhow::Result { /// to boot, for the reason [`parse_metadata_providers`] gives. pub(super) fn parse_lyrics_providers( list: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { let mut providers = Vec::new(); for name in list .split(',') .map(str::trim) .filter(|name| !name.is_empty()) { - let provider = crate::lrclib::LyricsProvider::parse(name) + let provider = crate::remote::lrclib::LyricsProvider::parse(name) .ok_or_else(|| anyhow::anyhow!("invalid WAVEFLOW_LYRICS_PROVIDERS entry {name:?}"))?; if providers.contains(&provider) { anyhow::bail!("WAVEFLOW_LYRICS_PROVIDERS names {name:?} twice"); @@ -207,24 +207,24 @@ pub(super) fn parse_metadata_providers( list: &str, has_lastfm_key: bool, has_theaudiodb_key: bool, -) -> anyhow::Result> { +) -> anyhow::Result> { let mut providers = Vec::new(); for name in list .split(',') .map(str::trim) .filter(|name| !name.is_empty()) { - let provider = crate::enrichment::Provider::parse(name) + let provider = crate::remote::enrichment::Provider::parse(name) .ok_or_else(|| anyhow::anyhow!("invalid WAVEFLOW_METADATA_PROVIDERS entry {name:?}"))?; if providers.contains(&provider) { anyhow::bail!("WAVEFLOW_METADATA_PROVIDERS names {name:?} twice"); } - if provider == crate::enrichment::Provider::LastFm && !has_lastfm_key { + if provider == crate::remote::enrichment::Provider::LastFm && !has_lastfm_key { anyhow::bail!( "WAVEFLOW_METADATA_PROVIDERS names lastfm without WAVEFLOW_METADATA_LASTFM_KEY" ); } - if provider == crate::enrichment::Provider::TheAudioDb && !has_theaudiodb_key { + if provider == crate::remote::enrichment::Provider::TheAudioDb && !has_theaudiodb_key { anyhow::bail!( "WAVEFLOW_METADATA_PROVIDERS names theaudiodb without WAVEFLOW_METADATA_THEAUDIODB_KEY" ); diff --git a/src/config/mod.rs b/src/config/mod.rs index 28d2606a..7687fdad 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -116,7 +116,7 @@ pub struct Config { /// `WAVEFLOW_SUBSONIC_REPLAYGAIN`: `off`, the default, `track` or /// `album`. A request overrides it with `replayGain=`; the original file /// is never transcoded to apply it. - pub subsonic_replay_gain: crate::replay_gain::ReplayGainMode, + pub subsonic_replay_gain: crate::media::replay_gain::ReplayGainMode, pub ffmpeg_path: PathBuf, pub ffprobe_path: PathBuf, pub transcode_cache_dir: PathBuf, @@ -265,7 +265,7 @@ pub struct Config { /// instance configured differently from the run that built its catalogue /// schedules a full rescan rather than serving a catalogue keyed under a /// rule it no longer follows. - pub pid: crate::pid::PidSpecs, + pub pid: crate::library::pid::PidSpecs, } impl std::fmt::Debug for Config { diff --git a/src/config/pid.rs b/src/config/pid.rs index fa5310f5..47899665 100644 --- a/src/config/pid.rs +++ b/src/config/pid.rs @@ -30,18 +30,19 @@ pub(super) fn parse_pid_spec( name: &str, default: &str, kind: PidSpecKind, -) -> anyhow::Result { +) -> anyhow::Result { let raw = std::env::var(name).unwrap_or_else(|_| default.to_owned()); - crate::pid::PidSpec::parse(&raw, kind.allows_album_id()) + crate::library::pid::PidSpec::parse(&raw, kind.allows_album_id()) .map_err(|error| anyhow::anyhow!("invalid {name}: {error}")) } -fn default_pid_spec(default: &str, kind: PidSpecKind) -> crate::pid::PidSpec { - crate::pid::PidSpec::parse(default, kind.allows_album_id()).expect("built-in pid spec default") +fn default_pid_spec(default: &str, kind: PidSpecKind) -> crate::library::pid::PidSpec { + crate::library::pid::PidSpec::parse(default, kind.allows_album_id()) + .expect("built-in pid spec default") } -pub(super) fn default_pid_specs() -> crate::pid::PidSpecs { - crate::pid::PidSpecs { +pub(super) fn default_pid_specs() -> crate::library::pid::PidSpecs { + crate::library::pid::PidSpecs { album: default_pid_spec(DEFAULT_PID_ALBUM, PidSpecKind::MayNotReferenceAlbumId), track: default_pid_spec(DEFAULT_PID_TRACK, PidSpecKind::MayReferenceAlbumId), artist: default_pid_spec(DEFAULT_PID_ARTIST, PidSpecKind::MayNotReferenceAlbumId), diff --git a/src/config/podcasts.rs b/src/config/podcasts.rs index 3b7d1412..30418b95 100644 --- a/src/config/podcasts.rs +++ b/src/config/podcasts.rs @@ -45,7 +45,7 @@ pub struct PodcastSettings { /// Which addresses a fetch may reach. Public ones in production, always; /// not an environment variable. The test suite's feeds live on loopback, /// and its fixture admits loopback for them alone. - pub addresses: crate::portrait::AddressPolicy, + pub addresses: crate::remote::portrait::AddressPolicy, } /// Remote artwork: the cover a podcast feed names, and the image an account @@ -65,7 +65,7 @@ pub struct RemoteArtworkSettings { pub timeout: Duration, /// Which addresses a fetch may reach, as for podcasts: public ones in /// production, always, and loopback as well for the test suite's hosts. - pub addresses: crate::portrait::AddressPolicy, + pub addresses: crate::remote::portrait::AddressPolicy, } /// The podcast switch and its bounds (RFC-025 decision 7). @@ -106,6 +106,6 @@ pub(super) fn parse_podcast_settings( 1_800u64, )?), dir: data_dir.join("podcasts"), - addresses: crate::portrait::AddressPolicy::Public, + addresses: crate::remote::portrait::AddressPolicy::Public, }) } diff --git a/src/config/scrobbling.rs b/src/config/scrobbling.rs index 390875d9..1326a109 100644 --- a/src/config/scrobbling.rs +++ b/src/config/scrobbling.rs @@ -150,9 +150,9 @@ pub(super) fn test_destinations() -> Vec { ] .into_iter() .map(|(provider, base)| { - let url = crate::scrobblers::validate_destination(base, true) + let url = crate::remote::scrobblers::validate_destination(base, true) .expect("a loopback destination nothing is listening on"); - let fingerprint = crate::scrobblers::destination_fingerprint(&url); + let fingerprint = crate::remote::scrobblers::destination_fingerprint(&url); ScrobbleDestination { provider, name: DEFAULT_SCROBBLE_DESTINATION.to_owned(), @@ -214,14 +214,14 @@ pub(super) fn parse_destinations( Some((name, raw)) => (name.trim(), raw.trim()), None => (DEFAULT_SCROBBLE_DESTINATION, entry), }; - crate::scrobblers::validate_destination_name(name) + crate::remote::scrobblers::validate_destination_name(name) .map_err(|error| anyhow::anyhow!("invalid {variable} entry {name:?}: {error}"))?; // **Never the URL itself in the message.** `validate_destination` // refuses a destination carrying credentials, so the one thing this // branch is most likely to be handed is the one thing that must not be // printed: `http://user:secret@host` would land in a startup log, or a // support paste, in full. The name and the variant say enough. - let url = crate::scrobblers::validate_destination(raw, allow_plaintext) + let url = crate::remote::scrobblers::validate_destination(raw, allow_plaintext) .map_err(|error| anyhow::anyhow!("invalid {variable} entry {name:?}: {error}"))?; if destinations.iter().any(|existing| existing.name == name) { anyhow::bail!("{variable} declares {name:?} twice"); @@ -229,7 +229,7 @@ pub(super) fn parse_destinations( // Two names for one machine would give a member two ways to reach the // same profile and two links to keep in step, and the second would look // like a different destination in every counter. - let fingerprint = crate::scrobblers::destination_fingerprint(&url); + let fingerprint = crate::remote::scrobblers::destination_fingerprint(&url); if let Some(twin) = destinations .iter() .find(|existing| existing.fingerprint == fingerprint) diff --git a/src/config/tests.rs b/src/config/tests.rs index 51a85102..01e441a2 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -37,7 +37,7 @@ fn trusted_proxies_are_read_strictly() { /// than starting enrichment narrower than the operator wrote. #[test] fn metadata_providers_are_read_strictly() { - use crate::enrichment::Provider; + use crate::remote::enrichment::Provider; assert_eq!(parse_metadata_providers("", false, false).unwrap(), []); assert_eq!(parse_metadata_providers(" , ", false, false).unwrap(), []); @@ -72,7 +72,7 @@ fn metadata_providers_are_read_strictly() { /// rather than leaving lyrics quietly off. #[test] fn lyrics_providers_are_read_strictly() { - use crate::lrclib::LyricsProvider; + use crate::remote::lrclib::LyricsProvider; assert_eq!(super::metadata::parse_lyrics_providers("").unwrap(), []); assert_eq!(super::metadata::parse_lyrics_providers(" , ").unwrap(), []); diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 00000000..fd752c12 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,8 @@ +//! HTTP pieces outside the two API surfaces: the security headers on every +//! response, the public share page, the `/reference` page and the embedded +//! web client. + +pub mod public_share; +pub mod reference; +pub mod security_headers; +pub mod webui; diff --git a/src/http/public_share/mod.rs b/src/http/public_share/mod.rs index 9db9ea60..1aebe7d2 100644 --- a/src/http/public_share/mod.rs +++ b/src/http/public_share/mod.rs @@ -27,10 +27,10 @@ use serde_json::Value; use uuid::Uuid; use crate::{ - authentication::now_ms, + auth::authentication::now_ms, + auth::stream_ticket::{self, TicketKind}, media::{OutputFormat, StreamQuery}, services::ServiceError, - stream_ticket::{self, TicketKind}, AppState, }; @@ -76,7 +76,7 @@ pub fn router(state: AppState, request_timeout: std::time::Duration) -> Router { .route("/share/{token}/tracks/{track_id}/cover", get(cover)) .merge(opening) .layer(axum::middleware::map_response( - crate::security_headers::share, + crate::http::security_headers::share, )) .with_state(state) } @@ -151,7 +151,7 @@ async fn page(state: &AppState, token: &str) -> Response { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; - let mut response = crate::webui::share_page(); + let mut response = crate::http::webui::share_page(); if !open && response.status() == StatusCode::OK { *response.status_mut() = StatusCode::NOT_FOUND; } diff --git a/src/http/reference/mod.rs b/src/http/reference/mod.rs index d24e8f33..9f0c4277 100644 --- a/src/http/reference/mod.rs +++ b/src/http/reference/mod.rs @@ -29,7 +29,7 @@ //! in the repository and the binary. Every browser that can run the page //! accepts gzip; a client that does not is handed the bytes decompressed. //! -//! **Under a policy of its own** ([`crate::security_headers::reference_policy`]): +//! **Under a policy of its own** ([`crate::http::security_headers::reference_policy`]): //! scripts from this origin only, and no connection anywhere else. use std::{io::Read as _, sync::OnceLock}; @@ -50,7 +50,7 @@ pub const SCALAR_VERSION: &str = "1.73.1"; pub const SCALAR_SHA256: &str = "62c34a0dda38945864011ebaf6e7fa7812a332429120d18922b34d01c4f7ccc8"; /// The bundle, as `gzip -9n` left it. -static SCALAR_GZIP: &[u8] = include_bytes!("../assets/scalar/api-reference-1.73.1.js.gz"); +static SCALAR_GZIP: &[u8] = include_bytes!("../../../assets/scalar/api-reference-1.73.1.js.gz"); /// Where the page loads the bundle from. Versioned, so it can be cached for /// good. @@ -119,7 +119,10 @@ fn serve_page(page: Bytes) -> Response { let mut response = ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], page).into_response(); let headers = response.headers_mut(); headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); - crate::security_headers::apply_reference(headers, &crate::security_headers::reference_policy()); + crate::http::security_headers::apply_reference( + headers, + &crate::http::security_headers::reference_policy(), + ); response } @@ -178,7 +181,7 @@ async fn serve_script(request_headers: HeaderMap) -> Response { if gzip { headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")); } - crate::security_headers::apply_reference( + crate::http::security_headers::apply_reference( headers, &HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"), ); diff --git a/src/http/security_headers/mod.rs b/src/http/security_headers/mod.rs index cfc2d2f4..4f7bac30 100644 --- a/src/http/security_headers/mod.rs +++ b/src/http/security_headers/mod.rs @@ -3,11 +3,11 @@ //! //! Three surfaces carry them: //! -//! - **the embedded web client** ([`crate::webui`]), every response of the +//! - **the embedded web client** ([`crate::http::webui`]), every response of the //! router fallback: the shell, its assets, and its refusals; //! - **the public shares** under `/share/`, whose token *is* the path, so a //! referrer would hand it to whatever host a page linked to; -//! - **the API reference** at `/reference` ([`crate::reference`]), since +//! - **the API reference** at `/reference` ([`crate::http::reference`]), since //! RFC-023 serves its script from the binary and can therefore hold it to a //! policy that names no other host. //! diff --git a/src/http/webui.rs b/src/http/webui.rs index f941134d..bca2a195 100644 --- a/src/http/webui.rs +++ b/src/http/webui.rs @@ -6,7 +6,7 @@ //! client-side route resolves by returning the shell. //! //! Every response here carries the web client's security headers, a -//! Content-Security-Policy among them ([`crate::security_headers`]). +//! Content-Security-Policy among them ([`crate::http::security_headers`]). use axum::{ body::Body, @@ -73,7 +73,7 @@ const ASSET_ONLY_PREFIXES: [&str; 2] = ["icons/", "brand/"]; pub async fn handler(uri: Uri) -> Response { let mut response = respond(uri.path().trim_start_matches('/')); - crate::security_headers::apply_web_client(response.headers_mut(), policy()); + crate::http::security_headers::apply_web_client(response.headers_mut(), policy()); response } @@ -83,7 +83,7 @@ pub async fn handler(uri: Uri) -> Response { fn policy() -> &'static HeaderValue { static POLICY: OnceLock = OnceLock::new(); POLICY.get_or_init(|| { - HeaderValue::from_str(&crate::security_headers::web_client_policy( + HeaderValue::from_str(&crate::http::security_headers::web_client_policy( &embedded_shell(), )) // A digest is base64 and every directive is ASCII, so this cannot @@ -124,12 +124,12 @@ fn shell() -> Response { /// client by accident, and the one address that does is chosen by its own /// route. It carries the client's policy, which the shell's inline blocks need /// to run, with framing refused as on every share response -/// ([`crate::security_headers::share_page_policy`]); the share route's own +/// ([`crate::http::security_headers::share_page_policy`]); the share route's own /// layer adds the rest of the share headers. pub fn share_page() -> Response { static POLICY: OnceLock = OnceLock::new(); let policy = POLICY.get_or_init(|| { - HeaderValue::from_str(&crate::security_headers::share_page_policy( + HeaderValue::from_str(&crate::http::security_headers::share_page_policy( &embedded_shell(), )) .unwrap_or(HeaderValue::from_static("default-src 'none'")) diff --git a/src/lib.rs b/src/lib.rs index 71928351..42c6ac74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,42 +2,17 @@ pub mod api; mod app; -pub mod authentication; -pub mod catalog; +pub mod auth; pub mod cli; pub mod config; -pub mod credit_split; -pub mod database; -pub mod enrichment; -pub mod link_roots; -pub mod lrclib; -pub mod lyrics; +pub mod http; +pub mod library; pub mod media; -pub mod migration_backup; -pub mod natural_order; -pub mod oauth; -pub mod outbound; -pub mod pid; -pub mod playlist_file; -pub mod podcast; -pub mod portrait; -pub mod public_share; pub mod quality; -pub mod reference; -pub mod replay_gain; -pub mod scanner; -pub mod scrobblers; -pub mod security; -pub mod security_headers; +pub mod remote; pub mod services; -pub mod setup_token; -pub mod sign_in; -pub mod stream_ticket; +pub mod storage; pub mod subsonic; -pub mod sync; -pub mod tags; -pub mod watcher; -pub mod webui; pub use app::{app, initialize, shutdown_signal, ApiDoc, AppState, OPENAPI_JSON_PATH, SCALAR_PATH}; pub use config::Config; diff --git a/src/library/credit_split/mod.rs b/src/library/credit_split/mod.rs index b0c2a581..f434c196 100644 --- a/src/library/credit_split/mod.rs +++ b/src/library/credit_split/mod.rs @@ -1,6 +1,6 @@ //! Splitting a joined artist credit, by decision (RFC-015). //! -//! The tag mapper (`crate::tags`) cuts an artist value on the separators no +//! The tag mapper (`crate::library::tags`) cuts an artist value on the separators no //! band name carries. A comma is not one of them — `Tyler, The Creator` is one //! artist, `Zedd, Alessia Cara` two — so a credit holding one is left whole, //! and an owner decides. This is the second step, applied to each name the @@ -19,7 +19,7 @@ use std::collections::{HashMap, HashSet}; use uuid::Uuid; -use crate::tags::{Credit, Role}; +use crate::library::tags::{Credit, Role}; /// The characters a joined credit is cut on: the desktop's set, ASCII and /// fullwidth commas and the two ideographic ones. Unpadded, as the desktop's @@ -31,7 +31,7 @@ pub const FRAGMENT_SEPARATORS: [char; 4] = [',', ',', '、', '、']; /// artist row under the default spec — and survives a change of spec, which /// an artist id would not. pub fn credit_key(name: &str) -> String { - crate::pid::str_clear(&name.trim().to_lowercase()) + crate::library::pid::str_clear(&name.trim().to_lowercase()) } /// The name cut on [`FRAGMENT_SEPARATORS`], never inside parentheses, trimmed, diff --git a/src/library/mod.rs b/src/library/mod.rs new file mode 100644 index 00000000..1eed2637 --- /dev/null +++ b/src/library/mod.rs @@ -0,0 +1,12 @@ +//! Reading the music on disk: the scanner and the watcher that wakes it, the +//! tag mapper, the identifiers derived from tags, credit splitting, the roots a +//! followed link may reach, playlist files and lyrics sheets. + +pub mod credit_split; +pub mod link_roots; +pub mod lyrics; +pub mod pid; +pub mod playlist_file; +pub mod scanner; +pub mod tags; +pub mod watcher; diff --git a/src/library/scanner/discovery/mod.rs b/src/library/scanner/discovery/mod.rs index aab1bc6a..882393d9 100644 --- a/src/library/scanner/discovery/mod.rs +++ b/src/library/scanner/discovery/mod.rs @@ -175,7 +175,7 @@ impl Reach<'_> { /// Whether a followed link may lead to `target`, a canonical path. fn admits_target(&self, target: &Path) -> bool { target.starts_with(self.root) - || crate::link_roots::admits(self.link_roots, self.libraries, target) + || crate::library::link_roots::admits(self.link_roots, self.libraries, target) } /// What the walk reached at `path`: the canonical path to read it from, @@ -196,7 +196,7 @@ impl Reach<'_> { let name = relative_to(self.root, &canonical); return Some((canonical, name)); } - (crate::link_roots::admits(self.link_roots, self.libraries, &canonical) + (crate::library::link_roots::admits(self.link_roots, self.libraries, &canonical) && path.starts_with(self.root)) .then(|| (canonical, relative_to(self.root, path))) } @@ -654,10 +654,10 @@ impl Walked<'_> { /// a relocation is only recorded when exactly one thing did, because moving a /// favourite onto the wrong row is worse than not moving it at all. pub(super) fn unclaimed( - candidates: Vec, + candidates: Vec, walked: &Walked<'_>, claimed: &HashSet, -) -> Vec { +) -> Vec { candidates .into_iter() .filter(|candidate| { diff --git a/src/library/scanner/extract/mod.rs b/src/library/scanner/extract/mod.rs index d25b7ec9..e49f102d 100644 --- a/src/library/scanner/extract/mod.rs +++ b/src/library/scanner/extract/mod.rs @@ -10,8 +10,8 @@ use lofty::{ use uuid::Uuid; use crate::{ - catalog::{ArtworkInput, CatalogTrackInput}, - lyrics::{self, LyricsInput}, + library::lyrics::{self, LyricsInput}, + storage::catalog::{ArtworkInput, CatalogTrackInput}, }; #[cfg(test)] @@ -38,7 +38,7 @@ pub(super) enum Scanned { pub(super) fn scan_file( path: &Path, artwork_dir: &Path, - known: Option, + known: Option, full: bool, ) -> Scanned { let signature = sidecar_signature(path); @@ -393,11 +393,11 @@ struct ExtendedTags { /// `PERFORMER` is the one role whose credit carries a second value. ID3 stores /// it as a musician-credits frame lofty exposes only through its own tag type, /// so what reaches here is the generic spelling — `Name (instrument)` — which -/// [`crate::tags::split_performer`] takes apart. -fn raw_credits(tag: Option<&lofty::tag::Tag>) -> crate::tags::RawCredits { - use crate::tags::Role; +/// [`crate::library::tags::split_performer`] takes apart. +fn raw_credits(tag: Option<&lofty::tag::Tag>) -> crate::library::tags::RawCredits { + use crate::library::tags::Role; let Some(tag) = tag else { - return crate::tags::RawCredits::default(); + return crate::library::tags::RawCredits::default(); }; let values = |key: ItemKey| -> Vec { tag.get_strings(key) @@ -438,10 +438,10 @@ fn raw_credits(tag: Option<&lofty::tag::Tag>) -> crate::tags::RawCredits { let performer_pairs = values(ItemKey::Performer) .into_iter() - .map(|value| crate::tags::split_performer(&value)) + .map(|value| crate::library::tags::split_performer(&value)) .collect(); - crate::tags::RawCredits { + crate::library::tags::RawCredits { artist: values(ItemKey::TrackArtist), artists: values(ItemKey::TrackArtists), album_artist: values(ItemKey::AlbumArtist), diff --git a/src/library/scanner/job.rs b/src/library/scanner/job.rs index 1921c534..4db5fa94 100644 --- a/src/library/scanner/job.rs +++ b/src/library/scanner/job.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use super::discovery::{discover_audio, unclaimed, Discovery, Reach, Walked}; use super::extract::{scan_file, Scanned}; use super::{normalised_scope, ScanManager, ScanProgress}; -use crate::catalog::{ApplyOutcome, CatalogApply, LibraryRecord}; +use crate::storage::catalog::{ApplyOutcome, CatalogApply, LibraryRecord}; impl ScanManager { pub(super) async fn run(&self, scan_id: Uuid, library: LibraryRecord) -> anyhow::Result<()> { @@ -227,7 +227,7 @@ impl ScanManager { if existing.pid.is_none() { hints.push(( existing.id, - crate::catalog::track_pid( + crate::storage::catalog::track_pid( self.db.pid(), library.id, &input, @@ -263,7 +263,7 @@ impl ScanManager { self.db .relocation_candidates_by_pid( library.id, - crate::catalog::track_pid( + crate::storage::catalog::track_pid( self.db.pid(), library.id, &input, diff --git a/src/library/scanner/mod.rs b/src/library/scanner/mod.rs index e5a7fb5a..b212b6ba 100644 --- a/src/library/scanner/mod.rs +++ b/src/library/scanner/mod.rs @@ -15,8 +15,8 @@ use tokio::sync::{broadcast, watch, Mutex}; use uuid::Uuid; use crate::{ - catalog::{CatalogTrackInput, LibraryRecord, ScanJobRecord}, - database::Database, + storage::catalog::{CatalogTrackInput, LibraryRecord, ScanJobRecord}, + storage::database::Database, }; /// One reading of a scan in flight, as it travels between the scanning task and @@ -94,7 +94,7 @@ pub struct ScanManager { libraries: Arc>, /// The folders outside every library a followed link may reach into /// (`WAVEFLOW_LINK_ROOTS`). - link_roots: crate::link_roots::LinkRoots, + link_roots: crate::library::link_roots::LinkRoots, } /// One scan counted as running, for as long as this lives. @@ -150,13 +150,13 @@ impl ScanManager { in_flight: Arc::new(watch::Sender::new(0)), scanning: Arc::new(DashMap::new()), libraries: Arc::new(watch::Sender::new(0)), - link_roots: crate::link_roots::LinkRoots::default(), + link_roots: crate::library::link_roots::LinkRoots::default(), } } /// The folders outside every library that a library following links may /// reach into. - pub fn with_link_roots(mut self, link_roots: crate::link_roots::LinkRoots) -> Self { + pub fn with_link_roots(mut self, link_roots: crate::library::link_roots::LinkRoots) -> Self { self.link_roots = link_roots; self } diff --git a/src/library/watcher/mod.rs b/src/library/watcher/mod.rs index 61c65aac..e65a760f 100644 --- a/src/library/watcher/mod.rs +++ b/src/library/watcher/mod.rs @@ -27,7 +27,9 @@ use notify::{ use tokio::{sync::mpsc, time::Instant}; use uuid::Uuid; -use crate::{catalog::LibraryRecord, database::Database, scanner::ScanManager}; +use crate::{ + library::scanner::ScanManager, storage::catalog::LibraryRecord, storage::database::Database, +}; /// How often the watched set is re-read even when nothing in this process said /// it changed: the CLI adds and removes libraries from another process. @@ -89,7 +91,7 @@ impl Settle { /// The scan this settles into walks those folders only, which is what /// RFC-018's rules allow a scan that did not look everywhere: only what it /// walked may be marked missing, and relocation claims nothing outside it. - /// Past [`crate::scanner::MAX_SCOPE_FOLDERS`] folders the changes were + /// Past [`crate::library::scanner::MAX_SCOPE_FOLDERS`] folders the changes were /// everywhere, and the whole library is walked instead. pub fn touch_folder(&mut self, library_id: Uuid, folder: String, now: Instant) { let pending = self.pending.entry(library_id).or_insert(Pending { @@ -100,7 +102,7 @@ impl Settle { pending.last = now; if let Some(folders) = pending.folders.as_mut() { folders.insert(folder); - if folders.len() > crate::scanner::MAX_SCOPE_FOLDERS { + if folders.len() > crate::library::scanner::MAX_SCOPE_FOLDERS { pending.folders = None; } } diff --git a/src/library/watcher/tests.rs b/src/library/watcher/tests.rs index 817fdefc..85d0366f 100644 --- a/src/library/watcher/tests.rs +++ b/src/library/watcher/tests.rs @@ -203,7 +203,7 @@ fn a_settled_library_names_the_folders_its_changes_were_in() { settle.touch_folder(whole, "Rock".into(), start); settle.touch(whole, start); settle.touch_folder(whole, "Jazz".into(), start); - for index in 0..=crate::scanner::MAX_SCOPE_FOLDERS { + for index in 0..=crate::library::scanner::MAX_SCOPE_FOLDERS { settle.touch_folder(crowded, format!("f{index}"), start); } let mut due = settle.due_with_scope(start + delay, |_| false); diff --git a/src/main.rs b/src/main.rs index 3b39bf02..204db17d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,11 +56,11 @@ async fn serve(config: Config, state: waveflow_server::AppState) -> anyhow::Resu // output is not the place for it. Where to find the token, never the token // (RFC-019 decision 1). match state.setup.source() { - Some(waveflow_server::setup_token::TokenSource::File(path)) => tracing::warn!( + Some(waveflow_server::auth::setup_token::TokenSource::File(path)) => tracing::warn!( path = %path.display(), "no account exists yet: the first administrator is created with the setup token stored in this file" ), - Some(waveflow_server::setup_token::TokenSource::Operator) => tracing::warn!( + Some(waveflow_server::auth::setup_token::TokenSource::Operator) => tracing::warn!( "no account exists yet: the first administrator is created with the value of WAVEFLOW_SETUP_TOKEN" ), None => {} @@ -68,7 +68,7 @@ async fn serve(config: Config, state: waveflow_server::AppState) -> anyhow::Resu state.scanner.spawn_background(config.scan_interval); // A change scanned once it settles, on top of the interval (RFC-021). if config.watch { - waveflow_server::watcher::spawn( + waveflow_server::library::watcher::spawn( state.scanner.clone(), state.db.clone(), config.watch_delay, diff --git a/src/media/canvas.rs b/src/media/canvas.rs index 64358ebe..72a88762 100644 --- a/src/media/canvas.rs +++ b/src/media/canvas.rs @@ -232,14 +232,14 @@ pub async fn create_canvas_ticket( .ok_or(MediaError::NotFound)?; let expires_at = i64::try_from(state.stream_ticket_ttl.as_millis()) .ok() - .and_then(|ttl| crate::authentication::now_ms().checked_add(ttl)) + .and_then(|ttl| crate::auth::authentication::now_ms().checked_add(ttl)) .ok_or_else(|| { tracing::error!("stream ticket TTL does not fit in an epoch timestamp"); MediaError::Internal })?; - let ticket = crate::stream_ticket::mint( + let ticket = crate::auth::stream_ticket::mint( &state.secret_box, - crate::stream_ticket::TicketKind::Canvas, + crate::auth::stream_ticket::TicketKind::Canvas, user.id, track_id, expires_at, @@ -276,10 +276,13 @@ pub async fn canvas_with_ticket( // This route serves a canvas, and it says so. Without the assertion an // audio ticket would open a canvas and the reverse — which is the whole // reason the kind is inside the sealed payload. - let (_, user_id, track_id) = - crate::stream_ticket::verify(&state.secret_box, &ticket, crate::authentication::now_ms()) - .filter(|(kind, ..)| *kind == crate::stream_ticket::TicketKind::Canvas) - .ok_or(MediaError::NotFound)?; + let (_, user_id, track_id) = crate::auth::stream_ticket::verify( + &state.secret_box, + &ticket, + crate::auth::authentication::now_ms(), + ) + .filter(|(kind, ..)| *kind == crate::auth::stream_ticket::TicketKind::Canvas) + .ok_or(MediaError::NotFound)?; // Re-checked on every redemption, so a ticket cannot outlive the membership // that justified it. let blob = state diff --git a/src/media/mod.rs b/src/media/mod.rs index db6ce7f5..07f21fae 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -2,6 +2,7 @@ mod artwork; mod canvas; +pub mod replay_gain; mod stream; #[cfg(test)] pub(crate) mod tests; @@ -45,7 +46,7 @@ use tokio::{ }; use uuid::Uuid; -use crate::{config::Config, replay_gain::ReplayGainMode, AppState}; +use crate::{config::Config, media::replay_gain::ReplayGainMode, AppState}; /// Audio is tenant-scoped and reached through a bearer token or a stream /// ticket, so the same URL means different things to different callers. No @@ -109,7 +110,7 @@ struct MediaInner { ffmpeg_digest: String, /// The folders outside every library a stored path may lead into through /// a link (`WAVEFLOW_LINK_ROOTS`). - link_roots: crate::link_roots::LinkRoots, + link_roots: crate::library::link_roots::LinkRoots, /// What a Subsonic transcode applies of ReplayGain unless the request /// says otherwise (`WAVEFLOW_SUBSONIC_REPLAYGAIN`). subsonic_replay_gain: ReplayGainMode, @@ -196,7 +197,7 @@ impl MediaService { cache_locks: DashMap::new(), cache_access: DashMap::new(), pending_fills: DashSet::new(), - link_roots: crate::link_roots::LinkRoots::new(config.link_roots.clone()), + link_roots: crate::library::link_roots::LinkRoots::new(config.link_roots.clone()), subsonic_replay_gain: config.subsonic_replay_gain, active_transcodes: AtomicUsize::new(0), transcoding_available: true, @@ -372,7 +373,7 @@ pub fn router(state: AppState) -> Router { async fn bearer_principal( state: &AppState, headers: &HeaderMap, -) -> Result { +) -> Result { crate::api::authenticated(state, headers, crate::api::Access::Read) .await .map_err(|_| MediaError::Unauthorized) diff --git a/src/media/stream.rs b/src/media/stream.rs index eab18b51..daaf072e 100644 --- a/src/media/stream.rs +++ b/src/media/stream.rs @@ -27,7 +27,7 @@ use super::transcode::{cache_key, extension, fill_deadline, mime_for_format}; use super::{ bearer_principal, MediaError, MediaService, OutputFormat, StreamQuery, MEDIA_CACHE_CONTROL, }; -use crate::{catalog::StreamTrack, replay_gain::ReplayGainMode, AppState}; +use crate::{media::replay_gain::ReplayGainMode, storage::catalog::StreamTrack, AppState}; const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024; @@ -73,7 +73,7 @@ impl MediaService { range: Option<&str>, mode: ReplayGainMode, ) -> Result { - let gain = crate::replay_gain::gain(track.replay_gain, mode); + let gain = crate::media::replay_gain::gain(track.replay_gain, mode); if !track.available { return Err(MediaError::NotFound); } @@ -230,14 +230,14 @@ pub async fn create_stream_ticket( // cannot be represented is a misconfiguration, not something to round off. let expires_at = i64::try_from(state.stream_ticket_ttl.as_millis()) .ok() - .and_then(|ttl| crate::authentication::now_ms().checked_add(ttl)) + .and_then(|ttl| crate::auth::authentication::now_ms().checked_add(ttl)) .ok_or_else(|| { tracing::error!("stream ticket TTL does not fit in an epoch timestamp"); MediaError::Internal })?; - let ticket = crate::stream_ticket::mint( + let ticket = crate::auth::stream_ticket::mint( &state.secret_box, - crate::stream_ticket::TicketKind::Audio, + crate::auth::stream_ticket::TicketKind::Audio, user.id, track_id, expires_at, @@ -289,10 +289,13 @@ pub async fn stream_with_ticket( // track, so probing tells an attacker nothing. A ticket of another kind // joins them: this route serves audio, and it says so rather than trusting // that only audio tickets reach it. - let (_, user_id, track_id) = - crate::stream_ticket::verify(&state.secret_box, &ticket, crate::authentication::now_ms()) - .filter(|(kind, ..)| *kind == crate::stream_ticket::TicketKind::Audio) - .ok_or(MediaError::NotFound)?; + let (_, user_id, track_id) = crate::auth::stream_ticket::verify( + &state.secret_box, + &ticket, + crate::auth::authentication::now_ms(), + ) + .filter(|(kind, ..)| *kind == crate::auth::stream_ticket::TicketKind::Audio) + .ok_or(MediaError::NotFound)?; let track = state .services .stream_source_for_user(user_id, track_id) @@ -473,8 +476,8 @@ pub async fn stream_track( pub(crate) async fn secure_track_path( root: &Path, relative: &str, - link_roots: &crate::link_roots::LinkRoots, - policy: &crate::link_roots::LinkPolicy, + link_roots: &crate::library::link_roots::LinkRoots, + policy: &crate::library::link_roots::LinkPolicy, ) -> io::Result { let canonical_root = tokio::fs::canonicalize(root).await?; let relative = Path::new(relative); @@ -517,7 +520,7 @@ pub(crate) async fn secure_track_path( let (link_roots, policy, target) = (link_roots.clone(), policy.clone(), canonical_file.clone()); tokio::task::spawn_blocking(move || { - crate::link_roots::admits( + crate::library::link_roots::admits( &link_roots.resolved(), &policy.other_libraries_resolved(), &target, diff --git a/src/media/tests.rs b/src/media/tests.rs index 781d3c47..028a2695 100644 --- a/src/media/tests.rs +++ b/src/media/tests.rs @@ -82,8 +82,8 @@ async fn media_paths_reject_parent_components_and_symlinks() { let root = temp.path().join("library"); std::fs::create_dir_all(&root).unwrap(); std::fs::write(root.join("safe.wav"), b"audio").unwrap(); - let none = crate::link_roots::LinkRoots::default(); - let policy = crate::link_roots::LinkPolicy::default(); + let none = crate::library::link_roots::LinkRoots::default(); + let policy = crate::library::link_roots::LinkPolicy::default(); assert!(secure_track_path(&root, "safe.wav", &none, &policy) .await .is_ok()); @@ -139,8 +139,8 @@ async fn a_link_is_followed_into_an_allowed_folder_only() { eprintln!("skipped: this system will not let the test make a symbolic link"); return; } - let allowed = crate::link_roots::LinkRoots::new(vec![farm.clone()]); - let following = crate::link_roots::LinkPolicy { + let allowed = crate::library::link_roots::LinkRoots::new(vec![farm.clone()]); + let following = crate::library::link_roots::LinkPolicy { follow_links: true, other_libraries: vec![neighbour.clone()], }; @@ -162,11 +162,11 @@ async fn a_link_is_followed_into_an_allowed_folder_only() { .is_err(), "a link inside the root is stored by its target, never by itself" ); - let none = crate::link_roots::LinkRoots::default(); + let none = crate::library::link_roots::LinkRoots::default(); assert!(secure_track_path(&root, "farmed.wav", &none, &following) .await .is_err()); - let not_following = crate::link_roots::LinkPolicy { + let not_following = crate::library::link_roots::LinkPolicy { follow_links: false, ..following }; @@ -203,8 +203,8 @@ async fn cache_pruning_removes_the_least_recently_used_file() { active_transcodes: std::sync::atomic::AtomicUsize::new(0), transcoding_available: true, ffmpeg_digest: String::new(), - link_roots: crate::link_roots::LinkRoots::default(), - subsonic_replay_gain: crate::replay_gain::ReplayGainMode::Off, + link_roots: crate::library::link_roots::LinkRoots::default(), + subsonic_replay_gain: crate::media::replay_gain::ReplayGainMode::Off, }; prune_cache(&inner).await; assert!(!old.exists()); @@ -352,8 +352,8 @@ fn a_media_service(dir: &std::path::Path) -> MediaService { active_transcodes: std::sync::atomic::AtomicUsize::new(0), transcoding_available: true, ffmpeg_digest: String::new(), - link_roots: crate::link_roots::LinkRoots::default(), - subsonic_replay_gain: crate::replay_gain::ReplayGainMode::Off, + link_roots: crate::library::link_roots::LinkRoots::default(), + subsonic_replay_gain: crate::media::replay_gain::ReplayGainMode::Off, }), } } diff --git a/src/media/transcode.rs b/src/media/transcode.rs index 94577dcd..e5a7bc58 100644 --- a/src/media/transcode.rs +++ b/src/media/transcode.rs @@ -29,7 +29,7 @@ use tokio_stream::wrappers::ReceiverStream; use uuid::Uuid; use super::{file_input, MediaError, MediaInner, MediaService, OutputFormat, MEDIA_CACHE_CONTROL}; -use crate::replay_gain::Gain; +use crate::media::replay_gain::Gain; const STREAM_CHUNK_SIZE: usize = 64 * 1024; const TRANSCODE_PROFILE_VERSION: &str = "waveflow-ffmpeg-v1"; diff --git a/src/remote/enrichment/mod.rs b/src/remote/enrichment/mod.rs index 14e29bad..543eecba 100644 --- a/src/remote/enrichment/mod.rs +++ b/src/remote/enrichment/mod.rs @@ -37,8 +37,8 @@ use waveflow_core::metadata::{ use crate::{ config::MetadataSettings, - database::Database, - portrait::{self, AddressPolicy, Refusal}, + remote::portrait::{self, AddressPolicy, Refusal}, + storage::database::Database, }; /// Last.fm's API root. A constant, not a setting (decision 9): there is no @@ -325,7 +325,7 @@ impl Enrichment { ) -> anyhow::Result { let mut sources = Vec::new(); for &provider in &settings.providers { - let http = crate::outbound::client(settings.request_timeout) + let http = crate::remote::outbound::client(settings.request_timeout) .map_err(|_| anyhow::anyhow!("the outbound client could not be built"))?; let client = match provider { Provider::LastFm => { @@ -507,7 +507,7 @@ impl Enrichment { return Ok(Vec::new()); } let rows = engine.db.metadata_artist_rows(&canonical).await?; - let now = crate::authentication::now_ms(); + let now = crate::auth::authentication::now_ms(); let mut ordered = Vec::new(); for (index, source) in engine.sources.iter().enumerate() { let row = rows.iter().find(|row| row.provider == source.provider); @@ -608,7 +608,7 @@ impl Engine { return; } }; - let fetched_at = crate::authentication::now_ms(); + let fetched_at = crate::auth::authentication::now_ms(); let outcome = answer.outcome(); let ttl = match outcome { Outcome::Hit => self.ttl_ms, diff --git a/src/remote/lrclib/mod.rs b/src/remote/lrclib/mod.rs index 7574390a..09b7e04f 100644 --- a/src/remote/lrclib/mod.rs +++ b/src/remote/lrclib/mod.rs @@ -27,7 +27,7 @@ use std::{sync::Arc, time::Duration}; use dashmap::DashMap; use serde::Deserialize; -use crate::{config::LyricsSettings, database::Database, enrichment::Pace}; +use crate::{config::LyricsSettings, remote::enrichment::Pace, storage::database::Database}; /// LRCLIB's root. A constant, not a setting: there is no operator field, so /// there is no URL to validate. @@ -218,7 +218,7 @@ impl LyricsLookup { let Some(&provider) = settings.providers.first() else { return Ok(Self::disabled()); }; - let http = crate::outbound::builder(settings.request_timeout) + let http = crate::remote::outbound::builder(settings.request_timeout) .user_agent(USER_AGENT) .build() .map_err(|_| anyhow::anyhow!("the outbound client could not be built"))?; @@ -275,7 +275,7 @@ impl LyricsLookup { let Some(key) = track.key() else { return Ok(None); }; - let now = crate::authentication::now_ms(); + let now = crate::auth::authentication::now_ms(); if let Some(row) = engine .db .lyrics_lookup_row(engine.provider.as_str(), &key) @@ -380,7 +380,7 @@ impl Engine { return; } }; - let fetched_at = crate::authentication::now_ms(); + let fetched_at = crate::auth::authentication::now_ms(); let ttl = match answer.outcome { LyricsOutcome::Hit | LyricsOutcome::Instrumental => self.ttl_ms, LyricsOutcome::Miss => self.miss_ttl_ms, @@ -516,11 +516,11 @@ fn judge(record: Record, duration_ms: i64) -> Answer { /// (`outbound::read_bounded_body`), under the lyrics cap rather than its /// general one: a sheet is held to what an upload or a sidecar may weigh. async fn read_bounded(response: reqwest::Response, limit: usize) -> Result, Verdict> { - crate::outbound::read_bounded_body(response, limit) + crate::remote::outbound::read_bounded_body(response, limit) .await .map_err(|error| match error { - crate::outbound::BodyError::TooLarge => Verdict::TooLarge, - crate::outbound::BodyError::Unread => Verdict::Transport, + crate::remote::outbound::BodyError::TooLarge => Verdict::TooLarge, + crate::remote::outbound::BodyError::Unread => Verdict::Transport, }) } diff --git a/src/remote/mod.rs b/src/remote/mod.rs new file mode 100644 index 00000000..4c824913 --- /dev/null +++ b/src/remote/mod.rs @@ -0,0 +1,10 @@ +//! Everything that talks to another host: the outbound HTTP client, metadata +//! enrichment and the portraits it fetches, LRCLIB lyrics, podcast feeds and +//! the scrobbling adapters. + +pub mod enrichment; +pub mod lrclib; +pub mod outbound; +pub mod podcast; +pub mod portrait; +pub mod scrobblers; diff --git a/src/remote/podcast/mod.rs b/src/remote/podcast/mod.rs index 0caebc3c..8d25c7b2 100644 --- a/src/remote/podcast/mod.rs +++ b/src/remote/podcast/mod.rs @@ -32,7 +32,7 @@ use std::{ time::Duration, }; -use crate::portrait::AddressPolicy; +use crate::remote::portrait::AddressPolicy; /// How many redirects one fetch follows. Enclosures commonly pass through one /// or two measurement prefixes on their way to a CDN; five leaves room for @@ -182,7 +182,7 @@ pub async fn open(url: &url::Url, policy: FetchPolicy) -> Result FetchError::TooLarge, - crate::outbound::BodyError::Unread => FetchError::Unreached, + crate::remote::outbound::BodyError::TooLarge => FetchError::TooLarge, + crate::remote::outbound::BodyError::Unread => FetchError::Unreached, }) }; tokio::time::timeout(policy.timeout, within) diff --git a/src/remote/portrait/mod.rs b/src/remote/portrait/mod.rs index adeffff1..c47c8d97 100644 --- a/src/remote/portrait/mod.rs +++ b/src/remote/portrait/mod.rs @@ -237,7 +237,7 @@ async fn fetch_within( if plain && !pinned.ip().is_loopback() { return Err(Refusal::Destination); } - let client = crate::outbound::builder(timeout) + let client = crate::remote::outbound::builder(timeout) .resolve(&host, pinned) .build() .map_err(|_| Refusal::Unreached)?; @@ -291,11 +291,11 @@ async fn checked_image(response: reqwest::Response, rule: Declared) -> Result Refusal::TooLarge, - crate::outbound::BodyError::Unread => Refusal::Unreached, + crate::remote::outbound::BodyError::TooLarge => Refusal::TooLarge, + crate::remote::outbound::BodyError::Unread => Refusal::Unreached, })?; let format = sniff(&bytes).ok_or(Refusal::NotAnImage)?; if declared.is_some_and(|declared| declared != format) { @@ -314,19 +314,19 @@ async fn checked_image(response: reqwest::Response, rule: Declared) -> Result Result { let within = async { - let response = crate::podcast::open(url, policy) + let response = crate::remote::podcast::open(url, policy) .await .map_err(Refusal::from)?; checked_image(response, Declared::ImageOrUnlabelled).await @@ -336,9 +336,9 @@ pub async fn fetch_followed( .unwrap_or(Err(Refusal::Unreached)) } -impl From for Refusal { - fn from(error: crate::podcast::FetchError) -> Self { - use crate::podcast::FetchError; +impl From for Refusal { + fn from(error: crate::remote::podcast::FetchError) -> Self { + use crate::remote::podcast::FetchError; match error { FetchError::Destination => Self::Destination, FetchError::Address => Self::Address, diff --git a/src/remote/scrobblers/lastfm.rs b/src/remote/scrobblers/lastfm.rs index fd89b865..1730de8f 100644 --- a/src/remote/scrobblers/lastfm.rs +++ b/src/remote/scrobblers/lastfm.rs @@ -70,7 +70,7 @@ impl LastFm { /// /// Bounded twice, which is the whole of RFC-023 decision 1 for this path: /// in time by [`Self::deadline`], and in size by - /// [`crate::outbound::read_bounded_body`]. Either refusal is + /// [`crate::remote::outbound::read_bounded_body`]. Either refusal is /// `Unavailable`, a failure of the way to Last.fm rather than of what the /// person brought. /// @@ -100,13 +100,15 @@ impl LastFm { tracing::warn!(call = what, %status, "last.fm refused the call"); return Err(ServiceError::Invalid); } - let body = - crate::outbound::read_bounded_body(response, crate::outbound::MAX_ANSWER_BYTES) - .await - .map_err(|error| { - tracing::warn!(call = what, %error, "last.fm's answer was not read"); - ServiceError::Unavailable - })?; + let body = crate::remote::outbound::read_bounded_body( + response, + crate::remote::outbound::MAX_ANSWER_BYTES, + ) + .await + .map_err(|error| { + tracing::warn!(call = what, %error, "last.fm's answer was not read"); + ServiceError::Unavailable + })?; serde_json::from_slice::(&body).map_err(|_| { tracing::warn!(call = what, "last.fm's answer is not the expected JSON"); ServiceError::Unavailable @@ -252,9 +254,9 @@ impl ScrobbleTarget for LastFm { if !status.is_success() { return status_verdict(status, retry_after); } - let answer = match crate::outbound::read_bounded_body( + let answer = match crate::remote::outbound::read_bounded_body( response, - crate::outbound::MAX_ANSWER_BYTES, + crate::remote::outbound::MAX_ANSWER_BYTES, ) .await { @@ -708,7 +710,7 @@ mod tests { /// An adapter aimed at a listener of the test's own. async fn reaching(body: axum::body::Body) -> (LastFm, tokio::task::JoinHandle<()>) { - let (url, server) = crate::outbound::tests::answering(body).await; + let (url, server) = crate::remote::outbound::tests::answering(body).await; let base = super::super::validate_destination(&url, true).expect("a destination"); let client = super::super::outbound_client(Duration::from_secs(5)).expect("a client"); let application = crate::config::LastFmApplication { @@ -731,7 +733,7 @@ mod tests { async fn a_session_answer_over_the_ceiling_is_not_read() { use crate::services::{LastFmSessionExchange, ServiceError}; let key = uuid::Uuid::new_v4().simple().to_string(); - let padding = " ".repeat(crate::outbound::MAX_ANSWER_BYTES); + let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); let (target, server) = reaching(axum::body::Body::from(format!( "{{\"session\":{{\"key\":\"{key}\"}}{padding}}}" ))) @@ -760,7 +762,7 @@ mod tests { async fn a_token_answer_over_the_ceiling_is_not_read() { use crate::services::{LastFmSessionExchange, ServiceError}; let token = uuid::Uuid::new_v4().simple().to_string(); - let padding = " ".repeat(crate::outbound::MAX_ANSWER_BYTES); + let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); let (target, server) = reaching(axum::body::Body::from(format!( "{{\"token\":\"{token}\"{padding}}}" ))) @@ -778,7 +780,7 @@ mod tests { /// apart. #[tokio::test] async fn a_scrobble_answer_over_the_ceiling_leaves_the_status_line_deciding() { - let padding = " ".repeat(crate::outbound::MAX_ANSWER_BYTES); + let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); let (target, server) = reaching(axum::body::Body::from(format!("{{\"error\":9{padding}}}"))).await; let envelope = an_envelope(); diff --git a/src/remote/scrobblers/maloja.rs b/src/remote/scrobblers/maloja.rs index de28f7f7..0868e378 100644 --- a/src/remote/scrobblers/maloja.rs +++ b/src/remote/scrobblers/maloja.rs @@ -154,9 +154,9 @@ impl ScrobbleTarget for Maloja { // answer over it, or one that breaks off, is not read at all, and // the status line decides — exactly as for a body that will not // parse. The verdict is logged; the body never is. - let refused = match crate::outbound::read_bounded_body( + let refused = match crate::remote::outbound::read_bounded_body( response, - crate::outbound::MAX_ANSWER_BYTES, + crate::remote::outbound::MAX_ANSWER_BYTES, ) .await { @@ -398,10 +398,10 @@ mod tests { /// which is the difference this tells apart. #[tokio::test] async fn an_answer_over_the_ceiling_leaves_the_status_line_deciding() { - let padding = " ".repeat(crate::outbound::MAX_ANSWER_BYTES); - let (url, server) = crate::outbound::tests::answering(axum::body::Body::from(format!( - "{{\"status\":\"failure\"{padding}}}" - ))) + let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); + let (url, server) = crate::remote::outbound::tests::answering(axum::body::Body::from( + format!("{{\"status\":\"failure\"{padding}}}"), + )) .await; let base = super::super::validate_destination(&url, true).expect("a destination"); let client = super::super::outbound_client(Duration::from_secs(5)).expect("a client"); diff --git a/src/remote/scrobblers/mod.rs b/src/remote/scrobblers/mod.rs index fe70ab5c..fcf2b480 100644 --- a/src/remote/scrobblers/mod.rs +++ b/src/remote/scrobblers/mod.rs @@ -7,7 +7,7 @@ //! //! Decision 10 calls outbound requests the real risk of the whole RFC — a //! server that calls a URL is a server somebody can make call a URL — so the -//! rules live once, in [`crate::outbound`], rather than in each adapter. That +//! rules live once, in [`crate::remote::outbound`], rather than in each adapter. That //! module is shared with metadata enrichment (RFC-011), the server's only other //! outbound surface. @@ -114,15 +114,15 @@ pub fn canonical_destination(url: &url::Url) -> String { /// keep `scrobble_link` free of destination addresses, which decision 10 says /// it holds none of. pub fn destination_fingerprint(url: &url::Url) -> String { - hex::encode(crate::security::bytes_hash( + hex::encode(crate::auth::security::bytes_hash( canonical_destination(url).as_bytes(), )) } -/// The client every adapter shares: [`crate::outbound::client`], which holds +/// The client every adapter shares: [`crate::remote::outbound::client`], which holds /// decision 10's rules and is shared with metadata enrichment. pub fn outbound_client(timeout: Duration) -> Result { - crate::outbound::client(timeout).map_err(|_| OutboundError::Unbuildable) + crate::remote::outbound::client(timeout).map_err(|_| OutboundError::Unbuildable) } /// Checks a destination an operator configured, before it is ever called. diff --git a/src/services/admin.rs b/src/services/admin.rs index 5733f7c0..bd716386 100644 --- a/src/services/admin.rs +++ b/src/services/admin.rs @@ -397,7 +397,7 @@ impl DomainServices { let record = self .db .create_api_token( - crate::database::NewApiToken { + crate::storage::database::NewApiToken { actor_id, user_id: account_id, name, diff --git a/src/services/artist_enrichment/mod.rs b/src/services/artist_enrichment/mod.rs index 70212c09..35ca7e47 100644 --- a/src/services/artist_enrichment/mod.rs +++ b/src/services/artist_enrichment/mod.rs @@ -15,7 +15,7 @@ //! handed out with an id a client could not follow. use super::*; -use crate::enrichment::{CachedArtist, Outcome, Provider}; +use crate::remote::enrichment::{CachedArtist, Outcome, Provider}; /// How many neighbours the native artist page carries. pub const ARTIST_PAGE_SIMILAR: usize = 20; @@ -93,7 +93,7 @@ impl DomainServices { // refresh scheduled, and an expired miss is not "nobody knows" but // "being asked again" — `pending`, as decision 11 defines it. Stale // content still shows, and `ready` wins over both. - let now = crate::authentication::now_ms(); + let now = crate::auth::authentication::now_ms(); let answered = rows.iter().filter(|row| row.expires_at > now).count(); let state = state_of( bio.is_some() || image.is_some() || banner.is_some() || similar.is_some(), diff --git a/src/services/artist_enrichment/tests.rs b/src/services/artist_enrichment/tests.rs index 3d2ed233..44fee857 100644 --- a/src/services/artist_enrichment/tests.rs +++ b/src/services/artist_enrichment/tests.rs @@ -1,5 +1,5 @@ use super::{merged_bio, state_of}; -use crate::enrichment::{CachedArtist, Outcome, Provider}; +use crate::remote::enrichment::{CachedArtist, Outcome, Provider}; use crate::services::EnrichmentState; fn row(outcome: Outcome, bio: Option<&str>, fetched_at: i64) -> CachedArtist { diff --git a/src/services/artist_image_search/mod.rs b/src/services/artist_image_search/mod.rs index f3b9ccd0..0e4de3da 100644 --- a/src/services/artist_image_search/mod.rs +++ b/src/services/artist_image_search/mod.rs @@ -23,8 +23,8 @@ use uuid::Uuid; use super::artist_images::{chooser_library, record_artist_change, write_choice}; use super::{ArtistImageKind, DomainServices, ServiceError}; -use crate::authentication::now_ms; -use crate::enrichment::{DeezerCandidate, Enrichment}; +use crate::auth::authentication::now_ms; +use crate::remote::enrichment::{DeezerCandidate, Enrichment}; /// How many candidates one search offers: two rows of a picker, and twenty /// image fetches at most behind one request. diff --git a/src/services/artist_images.rs b/src/services/artist_images.rs index e4e29568..c9612cef 100644 --- a/src/services/artist_images.rs +++ b/src/services/artist_images.rs @@ -12,9 +12,9 @@ use sqlx::{Row, Sqlite, Transaction}; use uuid::Uuid; use super::{DomainServices, ImageChoice, ImageChoices, ServiceError}; -use crate::authentication::now_ms; -use crate::database::LibraryRole; -use crate::portrait::Portrait; +use crate::auth::authentication::now_ms; +use crate::remote::portrait::Portrait; +use crate::storage::database::LibraryRole; /// Which of the two images. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -74,9 +74,9 @@ pub(super) async fn record_artist_change( artist_id: Uuid, now: i64, ) -> Result<(), sqlx::Error> { - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( tx, - crate::catalog::LibraryChange { + crate::storage::catalog::LibraryChange { library_id, entity_type: "artist", entity_id: artist_id, @@ -181,7 +181,7 @@ impl DomainServices { let mut read = self.db.pool().begin().await?; chooser_library(&mut read, user_id, artist_id).await?; } - crate::portrait::store(&self.artwork_dir, &image) + crate::remote::portrait::store(&self.artwork_dir, &image) .await .map_err(|_| ServiceError::Unavailable)?; let _writer = self.db.writer_guard().await; diff --git a/src/services/artwork.rs b/src/services/artwork.rs index ce4566b7..803b9be9 100644 --- a/src/services/artwork.rs +++ b/src/services/artwork.rs @@ -234,8 +234,11 @@ impl DomainServices { // The spent portrait searches first (RFC-013), in the same // transaction: their previews become unreferenced here, and a preview // a choice already names is kept by the clause below. - super::artist_image_search::forget_spent_searches(&mut tx, crate::authentication::now_ms()) - .await?; + super::artist_image_search::forget_spent_searches( + &mut tx, + crate::auth::authentication::now_ms(), + ) + .await?; let removed = sqlx::query( "DELETE FROM artwork WHERE \ NOT EXISTS (SELECT 1 FROM artist WHERE artist.artwork_hash = artwork.hash) \ diff --git a/src/services/canvas/mod.rs b/src/services/canvas/mod.rs index 7ce04036..84ec048e 100644 --- a/src/services/canvas/mod.rs +++ b/src/services/canvas/mod.rs @@ -36,7 +36,7 @@ use sqlx::Row; use uuid::Uuid; use super::{parse_uuid, CanvasBlob, DomainServices, ServiceError}; -use crate::authentication::now_ms; +use crate::auth::authentication::now_ms; mod placement; mod probe; diff --git a/src/services/canvas/placement.rs b/src/services/canvas/placement.rs index 74de14c6..5acfcc3b 100644 --- a/src/services/canvas/placement.rs +++ b/src/services/canvas/placement.rs @@ -193,7 +193,7 @@ impl DomainServices { .fetch_optional(&mut *tx) .await? .ok_or(ServiceError::NotFound)?; - let role = crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) + let role = crate::storage::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) .map_err(|_| ServiceError::Invalid)?; if !role.may_upload() { return Err(ServiceError::Forbidden); @@ -311,9 +311,9 @@ impl DomainServices { origin_device_id: Option, now: i64, ) -> Result<(), ServiceError> { - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( tx, - crate::catalog::LibraryChange { + crate::storage::catalog::LibraryChange { library_id: parse_uuid(library_id.to_owned())?, entity_type: "track", entity_id: track_id, @@ -350,8 +350,9 @@ impl DomainServices { .fetch_optional(&mut *tx) .await? .ok_or(ServiceError::NotFound)?; - let role = crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) - .map_err(|_| ServiceError::Invalid)?; + let role = + crate::storage::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) + .map_err(|_| ServiceError::Invalid)?; if !role.may_upload() { return Err(ServiceError::Forbidden); } @@ -507,7 +508,7 @@ impl DomainServices { .fetch_optional(self.db.pool()) .await? .ok_or(ServiceError::NotFound)?; - let role = crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) + let role = crate::storage::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) .map_err(|_| ServiceError::Invalid)?; if !role.may_upload() || row.try_get::("accepts_canvas")? == 0 { return Err(ServiceError::Forbidden); diff --git a/src/services/catalog.rs b/src/services/catalog.rs index a7fd1860..01270ebc 100644 --- a/src/services/catalog.rs +++ b/src/services/catalog.rs @@ -148,7 +148,7 @@ impl DomainServices { album_page: BrowsePage, song_page: BrowsePage, ) -> Result { - let Some(fts) = crate::catalog::fts_prefix_query(query) else { + let Some(fts) = crate::storage::catalog::fts_prefix_query(query) else { return Ok(CatalogSearch { artists: Vec::new(), albums: Vec::new(), diff --git a/src/services/credentials.rs b/src/services/credentials.rs index b8733a02..f01c4973 100644 --- a/src/services/credentials.rs +++ b/src/services/credentials.rs @@ -80,10 +80,13 @@ impl DomainServices { user_id: Uuid, request: AuthorizationRequest<'_>, ) -> Result { - crate::oauth::validate_redirect_uri(request.redirect_uri) - .map_err(|_| ServiceError::Invalid)?; - crate::oauth::validate_challenge(request.code_challenge_method, request.code_challenge) + crate::auth::oauth::validate_redirect_uri(request.redirect_uri) .map_err(|_| ServiceError::Invalid)?; + crate::auth::oauth::validate_challenge( + request.code_challenge_method, + request.code_challenge, + ) + .map_err(|_| ServiceError::Invalid)?; let client_id = request.client_id.trim(); let device_name = request.device_name.trim(); // Checked before the code exists: a name the session issuer would @@ -95,7 +98,7 @@ impl DomainServices { let code = security::generate_token("wfc_"); let now = now_ms(); self.db - .create_authorization(crate::database::NewAuthorization { + .create_authorization(crate::storage::database::NewAuthorization { code_hash: security::token_hash(&code), user_id, client_id, @@ -103,11 +106,11 @@ impl DomainServices { code_challenge: request.code_challenge, device_name, now_ms: now, - expires_at: now + crate::oauth::AUTHORIZATION_CODE_TTL_MS, + expires_at: now + crate::auth::oauth::AUTHORIZATION_CODE_TTL_MS, scopes: request.scopes, }) .await?; - Ok(crate::oauth::redirect_with_code( + Ok(crate::auth::oauth::redirect_with_code( request.redirect_uri, &code, request.state, diff --git a/src/services/credit_decisions.rs b/src/services/credit_decisions.rs index e33ebe6f..06d8aa48 100644 --- a/src/services/credit_decisions.rs +++ b/src/services/credit_decisions.rs @@ -3,7 +3,7 @@ //! A decision belongs to the library: taken by its owners and managers, read //! back by every scan, applied at once to the stored catalogue without //! reading a file, and undone the same way. The rule itself is -//! `crate::credit_split`; this is who may invoke it, and what follows from it +//! `crate::library::credit_split`; this is who may invoke it, and what follows from it //! — the library feed, the derivations a scan would rerun, and the favourites //! and ratings that would otherwise point at an artist nobody credits. @@ -13,9 +13,9 @@ use sqlx::{Sqlite, Transaction}; use tokio::sync::OwnedMutexGuard; use super::*; -use crate::authentication::now_ms; -use crate::credit_split::{credit_key, fragments, is_candidate}; -use crate::database::LibraryRole; +use crate::auth::authentication::now_ms; +use crate::library::credit_split::{credit_key, fragments, is_candidate}; +use crate::storage::database::LibraryRole; /// One fragment of a credit an owner is asked about, and whether the library /// already has an artist of that name — the strongest hint that a split is @@ -109,8 +109,8 @@ fn record_event( action: &'static str, payload: serde_json::Value, now: i64, -) -> crate::catalog::LibraryChange<'static> { - crate::catalog::LibraryChange { +) -> crate::storage::catalog::LibraryChange<'static> { + crate::storage::catalog::LibraryChange { library_id, entity_type, entity_id, @@ -301,7 +301,7 @@ impl DomainServices { .map(|part| self.db.pid().artist_id(library_id, part.trim())) .collect(); for part in &part_ids { - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( &mut tx, record_event( library_id, @@ -318,7 +318,7 @@ impl DomainServices { receipts = self .carry_user_data(&writer, &mut tx, artist_id, &part_ids) .await?; - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( &mut tx, record_event( library_id, @@ -408,7 +408,7 @@ impl DomainServices { self.record_moved(&mut tx, library_id, &moved, now).await?; let joined = self.db.pid().artist_id(library_id, credit.trim()); if still_credited(&mut tx, joined).await? { - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( &mut tx, record_event( library_id, @@ -427,7 +427,7 @@ impl DomainServices { } else { "delete" }; - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( &mut tx, record_event( library_id, @@ -456,11 +456,11 @@ impl DomainServices { tracks: &[String], now: i64, ) -> Result { - let decisions = crate::catalog::credit_decisions(tx, library_id).await?; + let decisions = crate::storage::catalog::credit_decisions(tx, library_id).await?; let mut moved = Moved::default(); for track in tracks { let track_id = parse_uuid(track.clone())?; - let outcome = crate::catalog::recompute_track_participants( + let outcome = crate::storage::catalog::recompute_track_participants( tx, self.db.pid(), library_id, @@ -487,7 +487,7 @@ impl DomainServices { now: i64, ) -> Result<(), ServiceError> { for (track, full_hash) in &moved.tracks { - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( tx, record_event( library_id, @@ -501,7 +501,7 @@ impl DomainServices { .await?; } for album in &moved.albums { - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( tx, record_event( library_id, @@ -720,8 +720,8 @@ impl DomainServices { /// the feed has already announced as deleted, and a retry would only answer /// `409`. Together, the two land or neither does. async fn rederive(tx: &mut Transaction<'_, Sqlite>, library_id: Uuid) -> Result<(), sqlx::Error> { - crate::database::Database::consolidate_sort_names_in(tx, library_id).await?; - crate::database::Database::consolidate_catalog_derivations_in(tx, library_id).await + crate::storage::database::Database::consolidate_sort_names_in(tx, library_id).await?; + crate::storage::database::Database::consolidate_catalog_derivations_in(tx, library_id).await } /// Whether anything still credits an artist: a participant, or an album that diff --git a/src/services/lastfm.rs b/src/services/lastfm.rs index c208acaf..faf00a78 100644 --- a/src/services/lastfm.rs +++ b/src/services/lastfm.rs @@ -195,8 +195,8 @@ impl DomainServices { // state is the only thing separating a legitimate return from a // fabricated one, and the cookie is the only thing separating *this* // browser's return from anybody who came by the URL afterwards. - let state = crate::security::generate_token(""); - let cookie = crate::security::generate_token(""); + let state = crate::auth::security::generate_token(""); + let cookie = crate::auth::security::generate_token(""); let now = now_ms(); let expires_at = now.saturating_add(i64::try_from(JOURNEY_TTL.as_millis()).unwrap_or(i64::MAX)); @@ -218,7 +218,7 @@ impl DomainServices { .bind(user_id.to_string()) .bind(destination) .bind(&fingerprint) - .bind(crate::security::token_hash(&cookie).as_slice()) + .bind(crate::auth::security::token_hash(&cookie).as_slice()) .bind(now) .bind(expires_at) .execute(&mut *tx) @@ -300,7 +300,7 @@ impl DomainServices { // One refusal for every way of being wrong: expired, or the wrong // browser. Distinguishing them would tell whoever found the URL // which half they are missing. - if now >= expires_at || !crate::security::token_matches(cookie, &cookie_hash) { + if now >= expires_at || !crate::auth::security::token_matches(cookie, &cookie_hash) { return Err(ServiceError::NotFound); } ( diff --git a/src/services/library_events.rs b/src/services/library_events.rs index 5d10efe1..7b9a4502 100644 --- a/src/services/library_events.rs +++ b/src/services/library_events.rs @@ -4,7 +4,7 @@ //! no feed at all. Written by scans in `catalog.rs`; read here. use super::*; -use crate::sync::MAX_SYNC_LIMIT; +use crate::storage::sync::MAX_SYNC_LIMIT; /// How often the feed is trimmed. /// diff --git a/src/services/library_folders/mod.rs b/src/services/library_folders/mod.rs index 8e9043c0..ce7ec2be 100644 --- a/src/services/library_folders/mod.rs +++ b/src/services/library_folders/mod.rs @@ -18,8 +18,8 @@ use uuid::Uuid; use super::{ attach_song_relations, song_from_row, BrowsePage, DomainServices, ServiceError, SongItem, }; -use crate::authentication::now_ms; -use crate::database::LibraryRole; +use crate::auth::authentication::now_ms; +use crate::storage::database::LibraryRole; /// The deepest a folder may sit: the root is 0, a folder at the root is 1. pub const MAX_FOLDER_DEPTH: i64 = 16; diff --git a/src/services/library_lifecycle/edit.rs b/src/services/library_lifecycle/edit.rs index 6f26808a..eb8efa72 100644 --- a/src/services/library_lifecycle/edit.rs +++ b/src/services/library_lifecycle/edit.rs @@ -209,7 +209,7 @@ impl DomainServices { // the job queued, and the next boot runs it (`ScanManager::boot`). self.scanner.spawn( scan_id, - crate::catalog::LibraryRecord { + crate::storage::catalog::LibraryRecord { id: library_id, name: name.unwrap_or(before.library.name), root_path: root.unwrap_or(before.library.root_path), diff --git a/src/services/library_tree/mod.rs b/src/services/library_tree/mod.rs index 6d3a2e73..1f27d82f 100644 --- a/src/services/library_tree/mod.rs +++ b/src/services/library_tree/mod.rs @@ -126,7 +126,7 @@ impl DomainServices { /// of an empty library is an empty listing. `Invalid` for a malformed path. /// /// Folders and tracks sort by name in natural order — numbers by value, - /// letters case-folded (`crate::natural_order`) — so `2 …` comes before + /// letters case-folded (`crate::storage::natural_order`) — so `2 …` comes before /// `10 …`. Tracks sort by file name, except in the received folder, whose file names are hashes: there they /// sort by artist then title, a missing artist last. pub async fn disk_tree( diff --git a/src/services/lyrics_uploads.rs b/src/services/lyrics_uploads.rs index f21db405..b36a92dd 100644 --- a/src/services/lyrics_uploads.rs +++ b/src/services/lyrics_uploads.rs @@ -14,8 +14,8 @@ use sqlx::Row; use uuid::Uuid; use super::{DomainServices, ServiceError}; -use crate::authentication::now_ms; -use crate::lyrics::{self, LyricsList, StructuredLyrics, UploadFormat}; +use crate::auth::authentication::now_ms; +use crate::library::lyrics::{self, LyricsList, StructuredLyrics, UploadFormat}; /// The sidecar's cap, so an upload is held to what a scan would read. pub const MAX_UPLOAD_BYTES: usize = 1024 * 1024; @@ -313,7 +313,7 @@ impl DomainServices { .fetch_optional(&mut **tx) .await? .ok_or(ServiceError::NotFound)?; - let role = crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) + let role = crate::storage::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) .map_err(|_| ServiceError::Invalid)?; if !role.may_write_metadata() { return Err(ServiceError::Forbidden); diff --git a/src/services/mod.rs b/src/services/mod.rs index a7390884..7f4b2240 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -8,12 +8,16 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::{ - authentication::now_ms, - database::{AccountRecord, AccountRole, ApiTokenRecord, Database, SubsonicCredentialStatus}, - lyrics::{self, LyricsList, StructuredLyrics}, - playlist_file::{self, PlaylistFileEntry}, - security::{self, EncryptedSecret, SecretBox}, - sync::{MutationContext, MutationIntent, MutationReceipt, OperationClaim, SyncService}, + auth::authentication::now_ms, + auth::security::{self, EncryptedSecret, SecretBox}, + library::lyrics::{self, LyricsList, StructuredLyrics}, + library::playlist_file::{self, PlaylistFileEntry}, + storage::database::{ + AccountRecord, AccountRole, ApiTokenRecord, Database, SubsonicCredentialStatus, + }, + storage::sync::{ + MutationContext, MutationIntent, MutationReceipt, OperationClaim, SyncService, + }, }; #[macro_use] @@ -24,7 +28,7 @@ pub struct DomainServices { db: Database, secret_box: Arc, sync: SyncService, - scanner: crate::scanner::ScanManager, + scanner: crate::library::scanner::ScanManager, uploads: crate::config::UploadLimits, library_event_retention: crate::config::LibraryEventRetention, /// How long a missing track is kept before the retention pass purges it, @@ -36,7 +40,7 @@ pub struct DomainServices { /// The folders outside every library a stored path may lead into through /// a link (`WAVEFLOW_LINK_ROOTS`): the quality analysis reads those files /// as the stream does. - link_roots: crate::link_roots::LinkRoots, + link_roots: crate::library::link_roots::LinkRoots, /// Bounds a credential an account mints for itself. See the field on /// [`crate::config::Config`] for why the administrative path is not /// bounded by it. @@ -95,7 +99,7 @@ pub struct DomainServices { /// Metadata enrichment — RFC-011. Disabled at construction and placed by /// [`Self::use_enrichment`], like the scrobbling adapters and for the same /// reason: a test drives it against a provider of its own. - enrichment: Arc>, + enrichment: Arc>, /// How long a portrait search's task may run, in milliseconds (RFC-013): /// [`ARTIST_IMAGE_SEARCH_DEADLINE`], except where a test shortens it /// rather than wait five minutes. @@ -105,7 +109,7 @@ pub struct DomainServices { quality: quality::QualityRuntime, /// Lyrics from LRCLIB — RFC-024. Disabled at construction and placed by /// [`Self::use_lyrics_lookup`], like enrichment and for the same reason. - lyrics_lookup: Arc>, + lyrics_lookup: Arc>, /// Podcasts (RFC-025): the settings, and the one download permit and one /// refresh lock the background work shares. podcasts: podcasts::PodcastRuntime, @@ -141,17 +145,16 @@ pub enum ServiceError { Security(#[from] security::SecurityError), } -impl From for ServiceError { - fn from(error: crate::sync::SyncError) -> Self { +impl From for ServiceError { + fn from(error: crate::storage::sync::SyncError) -> Self { match error { - crate::sync::SyncError::Invalid => Self::Invalid, + crate::storage::sync::SyncError::Invalid => Self::Invalid, // Mutations claim operations; they never read the journal, so // CursorExpired cannot reach this conversion. Folded into Conflict // rather than given a domain variant nothing would ever construct. - crate::sync::SyncError::Conflict | crate::sync::SyncError::CursorExpired => { - Self::Conflict - } - crate::sync::SyncError::Database(error) => Self::Database(error), + crate::storage::sync::SyncError::Conflict + | crate::storage::sync::SyncError::CursorExpired => Self::Conflict, + crate::storage::sync::SyncError::Database(error) => Self::Database(error), } } } @@ -305,7 +308,7 @@ impl DomainServices { db: Database, secret_box: Arc, sync: SyncService, - scanner: crate::scanner::ScanManager, + scanner: crate::library::scanner::ScanManager, config: &crate::config::Config, ) -> Self { // Copied here rather than borrowed, which is why mutating a `Config` @@ -320,7 +323,7 @@ impl DomainServices { library_event_retention: config.library_event_retention, unavailable_retention: config.unavailable_retention, purge_grace: config.purge_grace, - link_roots: crate::link_roots::LinkRoots::new(config.link_roots.clone()), + link_roots: crate::library::link_roots::LinkRoots::new(config.link_roots.clone()), api_token_ttl: config.api_token_ttl, upload_locks: Arc::new(dashmap::DashMap::new()), canvas: config.canvas, @@ -347,7 +350,7 @@ impl DomainServices { lastfm_unavailable: lastfm::lastfm_unavailability(config), lastfm_exchanges: Arc::new(dashmap::DashMap::new()), enrichment: Arc::new(std::sync::RwLock::new( - crate::enrichment::Enrichment::disabled(), + crate::remote::enrichment::Enrichment::disabled(), )), image_search_deadline_ms: Arc::new(std::sync::atomic::AtomicU64::new( u64::try_from(ARTIST_IMAGE_SEARCH_DEADLINE.as_millis()).unwrap_or(u64::MAX), @@ -357,7 +360,7 @@ impl DomainServices { config.request_timeout, ), lyrics_lookup: Arc::new(std::sync::RwLock::new( - crate::lrclib::LyricsLookup::disabled(), + crate::remote::lrclib::LyricsLookup::disabled(), )), podcasts: podcasts::PodcastRuntime::new(config.podcasts.clone()), remote_artwork: config.remote_artwork, @@ -367,7 +370,7 @@ impl DomainServices { } /// Places the enrichment engine every reader goes through. - pub fn use_enrichment(&self, enrichment: crate::enrichment::Enrichment) { + pub fn use_enrichment(&self, enrichment: crate::remote::enrichment::Enrichment) { *self .enrichment .write() diff --git a/src/services/model/accounts.rs b/src/services/model/accounts.rs index 8d392c2b..ff36243f 100644 --- a/src/services/model/accounts.rs +++ b/src/services/model/accounts.rs @@ -6,8 +6,8 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::{ - database::{AccountRecord, AccountRole}, - security::EncryptedSecret, + auth::security::EncryptedSecret, + storage::database::{AccountRecord, AccountRole}, }; pub struct SubsonicCredentialRecord { diff --git a/src/services/model/playlists.rs b/src/services/model/playlists.rs index fe3a43a6..e06f2551 100644 --- a/src/services/model/playlists.rs +++ b/src/services/model/playlists.rs @@ -6,7 +6,7 @@ use utoipa::ToSchema; use uuid::Uuid; use super::catalog::SongItem; -use crate::playlist_file::TextEncoding; +use crate::library::playlist_file::TextEncoding; #[derive(Debug, Clone, Serialize, ToSchema)] pub struct PlaylistItem { diff --git a/src/services/playlists/files.rs b/src/services/playlists/files.rs index e301706a..39555716 100644 --- a/src/services/playlists/files.rs +++ b/src/services/playlists/files.rs @@ -5,7 +5,7 @@ use super::*; impl DomainServices { /// Imports an `.m3u` or `.m3u8` file as a playlist of this account's. /// - /// The file is read by [`crate::playlist_file`]; what is left here is the + /// The file is read by [`crate::library::playlist_file`]; what is left here is the /// part that needs the catalogue — turning what an entry *says* into a /// track this account can actually reach. /// diff --git a/src/services/podcasts/episodes.rs b/src/services/podcasts/episodes.rs index 15bbea2c..4f44bc97 100644 --- a/src/services/podcasts/episodes.rs +++ b/src/services/podcasts/episodes.rs @@ -263,7 +263,7 @@ impl DomainServices { pub async fn podcast_stream_source( &self, episode_id: Uuid, - ) -> Result, ServiceError> { + ) -> Result, ServiceError> { let row = sqlx::query( "SELECT file_name, content_hash, suffix, duration_ms FROM podcast_episode \ WHERE id=? AND status='completed'", @@ -274,7 +274,7 @@ impl DomainServices { let Some(row) = row else { return Ok(None); }; - Ok(Some(crate::catalog::StreamTrack { + Ok(Some(crate::storage::catalog::StreamTrack { id: episode_id, library_id: Uuid::nil(), library_root: self.podcasts.settings.dir.clone(), @@ -287,8 +287,8 @@ impl DomainServices { // The server's own folder, written by its downloads: no link in // it is ever followed (RFC-021), and an episode carries no // ReplayGain tags (RFC-022). - link_policy: crate::link_roots::LinkPolicy::default(), - replay_gain: crate::replay_gain::ReplayGainTags::default(), + link_policy: crate::library::link_roots::LinkPolicy::default(), + replay_gain: crate::media::replay_gain::ReplayGainTags::default(), })) } @@ -298,7 +298,7 @@ impl DomainServices { &self, user_id: Uuid, id: Uuid, - ) -> Result, ServiceError> { + ) -> Result, ServiceError> { if let Some(track) = self.db.stream_track_for_user(user_id, id).await? { return Ok(Some(track)); } diff --git a/src/services/podcasts/mod.rs b/src/services/podcasts/mod.rs index 9347982e..43460941 100644 --- a/src/services/podcasts/mod.rs +++ b/src/services/podcasts/mod.rs @@ -26,7 +26,7 @@ use std::path::Path; use super::*; -use crate::podcast::{self, FetchPolicy}; +use crate::remote::podcast::{self, FetchPolicy}; /// How many episodes `getNewestPodcasts` answers when it is not told, as the /// specification has it, and the most it answers. diff --git a/src/services/quality/analysis.rs b/src/services/quality/analysis.rs index eb0ccd0c..8b161138 100644 --- a/src/services/quality/analysis.rs +++ b/src/services/quality/analysis.rs @@ -128,7 +128,7 @@ impl DomainServices { full_hash: row.try_get("full_hash")?, root_path: row.try_get("root_path")?, relative_path: row.try_get("relative_path")?, - link_policy: crate::link_roots::LinkPolicy::from_columns( + link_policy: crate::library::link_roots::LinkPolicy::from_columns( row.try_get("follow_links")?, row.try_get::, _>("other_roots")?.as_deref(), ), diff --git a/src/services/quality/mod.rs b/src/services/quality/mod.rs index 0f324521..24d1685b 100644 --- a/src/services/quality/mod.rs +++ b/src/services/quality/mod.rs @@ -374,7 +374,7 @@ struct Pending { full_hash: String, root_path: String, relative_path: String, - link_policy: crate::link_roots::LinkPolicy, + link_policy: crate::library::link_roots::LinkPolicy, duration_ms: i64, created_at: i64, } diff --git a/src/services/quality/reads.rs b/src/services/quality/reads.rs index 558f2bff..86f447f4 100644 --- a/src/services/quality/reads.rs +++ b/src/services/quality/reads.rs @@ -213,7 +213,10 @@ impl DomainServices { std::path::Path::new(&root_path), &relative_path, &self.link_roots, - &crate::link_roots::LinkPolicy::from_columns(follow_links, other_roots.as_deref()), + &crate::library::link_roots::LinkPolicy::from_columns( + follow_links, + other_roots.as_deref(), + ), ) .await .map_err(|_| ServiceError::Unavailable)?; diff --git a/src/services/radio/mod.rs b/src/services/radio/mod.rs index 6f51e1fb..bcaeaf68 100644 --- a/src/services/radio/mod.rs +++ b/src/services/radio/mod.rs @@ -17,7 +17,7 @@ //! `img-src`, and the listener's address that would leak with it). So the //! server fetches it when an account sets or changes it — never on a timer, and //! never while anybody listens — through the rules a podcast cover is fetched -//! under ([`crate::portrait::fetch_followed`]): public addresses only, checked +//! under ([`crate::remote::portrait::fetch_followed`]): public addresses only, checked //! and pinned, redirects followed by hand and each hop checked, 10 MiB, and the //! bytes sniffed as JPEG, PNG or WebP. Only the image comes back, and only into //! the artwork store; a failure keeps the address and shows no image, and the diff --git a/src/services/remote_artwork/mod.rs b/src/services/remote_artwork/mod.rs index 996404e9..a83032ea 100644 --- a/src/services/remote_artwork/mod.rs +++ b/src/services/remote_artwork/mod.rs @@ -4,7 +4,7 @@ //! The web client cannot show either from where it lives. RFC-019's //! Content-Security-Policy keeps `img-src` to this origin, and an image loaded //! from the feed's host would hand that host the address of every listener. So -//! the server fetches it once, through [`crate::portrait::fetch_followed`], +//! the server fetches it once, through [`crate::remote::portrait::fetch_followed`], //! stores the bytes in the artwork store beside the scanned covers, and every //! surface hands out the hash — never the address it came from. //! @@ -81,19 +81,19 @@ impl DomainServices { tracing::warn!(subject, reason = "unparsable", "remote artwork not fetched"); return None; }; - let policy = crate::podcast::FetchPolicy { + let policy = crate::remote::podcast::FetchPolicy { addresses: self.remote_artwork.addresses, allow_http, timeout: self.remote_artwork.timeout, }; - let image = match crate::portrait::fetch_followed(&parsed, policy).await { + let image = match crate::remote::portrait::fetch_followed(&parsed, policy).await { Ok(image) => image, Err(refusal) => { tracing::warn!(subject, reason = %refusal, "remote artwork not fetched"); return None; } }; - if let Err(error) = crate::portrait::store(&self.artwork_dir, &image).await { + if let Err(error) = crate::remote::portrait::store(&self.artwork_dir, &image).await { tracing::warn!(subject, %error, "remote artwork could not be stored"); return None; } diff --git a/src/services/remote_lyrics/mod.rs b/src/services/remote_lyrics/mod.rs index a89c7bbd..b31e8cc5 100644 --- a/src/services/remote_lyrics/mod.rs +++ b/src/services/remote_lyrics/mod.rs @@ -14,7 +14,7 @@ //! read from the cache or sent anywhere. use super::*; -use crate::lrclib::{FoundLyrics, LyricsLookup, TrackLookup}; +use crate::remote::lrclib::{FoundLyrics, LyricsLookup, TrackLookup}; impl DomainServices { /// Places the LRCLIB lookup every lyrics reader goes through. diff --git a/src/services/remote_lyrics/tests.rs b/src/services/remote_lyrics/tests.rs index f7d91ef9..a1ebcd0e 100644 --- a/src/services/remote_lyrics/tests.rs +++ b/src/services/remote_lyrics/tests.rs @@ -1,5 +1,5 @@ use super::sheet_of; -use crate::lrclib::FoundLyrics; +use crate::remote::lrclib::FoundLyrics; #[test] fn timings_win_and_a_sheet_without_words_is_none() { diff --git a/src/services/scan.rs b/src/services/scan.rs index ad662d7b..72323d87 100644 --- a/src/services/scan.rs +++ b/src/services/scan.rs @@ -54,7 +54,7 @@ impl DomainServices { /// only when nothing at all could be queued. /// /// Re-queuing a library that is already scanning is deliberately allowed, - /// exactly as calling the native endpoint twice is: [`crate::scanner::ScanManager`] + /// exactly as calling the native endpoint twice is: [`crate::library::scanner::ScanManager`] /// serialises jobs per library and a scan converges on file content, so a /// redundant pass costs time and changes nothing. pub async fn start_visible_scans(&self, user_id: Uuid) -> Result, ServiceError> { diff --git a/src/services/search.rs b/src/services/search.rs index 71b7ebab..1418b68e 100644 --- a/src/services/search.rs +++ b/src/services/search.rs @@ -111,7 +111,7 @@ impl DomainServices { // querying on each keystroke would otherwise get nothing until the word // is complete — "ech" returned zero results while "echo" returned the // album. Native clients type incrementally just as Subsonic ones do. - let Some(fts) = crate::catalog::fts_prefix_query(query) else { + let Some(fts) = crate::storage::catalog::fts_prefix_query(query) else { return Ok(SearchResult { artists: Vec::new(), albums: Vec::new(), diff --git a/src/services/shares/tests.rs b/src/services/shares/tests.rs index 8c200021..25365062 100644 --- a/src/services/shares/tests.rs +++ b/src/services/shares/tests.rs @@ -57,7 +57,7 @@ async fn a_share(state: &crate::AppState) -> (String, Uuid, Uuid) { ) .bind(share.to_string()) .bind(account.to_string()) - .bind(crate::security::token_hash(&token).as_slice()) + .bind(crate::auth::security::token_hash(&token).as_slice()) .execute(pool) .await .expect("share"); diff --git a/src/services/track_metadata.rs b/src/services/track_metadata.rs index 5ddd7aac..57309f29 100644 --- a/src/services/track_metadata.rs +++ b/src/services/track_metadata.rs @@ -116,8 +116,10 @@ impl DomainServices { .bind(user_id.to_string()) .fetch_optional(self.db.pool()) .await?; - let role = crate::database::LibraryRole::from_str(&role.ok_or(ServiceError::NotFound)?) - .map_err(|_| ServiceError::Invalid)?; + let role = crate::storage::database::LibraryRole::from_str( + &role.ok_or(ServiceError::NotFound)?, + ) + .map_err(|_| ServiceError::Invalid)?; if !role.may_write_metadata() { return Err(ServiceError::Forbidden); } @@ -164,9 +166,10 @@ impl DomainServices { // not the authority and does not need to be: it exists so a // caller who may not write cannot make the server read a // file and hash it end to end before being told no. - let role = - crate::database::LibraryRole::from_str(hint.try_get::<&str, _>("role")?) - .map_err(|_| ServiceError::Invalid)?; + let role = crate::storage::database::LibraryRole::from_str( + hint.try_get::<&str, _>("role")?, + ) + .map_err(|_| ServiceError::Invalid)?; // Only an explicit `null` drops a list. A list the patch // leaves out keeps its correction, so there is nothing to // restore and no reason to read the file. @@ -227,7 +230,7 @@ impl DomainServices { let scanned_title: String = row.try_get("title")?; let full_hash: String = row.try_get("full_hash")?; let last_seen_scan_id: Option = row.try_get("last_seen_scan_id")?; - let role = crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) + let role = crate::storage::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) .map_err(|_| ServiceError::Invalid)?; if !role.may_write_metadata() { // Blurred onto 404 by the surfaces above, like every other refusal @@ -244,7 +247,7 @@ impl DomainServices { // a stored list it cannot decode instead of reading it as none. Read as // none, a patch that never mentioned it would erase it by writing the // merge back. - let stored_lists = crate::catalog::track_override_lists(&mut tx, track_id).await?; + let stored_lists = crate::storage::catalog::track_override_lists(&mut tx, track_id).await?; let title = merge(requested_title, row.try_get("o_title")?); let sort_title = merge(requested_sort_title, row.try_get("o_sort_title")?); let year = merge(patch.year, row.try_get("o_year")?); @@ -326,13 +329,13 @@ impl DomainServices { // have refused every received file until a scan happened to walk // past it. let scan_id = last_seen_scan_id.map(parse_uuid).transpose()?; - let decisions = crate::catalog::credit_decisions(&mut tx, library_id).await?; - crate::database::Database::apply_catalog_track_in_transaction( + let decisions = crate::storage::catalog::credit_decisions(&mut tx, library_id).await?; + crate::storage::database::Database::apply_catalog_track_in_transaction( &mut tx, self.db.pid(), library_id, scan_id, - &crate::catalog::CatalogApply { + &crate::storage::catalog::CatalogApply { input, existing_id: Some(track_id), moved: false, @@ -356,12 +359,12 @@ impl DomainServices { // something merely similar. Only for a list this patch set: one it left // out already has its rows, and rewriting them identically would still // delete and reinsert every credit. - let effective = crate::catalog::TrackOverrideLists { + let effective = crate::storage::catalog::TrackOverrideLists { title: title.clone(), artists: requested_artists.flatten(), genres: requested_genres.flatten(), }; - crate::catalog::apply_track_override_lists( + crate::storage::catalog::apply_track_override_lists( &mut tx, self.db.pid(), library_id, @@ -413,9 +416,9 @@ impl DomainServices { // belongs to the library and every member sees it. The hash travels // with it unchanged, which is the client's evidence that its link // survived the edit. - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( &mut tx, - crate::catalog::LibraryChange { + crate::storage::catalog::LibraryChange { library_id, entity_type: "track", entity_id: track_id, diff --git a/src/services/uploads/negotiate.rs b/src/services/uploads/negotiate.rs index a6b11978..007b0117 100644 --- a/src/services/uploads/negotiate.rs +++ b/src/services/uploads/negotiate.rs @@ -96,7 +96,7 @@ impl DomainServices { .fetch_optional(&mut *tx) .await? .ok_or(ServiceError::NotFound)?; - let role = crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) + let role = crate::storage::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) .map_err(|_| ServiceError::Invalid)?; if !role.may_upload() { // Blurred onto 404 by the surface above. A caller who may not diff --git a/src/services/uploads/transfer.rs b/src/services/uploads/transfer.rs index 791aca35..ef85af13 100644 --- a/src/services/uploads/transfer.rs +++ b/src/services/uploads/transfer.rs @@ -75,7 +75,7 @@ impl DomainServices { .fetch_optional(self.db.pool()) .await? .ok_or(ServiceError::NotFound)?; - let role = crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) + let role = crate::storage::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) .map_err(|_| ServiceError::Invalid)?; if !role.may_upload() { // Blurred onto 404: someone who may no longer upload learns nothing @@ -353,8 +353,9 @@ impl DomainServices { .bind(&relative) .fetch_optional(&mut *tx) .await?; - let decisions = crate::catalog::credit_decisions(&mut tx, target.library_id).await?; - crate::database::Database::apply_catalog_track_in_transaction( + let decisions = + crate::storage::catalog::credit_decisions(&mut tx, target.library_id).await?; + crate::storage::database::Database::apply_catalog_track_in_transaction( &mut tx, self.db.pid(), target.library_id, @@ -362,7 +363,7 @@ impl DomainServices { // borrowing an unrelated job's identity, and the end-of-scan sweep // knows what that means. None, - &crate::catalog::CatalogApply { + &crate::storage::catalog::CatalogApply { input, existing_id: existing_id.map(parse_uuid).transpose()?, moved: false, diff --git a/src/storage/catalog/apply.rs b/src/storage/catalog/apply.rs index c77d4d4c..d1ae4577 100644 --- a/src/storage/catalog/apply.rs +++ b/src/storage/catalog/apply.rs @@ -4,7 +4,7 @@ use sqlx::{Sqlite, Transaction}; use uuid::Uuid; -use crate::{authentication::now_ms, database::Database}; +use crate::{auth::authentication::now_ms, storage::database::Database}; use super::{ credit_decisions, @@ -38,7 +38,7 @@ pub struct CatalogTrackInput { pub artists: Vec, pub album_artists: Vec, /// Every other role the file names, as it spelled them. - pub roles: Vec<(crate::tags::Role, Vec)>, + pub roles: Vec<(crate::library::tags::Role, Vec)>, /// Performer credits, already split into instrument and name. pub performer_pairs: Vec<(String, String)>, pub album: Option, @@ -94,7 +94,7 @@ pub struct CatalogTrackInput { pub disc_subtitle: Option, pub artwork: Option, pub lyrics_hash: String, - pub lyrics: Vec, + pub lyrics: Vec, } #[derive(Debug, Clone)] @@ -200,11 +200,11 @@ impl Database { /// scan failed to find. pub(crate) async fn apply_catalog_track_in_transaction( tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, + pid: &crate::library::pid::PidSpecs, library_id: Uuid, scan_id: Option, apply: &CatalogApply, - decisions: &crate::credit_split::Decisions, + decisions: &crate::library::credit_split::Decisions, now: i64, ) -> Result { let input = &apply.input; @@ -227,7 +227,7 @@ impl Database { // composer or a conductor comes from the file and nobody corrected it. let overrides = track_override_lists(tx, track_id).await?; // The library's decisions, read by the caller inside this transaction. - let credits = crate::credit_split::resolve( + let credits = crate::library::credit_split::resolve( &file_credits, decisions, overrides.artists.as_deref(), @@ -246,13 +246,13 @@ impl Database { // tag mapper's whole job, and it applies the reference's rules rather // than this file's old `;`-only cut. What is left here is turning // names into rows. - let artist_names: Vec = credit_names(&credits, crate::tags::Role::Artist); + let artist_names: Vec = credit_names(&credits, crate::library::tags::Role::Artist); let mut artist_ids = Vec::with_capacity(artist_names.len()); for artist in &artist_names { artist_ids.push(upsert_artist(tx, pid, library_id, artist, now).await?); } let album_artist_names: Vec = - credit_names(&credits, crate::tags::Role::AlbumArtist); + credit_names(&credits, crate::library::tags::Role::AlbumArtist); // The first step's names, not the decisions': the display string is // what the album's identity hashes (RFC-015 decision 4). let album_artist = album_artist_display(input, &file_credits); @@ -413,11 +413,11 @@ impl Database { .await?; for credit in &credits { let artist_id = match credit.role { - crate::tags::Role::Artist => artist_ids + crate::library::tags::Role::Artist => artist_ids .get(credit.position) .copied() .expect("every artist credit was upserted above"), - crate::tags::Role::AlbumArtist => album_artist_ids + crate::library::tags::Role::AlbumArtist => album_artist_ids .get(credit.position) .copied() .expect("every album-artist credit was upserted above"), @@ -533,7 +533,7 @@ async fn upsert_artwork( /// real character are two artists, as they are in the reference. pub(super) async fn upsert_artist( tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, + pid: &crate::library::pid::PidSpecs, library: Uuid, name: &str, now: i64, @@ -583,7 +583,7 @@ pub(super) async fn upsert_genre( /// and nothing has to notice. async fn upsert_album( tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, + pid: &crate::library::pid::PidSpecs, library: Uuid, input: &CatalogTrackInput, album_artist: Option<&str>, diff --git a/src/storage/catalog/availability.rs b/src/storage/catalog/availability.rs index 2466a6d1..aebd7f87 100644 --- a/src/storage/catalog/availability.rs +++ b/src/storage/catalog/availability.rs @@ -5,7 +5,7 @@ use sqlx::Row; use uuid::Uuid; -use crate::{authentication::now_ms, database::Database}; +use crate::{auth::authentication::now_ms, storage::database::Database}; use super::{parse_uuid, record_library_event, LibraryChange}; diff --git a/src/storage/catalog/credits.rs b/src/storage/catalog/credits.rs index 1e15c1ed..08b1ae07 100644 --- a/src/storage/catalog/credits.rs +++ b/src/storage/catalog/credits.rs @@ -11,10 +11,10 @@ use super::{apply::upsert_artist, parse_uuid, track_override_lists}; /// The two lists derived from it are read in lockstep — a name and the sort /// form paired with it — so they have to come from one ordering. fn credits_in_role( - credits: &[crate::tags::Credit], - role: crate::tags::Role, -) -> Vec<&crate::tags::Credit> { - let mut named: Vec<&crate::tags::Credit> = credits + credits: &[crate::library::tags::Credit], + role: crate::library::tags::Role, +) -> Vec<&crate::library::tags::Credit> { + let mut named: Vec<&crate::library::tags::Credit> = credits .iter() .filter(|credit| credit.role == role) .collect(); @@ -32,17 +32,16 @@ fn credits_in_role( /// `track_credit` holds the sort form a participant gets and a decision splits /// the same one a scan would. pub(super) fn with_album_artist_sorts( - mut credits: Vec, -) -> Vec { + mut credits: Vec, +) -> Vec { let album: Vec<(String, Option)> = - credits_in_role(&credits, crate::tags::Role::AlbumArtist) + credits_in_role(&credits, crate::library::tags::Role::AlbumArtist) .iter() .map(|credit| (credit.name.clone(), credit.sort_name.clone())) .collect(); - for credit in credits - .iter_mut() - .filter(|credit| credit.role == crate::tags::Role::Artist && credit.sort_name.is_none()) - { + for credit in credits.iter_mut().filter(|credit| { + credit.role == crate::library::tags::Role::Artist && credit.sort_name.is_none() + }) { credit.sort_name = album .iter() .find(|(name, _)| *name == credit.name) @@ -59,22 +58,22 @@ pub(super) fn with_album_artist_sorts( pub(crate) async fn credit_decisions( tx: &mut Transaction<'_, Sqlite>, library_id: Uuid, -) -> Result { +) -> Result { let rows = sqlx::query( "SELECT credit_key, decision, parts FROM artist_credit_decision WHERE library_id = ?", ) .bind(library_id.to_string()) .fetch_all(&mut **tx) .await?; - let mut decisions = crate::credit_split::Decisions::new(); + let mut decisions = crate::library::credit_split::Decisions::new(); for row in rows { let key: String = row.try_get("credit_key")?; let decision = match row.try_get::<&str, _>("decision")? { - "keep" => crate::credit_split::CreditDecision::Keep, + "keep" => crate::library::credit_split::CreditDecision::Keep, _ => { let parts: Option = row.try_get("parts")?; match parts.and_then(|parts| serde_json::from_str::>(&parts).ok()) { - Some(parts) => crate::credit_split::CreditDecision::Split(parts), + Some(parts) => crate::library::credit_split::CreditDecision::Split(parts), None => continue, } } @@ -89,7 +88,7 @@ pub(crate) async fn write_track_credits( tx: &mut Transaction<'_, Sqlite>, library_id: Uuid, track_id: Uuid, - credits: &[crate::tags::Credit], + credits: &[crate::library::tags::Credit], ) -> Result<(), sqlx::Error> { sqlx::query("DELETE FROM track_credit WHERE track_id = ?") .bind(track_id.to_string()) @@ -120,7 +119,7 @@ pub(crate) async fn write_track_credits( pub(crate) async fn read_track_credits( tx: &mut Transaction<'_, Sqlite>, track_id: Uuid, -) -> Result, sqlx::Error> { +) -> Result, sqlx::Error> { let rows = sqlx::query( "SELECT role, sub_role, position, name, sort_name FROM track_credit WHERE track_id = ? ORDER BY role, position", ) @@ -131,10 +130,10 @@ pub(crate) async fn read_track_credits( for row in rows { // A role this binary does not know was written by a newer one; it is // left out rather than guessed, as `track_participant` leaves it. - let Some(role) = crate::tags::Role::parse(row.try_get::<&str, _>("role")?) else { + let Some(role) = crate::library::tags::Role::parse(row.try_get::<&str, _>("role")?) else { continue; }; - credits.push(crate::tags::Credit { + credits.push(crate::library::tags::Credit { role, sub_role: row.try_get("sub_role")?, name: row.try_get("name")?, @@ -163,10 +162,10 @@ pub(crate) struct Recomputed { /// behind. pub(crate) async fn recompute_track_participants( tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, + pid: &crate::library::pid::PidSpecs, library_id: Uuid, track_id: Uuid, - decisions: &crate::credit_split::Decisions, + decisions: &crate::library::credit_split::Decisions, now: i64, ) -> Result { let row = sqlx::query("SELECT album_id, full_hash, artist_display FROM track WHERE id = ?") @@ -182,7 +181,7 @@ pub(crate) async fn recompute_track_participants( let file_credits = read_track_credits(tx, track_id).await?; let overrides = track_override_lists(tx, track_id).await?; - let credits = crate::credit_split::resolve( + let credits = crate::library::credit_split::resolve( &file_credits, decisions, overrides.artists.as_deref(), @@ -222,7 +221,7 @@ pub(crate) async fn recompute_track_participants( let mut first_album_artist = None; for credit in &credits { let artist_id = upsert_artist(tx, pid, library_id, &credit.name, now).await?; - if credit.role == crate::tags::Role::AlbumArtist && first_album_artist.is_none() { + if credit.role == crate::library::tags::Role::AlbumArtist && first_album_artist.is_none() { first_album_artist = Some(artist_id); } sqlx::query( @@ -270,8 +269,8 @@ pub(crate) async fn recompute_track_participants( } pub(super) fn credit_names( - credits: &[crate::tags::Credit], - role: crate::tags::Role, + credits: &[crate::library::tags::Credit], + role: crate::library::tags::Role, ) -> Vec { credits_in_role(credits, role) .iter() diff --git a/src/storage/catalog/derivations.rs b/src/storage/catalog/derivations.rs index 525c0970..4df6c04c 100644 --- a/src/storage/catalog/derivations.rs +++ b/src/storage/catalog/derivations.rs @@ -4,7 +4,7 @@ use sqlx::{Sqlite, Transaction}; use uuid::Uuid; -use crate::database::Database; +use crate::storage::database::Database; impl Database { /// Re-derives `album.sort_name` and `artist.sort_name` from the tags the diff --git a/src/storage/catalog/identity.rs b/src/storage/catalog/identity.rs index 3a944859..87ba5be1 100644 --- a/src/storage/catalog/identity.rs +++ b/src/storage/catalog/identity.rs @@ -5,7 +5,7 @@ use sqlx::{Row, Sqlite, Transaction}; use uuid::Uuid; -use crate::database::Database; +use crate::storage::database::Database; use super::{credits::credit_names, parse_uuid, CatalogTrackInput}; @@ -35,7 +35,7 @@ impl Database { /// Returns how many libraries were marked, so a caller can say so. pub async fn reconcile_catalog_identity( &self, - specs: &crate::pid::PidSpecs, + specs: &crate::library::pid::PidSpecs, ) -> Result { // `pid.track` is handled apart, and never by re-identifying anything: // a track's id is its path, then its content hash, then the relocation @@ -106,7 +106,7 @@ impl Database { /// before that first scan free. async fn clear_track_hints_if_spec_changed( &self, - specs: &crate::pid::PidSpecs, + specs: &crate::library::pid::PidSpecs, ) -> Result<(), sqlx::Error> { let active = specs.track.source(); // A catalogue built before the property existed is not evidence of a @@ -165,7 +165,7 @@ impl Database { /// user already stars what it would have become. async fn remap_artist_user_data( &self, - specs: &crate::pid::PidSpecs, + specs: &crate::library::pid::PidSpecs, ) -> Result { let rows = sqlx::query("SELECT id, library_id, name FROM artist") .fetch_all(self.pool()) @@ -328,8 +328,8 @@ impl<'a> TrackIdentity<'a> { /// Derived here rather than by the scanner because this is where identity is /// written — and now also where the relocation hint is, which has to hash the /// same values the row is built from. -pub(super) fn track_credits(input: &CatalogTrackInput) -> Vec { - crate::tags::credits(&crate::tags::RawCredits { +pub(super) fn track_credits(input: &CatalogTrackInput) -> Vec { + crate::library::tags::credits(&crate::library::tags::RawCredits { artist: input.artist.iter().cloned().collect(), artists: input.artists.clone(), album_artist: input.album_artist.iter().cloned().collect(), @@ -346,9 +346,9 @@ pub(super) fn track_credits(input: &CatalogTrackInput) -> Vec Option { - let names = credit_names(credits, crate::tags::Role::AlbumArtist); + let names = credit_names(credits, crate::library::tags::Role::AlbumArtist); input .album_artist .as_deref() @@ -365,7 +365,7 @@ pub(super) fn album_artist_display( /// only tells a scan that the file it is looking at is probably the one whose /// path disappeared, when neither the path nor the content hash can say so. pub fn track_pid( - specs: &crate::pid::PidSpecs, + specs: &crate::library::pid::PidSpecs, library_id: Uuid, input: &CatalogTrackInput, ) -> Uuid { @@ -374,7 +374,7 @@ pub fn track_pid( specs.track_id(library_id, &TrackIdentity::new(input, &album_artist)) } -impl crate::pid::PidSource for TrackIdentity<'_> { +impl crate::library::pid::PidSource for TrackIdentity<'_> { fn tag(&self, name: &str) -> Option<&str> { let value = match name { "musicbrainz_albumid" => self.input.musicbrainz_release_id.as_deref(), diff --git a/src/storage/catalog/libraries.rs b/src/storage/catalog/libraries.rs index be00ad33..c58cfea1 100644 --- a/src/storage/catalog/libraries.rs +++ b/src/storage/catalog/libraries.rs @@ -8,7 +8,7 @@ use sqlx::Row; use utoipa::ToSchema; use uuid::Uuid; -use crate::{authentication::now_ms, database::Database}; +use crate::{auth::authentication::now_ms, storage::database::Database}; use super::parse_uuid; @@ -34,8 +34,8 @@ pub struct LibraryScanSettings { pub struct LibraryAccess { pub id: Uuid, pub name: String, - pub visibility: crate::database::LibraryVisibility, - pub role: crate::database::LibraryRole, + pub visibility: crate::storage::database::LibraryVisibility, + pub role: crate::storage::database::LibraryRole, pub last_scan_started_at: Option, pub last_scan_completed_at: Option, /// Whether the library takes files at all. The operator's decision, set @@ -76,12 +76,14 @@ impl Database { Ok(LibraryAccess { id: parse_uuid(row.try_get("id")?)?, name: row.try_get("name")?, - visibility: crate::database::LibraryVisibility::from_str( + visibility: crate::storage::database::LibraryVisibility::from_str( row.try_get::<&str, _>("visibility")?, ) .map_err(|error| sqlx::Error::Decode(error.into()))?, - role: crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) - .map_err(|error| sqlx::Error::Decode(error.into()))?, + role: crate::storage::database::LibraryRole::from_str( + row.try_get::<&str, _>("role")?, + ) + .map_err(|error| sqlx::Error::Decode(error.into()))?, last_scan_started_at: row.try_get("last_scan_started_at")?, last_scan_completed_at: row.try_get("last_scan_completed_at")?, accepts_uploads: row.try_get::("accepts_uploads")? != 0, diff --git a/src/storage/catalog/overrides.rs b/src/storage/catalog/overrides.rs index db767a72..32838ad3 100644 --- a/src/storage/catalog/overrides.rs +++ b/src/storage/catalog/overrides.rs @@ -74,7 +74,7 @@ pub(crate) async fn track_override_lists( /// which is why the delete is narrowed rather than wholesale. pub(crate) async fn apply_track_override_lists( tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, + pid: &crate::library::pid::PidSpecs, library_id: Uuid, track_id: Uuid, lists: &TrackOverrideLists, diff --git a/src/storage/catalog/scan_jobs.rs b/src/storage/catalog/scan_jobs.rs index 0da15169..daac5884 100644 --- a/src/storage/catalog/scan_jobs.rs +++ b/src/storage/catalog/scan_jobs.rs @@ -6,7 +6,7 @@ use sqlx::{Row, Sqlite, Transaction}; use utoipa::ToSchema; use uuid::Uuid; -use crate::{authentication::now_ms, database::Database}; +use crate::{auth::authentication::now_ms, storage::database::Database}; use super::parse_uuid; @@ -171,7 +171,7 @@ impl Database { /// Membership alone is not that grant. A scan walks the owner's files and /// competes for the writer gate, so it is reserved to `owner` and /// `manager`: a `listener` is entitled to read the catalogue, not to spend - /// someone else's disk on it. [`crate::database::LibraryRole::may_scan`] + /// someone else's disk on it. [`crate::storage::database::LibraryRole::may_scan`] /// names the same rule for callers that have the row in hand. /// /// Returns `None` when the user is not, or no longer, entitled. Callers diff --git a/src/storage/catalog/tracks.rs b/src/storage/catalog/tracks.rs index 88a207b0..fe768cb2 100644 --- a/src/storage/catalog/tracks.rs +++ b/src/storage/catalog/tracks.rs @@ -8,7 +8,7 @@ use sqlx::Row; use utoipa::ToSchema; use uuid::Uuid; -use crate::database::Database; +use crate::storage::database::Database; use super::parse_uuid; @@ -25,9 +25,9 @@ pub struct StreamTrack { pub available: bool, /// Whether, and how far, a link in `relative_path` may be followed when /// the file is opened (RFC-021, links outside the root). - pub link_policy: crate::link_roots::LinkPolicy, + pub link_policy: crate::library::link_roots::LinkPolicy, /// The track's ReplayGain tags, for a transcode asked to apply them. - pub replay_gain: crate::replay_gain::ReplayGainTags, + pub replay_gain: crate::media::replay_gain::ReplayGainTags, } #[derive(Debug, Clone, Serialize, ToSchema)] @@ -204,11 +204,11 @@ fn stream_track_from_row(row: sqlx::sqlite::SqliteRow) -> Result("is_available")? != 0, - link_policy: crate::link_roots::LinkPolicy::from_columns( + link_policy: crate::library::link_roots::LinkPolicy::from_columns( row.try_get("follow_links")?, row.try_get::, _>("other_roots")?.as_deref(), ), - replay_gain: crate::replay_gain::ReplayGainTags { + replay_gain: crate::media::replay_gain::ReplayGainTags { track_gain: row.try_get("replay_gain_track_gain")?, track_peak: row.try_get("replay_gain_track_peak")?, album_gain: row.try_get("replay_gain_album_gain")?, diff --git a/src/storage/database/keys.rs b/src/storage/database/keys.rs index eb4ff9ed..fda7132d 100644 --- a/src/storage/database/keys.rs +++ b/src/storage/database/keys.rs @@ -32,7 +32,7 @@ pub struct KeyMaterial { /// not a detail of a single operation. pub enum Rekey<'a> { /// The old key is in hand. Every ciphertext is read and written back. - Reencrypt(&'a crate::security::SecretBox), + Reencrypt(&'a crate::auth::security::SecretBox), /// The old key is gone. Nothing can be read, so the rows go. Discard, } @@ -129,7 +129,7 @@ impl Database { /// /// Shares are rewritten whichever plan runs, and they are the reason this /// is one function rather than two. A share token is *derived* from the - /// key rather than stored ([`crate::security::SecretBox::derive_share_token`]), + /// key rather than stored ([`crate::auth::security::SecretBox::derive_share_token`]), /// so after a key change the stored hash is one the new key can never /// produce: the share is not merely re-addressed, it is unreachable, and /// its owner is shown a URL that answers 404. Recomputing the hash is what @@ -137,7 +137,7 @@ impl Database { pub async fn rekey( connection: &mut SqliteConnection, plan: Rekey<'_>, - new_key: &crate::security::SecretBox, + new_key: &crate::auth::security::SecretBox, new_fingerprint: &[u8], now_ms: i64, ) -> anyhow::Result { @@ -275,7 +275,7 @@ impl Database { // every synchronising client a modification with no journal entry // behind it. sqlx::query("UPDATE share SET token_hash=? WHERE id=?") - .bind(crate::security::token_hash(&token).as_slice()) + .bind(crate::auth::security::token_hash(&token).as_slice()) .bind(&id) .execute(&mut *tx) .await?; diff --git a/src/storage/database/metadata.rs b/src/storage/database/metadata.rs index 320ae663..8f592423 100644 --- a/src/storage/database/metadata.rs +++ b/src/storage/database/metadata.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use super::Database; -/// RFC-011's cache, which [`crate::enrichment`] fills and reads. +/// RFC-011's cache, which [`crate::remote::enrichment`] fills and reads. /// /// No user id and no tenancy anywhere in it: what a provider says about a name /// is the same whoever asks, and nothing here is account data (decision 4). @@ -22,7 +22,7 @@ impl Database { pub async fn metadata_artist_rows( &self, name_canonical: &str, - ) -> Result, sqlx::Error> { + ) -> Result, sqlx::Error> { let rows = sqlx::query( "SELECT provider, outcome, bio_summary, bio_full, image_hash, fanart_hash, \ fetched_at, expires_at \ @@ -34,14 +34,14 @@ impl Database { let mut cached = Vec::with_capacity(rows.len()); for row in rows { let provider: String = row.try_get("provider")?; - let Some(provider) = crate::enrichment::Provider::parse(&provider) else { + let Some(provider) = crate::remote::enrichment::Provider::parse(&provider) else { continue; }; let outcome: String = row.try_get("outcome")?; - let outcome = crate::enrichment::Outcome::parse(&outcome).ok_or_else(|| { + let outcome = crate::remote::enrichment::Outcome::parse(&outcome).ok_or_else(|| { sqlx::Error::Decode(format!("unknown enrichment outcome {outcome:?}").into()) })?; - cached.push(crate::enrichment::CachedArtist { + cached.push(crate::remote::enrichment::CachedArtist { provider, outcome, bio_summary: row.try_get("bio_summary")?, @@ -58,9 +58,9 @@ impl Database { /// One provider's ranked neighbours for one canonical name, best first. pub async fn metadata_artist_similar( &self, - provider: crate::enrichment::Provider, + provider: crate::remote::enrichment::Provider, name_canonical: &str, - ) -> Result, sqlx::Error> { + ) -> Result, sqlx::Error> { let rows = sqlx::query( "SELECT similar_name, similar_canonical, score FROM metadata_artist_similar \ WHERE provider = ? AND name_canonical = ? ORDER BY rank", @@ -71,7 +71,7 @@ impl Database { .await?; rows.into_iter() .map(|row| { - Ok(crate::enrichment::CachedSimilar { + Ok(crate::remote::enrichment::CachedSimilar { name: row.try_get("similar_name")?, name_canonical: row.try_get("similar_canonical")?, score: row.try_get("score")?, @@ -109,7 +109,7 @@ impl Database { &mut tx, name_canonical, hash, - crate::authentication::now_ms(), + crate::auth::authentication::now_ms(), ) .await?; tx.commit().await @@ -140,7 +140,7 @@ impl Database { &mut tx, name_canonical, Some(hash), - crate::authentication::now_ms(), + crate::auth::authentication::now_ms(), ) .await?; tx.commit().await @@ -152,7 +152,7 @@ impl Database { &self, provider: &str, lookup_key: &str, - ) -> Result, sqlx::Error> { + ) -> Result, sqlx::Error> { let Some(row) = sqlx::query( "SELECT outcome, synced_lyrics, plain_lyrics, fetched_at, expires_at \ FROM lyrics_lookup WHERE provider = ? AND lookup_key = ?", @@ -165,10 +165,10 @@ impl Database { return Ok(None); }; let outcome: String = row.try_get("outcome")?; - let outcome = crate::lrclib::LyricsOutcome::parse(&outcome).ok_or_else(|| { + let outcome = crate::remote::lrclib::LyricsOutcome::parse(&outcome).ok_or_else(|| { sqlx::Error::Decode(format!("unknown lyrics outcome {outcome:?}").into()) })?; - Ok(Some(crate::lrclib::CachedLyrics { + Ok(Some(crate::remote::lrclib::CachedLyrics { outcome, synced: row.try_get("synced_lyrics")?, plain: row.try_get("plain_lyrics")?, @@ -183,7 +183,7 @@ impl Database { &self, provider: &str, lookup_key: &str, - answer: &crate::lrclib::CachedLyrics, + answer: &crate::remote::lrclib::CachedLyrics, ) -> Result<(), sqlx::Error> { let _writer = self.writer_guard().await; let mut tx = self.begin_write(&_writer).await?; @@ -220,9 +220,9 @@ impl Database { /// ever runs under it. pub(crate) async fn write_metadata_artist( &self, - provider: crate::enrichment::Provider, + provider: crate::remote::enrichment::Provider, name_canonical: &str, - answer: &crate::enrichment::ArtistAnswer, + answer: &crate::remote::enrichment::ArtistAnswer, fetched_at: i64, expires_at: i64, ) -> Result<(), sqlx::Error> { @@ -351,9 +351,9 @@ async fn link_portrait_in( let library_id: String = row.try_get("library_id")?; let parse = |value: &str| Uuid::parse_str(value).map_err(|error| sqlx::Error::Decode(error.into())); - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( tx, - crate::catalog::LibraryChange { + crate::storage::catalog::LibraryChange { library_id: parse(&library_id)?, entity_type: "artist", entity_id: parse(&id)?, @@ -435,9 +435,9 @@ async fn record_artist_moves( let library_id: String = row.try_get("library_id")?; let parse = |value: &str| Uuid::parse_str(value).map_err(|error| sqlx::Error::Decode(error.into())); - crate::catalog::record_library_event( + crate::storage::catalog::record_library_event( tx, - crate::catalog::LibraryChange { + crate::storage::catalog::LibraryChange { library_id: parse(&library_id)?, entity_type: "artist", entity_id: parse(&id)?, diff --git a/src/storage/database/mod.rs b/src/storage/database/mod.rs index 64ae8094..e27a9dc7 100644 --- a/src/storage/database/mod.rs +++ b/src/storage/database/mod.rs @@ -60,7 +60,7 @@ pub struct Database { /// the writer's business: the test suite applies catalogue rows directly, /// and putting the specs anywhere else would have those tests exercise a /// different identity path than production. - pid: crate::pid::PidSpecs, + pid: crate::library::pid::PidSpecs, } impl Database { @@ -76,8 +76,8 @@ impl Database { // Numbers in names by value, for listings paged in SQL; see // `natural_order` for why no schema object may name it. .collation( - crate::natural_order::COLLATION, - crate::natural_order::compare, + crate::storage::natural_order::COLLATION, + crate::storage::natural_order::compare, ); let pool = SqlitePoolOptions::new() @@ -364,7 +364,7 @@ impl Database { } /// The identity rules the catalogue is written under. - pub fn pid(&self) -> &crate::pid::PidSpecs { + pub fn pid(&self) -> &crate::library::pid::PidSpecs { &self.pid } diff --git a/src/storage/database/sessions.rs b/src/storage/database/sessions.rs index bfba20b7..c614b72e 100644 --- a/src/storage/database/sessions.rs +++ b/src/storage/database/sessions.rs @@ -196,7 +196,7 @@ impl Database { loop { ticker.tick().await; match db - .prune_authorizations(crate::authentication::now_ms()) + .prune_authorizations(crate::auth::authentication::now_ms()) .await { Ok(0) => {} diff --git a/src/storage/database/subsonic_credentials.rs b/src/storage/database/subsonic_credentials.rs index 9307c53e..5ba80b81 100644 --- a/src/storage/database/subsonic_credentials.rs +++ b/src/storage/database/subsonic_credentials.rs @@ -3,7 +3,7 @@ use uuid::Uuid; -use crate::security::EncryptedSecret; +use crate::auth::security::EncryptedSecret; use super::{insert_audit, Database}; diff --git a/src/storage/migration_backup.rs b/src/storage/migration_backup.rs index 0e07657b..a803f7d6 100644 --- a/src/storage/migration_backup.rs +++ b/src/storage/migration_backup.rs @@ -32,7 +32,7 @@ use std::path::{Path, PathBuf}; use anyhow::Context; use crate::config::Config; -use crate::database::{Database, SchemaState}; +use crate::storage::database::{Database, SchemaState}; /// What every copy directory made here is named after, followed by the epoch /// milliseconds it was taken at. Pruning recognises nothing else. @@ -53,7 +53,7 @@ pub async fn before_migrating(db: &Database, config: &Config) -> anyhow::Result< }; let directory = config .data_dir - .join(format!("{PREFIX}{}", crate::authentication::now_ms())); + .join(format!("{PREFIX}{}", crate::auth::authentication::now_ms())); let refused = |what: &str| { format!( "could not copy the {what} before applying {pending} migration(s) to {}; \ @@ -146,9 +146,12 @@ async fn copy_pair( let key = tokio::fs::read(&copied_key) .await .with_context(|| refused("instance key"))?; - if !Database::check_file_instance_key(&copied_database, &crate::security::bytes_hash(&key)) - .await - .with_context(|| refused("database"))? + if !Database::check_file_instance_key( + &copied_database, + &crate::auth::security::bytes_hash(&key), + ) + .await + .with_context(|| refused("database"))? { anyhow::bail!( "instance.key does not match waveflow.db; nothing has been applied. \ diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 00000000..b59a1e24 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,9 @@ +//! The SQLite database: the connection and its foundation repositories, the +//! catalogue repository, the user-data change journal, the natural-order +//! collation registered on the pool, and the copy taken before a migration. + +pub mod catalog; +pub mod database; +pub mod migration_backup; +pub mod natural_order; +pub mod sync; diff --git a/src/storage/sync/mod.rs b/src/storage/sync/mod.rs index 834b626a..612f6934 100644 --- a/src/storage/sync/mod.rs +++ b/src/storage/sync/mod.rs @@ -11,8 +11,8 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::{ - authentication::now_ms, - database::{Database, SyncOperationReservation}, + auth::authentication::now_ms, + storage::database::{Database, SyncOperationReservation}, }; pub const DEFAULT_SYNC_LIMIT: i64 = 100; diff --git a/src/subsonic/auth.rs b/src/subsonic/auth.rs index cce95b7b..ec1b1eac 100644 --- a/src/subsonic/auth.rs +++ b/src/subsonic/auth.rs @@ -3,7 +3,7 @@ //! Split out of `subsonic.rs`; the wire contract is frozen, so this moved nothing. use super::*; -use crate::sign_in::{Refusal, Surface}; +use crate::auth::sign_in::{Refusal, Surface}; pub(super) async fn authenticate( state: &AppState, @@ -28,7 +28,8 @@ pub(super) async fn authenticate( } let username = params.first("u").ok_or_else(missing)?; - let Ok(reservation) = guard.reserve(address, &crate::sign_in::canonical_account(username)) + let Ok(reservation) = + guard.reserve(address, &crate::auth::sign_in::canonical_account(username)) else { guard.note(Surface::Subsonic, Refusal::Throttled); return Err(auth_error()); diff --git a/src/subsonic/lyrics.rs b/src/subsonic/lyrics.rs index 98c5cf88..50913053 100644 --- a/src/subsonic/lyrics.rs +++ b/src/subsonic/lyrics.rs @@ -57,7 +57,7 @@ pub(super) async fn get_lyrics_by_song_id( .children(lyrics.structured_lyrics.iter().map(structured_lyrics_node))) } -pub(super) fn structured_lyrics_node(lyrics: &crate::lyrics::StructuredLyrics) -> Node { +pub(super) fn structured_lyrics_node(lyrics: &crate::library::lyrics::StructuredLyrics) -> Node { Node::new("structuredLyrics") .maybe_attr("displayArtist", lyrics.display_artist.clone()) .attr("displayTitle", lyrics.display_title.clone()) diff --git a/src/subsonic/media.rs b/src/subsonic/media.rs index 99cf2042..a18ff113 100644 --- a/src/subsonic/media.rs +++ b/src/subsonic/media.rs @@ -81,7 +81,7 @@ pub(super) async fn media_response( // other server ignores — the operator's default otherwise. Only a // transcode carries it; the original is never re-encoded to apply it. let replay_gain = match params.first("replayGain") { - Some(mode) => crate::replay_gain::ReplayGainMode::parse(mode) + Some(mode) => crate::media::replay_gain::ReplayGainMode::parse(mode) .ok_or_else(|| invalid("Invalid replayGain"))?, None => state.media.subsonic_replay_gain(), }; diff --git a/src/subsonic/mod.rs b/src/subsonic/mod.rs index c650beed..bdff241f 100644 --- a/src/subsonic/mod.rs +++ b/src/subsonic/mod.rs @@ -15,13 +15,13 @@ use serde_json::{Map, Value}; use uuid::Uuid; use crate::{ - database::AccountRole, + auth::security, media::{MediaError, OutputFormat, StreamQuery}, - security, services::{ AlbumItem, AlbumListQuery, AlbumOrder, ArtistItem, ArtistSummary, BrowsePage, PlaylistItem, ServiceError, SongItem, }, + storage::database::AccountRole, AppState, }; @@ -184,7 +184,7 @@ struct ProtocolError { pub fn router(state: AppState) -> Router { // `/rest` carries none of the security headers: the façade's wire // behaviour is frozen (RFC-019 decision 2). The public shares, which do - // carry them, are served by `crate::public_share` since RFC-020. + // carry them, are served by `crate::http::public_share` since RFC-020. Router::new() .route("/rest/{method}", get(handle).post(handle)) .with_state(state) @@ -198,7 +198,7 @@ pub async fn handle( let request_method = request.method().clone(); let query = request.uri().query().unwrap_or_default().to_owned(); // Read before the body is: a POST's request is consumed below. - let address = crate::sign_in::request_bucket( + let address = crate::auth::sign_in::request_bucket( request.extensions(), request.headers(), &state.trusted_proxies, diff --git a/tests/auth/main.rs b/tests/auth/main.rs index 0ea86974..f557b578 100644 --- a/tests/auth/main.rs +++ b/tests/auth/main.rs @@ -9,11 +9,11 @@ use axum::http::StatusCode; use sqlx::Row; use tower::ServiceExt; use uuid::Uuid; -use waveflow_server::authentication::now_ms; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. diff --git a/tests/auth/oauth.rs b/tests/auth/oauth.rs index 657b5903..1a43e3b9 100644 --- a/tests/auth/oauth.rs +++ b/tests/auth/oauth.rs @@ -54,7 +54,7 @@ async fn pkce_authorization_grants_a_native_session_exactly_once() { let token = login_token(&router, "pkce-user", password).await; let verifier = "H1r8mQ2xY7pL4vC0nB6zK9tW3sD5gJ8fA2eR7uI1oP4"; - let challenge = waveflow_server::oauth::challenge_for(verifier); + let challenge = waveflow_server::auth::oauth::challenge_for(verifier); let redirect_uri = "http://127.0.0.1:49152/callback"; let authorize = |body: serde_json::Value, bearer: Option| { diff --git a/tests/auth/sessions.rs b/tests/auth/sessions.rs index 4cae5677..e67126d5 100644 --- a/tests/auth/sessions.rs +++ b/tests/auth/sessions.rs @@ -375,7 +375,7 @@ async fn a_disabled_account_s_tokens_and_grants_all_stop_working() { let grant = serde_json::json!({ "client_id": "com.waveflow.desktop", "redirect_uri": redirect_uri, - "code_challenge": waveflow_server::oauth::challenge_for(&verifier), + "code_challenge": waveflow_server::auth::oauth::challenge_for(&verifier), "code_challenge_method": "S256", "device_name": "Disabled Desktop" }); diff --git a/tests/auth/setup.rs b/tests/auth/setup.rs index eb382e65..fe195897 100644 --- a/tests/auth/setup.rs +++ b/tests/auth/setup.rs @@ -176,7 +176,7 @@ fn setup_token_on_disk(config: &waveflow_server::Config) -> String { std::fs::read_to_string( config .data_dir - .join(waveflow_server::setup_token::FILE_NAME), + .join(waveflow_server::auth::setup_token::FILE_NAME), ) .expect("a fresh instance writes its setup token") .trim() @@ -203,7 +203,7 @@ async fn setup_is_refused_without_the_token_and_forgets_it_once_used() { let router = waveflow_server::app(&config, state.clone()); let token_file = config .data_dir - .join(waveflow_server::setup_token::FILE_NAME); + .join(waveflow_server::auth::setup_token::FILE_NAME); let token = setup_token_on_disk(&config); let password = Uuid::new_v4().to_string(); @@ -286,7 +286,7 @@ async fn an_operator_chosen_setup_token_replaces_the_drawn_one() { let router = waveflow_server::app(&config, state); assert!(!config .data_dir - .join(waveflow_server::setup_token::FILE_NAME) + .join(waveflow_server::auth::setup_token::FILE_NAME) .exists()); let password = Uuid::new_v4().to_string(); diff --git a/tests/auth/subsonic_credential.rs b/tests/auth/subsonic_credential.rs index 3c19dce7..82d435d7 100644 --- a/tests/auth/subsonic_credential.rs +++ b/tests/auth/subsonic_credential.rs @@ -75,7 +75,7 @@ async fn an_account_rotates_or_switches_off_its_own_subsonic_credential() { state .db .create_api_token( - waveflow_server::database::NewApiToken { + waveflow_server::storage::database::NewApiToken { actor_id: listener, user_id: listener, name: "a narrowed client", @@ -91,7 +91,7 @@ async fn an_account_rotates_or_switches_off_its_own_subsonic_credential() { state .db .create_api_token( - waveflow_server::database::NewApiToken { + waveflow_server::storage::database::NewApiToken { actor_id: listener, user_id: listener, name: "a client with a deadline", diff --git a/tests/auth/throttling.rs b/tests/auth/throttling.rs index 93d91064..5e20bcf7 100644 --- a/tests/auth/throttling.rs +++ b/tests/auth/throttling.rs @@ -120,7 +120,7 @@ async fn subsonic_sign_in(router: &axum::Router, credentials: &str, peer: &str) #[tokio::test] async fn failed_sign_ins_are_limited_per_address_and_account() { let (_temp, app) = guarded(&[]).await; - for _ in 0..waveflow_server::sign_in::FAILURES_PER_WINDOW { + for _ in 0..waveflow_server::auth::sign_in::FAILURES_PER_WINDOW { let refused = native_sign_in(&app.router, "Guarded", &app.wrong, "198.51.100.7", None).await; assert_eq!(refused.status(), StatusCode::UNAUTHORIZED); @@ -189,10 +189,10 @@ async fn concurrent_guesses_cannot_slip_past_the_limit() { .iter() .filter(|status| **status == StatusCode::TOO_MANY_REQUESTS) .count(); - assert_eq!(checked, waveflow_server::sign_in::FAILURES_PER_WINDOW); + assert_eq!(checked, waveflow_server::auth::sign_in::FAILURES_PER_WINDOW); assert_eq!( throttled, - 40 - waveflow_server::sign_in::FAILURES_PER_WINDOW + 40 - waveflow_server::auth::sign_in::FAILURES_PER_WINDOW ); } @@ -244,7 +244,7 @@ async fn an_unknown_account_costs_a_verification() { #[tokio::test] async fn subsonic_failures_are_limited_per_address_and_case_folded() { let (_temp, app) = guarded(&[]).await; - for index in 0..waveflow_server::sign_in::FAILURES_PER_WINDOW { + for index in 0..waveflow_server::auth::sign_in::FAILURES_PER_WINDOW { let name = if index % 2 == 0 { "Guarded" } else { "GUARDED" }; assert_eq!( subsonic_sign_in(&app.router, &format!("u={name}&p=wrong"), "198.51.100.7").await, @@ -301,7 +301,7 @@ async fn a_trusted_proxy_names_the_client_and_a_stranger_cannot() { .status() } }; - for index in 0..waveflow_server::sign_in::FAILURES_PER_WINDOW { + for index in 0..waveflow_server::auth::sign_in::FAILURES_PER_WINDOW { let forwarded = format!("192.0.2.{index}, 198.51.100.7"); assert_eq!( sign_in(app.wrong.clone(), "10.0.0.2", forwarded).await, @@ -322,7 +322,7 @@ async fn a_trusted_proxy_names_the_client_and_a_stranger_cannot() { StatusCode::OK ); - for index in 0..waveflow_server::sign_in::FAILURES_PER_WINDOW { + for index in 0..waveflow_server::auth::sign_in::FAILURES_PER_WINDOW { assert_eq!( sign_in( app.wrong.clone(), @@ -395,7 +395,7 @@ async fn an_unreadable_hash_is_not_a_line_per_attempt() { let router = waveflow_server::app(&config, state); let password = Uuid::new_v4().to_string(); - for _ in 0..waveflow_server::sign_in::FAILURES_PER_WINDOW + 5 { + for _ in 0..waveflow_server::auth::sign_in::FAILURES_PER_WINDOW + 5 { let refused = native_sign_in(&router, "Damaged", &password, "198.51.100.40", None).await; assert_eq!(refused.status(), StatusCode::SERVICE_UNAVAILABLE); } diff --git a/tests/auth/tokens.rs b/tests/auth/tokens.rs index d64af5ab..00fadce9 100644 --- a/tests/auth/tokens.rs +++ b/tests/auth/tokens.rs @@ -18,7 +18,7 @@ async fn long_lived_native_api_tokens_authenticate_and_honor_revocation() { state .db .create_api_token( - waveflow_server::database::NewApiToken { + waveflow_server::storage::database::NewApiToken { // The actor the audit entry records: here the account itself. actor_id: user_id, user_id, @@ -103,7 +103,7 @@ async fn an_account_manages_its_own_clients_and_cannot_widen_one() { let theirs = state .db .create_api_token( - waveflow_server::database::NewApiToken { + waveflow_server::storage::database::NewApiToken { // The actor the audit entry records. actor_id: stranger, user_id: stranger, @@ -122,7 +122,7 @@ async fn an_account_manages_its_own_clients_and_cannot_widen_one() { state .db .create_api_token( - waveflow_server::database::NewApiToken { + waveflow_server::storage::database::NewApiToken { // The actor the audit entry records: here the account itself. actor_id: owner, user_id: owner, @@ -305,7 +305,7 @@ async fn a_self_minted_credential_expires_and_the_admin_path_is_bounded_too() { state .db .create_api_token( - waveflow_server::database::NewApiToken { + waveflow_server::storage::database::NewApiToken { // The actor the audit entry records: here the account itself. actor_id: boss, user_id: boss, @@ -405,7 +405,7 @@ async fn a_self_minted_credential_expires_and_the_admin_path_is_bounded_too() { state .db .create_api_token( - waveflow_server::database::NewApiToken { + waveflow_server::storage::database::NewApiToken { // The actor the audit entry records: here the account itself. actor_id: owner_of_ttl_user, user_id: owner_of_ttl_user, diff --git a/tests/canvas.rs b/tests/canvas.rs index 148a3918..fd7a665f 100644 --- a/tests/canvas.rs +++ b/tests/canvas.rs @@ -9,12 +9,12 @@ use axum::body::Body; use axum::http::Request; use axum::http::StatusCode; use tower::ServiceExt; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::LibraryRecord; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; use waveflow_server::config::CanvasLimits; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::storage::catalog::LibraryRecord; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. @@ -792,9 +792,9 @@ async fn a_ticket_opens_the_canvas_it_was_minted_for_and_nothing_else() { // The two kinds do not answer for each other, which is the whole reason the // kind is sealed inside the payload. An audio ticket for this very track, // minted for this very account and unexpired, does not open the canvas. - let audio = waveflow_server::stream_ticket::mint( + let audio = waveflow_server::auth::stream_ticket::mint( &state.secret_box, - waveflow_server::stream_ticket::TicketKind::Audio, + waveflow_server::auth::stream_ticket::TicketKind::Audio, fixture.owner, track, now_ms() + 60_000, @@ -1457,9 +1457,9 @@ async fn a_disabled_account_s_canvas_ticket_opens_nothing() { ) .await .unwrap(); - let ticket = waveflow_server::stream_ticket::mint( + let ticket = waveflow_server::auth::stream_ticket::mint( &state.secret_box, - waveflow_server::stream_ticket::TicketKind::Canvas, + waveflow_server::auth::stream_ticket::TicketKind::Canvas, owner, track, now_ms() + 60_000, diff --git a/tests/catalog/credits.rs b/tests/catalog/credits.rs index ede2832c..ff50eeb8 100644 --- a/tests/catalog/credits.rs +++ b/tests/catalog/credits.rs @@ -292,11 +292,11 @@ async fn a_contributor_is_not_one_of_the_track_artists() { input.is_compilation = false; input.roles = vec![ ( - waveflow_server::tags::Role::Producer, + waveflow_server::library::tags::Role::Producer, vec!["Rita Sound".into()], ), ( - waveflow_server::tags::Role::Composer, + waveflow_server::library::tags::Role::Composer, vec!["Otto Pen".into()], ), ]; diff --git a/tests/catalog/main.rs b/tests/catalog/main.rs index 9d2a686a..630990e4 100644 --- a/tests/catalog/main.rs +++ b/tests/catalog/main.rs @@ -6,11 +6,11 @@ use axum::body::Body; use axum::http::Request; use axum::http::StatusCode; use tower::ServiceExt; -use waveflow_server::authentication::now_ms; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; use waveflow_server::services::ServiceError; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. diff --git a/tests/cli.rs b/tests/cli.rs index a8c15d5f..8308a561 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,7 +6,7 @@ //! two streams — not by a request. use clap::Parser; -use waveflow_server::authentication::now_ms; +use waveflow_server::auth::authentication::now_ms; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. @@ -652,7 +652,7 @@ async fn sealed_installation( owner, name, &root, - waveflow_server::database::LibraryVisibility::Private, + waveflow_server::storage::database::LibraryVisibility::Private, now_ms(), ) .await @@ -660,7 +660,7 @@ async fn sealed_installation( run_scan( state, owner, - waveflow_server::catalog::LibraryRecord { + waveflow_server::storage::catalog::LibraryRecord { id: library, name: name.into(), root_path: root, @@ -857,7 +857,7 @@ async fn rotating_the_key_keeps_every_secret_and_re_points_the_shares() { "the recovery bundle must hold the key as it was" ); assert!( - waveflow_server::database::Database::check_file(&recovery.join("waveflow.db")) + waveflow_server::storage::database::Database::check_file(&recovery.join("waveflow.db")) .await .unwrap_or(false), "the recovery copy must be a sound database" @@ -1180,8 +1180,8 @@ async fn database_rescan_full_asks_every_library_to_read_everything() { owner, "Rescan", &std::fs::canonicalize(&music).unwrap(), - waveflow_server::database::LibraryVisibility::Private, - waveflow_server::authentication::now_ms(), + waveflow_server::storage::database::LibraryVisibility::Private, + waveflow_server::auth::authentication::now_ms(), ) .await .unwrap(); @@ -1209,7 +1209,7 @@ async fn the_cli_renames_moves_purges_and_deletes_a_library() { admin, "Lifecycle", &root, - waveflow_server::database::LibraryVisibility::Private, + waveflow_server::storage::database::LibraryVisibility::Private, now_ms(), ) .await @@ -1218,7 +1218,7 @@ async fn the_cli_renames_moves_purges_and_deletes_a_library() { scan_once( &state, admin, - waveflow_server::catalog::LibraryRecord { + waveflow_server::storage::catalog::LibraryRecord { id: library, name: "Lifecycle".into(), root_path: root.clone(), diff --git a/tests/credits.rs b/tests/credits.rs index 284e04ea..996a15b7 100644 --- a/tests/credits.rs +++ b/tests/credits.rs @@ -14,12 +14,12 @@ use serde_json::json; use support::*; use tower::ServiceExt; use uuid::Uuid; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::CatalogTrackInput; -use waveflow_server::database::{LibraryRole, LibraryVisibility}; +use waveflow_server::auth::authentication::now_ms; use waveflow_server::services::{ AttentionCategory, BrowsePage, CreditDecisionKind, TrackMetadataPatch, }; +use waveflow_server::storage::catalog::CatalogTrackInput; +use waveflow_server::storage::database::{LibraryRole, LibraryVisibility}; use waveflow_server::AppState; /// One credited row: track, role, position, name, sort form. diff --git a/tests/discovery.rs b/tests/discovery.rs index dbc3c3bf..08efa2c9 100644 --- a/tests/discovery.rs +++ b/tests/discovery.rs @@ -31,8 +31,8 @@ use axum::{ use uuid::Uuid; use waveflow_server::{ config::LyricsSettings, - lrclib::{LyricsLookup, LyricsProvider}, - lyrics::LyricsInput, + library::lyrics::LyricsInput, + remote::lrclib::{LyricsLookup, LyricsProvider}, services::ServiceError, AppState, Config, }; @@ -41,7 +41,7 @@ use waveflow_server::{ async fn account(state: &AppState, username: &str) -> Uuid { let id = Uuid::new_v4(); - let now = waveflow_server::authentication::now_ms(); + let now = waveflow_server::auth::authentication::now_ms(); sqlx::query( "INSERT INTO account (id, username, password_hash, role, disabled, created_at, updated_at) \ VALUES (?, ?, 'this-account-never-authenticates', 'admin', 0, ?, ?)", @@ -92,7 +92,7 @@ async fn library( files: &[File], ) -> (Uuid, Vec) { static NEXT: AtomicUsize = AtomicUsize::new(0); - let now = waveflow_server::authentication::now_ms(); + let now = waveflow_server::auth::authentication::now_ms(); let root = config.data_dir.join(name); std::fs::create_dir_all(&root).unwrap(); let library = state @@ -101,7 +101,7 @@ async fn library( owner, name, &std::fs::canonicalize(&root).unwrap(), - waveflow_server::database::LibraryVisibility::Private, + waveflow_server::storage::database::LibraryVisibility::Private, now, ) .await @@ -184,8 +184,8 @@ async fn subsonic_key(state: &AppState, user: Uuid) -> String { user, user, &state.secret_box.encrypt(password.as_bytes()).unwrap(), - &waveflow_server::security::token_hash(&api_key), - waveflow_server::authentication::now_ms(), + &waveflow_server::auth::security::token_hash(&api_key), + waveflow_server::auth::authentication::now_ms(), ) .await .unwrap(); @@ -201,7 +201,7 @@ async fn get_json(router: &Router, uri: &str, token: &str) -> (StatusCode, serde /// A listen of `track` by `user`, `ago` milliseconds before now. async fn listen(state: &AppState, user: Uuid, track: Uuid, ago: i64) { - let at = waveflow_server::authentication::now_ms() - ago; + let at = waveflow_server::auth::authentication::now_ms() - ago; state .services .scrobble(user, track, true, Some(at)) @@ -387,7 +387,7 @@ async fn shelf() -> Shelf { .upload_lyrics( owner, ids[2], - waveflow_server::lyrics::UploadFormat::Txt, + waveflow_server::library::lyrics::UploadFormat::Txt, b"My name Isobel", ) .await @@ -1065,7 +1065,7 @@ async fn forgotten_favourites_are_old_stars_not_listened_to_lately() { .map(|n| file(&format!("Star {n}"), &format!("Starred {n}"), None, None)) .collect(); let (_, ids) = library(&state, &config, me, "mix-forgotten", &files).await; - let now = waveflow_server::authentication::now_ms(); + let now = waveflow_server::auth::authentication::now_ms(); for (track, starred_ago) in [(ids[0], 60 * DAY), (ids[1], DAY), (ids[2], 60 * DAY)] { sqlx::query( "INSERT INTO user_star (user_id, entity_type, entity_id, starred_at) \ diff --git a/tests/enrichment/banners.rs b/tests/enrichment/banners.rs index fc9f3394..39afa86b 100644 --- a/tests/enrichment/banners.rs +++ b/tests/enrichment/banners.rs @@ -181,7 +181,7 @@ async fn a_banner_is_the_first_real_fanart_and_is_shown_on_every_artist() { assert_eq!(linked[1].as_deref(), Some(hash.as_str())); assert_eq!( hash, - waveflow_server::portrait::Portrait::from_bytes(fake.picture("second.jpg")) + waveflow_server::remote::portrait::Portrait::from_bytes(fake.picture("second.jpg")) .unwrap() .hash ); diff --git a/tests/enrichment/image_search.rs b/tests/enrichment/image_search.rs index 59c0eb44..1de1815f 100644 --- a/tests/enrichment/image_search.rs +++ b/tests/enrichment/image_search.rs @@ -190,12 +190,12 @@ async fn send( #[tokio::test] async fn candidates_are_previewed_by_the_server_and_picked_from_a_live_search() { use axum::http::Method; - use waveflow_server::database::{AccountRole, LibraryRole}; + use waveflow_server::storage::database::{AccountRole, LibraryRole}; let (_temp, config, state, gallery, owner, bjork) = picker(false, true).await; let password = uuid::Uuid::new_v4().to_string(); - let hash = waveflow_server::security::hash_password(&password).unwrap(); - let now = waveflow_server::authentication::now_ms(); + let hash = waveflow_server::auth::security::hash_password(&password).unwrap(); + let now = waveflow_server::auth::authentication::now_ms(); sqlx::query("UPDATE account SET username='gallery-owner', password_hash=? WHERE id=?") .bind(&hash) .bind(owner.to_string()) @@ -487,7 +487,7 @@ async fn a_search_stops_at_its_deadline() { #[tokio::test] async fn the_sweep_takes_spent_searches_and_their_previews_only() { let (_temp, _config, state, _gallery, owner, bjork) = picker(false, true).await; - let now = waveflow_server::authentication::now_ms(); + let now = waveflow_server::auth::authentication::now_ms(); let stale = uuid::Uuid::new_v4().to_string(); let fetching = uuid::Uuid::new_v4().to_string(); for (id, by, at) in [ @@ -569,7 +569,7 @@ async fn the_sweep_takes_spent_searches_and_their_previews_only() { #[tokio::test] async fn boot_deletes_the_searches_left_fetching() { let (_temp, config, state, _gallery, owner, bjork) = picker(false, true).await; - let now = waveflow_server::authentication::now_ms(); + let now = waveflow_server::auth::authentication::now_ms(); for (complete, expires_at) in [(0, None), (1, Some(now + 60 * 60 * 1000))] { sqlx::query( "INSERT INTO artist_image_search \ @@ -630,7 +630,7 @@ async fn previews_keep_deezer_s_pace() { async fn a_search_outlives_the_ordinary_request_timeout() { let (_temp, config, state, gallery, owner, bjork) = picker(false, false).await; let password = uuid::Uuid::new_v4().to_string(); - let hash = waveflow_server::security::hash_password(&password).unwrap(); + let hash = waveflow_server::auth::security::hash_password(&password).unwrap(); sqlx::query("UPDATE account SET username='slow-owner', password_hash=? WHERE id=?") .bind(&hash) .bind(owner.to_string()) diff --git a/tests/enrichment/lastfm.rs b/tests/enrichment/lastfm.rs index aa674bc2..02cfeb60 100644 --- a/tests/enrichment/lastfm.rs +++ b/tests/enrichment/lastfm.rs @@ -158,7 +158,7 @@ async fn no_more_than_the_ceiling_of_fills_is_ever_queued() { &listener, ); - let names: Vec = (0..=waveflow_server::enrichment::MAX_IN_FLIGHT) + let names: Vec = (0..=waveflow_server::remote::enrichment::MAX_IN_FLIGHT) .map(|index| format!("Artist {index}")) .collect(); let visits = names.iter().map(|name| enrichment.artist(name)); @@ -169,7 +169,7 @@ async fn no_more_than_the_ceiling_of_fills_is_ever_queued() { assert_eq!( listener.fake.info.load(Ordering::SeqCst), - waveflow_server::enrichment::MAX_IN_FLIGHT + waveflow_server::remote::enrichment::MAX_IN_FLIGHT ); } diff --git a/tests/enrichment/main.rs b/tests/enrichment/main.rs index 5a279815..af2ae832 100644 --- a/tests/enrichment/main.rs +++ b/tests/enrichment/main.rs @@ -31,8 +31,8 @@ use axum::{ }; use waveflow_server::{ config::MetadataSettings, - enrichment::{Endpoints, Enrichment, Outcome, Provider}, - portrait::AddressPolicy, + remote::enrichment::{Endpoints, Enrichment, Outcome, Provider}, + remote::portrait::AddressPolicy, }; mod artist_info; @@ -179,7 +179,7 @@ fn engine( // token and a Subsonic key minted at run time. async fn account(state: &waveflow_server::AppState, username: &str) -> uuid::Uuid { let id = uuid::Uuid::new_v4(); - let now = waveflow_server::authentication::now_ms(); + let now = waveflow_server::auth::authentication::now_ms(); sqlx::query( "INSERT INTO account (id, username, password_hash, role, disabled, created_at, updated_at) \ VALUES (?, ?, 'this-account-never-authenticates', 'admin', 0, ?, ?)", @@ -215,7 +215,7 @@ async fn library_with( name: &str, artists: &[(&str, &str)], ) { - let now = waveflow_server::authentication::now_ms(); + let now = waveflow_server::auth::authentication::now_ms(); let root = config.data_dir.join(name); std::fs::create_dir_all(&root).unwrap(); let library = state @@ -224,7 +224,7 @@ async fn library_with( owner, name, &std::fs::canonicalize(&root).unwrap(), - waveflow_server::database::LibraryVisibility::Private, + waveflow_server::storage::database::LibraryVisibility::Private, now, ) .await @@ -311,8 +311,8 @@ async fn subsonic_key(state: &waveflow_server::AppState, user: uuid::Uuid) -> St user, user, &state.secret_box.encrypt(password.as_bytes()).unwrap(), - &waveflow_server::security::token_hash(&api_key), - waveflow_server::authentication::now_ms(), + &waveflow_server::auth::security::token_hash(&api_key), + waveflow_server::auth::authentication::now_ms(), ) .await .unwrap(); diff --git a/tests/identity.rs b/tests/identity.rs index d428013a..2de241cb 100644 --- a/tests/identity.rs +++ b/tests/identity.rs @@ -3,11 +3,11 @@ //! Split out of `v2_foundations.rs`. use tempfile::TempDir; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::LibraryRecord; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; +use waveflow_server::storage::catalog::LibraryRecord; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryVisibility; use waveflow_server::Config; // Not every target uses every fixture, and a shared module is not dead @@ -301,10 +301,10 @@ async fn a_changed_track_spec_drops_every_relocation_hint_without_a_rescan() { // computed under another: two specs can evaluate to the same string, and // the hint would then hand a new file this track's identity — and this // track's favourite. So the stale values go. - let altered = waveflow_server::pid::PidSpecs { + let altered = waveflow_server::library::pid::PidSpecs { album: config.pid.album.clone(), artist: config.pid.artist.clone(), - track: waveflow_server::pid::PidSpec::parse("folder,title", true).unwrap(), + track: waveflow_server::library::pid::PidSpec::parse("folder,title", true).unwrap(), }; let rescans = state.db.reconcile_catalog_identity(&altered).await.unwrap(); assert_eq!( @@ -398,10 +398,11 @@ async fn a_changed_artist_spec_carries_the_favourite_onto_the_new_identifier() { .await .unwrap(); - let altered = waveflow_server::pid::PidSpecs { + let altered = waveflow_server::library::pid::PidSpecs { album: config.pid.album.clone(), track: config.pid.track.clone(), - artist: waveflow_server::pid::PidSpec::parse("albumartistid,title", false).unwrap(), + artist: waveflow_server::library::pid::PidSpec::parse("albumartistid,title", false) + .unwrap(), }; let expected = altered.artist_id(library_id, "Nova Kern"); assert_ne!( @@ -434,10 +435,11 @@ async fn a_remap_whose_targets_chain_does_not_change_who_owns_what() { let (_temp, config, state) = test_app().await; let (owner, library_id) = remap_fixture(&state, &config, "chained").await; - let altered = waveflow_server::pid::PidSpecs { + let altered = waveflow_server::library::pid::PidSpecs { album: config.pid.album.clone(), track: config.pid.track.clone(), - artist: waveflow_server::pid::PidSpec::parse("albumartistid,title", false).unwrap(), + artist: waveflow_server::library::pid::PidSpec::parse("albumartistid,title", false) + .unwrap(), }; // Built by hand: no spec this engine can parse makes one artist's new // identifier another's old one, because only `albumartistid` carries a @@ -474,10 +476,10 @@ async fn two_artists_folding_onto_one_identifier_keep_a_single_favourite() { // `title` names nothing for an artist, so every artist evaluates to the // same empty string and the whole library folds onto one identifier. A // degenerate spec, and exactly the shape the collision handling is for. - let altered = waveflow_server::pid::PidSpecs { + let altered = waveflow_server::library::pid::PidSpecs { album: config.pid.album.clone(), track: config.pid.track.clone(), - artist: waveflow_server::pid::PidSpec::parse("title", false).unwrap(), + artist: waveflow_server::library::pid::PidSpec::parse("title", false).unwrap(), }; let folded = altered.artist_id(library_id, "Fold One"); assert_eq!(folded, altered.artist_id(library_id, "Fold Two")); diff --git a/tests/media/main.rs b/tests/media/main.rs index 771520b8..e3371548 100644 --- a/tests/media/main.rs +++ b/tests/media/main.rs @@ -7,11 +7,11 @@ use axum::http::Request; use axum::http::StatusCode; use http_body_util::BodyExt; use tower::ServiceExt; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::LibraryRecord; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; +use waveflow_server::storage::catalog::LibraryRecord; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryVisibility; use waveflow_server::Config; // Not every target uses every fixture, and a shared module is not dead diff --git a/tests/media/stream_tickets.rs b/tests/media/stream_tickets.rs index bdbd770e..bd601f8a 100644 --- a/tests/media/stream_tickets.rs +++ b/tests/media/stream_tickets.rs @@ -192,9 +192,9 @@ async fn stream_tickets_authorise_browser_playback_without_a_bearer() { } // An expired ticket is refused even though it is otherwise well formed. - let expired = waveflow_server::stream_ticket::mint( + let expired = waveflow_server::auth::stream_ticket::mint( &secret_box, - waveflow_server::stream_ticket::TicketKind::Audio, + waveflow_server::auth::stream_ticket::TicketKind::Audio, owner, uuid::Uuid::parse_str(&track_id).unwrap(), now_ms() - 1, @@ -215,9 +215,9 @@ async fn stream_tickets_authorise_browser_playback_without_a_bearer() { // track by this very account under the instance key, and unexpired. The // kind is the only thing separating it from the ticket that works two // assertions above — which is the whole reason the byte exists. - let wrong_kind = waveflow_server::stream_ticket::mint( + let wrong_kind = waveflow_server::auth::stream_ticket::mint( &secret_box, - waveflow_server::stream_ticket::TicketKind::Canvas, + waveflow_server::auth::stream_ticket::TicketKind::Canvas, owner, uuid::Uuid::parse_str(&track_id).unwrap(), now_ms() + 60_000, diff --git a/tests/migrations.rs b/tests/migrations.rs index 5863bf4f..69b24a9e 100644 --- a/tests/migrations.rs +++ b/tests/migrations.rs @@ -26,10 +26,10 @@ use sqlx::migrate::Migrate; use sqlx::Connection; use sqlx::SqliteConnection; -use waveflow_server::database::Database; -use waveflow_server::database::MIGRATOR; use waveflow_server::services::AlbumListQuery; use waveflow_server::services::BrowsePage; +use waveflow_server::storage::database::Database; +use waveflow_server::storage::database::MIGRATOR; use waveflow_server::Config; /// The schema each released tag left on disk, named by its last migration. @@ -704,7 +704,7 @@ async fn a_key_change_reads_without_migrating_and_copies_before_it_migrates() { sqlx::query( "INSERT INTO instance_metadata (singleton, key_fingerprint, created_at) VALUES (1, ?, 0)", ) - .bind(waveflow_server::security::bytes_hash(&key).as_slice()) + .bind(waveflow_server::auth::security::bytes_hash(&key).as_slice()) .execute(&mut *connection) .await .expect("bind the key"); @@ -813,7 +813,7 @@ async fn an_installation_behind(config: &Config, extra: Option<&'static str>) -> sqlx::query( "INSERT INTO instance_metadata (singleton, key_fingerprint, created_at) VALUES (1, ?, 0)", ) - .bind(waveflow_server::security::bytes_hash(&key).as_slice()) + .bind(waveflow_server::auth::security::bytes_hash(&key).as_slice()) .execute(&mut *connection) .await .expect("bind the key"); @@ -842,7 +842,7 @@ fn pre_migration_copies(config: &Config) -> Vec { let taken_at = path .file_name()? .to_str()? - .strip_prefix(waveflow_server::migration_backup::PREFIX)? + .strip_prefix(waveflow_server::storage::migration_backup::PREFIX)? .parse::() .ok()?; Some((taken_at, path)) @@ -864,13 +864,13 @@ async fn an_upgrade_copies_the_database_and_its_key_before_migrating() { for stale in ["1", "2", "3", "4"] { std::fs::create_dir_all(config.data_dir.join(format!( "{}{stale}", - waveflow_server::migration_backup::PREFIX + waveflow_server::storage::migration_backup::PREFIX ))) .unwrap(); } let lookalike = config.data_dir.join(format!( "{}notes", - waveflow_server::migration_backup::PREFIX + waveflow_server::storage::migration_backup::PREFIX )); let restore = config.data_dir.join("pre-restore-5"); std::fs::create_dir_all(&lookalike).unwrap(); @@ -903,13 +903,13 @@ async fn an_upgrade_copies_the_database_and_its_key_before_migrating() { assert!( Database::check_file_instance_key( &newest.join("waveflow.db"), - &waveflow_server::security::bytes_hash(&key) + &waveflow_server::auth::security::bytes_hash(&key) ) .await .unwrap(), "the copied pair does not agree" ); - let prefix = waveflow_server::migration_backup::PREFIX; + let prefix = waveflow_server::storage::migration_backup::PREFIX; for gone in ["1", "2"] { assert!(!config.data_dir.join(format!("{prefix}{gone}")).exists()); } @@ -1045,7 +1045,7 @@ async fn the_copy_just_made_survives_a_clock_that_went_back() { let temp = tempfile::tempdir().expect("temporary directory"); let config = Config::for_data_dir(temp.path().join("data")); let (_, behind) = an_installation_behind(&config, None).await; - let prefix = waveflow_server::migration_backup::PREFIX; + let prefix = waveflow_server::storage::migration_backup::PREFIX; // Three copies dated long after anything this boot can read off its clock. let future = ["99999999999991", "99999999999992", "99999999999993"]; for stamp in future { diff --git a/tests/native_api/artists.rs b/tests/native_api/artists.rs index 3531831c..e70e2728 100644 --- a/tests/native_api/artists.rs +++ b/tests/native_api/artists.rs @@ -42,7 +42,7 @@ async fn chosen_artist_images_lead_every_reader_and_outlive_the_portrait_fill() owner, library_id, listener, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now_ms(), ) .await @@ -475,7 +475,7 @@ async fn an_artist_page_groups_its_discography_and_lists_its_appearances() { input.track_number = Some(index as i64 + 1); if title == "Nine" { input.roles = vec![( - waveflow_server::tags::Role::Composer, + waveflow_server::library::tags::Role::Composer, vec!["Disco Artist".into()], )]; } diff --git a/tests/native_api/folders.rs b/tests/native_api/folders.rs index bfdf2976..57e2c026 100644 --- a/tests/native_api/folders.rs +++ b/tests/native_api/folders.rs @@ -38,7 +38,7 @@ async fn the_disk_tree_lists_one_level_of_the_indexed_paths_to_members_only() { owner, library_id, listener, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now_ms(), ) .await @@ -328,7 +328,7 @@ async fn filed_folders_sort_a_library_in_the_database_only() { owner, library, listener, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now_ms(), ) .await diff --git a/tests/native_api/libraries.rs b/tests/native_api/libraries.rs index d18aeba7..eaae9079 100644 --- a/tests/native_api/libraries.rs +++ b/tests/native_api/libraries.rs @@ -223,7 +223,7 @@ async fn account_with_token( state .db .create_api_token( - waveflow_server::database::NewApiToken { + waveflow_server::storage::database::NewApiToken { actor_id: id, user_id: id, name: "lifecycle", @@ -277,7 +277,7 @@ async fn the_library_lifecycle_routes_answer_by_standing() { owner, library_id, listener, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now_ms(), ) .await @@ -481,7 +481,7 @@ async fn a_narrowed_admin_token_has_no_say_over_a_foreign_library() { state .db .create_api_token( - waveflow_server::database::NewApiToken { + waveflow_server::storage::database::NewApiToken { actor_id: admin, user_id: admin, name: "write only", diff --git a/tests/native_api/lyrics.rs b/tests/native_api/lyrics.rs index bd44cf0c..9c05b09a 100644 --- a/tests/native_api/lyrics.rs +++ b/tests/native_api/lyrics.rs @@ -39,7 +39,7 @@ async fn uploaded_lyrics_lead_the_order_download_as_they_came_and_stay_off_subso owner, library_id, listener, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now_ms(), ) .await diff --git a/tests/native_api/main.rs b/tests/native_api/main.rs index bf8f2712..53fd707b 100644 --- a/tests/native_api/main.rs +++ b/tests/native_api/main.rs @@ -8,16 +8,16 @@ use axum::http::Request; use axum::http::StatusCode; use futures_util::future::BoxFuture; use tower::ServiceExt; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::LibraryRecord; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; use waveflow_server::services::ServiceError; use waveflow_server::services::MAX_HISTORY_LIMIT; use waveflow_server::services::{ ScrobbleEnvelope, ScrobbleProvider, ScrobbleTarget, ScrobbleVerdict, }; +use waveflow_server::storage::catalog::LibraryRecord; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. diff --git a/tests/native_api/playlists.rs b/tests/native_api/playlists.rs index 227a0762..d885a0a6 100644 --- a/tests/native_api/playlists.rs +++ b/tests/native_api/playlists.rs @@ -184,7 +184,7 @@ async fn a_public_playlist_is_read_by_other_accounts_through_their_own_libraries owner, shared, viewer, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now_ms(), ) .await diff --git a/tests/podcasts/covers.rs b/tests/podcasts/covers.rs index 5fe908ab..a2a8e0b0 100644 --- a/tests/podcasts/covers.rs +++ b/tests/podcasts/covers.rs @@ -185,7 +185,7 @@ async fn a_cover_that_is_not_a_safe_image_stores_nothing() { let router = waveflow_server::app(&config, state.clone()); let token = login_token(&router, "cover-guard", &password).await; - use waveflow_server::portrait::Refusal; + use waveflow_server::remote::portrait::Refusal; let cases = [ ("/huge.jpg", Refusal::TooLarge, "declared over the ceiling"), ( @@ -225,14 +225,14 @@ async fn a_cover_that_is_not_a_safe_image_stores_nothing() { // And each for its own reason, asked of the fetcher itself: a refusal and // an unreachable host store the same nothing, and only the reason tells // a checked hop from one that was attempted and happened to fail. - let policy = waveflow_server::podcast::FetchPolicy { - addresses: waveflow_server::portrait::AddressPolicy::AllowLoopback, + let policy = waveflow_server::remote::podcast::FetchPolicy { + addresses: waveflow_server::remote::portrait::AddressPolicy::AllowLoopback, allow_http: false, timeout: std::time::Duration::from_secs(10), }; for (cover, refusal, why) in cases { let url = url::Url::parse(&format!("{}{cover}", host.base)).unwrap(); - let fetched = waveflow_server::portrait::fetch_followed(&url, policy).await; + let fetched = waveflow_server::remote::portrait::fetch_followed(&url, policy).await; assert_eq!(fetched.err(), Some(refusal), "{why}"); } assert_eq!(artwork_rows(&state).await, 0); diff --git a/tests/podcasts/main.rs b/tests/podcasts/main.rs index c992ebe0..97c967a0 100644 --- a/tests/podcasts/main.rs +++ b/tests/podcasts/main.rs @@ -28,9 +28,9 @@ use axum::{ use serde_json::json; use tower::ServiceExt; use uuid::Uuid; -use waveflow_server::authentication::now_ms; -use waveflow_server::database::AccountRole; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; +use waveflow_server::storage::database::AccountRole; #[allow(dead_code)] #[path = "../support/mod.rs"] diff --git a/tests/quality.rs b/tests/quality.rs index c579e87e..a91d9679 100644 --- a/tests/quality.rs +++ b/tests/quality.rs @@ -20,10 +20,10 @@ use http_body_util::BodyExt; use support::*; use tower::ServiceExt; use uuid::Uuid; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::LibraryRecord; -use waveflow_server::database::{LibraryRole, LibraryVisibility}; +use waveflow_server::auth::authentication::now_ms; use waveflow_server::services::{AnalysisState, AttentionCategory, BrowsePage, QualityVerdict}; +use waveflow_server::storage::catalog::LibraryRecord; +use waveflow_server::storage::database::{LibraryRole, LibraryVisibility}; use waveflow_server::AppState; struct Shelf { diff --git a/tests/scanner/indexing.rs b/tests/scanner/indexing.rs index edf77117..5427ada6 100644 --- a/tests/scanner/indexing.rs +++ b/tests/scanner/indexing.rs @@ -598,7 +598,7 @@ async fn a_re_encoded_file_that_moved_keeps_its_track_and_its_favourite() { fn scan_progress_converts_into_the_shape_the_stream_sends() { let scan = uuid::Uuid::new_v4(); let library = uuid::Uuid::new_v4(); - let record = waveflow_server::scanner::ScanProgress { + let record = waveflow_server::library::scanner::ScanProgress { scan_id: scan, library_id: library, status: "running".into(), diff --git a/tests/scanner/lifecycle.rs b/tests/scanner/lifecycle.rs index af3c5eeb..a6f3fb3f 100644 --- a/tests/scanner/lifecycle.rs +++ b/tests/scanner/lifecycle.rs @@ -78,7 +78,7 @@ async fn the_lifecycle_follows_the_role_model() { owner, library.id, listener, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now_ms(), ) .await diff --git a/tests/scanner/main.rs b/tests/scanner/main.rs index 1092e9b0..40c94b86 100644 --- a/tests/scanner/main.rs +++ b/tests/scanner/main.rs @@ -3,12 +3,12 @@ //! Split out of `v2_foundations.rs`. use sqlx::Row; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::ApplyOutcome; -use waveflow_server::catalog::LibraryRecord; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; +use waveflow_server::storage::catalog::ApplyOutcome; +use waveflow_server::storage::catalog::LibraryRecord; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. @@ -72,7 +72,7 @@ async fn job( state: &waveflow_server::AppState, owner: uuid::Uuid, scan: uuid::Uuid, -) -> waveflow_server::catalog::ScanJobRecord { +) -> waveflow_server::storage::catalog::ScanJobRecord { state .db .scan_job_for_user(owner, scan) @@ -129,7 +129,7 @@ async fn settled( state: &waveflow_server::AppState, owner: uuid::Uuid, scan: uuid::Uuid, -) -> waveflow_server::catalog::ScanJobRecord { +) -> waveflow_server::storage::catalog::ScanJobRecord { for _ in 0..500 { let record = job(state, owner, scan).await; if record.status == "completed" || record.status == "failed" { diff --git a/tests/scanner/purges.rs b/tests/scanner/purges.rs index d6b58cab..b005fcdf 100644 --- a/tests/scanner/purges.rs +++ b/tests/scanner/purges.rs @@ -195,7 +195,7 @@ async fn a_purge_is_explicit_and_waits_for_no_scan() { owner, library.id, listener, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now_ms(), ) .await @@ -339,7 +339,7 @@ async fn a_purge_can_be_taken_back_and_tells_every_journal_when_it_lands() { owner, library.id, listener, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now_ms(), ) .await diff --git a/tests/scanner/scheduling.rs b/tests/scanner/scheduling.rs index a4bed3de..75b6811c 100644 --- a/tests/scanner/scheduling.rs +++ b/tests/scanner/scheduling.rs @@ -11,7 +11,7 @@ async fn a_watched_change_is_scanned_once_it_settles() { }) .await; scan_once(&state, owner, library.clone()).await; - let watcher = waveflow_server::watcher::spawn( + let watcher = waveflow_server::library::watcher::spawn( state.scanner.clone(), state.db.clone(), std::time::Duration::from_millis(200), @@ -88,11 +88,16 @@ async fn a_scan_the_watcher_cannot_queue_stays_pending() { }, )]); let delay = std::time::Duration::from_millis(10); - let mut settle = waveflow_server::watcher::Settle::new(delay); + let mut settle = waveflow_server::library::watcher::Settle::new(delay); let start = tokio::time::Instant::now(); settle.touch(ghost, start); - waveflow_server::watcher::dispatch_due(&mut settle, &watched, &state.scanner, start + delay) - .await; + waveflow_server::library::watcher::dispatch_due( + &mut settle, + &watched, + &state.scanner, + start + delay, + ) + .await; assert!( settle.is_pending(ghost), "a change whose scan could not be queued was dropped" @@ -319,7 +324,7 @@ async fn a_library_is_watched_only_when_it_asks_and_in_the_folder_that_changed() "a switch queues no scan" ); assert!(state.db.watched_libraries().await.unwrap().is_empty()); - let watcher = waveflow_server::watcher::spawn( + let watcher = waveflow_server::library::watcher::spawn( state.scanner.clone(), state.db.clone(), std::time::Duration::from_millis(200), diff --git a/tests/scrobbling/adapters.rs b/tests/scrobbling/adapters.rs index 61014f02..f3a640b3 100644 --- a/tests/scrobbling/adapters.rs +++ b/tests/scrobbling/adapters.rs @@ -5,9 +5,9 @@ use super::*; /// Points Maloja's declared instance at `base`, leaving the others alone. fn maloja_reaching(config: &mut waveflow_server::Config, base: &str) { - let url = waveflow_server::scrobblers::validate_destination(base, true) + let url = waveflow_server::remote::scrobblers::validate_destination(base, true) .expect("a destination the suite chose"); - let fingerprint = waveflow_server::scrobblers::destination_fingerprint(&url); + let fingerprint = waveflow_server::remote::scrobblers::destination_fingerprint(&url); config .destinations .retain(|declared| declared.provider != ScrobbleProvider::Maloja); @@ -180,8 +180,8 @@ async fn a_last_fm_session_key_that_died_breaks_the_link_rather_than_looking_sen .await; let temp = tempfile::tempdir().unwrap(); let mut config = lastfm_app(&temp); - let url = waveflow_server::scrobblers::validate_destination(&base, true).unwrap(); - let fingerprint = waveflow_server::scrobblers::destination_fingerprint(&url); + let url = waveflow_server::remote::scrobblers::validate_destination(&base, true).unwrap(); + let fingerprint = waveflow_server::remote::scrobblers::destination_fingerprint(&url); config.destinations = vec![waveflow_server::config::ScrobbleDestination { provider: ScrobbleProvider::LastFm, name: "default".to_owned(), @@ -219,8 +219,8 @@ async fn lastfm_app_reaching( base: &str, ) -> (waveflow_server::Config, AppState) { let mut config = lastfm_app(temp); - let url = waveflow_server::scrobblers::validate_destination(base, true).unwrap(); - let fingerprint = waveflow_server::scrobblers::destination_fingerprint(&url); + let url = waveflow_server::remote::scrobblers::validate_destination(base, true).unwrap(); + let fingerprint = waveflow_server::remote::scrobblers::destination_fingerprint(&url); config.destinations = vec![waveflow_server::config::ScrobbleDestination { provider: ScrobbleProvider::LastFm, name: "default".to_owned(), @@ -296,9 +296,9 @@ async fn the_adapter_asks_last_fm_for_a_token_and_names_it_in_the_address() { let base = spawn_answering("/2.0/", r#"{"token":"a-real-request-token"}"#).await; let temp = tempfile::tempdir().unwrap(); let mut config = waveflow_server::Config::for_data_dir(temp.path().join("data")); - let url = waveflow_server::scrobblers::validate_destination(&base, true) + let url = waveflow_server::remote::scrobblers::validate_destination(&base, true) .expect("a destination the suite chose"); - let fingerprint = waveflow_server::scrobblers::destination_fingerprint(&url); + let fingerprint = waveflow_server::remote::scrobblers::destination_fingerprint(&url); config .destinations .retain(|declared| declared.provider != ScrobbleProvider::LastFm); @@ -350,9 +350,9 @@ async fn an_answer_carrying_no_token_is_refused_rather_than_passed_on() { let base = spawn_answering("/2.0/", r#"{"error":4,"message":"Invalid API key"}"#).await; let temp = tempfile::tempdir().unwrap(); let mut config = waveflow_server::Config::for_data_dir(temp.path().join("data")); - let url = waveflow_server::scrobblers::validate_destination(&base, true) + let url = waveflow_server::remote::scrobblers::validate_destination(&base, true) .expect("a destination the suite chose"); - let fingerprint = waveflow_server::scrobblers::destination_fingerprint(&url); + let fingerprint = waveflow_server::remote::scrobblers::destination_fingerprint(&url); config .destinations .retain(|declared| declared.provider != ScrobbleProvider::LastFm); diff --git a/tests/scrobbling/listenbrainz.rs b/tests/scrobbling/listenbrainz.rs index c845143b..7e18319a 100644 --- a/tests/scrobbling/listenbrainz.rs +++ b/tests/scrobbling/listenbrainz.rs @@ -10,9 +10,9 @@ use super::*; /// and leaves the other recipients alone, because more than one test needs a /// second destination in the same queue. fn reaching(config: &mut waveflow_server::Config, base: &str) { - let url = waveflow_server::scrobblers::validate_destination(base, true) + let url = waveflow_server::remote::scrobblers::validate_destination(base, true) .expect("a destination the suite chose"); - let fingerprint = waveflow_server::scrobblers::destination_fingerprint(&url); + let fingerprint = waveflow_server::remote::scrobblers::destination_fingerprint(&url); config .destinations .retain(|declared| declared.provider != ScrobbleProvider::ListenBrainz); diff --git a/tests/scrobbling/main.rs b/tests/scrobbling/main.rs index 67ff2a9b..08141216 100644 --- a/tests/scrobbling/main.rs +++ b/tests/scrobbling/main.rs @@ -36,14 +36,14 @@ use std::time::Duration; use futures_util::future::BoxFuture; use tower::ServiceExt as _; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::LibraryRecord; +use waveflow_server::auth::authentication::now_ms; use waveflow_server::config::ScrobbleLimits; -use waveflow_server::database::LibraryVisibility; use waveflow_server::services::{ ScrobbleEnvelope, ScrobbleProvider, ScrobbleTarget, ScrobbleUnavailable, ScrobbleVerdict, ServiceError, }; +use waveflow_server::storage::catalog::LibraryRecord; +use waveflow_server::storage::database::LibraryVisibility; use waveflow_server::AppState; // Not every target uses every fixture, and a shared module is not dead code @@ -345,9 +345,9 @@ fn declaring( config.destinations = declared .iter() .map(|(provider, name, base)| { - let url = waveflow_server::scrobblers::validate_destination(base, true) + let url = waveflow_server::remote::scrobblers::validate_destination(base, true) .expect("a destination the suite chose"); - let fingerprint = waveflow_server::scrobblers::destination_fingerprint(&url); + let fingerprint = waveflow_server::remote::scrobblers::destination_fingerprint(&url); waveflow_server::config::ScrobbleDestination { provider: *provider, name: (*name).to_owned(), diff --git a/tests/service.rs b/tests/service.rs index 0a2915fa..10ea8a99 100644 --- a/tests/service.rs +++ b/tests/service.rs @@ -6,9 +6,9 @@ use axum::body::Body; use axum::http::Request; use axum::http::StatusCode; use tower::ServiceExt; -use waveflow_server::authentication::now_ms; -use waveflow_server::database::AccountRole; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; +use waveflow_server::storage::database::AccountRole; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. @@ -574,7 +574,7 @@ async fn sqlite_and_instance_key_backup_restore_as_one_consistent_bundle() { .unwrap(); std::fs::copy(&config.instance_key_path, bundle.join("instance.key")).unwrap(); assert!( - waveflow_server::database::Database::check_file(&bundle.join("waveflow.db")) + waveflow_server::storage::database::Database::check_file(&bundle.join("waveflow.db")) .await .unwrap() ); @@ -672,7 +672,7 @@ async fn a_refused_boot_gives_the_database_back_before_it_returns() { refusal.contains("instance.key does not match"), "attempt {attempt}: refused for another reason: {refusal}" ); - let next = waveflow_server::database::Database::open(&impatient) + let next = waveflow_server::storage::database::Database::open(&impatient) .await .unwrap_or_else(|error| { panic!("attempt {attempt}: the refused boot still held the database: {error}") @@ -993,7 +993,7 @@ async fn the_api_reference_is_served_from_the_binary() { assert!( html.contains(&format!( "", - waveflow_server::reference::SCALAR_SCRIPT_PATH + waveflow_server::http::reference::SCALAR_SCRIPT_PATH )), "{html}" ); @@ -1006,7 +1006,7 @@ async fn the_api_reference_is_served_from_the_binary() { // Stored compressed and sent as stored to whoever takes gzip… let script = get( - waveflow_server::reference::SCALAR_SCRIPT_PATH, + waveflow_server::http::reference::SCALAR_SCRIPT_PATH, Some("gzip, br"), ) .await; @@ -1021,8 +1021,11 @@ async fn the_api_reference_is_served_from_the_binary() { .unwrap() .contains("immutable")); // …and decompressed for a client that takes none. - let plain = get(waveflow_server::reference::SCALAR_SCRIPT_PATH, None).await; + let plain = get(waveflow_server::http::reference::SCALAR_SCRIPT_PATH, None).await; assert!(plain.headers().get("content-encoding").is_none()); let bytes = plain.into_body().collect().await.unwrap().to_bytes(); - assert_eq!(bytes, waveflow_server::reference::decompressed().unwrap()); + assert_eq!( + bytes, + waveflow_server::http::reference::decompressed().unwrap() + ); } diff --git a/tests/smart_playlists.rs b/tests/smart_playlists.rs index a8178781..dd52156d 100644 --- a/tests/smart_playlists.rs +++ b/tests/smart_playlists.rs @@ -5,10 +5,10 @@ use axum::http::StatusCode; use serde_json::json; use uuid::Uuid; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::CatalogTrackInput; -use waveflow_server::database::{AccountRole, LibraryRole, LibraryVisibility}; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; +use waveflow_server::storage::catalog::CatalogTrackInput; +use waveflow_server::storage::database::{AccountRole, LibraryRole, LibraryVisibility}; #[allow(dead_code)] mod support; diff --git a/tests/subsonic_browse.rs b/tests/subsonic_browse.rs index f278a789..10015eb9 100644 --- a/tests/subsonic_browse.rs +++ b/tests/subsonic_browse.rs @@ -7,11 +7,11 @@ use axum::http::Request; use axum::http::StatusCode; use tower::ServiceExt; use uuid::Uuid; -use waveflow_server::authentication::now_ms; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; use waveflow_server::services::ServiceError; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. @@ -1042,7 +1042,7 @@ async fn an_artists_most_played_comes_from_local_history_on_both_surfaces() { owner, library, listener, - waveflow_server::database::LibraryRole::Listener, + waveflow_server::storage::database::LibraryRole::Listener, now, ) .await diff --git a/tests/subsonic_contract/compatibility.rs b/tests/subsonic_contract/compatibility.rs index 26ba7f5c..8e1e819b 100644 --- a/tests/subsonic_contract/compatibility.rs +++ b/tests/subsonic_contract/compatibility.rs @@ -114,7 +114,7 @@ async fn subsonic_xml_json_auth_catalog_and_user_data_are_compatible() { Some(1), Some(1), ); - foreign_input.lyrics = vec![waveflow_server::lyrics::LyricsInput { + foreign_input.lyrics = vec![waveflow_server::library::lyrics::LyricsInput { source: "embedded", lang: "eng".into(), synced: false, diff --git a/tests/subsonic_contract/main.rs b/tests/subsonic_contract/main.rs index a7f09d62..6f2d9383 100644 --- a/tests/subsonic_contract/main.rs +++ b/tests/subsonic_contract/main.rs @@ -8,11 +8,11 @@ use axum::http::StatusCode; use http_body_util::BodyExt; use tower::ServiceExt; use uuid::Uuid; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::LibraryRecord; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; +use waveflow_server::storage::catalog::LibraryRecord; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. diff --git a/tests/subsonic_fields.rs b/tests/subsonic_fields.rs index 9fd8a2a0..27d99990 100644 --- a/tests/subsonic_fields.rs +++ b/tests/subsonic_fields.rs @@ -6,10 +6,10 @@ use axum::body::Body; use axum::http::Request; use axum::http::StatusCode; use tower::ServiceExt; -use waveflow_server::authentication::now_ms; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. @@ -374,7 +374,7 @@ async fn an_artist_reference_is_not_an_artist_record() { input.sort_artist = Some("Nocturnes, The".into()); // A contributor, so the whitelist below actually has one to check. input.roles = vec![( - waveflow_server::tags::Role::Producer, + waveflow_server::library::tags::Role::Producer, vec!["Rita Sound".into()], )]; state @@ -586,11 +586,11 @@ async fn credits_reach_the_wire_in_both_encodings() { credited.is_compilation = false; credited.roles = vec![ ( - waveflow_server::tags::Role::Composer, + waveflow_server::library::tags::Role::Composer, vec!["Otto Pen; Ada Vale".into()], ), ( - waveflow_server::tags::Role::Producer, + waveflow_server::library::tags::Role::Producer, vec!["Rita Sound".into()], ), ]; diff --git a/tests/subsonic_methods/main.rs b/tests/subsonic_methods/main.rs index 869c2c00..d97d39a7 100644 --- a/tests/subsonic_methods/main.rs +++ b/tests/subsonic_methods/main.rs @@ -7,12 +7,12 @@ use axum::http::Request; use axum::http::StatusCode; use tower::ServiceExt; use uuid::Uuid; -use waveflow_server::authentication::now_ms; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; use waveflow_server::services::ServiceError; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. diff --git a/tests/subsonic_methods/transcoding.rs b/tests/subsonic_methods/transcoding.rs index 1a0337ae..7107fb71 100644 --- a/tests/subsonic_methods/transcoding.rs +++ b/tests/subsonic_methods/transcoding.rs @@ -58,7 +58,7 @@ async fn a_format_the_facade_cannot_produce_falls_back_and_aac_is_produced() { run_scan( &state, owner, - waveflow_server::catalog::LibraryRecord { + waveflow_server::storage::catalog::LibraryRecord { id: library, name: "Formats".into(), root_path: root, @@ -148,7 +148,7 @@ async fn a_subsonic_transcode_carries_the_tracks_replay_gain_when_asked() { let temp = tempfile::tempdir().unwrap(); let mut config = waveflow_server::Config::for_data_dir(temp.path().join("data")); - config.subsonic_replay_gain = waveflow_server::replay_gain::ReplayGainMode::Track; + config.subsonic_replay_gain = waveflow_server::media::replay_gain::ReplayGainMode::Track; let state = waveflow_server::initialize(&config).await.unwrap(); let (owner, api_key) = subsonic_account(&state, "levelled").await; let music = config.data_dir.join("levelled-music"); @@ -186,7 +186,7 @@ async fn a_subsonic_transcode_carries_the_tracks_replay_gain_when_asked() { run_scan( &state, owner, - waveflow_server::catalog::LibraryRecord { + waveflow_server::storage::catalog::LibraryRecord { id: library, name: "levelled".into(), root_path: root, diff --git a/tests/support/mod.rs b/tests/support/mod.rs index cc61cde9..1bc6edfd 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -9,8 +9,8 @@ use axum::http::StatusCode; use http_body_util::BodyExt; use tempfile::TempDir; use tower::ServiceExt; -use waveflow_server::catalog::CatalogTrackInput; -use waveflow_server::catalog::LibraryRecord; +use waveflow_server::storage::catalog::CatalogTrackInput; +use waveflow_server::storage::catalog::LibraryRecord; use waveflow_server::Config; pub async fn test_app() -> (TempDir, Config, waveflow_server::AppState) { @@ -445,8 +445,8 @@ pub async fn seed_titles( account, name, &std::fs::canonicalize(&root).unwrap(), - waveflow_server::database::LibraryVisibility::Private, - waveflow_server::authentication::now_ms(), + waveflow_server::storage::database::LibraryVisibility::Private, + waveflow_server::auth::authentication::now_ms(), ) .await .unwrap(); @@ -537,7 +537,7 @@ pub async fn admin_with_api_token( username: &str, ) -> (uuid::Uuid, String) { let id = uuid::Uuid::new_v4(); - let now = waveflow_server::authentication::now_ms(); + let now = waveflow_server::auth::authentication::now_ms(); sqlx::query( "INSERT INTO account (id, username, password_hash, role, disabled, created_at, updated_at) \ VALUES (?, ?, 'this-account-never-authenticates', 'admin', 0, ?, ?)", diff --git a/tests/sync.rs b/tests/sync.rs index f4a94617..4d5161ec 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -10,18 +10,18 @@ use futures_util::StreamExt; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tower::ServiceExt; use uuid::Uuid; -use waveflow_server::authentication::now_ms; -use waveflow_server::catalog::LibraryRecord; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; use waveflow_server::services::ServiceError; use waveflow_server::services::MAX_QUEUE_TRACKS; use waveflow_server::services::MAX_SHARE_TRACKS; -use waveflow_server::sync::MutationContext; -use waveflow_server::sync::SyncError; -use waveflow_server::sync::MAX_SYNC_LIMIT; +use waveflow_server::storage::catalog::LibraryRecord; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryRole; +use waveflow_server::storage::database::LibraryVisibility; +use waveflow_server::storage::sync::MutationContext; +use waveflow_server::storage::sync::SyncError; +use waveflow_server::storage::sync::MAX_SYNC_LIMIT; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. diff --git a/tests/uploads/listings.rs b/tests/uploads/listings.rs index c4cfb701..03360e84 100644 --- a/tests/uploads/listings.rs +++ b/tests/uploads/listings.rs @@ -45,7 +45,7 @@ async fn a_client_can_tell_its_own_upload_from_a_discovery() { run_scan( &state, owner, - waveflow_server::catalog::LibraryRecord { + waveflow_server::storage::catalog::LibraryRecord { id: library, name: "Attributed".into(), root_path: root, diff --git a/tests/uploads/main.rs b/tests/uploads/main.rs index 5b58e562..1f07a7fc 100644 --- a/tests/uploads/main.rs +++ b/tests/uploads/main.rs @@ -7,12 +7,12 @@ use axum::body::Body; use axum::http::Request; use axum::http::StatusCode; use tower::ServiceExt; -use waveflow_server::authentication::now_ms; +use waveflow_server::auth::authentication::now_ms; +use waveflow_server::auth::security; use waveflow_server::config::UploadLimits; -use waveflow_server::database::AccountRole; -use waveflow_server::database::LibraryRole; -use waveflow_server::database::LibraryVisibility; -use waveflow_server::security; +use waveflow_server::storage::database::AccountRole; +use waveflow_server::storage::database::LibraryRole; +use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. From c56d196ded43f3f548bcd530a090e505f5099330 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 17:48:16 +0100 Subject: [PATCH 03/13] refactor(src): turn the remaining modules with inline tests into directories Renames only: api/scrobbling, api/sync, api/web_session, quality/bandwidth and the three scrobbler adapters become foo/mod.rs, so their tests can sit beside them in foo/tests.rs. Signed-off-by: InstaZDLL --- src/api/{scrobbling.rs => scrobbling/mod.rs} | 0 src/api/{sync.rs => sync/mod.rs} | 0 src/api/{web_session.rs => web_session/mod.rs} | 0 src/quality/{bandwidth.rs => bandwidth/mod.rs} | 0 src/remote/scrobblers/{lastfm.rs => lastfm/mod.rs} | 0 src/remote/scrobblers/{listenbrainz.rs => listenbrainz/mod.rs} | 0 src/remote/scrobblers/{maloja.rs => maloja/mod.rs} | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename src/api/{scrobbling.rs => scrobbling/mod.rs} (100%) rename src/api/{sync.rs => sync/mod.rs} (100%) rename src/api/{web_session.rs => web_session/mod.rs} (100%) rename src/quality/{bandwidth.rs => bandwidth/mod.rs} (100%) rename src/remote/scrobblers/{lastfm.rs => lastfm/mod.rs} (100%) rename src/remote/scrobblers/{listenbrainz.rs => listenbrainz/mod.rs} (100%) rename src/remote/scrobblers/{maloja.rs => maloja/mod.rs} (100%) diff --git a/src/api/scrobbling.rs b/src/api/scrobbling/mod.rs similarity index 100% rename from src/api/scrobbling.rs rename to src/api/scrobbling/mod.rs diff --git a/src/api/sync.rs b/src/api/sync/mod.rs similarity index 100% rename from src/api/sync.rs rename to src/api/sync/mod.rs diff --git a/src/api/web_session.rs b/src/api/web_session/mod.rs similarity index 100% rename from src/api/web_session.rs rename to src/api/web_session/mod.rs diff --git a/src/quality/bandwidth.rs b/src/quality/bandwidth/mod.rs similarity index 100% rename from src/quality/bandwidth.rs rename to src/quality/bandwidth/mod.rs diff --git a/src/remote/scrobblers/lastfm.rs b/src/remote/scrobblers/lastfm/mod.rs similarity index 100% rename from src/remote/scrobblers/lastfm.rs rename to src/remote/scrobblers/lastfm/mod.rs diff --git a/src/remote/scrobblers/listenbrainz.rs b/src/remote/scrobblers/listenbrainz/mod.rs similarity index 100% rename from src/remote/scrobblers/listenbrainz.rs rename to src/remote/scrobblers/listenbrainz/mod.rs diff --git a/src/remote/scrobblers/maloja.rs b/src/remote/scrobblers/maloja/mod.rs similarity index 100% rename from src/remote/scrobblers/maloja.rs rename to src/remote/scrobblers/maloja/mod.rs From 607b5f32cbb60fb9d939247aaf87686976d458e0 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 17:49:52 +0100 Subject: [PATCH 04/13] refactor(src): move the last thirty inline test modules beside their code Every #[cfg(test)] mod tests { ... } left in src/ becomes foo/tests.rs, declared where the block stood. Bodies are moved verbatim and dedented one level; string literals that span lines keep their content byte for byte. Two rustfmt reflows follow from the shallower indentation. No file under src/ opens an inline test module any more. Signed-off-by: InstaZDLL --- src/api/scrobbling/mod.rs | 24 +- src/api/scrobbling/tests.rs | 21 ++ src/api/sync/mod.rs | 84 +------ src/api/sync/tests.rs | 81 +++++++ src/api/web_session/mod.rs | 12 +- src/api/web_session/tests.rs | 9 + src/auth/oauth/mod.rs | 97 +------- src/auth/oauth/tests.rs | 94 ++++++++ src/auth/security/mod.rs | 45 +--- src/auth/security/tests.rs | 42 ++++ src/auth/setup_token/mod.rs | 127 +--------- src/auth/setup_token/tests.rs | 124 ++++++++++ src/auth/sign_in/mod.rs | 247 +------------------ src/auth/sign_in/tests.rs | 244 +++++++++++++++++++ src/auth/stream_ticket/mod.rs | 129 +--------- src/auth/stream_ticket/tests.rs | 126 ++++++++++ src/http/public_share/mod.rs | 31 +-- src/http/public_share/tests.rs | 28 +++ src/http/reference/mod.rs | 100 +------- src/http/reference/tests.rs | 97 ++++++++ src/http/security_headers/mod.rs | 76 +----- src/http/security_headers/tests.rs | 73 ++++++ src/library/credit_split/mod.rs | 206 +--------------- src/library/credit_split/tests.rs | 203 ++++++++++++++++ src/library/link_roots/mod.rs | 37 +-- src/library/link_roots/tests.rs | 34 +++ src/library/lyrics/mod.rs | 231 +----------------- src/library/lyrics/tests.rs | 228 ++++++++++++++++++ src/library/pid/mod.rs | 165 +------------ src/library/pid/tests.rs | 162 +++++++++++++ src/library/playlist_file/mod.rs | 250 +------------------- src/library/playlist_file/tests.rs | 247 +++++++++++++++++++ src/library/tags/mod.rs | 248 +------------------ src/library/tags/tests.rs | 245 +++++++++++++++++++ src/media/replay_gain/mod.rs | 82 +------ src/media/replay_gain/tests.rs | 76 ++++++ src/quality/bandwidth/mod.rs | 131 +--------- src/quality/bandwidth/tests.rs | 128 ++++++++++ src/quality/mod.rs | 98 +------- src/quality/tests.rs | 95 ++++++++ src/remote/lrclib/mod.rs | 64 +---- src/remote/lrclib/tests.rs | 61 +++++ src/remote/outbound/mod.rs | 92 +------ src/remote/outbound/tests.rs | 88 +++++++ src/remote/podcast/mod.rs | 218 +---------------- src/remote/podcast/tests.rs | 215 +++++++++++++++++ src/remote/portrait/mod.rs | 54 +---- src/remote/portrait/tests.rs | 51 ++++ src/remote/scrobblers/lastfm/mod.rs | 240 +------------------ src/remote/scrobblers/lastfm/tests.rs | 237 +++++++++++++++++++ src/remote/scrobblers/listenbrainz/mod.rs | 163 +------------ src/remote/scrobblers/listenbrainz/tests.rs | 160 +++++++++++++ src/remote/scrobblers/maloja/mod.rs | 158 +------------ src/remote/scrobblers/maloja/tests.rs | 155 ++++++++++++ src/remote/scrobblers/mod.rs | 124 +--------- src/remote/scrobblers/tests.rs | 121 ++++++++++ src/storage/natural_order/mod.rs | 57 +---- src/storage/natural_order/tests.rs | 54 +++++ src/storage/sync/mod.rs | 46 +--- src/storage/sync/tests.rs | 43 ++++ 60 files changed, 3572 insertions(+), 3606 deletions(-) create mode 100644 src/api/scrobbling/tests.rs create mode 100644 src/api/sync/tests.rs create mode 100644 src/api/web_session/tests.rs create mode 100644 src/auth/oauth/tests.rs create mode 100644 src/auth/security/tests.rs create mode 100644 src/auth/setup_token/tests.rs create mode 100644 src/auth/sign_in/tests.rs create mode 100644 src/auth/stream_ticket/tests.rs create mode 100644 src/http/public_share/tests.rs create mode 100644 src/http/reference/tests.rs create mode 100644 src/http/security_headers/tests.rs create mode 100644 src/library/credit_split/tests.rs create mode 100644 src/library/link_roots/tests.rs create mode 100644 src/library/lyrics/tests.rs create mode 100644 src/library/pid/tests.rs create mode 100644 src/library/playlist_file/tests.rs create mode 100644 src/library/tags/tests.rs create mode 100644 src/media/replay_gain/tests.rs create mode 100644 src/quality/bandwidth/tests.rs create mode 100644 src/quality/tests.rs create mode 100644 src/remote/lrclib/tests.rs create mode 100644 src/remote/outbound/tests.rs create mode 100644 src/remote/podcast/tests.rs create mode 100644 src/remote/portrait/tests.rs create mode 100644 src/remote/scrobblers/lastfm/tests.rs create mode 100644 src/remote/scrobblers/listenbrainz/tests.rs create mode 100644 src/remote/scrobblers/maloja/tests.rs create mode 100644 src/remote/scrobblers/tests.rs create mode 100644 src/storage/natural_order/tests.rs create mode 100644 src/storage/sync/tests.rs diff --git a/src/api/scrobbling/mod.rs b/src/api/scrobbling/mod.rs index 2539d6a2..76f5c6f8 100644 --- a/src/api/scrobbling/mod.rs +++ b/src/api/scrobbling/mod.rs @@ -47,29 +47,7 @@ pub struct RetriedScrobbleResponse { } #[cfg(test)] -mod tests { - use super::*; - - /// The secret does not survive being formatted. - /// - /// The failure message deliberately carries neither the secret nor the - /// formatted output: if this guard were removed, that output would *be* the - /// token, and a panic message is a log line. The previous slice of this RFC - /// earned a CodeQL `rust/cleartext-logging` alert for exactly that, on a - /// test whose subject was also non-disclosure. - #[test] - fn a_link_request_does_not_print_its_secret() { - let request = LinkScrobbleRequest { - secret: "a-token-nobody-should-read".to_owned(), - }; - let shown = format!("{request:?}"); - assert!( - !shown.contains("a-token-nobody-should-read"), - "the debug output carried the secret" - ); - assert!(shown.contains("[redacted]"), "the secret was not replaced"); - } -} +mod tests; /// The destination named in a path, or a 422. /// diff --git a/src/api/scrobbling/tests.rs b/src/api/scrobbling/tests.rs new file mode 100644 index 00000000..516c3d7e --- /dev/null +++ b/src/api/scrobbling/tests.rs @@ -0,0 +1,21 @@ +use super::*; + +/// The secret does not survive being formatted. +/// +/// The failure message deliberately carries neither the secret nor the +/// formatted output: if this guard were removed, that output would *be* the +/// token, and a panic message is a log line. The previous slice of this RFC +/// earned a CodeQL `rust/cleartext-logging` alert for exactly that, on a +/// test whose subject was also non-disclosure. +#[test] +fn a_link_request_does_not_print_its_secret() { + let request = LinkScrobbleRequest { + secret: "a-token-nobody-should-read".to_owned(), + }; + let shown = format!("{request:?}"); + assert!( + !shown.contains("a-token-nobody-should-read"), + "the debug output carried the secret" + ); + assert!(shown.contains("[redacted]"), "the secret was not replaced"); +} diff --git a/src/api/sync/mod.rs b/src/api/sync/mod.rs index a6b9be17..cbaf1648 100644 --- a/src/api/sync/mod.rs +++ b/src/api/sync/mod.rs @@ -264,86 +264,4 @@ pub(super) async fn send_sync_notice( } #[cfg(test)] -mod tests { - use super::{sync_notice_action, SyncNoticeAction}; - - /// A lagged socket recovers the cursor from the journal, not from a default. - /// - /// The distinction needs a non-zero cursor to be visible at all: a user with - /// no events answers 0, which is also what returning a constant would give, - /// so the empty case alone proves only that the branch is wired to - /// something. The event below is written through the real journal path, so - /// the expected value is one the test never chose. - #[tokio::test] - async fn lagged_sync_socket_recovers_from_the_durable_cursor() { - let temp = tempfile::tempdir().unwrap(); - let config = crate::Config::for_data_dir(temp.path().join("data")); - let db = crate::storage::database::Database::open(&config) - .await - .unwrap(); - db.migrate().await.unwrap(); - let user_id = db - .create_account( - "lagged", - "hash", - crate::storage::database::AccountRole::User, - crate::auth::authentication::now_ms(), - ) - .await - .unwrap(); - let sync = crate::storage::sync::SyncService::new(db.clone()); - - let context = crate::storage::sync::MutationContext::server_generated(); - let writer = db.writer_guard().await; - let mut tx = db.begin_write(&writer).await.unwrap(); - sync.claim_operation( - &writer, - &mut tx, - user_id, - context, - crate::storage::sync::MutationIntent::new( - "set-rating", - "track:seed", - &serde_json::json!({}), - ), - ) - .await - .unwrap(); - let receipt = sync - .complete_operation( - &mut tx, - user_id, - context, - "rating", - uuid::Uuid::new_v4(), - "upsert", - &serde_json::json!({}), - None, - ) - .await - .unwrap(); - tx.commit().await.unwrap(); - drop(writer); - assert_ne!(receipt.cursor, 0); - - let action = sync_notice_action( - &sync, - user_id, - Err(tokio::sync::broadcast::error::RecvError::Lagged(3)), - ) - .await - .unwrap(); - assert_eq!(action, SyncNoticeAction::Send(receipt.cursor)); - - // An account with nothing in the journal still falls back to the base - // cursor rather than failing. - let action = sync_notice_action( - &sync, - uuid::Uuid::new_v4(), - Err(tokio::sync::broadcast::error::RecvError::Lagged(3)), - ) - .await - .unwrap(); - assert_eq!(action, SyncNoticeAction::Send(0)); - } -} +mod tests; diff --git a/src/api/sync/tests.rs b/src/api/sync/tests.rs new file mode 100644 index 00000000..b138f98d --- /dev/null +++ b/src/api/sync/tests.rs @@ -0,0 +1,81 @@ +use super::{sync_notice_action, SyncNoticeAction}; + +/// A lagged socket recovers the cursor from the journal, not from a default. +/// +/// The distinction needs a non-zero cursor to be visible at all: a user with +/// no events answers 0, which is also what returning a constant would give, +/// so the empty case alone proves only that the branch is wired to +/// something. The event below is written through the real journal path, so +/// the expected value is one the test never chose. +#[tokio::test] +async fn lagged_sync_socket_recovers_from_the_durable_cursor() { + let temp = tempfile::tempdir().unwrap(); + let config = crate::Config::for_data_dir(temp.path().join("data")); + let db = crate::storage::database::Database::open(&config) + .await + .unwrap(); + db.migrate().await.unwrap(); + let user_id = db + .create_account( + "lagged", + "hash", + crate::storage::database::AccountRole::User, + crate::auth::authentication::now_ms(), + ) + .await + .unwrap(); + let sync = crate::storage::sync::SyncService::new(db.clone()); + + let context = crate::storage::sync::MutationContext::server_generated(); + let writer = db.writer_guard().await; + let mut tx = db.begin_write(&writer).await.unwrap(); + sync.claim_operation( + &writer, + &mut tx, + user_id, + context, + crate::storage::sync::MutationIntent::new( + "set-rating", + "track:seed", + &serde_json::json!({}), + ), + ) + .await + .unwrap(); + let receipt = sync + .complete_operation( + &mut tx, + user_id, + context, + "rating", + uuid::Uuid::new_v4(), + "upsert", + &serde_json::json!({}), + None, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + drop(writer); + assert_ne!(receipt.cursor, 0); + + let action = sync_notice_action( + &sync, + user_id, + Err(tokio::sync::broadcast::error::RecvError::Lagged(3)), + ) + .await + .unwrap(); + assert_eq!(action, SyncNoticeAction::Send(receipt.cursor)); + + // An account with nothing in the journal still falls back to the base + // cursor rather than failing. + let action = sync_notice_action( + &sync, + uuid::Uuid::new_v4(), + Err(tokio::sync::broadcast::error::RecvError::Lagged(3)), + ) + .await + .unwrap(); + assert_eq!(action, SyncNoticeAction::Send(0)); +} diff --git a/src/api/web_session/mod.rs b/src/api/web_session/mod.rs index 34a4f72b..57bac45d 100644 --- a/src/api/web_session/mod.rs +++ b/src/api/web_session/mod.rs @@ -126,14 +126,4 @@ pub(super) fn validate_web_origin(state: &AppState, headers: &HeaderMap) -> Resu } #[cfg(test)] -mod tests { - use super::public_url_is_https; - - #[test] - fn secure_cookie_detection_uses_the_parsed_url_scheme() { - assert!(public_url_is_https(Some("HTTPS://waveflow.test/"))); - assert!(!public_url_is_https(Some("http://waveflow.test"))); - assert!(!public_url_is_https(Some("not a URL"))); - assert!(!public_url_is_https(None)); - } -} +mod tests; diff --git a/src/api/web_session/tests.rs b/src/api/web_session/tests.rs new file mode 100644 index 00000000..433b13f9 --- /dev/null +++ b/src/api/web_session/tests.rs @@ -0,0 +1,9 @@ +use super::public_url_is_https; + +#[test] +fn secure_cookie_detection_uses_the_parsed_url_scheme() { + assert!(public_url_is_https(Some("HTTPS://waveflow.test/"))); + assert!(!public_url_is_https(Some("http://waveflow.test"))); + assert!(!public_url_is_https(Some("not a URL"))); + assert!(!public_url_is_https(None)); +} diff --git a/src/auth/oauth/mod.rs b/src/auth/oauth/mod.rs index 76ca43ed..eb306c93 100644 --- a/src/auth/oauth/mod.rs +++ b/src/auth/oauth/mod.rs @@ -105,99 +105,4 @@ pub fn redirect_with_code(redirect_uri: &str, code: &str, state: Option<&str>) - } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn loopback_and_private_schemes_are_accepted_but_remote_http_is_not() { - assert!(validate_redirect_uri("http://127.0.0.1:49152/callback").is_ok()); - assert!(validate_redirect_uri("http://localhost:1234/cb").is_ok()); - assert!(validate_redirect_uri("http://[::1]:5000/cb").is_ok()); - assert!(validate_redirect_uri("https://desktop.example.com/cb").is_ok()); - assert!(validate_redirect_uri("com.waveflow.desktop://auth").is_ok()); - - // Clear-text to another host would leak the code off the machine. - assert_eq!( - validate_redirect_uri("http://evil.example.com/cb"), - Err(GrantError::UnsupportedRedirect) - ); - // A bare scheme is claimable by any other application. - assert_eq!( - validate_redirect_uri("waveflow:/auth"), - Err(GrantError::UnsupportedRedirect) - ); - assert_eq!( - validate_redirect_uri("not a url"), - Err(GrantError::UnsupportedRedirect) - ); - } - - #[test] - fn only_s256_challenges_are_accepted() { - let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(b"a".repeat(43))); - assert!(validate_challenge("S256", &challenge).is_ok()); - assert!(validate_challenge("s256", &challenge).is_ok()); - assert_eq!( - validate_challenge("plain", &challenge), - Err(GrantError::UnsupportedChallenge) - ); - assert_eq!( - validate_challenge("S256", "short"), - Err(GrantError::UnsupportedChallenge) - ); - assert_eq!( - validate_challenge("S256", &"a".repeat(64)), - Err(GrantError::UnsupportedChallenge), - "an S256 challenge is exactly 43 characters" - ); - assert_eq!( - validate_challenge("S256", &"+".repeat(43)), - Err(GrantError::UnsupportedChallenge), - "standard base64 padding characters are not base64url" - ); - - // Right length, right alphabet, but the final character sets padding - // bits a digest never sets. It decodes to nothing a verifier can match. - let canonical = challenge_for(&"x".repeat(64)); - const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let last = canonical.chars().last().unwrap(); - let shifted = ALPHABET - .chars() - .nth(ALPHABET.chars().position(|c| c == last).unwrap() + 1) - .unwrap(); - let non_canonical = format!("{}{shifted}", &canonical[..42]); - assert_eq!(non_canonical.len(), 43); - assert_eq!( - validate_challenge("S256", &non_canonical), - Err(GrantError::UnsupportedChallenge), - "a non-canonical encoding is not a challenge any verifier can match" - ); - } - - #[test] - fn a_verifier_only_matches_its_own_challenge() { - let verifier = "x".repeat(64); - let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); - assert!(verify_challenge(&challenge, &verifier).is_ok()); - assert_eq!( - verify_challenge(&challenge, &"y".repeat(64)), - Err(GrantError::InvalidVerifier) - ); - assert_eq!( - verify_challenge(&challenge, "too-short"), - Err(GrantError::InvalidVerifier) - ); - } - - #[test] - fn redirect_preserves_existing_query_and_state() { - let redirected = redirect_with_code( - "http://127.0.0.1:49152/cb?flow=desktop", - "the-code", - Some("the-state"), - ); - assert!(redirected.contains("flow=desktop")); - assert!(redirected.contains("code=the-code")); - assert!(redirected.contains("state=the-state")); - } -} +mod tests; diff --git a/src/auth/oauth/tests.rs b/src/auth/oauth/tests.rs new file mode 100644 index 00000000..6d307e7e --- /dev/null +++ b/src/auth/oauth/tests.rs @@ -0,0 +1,94 @@ +use super::*; + +#[test] +fn loopback_and_private_schemes_are_accepted_but_remote_http_is_not() { + assert!(validate_redirect_uri("http://127.0.0.1:49152/callback").is_ok()); + assert!(validate_redirect_uri("http://localhost:1234/cb").is_ok()); + assert!(validate_redirect_uri("http://[::1]:5000/cb").is_ok()); + assert!(validate_redirect_uri("https://desktop.example.com/cb").is_ok()); + assert!(validate_redirect_uri("com.waveflow.desktop://auth").is_ok()); + + // Clear-text to another host would leak the code off the machine. + assert_eq!( + validate_redirect_uri("http://evil.example.com/cb"), + Err(GrantError::UnsupportedRedirect) + ); + // A bare scheme is claimable by any other application. + assert_eq!( + validate_redirect_uri("waveflow:/auth"), + Err(GrantError::UnsupportedRedirect) + ); + assert_eq!( + validate_redirect_uri("not a url"), + Err(GrantError::UnsupportedRedirect) + ); +} + +#[test] +fn only_s256_challenges_are_accepted() { + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(b"a".repeat(43))); + assert!(validate_challenge("S256", &challenge).is_ok()); + assert!(validate_challenge("s256", &challenge).is_ok()); + assert_eq!( + validate_challenge("plain", &challenge), + Err(GrantError::UnsupportedChallenge) + ); + assert_eq!( + validate_challenge("S256", "short"), + Err(GrantError::UnsupportedChallenge) + ); + assert_eq!( + validate_challenge("S256", &"a".repeat(64)), + Err(GrantError::UnsupportedChallenge), + "an S256 challenge is exactly 43 characters" + ); + assert_eq!( + validate_challenge("S256", &"+".repeat(43)), + Err(GrantError::UnsupportedChallenge), + "standard base64 padding characters are not base64url" + ); + + // Right length, right alphabet, but the final character sets padding + // bits a digest never sets. It decodes to nothing a verifier can match. + let canonical = challenge_for(&"x".repeat(64)); + const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let last = canonical.chars().last().unwrap(); + let shifted = ALPHABET + .chars() + .nth(ALPHABET.chars().position(|c| c == last).unwrap() + 1) + .unwrap(); + let non_canonical = format!("{}{shifted}", &canonical[..42]); + assert_eq!(non_canonical.len(), 43); + assert_eq!( + validate_challenge("S256", &non_canonical), + Err(GrantError::UnsupportedChallenge), + "a non-canonical encoding is not a challenge any verifier can match" + ); +} + +#[test] +fn a_verifier_only_matches_its_own_challenge() { + let verifier = "x".repeat(64); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + assert!(verify_challenge(&challenge, &verifier).is_ok()); + assert_eq!( + verify_challenge(&challenge, &"y".repeat(64)), + Err(GrantError::InvalidVerifier) + ); + assert_eq!( + verify_challenge(&challenge, "too-short"), + Err(GrantError::InvalidVerifier) + ); +} + +#[test] +fn redirect_preserves_existing_query_and_state() { + let redirected = redirect_with_code( + "http://127.0.0.1:49152/cb?flow=desktop", + "the-code", + Some("the-state"), + ); + assert!(redirected.contains("flow=desktop")); + assert!(redirected.contains("code=the-code")); + assert!(redirected.contains("state=the-state")); +} diff --git a/src/auth/security/mod.rs b/src/auth/security/mod.rs index 5e49da8c..b58c5171 100644 --- a/src/auth/security/mod.rs +++ b/src/auth/security/mod.rs @@ -218,47 +218,4 @@ pub(crate) fn write_new_secret_file(path: &Path, bytes: &[u8]) -> Result std::io::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - fn config(temp: &tempfile::TempDir) -> Config { - Config::for_data_dir(temp.path().join("data")) - } - - fn written_token(config: &Config) -> String { - std::fs::read_to_string(config.data_dir.join(FILE_NAME)) - .expect("the token file") - .trim() - .to_owned() - } - - #[test] - fn a_drawn_token_admits_only_itself_and_is_forgotten_on_close() { - let temp = tempfile::tempdir().unwrap(); - let config = config(&temp); - let gate = SetupGate::prepare(&config, true).unwrap(); - let token = written_token(&config); - assert!(token.starts_with(PREFIX)); - assert_eq!( - gate.source(), - Some(TokenSource::File(config.data_dir.join(FILE_NAME))) - ); - - assert_eq!(gate.admit(None), Admission::Rejected); - assert_eq!(gate.admit(Some("")), Admission::Rejected); - let wrong = security::generate_token(PREFIX); - assert_eq!(gate.admit(Some(&wrong)), Admission::Rejected); - assert_eq!(gate.admit(Some(&token)), Admission::Admitted); - - gate.close(); - assert_eq!(gate.admit(Some(&token)), Admission::Closed); - assert!(!config.data_dir.join(FILE_NAME).exists()); - assert_eq!(gate.source(), None); - } - - #[test] - fn a_restart_keeps_the_token_already_written() { - let temp = tempfile::tempdir().unwrap(); - let config = config(&temp); - SetupGate::prepare(&config, true).unwrap(); - let first = written_token(&config); - let again = SetupGate::prepare(&config, true).unwrap(); - assert_eq!(written_token(&config), first); - assert_eq!(again.admit(Some(&first)), Admission::Admitted); - } - - #[test] - fn an_operator_token_writes_no_file_and_replaces_a_drawn_one() { - let temp = tempfile::tempdir().unwrap(); - let mut config = config(&temp); - SetupGate::prepare(&config, true).unwrap(); - let drawn = written_token(&config); - - let chosen = uuid::Uuid::new_v4().to_string(); - config.setup_token = Some(chosen.clone()); - let gate = SetupGate::prepare(&config, true).unwrap(); - assert!(!config.data_dir.join(FILE_NAME).exists()); - assert_eq!(gate.source(), Some(TokenSource::Operator)); - assert_eq!(gate.admit(Some(&drawn)), Admission::Rejected); - assert_eq!(gate.admit(Some(&chosen)), Admission::Admitted); - } - - #[test] - fn a_file_another_process_has_not_finished_writing_is_refused() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join(FILE_NAME); - let token = security::generate_token(PREFIX); - // Created and not yet written, then cut short: neither is a token, and - // the first would otherwise admit a request that sends none. - for partial in [ - String::new(), - "\n".to_owned(), - token[..PREFIX.len() + 4].to_owned(), - ] { - std::fs::write(&path, &partial).unwrap(); - let refused = written_by_another(&path).expect_err(&format!("{partial:?}")); - assert!(refused.to_string().contains(&path.display().to_string())); - } - std::fs::write(&path, format!("{token}\n")).unwrap(); - assert_eq!(written_by_another(&path).unwrap(), token); - } - - #[test] - fn an_operator_token_built_in_process_still_meets_the_floor() { - let temp = tempfile::tempdir().unwrap(); - let mut config = config(&temp); - config.setup_token = - Some(uuid::Uuid::new_v4().to_string()[..SETUP_TOKEN_MIN_CHARS - 1].to_owned()); - assert!(SetupGate::prepare(&config, true).is_err()); - // Spaces do not count toward it. - config.setup_token = Some(format!( - " {} ", - &uuid::Uuid::new_v4().to_string()[..SETUP_TOKEN_MIN_CHARS - 1] - )); - assert!(SetupGate::prepare(&config, true).is_err()); - } - - #[test] - fn a_boot_with_an_account_closes_setup_and_deletes_a_stale_file() { - let temp = tempfile::tempdir().unwrap(); - let config = config(&temp); - SetupGate::prepare(&config, true).unwrap(); - let drawn = written_token(&config); - - let gate = SetupGate::prepare(&config, false).unwrap(); - assert!(!config.data_dir.join(FILE_NAME).exists()); - assert_eq!(gate.admit(Some(&drawn)), Admission::Closed); - } - - #[cfg(unix)] - #[test] - fn the_token_file_is_readable_by_its_owner_only() { - use std::os::unix::fs::PermissionsExt; - let temp = tempfile::tempdir().unwrap(); - let config = config(&temp); - SetupGate::prepare(&config, true).unwrap(); - let mode = std::fs::metadata(config.data_dir.join(FILE_NAME)) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o077, 0, "mode {mode:o}"); - } -} +mod tests; diff --git a/src/auth/setup_token/tests.rs b/src/auth/setup_token/tests.rs new file mode 100644 index 00000000..ea7b5efc --- /dev/null +++ b/src/auth/setup_token/tests.rs @@ -0,0 +1,124 @@ +use super::*; + +fn config(temp: &tempfile::TempDir) -> Config { + Config::for_data_dir(temp.path().join("data")) +} + +fn written_token(config: &Config) -> String { + std::fs::read_to_string(config.data_dir.join(FILE_NAME)) + .expect("the token file") + .trim() + .to_owned() +} + +#[test] +fn a_drawn_token_admits_only_itself_and_is_forgotten_on_close() { + let temp = tempfile::tempdir().unwrap(); + let config = config(&temp); + let gate = SetupGate::prepare(&config, true).unwrap(); + let token = written_token(&config); + assert!(token.starts_with(PREFIX)); + assert_eq!( + gate.source(), + Some(TokenSource::File(config.data_dir.join(FILE_NAME))) + ); + + assert_eq!(gate.admit(None), Admission::Rejected); + assert_eq!(gate.admit(Some("")), Admission::Rejected); + let wrong = security::generate_token(PREFIX); + assert_eq!(gate.admit(Some(&wrong)), Admission::Rejected); + assert_eq!(gate.admit(Some(&token)), Admission::Admitted); + + gate.close(); + assert_eq!(gate.admit(Some(&token)), Admission::Closed); + assert!(!config.data_dir.join(FILE_NAME).exists()); + assert_eq!(gate.source(), None); +} + +#[test] +fn a_restart_keeps_the_token_already_written() { + let temp = tempfile::tempdir().unwrap(); + let config = config(&temp); + SetupGate::prepare(&config, true).unwrap(); + let first = written_token(&config); + let again = SetupGate::prepare(&config, true).unwrap(); + assert_eq!(written_token(&config), first); + assert_eq!(again.admit(Some(&first)), Admission::Admitted); +} + +#[test] +fn an_operator_token_writes_no_file_and_replaces_a_drawn_one() { + let temp = tempfile::tempdir().unwrap(); + let mut config = config(&temp); + SetupGate::prepare(&config, true).unwrap(); + let drawn = written_token(&config); + + let chosen = uuid::Uuid::new_v4().to_string(); + config.setup_token = Some(chosen.clone()); + let gate = SetupGate::prepare(&config, true).unwrap(); + assert!(!config.data_dir.join(FILE_NAME).exists()); + assert_eq!(gate.source(), Some(TokenSource::Operator)); + assert_eq!(gate.admit(Some(&drawn)), Admission::Rejected); + assert_eq!(gate.admit(Some(&chosen)), Admission::Admitted); +} + +#[test] +fn a_file_another_process_has_not_finished_writing_is_refused() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(FILE_NAME); + let token = security::generate_token(PREFIX); + // Created and not yet written, then cut short: neither is a token, and + // the first would otherwise admit a request that sends none. + for partial in [ + String::new(), + "\n".to_owned(), + token[..PREFIX.len() + 4].to_owned(), + ] { + std::fs::write(&path, &partial).unwrap(); + let refused = written_by_another(&path).expect_err(&format!("{partial:?}")); + assert!(refused.to_string().contains(&path.display().to_string())); + } + std::fs::write(&path, format!("{token}\n")).unwrap(); + assert_eq!(written_by_another(&path).unwrap(), token); +} + +#[test] +fn an_operator_token_built_in_process_still_meets_the_floor() { + let temp = tempfile::tempdir().unwrap(); + let mut config = config(&temp); + config.setup_token = + Some(uuid::Uuid::new_v4().to_string()[..SETUP_TOKEN_MIN_CHARS - 1].to_owned()); + assert!(SetupGate::prepare(&config, true).is_err()); + // Spaces do not count toward it. + config.setup_token = Some(format!( + " {} ", + &uuid::Uuid::new_v4().to_string()[..SETUP_TOKEN_MIN_CHARS - 1] + )); + assert!(SetupGate::prepare(&config, true).is_err()); +} + +#[test] +fn a_boot_with_an_account_closes_setup_and_deletes_a_stale_file() { + let temp = tempfile::tempdir().unwrap(); + let config = config(&temp); + SetupGate::prepare(&config, true).unwrap(); + let drawn = written_token(&config); + + let gate = SetupGate::prepare(&config, false).unwrap(); + assert!(!config.data_dir.join(FILE_NAME).exists()); + assert_eq!(gate.admit(Some(&drawn)), Admission::Closed); +} + +#[cfg(unix)] +#[test] +fn the_token_file_is_readable_by_its_owner_only() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let config = config(&temp); + SetupGate::prepare(&config, true).unwrap(); + let mode = std::fs::metadata(config.data_dir.join(FILE_NAME)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o077, 0, "mode {mode:o}"); +} diff --git a/src/auth/sign_in/mod.rs b/src/auth/sign_in/mod.rs index f3640dcf..2f90a85c 100644 --- a/src/auth/sign_in/mod.rs +++ b/src/auth/sign_in/mod.rs @@ -506,249 +506,4 @@ impl Surface { } #[cfg(test)] -mod tests { - use super::*; - use axum::http::HeaderValue; - - fn nets(list: &[&str]) -> Vec { - list.iter().map(|net| net.parse().unwrap()).collect() - } - - fn forwarded(values: &[&str]) -> HeaderMap { - let mut headers = HeaderMap::new(); - for value in values { - headers.append("x-forwarded-for", HeaderValue::from_str(value).unwrap()); - } - headers - } - - fn ip(value: &str) -> IpAddr { - value.parse().unwrap() - } - - #[test] - fn an_untrusted_peer_is_the_client_whatever_it_forwards() { - let headers = forwarded(&["203.0.113.9"]); - assert_eq!( - client_address(Some(ip("198.51.100.7")), &headers, &nets(&["10.0.0.0/8"])), - Some(ip("198.51.100.7")) - ); - } - - #[test] - fn a_trusted_proxy_is_read_from_the_right() { - // The client wrote 1.1.1.1; the proxy appended the real 198.51.100.7. - let headers = forwarded(&["1.1.1.1, 198.51.100.7"]); - assert_eq!( - client_address(Some(ip("10.0.0.2")), &headers, &nets(&["10.0.0.0/8"])), - Some(ip("198.51.100.7")) - ); - // Two proxies, and several headers joined in order. - let headers = forwarded(&["1.1.1.1", "198.51.100.7, 10.0.0.3"]); - assert_eq!( - client_address(Some(ip("10.0.0.2")), &headers, &nets(&["10.0.0.0/8"])), - Some(ip("198.51.100.7")) - ); - } - - #[test] - fn a_mapped_ipv4_proxy_is_still_the_trusted_one() { - // A dual-stack listener reports the IPv4 proxy as mapped, and so may a - // proxy forwarding through another. - let headers = forwarded(&["1.1.1.1, 198.51.100.7, ::ffff:10.0.0.3"]); - assert_eq!( - client_address( - Some(ip("::ffff:10.0.0.2")), - &headers, - &nets(&["10.0.0.0/8"]) - ), - Some(ip("198.51.100.7")) - ); - // A list naming the proxy in its mapped form holds it whichever way - // the socket reports it, once loaded. - let mapped = vec![trusted_network("::ffff:10.0.0.0/104".parse().unwrap())]; - assert_eq!(mapped, nets(&["10.0.0.0/8"])); - for peer in ["::ffff:10.0.0.2", "10.0.0.2"] { - assert_eq!( - client_address(Some(ip(peer)), &headers, &mapped), - Some(ip("198.51.100.7")) - ); - } - // But an IPv6 block wider than the mapped range trusts no IPv4 peer: - // were it trusted, the walk would hand back the hop before the - // malformed entry instead of the peer. - let wide = vec![trusted_network("::/0".parse().unwrap())]; - let headers = forwarded(&["garbage, 198.51.100.7"]); - for peer in ["::ffff:10.0.0.2", "10.0.0.2"] { - assert_eq!( - client_address(Some(ip(peer)), &headers, &wide), - Some(ip("10.0.0.2")) - ); - } - assert_eq!( - trusted_network("2001:db8::/96".parse().unwrap()), - "2001:db8::/96".parse::().unwrap() - ); - } - - #[test] - fn a_malformed_or_all_trusted_chain_never_yields_a_chosen_address() { - let trusted = nets(&["10.0.0.0/8"]); - let headers = forwarded(&["1.1.1.1, garbage, 10.0.0.3"]); - assert_eq!( - client_address(Some(ip("10.0.0.2")), &headers, &trusted), - Some(ip("10.0.0.3")) - ); - let headers = forwarded(&["10.0.0.9, 10.0.0.3"]); - assert_eq!( - client_address(Some(ip("10.0.0.2")), &headers, &trusted), - Some(ip("10.0.0.2")) - ); - assert_eq!( - client_address(Some(ip("10.0.0.2")), &HeaderMap::new(), &trusted), - Some(ip("10.0.0.2")) - ); - } - - /// A byte that is not text in the part the client wrote stops nothing on - /// the right of it: the entry the proxy appended is still found. - #[test] - fn a_non_text_byte_in_the_client_s_part_does_not_hide_the_proxy_s_entry() { - let mut headers = HeaderMap::new(); - headers.append( - "x-forwarded-for", - HeaderValue::from_bytes(b"1.1.1.1\xff, 198.51.100.7").unwrap(), - ); - assert_eq!( - client_address(Some(ip("10.0.0.2")), &headers, &nets(&["10.0.0.0/8"])), - Some(ip("198.51.100.7")) - ); - // And on its own, it stops the walk at the last trusted hop. - let mut headers = HeaderMap::new(); - headers.append( - "x-forwarded-for", - HeaderValue::from_bytes(b"\xff, 10.0.0.3").unwrap(), - ); - assert_eq!( - client_address(Some(ip("10.0.0.2")), &headers, &nets(&["10.0.0.0/8"])), - Some(ip("10.0.0.3")) - ); - } - - #[test] - fn ipv6_counts_by_its_slash_64_and_mapped_ipv4_as_ipv4() { - assert_eq!( - address_bucket(Some(ip("2001:db8:1:2:aaaa::1"))), - address_bucket(Some(ip("2001:db8:1:2:bbbb::9"))) - ); - assert_ne!( - address_bucket(Some(ip("2001:db8:1:2::1"))), - address_bucket(Some(ip("2001:db8:1:3::1"))) - ); - assert_eq!( - address_bucket(Some(ip("::ffff:198.51.100.7"))), - "198.51.100.7" - ); - } - - #[test] - fn case_variants_of_a_username_share_a_window() { - let guard = SignInGuard::new(2); - for name in ["Alice", "ALICE", "alice", "aLiCe"] - .iter() - .cycle() - .take(FAILURES_PER_WINDOW) - { - drop(guard.reserve("a", &canonical_account(name)).unwrap()); - } - assert!(guard.reserve("a", &canonical_account("alice")).is_err()); - assert_eq!(guard.window_count(), 1); - } - - #[test] - fn another_address_keeps_its_own_budget() { - let guard = SignInGuard::new(2); - for _ in 0..FAILURES_PER_WINDOW { - drop(guard.reserve("attacker", "owner").unwrap()); - } - assert_eq!(guard.reserve("attacker", "owner").err(), Some(Throttled)); - assert!(guard.reserve("home", "owner").is_ok()); - } - - #[test] - fn a_released_slot_is_given_back_and_a_dropped_one_is_kept() { - let guard = SignInGuard::new(2); - for _ in 0..FAILURES_PER_WINDOW { - guard.reserve("a", "owner").unwrap().release(); - } - assert!(guard.reserve("a", "owner").is_ok()); - } - - #[test] - fn one_address_never_opens_more_than_its_windows() { - let guard = SignInGuard::new(2); - // 31 accounts, one window each. - for index in 0..WINDOWS_PER_ADDRESS - 1 { - drop(guard.reserve("a", &format!("invented-{index}")).unwrap()); - } - assert_eq!(guard.window_count(), WINDOWS_PER_ADDRESS - 1); - // The 32nd account onwards all count in the one overflow window. - for index in 0..FAILURES_PER_WINDOW { - drop(guard.reserve("a", &format!("more-{index}")).unwrap()); - } - assert_eq!(guard.window_count(), WINDOWS_PER_ADDRESS); - assert_eq!(guard.reserve("a", "yet-another").err(), Some(Throttled)); - // An account that already has its window keeps it. - assert!(guard.reserve("a", "invented-0").is_ok()); - assert!(guard.reserve("b", "someone").is_ok()); - } - - #[test] - fn a_full_table_refuses_new_keys_and_keeps_live_ones() { - let guard = SignInGuard::new(2); - { - let mut table = guard.table.lock().unwrap(); - let now = Instant::now(); - for index in 0..MAX_WINDOWS { - table.windows.insert( - window_key(&format!("filler-{index}"), "x"), - Window { - address: format!("filler-{index}"), - overflow: false, - attempts: VecDeque::from([now]), - }, - ); - } - } - assert_eq!(guard.reserve("new", "owner").err(), Some(Throttled)); - // A window already counting is never dropped to make room. - assert!(guard.reserve("filler-7", "x").is_ok()); - assert_eq!(guard.window_count(), MAX_WINDOWS); - } - - #[tokio::test] - async fn the_gate_bounds_the_queue() { - let guard = SignInGuard::new(1); - let held = guard.admit().await.expect("the first permit is free"); - let mut waiters = Vec::new(); - for _ in 0..WAITING_PER_PERMIT { - let guard = Arc::clone(&guard); - waiters.push(tokio::spawn(async move { guard.admit().await.is_some() })); - } - while guard.waiting() < WAITING_PER_PERMIT { - tokio::task::yield_now().await; - } - // The queue is full: the next one is turned away at once, not after - // waiting out the five seconds a queued request may wait. - let turned_away = tokio::time::timeout(Duration::from_millis(500), guard.admit()).await; - assert!( - matches!(turned_away, Ok(None)), - "a request past a full queue waited for a permit" - ); - drop(held); - for waiter in waiters { - assert!(waiter.await.unwrap()); - } - assert_eq!(guard.waiting(), 0); - } -} +mod tests; diff --git a/src/auth/sign_in/tests.rs b/src/auth/sign_in/tests.rs new file mode 100644 index 00000000..34893b01 --- /dev/null +++ b/src/auth/sign_in/tests.rs @@ -0,0 +1,244 @@ +use super::*; +use axum::http::HeaderValue; + +fn nets(list: &[&str]) -> Vec { + list.iter().map(|net| net.parse().unwrap()).collect() +} + +fn forwarded(values: &[&str]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for value in values { + headers.append("x-forwarded-for", HeaderValue::from_str(value).unwrap()); + } + headers +} + +fn ip(value: &str) -> IpAddr { + value.parse().unwrap() +} + +#[test] +fn an_untrusted_peer_is_the_client_whatever_it_forwards() { + let headers = forwarded(&["203.0.113.9"]); + assert_eq!( + client_address(Some(ip("198.51.100.7")), &headers, &nets(&["10.0.0.0/8"])), + Some(ip("198.51.100.7")) + ); +} + +#[test] +fn a_trusted_proxy_is_read_from_the_right() { + // The client wrote 1.1.1.1; the proxy appended the real 198.51.100.7. + let headers = forwarded(&["1.1.1.1, 198.51.100.7"]); + assert_eq!( + client_address(Some(ip("10.0.0.2")), &headers, &nets(&["10.0.0.0/8"])), + Some(ip("198.51.100.7")) + ); + // Two proxies, and several headers joined in order. + let headers = forwarded(&["1.1.1.1", "198.51.100.7, 10.0.0.3"]); + assert_eq!( + client_address(Some(ip("10.0.0.2")), &headers, &nets(&["10.0.0.0/8"])), + Some(ip("198.51.100.7")) + ); +} + +#[test] +fn a_mapped_ipv4_proxy_is_still_the_trusted_one() { + // A dual-stack listener reports the IPv4 proxy as mapped, and so may a + // proxy forwarding through another. + let headers = forwarded(&["1.1.1.1, 198.51.100.7, ::ffff:10.0.0.3"]); + assert_eq!( + client_address( + Some(ip("::ffff:10.0.0.2")), + &headers, + &nets(&["10.0.0.0/8"]) + ), + Some(ip("198.51.100.7")) + ); + // A list naming the proxy in its mapped form holds it whichever way + // the socket reports it, once loaded. + let mapped = vec![trusted_network("::ffff:10.0.0.0/104".parse().unwrap())]; + assert_eq!(mapped, nets(&["10.0.0.0/8"])); + for peer in ["::ffff:10.0.0.2", "10.0.0.2"] { + assert_eq!( + client_address(Some(ip(peer)), &headers, &mapped), + Some(ip("198.51.100.7")) + ); + } + // But an IPv6 block wider than the mapped range trusts no IPv4 peer: + // were it trusted, the walk would hand back the hop before the + // malformed entry instead of the peer. + let wide = vec![trusted_network("::/0".parse().unwrap())]; + let headers = forwarded(&["garbage, 198.51.100.7"]); + for peer in ["::ffff:10.0.0.2", "10.0.0.2"] { + assert_eq!( + client_address(Some(ip(peer)), &headers, &wide), + Some(ip("10.0.0.2")) + ); + } + assert_eq!( + trusted_network("2001:db8::/96".parse().unwrap()), + "2001:db8::/96".parse::().unwrap() + ); +} + +#[test] +fn a_malformed_or_all_trusted_chain_never_yields_a_chosen_address() { + let trusted = nets(&["10.0.0.0/8"]); + let headers = forwarded(&["1.1.1.1, garbage, 10.0.0.3"]); + assert_eq!( + client_address(Some(ip("10.0.0.2")), &headers, &trusted), + Some(ip("10.0.0.3")) + ); + let headers = forwarded(&["10.0.0.9, 10.0.0.3"]); + assert_eq!( + client_address(Some(ip("10.0.0.2")), &headers, &trusted), + Some(ip("10.0.0.2")) + ); + assert_eq!( + client_address(Some(ip("10.0.0.2")), &HeaderMap::new(), &trusted), + Some(ip("10.0.0.2")) + ); +} + +/// A byte that is not text in the part the client wrote stops nothing on +/// the right of it: the entry the proxy appended is still found. +#[test] +fn a_non_text_byte_in_the_client_s_part_does_not_hide_the_proxy_s_entry() { + let mut headers = HeaderMap::new(); + headers.append( + "x-forwarded-for", + HeaderValue::from_bytes(b"1.1.1.1\xff, 198.51.100.7").unwrap(), + ); + assert_eq!( + client_address(Some(ip("10.0.0.2")), &headers, &nets(&["10.0.0.0/8"])), + Some(ip("198.51.100.7")) + ); + // And on its own, it stops the walk at the last trusted hop. + let mut headers = HeaderMap::new(); + headers.append( + "x-forwarded-for", + HeaderValue::from_bytes(b"\xff, 10.0.0.3").unwrap(), + ); + assert_eq!( + client_address(Some(ip("10.0.0.2")), &headers, &nets(&["10.0.0.0/8"])), + Some(ip("10.0.0.3")) + ); +} + +#[test] +fn ipv6_counts_by_its_slash_64_and_mapped_ipv4_as_ipv4() { + assert_eq!( + address_bucket(Some(ip("2001:db8:1:2:aaaa::1"))), + address_bucket(Some(ip("2001:db8:1:2:bbbb::9"))) + ); + assert_ne!( + address_bucket(Some(ip("2001:db8:1:2::1"))), + address_bucket(Some(ip("2001:db8:1:3::1"))) + ); + assert_eq!( + address_bucket(Some(ip("::ffff:198.51.100.7"))), + "198.51.100.7" + ); +} + +#[test] +fn case_variants_of_a_username_share_a_window() { + let guard = SignInGuard::new(2); + for name in ["Alice", "ALICE", "alice", "aLiCe"] + .iter() + .cycle() + .take(FAILURES_PER_WINDOW) + { + drop(guard.reserve("a", &canonical_account(name)).unwrap()); + } + assert!(guard.reserve("a", &canonical_account("alice")).is_err()); + assert_eq!(guard.window_count(), 1); +} + +#[test] +fn another_address_keeps_its_own_budget() { + let guard = SignInGuard::new(2); + for _ in 0..FAILURES_PER_WINDOW { + drop(guard.reserve("attacker", "owner").unwrap()); + } + assert_eq!(guard.reserve("attacker", "owner").err(), Some(Throttled)); + assert!(guard.reserve("home", "owner").is_ok()); +} + +#[test] +fn a_released_slot_is_given_back_and_a_dropped_one_is_kept() { + let guard = SignInGuard::new(2); + for _ in 0..FAILURES_PER_WINDOW { + guard.reserve("a", "owner").unwrap().release(); + } + assert!(guard.reserve("a", "owner").is_ok()); +} + +#[test] +fn one_address_never_opens_more_than_its_windows() { + let guard = SignInGuard::new(2); + // 31 accounts, one window each. + for index in 0..WINDOWS_PER_ADDRESS - 1 { + drop(guard.reserve("a", &format!("invented-{index}")).unwrap()); + } + assert_eq!(guard.window_count(), WINDOWS_PER_ADDRESS - 1); + // The 32nd account onwards all count in the one overflow window. + for index in 0..FAILURES_PER_WINDOW { + drop(guard.reserve("a", &format!("more-{index}")).unwrap()); + } + assert_eq!(guard.window_count(), WINDOWS_PER_ADDRESS); + assert_eq!(guard.reserve("a", "yet-another").err(), Some(Throttled)); + // An account that already has its window keeps it. + assert!(guard.reserve("a", "invented-0").is_ok()); + assert!(guard.reserve("b", "someone").is_ok()); +} + +#[test] +fn a_full_table_refuses_new_keys_and_keeps_live_ones() { + let guard = SignInGuard::new(2); + { + let mut table = guard.table.lock().unwrap(); + let now = Instant::now(); + for index in 0..MAX_WINDOWS { + table.windows.insert( + window_key(&format!("filler-{index}"), "x"), + Window { + address: format!("filler-{index}"), + overflow: false, + attempts: VecDeque::from([now]), + }, + ); + } + } + assert_eq!(guard.reserve("new", "owner").err(), Some(Throttled)); + // A window already counting is never dropped to make room. + assert!(guard.reserve("filler-7", "x").is_ok()); + assert_eq!(guard.window_count(), MAX_WINDOWS); +} + +#[tokio::test] +async fn the_gate_bounds_the_queue() { + let guard = SignInGuard::new(1); + let held = guard.admit().await.expect("the first permit is free"); + let mut waiters = Vec::new(); + for _ in 0..WAITING_PER_PERMIT { + let guard = Arc::clone(&guard); + waiters.push(tokio::spawn(async move { guard.admit().await.is_some() })); + } + while guard.waiting() < WAITING_PER_PERMIT { + tokio::task::yield_now().await; + } + // The queue is full: the next one is turned away at once, not after + // waiting out the five seconds a queued request may wait. + let turned_away = tokio::time::timeout(Duration::from_millis(500), guard.admit()).await; + assert!( + matches!(turned_away, Ok(None)), + "a request past a full queue waited for a permit" + ); + drop(held); + for waiter in waiters { + assert!(waiter.await.unwrap()); + } + assert_eq!(guard.waiting(), 0); +} diff --git a/src/auth/stream_ticket/mod.rs b/src/auth/stream_ticket/mod.rs index 529d1610..d9304cdc 100644 --- a/src/auth/stream_ticket/mod.rs +++ b/src/auth/stream_ticket/mod.rs @@ -121,131 +121,4 @@ pub fn verify(secret: &SecretBox, ticket: &str, now_ms: i64) -> Option<(TicketKi } #[cfg(test)] -mod tests { - use super::*; - - fn secret() -> SecretBox { - SecretBox::from_key_bytes(&[7u8; 32]).unwrap() - } - - /// Seals a payload the public API cannot express, so the test can present a - /// ticket that is genuine in every respect except its kind. - fn seal(secret: &SecretBox, payload: &[u8]) -> String { - let sealed = secret.encrypt(payload).unwrap(); - let mut raw = Vec::with_capacity(NONCE_LEN + sealed.ciphertext.len()); - raw.extend_from_slice(&sealed.nonce); - raw.extend_from_slice(&sealed.ciphertext); - URL_SAFE_NO_PAD.encode(raw) - } - - fn payload_with_kind(byte: u8, user: Uuid, track: Uuid, expires_at: i64) -> Vec { - let mut payload = Vec::with_capacity(PAYLOAD_LEN); - payload.push(byte); - payload.extend_from_slice(user.as_bytes()); - payload.extend_from_slice(track.as_bytes()); - payload.extend_from_slice(&expires_at.to_be_bytes()); - payload - } - - #[test] - fn round_trip_returns_the_authorised_triple() { - let secret = secret(); - for kind in [ - TicketKind::Audio, - TicketKind::Canvas, - TicketKind::ShareVisit, - ] { - let user = Uuid::new_v4(); - let track = Uuid::new_v4(); - let ticket = mint(&secret, kind, user, track, 1_000).unwrap(); - assert_eq!(verify(&secret, &ticket, 999), Some((kind, user, track))); - } - } - - #[test] - fn the_kinds_do_not_answer_for_each_other() { - let secret = secret(); - let user = Uuid::new_v4(); - let track = Uuid::new_v4(); - let audio = mint(&secret, TicketKind::Audio, user, track, 1_000).unwrap(); - let canvas = mint(&secret, TicketKind::Canvas, user, track, 1_000).unwrap(); - let visit = mint(&secret, TicketKind::ShareVisit, user, track, 1_000).unwrap(); - assert_ne!(audio, canvas, "the kind is inside the sealed payload"); - assert_eq!( - verify(&secret, &audio, 999).map(|(kind, ..)| kind), - Some(TicketKind::Audio) - ); - assert_eq!( - verify(&secret, &canvas, 999).map(|(kind, ..)| kind), - Some(TicketKind::Canvas) - ); - assert_eq!( - verify(&secret, &visit, 999).map(|(kind, ..)| kind), - Some(TicketKind::ShareVisit) - ); - } - - #[test] - fn an_unknown_kind_is_refused_even_under_the_right_key() { - let secret = secret(); - let user = Uuid::new_v4(); - let track = Uuid::new_v4(); - // Sealed under the real key, correct length, live expiry: the kind byte - // is the only thing wrong with it. A forged ticket would prove nothing - // about this contract, since it already fails to decrypt. - for byte in [0x00, 0x04, 0xff] { - let ticket = seal(&secret, &payload_with_kind(byte, user, track, 1_000)); - assert_eq!( - verify(&secret, &ticket, 999), - None, - "kind {byte:#04x} is not one of the kinds" - ); - } - } - - #[test] - fn a_payload_of_the_previous_length_is_refused() { - let secret = secret(); - // What `mint` produced before the kind byte existed: user || track || - // expiry, forty bytes, sealed under the current key. - let mut payload = Vec::with_capacity(40); - payload.extend_from_slice(Uuid::new_v4().as_bytes()); - payload.extend_from_slice(Uuid::new_v4().as_bytes()); - payload.extend_from_slice(&1_000i64.to_be_bytes()); - let ticket = seal(&secret, &payload); - assert_eq!(verify(&secret, &ticket, 999), None); - } - - #[test] - fn expired_forged_and_malformed_tickets_are_all_rejected() { - let secret = secret(); - let ticket = mint( - &secret, - TicketKind::Audio, - Uuid::new_v4(), - Uuid::new_v4(), - 1_000, - ) - .unwrap(); - assert_eq!(verify(&secret, &ticket, 1_000), None, "expiry is exclusive"); - assert_eq!(verify(&secret, &ticket, 1_001), None); - - let other = SecretBox::from_key_bytes(&[9u8; 32]).unwrap(); - assert_eq!( - verify(&other, &ticket, 0), - None, - "a ticket minted elsewhere must not validate" - ); - - assert_eq!(verify(&secret, "not-base64!", 0), None); - assert_eq!(verify(&secret, "", 0), None); - let mut tampered = URL_SAFE_NO_PAD.decode(&ticket).unwrap(); - let last = tampered.len() - 1; - tampered[last] ^= 0xff; - assert_eq!( - verify(&secret, &URL_SAFE_NO_PAD.encode(tampered), 0), - None, - "AEAD must reject a flipped bit" - ); - } -} +mod tests; diff --git a/src/auth/stream_ticket/tests.rs b/src/auth/stream_ticket/tests.rs new file mode 100644 index 00000000..d72e56ba --- /dev/null +++ b/src/auth/stream_ticket/tests.rs @@ -0,0 +1,126 @@ +use super::*; + +fn secret() -> SecretBox { + SecretBox::from_key_bytes(&[7u8; 32]).unwrap() +} + +/// Seals a payload the public API cannot express, so the test can present a +/// ticket that is genuine in every respect except its kind. +fn seal(secret: &SecretBox, payload: &[u8]) -> String { + let sealed = secret.encrypt(payload).unwrap(); + let mut raw = Vec::with_capacity(NONCE_LEN + sealed.ciphertext.len()); + raw.extend_from_slice(&sealed.nonce); + raw.extend_from_slice(&sealed.ciphertext); + URL_SAFE_NO_PAD.encode(raw) +} + +fn payload_with_kind(byte: u8, user: Uuid, track: Uuid, expires_at: i64) -> Vec { + let mut payload = Vec::with_capacity(PAYLOAD_LEN); + payload.push(byte); + payload.extend_from_slice(user.as_bytes()); + payload.extend_from_slice(track.as_bytes()); + payload.extend_from_slice(&expires_at.to_be_bytes()); + payload +} + +#[test] +fn round_trip_returns_the_authorised_triple() { + let secret = secret(); + for kind in [ + TicketKind::Audio, + TicketKind::Canvas, + TicketKind::ShareVisit, + ] { + let user = Uuid::new_v4(); + let track = Uuid::new_v4(); + let ticket = mint(&secret, kind, user, track, 1_000).unwrap(); + assert_eq!(verify(&secret, &ticket, 999), Some((kind, user, track))); + } +} + +#[test] +fn the_kinds_do_not_answer_for_each_other() { + let secret = secret(); + let user = Uuid::new_v4(); + let track = Uuid::new_v4(); + let audio = mint(&secret, TicketKind::Audio, user, track, 1_000).unwrap(); + let canvas = mint(&secret, TicketKind::Canvas, user, track, 1_000).unwrap(); + let visit = mint(&secret, TicketKind::ShareVisit, user, track, 1_000).unwrap(); + assert_ne!(audio, canvas, "the kind is inside the sealed payload"); + assert_eq!( + verify(&secret, &audio, 999).map(|(kind, ..)| kind), + Some(TicketKind::Audio) + ); + assert_eq!( + verify(&secret, &canvas, 999).map(|(kind, ..)| kind), + Some(TicketKind::Canvas) + ); + assert_eq!( + verify(&secret, &visit, 999).map(|(kind, ..)| kind), + Some(TicketKind::ShareVisit) + ); +} + +#[test] +fn an_unknown_kind_is_refused_even_under_the_right_key() { + let secret = secret(); + let user = Uuid::new_v4(); + let track = Uuid::new_v4(); + // Sealed under the real key, correct length, live expiry: the kind byte + // is the only thing wrong with it. A forged ticket would prove nothing + // about this contract, since it already fails to decrypt. + for byte in [0x00, 0x04, 0xff] { + let ticket = seal(&secret, &payload_with_kind(byte, user, track, 1_000)); + assert_eq!( + verify(&secret, &ticket, 999), + None, + "kind {byte:#04x} is not one of the kinds" + ); + } +} + +#[test] +fn a_payload_of_the_previous_length_is_refused() { + let secret = secret(); + // What `mint` produced before the kind byte existed: user || track || + // expiry, forty bytes, sealed under the current key. + let mut payload = Vec::with_capacity(40); + payload.extend_from_slice(Uuid::new_v4().as_bytes()); + payload.extend_from_slice(Uuid::new_v4().as_bytes()); + payload.extend_from_slice(&1_000i64.to_be_bytes()); + let ticket = seal(&secret, &payload); + assert_eq!(verify(&secret, &ticket, 999), None); +} + +#[test] +fn expired_forged_and_malformed_tickets_are_all_rejected() { + let secret = secret(); + let ticket = mint( + &secret, + TicketKind::Audio, + Uuid::new_v4(), + Uuid::new_v4(), + 1_000, + ) + .unwrap(); + assert_eq!(verify(&secret, &ticket, 1_000), None, "expiry is exclusive"); + assert_eq!(verify(&secret, &ticket, 1_001), None); + + let other = SecretBox::from_key_bytes(&[9u8; 32]).unwrap(); + assert_eq!( + verify(&other, &ticket, 0), + None, + "a ticket minted elsewhere must not validate" + ); + + assert_eq!(verify(&secret, "not-base64!", 0), None); + assert_eq!(verify(&secret, "", 0), None); + let mut tampered = URL_SAFE_NO_PAD.decode(&ticket).unwrap(); + let last = tampered.len() - 1; + tampered[last] ^= 0xff; + assert_eq!( + verify(&secret, &URL_SAFE_NO_PAD.encode(tampered), 0), + None, + "AEAD must reject a flipped bit" + ); +} diff --git a/src/http/public_share/mod.rs b/src/http/public_share/mod.rs index 1aebe7d2..d9b699fa 100644 --- a/src/http/public_share/mod.rs +++ b/src/http/public_share/mod.rs @@ -375,33 +375,4 @@ async fn cover( } #[cfg(test)] -mod tests { - use super::*; - - fn accept(value: &str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(header::ACCEPT, HeaderValue::from_str(value).unwrap()); - headers - } - - #[test] - fn only_an_explicit_html_request_is_handed_the_page() { - for page in [ - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "TEXT/HTML", - "application/json;q=0.5, text/html", - ] { - assert!(wants_page(&accept(page)), "{page}"); - } - for document in [ - "*/*", - "application/json", - "text/*", - "text/html;q=0", - "text/htmlx", - ] { - assert!(!wants_page(&accept(document)), "{document}"); - } - assert!(!wants_page(&HeaderMap::new())); - } -} +mod tests; diff --git a/src/http/public_share/tests.rs b/src/http/public_share/tests.rs new file mode 100644 index 00000000..be1b6570 --- /dev/null +++ b/src/http/public_share/tests.rs @@ -0,0 +1,28 @@ +use super::*; + +fn accept(value: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::ACCEPT, HeaderValue::from_str(value).unwrap()); + headers +} + +#[test] +fn only_an_explicit_html_request_is_handed_the_page() { + for page in [ + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "TEXT/HTML", + "application/json;q=0.5, text/html", + ] { + assert!(wants_page(&accept(page)), "{page}"); + } + for document in [ + "*/*", + "application/json", + "text/*", + "text/html;q=0", + "text/htmlx", + ] { + assert!(!wants_page(&accept(document)), "{document}"); + } + assert!(!wants_page(&HeaderMap::new())); +} diff --git a/src/http/reference/mod.rs b/src/http/reference/mod.rs index 9f0c4277..e46890ee 100644 --- a/src/http/reference/mod.rs +++ b/src/http/reference/mod.rs @@ -257,102 +257,4 @@ pub fn decompressed() -> std::io::Result> { } #[cfg(test)] -mod tests { - use sha2::{Digest, Sha256}; - - use super::*; - - /// The embedded bytes are the release this module names, and nothing - /// else: a file replaced without its digest, or a digest changed without - /// its file, fails here. - #[test] - fn the_embedded_bundle_is_the_pinned_release() { - let bundle = decompressed().expect("the bundle decompresses"); - assert_eq!(hex::encode(Sha256::digest(&bundle)), SCALAR_SHA256); - assert!(SCALAR_SCRIPT_PATH.contains(SCALAR_VERSION)); - } - - /// A client that takes no gzip costs one decompression per process, not - /// one per request: the route is open to whoever `/reference` is open to. - /// Served twice, and three times more, the count stays at one, and both - /// answers are the published bundle. - #[tokio::test] - async fn the_bundle_is_decompressed_once_whatever_the_requests() { - use http_body_util::BodyExt as _; - let mut bodies = Vec::new(); - for _ in 0..5 { - let response = serve_script(HeaderMap::new()).await; - assert_eq!(response.status(), StatusCode::OK); - assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); - bodies.push(response.into_body().collect().await.unwrap().to_bytes()); - } - assert_eq!( - DECOMPRESSIONS.load(std::sync::atomic::Ordering::SeqCst), - 1, - "the bundle must be decompressed once and kept" - ); - assert_eq!(hex::encode(Sha256::digest(&bodies[0])), SCALAR_SHA256); - assert!(bodies.iter().all(|body| body == &bodies[0])); - } - - #[test] - fn gzip_is_sent_only_to_whoever_takes_it() { - let with = |value: &str| { - let mut headers = HeaderMap::new(); - headers.insert( - header::ACCEPT_ENCODING, - HeaderValue::from_str(value).unwrap(), - ); - accepts_gzip(&headers) - }; - assert!(with("gzip, deflate, br, zstd")); - assert!(with("br;q=1.0, GZIP;q=0.5")); - assert!(with("*")); - assert!(!with("br")); - assert!(!with("gzip;q=0")); - assert!(!accepts_gzip(&HeaderMap::new())); - // A coding the field names is decided by its own entry, and `*` only - // stands for the ones it does not name (RFC 9110 §12.5.3). - assert!(!with("gzip;q=0, *;q=1")); - assert!(!with("*, gzip;q=0")); - assert!(with("*;q=0, gzip")); - assert!(!with("br, *;q=0")); - assert!(with("x-gzip")); - // Names are case-insensitive, the weight's as much as the coding's, - // and whitespace around `;` and `=` changes nothing. - assert!(!with("gzip;Q=0")); - assert!(!with("GZIP ; q = 0")); - assert!(!with("gzip;q=0.000, *")); - assert!(with("Gzip;Q=0.001")); - assert!(with("gzip;q=1.000")); - // A weight outside the grammar makes its entry say nothing: the next - // entry, or the wildcard, decides, and nothing panics. - for malformed in ["2", "1.5", "-1", "0.0001", "abc", "", "NaN", "inf", "1e0"] { - assert!( - !with(&format!("gzip;q={malformed}")), - "q={malformed} is no acceptance" - ); - assert!( - with(&format!("gzip;q={malformed}, *")), - "q={malformed} leaves the wildcard deciding" - ); - assert!( - !with(&format!("gzip;q={malformed}, gzip;q=0")), - "q={malformed} leaves the next gzip entry deciding" - ); - } - // An empty field is identity only. - assert!(!with("")); - assert!(!with(" , ;q=1")); - } - - /// No string in the document can end the block it is carried in. - #[test] - fn the_document_cannot_close_its_own_block() { - let mut openapi = utoipa::openapi::OpenApi::default(); - openapi.info.description = Some("".to_owned()); - let page = page_template(&openapi); - assert_eq!(page.matches("").count(), 2, "{page}"); - assert!(page.contains("\\u003c/script>")); - } -} +mod tests; diff --git a/src/http/reference/tests.rs b/src/http/reference/tests.rs new file mode 100644 index 00000000..44b8a00a --- /dev/null +++ b/src/http/reference/tests.rs @@ -0,0 +1,97 @@ +use sha2::{Digest, Sha256}; + +use super::*; + +/// The embedded bytes are the release this module names, and nothing +/// else: a file replaced without its digest, or a digest changed without +/// its file, fails here. +#[test] +fn the_embedded_bundle_is_the_pinned_release() { + let bundle = decompressed().expect("the bundle decompresses"); + assert_eq!(hex::encode(Sha256::digest(&bundle)), SCALAR_SHA256); + assert!(SCALAR_SCRIPT_PATH.contains(SCALAR_VERSION)); +} + +/// A client that takes no gzip costs one decompression per process, not +/// one per request: the route is open to whoever `/reference` is open to. +/// Served twice, and three times more, the count stays at one, and both +/// answers are the published bundle. +#[tokio::test] +async fn the_bundle_is_decompressed_once_whatever_the_requests() { + use http_body_util::BodyExt as _; + let mut bodies = Vec::new(); + for _ in 0..5 { + let response = serve_script(HeaderMap::new()).await; + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + bodies.push(response.into_body().collect().await.unwrap().to_bytes()); + } + assert_eq!( + DECOMPRESSIONS.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the bundle must be decompressed once and kept" + ); + assert_eq!(hex::encode(Sha256::digest(&bodies[0])), SCALAR_SHA256); + assert!(bodies.iter().all(|body| body == &bodies[0])); +} + +#[test] +fn gzip_is_sent_only_to_whoever_takes_it() { + let with = |value: &str| { + let mut headers = HeaderMap::new(); + headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_str(value).unwrap(), + ); + accepts_gzip(&headers) + }; + assert!(with("gzip, deflate, br, zstd")); + assert!(with("br;q=1.0, GZIP;q=0.5")); + assert!(with("*")); + assert!(!with("br")); + assert!(!with("gzip;q=0")); + assert!(!accepts_gzip(&HeaderMap::new())); + // A coding the field names is decided by its own entry, and `*` only + // stands for the ones it does not name (RFC 9110 §12.5.3). + assert!(!with("gzip;q=0, *;q=1")); + assert!(!with("*, gzip;q=0")); + assert!(with("*;q=0, gzip")); + assert!(!with("br, *;q=0")); + assert!(with("x-gzip")); + // Names are case-insensitive, the weight's as much as the coding's, + // and whitespace around `;` and `=` changes nothing. + assert!(!with("gzip;Q=0")); + assert!(!with("GZIP ; q = 0")); + assert!(!with("gzip;q=0.000, *")); + assert!(with("Gzip;Q=0.001")); + assert!(with("gzip;q=1.000")); + // A weight outside the grammar makes its entry say nothing: the next + // entry, or the wildcard, decides, and nothing panics. + for malformed in ["2", "1.5", "-1", "0.0001", "abc", "", "NaN", "inf", "1e0"] { + assert!( + !with(&format!("gzip;q={malformed}")), + "q={malformed} is no acceptance" + ); + assert!( + with(&format!("gzip;q={malformed}, *")), + "q={malformed} leaves the wildcard deciding" + ); + assert!( + !with(&format!("gzip;q={malformed}, gzip;q=0")), + "q={malformed} leaves the next gzip entry deciding" + ); + } + // An empty field is identity only. + assert!(!with("")); + assert!(!with(" , ;q=1")); +} + +/// No string in the document can end the block it is carried in. +#[test] +fn the_document_cannot_close_its_own_block() { + let mut openapi = utoipa::openapi::OpenApi::default(); + openapi.info.description = Some("".to_owned()); + let page = page_template(&openapi); + assert_eq!(page.matches("").count(), 2, "{page}"); + assert!(page.contains("\\u003c/script>")); +} diff --git a/src/http/security_headers/mod.rs b/src/http/security_headers/mod.rs index 4f7bac30..6155f3f3 100644 --- a/src/http/security_headers/mod.rs +++ b/src/http/security_headers/mod.rs @@ -204,78 +204,4 @@ fn inline_digests(html: &str, tag: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn digest(content: &str) -> String { - format!( - "'sha256-{}'", - STANDARD.encode(Sha256::digest(content.as_bytes())) - ) - } - - #[test] - fn each_inline_block_is_admitted_by_its_digest_and_nothing_else() { - let style = "\n #splash { opacity: 1; }\n"; - let script = "\n setTimeout(function () {}, 10000);\n"; - let html = format!( - "\ - \ - \ - not a tag" - ); - let policy = web_client_policy(&html); - assert!( - policy.contains(&format!("script-src 'self' {};", digest(script))), - "{policy}" - ); - assert!( - policy.contains(&format!("style-src 'self' {};", digest(style))), - "{policy}" - ); - // The bundle's own tag has a `src` and is governed by `'self'`. - assert_eq!(policy.matches("'sha256-").count(), 2, "{policy}"); - assert!(!policy.contains("unsafe-inline"), "{policy}"); - assert!(!policy.contains("unsafe-eval"), "{policy}"); - } - - #[test] - fn a_shell_without_inline_blocks_admits_this_origin_only() { - let policy = web_client_policy("

placeholder

"); - assert!(policy.contains("script-src 'self';"), "{policy}"); - assert!(policy.contains("style-src 'self';"), "{policy}"); - assert!(policy.ends_with("frame-ancestors 'self'"), "{policy}"); - } - - /// The share page runs the client under the client's own policy, and is - /// framed by nobody (RFC-020 decision 3). - #[test] - fn the_share_page_is_the_client_policy_framed_by_nobody() { - let html = ""; - let page = share_page_policy(html); - assert_eq!( - page, - web_client_policy(html).replace("frame-ancestors 'self'", "frame-ancestors 'none'") - ); - } - - /// The share layer keeps the page's policy and gives every other share - /// response the framing-only one. - #[tokio::test] - async fn the_share_layer_keeps_the_page_policy_and_sets_the_rest() { - let page_policy = HeaderValue::from_static("default-src 'self'; frame-ancestors 'none'"); - let mut page = Response::new(axum::body::Body::empty()); - page.headers_mut() - .insert(header::CONTENT_SECURITY_POLICY, page_policy.clone()); - let page = share(page).await; - assert_eq!(page.headers()[header::CONTENT_SECURITY_POLICY], page_policy); - assert_eq!(page.headers()[header::REFERRER_POLICY], "no-referrer"); - - let document = share(Response::new(axum::body::Body::empty())).await; - assert_eq!( - document.headers()[header::CONTENT_SECURITY_POLICY], - "frame-ancestors 'none'" - ); - assert_eq!(document.headers()[header::X_FRAME_OPTIONS], "DENY"); - } -} +mod tests; diff --git a/src/http/security_headers/tests.rs b/src/http/security_headers/tests.rs new file mode 100644 index 00000000..5c4e9d24 --- /dev/null +++ b/src/http/security_headers/tests.rs @@ -0,0 +1,73 @@ +use super::*; + +fn digest(content: &str) -> String { + format!( + "'sha256-{}'", + STANDARD.encode(Sha256::digest(content.as_bytes())) + ) +} + +#[test] +fn each_inline_block_is_admitted_by_its_digest_and_nothing_else() { + let style = "\n #splash { opacity: 1; }\n"; + let script = "\n setTimeout(function () {}, 10000);\n"; + let html = format!( + "\ + \ + \ + not a tag" + ); + let policy = web_client_policy(&html); + assert!( + policy.contains(&format!("script-src 'self' {};", digest(script))), + "{policy}" + ); + assert!( + policy.contains(&format!("style-src 'self' {};", digest(style))), + "{policy}" + ); + // The bundle's own tag has a `src` and is governed by `'self'`. + assert_eq!(policy.matches("'sha256-").count(), 2, "{policy}"); + assert!(!policy.contains("unsafe-inline"), "{policy}"); + assert!(!policy.contains("unsafe-eval"), "{policy}"); +} + +#[test] +fn a_shell_without_inline_blocks_admits_this_origin_only() { + let policy = web_client_policy("

placeholder

"); + assert!(policy.contains("script-src 'self';"), "{policy}"); + assert!(policy.contains("style-src 'self';"), "{policy}"); + assert!(policy.ends_with("frame-ancestors 'self'"), "{policy}"); +} + +/// The share page runs the client under the client's own policy, and is +/// framed by nobody (RFC-020 decision 3). +#[test] +fn the_share_page_is_the_client_policy_framed_by_nobody() { + let html = ""; + let page = share_page_policy(html); + assert_eq!( + page, + web_client_policy(html).replace("frame-ancestors 'self'", "frame-ancestors 'none'") + ); +} + +/// The share layer keeps the page's policy and gives every other share +/// response the framing-only one. +#[tokio::test] +async fn the_share_layer_keeps_the_page_policy_and_sets_the_rest() { + let page_policy = HeaderValue::from_static("default-src 'self'; frame-ancestors 'none'"); + let mut page = Response::new(axum::body::Body::empty()); + page.headers_mut() + .insert(header::CONTENT_SECURITY_POLICY, page_policy.clone()); + let page = share(page).await; + assert_eq!(page.headers()[header::CONTENT_SECURITY_POLICY], page_policy); + assert_eq!(page.headers()[header::REFERRER_POLICY], "no-referrer"); + + let document = share(Response::new(axum::body::Body::empty())).await; + assert_eq!( + document.headers()[header::CONTENT_SECURITY_POLICY], + "frame-ancestors 'none'" + ); + assert_eq!(document.headers()[header::X_FRAME_OPTIONS], "DENY"); +} diff --git a/src/library/credit_split/mod.rs b/src/library/credit_split/mod.rs index f434c196..05e05a0a 100644 --- a/src/library/credit_split/mod.rs +++ b/src/library/credit_split/mod.rs @@ -204,208 +204,4 @@ pub fn resolve( } #[cfg(test)] -mod tests { - use super::*; - - fn credit(role: Role, position: usize, name: &str, sort: Option<&str>) -> Credit { - Credit { - role, - sub_role: String::new(), - name: name.to_owned(), - sort_name: sort.map(str::to_owned), - position, - } - } - - /// The identity the default spec derives, without a library: enough to - /// tell two names apart the way the catalogue does. - fn identity(name: &str) -> Uuid { - Uuid::new_v5(&Uuid::NAMESPACE_OID, credit_key(name).as_bytes()) - } - - fn names(credits: &[Credit], role: Role) -> Vec<(usize, &str)> { - credits - .iter() - .filter(|credit| credit.role == role) - .map(|credit| (credit.position, credit.name.as_str())) - .collect() - } - - fn split(parts: &[&str]) -> CreditDecision { - CreditDecision::Split(parts.iter().map(|part| (*part).to_owned()).collect()) - } - - #[test] - fn the_fragments_are_the_desktops_with_parentheses_protected() { - assert_eq!( - fragments("Ashreveal, Ashish Swargiary"), - ["Ashreveal", "Ashish Swargiary"] - ); - assert_eq!(fragments("蔡依林,周杰伦"), ["蔡依林", "周杰伦"]); - assert_eq!(fragments("庄心妍、祁隆"), ["庄心妍", "祁隆"]); - assert_eq!(fragments("A、B, C"), ["A", "B", "C"]); - assert_eq!(fragments("A, B, A"), ["A", "B"]); - assert_eq!( - fragments("Ian Asher, Olly Alexander (Years, Years)"), - ["Ian Asher", "Olly Alexander (Years, Years)"], - "a comma inside parentheses qualifies a name" - ); - } - - #[test] - fn a_candidate_has_two_fragments_and_none_is_the_whole() { - assert!(is_candidate("Zedd, Alessia Cara")); - assert!(is_candidate("Tyler, The Creator"), "only a person can tell"); - assert!(!is_candidate("Solo,")); - assert!(!is_candidate("Simon & Garfunkel")); - assert!(!is_candidate("A, a"), "one fragment once deduplicated"); - } - - #[test] - fn the_key_folds_case_and_typographic_punctuation_only() { - assert_eq!(credit_key(" A, B "), credit_key("a, b")); - assert_eq!(credit_key("Guns N’ Roses"), credit_key("guns n' roses")); - assert_ne!(credit_key("AC/DC"), credit_key("AC DC")); - } - - #[test] - fn a_split_replaces_the_credit_in_place_and_renumbers() { - let credits = [ - credit(Role::Artist, 0, "X", None), - credit(Role::Artist, 1, "A, B", None), - credit(Role::Artist, 2, "Y", None), - credit(Role::AlbumArtist, 0, "A, B", None), - ]; - let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); - let resolved = resolve(&credits, &decisions, None, identity); - assert_eq!( - names(&resolved, Role::Artist), - [(0, "X"), (1, "A"), (2, "B"), (3, "Y")] - ); - assert_eq!(names(&resolved, Role::AlbumArtist), [(0, "A"), (1, "B")]); - } - - #[test] - fn no_decision_leaves_every_credit_as_the_file_wrote_it() { - let credits = [ - credit(Role::Artist, 0, "A, B", Some("B, A")), - credit(Role::Composer, 0, "C, D", None), - ]; - let resolved = resolve(&credits, &Decisions::new(), None, identity); - assert_eq!(resolved.len(), 2); - assert_eq!(resolved[0].sort_name.as_deref(), Some("B, A")); - } - - #[test] - fn a_keep_changes_nothing() { - let credits = [credit(Role::Artist, 0, "Tyler, The Creator", None)]; - let decisions = Decisions::from([(credit_key("Tyler, The Creator"), CreditDecision::Keep)]); - assert_eq!( - names(&resolve(&credits, &decisions, None, identity), Role::Artist), - [(0, "Tyler, The Creator")] - ); - } - - #[test] - fn a_part_already_credited_is_not_credited_twice() { - let credits = [ - credit(Role::Artist, 0, "A", None), - credit(Role::Artist, 1, "a, B", None), - ]; - let decisions = Decisions::from([(credit_key("a, B"), split(&["a", "B"]))]); - assert_eq!( - names(&resolve(&credits, &decisions, None, identity), Role::Artist), - [(0, "A"), (1, "B")], - "the first occurrence wins, and positions stay contiguous" - ); - } - - #[test] - fn two_credits_sharing_a_part_keep_the_files_order_whichever_is_undone() { - let credits = [ - credit(Role::Artist, 0, "A, B", None), - credit(Role::Artist, 1, "A, C", None), - ]; - let both = Decisions::from([ - (credit_key("A, B"), split(&["A", "B"])), - (credit_key("A, C"), split(&["A", "C"])), - ]); - assert_eq!( - names(&resolve(&credits, &both, None, identity), Role::Artist), - [(0, "A"), (1, "B"), (2, "C")] - ); - let first = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); - assert_eq!( - names(&resolve(&credits, &first, None, identity), Role::Artist), - [(0, "A"), (1, "B"), (2, "A, C")] - ); - let second = Decisions::from([(credit_key("A, C"), split(&["A", "C"]))]); - assert_eq!( - names(&resolve(&credits, &second, None, identity), Role::Artist), - [(0, "A, B"), (1, "A"), (2, "C")] - ); - } - - #[test] - fn sort_forms_are_paired_before_the_fragments_are_deduplicated() { - let credits = [credit(Role::Artist, 0, "A, B, B, C", Some("a, b, b, c"))]; - let decisions = Decisions::from([(credit_key("A, B, B, C"), split(&["A", "B", "C"]))]); - let resolved = resolve(&credits, &decisions, None, identity); - let sorts: Vec> = resolved - .iter() - .map(|credit| credit.sort_name.as_deref()) - .collect(); - assert_eq!(sorts, [Some("a"), Some("b"), Some("c")]); - } - - #[test] - fn a_sort_form_of_another_length_pairs_with_nothing() { - let credits = [credit(Role::Artist, 0, "A, B", Some("a"))]; - let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); - assert!(resolve(&credits, &decisions, None, identity) - .iter() - .all(|credit| credit.sort_name.is_none())); - } - - #[test] - fn a_retagged_spelling_falls_under_the_same_decision() { - let credits = [credit(Role::Artist, 0, "a, b", None)]; - let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); - assert_eq!( - names(&resolve(&credits, &decisions, None, identity), Role::Artist), - [(0, "A"), (1, "B")] - ); - } - - #[test] - fn a_correction_is_final_and_the_other_roles_still_follow_decisions() { - let credits = [ - credit(Role::Artist, 0, "A, B", None), - credit(Role::AlbumArtist, 0, "A, B", Some("B, A")), - ]; - let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); - let corrected = ["A, B".to_owned()]; - let resolved = resolve(&credits, &decisions, Some(&corrected), identity); - assert_eq!(names(&resolved, Role::Artist), [(0, "A, B")]); - assert_eq!(names(&resolved, Role::AlbumArtist), [(0, "A"), (1, "B")]); - assert_eq!( - resolved[0].sort_name.as_deref(), - Some("B, A"), - "a corrected name takes the album artist's sort form of that name" - ); - } - - #[test] - fn performers_number_one_sequence_across_their_instruments() { - let mut guitar = credit(Role::Performer, 0, "A, B", None); - guitar.sub_role = "Guitar".into(); - let mut drums = credit(Role::Performer, 1, "C", None); - drums.sub_role = "Drums".into(); - let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); - let resolved = resolve(&[guitar, drums], &decisions, None, identity); - assert_eq!( - names(&resolved, Role::Performer), - [(0, "A"), (1, "B"), (2, "C")] - ); - } -} +mod tests; diff --git a/src/library/credit_split/tests.rs b/src/library/credit_split/tests.rs new file mode 100644 index 00000000..e6208c0c --- /dev/null +++ b/src/library/credit_split/tests.rs @@ -0,0 +1,203 @@ +use super::*; + +fn credit(role: Role, position: usize, name: &str, sort: Option<&str>) -> Credit { + Credit { + role, + sub_role: String::new(), + name: name.to_owned(), + sort_name: sort.map(str::to_owned), + position, + } +} + +/// The identity the default spec derives, without a library: enough to +/// tell two names apart the way the catalogue does. +fn identity(name: &str) -> Uuid { + Uuid::new_v5(&Uuid::NAMESPACE_OID, credit_key(name).as_bytes()) +} + +fn names(credits: &[Credit], role: Role) -> Vec<(usize, &str)> { + credits + .iter() + .filter(|credit| credit.role == role) + .map(|credit| (credit.position, credit.name.as_str())) + .collect() +} + +fn split(parts: &[&str]) -> CreditDecision { + CreditDecision::Split(parts.iter().map(|part| (*part).to_owned()).collect()) +} + +#[test] +fn the_fragments_are_the_desktops_with_parentheses_protected() { + assert_eq!( + fragments("Ashreveal, Ashish Swargiary"), + ["Ashreveal", "Ashish Swargiary"] + ); + assert_eq!(fragments("蔡依林,周杰伦"), ["蔡依林", "周杰伦"]); + assert_eq!(fragments("庄心妍、祁隆"), ["庄心妍", "祁隆"]); + assert_eq!(fragments("A、B, C"), ["A", "B", "C"]); + assert_eq!(fragments("A, B, A"), ["A", "B"]); + assert_eq!( + fragments("Ian Asher, Olly Alexander (Years, Years)"), + ["Ian Asher", "Olly Alexander (Years, Years)"], + "a comma inside parentheses qualifies a name" + ); +} + +#[test] +fn a_candidate_has_two_fragments_and_none_is_the_whole() { + assert!(is_candidate("Zedd, Alessia Cara")); + assert!(is_candidate("Tyler, The Creator"), "only a person can tell"); + assert!(!is_candidate("Solo,")); + assert!(!is_candidate("Simon & Garfunkel")); + assert!(!is_candidate("A, a"), "one fragment once deduplicated"); +} + +#[test] +fn the_key_folds_case_and_typographic_punctuation_only() { + assert_eq!(credit_key(" A, B "), credit_key("a, b")); + assert_eq!(credit_key("Guns N’ Roses"), credit_key("guns n' roses")); + assert_ne!(credit_key("AC/DC"), credit_key("AC DC")); +} + +#[test] +fn a_split_replaces_the_credit_in_place_and_renumbers() { + let credits = [ + credit(Role::Artist, 0, "X", None), + credit(Role::Artist, 1, "A, B", None), + credit(Role::Artist, 2, "Y", None), + credit(Role::AlbumArtist, 0, "A, B", None), + ]; + let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); + let resolved = resolve(&credits, &decisions, None, identity); + assert_eq!( + names(&resolved, Role::Artist), + [(0, "X"), (1, "A"), (2, "B"), (3, "Y")] + ); + assert_eq!(names(&resolved, Role::AlbumArtist), [(0, "A"), (1, "B")]); +} + +#[test] +fn no_decision_leaves_every_credit_as_the_file_wrote_it() { + let credits = [ + credit(Role::Artist, 0, "A, B", Some("B, A")), + credit(Role::Composer, 0, "C, D", None), + ]; + let resolved = resolve(&credits, &Decisions::new(), None, identity); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0].sort_name.as_deref(), Some("B, A")); +} + +#[test] +fn a_keep_changes_nothing() { + let credits = [credit(Role::Artist, 0, "Tyler, The Creator", None)]; + let decisions = Decisions::from([(credit_key("Tyler, The Creator"), CreditDecision::Keep)]); + assert_eq!( + names(&resolve(&credits, &decisions, None, identity), Role::Artist), + [(0, "Tyler, The Creator")] + ); +} + +#[test] +fn a_part_already_credited_is_not_credited_twice() { + let credits = [ + credit(Role::Artist, 0, "A", None), + credit(Role::Artist, 1, "a, B", None), + ]; + let decisions = Decisions::from([(credit_key("a, B"), split(&["a", "B"]))]); + assert_eq!( + names(&resolve(&credits, &decisions, None, identity), Role::Artist), + [(0, "A"), (1, "B")], + "the first occurrence wins, and positions stay contiguous" + ); +} + +#[test] +fn two_credits_sharing_a_part_keep_the_files_order_whichever_is_undone() { + let credits = [ + credit(Role::Artist, 0, "A, B", None), + credit(Role::Artist, 1, "A, C", None), + ]; + let both = Decisions::from([ + (credit_key("A, B"), split(&["A", "B"])), + (credit_key("A, C"), split(&["A", "C"])), + ]); + assert_eq!( + names(&resolve(&credits, &both, None, identity), Role::Artist), + [(0, "A"), (1, "B"), (2, "C")] + ); + let first = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); + assert_eq!( + names(&resolve(&credits, &first, None, identity), Role::Artist), + [(0, "A"), (1, "B"), (2, "A, C")] + ); + let second = Decisions::from([(credit_key("A, C"), split(&["A", "C"]))]); + assert_eq!( + names(&resolve(&credits, &second, None, identity), Role::Artist), + [(0, "A, B"), (1, "A"), (2, "C")] + ); +} + +#[test] +fn sort_forms_are_paired_before_the_fragments_are_deduplicated() { + let credits = [credit(Role::Artist, 0, "A, B, B, C", Some("a, b, b, c"))]; + let decisions = Decisions::from([(credit_key("A, B, B, C"), split(&["A", "B", "C"]))]); + let resolved = resolve(&credits, &decisions, None, identity); + let sorts: Vec> = resolved + .iter() + .map(|credit| credit.sort_name.as_deref()) + .collect(); + assert_eq!(sorts, [Some("a"), Some("b"), Some("c")]); +} + +#[test] +fn a_sort_form_of_another_length_pairs_with_nothing() { + let credits = [credit(Role::Artist, 0, "A, B", Some("a"))]; + let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); + assert!(resolve(&credits, &decisions, None, identity) + .iter() + .all(|credit| credit.sort_name.is_none())); +} + +#[test] +fn a_retagged_spelling_falls_under_the_same_decision() { + let credits = [credit(Role::Artist, 0, "a, b", None)]; + let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); + assert_eq!( + names(&resolve(&credits, &decisions, None, identity), Role::Artist), + [(0, "A"), (1, "B")] + ); +} + +#[test] +fn a_correction_is_final_and_the_other_roles_still_follow_decisions() { + let credits = [ + credit(Role::Artist, 0, "A, B", None), + credit(Role::AlbumArtist, 0, "A, B", Some("B, A")), + ]; + let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); + let corrected = ["A, B".to_owned()]; + let resolved = resolve(&credits, &decisions, Some(&corrected), identity); + assert_eq!(names(&resolved, Role::Artist), [(0, "A, B")]); + assert_eq!(names(&resolved, Role::AlbumArtist), [(0, "A"), (1, "B")]); + assert_eq!( + resolved[0].sort_name.as_deref(), + Some("B, A"), + "a corrected name takes the album artist's sort form of that name" + ); +} + +#[test] +fn performers_number_one_sequence_across_their_instruments() { + let mut guitar = credit(Role::Performer, 0, "A, B", None); + guitar.sub_role = "Guitar".into(); + let mut drums = credit(Role::Performer, 1, "C", None); + drums.sub_role = "Drums".into(); + let decisions = Decisions::from([(credit_key("A, B"), split(&["A", "B"]))]); + let resolved = resolve(&[guitar, drums], &decisions, None, identity); + assert_eq!( + names(&resolved, Role::Performer), + [(0, "A"), (1, "B"), (2, "C")] + ); +} diff --git a/src/library/link_roots/mod.rs b/src/library/link_roots/mod.rs index 34a7efa2..209aca78 100644 --- a/src/library/link_roots/mod.rs +++ b/src/library/link_roots/mod.rs @@ -100,39 +100,4 @@ pub fn admits(roots: &[PathBuf], libraries: &[PathBuf], target: &Path) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_target_must_sit_in_an_allowed_root_and_in_no_other_library() { - let roots = [PathBuf::from("/srv/beets")]; - let libraries = [PathBuf::from("/srv/beets/shared-library")]; - assert!(admits(&roots, &libraries, Path::new("/srv/beets/a/b.flac"))); - assert!(!admits( - &roots, - &libraries, - Path::new("/srv/elsewhere/b.flac") - )); - assert!(!admits( - &roots, - &libraries, - Path::new("/srv/beets/shared-library/b.flac") - )); - // A prefix of a name is not a folder. - assert!(!admits(&roots, &[], Path::new("/srv/beetsx/b.flac"))); - assert!(!admits(&[], &[], Path::new("/srv/beets/a.flac"))); - } - - #[test] - fn a_root_that_does_not_resolve_reaches_nothing() { - let present = tempfile::tempdir().unwrap(); - let roots = LinkRoots::new(vec![ - present.path().to_path_buf(), - present.path().join("not-there"), - ]); - assert_eq!( - roots.resolved(), - vec![std::fs::canonicalize(present.path()).unwrap()] - ); - } -} +mod tests; diff --git a/src/library/link_roots/tests.rs b/src/library/link_roots/tests.rs new file mode 100644 index 00000000..8eabecda --- /dev/null +++ b/src/library/link_roots/tests.rs @@ -0,0 +1,34 @@ +use super::*; + +#[test] +fn a_target_must_sit_in_an_allowed_root_and_in_no_other_library() { + let roots = [PathBuf::from("/srv/beets")]; + let libraries = [PathBuf::from("/srv/beets/shared-library")]; + assert!(admits(&roots, &libraries, Path::new("/srv/beets/a/b.flac"))); + assert!(!admits( + &roots, + &libraries, + Path::new("/srv/elsewhere/b.flac") + )); + assert!(!admits( + &roots, + &libraries, + Path::new("/srv/beets/shared-library/b.flac") + )); + // A prefix of a name is not a folder. + assert!(!admits(&roots, &[], Path::new("/srv/beetsx/b.flac"))); + assert!(!admits(&[], &[], Path::new("/srv/beets/a.flac"))); +} + +#[test] +fn a_root_that_does_not_resolve_reaches_nothing() { + let present = tempfile::tempdir().unwrap(); + let roots = LinkRoots::new(vec![ + present.path().to_path_buf(), + present.path().join("not-there"), + ]); + assert_eq!( + roots.resolved(), + vec![std::fs::canonicalize(present.path()).unwrap()] + ); +} diff --git a/src/library/lyrics/mod.rs b/src/library/lyrics/mod.rs index e6d634b5..1c4336f4 100644 --- a/src/library/lyrics/mod.rs +++ b/src/library/lyrics/mod.rs @@ -423,233 +423,4 @@ fn parse_timestamp(value: &str) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_ttml_line_break_keeps_its_words_apart() { - let parsed = parse_ttml( - r#"
-

hello
world

-
"#, - ) - .unwrap(); - assert_eq!(parsed.lines[0].value, "hello world"); - } - - #[test] - fn a_ttml_language_comes_from_the_root_or_the_body_only() { - let deep = parse_ttml( - r#" -

hi

"#, - ) - .unwrap(); - assert_eq!(deep.lang, UNKNOWN_LANG); - let body = parse_ttml( - r#" -

hi

"#, - ) - .unwrap(); - assert_eq!(body.lang, "ko"); - let blank_root = parse_ttml( - r#" -

hi

"#, - ) - .unwrap(); - assert_eq!(blank_root.lang, "ko"); - } - - #[test] - fn a_paragraph_nested_in_a_paragraph_reads_once() { - let parsed = parse_ttml( - r#"
-

outer

inner

deepest

-

next

-
"#, - ) - .unwrap(); - assert_eq!( - parsed.lines, - vec![ - LyricsLine { - start: Some(1000), - value: "outer inner deepest".into(), - }, - LyricsLine { - start: Some(4000), - value: "next".into(), - }, - ] - ); - } - - #[test] - fn parses_and_orders_lrc_timestamps() { - assert_eq!( - lines("[00:02.50]second\n[00:01.125][00:03]shared", true), - vec![ - LyricsLine { - start: Some(1125), - value: "shared".into(), - }, - LyricsLine { - start: Some(2500), - value: "second".into(), - }, - LyricsLine { - start: Some(3000), - value: "shared".into(), - }, - ] - ); - } - - #[test] - fn ignores_lrc_metadata_and_preserves_plain_lines() { - assert_eq!( - lines("[ar:WaveFlow]\r\nfirst\r\n\r\nsecond", false), - vec![ - LyricsLine { - start: None, - value: "[ar:WaveFlow]".into(), - }, - LyricsLine { - start: None, - value: "first".into(), - }, - LyricsLine { - start: None, - value: "".into(), - }, - LyricsLine { - start: None, - value: "second".into(), - }, - ] - ); - } - - #[test] - fn reads_every_ttml_clock_and_offset_form() { - assert_eq!(ttml_time("01:02:03.5"), Some(3_723_500)); - assert_eq!(ttml_time("2:03.250"), Some(123_250)); - assert_eq!(ttml_time("12.345"), Some(12_345)); - assert_eq!(ttml_time("1.5s"), Some(1_500)); - assert_eq!(ttml_time("1500ms"), Some(1_500)); - assert_eq!(ttml_time("2m"), Some(120_000)); - assert_eq!(ttml_time("1h"), Some(3_600_000)); - // Frames need a frame rate the sheet does not get to set. - assert_eq!(ttml_time("00:00:01:12"), None); - assert_eq!(ttml_time("12f"), None); - // Out of range, and not a time at all. - assert_eq!(ttml_time("00:61:00"), None); - assert_eq!(ttml_time("-1s"), None); - assert_eq!(ttml_time(""), None); - } - - #[test] - fn reads_a_ttml_sheet_with_word_spans() { - let ttml = r#" - -
-

夜の 街

-

first

-

no time, left out

-

frames, left out

-
-
"#; - let parsed = parse_ttml(ttml).expect("a sheet"); - assert_eq!(parsed.lang, "ja"); - assert!(parsed.synced); - assert_eq!( - parsed.lines, - vec![ - LyricsLine { - start: Some(1_000), - value: "first".into(), - }, - LyricsLine { - start: Some(5_500), - value: "夜の 街".into(), - }, - ] - ); - } - - #[test] - fn refuses_a_ttml_document_with_a_dtd() { - // An entity is how an XML upload reaches a file or inflates itself; - // the default parser options refuse the DTD that would declare one. - let hostile = r#" -]> -

&x;

"#; - assert_eq!(parse_ttml(hostile), None); - assert_eq!(parse_upload(UploadFormat::Ttml, hostile), None); - } - - #[test] - fn refuses_uploads_with_nothing_usable_in_them() { - assert_eq!(parse_upload(UploadFormat::Txt, " \n\t\n"), None); - assert_eq!( - parse_upload(UploadFormat::Lrc, "[ar:Someone]\n[00:01.00]\n"), - None - ); - assert_eq!( - parse_upload( - UploadFormat::Ttml, - r#"

untimed

"# - ), - None - ); - assert_eq!( - parse_upload(UploadFormat::Ttml, "

x

"), - None - ); - let lrc = parse_upload(UploadFormat::Lrc, "[la:fr]\n[00:01.00]bonjour").expect("a sheet"); - assert_eq!(lrc.lang, "fr"); - assert!(lrc.synced); - let plain = parse_upload(UploadFormat::Lrc, "no timestamps\nat all").expect("a sheet"); - assert!(!plain.synced, "an .lrc without timings is plain text"); - } - - #[test] - fn writes_scanned_sheets_back_as_lrc_and_text() { - let synced = lines("[00:01.25]one\n[61:02.5]two", true); - assert_eq!(to_lrc(&synced), "[00:01.25]one\n[61:02.50]two\n"); - assert_eq!(lines(&to_lrc(&synced), true), synced, "round trip"); - assert_eq!(to_text(&lines("a\n\nb", false)), "a\n\nb\n"); - } - - #[test] - fn rejects_negative_and_overflowing_timestamps() { - assert_eq!(parse_timestamp("-1:00"), None); - assert_eq!(parse_timestamp("00:-01"), None); - assert_eq!(parse_timestamp(&format!("{}:00", i64::MAX)), None); - } - - /// A line may carry several timestamps, and each is a line once read; a - /// sheet that piles them up is read as the text it is instead of being - /// expanded without end (RFC-023 decision 5). - /// - /// Sized to be told apart cheaply: twenty thousand timestamps before a - /// kilobyte of text is 120 KB of file and twenty megabytes expanded, so a - /// parser without the budget passes neither assertion and allocates - /// nothing a test run would notice. - #[test] - fn a_sheet_that_piles_up_timestamps_is_not_expanded() { - let sheet = format!("{}{}", "[0:0]".repeat(20_000), "la".repeat(512)); - assert!(!has_lrc_timestamps(&sheet), "over the budget is not synced"); - assert_eq!(lines(&sheet, true).len(), 1, "read as the one line it is"); - let upload = parse_upload(UploadFormat::Lrc, &sheet).expect("still a sheet"); - assert!(!upload.synced); - assert_eq!(upload.lines.len(), 1); - - // A chorus repeated, as real sheets do, is still expanded. - let chorus = lines( - "[00:10.00][01:10.00][02:10.00]chorus\n[00:20.00]verse", - true, - ); - assert_eq!(chorus.len(), 4); - assert_eq!(chorus[1].value, "verse"); - } -} +mod tests; diff --git a/src/library/lyrics/tests.rs b/src/library/lyrics/tests.rs new file mode 100644 index 00000000..713a2fef --- /dev/null +++ b/src/library/lyrics/tests.rs @@ -0,0 +1,228 @@ +use super::*; + +#[test] +fn a_ttml_line_break_keeps_its_words_apart() { + let parsed = parse_ttml( + r#"
+

hello
world

+
"#, + ) + .unwrap(); + assert_eq!(parsed.lines[0].value, "hello world"); +} + +#[test] +fn a_ttml_language_comes_from_the_root_or_the_body_only() { + let deep = parse_ttml( + r#" +

hi

"#, + ) + .unwrap(); + assert_eq!(deep.lang, UNKNOWN_LANG); + let body = parse_ttml( + r#" +

hi

"#, + ) + .unwrap(); + assert_eq!(body.lang, "ko"); + let blank_root = parse_ttml( + r#" +

hi

"#, + ) + .unwrap(); + assert_eq!(blank_root.lang, "ko"); +} + +#[test] +fn a_paragraph_nested_in_a_paragraph_reads_once() { + let parsed = parse_ttml( + r#"
+

outer

inner

deepest

+

next

+
"#, + ) + .unwrap(); + assert_eq!( + parsed.lines, + vec![ + LyricsLine { + start: Some(1000), + value: "outer inner deepest".into(), + }, + LyricsLine { + start: Some(4000), + value: "next".into(), + }, + ] + ); +} + +#[test] +fn parses_and_orders_lrc_timestamps() { + assert_eq!( + lines("[00:02.50]second\n[00:01.125][00:03]shared", true), + vec![ + LyricsLine { + start: Some(1125), + value: "shared".into(), + }, + LyricsLine { + start: Some(2500), + value: "second".into(), + }, + LyricsLine { + start: Some(3000), + value: "shared".into(), + }, + ] + ); +} + +#[test] +fn ignores_lrc_metadata_and_preserves_plain_lines() { + assert_eq!( + lines("[ar:WaveFlow]\r\nfirst\r\n\r\nsecond", false), + vec![ + LyricsLine { + start: None, + value: "[ar:WaveFlow]".into(), + }, + LyricsLine { + start: None, + value: "first".into(), + }, + LyricsLine { + start: None, + value: "".into(), + }, + LyricsLine { + start: None, + value: "second".into(), + }, + ] + ); +} + +#[test] +fn reads_every_ttml_clock_and_offset_form() { + assert_eq!(ttml_time("01:02:03.5"), Some(3_723_500)); + assert_eq!(ttml_time("2:03.250"), Some(123_250)); + assert_eq!(ttml_time("12.345"), Some(12_345)); + assert_eq!(ttml_time("1.5s"), Some(1_500)); + assert_eq!(ttml_time("1500ms"), Some(1_500)); + assert_eq!(ttml_time("2m"), Some(120_000)); + assert_eq!(ttml_time("1h"), Some(3_600_000)); + // Frames need a frame rate the sheet does not get to set. + assert_eq!(ttml_time("00:00:01:12"), None); + assert_eq!(ttml_time("12f"), None); + // Out of range, and not a time at all. + assert_eq!(ttml_time("00:61:00"), None); + assert_eq!(ttml_time("-1s"), None); + assert_eq!(ttml_time(""), None); +} + +#[test] +fn reads_a_ttml_sheet_with_word_spans() { + let ttml = r#" + +
+

夜の 街

+

first

+

no time, left out

+

frames, left out

+
+
"#; + let parsed = parse_ttml(ttml).expect("a sheet"); + assert_eq!(parsed.lang, "ja"); + assert!(parsed.synced); + assert_eq!( + parsed.lines, + vec![ + LyricsLine { + start: Some(1_000), + value: "first".into(), + }, + LyricsLine { + start: Some(5_500), + value: "夜の 街".into(), + }, + ] + ); +} + +#[test] +fn refuses_a_ttml_document_with_a_dtd() { + // An entity is how an XML upload reaches a file or inflates itself; + // the default parser options refuse the DTD that would declare one. + let hostile = r#" +]> +

&x;

"#; + assert_eq!(parse_ttml(hostile), None); + assert_eq!(parse_upload(UploadFormat::Ttml, hostile), None); +} + +#[test] +fn refuses_uploads_with_nothing_usable_in_them() { + assert_eq!(parse_upload(UploadFormat::Txt, " \n\t\n"), None); + assert_eq!( + parse_upload(UploadFormat::Lrc, "[ar:Someone]\n[00:01.00]\n"), + None + ); + assert_eq!( + parse_upload( + UploadFormat::Ttml, + r#"

untimed

"# + ), + None + ); + assert_eq!( + parse_upload(UploadFormat::Ttml, "

x

"), + None + ); + let lrc = parse_upload(UploadFormat::Lrc, "[la:fr]\n[00:01.00]bonjour").expect("a sheet"); + assert_eq!(lrc.lang, "fr"); + assert!(lrc.synced); + let plain = parse_upload(UploadFormat::Lrc, "no timestamps\nat all").expect("a sheet"); + assert!(!plain.synced, "an .lrc without timings is plain text"); +} + +#[test] +fn writes_scanned_sheets_back_as_lrc_and_text() { + let synced = lines("[00:01.25]one\n[61:02.5]two", true); + assert_eq!(to_lrc(&synced), "[00:01.25]one\n[61:02.50]two\n"); + assert_eq!(lines(&to_lrc(&synced), true), synced, "round trip"); + assert_eq!(to_text(&lines("a\n\nb", false)), "a\n\nb\n"); +} + +#[test] +fn rejects_negative_and_overflowing_timestamps() { + assert_eq!(parse_timestamp("-1:00"), None); + assert_eq!(parse_timestamp("00:-01"), None); + assert_eq!(parse_timestamp(&format!("{}:00", i64::MAX)), None); +} + +/// A line may carry several timestamps, and each is a line once read; a +/// sheet that piles them up is read as the text it is instead of being +/// expanded without end (RFC-023 decision 5). +/// +/// Sized to be told apart cheaply: twenty thousand timestamps before a +/// kilobyte of text is 120 KB of file and twenty megabytes expanded, so a +/// parser without the budget passes neither assertion and allocates +/// nothing a test run would notice. +#[test] +fn a_sheet_that_piles_up_timestamps_is_not_expanded() { + let sheet = format!("{}{}", "[0:0]".repeat(20_000), "la".repeat(512)); + assert!(!has_lrc_timestamps(&sheet), "over the budget is not synced"); + assert_eq!(lines(&sheet, true).len(), 1, "read as the one line it is"); + let upload = parse_upload(UploadFormat::Lrc, &sheet).expect("still a sheet"); + assert!(!upload.synced); + assert_eq!(upload.lines.len(), 1); + + // A chorus repeated, as real sheets do, is still expanded. + let chorus = lines( + "[00:10.00][01:10.00][02:10.00]chorus\n[00:20.00]verse", + true, + ); + assert_eq!(chorus.len(), 4); + assert_eq!(chorus[1].value, "verse"); +} diff --git a/src/library/pid/mod.rs b/src/library/pid/mod.rs index 84de7967..17e7cb60 100644 --- a/src/library/pid/mod.rs +++ b/src/library/pid/mod.rs @@ -267,167 +267,4 @@ fn hash_parts(parts: &[&str]) -> Uuid { } #[cfg(test)] -mod tests { - use super::*; - - struct Fixture { - tags: Vec<(&'static str, &'static str)>, - folder: &'static str, - album_artist: &'static str, - title: &'static str, - album: &'static str, - } - - impl Default for Fixture { - fn default() -> Self { - Self { - tags: Vec::new(), - folder: "/music/album", - album_artist: "Album Artist", - title: "Track Title", - album: "Album Name", - } - } - } - - impl PidSource for Fixture { - fn tag(&self, name: &str) -> Option<&str> { - self.tags - .iter() - .find(|(key, _)| *key == name) - .map(|(_, value)| *value) - } - fn folder(&self) -> &str { - self.folder - } - fn album_artist_display(&self) -> &str { - self.album_artist - } - fn title(&self) -> &str { - self.title - } - fn album(&self) -> &str { - self.album - } - } - - fn default_specs() -> PidSpecs { - PidSpecs { - album: PidSpec::parse( - "musicbrainz_albumid|albumartistid,album,albumversion,releasedate", - false, - ) - .expect("album spec"), - track: PidSpec::parse( - "musicbrainz_trackid|albumid,discnumber,tracknumber,title", - true, - ) - .expect("track spec"), - artist: PidSpec::parse("albumartistid", false).expect("artist spec"), - } - } - - const LIBRARY: Uuid = Uuid::from_u128(1); - - #[test] - fn the_first_group_holding_a_value_wins() { - let specs = default_specs(); - let untagged = Fixture::default(); - let tagged = Fixture { - tags: vec![("musicbrainz_albumid", "1234")], - ..Fixture::default() - }; - assert_ne!( - specs.album_id(LIBRARY, &untagged), - specs.album_id(LIBRARY, &tagged), - "a release id answers instead of the fallback group" - ); - // And a tag no group names moves nothing. - let unrelated = Fixture { - tags: vec![("comment", "anything")], - ..Fixture::default() - }; - assert_eq!( - specs.album_id(LIBRARY, &untagged), - specs.album_id(LIBRARY, &unrelated) - ); - } - - /// The defect this engine exists to make impossible: a joined credit used - /// to collapse onto the row id of its first artist. - #[test] - fn a_joined_credit_identifies_the_album_by_the_whole_string() { - let specs = default_specs(); - let of = |credit: &'static str| { - specs.album_id( - LIBRARY, - &Fixture { - album_artist: credit, - ..Fixture::default() - }, - ) - }; - assert_ne!(of("Nova Kern; Lior Sand"), of("Nova Kern; Ada Vale")); - assert_ne!(of("Nova Kern"), of("Nova Kern; Lior Sand")); - // The boundary between credits is part of the identity, which the old - // canonical form erased. - assert_ne!(of("A; B C"), of("A; B; C")); - } - - #[test] - fn punctuation_and_accents_survive_normalisation() { - assert_eq!(str_clear("Sgt. Pepper\u{2019}s"), "Sgt. Pepper's"); - assert_eq!(str_clear("Beyonc\u{e9}"), "Beyonc\u{e9}"); - assert_eq!(str_clear("AC/DC"), "AC/DC"); - assert_eq!(str_clear("Post\u{2014}Rock"), "Post-Rock"); - assert_ne!(str_clear("AC/DC"), str_clear("AC DC")); - } - - #[test] - fn the_library_separates_two_catalogues_holding_the_same_file() { - let specs = default_specs(); - let other = Uuid::from_u128(2); - assert_ne!( - specs.album_id(LIBRARY, &Fixture::default()), - specs.album_id(other, &Fixture::default()) - ); - assert_ne!( - specs.artist_id(LIBRARY, "Nova Kern"), - specs.artist_id(other, "Nova Kern") - ); - } - - #[test] - fn a_track_follows_its_album() { - let specs = default_specs(); - let here = Fixture::default(); - let elsewhere = Fixture { - album: "Another Album", - ..Fixture::default() - }; - assert_ne!( - specs.track_id(LIBRARY, &here), - specs.track_id(LIBRARY, &elsewhere), - "`albumid` reaches the album spec" - ); - } - - #[test] - fn a_recursive_album_spec_is_refused() { - assert!(PidSpec::parse("albumid,album", false).is_err()); - assert!(PidSpec::parse("albumid,title", true).is_ok()); - assert!(PidSpec::parse("", false).is_err()); - } - - #[test] - fn every_identifier_is_a_version_eight_uuid() { - let specs = default_specs(); - assert_eq!( - specs - .album_id(LIBRARY, &Fixture::default()) - .get_version_num(), - 8 - ); - assert_eq!(specs.artist_id(LIBRARY, "Nova Kern").get_version_num(), 8); - } -} +mod tests; diff --git a/src/library/pid/tests.rs b/src/library/pid/tests.rs new file mode 100644 index 00000000..ceec75a4 --- /dev/null +++ b/src/library/pid/tests.rs @@ -0,0 +1,162 @@ +use super::*; + +struct Fixture { + tags: Vec<(&'static str, &'static str)>, + folder: &'static str, + album_artist: &'static str, + title: &'static str, + album: &'static str, +} + +impl Default for Fixture { + fn default() -> Self { + Self { + tags: Vec::new(), + folder: "/music/album", + album_artist: "Album Artist", + title: "Track Title", + album: "Album Name", + } + } +} + +impl PidSource for Fixture { + fn tag(&self, name: &str) -> Option<&str> { + self.tags + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| *value) + } + fn folder(&self) -> &str { + self.folder + } + fn album_artist_display(&self) -> &str { + self.album_artist + } + fn title(&self) -> &str { + self.title + } + fn album(&self) -> &str { + self.album + } +} + +fn default_specs() -> PidSpecs { + PidSpecs { + album: PidSpec::parse( + "musicbrainz_albumid|albumartistid,album,albumversion,releasedate", + false, + ) + .expect("album spec"), + track: PidSpec::parse( + "musicbrainz_trackid|albumid,discnumber,tracknumber,title", + true, + ) + .expect("track spec"), + artist: PidSpec::parse("albumartistid", false).expect("artist spec"), + } +} + +const LIBRARY: Uuid = Uuid::from_u128(1); + +#[test] +fn the_first_group_holding_a_value_wins() { + let specs = default_specs(); + let untagged = Fixture::default(); + let tagged = Fixture { + tags: vec![("musicbrainz_albumid", "1234")], + ..Fixture::default() + }; + assert_ne!( + specs.album_id(LIBRARY, &untagged), + specs.album_id(LIBRARY, &tagged), + "a release id answers instead of the fallback group" + ); + // And a tag no group names moves nothing. + let unrelated = Fixture { + tags: vec![("comment", "anything")], + ..Fixture::default() + }; + assert_eq!( + specs.album_id(LIBRARY, &untagged), + specs.album_id(LIBRARY, &unrelated) + ); +} + +/// The defect this engine exists to make impossible: a joined credit used +/// to collapse onto the row id of its first artist. +#[test] +fn a_joined_credit_identifies_the_album_by_the_whole_string() { + let specs = default_specs(); + let of = |credit: &'static str| { + specs.album_id( + LIBRARY, + &Fixture { + album_artist: credit, + ..Fixture::default() + }, + ) + }; + assert_ne!(of("Nova Kern; Lior Sand"), of("Nova Kern; Ada Vale")); + assert_ne!(of("Nova Kern"), of("Nova Kern; Lior Sand")); + // The boundary between credits is part of the identity, which the old + // canonical form erased. + assert_ne!(of("A; B C"), of("A; B; C")); +} + +#[test] +fn punctuation_and_accents_survive_normalisation() { + assert_eq!(str_clear("Sgt. Pepper\u{2019}s"), "Sgt. Pepper's"); + assert_eq!(str_clear("Beyonc\u{e9}"), "Beyonc\u{e9}"); + assert_eq!(str_clear("AC/DC"), "AC/DC"); + assert_eq!(str_clear("Post\u{2014}Rock"), "Post-Rock"); + assert_ne!(str_clear("AC/DC"), str_clear("AC DC")); +} + +#[test] +fn the_library_separates_two_catalogues_holding_the_same_file() { + let specs = default_specs(); + let other = Uuid::from_u128(2); + assert_ne!( + specs.album_id(LIBRARY, &Fixture::default()), + specs.album_id(other, &Fixture::default()) + ); + assert_ne!( + specs.artist_id(LIBRARY, "Nova Kern"), + specs.artist_id(other, "Nova Kern") + ); +} + +#[test] +fn a_track_follows_its_album() { + let specs = default_specs(); + let here = Fixture::default(); + let elsewhere = Fixture { + album: "Another Album", + ..Fixture::default() + }; + assert_ne!( + specs.track_id(LIBRARY, &here), + specs.track_id(LIBRARY, &elsewhere), + "`albumid` reaches the album spec" + ); +} + +#[test] +fn a_recursive_album_spec_is_refused() { + assert!(PidSpec::parse("albumid,album", false).is_err()); + assert!(PidSpec::parse("albumid,title", true).is_ok()); + assert!(PidSpec::parse("", false).is_err()); +} + +#[test] +fn every_identifier_is_a_version_eight_uuid() { + let specs = default_specs(); + assert_eq!( + specs + .album_id(LIBRARY, &Fixture::default()) + .get_version_num(), + 8 + ); + assert_eq!(specs.artist_id(LIBRARY, "Nova Kern").get_version_num(), 8); +} diff --git a/src/library/playlist_file/mod.rs b/src/library/playlist_file/mod.rs index 589a93c9..d78c8cab 100644 --- a/src/library/playlist_file/mod.rs +++ b/src/library/playlist_file/mod.rs @@ -253,252 +253,4 @@ fn percent_decode(value: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - /// The parser with no ceiling: what these tests are about is the reading. - fn parse(bytes: &[u8]) -> PlaylistFile { - super::parse(bytes, usize::MAX).expect("no ceiling to reach") - } - - /// The ceiling is noticed at the first entry past it, and a file exactly - /// at it is read (RFC-023 decision 5). Comments and directives are not - /// entries and count for nothing. - #[test] - fn a_file_past_the_ceiling_is_refused_and_one_at_it_is_read() { - let at = "#EXTM3U\n#EXTINF:1,A - B\na.flac\nb.flac\nc.flac\n"; - assert_eq!(super::parse(at.as_bytes(), 3).unwrap().entries.len(), 3); - let past = format!("{at}d.flac\n"); - assert_eq!(super::parse(past.as_bytes(), 3).err(), Some(TooManyEntries)); - } - - fn targets(file: &PlaylistFile) -> Vec { - file.entries - .iter() - .map(|entry| entry.segments.join("/")) - .collect() - } - - #[test] - fn a_plain_file_is_one_target_per_line() { - let file = parse(b"Artist/Album/01.flac\nArtist/Album/02.flac\n"); - assert_eq!(file.encoding, TextEncoding::UTF_8); - assert_eq!(file.name, None); - assert_eq!( - targets(&file), - ["Artist/Album/01.flac", "Artist/Album/02.flac"] - ); - assert_eq!(file.entries[1].line, 2); - assert!(file.entries.iter().all(|entry| entry.title.is_none())); - } - - #[test] - fn the_extended_dialect_attaches_its_metadata_to_the_next_target() { - let file = parse( - b"#EXTM3U\n#PLAYLIST:Evening\n#EXTINF:245,Harbour Bells - Lamp Hymn\nArtist/01.flac\n\ - # a human comment\n#EXTINF:-1,A stream\nArtist/02.flac\n", - ); - assert_eq!(file.name.as_deref(), Some("Evening")); - assert_eq!(targets(&file), ["Artist/01.flac", "Artist/02.flac"]); - assert_eq!(file.entries[0].duration_secs, Some(245)); - assert_eq!( - file.entries[0].artist_and_title(), - Some(("Harbour Bells", "Lamp Hymn")) - ); - // `-1` is a stream's duration, which is not a measurement. - assert_eq!(file.entries[1].duration_secs, None); - assert_eq!(file.entries[1].title.as_deref(), Some("A stream")); - assert_eq!(file.entries[1].artist_and_title(), None); - } - - #[test] - fn an_extinf_with_no_target_after_it_carries_nothing_forward() { - // The metadata belongs to a target. A file that ends on `#EXTINF` - // must not hand it to whatever the next import reads. - let file = parse(b"#EXTM3U\n#EXTINF:1,Orphan\n"); - assert!(file.entries.is_empty()); - let file = parse(b"#EXTM3U\n#EXTINF:1,Orphan\n\n#EXTINF:2,Owner - Song\nA/01.flac\n"); - assert_eq!(file.entries.len(), 1); - assert_eq!(file.entries[0].title.as_deref(), Some("Owner - Song")); - } - - #[test] - fn windows_line_endings_and_a_bom_do_not_reach_the_path() { - let mut bytes = vec![0xEF, 0xBB, 0xBF]; - bytes.extend_from_slice(b"#EXTM3U\r\nArtist/Album/01.flac\r\n"); - let file = parse(&bytes); - assert_eq!(file.encoding, TextEncoding::UTF_8); - assert_eq!(targets(&file), ["Artist/Album/01.flac"]); - } - - #[test] - fn a_file_that_is_not_utf8_is_read_as_windows_1252() { - // "Bjrk/Vespertine/01.flac" — the byte a Windows player writes - // for ö, which is not valid UTF-8. - let mut bytes = b"Bj".to_vec(); - bytes.push(0xF6); - bytes.extend_from_slice(b"rk/Vespertine/01.flac\n"); - let file = parse(&bytes); - assert_eq!(file.encoding.name(), "windows-1252"); - assert_eq!(targets(&file), ["Björk/Vespertine/01.flac"]); - - // And the thirty-two code points that tell Windows-1252 from Latin-1: - // 0x92 is a right single quote in a title, not a control code. - let mut bytes = b"#EXTINF:1,L".to_vec(); - bytes.push(0x92); - bytes.extend_from_slice(b"artiste - Song\nA/01.flac\n"); - let file = parse(&bytes); - assert_eq!( - file.entries[0].title.as_deref(), - Some("L\u{2019}artiste - Song") - ); - } - - /// What Notepad saves as "Unicode", and what some players export: UTF-16 - /// little-endian behind its byte-order mark. Read as Windows-1252, every - /// other character was a NUL and no path matched anything. - #[test] - fn a_utf16_file_is_read_by_its_byte_order_mark() { - let text = "#EXTM3U\r\n#EXTINF:245,Björk - Hidden Place\r\nBjörk/Vespertine/01.flac\r\n"; - let mut bytes = vec![0xFF, 0xFE]; - bytes.extend(text.encode_utf16().flat_map(u16::to_le_bytes)); - let file = parse(&bytes); - assert_eq!(serde_json::to_value(file.encoding).unwrap(), "utf-16le"); - assert_eq!(targets(&file), ["Björk/Vespertine/01.flac"]); - assert_eq!( - file.entries[0].artist_and_title(), - Some(("Björk", "Hidden Place")) - ); - } - - /// A playlist written on a Japanese Windows, in Shift_JIS. The Windows-1252 - /// fallback read it without failing, which was worse: every name came back - /// as Latin nonsense that matched no path and no title. - #[test] - fn a_file_in_an_east_asian_code_page_is_read_in_it() { - let text = "#EXTM3U\r\n#PLAYLIST:夏の夜に聴きたい歌\r\n\ - #EXTINF:245,宇多田ヒカル - 花束を君に\r\n\ - 宇多田ヒカル/ファントーム/01 花束を君に.flac\r\n\ - #EXTINF:301,椎名林檎 - 丸の内サディスティック\r\n\ - 椎名林檎/無罪モラトリアム/03 丸の内サディスティック.flac\r\n"; - let (bytes, _, unmappable) = encoding_rs::SHIFT_JIS.encode(text); - assert!(!unmappable, "Shift_JIS holds the sample"); - assert!(std::str::from_utf8(&bytes).is_err()); - let file = parse(&bytes); - assert_eq!(serde_json::to_value(file.encoding).unwrap(), "shift_jis"); - assert_eq!(file.name.as_deref(), Some("夏の夜に聴きたい歌")); - assert_eq!( - targets(&file), - [ - "宇多田ヒカル/ファントーム/01 花束を君に.flac", - "椎名林檎/無罪モラトリアム/03 丸の内サディスティック.flac", - ] - ); - assert_eq!( - file.entries[1].artist_and_title(), - Some(("椎名林檎", "丸の内サディスティック")) - ); - } - - /// The report names the encoding the core read, not a second guess that - /// happens to agree today: decoding each sample with the named encoding - /// gives back the core's text exactly. - #[test] - fn the_encoding_named_is_the_one_the_core_read() { - let samples: [(&str, &'static Encoding); 6] = [ - ( - "#EXTINF:1,Björk - Cocoon\nBjörk/Vespertine/02.flac\n", - encoding_rs::WINDOWS_1252, - ), - ( - "#EXTINF:1,宇多田ヒカル - 花束を君に\n宇多田ヒカル/01 花束を君に.flac\n", - encoding_rs::SHIFT_JIS, - ), - ( - "#EXTINF:1,王菲 - 红豆\n王菲/唱游/03 红豆.flac\n", - encoding_rs::GB18030, - ), - ( - "#EXTINF:1,周杰倫 - 晴天\n周杰倫/葉惠美/01 晴天.flac\n", - encoding_rs::BIG5, - ), - ( - "#EXTINF:1,아이유 - 밤편지\n아이유/팔레트/01 밤편지.flac\n", - encoding_rs::EUC_KR, - ), - ( - "#EXTINF:1,Björk - Cocoon\nBjörk/Vespertine/02.flac\n", - encoding_rs::UTF_8, - ), - ]; - for (text, encoding) in samples { - let (bytes, _, unmappable) = encoding.encode(text); - assert!(!unmappable, "{} holds the sample", encoding.name()); - let named = encoding_read_as(&bytes); - assert_eq!( - named.decode_without_bom_handling(&bytes).0, - waveflow_core::text_encoding::decode(&bytes), - "{} was named {}", - encoding.name(), - named.name() - ); - } - let mut marked = vec![0xFE, 0xFF]; - marked.extend("Björk/01.flac".encode_utf16().flat_map(u16::to_be_bytes)); - assert_eq!(encoding_read_as(&marked), encoding_rs::UTF_16BE); - } - - #[test] - fn every_shape_of_target_reduces_to_the_same_segments() { - let expected = ["Artist", "Album", "01.flac"]; - for target in [ - "Artist/Album/01.flac", - "./Artist/Album/01.flac", - "../../Artist/Album/01.flac", - "/home/someone/Music/Artist/Album/01.flac", - "Artist\\Album\\01.flac", - "C:\\Users\\someone\\Music\\Artist\\Album\\01.flac", - "file:///home/someone/Music/Artist/Album/01.flac", - "file://server/Music/Artist/Album/01.flac", - // A scheme is case-insensitive, and a player that shouts it names - // the same file. - "File:///home/someone/Music/Artist/Album/01.flac", - "FILE://server/Music/Artist/Album/01.flac", - "Artist/Album/%30%31.flac", - ] { - let file = parse(target.as_bytes()); - let entry = &file.entries[0]; - let tail = entry.segments[entry.segments.len() - 3..].to_vec(); - assert_eq!(tail, expected, "{target}"); - assert_eq!(entry.raw, target, "the report quotes what was written"); - } - } - - #[test] - fn a_remote_url_names_no_path_at_all() { - // Deliberately not a failed path match: a web radio is not a track - // this catalogue could hold, and the ladder has nothing to climb. - // And the case-insensitive scheme match does not widen to another - // scheme that merely looks like one. - for target in [ - "http://radio.example/stream", - "https://radio.example/s.mp3", - "HTTP://radio.example/stream", - "filesystem://radio.example/s.mp3", - ] { - let file = parse(target.as_bytes()); - assert!(file.entries[0].segments.is_empty(), "{target}"); - } - } - - #[test] - fn a_hyphen_without_spaces_belongs_to_the_name() { - let file = parse(b"#EXTINF:1,Jean-Michel Jarre\nA/01.flac\n"); - assert_eq!(file.entries[0].artist_and_title(), None); - let file = parse(b"#EXTINF:1,Jean-Michel Jarre - Oxygene-4\nA/01.flac\n"); - assert_eq!( - file.entries[0].artist_and_title(), - Some(("Jean-Michel Jarre", "Oxygene-4")) - ); - } -} +mod tests; diff --git a/src/library/playlist_file/tests.rs b/src/library/playlist_file/tests.rs new file mode 100644 index 00000000..d612f1f8 --- /dev/null +++ b/src/library/playlist_file/tests.rs @@ -0,0 +1,247 @@ +use super::*; + +/// The parser with no ceiling: what these tests are about is the reading. +fn parse(bytes: &[u8]) -> PlaylistFile { + super::parse(bytes, usize::MAX).expect("no ceiling to reach") +} + +/// The ceiling is noticed at the first entry past it, and a file exactly +/// at it is read (RFC-023 decision 5). Comments and directives are not +/// entries and count for nothing. +#[test] +fn a_file_past_the_ceiling_is_refused_and_one_at_it_is_read() { + let at = "#EXTM3U\n#EXTINF:1,A - B\na.flac\nb.flac\nc.flac\n"; + assert_eq!(super::parse(at.as_bytes(), 3).unwrap().entries.len(), 3); + let past = format!("{at}d.flac\n"); + assert_eq!(super::parse(past.as_bytes(), 3).err(), Some(TooManyEntries)); +} + +fn targets(file: &PlaylistFile) -> Vec { + file.entries + .iter() + .map(|entry| entry.segments.join("/")) + .collect() +} + +#[test] +fn a_plain_file_is_one_target_per_line() { + let file = parse(b"Artist/Album/01.flac\nArtist/Album/02.flac\n"); + assert_eq!(file.encoding, TextEncoding::UTF_8); + assert_eq!(file.name, None); + assert_eq!( + targets(&file), + ["Artist/Album/01.flac", "Artist/Album/02.flac"] + ); + assert_eq!(file.entries[1].line, 2); + assert!(file.entries.iter().all(|entry| entry.title.is_none())); +} + +#[test] +fn the_extended_dialect_attaches_its_metadata_to_the_next_target() { + let file = parse( + b"#EXTM3U\n#PLAYLIST:Evening\n#EXTINF:245,Harbour Bells - Lamp Hymn\nArtist/01.flac\n\ + # a human comment\n#EXTINF:-1,A stream\nArtist/02.flac\n", + ); + assert_eq!(file.name.as_deref(), Some("Evening")); + assert_eq!(targets(&file), ["Artist/01.flac", "Artist/02.flac"]); + assert_eq!(file.entries[0].duration_secs, Some(245)); + assert_eq!( + file.entries[0].artist_and_title(), + Some(("Harbour Bells", "Lamp Hymn")) + ); + // `-1` is a stream's duration, which is not a measurement. + assert_eq!(file.entries[1].duration_secs, None); + assert_eq!(file.entries[1].title.as_deref(), Some("A stream")); + assert_eq!(file.entries[1].artist_and_title(), None); +} + +#[test] +fn an_extinf_with_no_target_after_it_carries_nothing_forward() { + // The metadata belongs to a target. A file that ends on `#EXTINF` + // must not hand it to whatever the next import reads. + let file = parse(b"#EXTM3U\n#EXTINF:1,Orphan\n"); + assert!(file.entries.is_empty()); + let file = parse(b"#EXTM3U\n#EXTINF:1,Orphan\n\n#EXTINF:2,Owner - Song\nA/01.flac\n"); + assert_eq!(file.entries.len(), 1); + assert_eq!(file.entries[0].title.as_deref(), Some("Owner - Song")); +} + +#[test] +fn windows_line_endings_and_a_bom_do_not_reach_the_path() { + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice(b"#EXTM3U\r\nArtist/Album/01.flac\r\n"); + let file = parse(&bytes); + assert_eq!(file.encoding, TextEncoding::UTF_8); + assert_eq!(targets(&file), ["Artist/Album/01.flac"]); +} + +#[test] +fn a_file_that_is_not_utf8_is_read_as_windows_1252() { + // "Bjrk/Vespertine/01.flac" — the byte a Windows player writes + // for ö, which is not valid UTF-8. + let mut bytes = b"Bj".to_vec(); + bytes.push(0xF6); + bytes.extend_from_slice(b"rk/Vespertine/01.flac\n"); + let file = parse(&bytes); + assert_eq!(file.encoding.name(), "windows-1252"); + assert_eq!(targets(&file), ["Björk/Vespertine/01.flac"]); + + // And the thirty-two code points that tell Windows-1252 from Latin-1: + // 0x92 is a right single quote in a title, not a control code. + let mut bytes = b"#EXTINF:1,L".to_vec(); + bytes.push(0x92); + bytes.extend_from_slice(b"artiste - Song\nA/01.flac\n"); + let file = parse(&bytes); + assert_eq!( + file.entries[0].title.as_deref(), + Some("L\u{2019}artiste - Song") + ); +} + +/// What Notepad saves as "Unicode", and what some players export: UTF-16 +/// little-endian behind its byte-order mark. Read as Windows-1252, every +/// other character was a NUL and no path matched anything. +#[test] +fn a_utf16_file_is_read_by_its_byte_order_mark() { + let text = "#EXTM3U\r\n#EXTINF:245,Björk - Hidden Place\r\nBjörk/Vespertine/01.flac\r\n"; + let mut bytes = vec![0xFF, 0xFE]; + bytes.extend(text.encode_utf16().flat_map(u16::to_le_bytes)); + let file = parse(&bytes); + assert_eq!(serde_json::to_value(file.encoding).unwrap(), "utf-16le"); + assert_eq!(targets(&file), ["Björk/Vespertine/01.flac"]); + assert_eq!( + file.entries[0].artist_and_title(), + Some(("Björk", "Hidden Place")) + ); +} + +/// A playlist written on a Japanese Windows, in Shift_JIS. The Windows-1252 +/// fallback read it without failing, which was worse: every name came back +/// as Latin nonsense that matched no path and no title. +#[test] +fn a_file_in_an_east_asian_code_page_is_read_in_it() { + let text = "#EXTM3U\r\n#PLAYLIST:夏の夜に聴きたい歌\r\n\ + #EXTINF:245,宇多田ヒカル - 花束を君に\r\n\ + 宇多田ヒカル/ファントーム/01 花束を君に.flac\r\n\ + #EXTINF:301,椎名林檎 - 丸の内サディスティック\r\n\ + 椎名林檎/無罪モラトリアム/03 丸の内サディスティック.flac\r\n"; + let (bytes, _, unmappable) = encoding_rs::SHIFT_JIS.encode(text); + assert!(!unmappable, "Shift_JIS holds the sample"); + assert!(std::str::from_utf8(&bytes).is_err()); + let file = parse(&bytes); + assert_eq!(serde_json::to_value(file.encoding).unwrap(), "shift_jis"); + assert_eq!(file.name.as_deref(), Some("夏の夜に聴きたい歌")); + assert_eq!( + targets(&file), + [ + "宇多田ヒカル/ファントーム/01 花束を君に.flac", + "椎名林檎/無罪モラトリアム/03 丸の内サディスティック.flac", + ] + ); + assert_eq!( + file.entries[1].artist_and_title(), + Some(("椎名林檎", "丸の内サディスティック")) + ); +} + +/// The report names the encoding the core read, not a second guess that +/// happens to agree today: decoding each sample with the named encoding +/// gives back the core's text exactly. +#[test] +fn the_encoding_named_is_the_one_the_core_read() { + let samples: [(&str, &'static Encoding); 6] = [ + ( + "#EXTINF:1,Björk - Cocoon\nBjörk/Vespertine/02.flac\n", + encoding_rs::WINDOWS_1252, + ), + ( + "#EXTINF:1,宇多田ヒカル - 花束を君に\n宇多田ヒカル/01 花束を君に.flac\n", + encoding_rs::SHIFT_JIS, + ), + ( + "#EXTINF:1,王菲 - 红豆\n王菲/唱游/03 红豆.flac\n", + encoding_rs::GB18030, + ), + ( + "#EXTINF:1,周杰倫 - 晴天\n周杰倫/葉惠美/01 晴天.flac\n", + encoding_rs::BIG5, + ), + ( + "#EXTINF:1,아이유 - 밤편지\n아이유/팔레트/01 밤편지.flac\n", + encoding_rs::EUC_KR, + ), + ( + "#EXTINF:1,Björk - Cocoon\nBjörk/Vespertine/02.flac\n", + encoding_rs::UTF_8, + ), + ]; + for (text, encoding) in samples { + let (bytes, _, unmappable) = encoding.encode(text); + assert!(!unmappable, "{} holds the sample", encoding.name()); + let named = encoding_read_as(&bytes); + assert_eq!( + named.decode_without_bom_handling(&bytes).0, + waveflow_core::text_encoding::decode(&bytes), + "{} was named {}", + encoding.name(), + named.name() + ); + } + let mut marked = vec![0xFE, 0xFF]; + marked.extend("Björk/01.flac".encode_utf16().flat_map(u16::to_be_bytes)); + assert_eq!(encoding_read_as(&marked), encoding_rs::UTF_16BE); +} + +#[test] +fn every_shape_of_target_reduces_to_the_same_segments() { + let expected = ["Artist", "Album", "01.flac"]; + for target in [ + "Artist/Album/01.flac", + "./Artist/Album/01.flac", + "../../Artist/Album/01.flac", + "/home/someone/Music/Artist/Album/01.flac", + "Artist\\Album\\01.flac", + "C:\\Users\\someone\\Music\\Artist\\Album\\01.flac", + "file:///home/someone/Music/Artist/Album/01.flac", + "file://server/Music/Artist/Album/01.flac", + // A scheme is case-insensitive, and a player that shouts it names + // the same file. + "File:///home/someone/Music/Artist/Album/01.flac", + "FILE://server/Music/Artist/Album/01.flac", + "Artist/Album/%30%31.flac", + ] { + let file = parse(target.as_bytes()); + let entry = &file.entries[0]; + let tail = entry.segments[entry.segments.len() - 3..].to_vec(); + assert_eq!(tail, expected, "{target}"); + assert_eq!(entry.raw, target, "the report quotes what was written"); + } +} + +#[test] +fn a_remote_url_names_no_path_at_all() { + // Deliberately not a failed path match: a web radio is not a track + // this catalogue could hold, and the ladder has nothing to climb. + // And the case-insensitive scheme match does not widen to another + // scheme that merely looks like one. + for target in [ + "http://radio.example/stream", + "https://radio.example/s.mp3", + "HTTP://radio.example/stream", + "filesystem://radio.example/s.mp3", + ] { + let file = parse(target.as_bytes()); + assert!(file.entries[0].segments.is_empty(), "{target}"); + } +} + +#[test] +fn a_hyphen_without_spaces_belongs_to_the_name() { + let file = parse(b"#EXTINF:1,Jean-Michel Jarre\nA/01.flac\n"); + assert_eq!(file.entries[0].artist_and_title(), None); + let file = parse(b"#EXTINF:1,Jean-Michel Jarre - Oxygene-4\nA/01.flac\n"); + assert_eq!( + file.entries[0].artist_and_title(), + Some(("Jean-Michel Jarre", "Oxygene-4")) + ); +} diff --git a/src/library/tags/mod.rs b/src/library/tags/mod.rs index f2e94f2f..2f882199 100644 --- a/src/library/tags/mod.rs +++ b/src/library/tags/mod.rs @@ -397,250 +397,4 @@ fn paired_sorts(names: &[String], raw: Option<&str>, separators: &[&str]) -> Vec } #[cfg(test)] -mod tests { - use super::*; - - fn names_of(credits: &[Credit], role: Role) -> Vec<&str> { - credits - .iter() - .filter(|credit| credit.role == role) - .map(|credit| credit.name.as_str()) - .collect() - } - - /// The separator set is padded so a slash inside a name survives it. - #[test] - fn a_slash_inside_a_name_is_not_a_separator() { - let raw = RawCredits { - artist: vec!["AC/DC".into()], - ..RawCredits::default() - }; - assert_eq!(names_of(&credits(&raw), Role::Artist), vec!["AC/DC"]); - - let joined = RawCredits { - artist: vec!["Nova Kern / Lior Sand".into()], - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&joined), Role::Artist), - vec!["Nova Kern", "Lior Sand"] - ); - } - - /// A separator inside parentheses qualifies one credit; it does not divide - /// two. The real tag that exposed this named two people and produced three - /// entities, each carrying a parenthesis it never opened. - #[test] - fn a_role_separator_inside_parentheses_does_not_cut() { - let raw = RawCredits { - artist: vec!["ITZY".into()], - roles: vec![( - Role::Composer, - vec!["Kobee (Melange / INHOUSE), Holy M (Melange / INHOUSE)".into()], - )], - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&raw), Role::Composer), - vec!["Kobee (Melange / INHOUSE), Holy M (Melange / INHOUSE)"] - ); - } - - /// The parenthesis rule must not cost the cut it was added beside: a - /// separator outside one still divides, in the same value. - #[test] - fn a_role_separator_outside_parentheses_still_cuts() { - let raw = RawCredits { - artist: vec!["Nobody".into()], - roles: vec![( - Role::Composer, - vec!["Bach (arr. Gounod)/Liszt (after Bach)".into()], - )], - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&raw), Role::Composer), - vec!["Bach (arr. Gounod)", "Liszt (after Bach)"] - ); - } - - /// A tag whose parentheses never balance must not swallow the whole value - /// or hang the scan. The opening one protects only the tail it opened. - #[test] - fn an_unbalanced_parenthesis_protects_only_its_tail() { - let unclosed = RawCredits { - artist: vec!["Nobody".into()], - roles: vec![( - Role::Composer, - vec!["Bach/Gounod (Melange / INHOUSE".into()], - )], - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&unclosed), Role::Composer), - vec!["Bach", "Gounod (Melange / INHOUSE"] - ); - - // A stray closing parenthesis must not make the rest uncuttable. - let stray = RawCredits { - artist: vec!["Nobody".into()], - roles: vec![(Role::Composer, vec!["INHOUSE)/Bach".into()])], - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&stray), Role::Composer), - vec!["INHOUSE)", "Bach"] - ); - } - - /// A role tag is cut on the bare separators, where an artist tag is not. - #[test] - fn a_role_tag_is_cut_where_an_artist_tag_would_not_be() { - let raw = RawCredits { - artist: vec!["Nobody".into()], - roles: vec![(Role::Composer, vec!["Bach/Gounod".into()])], - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&raw), Role::Composer), - vec!["Bach", "Gounod"] - ); - } - - #[test] - fn the_plural_tag_wins_and_is_never_cut() { - let raw = RawCredits { - artist: vec!["Ignored; Entirely".into()], - artists: vec!["Nova Kern; Lior Sand".into()], - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&raw), Role::Artist), - vec!["Nova Kern; Lior Sand"], - "a tagger writing the plural tag has already said where the boundaries are" - ); - } - - #[test] - fn a_singular_tag_holding_two_frames_is_not_cut_either() { - let raw = RawCredits { - artist: vec!["Nova Kern; A".into(), "Lior Sand".into()], - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&raw), Role::Artist), - vec!["Nova Kern; A", "Lior Sand"] - ); - } - - #[test] - fn a_performer_carries_the_instrument_it_is_credited_on() { - assert_eq!( - split_performer("Jimmy Page (guitar)"), - ("Guitar".to_owned(), "Jimmy Page".to_owned()) - ); - assert_eq!( - split_performer("Salaam Remi (drums (drum set) and organ)"), - ( - "Drums (Drum Set) And Organ".to_owned(), - "Salaam Remi".to_owned() - ) - ); - assert_eq!( - split_performer("Nobody In Particular"), - (String::new(), "Nobody In Particular".to_owned()) - ); - } - - #[test] - fn a_performer_pair_names_every_player_of_its_instrument() { - let raw = RawCredits { - artist: vec!["Nobody".into()], - performer_pairs: vec![("guitar".into(), "Jimmy Page; Nova Kern".into())], - ..RawCredits::default() - }; - let credits = credits(&raw); - let performers: Vec<(&str, &str)> = credits - .iter() - .filter(|credit| credit.role == Role::Performer) - .map(|credit| (credit.sub_role.as_str(), credit.name.as_str())) - .collect(); - assert_eq!( - performers, - vec![("Guitar", "Jimmy Page"), ("Guitar", "Nova Kern")] - ); - } - - /// An album has to hang off somebody, and which somebody depends on - /// whether the file calls itself a compilation. - #[test] - fn a_file_naming_no_album_artist_still_has_one() { - let plain = RawCredits { - artist: vec!["Nova Kern".into()], - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&plain), Role::AlbumArtist), - vec!["Nova Kern"] - ); - - let compilation = RawCredits { - artist: vec!["Nova Kern".into()], - is_compilation: true, - ..RawCredits::default() - }; - assert_eq!( - names_of(&credits(&compilation), Role::AlbumArtist), - vec![VARIOUS_ARTISTS] - ); - } - - #[test] - fn a_sort_tag_is_paired_position_by_position_or_not_at_all() { - let paired = RawCredits { - artist: vec!["The Nocturnes; Nova Kern".into()], - sort_artist: Some("Nocturnes, The; Kern, Nova".into()), - ..RawCredits::default() - }; - let paired_credits = credits(&paired); - let sorts: Vec> = paired_credits - .iter() - .filter(|credit| credit.role == Role::Artist) - .map(|credit| credit.sort_name.as_deref()) - .collect(); - assert_eq!(sorts, vec![Some("Nocturnes, The"), Some("Kern, Nova")]); - - // Lengths that disagree mean the tagger did not pair them, and filing - // one artist under another's sort form is worse than filing none. - let mismatched = RawCredits { - artist: vec!["The Nocturnes; Nova Kern".into()], - sort_artist: Some("Nocturnes, The".into()), - ..RawCredits::default() - }; - let unpaired = credits(&mismatched); - assert!(unpaired - .iter() - .filter(|credit| credit.role == Role::Artist) - .all(|credit| credit.sort_name.is_none())); - } - - #[test] - fn positions_are_per_role_and_follow_tag_order() { - let raw = RawCredits { - artist: vec!["A; B".into()], - roles: vec![(Role::Producer, vec!["P1; P2".into()])], - ..RawCredits::default() - }; - let credits = credits(&raw); - let at = |role: Role, position: usize| { - credits - .iter() - .find(|credit| credit.role == role && credit.position == position) - .map(|credit| credit.name.as_str()) - }; - assert_eq!(at(Role::Artist, 0), Some("A")); - assert_eq!(at(Role::Artist, 1), Some("B")); - assert_eq!(at(Role::Producer, 0), Some("P1")); - assert_eq!(at(Role::Producer, 1), Some("P2")); - } -} +mod tests; diff --git a/src/library/tags/tests.rs b/src/library/tags/tests.rs new file mode 100644 index 00000000..43798c5a --- /dev/null +++ b/src/library/tags/tests.rs @@ -0,0 +1,245 @@ +use super::*; + +fn names_of(credits: &[Credit], role: Role) -> Vec<&str> { + credits + .iter() + .filter(|credit| credit.role == role) + .map(|credit| credit.name.as_str()) + .collect() +} + +/// The separator set is padded so a slash inside a name survives it. +#[test] +fn a_slash_inside_a_name_is_not_a_separator() { + let raw = RawCredits { + artist: vec!["AC/DC".into()], + ..RawCredits::default() + }; + assert_eq!(names_of(&credits(&raw), Role::Artist), vec!["AC/DC"]); + + let joined = RawCredits { + artist: vec!["Nova Kern / Lior Sand".into()], + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&joined), Role::Artist), + vec!["Nova Kern", "Lior Sand"] + ); +} + +/// A separator inside parentheses qualifies one credit; it does not divide +/// two. The real tag that exposed this named two people and produced three +/// entities, each carrying a parenthesis it never opened. +#[test] +fn a_role_separator_inside_parentheses_does_not_cut() { + let raw = RawCredits { + artist: vec!["ITZY".into()], + roles: vec![( + Role::Composer, + vec!["Kobee (Melange / INHOUSE), Holy M (Melange / INHOUSE)".into()], + )], + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&raw), Role::Composer), + vec!["Kobee (Melange / INHOUSE), Holy M (Melange / INHOUSE)"] + ); +} + +/// The parenthesis rule must not cost the cut it was added beside: a +/// separator outside one still divides, in the same value. +#[test] +fn a_role_separator_outside_parentheses_still_cuts() { + let raw = RawCredits { + artist: vec!["Nobody".into()], + roles: vec![( + Role::Composer, + vec!["Bach (arr. Gounod)/Liszt (after Bach)".into()], + )], + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&raw), Role::Composer), + vec!["Bach (arr. Gounod)", "Liszt (after Bach)"] + ); +} + +/// A tag whose parentheses never balance must not swallow the whole value +/// or hang the scan. The opening one protects only the tail it opened. +#[test] +fn an_unbalanced_parenthesis_protects_only_its_tail() { + let unclosed = RawCredits { + artist: vec!["Nobody".into()], + roles: vec![( + Role::Composer, + vec!["Bach/Gounod (Melange / INHOUSE".into()], + )], + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&unclosed), Role::Composer), + vec!["Bach", "Gounod (Melange / INHOUSE"] + ); + + // A stray closing parenthesis must not make the rest uncuttable. + let stray = RawCredits { + artist: vec!["Nobody".into()], + roles: vec![(Role::Composer, vec!["INHOUSE)/Bach".into()])], + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&stray), Role::Composer), + vec!["INHOUSE)", "Bach"] + ); +} + +/// A role tag is cut on the bare separators, where an artist tag is not. +#[test] +fn a_role_tag_is_cut_where_an_artist_tag_would_not_be() { + let raw = RawCredits { + artist: vec!["Nobody".into()], + roles: vec![(Role::Composer, vec!["Bach/Gounod".into()])], + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&raw), Role::Composer), + vec!["Bach", "Gounod"] + ); +} + +#[test] +fn the_plural_tag_wins_and_is_never_cut() { + let raw = RawCredits { + artist: vec!["Ignored; Entirely".into()], + artists: vec!["Nova Kern; Lior Sand".into()], + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&raw), Role::Artist), + vec!["Nova Kern; Lior Sand"], + "a tagger writing the plural tag has already said where the boundaries are" + ); +} + +#[test] +fn a_singular_tag_holding_two_frames_is_not_cut_either() { + let raw = RawCredits { + artist: vec!["Nova Kern; A".into(), "Lior Sand".into()], + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&raw), Role::Artist), + vec!["Nova Kern; A", "Lior Sand"] + ); +} + +#[test] +fn a_performer_carries_the_instrument_it_is_credited_on() { + assert_eq!( + split_performer("Jimmy Page (guitar)"), + ("Guitar".to_owned(), "Jimmy Page".to_owned()) + ); + assert_eq!( + split_performer("Salaam Remi (drums (drum set) and organ)"), + ( + "Drums (Drum Set) And Organ".to_owned(), + "Salaam Remi".to_owned() + ) + ); + assert_eq!( + split_performer("Nobody In Particular"), + (String::new(), "Nobody In Particular".to_owned()) + ); +} + +#[test] +fn a_performer_pair_names_every_player_of_its_instrument() { + let raw = RawCredits { + artist: vec!["Nobody".into()], + performer_pairs: vec![("guitar".into(), "Jimmy Page; Nova Kern".into())], + ..RawCredits::default() + }; + let credits = credits(&raw); + let performers: Vec<(&str, &str)> = credits + .iter() + .filter(|credit| credit.role == Role::Performer) + .map(|credit| (credit.sub_role.as_str(), credit.name.as_str())) + .collect(); + assert_eq!( + performers, + vec![("Guitar", "Jimmy Page"), ("Guitar", "Nova Kern")] + ); +} + +/// An album has to hang off somebody, and which somebody depends on +/// whether the file calls itself a compilation. +#[test] +fn a_file_naming_no_album_artist_still_has_one() { + let plain = RawCredits { + artist: vec!["Nova Kern".into()], + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&plain), Role::AlbumArtist), + vec!["Nova Kern"] + ); + + let compilation = RawCredits { + artist: vec!["Nova Kern".into()], + is_compilation: true, + ..RawCredits::default() + }; + assert_eq!( + names_of(&credits(&compilation), Role::AlbumArtist), + vec![VARIOUS_ARTISTS] + ); +} + +#[test] +fn a_sort_tag_is_paired_position_by_position_or_not_at_all() { + let paired = RawCredits { + artist: vec!["The Nocturnes; Nova Kern".into()], + sort_artist: Some("Nocturnes, The; Kern, Nova".into()), + ..RawCredits::default() + }; + let paired_credits = credits(&paired); + let sorts: Vec> = paired_credits + .iter() + .filter(|credit| credit.role == Role::Artist) + .map(|credit| credit.sort_name.as_deref()) + .collect(); + assert_eq!(sorts, vec![Some("Nocturnes, The"), Some("Kern, Nova")]); + + // Lengths that disagree mean the tagger did not pair them, and filing + // one artist under another's sort form is worse than filing none. + let mismatched = RawCredits { + artist: vec!["The Nocturnes; Nova Kern".into()], + sort_artist: Some("Nocturnes, The".into()), + ..RawCredits::default() + }; + let unpaired = credits(&mismatched); + assert!(unpaired + .iter() + .filter(|credit| credit.role == Role::Artist) + .all(|credit| credit.sort_name.is_none())); +} + +#[test] +fn positions_are_per_role_and_follow_tag_order() { + let raw = RawCredits { + artist: vec!["A; B".into()], + roles: vec![(Role::Producer, vec!["P1; P2".into()])], + ..RawCredits::default() + }; + let credits = credits(&raw); + let at = |role: Role, position: usize| { + credits + .iter() + .find(|credit| credit.role == role && credit.position == position) + .map(|credit| credit.name.as_str()) + }; + assert_eq!(at(Role::Artist, 0), Some("A")); + assert_eq!(at(Role::Artist, 1), Some("B")); + assert_eq!(at(Role::Producer, 0), Some("P1")); + assert_eq!(at(Role::Producer, 1), Some("P2")); +} diff --git a/src/media/replay_gain/mod.rs b/src/media/replay_gain/mod.rs index 24f34454..b0d4c105 100644 --- a/src/media/replay_gain/mod.rs +++ b/src/media/replay_gain/mod.rs @@ -101,84 +101,4 @@ pub fn gain(tags: ReplayGainTags, mode: ReplayGainMode) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - fn tags( - track: (Option, Option), - album: (Option, Option), - ) -> ReplayGainTags { - ReplayGainTags { - track_gain: track.0, - track_peak: track.1, - album_gain: album.0, - album_peak: album.1, - } - } - - #[test] - fn each_mode_reads_its_own_pair_and_falls_back_to_the_other() { - let both = tags((Some(-6.0), None), (Some(-3.0), None)); - assert_eq!(gain(both, ReplayGainMode::Track).unwrap().decibels(), -6.0); - assert_eq!(gain(both, ReplayGainMode::Album).unwrap().decibels(), -3.0); - assert_eq!(gain(both, ReplayGainMode::Off), None); - let album_only = tags((None, None), (Some(-4.5), None)); - assert_eq!( - gain(album_only, ReplayGainMode::Track).unwrap().decibels(), - -4.5 - ); - let track_only = tags((Some(-2.25), None), (None, None)); - assert_eq!( - gain(track_only, ReplayGainMode::Album).unwrap().decibels(), - -2.25 - ); - assert_eq!(gain(ReplayGainTags::default(), ReplayGainMode::Album), None); - assert_eq!( - gain( - tags((Some(f64::NAN), None), (None, None)), - ReplayGainMode::Track - ), - None - ); - } - - #[test] - fn a_raised_track_never_clips_and_a_wrong_tag_is_bounded() { - // +6 dB on a peak of 0.8 would clip; it is held at what 0.8 allows. - let hot = tags((Some(6.0), Some(0.8)), (None, None)); - let held = gain(hot, ReplayGainMode::Track).unwrap().decibels(); - assert!((held - 1.94).abs() < 0.005, "{held}"); - // A cut is never limited by the peak. - let cut = tags((Some(-8.0), Some(1.0)), (None, None)); - assert_eq!(gain(cut, ReplayGainMode::Track).unwrap().decibels(), -8.0); - let absurd = tags((Some(90.0), None), (None, None)); - assert_eq!( - gain(absurd, ReplayGainMode::Track).unwrap().decibels(), - 18.0 - ); - let silent = tags((Some(-500.0), None), (None, None)); - assert_eq!( - gain(silent, ReplayGainMode::Track).unwrap().decibels(), - -60.0 - ); - assert_eq!( - gain( - tags((Some(0.001), None), (None, None)), - ReplayGainMode::Track - ), - None - ); - } - - #[test] - fn modes_are_read_as_written() { - assert_eq!(ReplayGainMode::parse("Album"), Some(ReplayGainMode::Album)); - assert_eq!( - ReplayGainMode::parse(" track "), - Some(ReplayGainMode::Track) - ); - assert_eq!(ReplayGainMode::parse("off"), Some(ReplayGainMode::Off)); - assert_eq!(ReplayGainMode::parse("loud"), None); - assert_eq!(Gain { centibels: -650 }.filter(), "volume=-6.50dB"); - } -} +mod tests; diff --git a/src/media/replay_gain/tests.rs b/src/media/replay_gain/tests.rs new file mode 100644 index 00000000..63e7e411 --- /dev/null +++ b/src/media/replay_gain/tests.rs @@ -0,0 +1,76 @@ +use super::*; + +fn tags(track: (Option, Option), album: (Option, Option)) -> ReplayGainTags { + ReplayGainTags { + track_gain: track.0, + track_peak: track.1, + album_gain: album.0, + album_peak: album.1, + } +} + +#[test] +fn each_mode_reads_its_own_pair_and_falls_back_to_the_other() { + let both = tags((Some(-6.0), None), (Some(-3.0), None)); + assert_eq!(gain(both, ReplayGainMode::Track).unwrap().decibels(), -6.0); + assert_eq!(gain(both, ReplayGainMode::Album).unwrap().decibels(), -3.0); + assert_eq!(gain(both, ReplayGainMode::Off), None); + let album_only = tags((None, None), (Some(-4.5), None)); + assert_eq!( + gain(album_only, ReplayGainMode::Track).unwrap().decibels(), + -4.5 + ); + let track_only = tags((Some(-2.25), None), (None, None)); + assert_eq!( + gain(track_only, ReplayGainMode::Album).unwrap().decibels(), + -2.25 + ); + assert_eq!(gain(ReplayGainTags::default(), ReplayGainMode::Album), None); + assert_eq!( + gain( + tags((Some(f64::NAN), None), (None, None)), + ReplayGainMode::Track + ), + None + ); +} + +#[test] +fn a_raised_track_never_clips_and_a_wrong_tag_is_bounded() { + // +6 dB on a peak of 0.8 would clip; it is held at what 0.8 allows. + let hot = tags((Some(6.0), Some(0.8)), (None, None)); + let held = gain(hot, ReplayGainMode::Track).unwrap().decibels(); + assert!((held - 1.94).abs() < 0.005, "{held}"); + // A cut is never limited by the peak. + let cut = tags((Some(-8.0), Some(1.0)), (None, None)); + assert_eq!(gain(cut, ReplayGainMode::Track).unwrap().decibels(), -8.0); + let absurd = tags((Some(90.0), None), (None, None)); + assert_eq!( + gain(absurd, ReplayGainMode::Track).unwrap().decibels(), + 18.0 + ); + let silent = tags((Some(-500.0), None), (None, None)); + assert_eq!( + gain(silent, ReplayGainMode::Track).unwrap().decibels(), + -60.0 + ); + assert_eq!( + gain( + tags((Some(0.001), None), (None, None)), + ReplayGainMode::Track + ), + None + ); +} + +#[test] +fn modes_are_read_as_written() { + assert_eq!(ReplayGainMode::parse("Album"), Some(ReplayGainMode::Album)); + assert_eq!( + ReplayGainMode::parse(" track "), + Some(ReplayGainMode::Track) + ); + assert_eq!(ReplayGainMode::parse("off"), Some(ReplayGainMode::Off)); + assert_eq!(ReplayGainMode::parse("loud"), None); + assert_eq!(Gain { centibels: -650 }.filter(), "volume=-6.50dB"); +} diff --git a/src/quality/bandwidth/mod.rs b/src/quality/bandwidth/mod.rs index f67be830..fe471e93 100644 --- a/src/quality/bandwidth/mod.rs +++ b/src/quality/bandwidth/mod.rs @@ -271,133 +271,4 @@ fn find_wall(db: &[f64], rate: f64) -> Bandwidth { } #[cfg(test)] -mod tests { - use super::*; - - const RATE: f64 = 44_100.0; - - /// Noise shaped by a gain per frequency: white noise through an FFT - /// mask, in frames, so the test controls the spectrum exactly. - fn shaped_noise(secs: f64, gain_db: impl Fn(f64) -> f64) -> Vec { - let mut planner = RealFftPlanner::::new(); - let forward = planner.plan_fft_forward(FRAME); - let inverse = planner.plan_fft_inverse(FRAME); - let mut seed = 0x1234_5678_u32; - let mut out = Vec::new(); - while (out.len() as f64) < secs * RATE { - let mut block: Vec = (0..FRAME) - .map(|_| { - seed ^= seed << 13; - seed ^= seed >> 17; - seed ^= seed << 5; - (seed as f32 / u32::MAX as f32) - 0.5 - }) - .collect(); - let mut spec = forward.make_output_vec(); - forward.process(&mut block, &mut spec).unwrap(); - for (k, bin) in spec.iter_mut().enumerate() { - let hz = k as f64 * RATE / FRAME as f64; - *bin *= 10f32.powf((gain_db(hz) / 20.0) as f32); - } - let mut back = inverse.make_output_vec(); - inverse.process(&mut spec, &mut back).unwrap(); - out.extend(back.iter().map(|v| v / FRAME as f32)); - } - out - } - - fn measure(signal: &[f32]) -> Bandwidth { - let mut meter = BandwidthMeter::new(RATE, 1); - for &s in signal { - meter.push_frame(&[s]); - } - meter.finish().expect("a spectrum") - } - - /// A music-like tilt: the level falls 3 dB per octave above 1 kHz. - fn tilt(hz: f64) -> f64 { - -3.0 * (hz.max(1000.0) / 1000.0).log2() - } - - #[test] - fn a_lossy_cut_is_found_where_it_stands() { - for cut in [16_000.0, 19_000.0, 20_000.0] { - let b = measure(&shaped_noise(20.0, |hz| { - tilt(hz) + if hz > cut { -60.0 } else { 0.0 } - })); - let found = b.cutoff_hz.expect("a wall"); - assert!((found - cut).abs() < 300.0, "{cut}: {b:?}"); - } - } - - #[test] - fn a_full_band_spectrum_has_no_wall() { - let b = measure(&shaped_noise(20.0, tilt)); - assert!(b.cutoff_hz.is_none(), "{b:?}"); - } - - /// An old, dull recording: the highs fade by 12 dB per octave from - /// 6 kHz. Low, but no wall — not a lossy file. - #[test] - fn a_gentle_fade_is_not_a_wall() { - let b = measure(&shaped_noise(20.0, |hz| { - tilt(hz) - 12.0 * (hz.max(6000.0) / 6000.0).log2() - })); - assert!(b.cutoff_hz.is_none(), "{b:?}"); - } - - #[test] - fn the_port_follows_the_core() { - use waveflow_core::analysis::bandwidth as core; - assert_eq!(BANDWIDTH_VERSION, core::BANDWIDTH_VERSION); - assert_eq!(WALL_DB, core::WALL_DB); - assert_eq!(LOSSY_CUTOFF_HZ, core::LOSSY_CUTOFF_HZ); - assert_eq!(NYQUIST_MARGIN_HZ, core::NYQUIST_MARGIN_HZ); - } - - #[test] - fn the_sql_threshold_is_the_constant() { - let sql = waveflow_core::probable_fake_lossless_sql!(); - assert!( - sql.contains(&format!( - "MIN({}, t.sample_rate / 2.0 - {})", - LOSSY_CUTOFF_HZ as i64, NYQUIST_MARGIN_HZ as i64 - )), - "{sql}" - ); - } - - #[test] - fn silence_measures_nothing() { - let mut meter = BandwidthMeter::new(RATE, 2); - for _ in 0..(5.0 * RATE) as usize { - meter.push_frame(&[0.0, 0.0]); - } - assert!(meter.finish().is_none()); - } - - /// Highs in opposition between the channels vanish from a mono sum, - /// which then shows a wall at 15 kHz; channel by channel they are - /// there, and a genuine file is not taken for a fake. - #[test] - fn highs_in_opposition_are_not_a_wall() { - let lows = shaped_noise(20.0, |hz| { - tilt(hz) + if hz > 15_000.0 { -80.0 } else { 0.0 } - }); - let highs = shaped_noise(20.0, |hz| { - tilt(hz) + if hz > 15_000.0 { 0.0 } else { -80.0 } - }); - let mut meter = BandwidthMeter::new(RATE, 2); - for (&l, &h) in lows.iter().zip(&highs) { - meter.push_frame(&[l + h, l - h]); - } - let b = meter.finish().expect("a spectrum"); - assert!(b.cutoff_hz.is_none(), "{b:?}"); - - let mono: Vec = lows.to_vec(); - assert!( - measure(&mono).cutoff_hz.is_some(), - "the sum alone has the wall" - ); - } -} +mod tests; diff --git a/src/quality/bandwidth/tests.rs b/src/quality/bandwidth/tests.rs new file mode 100644 index 00000000..ac0d251c --- /dev/null +++ b/src/quality/bandwidth/tests.rs @@ -0,0 +1,128 @@ +use super::*; + +const RATE: f64 = 44_100.0; + +/// Noise shaped by a gain per frequency: white noise through an FFT +/// mask, in frames, so the test controls the spectrum exactly. +fn shaped_noise(secs: f64, gain_db: impl Fn(f64) -> f64) -> Vec { + let mut planner = RealFftPlanner::::new(); + let forward = planner.plan_fft_forward(FRAME); + let inverse = planner.plan_fft_inverse(FRAME); + let mut seed = 0x1234_5678_u32; + let mut out = Vec::new(); + while (out.len() as f64) < secs * RATE { + let mut block: Vec = (0..FRAME) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + (seed as f32 / u32::MAX as f32) - 0.5 + }) + .collect(); + let mut spec = forward.make_output_vec(); + forward.process(&mut block, &mut spec).unwrap(); + for (k, bin) in spec.iter_mut().enumerate() { + let hz = k as f64 * RATE / FRAME as f64; + *bin *= 10f32.powf((gain_db(hz) / 20.0) as f32); + } + let mut back = inverse.make_output_vec(); + inverse.process(&mut spec, &mut back).unwrap(); + out.extend(back.iter().map(|v| v / FRAME as f32)); + } + out +} + +fn measure(signal: &[f32]) -> Bandwidth { + let mut meter = BandwidthMeter::new(RATE, 1); + for &s in signal { + meter.push_frame(&[s]); + } + meter.finish().expect("a spectrum") +} + +/// A music-like tilt: the level falls 3 dB per octave above 1 kHz. +fn tilt(hz: f64) -> f64 { + -3.0 * (hz.max(1000.0) / 1000.0).log2() +} + +#[test] +fn a_lossy_cut_is_found_where_it_stands() { + for cut in [16_000.0, 19_000.0, 20_000.0] { + let b = measure(&shaped_noise(20.0, |hz| { + tilt(hz) + if hz > cut { -60.0 } else { 0.0 } + })); + let found = b.cutoff_hz.expect("a wall"); + assert!((found - cut).abs() < 300.0, "{cut}: {b:?}"); + } +} + +#[test] +fn a_full_band_spectrum_has_no_wall() { + let b = measure(&shaped_noise(20.0, tilt)); + assert!(b.cutoff_hz.is_none(), "{b:?}"); +} + +/// An old, dull recording: the highs fade by 12 dB per octave from +/// 6 kHz. Low, but no wall — not a lossy file. +#[test] +fn a_gentle_fade_is_not_a_wall() { + let b = measure(&shaped_noise(20.0, |hz| { + tilt(hz) - 12.0 * (hz.max(6000.0) / 6000.0).log2() + })); + assert!(b.cutoff_hz.is_none(), "{b:?}"); +} + +#[test] +fn the_port_follows_the_core() { + use waveflow_core::analysis::bandwidth as core; + assert_eq!(BANDWIDTH_VERSION, core::BANDWIDTH_VERSION); + assert_eq!(WALL_DB, core::WALL_DB); + assert_eq!(LOSSY_CUTOFF_HZ, core::LOSSY_CUTOFF_HZ); + assert_eq!(NYQUIST_MARGIN_HZ, core::NYQUIST_MARGIN_HZ); +} + +#[test] +fn the_sql_threshold_is_the_constant() { + let sql = waveflow_core::probable_fake_lossless_sql!(); + assert!( + sql.contains(&format!( + "MIN({}, t.sample_rate / 2.0 - {})", + LOSSY_CUTOFF_HZ as i64, NYQUIST_MARGIN_HZ as i64 + )), + "{sql}" + ); +} + +#[test] +fn silence_measures_nothing() { + let mut meter = BandwidthMeter::new(RATE, 2); + for _ in 0..(5.0 * RATE) as usize { + meter.push_frame(&[0.0, 0.0]); + } + assert!(meter.finish().is_none()); +} + +/// Highs in opposition between the channels vanish from a mono sum, +/// which then shows a wall at 15 kHz; channel by channel they are +/// there, and a genuine file is not taken for a fake. +#[test] +fn highs_in_opposition_are_not_a_wall() { + let lows = shaped_noise(20.0, |hz| { + tilt(hz) + if hz > 15_000.0 { -80.0 } else { 0.0 } + }); + let highs = shaped_noise(20.0, |hz| { + tilt(hz) + if hz > 15_000.0 { 0.0 } else { -80.0 } + }); + let mut meter = BandwidthMeter::new(RATE, 2); + for (&l, &h) in lows.iter().zip(&highs) { + meter.push_frame(&[l + h, l - h]); + } + let b = meter.finish().expect("a spectrum"); + assert!(b.cutoff_hz.is_none(), "{b:?}"); + + let mono: Vec = lows.to_vec(); + assert!( + measure(&mono).cutoff_hz.is_some(), + "the sum alone has the wall" + ); +} diff --git a/src/quality/mod.rs b/src/quality/mod.rs index 30fe50b5..74ce746d 100644 --- a/src/quality/mod.rs +++ b/src/quality/mod.rs @@ -403,100 +403,4 @@ pub async fn spectrogram( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_padded_sixteen_bit_file_uses_sixteen_bits() { - // A 16-bit sample decoded to s32 is shifted left by sixteen. - assert_eq!(effective_bits(0x7FFF_0000 | 0x0001_0000, false), Some(16)); - // A 24-bit one by eight, and a genuine one sets its lowest bit. - assert_eq!(effective_bits(0x0000_0100, false), Some(24)); - assert_eq!(effective_bits(0x0000_0001, false), Some(32)); - assert_eq!(effective_bits(0, false), None); - assert_eq!(effective_bits(0x0001_0000, true), None); - } - - #[test] - fn the_spectrum_is_stored_under_its_loudest_band() { - let mut db = vec![-20.0; 2049]; - for level in db.iter_mut().skip(1500) { - *level = -80.0; - } - let stored = quantise_spectrum(&db); - assert_eq!(stored.len(), SPECTRUM_BANDS); - assert_eq!(stored[0], 0); - assert_eq!(stored[SPECTRUM_BANDS - 1], 120); - assert_eq!(spectrum_db(&stored)[SPECTRUM_BANDS - 1], -60.0); - } - - #[test] - fn a_spectrogram_gives_up_before_the_request_does() { - assert_eq!( - spectrogram_deadline(Duration::from_secs(30)), - Duration::from_secs(20) - ); - assert_eq!( - spectrogram_deadline(Duration::from_secs(10)), - Duration::from_secs(5) - ); - assert_eq!( - spectrogram_deadline(Duration::from_secs(2)), - Duration::from_secs(1) - ); - // The shortest setting the configuration accepts still leaves the - // route time to answer for itself. - assert_eq!( - spectrogram_deadline(Duration::from_secs(1)), - Duration::from_millis(500) - ); - } - - #[test] - fn the_decoder_digest_reads_past_the_first_line() { - let one = b"ffmpeg version 8.0\nconfiguration: --enable-libwebp\nlibavcodec 62. 11.100 / 62. 11.100\n"; - let two = b"ffmpeg version 8.0\nconfiguration: --disable-libwebp\nlibavcodec 62. 11.100 / 62. 11.100\n"; - let three = b"ffmpeg version 8.0\nconfiguration: --enable-libwebp\nlibavcodec 62. 11.100 / 62. 12.100\n"; - assert_ne!(decoder_digest(one), decoder_digest(two)); - assert_ne!(decoder_digest(one), decoder_digest(three)); - assert_eq!(decoder_digest(one).len(), 64); - } - - /// The probe, the measurement and the spectrogram open files and nothing - /// else (RFC-023 decision 2): an input that is a URL is refused by - /// ffprobe and by FFmpeg before either connects to anything, and a real - /// file still measures. - #[tokio::test] - async fn the_analysis_opens_files_and_nothing_else() { - let temp = tempfile::tempdir().unwrap(); - let real = temp.path().join("real.wav"); - crate::media::tests::silent_wav(&real, 1); - let ffmpeg = Path::new("ffmpeg"); - let ffprobe = Path::new("ffprobe"); - let deadline = Duration::from_secs(60); - assert!( - matches!( - measure(ffprobe, ffmpeg, &real, deadline).await, - Measurement::Silent { .. } - ), - "a real file must still be measured" - ); - - let (address, connections, listener) = crate::media::tests::counting_listener().await; - let url = format!("http://{address}/track.flac"); - assert!(matches!( - measure(ffprobe, ffmpeg, Path::new(&url), deadline).await, - Measurement::Unreadable(Failure::Decode) - )); - assert!(spectrogram(ffmpeg, Path::new(&url), false, deadline) - .await - .is_none()); - tokio::time::sleep(Duration::from_millis(200)).await; - listener.abort(); - assert_eq!( - connections.load(std::sync::atomic::Ordering::SeqCst), - 0, - "neither tool may contact anything" - ); - } -} +mod tests; diff --git a/src/quality/tests.rs b/src/quality/tests.rs new file mode 100644 index 00000000..499ca300 --- /dev/null +++ b/src/quality/tests.rs @@ -0,0 +1,95 @@ +use super::*; + +#[test] +fn a_padded_sixteen_bit_file_uses_sixteen_bits() { + // A 16-bit sample decoded to s32 is shifted left by sixteen. + assert_eq!(effective_bits(0x7FFF_0000 | 0x0001_0000, false), Some(16)); + // A 24-bit one by eight, and a genuine one sets its lowest bit. + assert_eq!(effective_bits(0x0000_0100, false), Some(24)); + assert_eq!(effective_bits(0x0000_0001, false), Some(32)); + assert_eq!(effective_bits(0, false), None); + assert_eq!(effective_bits(0x0001_0000, true), None); +} + +#[test] +fn the_spectrum_is_stored_under_its_loudest_band() { + let mut db = vec![-20.0; 2049]; + for level in db.iter_mut().skip(1500) { + *level = -80.0; + } + let stored = quantise_spectrum(&db); + assert_eq!(stored.len(), SPECTRUM_BANDS); + assert_eq!(stored[0], 0); + assert_eq!(stored[SPECTRUM_BANDS - 1], 120); + assert_eq!(spectrum_db(&stored)[SPECTRUM_BANDS - 1], -60.0); +} + +#[test] +fn a_spectrogram_gives_up_before_the_request_does() { + assert_eq!( + spectrogram_deadline(Duration::from_secs(30)), + Duration::from_secs(20) + ); + assert_eq!( + spectrogram_deadline(Duration::from_secs(10)), + Duration::from_secs(5) + ); + assert_eq!( + spectrogram_deadline(Duration::from_secs(2)), + Duration::from_secs(1) + ); + // The shortest setting the configuration accepts still leaves the + // route time to answer for itself. + assert_eq!( + spectrogram_deadline(Duration::from_secs(1)), + Duration::from_millis(500) + ); +} + +#[test] +fn the_decoder_digest_reads_past_the_first_line() { + let one = b"ffmpeg version 8.0\nconfiguration: --enable-libwebp\nlibavcodec 62. 11.100 / 62. 11.100\n"; + let two = b"ffmpeg version 8.0\nconfiguration: --disable-libwebp\nlibavcodec 62. 11.100 / 62. 11.100\n"; + let three = b"ffmpeg version 8.0\nconfiguration: --enable-libwebp\nlibavcodec 62. 11.100 / 62. 12.100\n"; + assert_ne!(decoder_digest(one), decoder_digest(two)); + assert_ne!(decoder_digest(one), decoder_digest(three)); + assert_eq!(decoder_digest(one).len(), 64); +} + +/// The probe, the measurement and the spectrogram open files and nothing +/// else (RFC-023 decision 2): an input that is a URL is refused by +/// ffprobe and by FFmpeg before either connects to anything, and a real +/// file still measures. +#[tokio::test] +async fn the_analysis_opens_files_and_nothing_else() { + let temp = tempfile::tempdir().unwrap(); + let real = temp.path().join("real.wav"); + crate::media::tests::silent_wav(&real, 1); + let ffmpeg = Path::new("ffmpeg"); + let ffprobe = Path::new("ffprobe"); + let deadline = Duration::from_secs(60); + assert!( + matches!( + measure(ffprobe, ffmpeg, &real, deadline).await, + Measurement::Silent { .. } + ), + "a real file must still be measured" + ); + + let (address, connections, listener) = crate::media::tests::counting_listener().await; + let url = format!("http://{address}/track.flac"); + assert!(matches!( + measure(ffprobe, ffmpeg, Path::new(&url), deadline).await, + Measurement::Unreadable(Failure::Decode) + )); + assert!(spectrogram(ffmpeg, Path::new(&url), false, deadline) + .await + .is_none()); + tokio::time::sleep(Duration::from_millis(200)).await; + listener.abort(); + assert_eq!( + connections.load(std::sync::atomic::Ordering::SeqCst), + 0, + "neither tool may contact anything" + ); +} diff --git a/src/remote/lrclib/mod.rs b/src/remote/lrclib/mod.rs index 09b7e04f..030f45a1 100644 --- a/src/remote/lrclib/mod.rs +++ b/src/remote/lrclib/mod.rs @@ -579,66 +579,4 @@ fn duration_ms(duration: Duration) -> i64 { } #[cfg(test)] -mod tests { - use super::*; - - fn record(json: &str) -> Record { - serde_json::from_str(json).unwrap() - } - - #[test] - fn a_record_is_judged_by_what_it_carries() { - let hit = judge( - record(r#"{"duration":234,"plainLyrics":"a","syncedLyrics":"[00:01.00]a"}"#), - 234_400, - ); - assert_eq!(hit.outcome, LyricsOutcome::Hit); - assert_eq!(hit.synced.as_deref(), Some("[00:01.00]a")); - let instrumental = judge(record(r#"{"instrumental":true,"plainLyrics":"x"}"#), 1_000); - assert_eq!(instrumental.outcome, LyricsOutcome::Instrumental); - assert!(instrumental.plain.is_none()); - let blank = judge(record(r#"{"plainLyrics":" ","syncedLyrics":null}"#), 1_000); - assert_eq!(blank.outcome, LyricsOutcome::Miss); - } - - #[test] - fn another_duration_is_another_recording() { - // Two seconds either way is LRCLIB's own tolerance; three is not. - let close = judge(record(r#"{"duration":236,"plainLyrics":"a"}"#), 234_000); - assert_eq!(close.outcome, LyricsOutcome::Hit); - let far = judge(record(r#"{"duration":237.5,"plainLyrics":"a"}"#), 234_000); - assert_eq!(far.outcome, LyricsOutcome::Miss); - // An instrumental is held to the same tolerance: another recording's - // silence is not this track's answer, and would stand for 30 days. - let other = judge(record(r#"{"duration":300,"instrumental":true}"#), 234_000); - assert_eq!(other.outcome, LyricsOutcome::Miss); - let ours = judge(record(r#"{"duration":235,"instrumental":true}"#), 234_000); - assert_eq!(ours.outcome, LyricsOutcome::Instrumental); - } - - #[test] - fn the_key_folds_spelling_and_refuses_what_cannot_match() { - let track = |artist: &str, title: &str, duration_ms| TrackLookup { - artist: artist.into(), - title: title.into(), - album: Some("Post".into()), - duration_ms, - }; - assert_eq!( - track("Björk", "Army of Me", 234_000).key(), - track("BJÖRK", "army of me", 234_200).key() - ); - assert_ne!( - track("Björk", "Army of Me", 234_000).key(), - track("Björk", "Army of Me", 236_000).key() - ); - assert!(track("", "Army of Me", 234_000).key().is_none()); - assert!(track("Björk", "Army of Me", 0).key().is_none()); - } - - #[test] - fn the_user_agent_names_the_project() { - assert!(USER_AGENT.starts_with("WaveFlowServer/")); - assert!(USER_AGENT.ends_with("(https://github.com/InstaZDLL/waveflow-server)")); - } -} +mod tests; diff --git a/src/remote/lrclib/tests.rs b/src/remote/lrclib/tests.rs new file mode 100644 index 00000000..bf5e7f04 --- /dev/null +++ b/src/remote/lrclib/tests.rs @@ -0,0 +1,61 @@ +use super::*; + +fn record(json: &str) -> Record { + serde_json::from_str(json).unwrap() +} + +#[test] +fn a_record_is_judged_by_what_it_carries() { + let hit = judge( + record(r#"{"duration":234,"plainLyrics":"a","syncedLyrics":"[00:01.00]a"}"#), + 234_400, + ); + assert_eq!(hit.outcome, LyricsOutcome::Hit); + assert_eq!(hit.synced.as_deref(), Some("[00:01.00]a")); + let instrumental = judge(record(r#"{"instrumental":true,"plainLyrics":"x"}"#), 1_000); + assert_eq!(instrumental.outcome, LyricsOutcome::Instrumental); + assert!(instrumental.plain.is_none()); + let blank = judge(record(r#"{"plainLyrics":" ","syncedLyrics":null}"#), 1_000); + assert_eq!(blank.outcome, LyricsOutcome::Miss); +} + +#[test] +fn another_duration_is_another_recording() { + // Two seconds either way is LRCLIB's own tolerance; three is not. + let close = judge(record(r#"{"duration":236,"plainLyrics":"a"}"#), 234_000); + assert_eq!(close.outcome, LyricsOutcome::Hit); + let far = judge(record(r#"{"duration":237.5,"plainLyrics":"a"}"#), 234_000); + assert_eq!(far.outcome, LyricsOutcome::Miss); + // An instrumental is held to the same tolerance: another recording's + // silence is not this track's answer, and would stand for 30 days. + let other = judge(record(r#"{"duration":300,"instrumental":true}"#), 234_000); + assert_eq!(other.outcome, LyricsOutcome::Miss); + let ours = judge(record(r#"{"duration":235,"instrumental":true}"#), 234_000); + assert_eq!(ours.outcome, LyricsOutcome::Instrumental); +} + +#[test] +fn the_key_folds_spelling_and_refuses_what_cannot_match() { + let track = |artist: &str, title: &str, duration_ms| TrackLookup { + artist: artist.into(), + title: title.into(), + album: Some("Post".into()), + duration_ms, + }; + assert_eq!( + track("Björk", "Army of Me", 234_000).key(), + track("BJÖRK", "army of me", 234_200).key() + ); + assert_ne!( + track("Björk", "Army of Me", 234_000).key(), + track("Björk", "Army of Me", 236_000).key() + ); + assert!(track("", "Army of Me", 234_000).key().is_none()); + assert!(track("Björk", "Army of Me", 0).key().is_none()); +} + +#[test] +fn the_user_agent_names_the_project() { + assert!(USER_AGENT.starts_with("WaveFlowServer/")); + assert!(USER_AGENT.ends_with("(https://github.com/InstaZDLL/waveflow-server)")); +} diff --git a/src/remote/outbound/mod.rs b/src/remote/outbound/mod.rs index c53e9ffa..fb165221 100644 --- a/src/remote/outbound/mod.rs +++ b/src/remote/outbound/mod.rs @@ -121,94 +121,4 @@ pub async fn read_bounded_body( } #[cfg(test)] -pub(crate) mod tests { - use std::time::Duration; - - use super::{read_bounded_body, BodyError}; - - /// A listener answering its first request with `body` and every later one - /// with nothing. A body built from bytes declares its length; one built - /// from a stream is chunked and declares none. - pub(crate) async fn answering(body: axum::body::Body) -> (String, tokio::task::JoinHandle<()>) { - let body = std::sync::Arc::new(std::sync::Mutex::new(Some(body))); - let router = axum::Router::new().fallback(move || { - let body = std::sync::Arc::clone(&body); - async move { - let body = body - .lock() - .unwrap() - .take() - .unwrap_or_else(axum::body::Body::empty); - axum::response::Response::builder() - .status(200) - .header("content-type", "application/json") - .body(body) - .unwrap() - } - }); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - (format!("http://{address}/"), server) - } - - /// A body that never ends: the same block of spaces, forever. - pub(crate) fn endless(prefix: &'static str) -> axum::body::Body { - let head = futures_util::stream::once(async move { - Ok::<_, std::io::Error>(bytes::Bytes::from_static(prefix.as_bytes())) - }); - let tail = futures_util::stream::repeat_with(|| { - Ok::<_, std::io::Error>(bytes::Bytes::from(vec![b' '; 64 * 1024])) - }); - axum::body::Body::from_stream(futures_util::StreamExt::chain(head, tail)) - } - - async fn read(body: axum::body::Body, limit: usize) -> Result, BodyError> { - let (url, server) = answering(body).await; - let client = super::client(Duration::from_secs(5)).unwrap(); - let response = client.get(url).send().await.unwrap(); - // Bounded by the test, not by the reader: without the ceiling, the - // endless body below is read until memory runs out, and this is what - // turns that into a failure with a reason. - let read = - tokio::time::timeout(Duration::from_secs(20), read_bounded_body(response, limit)) - .await - .expect("a bounded read must end on its own"); - server.abort(); - read - } - - #[tokio::test] - async fn an_answer_within_the_ceiling_is_read_whole() { - let body = read(axum::body::Body::from("{\"ok\":true}"), 1024).await; - assert_eq!(body.unwrap(), b"{\"ok\":true}"); - } - - /// The case `Content-Length` alone cannot catch: a chunked body that - /// never says how long it is, and never stops. - #[tokio::test] - async fn an_endless_answer_is_cut_off_at_the_ceiling() { - let body = read(endless("{\"status\":\"failure\""), 1024 * 1024).await; - assert_eq!(body, Err(BodyError::TooLarge)); - } - - /// A chunked answer that ends, but past the ceiling: the running count, - /// not the header, is what refuses it. - #[tokio::test] - async fn an_unannounced_answer_over_the_ceiling_is_refused() { - let chunks = futures_util::stream::iter( - (0..4).map(|_| Ok::<_, std::io::Error>(bytes::Bytes::from(vec![b'x'; 512]))), - ); - let body = read(axum::body::Body::from_stream(chunks), 1024).await; - assert_eq!(body, Err(BodyError::TooLarge)); - } - - /// An answer that says up front it is too large is refused unread. - #[tokio::test] - async fn an_announced_giant_is_refused() { - let body = read(axum::body::Body::from(vec![b'x'; 4096]), 1024).await; - assert_eq!(body, Err(BodyError::TooLarge)); - } -} +pub(crate) mod tests; diff --git a/src/remote/outbound/tests.rs b/src/remote/outbound/tests.rs new file mode 100644 index 00000000..af51414e --- /dev/null +++ b/src/remote/outbound/tests.rs @@ -0,0 +1,88 @@ +use std::time::Duration; + +use super::{read_bounded_body, BodyError}; + +/// A listener answering its first request with `body` and every later one +/// with nothing. A body built from bytes declares its length; one built +/// from a stream is chunked and declares none. +pub(crate) async fn answering(body: axum::body::Body) -> (String, tokio::task::JoinHandle<()>) { + let body = std::sync::Arc::new(std::sync::Mutex::new(Some(body))); + let router = axum::Router::new().fallback(move || { + let body = std::sync::Arc::clone(&body); + async move { + let body = body + .lock() + .unwrap() + .take() + .unwrap_or_else(axum::body::Body::empty); + axum::response::Response::builder() + .status(200) + .header("content-type", "application/json") + .body(body) + .unwrap() + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + (format!("http://{address}/"), server) +} + +/// A body that never ends: the same block of spaces, forever. +pub(crate) fn endless(prefix: &'static str) -> axum::body::Body { + let head = futures_util::stream::once(async move { + Ok::<_, std::io::Error>(bytes::Bytes::from_static(prefix.as_bytes())) + }); + let tail = futures_util::stream::repeat_with(|| { + Ok::<_, std::io::Error>(bytes::Bytes::from(vec![b' '; 64 * 1024])) + }); + axum::body::Body::from_stream(futures_util::StreamExt::chain(head, tail)) +} + +async fn read(body: axum::body::Body, limit: usize) -> Result, BodyError> { + let (url, server) = answering(body).await; + let client = super::client(Duration::from_secs(5)).unwrap(); + let response = client.get(url).send().await.unwrap(); + // Bounded by the test, not by the reader: without the ceiling, the + // endless body below is read until memory runs out, and this is what + // turns that into a failure with a reason. + let read = tokio::time::timeout(Duration::from_secs(20), read_bounded_body(response, limit)) + .await + .expect("a bounded read must end on its own"); + server.abort(); + read +} + +#[tokio::test] +async fn an_answer_within_the_ceiling_is_read_whole() { + let body = read(axum::body::Body::from("{\"ok\":true}"), 1024).await; + assert_eq!(body.unwrap(), b"{\"ok\":true}"); +} + +/// The case `Content-Length` alone cannot catch: a chunked body that +/// never says how long it is, and never stops. +#[tokio::test] +async fn an_endless_answer_is_cut_off_at_the_ceiling() { + let body = read(endless("{\"status\":\"failure\""), 1024 * 1024).await; + assert_eq!(body, Err(BodyError::TooLarge)); +} + +/// A chunked answer that ends, but past the ceiling: the running count, +/// not the header, is what refuses it. +#[tokio::test] +async fn an_unannounced_answer_over_the_ceiling_is_refused() { + let chunks = futures_util::stream::iter( + (0..4).map(|_| Ok::<_, std::io::Error>(bytes::Bytes::from(vec![b'x'; 512]))), + ); + let body = read(axum::body::Body::from_stream(chunks), 1024).await; + assert_eq!(body, Err(BodyError::TooLarge)); +} + +/// An answer that says up front it is too large is refused unread. +#[tokio::test] +async fn an_announced_giant_is_refused() { + let body = read(axum::body::Body::from(vec![b'x'; 4096]), 1024).await; + assert_eq!(body, Err(BodyError::TooLarge)); +} diff --git a/src/remote/podcast/mod.rs b/src/remote/podcast/mod.rs index 8d25c7b2..e6918f59 100644 --- a/src/remote/podcast/mod.rs +++ b/src/remote/podcast/mod.rs @@ -610,220 +610,4 @@ pub fn sniff_audio(head: &[u8]) -> Option<(&'static str, &'static str)> { } #[cfg(test)] -mod tests { - use super::*; - - fn base() -> url::Url { - url::Url::parse("https://feeds.example.org/show.xml").unwrap() - } - - #[test] - fn an_rss_feed_is_read_newest_first_and_an_item_without_audio_is_skipped() { - let feed = parse_feed( - br#" - - - The Show - About things

]]>
- https://example.org/ - - - First - ep-1 - Mon, 01 Jan 2024 10:00:00 +0000 - 1:02:03 - - - - Second - Tue, 02 Jan 2024 10:00:00 +0000 - - - No audio -
-
"#, - &base(), - ) - .unwrap(); - assert_eq!(feed.title, "The Show"); - assert_eq!(feed.description.as_deref(), Some("About things")); - assert_eq!( - feed.image_url.as_deref(), - Some("https://example.org/cover.jpg") - ); - assert_eq!(feed.episodes.len(), 2); - assert_eq!(feed.episodes[0].title, "Second"); - assert_eq!(feed.episodes[0].guid, "https://cdn.example.org/2.mp3"); - assert_eq!( - feed.episodes[1].enclosure_url, - "https://feeds.example.org/audio/1.mp3" - ); - assert_eq!(feed.episodes[1].duration_ms, Some(3_723_000)); - assert_eq!(feed.episodes[1].enclosure_length, Some(1234)); - } - - #[test] - fn an_atom_feed_is_read() { - let feed = parse_feed( - br#" - Atom Show - - urn:1One2024-03-01T00:00:00Z - - -"#, - &base(), - ) - .unwrap(); - assert_eq!(feed.title, "Atom Show"); - assert_eq!(feed.episodes[0].guid, "urn:1"); - assert_eq!(feed.episodes[0].published_at, Some(1_709_251_200_000)); - } - - /// The billion laughs, and its quieter relatives: any DTD is refused, so no - /// entity is declared and nothing expands. - #[test] - fn a_document_type_declaration_is_refused_outright() { - let laughs = br#" -]> -&lol2;"#; - assert_eq!(parse_feed(laughs, &base()), Err(FeedError::Invalid)); - let external = br#" -]> -&xxe;"#; - assert_eq!(parse_feed(external, &base()), Err(FeedError::Invalid)); - assert_eq!(parse_feed(b"not xml", &base()), Err(FeedError::Invalid)); - assert_eq!( - parse_feed(b"", &base()), - Err(FeedError::Invalid) - ); - } - - #[test] - fn an_enclosure_that_is_not_http_is_not_an_episode() { - let feed = parse_feed( - br#"T - - -"#, - &base(), - ) - .unwrap(); - assert!(feed.episodes.is_empty()); - } - - /// A secure address may move anywhere secure, a clear one anywhere; a - /// secure one may not move into the clear. A scheme-relative target - /// (`//host/path`) keeps the scheme it came from, so it never downgrades. - #[test] - fn a_redirect_never_leaves_https_for_http() { - let secure = url::Url::parse("https://feeds.example.org/show.xml?auth=t").unwrap(); - let clear = url::Url::parse("http://feeds.example.org/show.xml").unwrap(); - assert_eq!( - next_hop(&secure, "https://cdn.example.org/show.xml") - .unwrap() - .as_str(), - "https://cdn.example.org/show.xml" - ); - assert_eq!( - next_hop(&secure, "/moved.xml").unwrap().as_str(), - "https://feeds.example.org/moved.xml" - ); - assert_eq!( - next_hop(&secure, "//cdn.example.org/show.xml") - .unwrap() - .as_str(), - "https://cdn.example.org/show.xml" - ); - assert_eq!( - next_hop(&clear, "//cdn.example.org/show.xml") - .unwrap() - .as_str(), - "http://cdn.example.org/show.xml" - ); - assert_eq!( - next_hop(&clear, "https://cdn.example.org/show.xml") - .unwrap() - .as_str(), - "https://cdn.example.org/show.xml" - ); - assert_eq!( - next_hop(&clear, "http://cdn.example.org/show.xml") - .unwrap() - .as_str(), - "http://cdn.example.org/show.xml" - ); - assert_eq!( - next_hop(&secure, "http://cdn.example.org/show.xml"), - Err(FetchError::Destination) - ); - assert_eq!( - next_hop(&secure, "HTTP://cdn.example.org/show.xml"), - Err(FetchError::Destination) - ); - } - - #[test] - fn a_feed_address_is_absolute_http_and_carries_no_credentials() { - let public = AddressPolicy::Public; - assert!(validate_feed_url("https://example.org/feed", false, public).is_some()); - assert!(validate_feed_url("http://example.org/feed", false, public).is_none()); - assert!(validate_feed_url("http://example.org/feed", true, public).is_some()); - assert!(validate_feed_url("https://user:secret@example.org/feed", true, public).is_none()); - assert!(validate_feed_url("file:///etc/passwd", true, public).is_none()); - assert!(validate_feed_url("ftp://example.org/feed", true, public).is_none()); - assert!(validate_feed_url("/feed.xml", true, public).is_none()); - } - - #[test] - fn audio_is_recognised_by_its_bytes() { - assert_eq!(sniff_audio(b"ID3\x04\0\0").map(|f| f.0), Some("mp3")); - assert_eq!( - sniff_audio(&[0xff, 0xfb, 0x90, 0x00]).map(|f| f.0), - Some("mp3") - ); - assert_eq!(sniff_audio(b"\0\0\0\x20ftypM4A ").map(|f| f.0), Some("m4a")); - assert_eq!(sniff_audio(b"OggS\0\x02").map(|f| f.0), Some("ogg")); - assert_eq!(sniff_audio(b"").map(|f| f.0), None); - assert_eq!(sniff_audio(b"MZ\x90\0").map(|f| f.0), None); - } - - /// Text is cut where it passes its bound, exactly where the earlier count - /// cut it. The count is now kept as characters are pushed rather than - /// recomputed for each one; that recount was measured and is not slow in - /// practice — std's character count runs optimized even in a debug build, - /// and the feed above parses in well under a second either way — so this - /// test holds the behaviour, not a timing. - #[test] - fn long_texts_are_cut_where_they_pass_their_bound() { - let description = "word ".repeat(1_600); - let items: String = (0..MAX_EPISODES_PER_FEED) - .map(|index| { - format!( - "Episode {index}{description}\ - " - ) - }) - .collect(); - let document = format!("Long{items}"); - let feed = parse_feed(document.as_bytes(), &base()).unwrap(); - assert_eq!(feed.episodes.len(), MAX_EPISODES_PER_FEED); - let kept = feed.episodes[0].description.as_deref().unwrap(); - assert_eq!( - kept.chars().count(), - MAX_DESCRIPTION_CHARS - 1, - "trimmed of its last space" - ); - assert_eq!(plain_text("a b c", 100), "a b c"); - assert_eq!(plain_text("abcdef", 3), "abc"); - } - - #[test] - fn durations_take_the_three_spellings() { - assert_eq!(parse_duration("90"), Some(90_000)); - assert_eq!(parse_duration("01:30"), Some(90_000)); - assert_eq!(parse_duration("1:00:00"), Some(3_600_000)); - assert_eq!(parse_duration("x"), None); - assert_eq!(parse_duration("1:2:3:4"), None); - } -} +mod tests; diff --git a/src/remote/podcast/tests.rs b/src/remote/podcast/tests.rs new file mode 100644 index 00000000..f01a04a2 --- /dev/null +++ b/src/remote/podcast/tests.rs @@ -0,0 +1,215 @@ +use super::*; + +fn base() -> url::Url { + url::Url::parse("https://feeds.example.org/show.xml").unwrap() +} + +#[test] +fn an_rss_feed_is_read_newest_first_and_an_item_without_audio_is_skipped() { + let feed = parse_feed( + br#" + + + The Show + About things

]]>
+ https://example.org/ + + + First + ep-1 + Mon, 01 Jan 2024 10:00:00 +0000 + 1:02:03 + + + + Second + Tue, 02 Jan 2024 10:00:00 +0000 + + + No audio +
+
"#, + &base(), + ) + .unwrap(); + assert_eq!(feed.title, "The Show"); + assert_eq!(feed.description.as_deref(), Some("About things")); + assert_eq!( + feed.image_url.as_deref(), + Some("https://example.org/cover.jpg") + ); + assert_eq!(feed.episodes.len(), 2); + assert_eq!(feed.episodes[0].title, "Second"); + assert_eq!(feed.episodes[0].guid, "https://cdn.example.org/2.mp3"); + assert_eq!( + feed.episodes[1].enclosure_url, + "https://feeds.example.org/audio/1.mp3" + ); + assert_eq!(feed.episodes[1].duration_ms, Some(3_723_000)); + assert_eq!(feed.episodes[1].enclosure_length, Some(1234)); +} + +#[test] +fn an_atom_feed_is_read() { + let feed = parse_feed( + br#" + Atom Show + + urn:1One2024-03-01T00:00:00Z + + +"#, + &base(), + ) + .unwrap(); + assert_eq!(feed.title, "Atom Show"); + assert_eq!(feed.episodes[0].guid, "urn:1"); + assert_eq!(feed.episodes[0].published_at, Some(1_709_251_200_000)); +} + +/// The billion laughs, and its quieter relatives: any DTD is refused, so no +/// entity is declared and nothing expands. +#[test] +fn a_document_type_declaration_is_refused_outright() { + let laughs = br#" +]> +&lol2;"#; + assert_eq!(parse_feed(laughs, &base()), Err(FeedError::Invalid)); + let external = br#" +]> +&xxe;"#; + assert_eq!(parse_feed(external, &base()), Err(FeedError::Invalid)); + assert_eq!(parse_feed(b"not xml", &base()), Err(FeedError::Invalid)); + assert_eq!( + parse_feed(b"", &base()), + Err(FeedError::Invalid) + ); +} + +#[test] +fn an_enclosure_that_is_not_http_is_not_an_episode() { + let feed = parse_feed( + br#"T + + +"#, + &base(), + ) + .unwrap(); + assert!(feed.episodes.is_empty()); +} + +/// A secure address may move anywhere secure, a clear one anywhere; a +/// secure one may not move into the clear. A scheme-relative target +/// (`//host/path`) keeps the scheme it came from, so it never downgrades. +#[test] +fn a_redirect_never_leaves_https_for_http() { + let secure = url::Url::parse("https://feeds.example.org/show.xml?auth=t").unwrap(); + let clear = url::Url::parse("http://feeds.example.org/show.xml").unwrap(); + assert_eq!( + next_hop(&secure, "https://cdn.example.org/show.xml") + .unwrap() + .as_str(), + "https://cdn.example.org/show.xml" + ); + assert_eq!( + next_hop(&secure, "/moved.xml").unwrap().as_str(), + "https://feeds.example.org/moved.xml" + ); + assert_eq!( + next_hop(&secure, "//cdn.example.org/show.xml") + .unwrap() + .as_str(), + "https://cdn.example.org/show.xml" + ); + assert_eq!( + next_hop(&clear, "//cdn.example.org/show.xml") + .unwrap() + .as_str(), + "http://cdn.example.org/show.xml" + ); + assert_eq!( + next_hop(&clear, "https://cdn.example.org/show.xml") + .unwrap() + .as_str(), + "https://cdn.example.org/show.xml" + ); + assert_eq!( + next_hop(&clear, "http://cdn.example.org/show.xml") + .unwrap() + .as_str(), + "http://cdn.example.org/show.xml" + ); + assert_eq!( + next_hop(&secure, "http://cdn.example.org/show.xml"), + Err(FetchError::Destination) + ); + assert_eq!( + next_hop(&secure, "HTTP://cdn.example.org/show.xml"), + Err(FetchError::Destination) + ); +} + +#[test] +fn a_feed_address_is_absolute_http_and_carries_no_credentials() { + let public = AddressPolicy::Public; + assert!(validate_feed_url("https://example.org/feed", false, public).is_some()); + assert!(validate_feed_url("http://example.org/feed", false, public).is_none()); + assert!(validate_feed_url("http://example.org/feed", true, public).is_some()); + assert!(validate_feed_url("https://user:secret@example.org/feed", true, public).is_none()); + assert!(validate_feed_url("file:///etc/passwd", true, public).is_none()); + assert!(validate_feed_url("ftp://example.org/feed", true, public).is_none()); + assert!(validate_feed_url("/feed.xml", true, public).is_none()); +} + +#[test] +fn audio_is_recognised_by_its_bytes() { + assert_eq!(sniff_audio(b"ID3\x04\0\0").map(|f| f.0), Some("mp3")); + assert_eq!( + sniff_audio(&[0xff, 0xfb, 0x90, 0x00]).map(|f| f.0), + Some("mp3") + ); + assert_eq!(sniff_audio(b"\0\0\0\x20ftypM4A ").map(|f| f.0), Some("m4a")); + assert_eq!(sniff_audio(b"OggS\0\x02").map(|f| f.0), Some("ogg")); + assert_eq!(sniff_audio(b"").map(|f| f.0), None); + assert_eq!(sniff_audio(b"MZ\x90\0").map(|f| f.0), None); +} + +/// Text is cut where it passes its bound, exactly where the earlier count +/// cut it. The count is now kept as characters are pushed rather than +/// recomputed for each one; that recount was measured and is not slow in +/// practice — std's character count runs optimized even in a debug build, +/// and the feed above parses in well under a second either way — so this +/// test holds the behaviour, not a timing. +#[test] +fn long_texts_are_cut_where_they_pass_their_bound() { + let description = "word ".repeat(1_600); + let items: String = (0..MAX_EPISODES_PER_FEED) + .map(|index| { + format!( + "Episode {index}{description}\ + " + ) + }) + .collect(); + let document = format!("Long{items}"); + let feed = parse_feed(document.as_bytes(), &base()).unwrap(); + assert_eq!(feed.episodes.len(), MAX_EPISODES_PER_FEED); + let kept = feed.episodes[0].description.as_deref().unwrap(); + assert_eq!( + kept.chars().count(), + MAX_DESCRIPTION_CHARS - 1, + "trimmed of its last space" + ); + assert_eq!(plain_text("a b c", 100), "a b c"); + assert_eq!(plain_text("abcdef", 3), "abc"); +} + +#[test] +fn durations_take_the_three_spellings() { + assert_eq!(parse_duration("90"), Some(90_000)); + assert_eq!(parse_duration("01:30"), Some(90_000)); + assert_eq!(parse_duration("1:00:00"), Some(3_600_000)); + assert_eq!(parse_duration("x"), None); + assert_eq!(parse_duration("1:2:3:4"), None); +} diff --git a/src/remote/portrait/mod.rs b/src/remote/portrait/mod.rs index c47c8d97..a9dc88ca 100644 --- a/src/remote/portrait/mod.rs +++ b/src/remote/portrait/mod.rs @@ -397,56 +397,4 @@ pub async fn store(artwork_dir: &Path, portrait: &Portrait) -> std::io::Result<( } #[cfg(test)] -mod tests { - use super::*; - - /// The protection the suite cannot exercise end to end, since its image - /// host is on loopback: the default refuses loopback and every private - /// range, and admits a public address. - #[test] - fn the_default_policy_refuses_a_loopback_address() { - let policy = AddressPolicy::default(); - for refused in [ - "127.0.0.1", - "::1", - "::ffff:127.0.0.1", - "10.1.2.3", - "172.16.0.1", - "192.168.1.1", - "169.254.169.254", - "100.64.0.1", - "0.0.0.0", - "::", - "fd00::1", - "fe80::1", - "::ffff:192.168.1.1", - "198.18.0.1", - "240.0.0.1", - "fec0::1", - "64:ff9b::a00:1", - "64:ff9b:1::1", - "2002:a00:1::1", - "::10.0.0.1", - "192.0.0.1", - "192.88.99.1", - "2001:0:a00:1::1", - "2001:db8::1", - "100::1", - "::ffff:0:a00:1", - ] { - assert!(!policy.admits(refused.parse().unwrap()), "{refused}"); - } - assert!(policy.admits("93.184.216.34".parse().unwrap())); - assert!(policy.admits("2606:4700::1111".parse().unwrap())); - assert!(AddressPolicy::AllowLoopback.admits("127.0.0.1".parse().unwrap())); - assert!(!AddressPolicy::AllowLoopback.admits("10.0.0.1".parse().unwrap())); - } - - #[test] - fn the_bytes_decide_the_format() { - assert_eq!(sniff(&[0xff, 0xd8, 0xff, 0xe0]), Some("jpg")); - assert_eq!(sniff(b"\x89PNG\r\n\x1a\n...."), Some("png")); - assert_eq!(sniff(b"RIFF\0\0\0\0WEBPVP8 "), Some("webp")); - assert_eq!(sniff(b""), None); - } -} +mod tests; diff --git a/src/remote/portrait/tests.rs b/src/remote/portrait/tests.rs new file mode 100644 index 00000000..335e1029 --- /dev/null +++ b/src/remote/portrait/tests.rs @@ -0,0 +1,51 @@ +use super::*; + +/// The protection the suite cannot exercise end to end, since its image +/// host is on loopback: the default refuses loopback and every private +/// range, and admits a public address. +#[test] +fn the_default_policy_refuses_a_loopback_address() { + let policy = AddressPolicy::default(); + for refused in [ + "127.0.0.1", + "::1", + "::ffff:127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "0.0.0.0", + "::", + "fd00::1", + "fe80::1", + "::ffff:192.168.1.1", + "198.18.0.1", + "240.0.0.1", + "fec0::1", + "64:ff9b::a00:1", + "64:ff9b:1::1", + "2002:a00:1::1", + "::10.0.0.1", + "192.0.0.1", + "192.88.99.1", + "2001:0:a00:1::1", + "2001:db8::1", + "100::1", + "::ffff:0:a00:1", + ] { + assert!(!policy.admits(refused.parse().unwrap()), "{refused}"); + } + assert!(policy.admits("93.184.216.34".parse().unwrap())); + assert!(policy.admits("2606:4700::1111".parse().unwrap())); + assert!(AddressPolicy::AllowLoopback.admits("127.0.0.1".parse().unwrap())); + assert!(!AddressPolicy::AllowLoopback.admits("10.0.0.1".parse().unwrap())); +} + +#[test] +fn the_bytes_decide_the_format() { + assert_eq!(sniff(&[0xff, 0xd8, 0xff, 0xe0]), Some("jpg")); + assert_eq!(sniff(b"\x89PNG\r\n\x1a\n...."), Some("png")); + assert_eq!(sniff(b"RIFF\0\0\0\0WEBPVP8 "), Some("webp")); + assert_eq!(sniff(b""), None); +} diff --git a/src/remote/scrobblers/lastfm/mod.rs b/src/remote/scrobblers/lastfm/mod.rs index 1730de8f..85113a31 100644 --- a/src/remote/scrobblers/lastfm/mod.rs +++ b/src/remote/scrobblers/lastfm/mod.rs @@ -551,242 +551,4 @@ fn status_verdict(status: reqwest::StatusCode, retry_after: Option) -> } #[cfg(test)] -mod tests { - use super::*; - - fn an_envelope() -> ScrobbleEnvelope { - ScrobbleEnvelope { - played_at: 1_700_000_123_456, - title: "Zenith".to_owned(), - artists: vec!["Nova Kern".to_owned(), "Juno Vale".to_owned()], - album: Some("Convergence".to_owned()), - album_artist: Some("Nova Kern".to_owned()), - duration_ms: Some(245_678), - musicbrainz_recording_id: Some("d2b9c1f0-0000-4000-8000-000000000001".to_owned()), - } - } - - /// The signature is Last.fm's own recipe, checked against a value computed - /// by hand from it. - /// - /// Sorted by name, name and value concatenated with nothing between them, - /// the shared secret appended, MD5 of the UTF-8 bytes. Every one of those is - /// a place to be wrong in a way no test that only signed and compared - /// against itself would ever notice — which is why the expectation here is a - /// literal rather than a second call to `signature`. - #[test] - fn the_signature_follows_the_recipe_last_fm_publishes() { - use md5::Digest as _; - let params = [ - ("method", "auth.getSession".to_owned()), - ("api_key", "the-key".to_owned()), - ("token", "the-token".to_owned()), - ]; - // "api_keythe-keymethodauth.getSessiontokenthe-token" + secret. - let expected = hex::encode(md5::Md5::digest( - b"api_keythe-keymethodauth.getSessiontokenthe-tokenthe-secret", - )); - assert_eq!(signature(¶ms, "the-secret"), expected); - } - - /// `format` and `callback` are outside the signature. - /// - /// Last.fm's rule, and it is not cosmetic: including either produces a - /// signature they refuse, and the failure would arrive as an authentication - /// error that looks exactly like a bad session key. - #[test] - fn the_shape_of_the_answer_is_not_part_of_the_signature() { - let bare = [("method", "track.scrobble".to_owned())]; - let dressed = [ - ("method", "track.scrobble".to_owned()), - ("format", "json".to_owned()), - ("callback", "https://example.test/back".to_owned()), - ]; - assert_eq!( - signature(&bare, "the-secret"), - signature(&dressed, "the-secret") - ); - } - - /// The order the parameters were built in does not reach the signature. - #[test] - fn the_signature_sorts_before_it_concatenates() { - let one_way = [ - ("track", "Zenith".to_owned()), - ("artist", "Nova Kern".to_owned()), - ]; - let the_other = [ - ("artist", "Nova Kern".to_owned()), - ("track", "Zenith".to_owned()), - ]; - assert_eq!( - signature(&one_way, "the-secret"), - signature(&the_other, "the-secret") - ); - } - - /// The instants are seconds, and every credit is carried. - #[test] - fn the_submission_carries_seconds_and_every_credit() { - let params = scrobble_params(&an_envelope(), "a-session-key"); - let value = |name: &str| { - params - .iter() - .find(|(key, _)| *key == name) - .map(|(_, value)| value.as_str()) - }; - assert_eq!(value("timestamp"), Some("1700000123")); - assert_eq!(value("duration"), Some("245")); - assert_eq!(value("artist"), Some("Nova Kern, Juno Vale")); - assert_eq!(value("track"), Some("Zenith")); - assert_eq!(value("album"), Some("Convergence")); - assert_eq!(value("albumArtist"), Some("Nova Kern")); - assert_eq!(value("sk"), Some("a-session-key")); - } - - /// What this server does not know is not sent. - #[test] - fn what_is_unknown_is_left_out_rather_than_sent_empty() { - let bare = ScrobbleEnvelope { - album: None, - album_artist: None, - duration_ms: None, - musicbrainz_recording_id: None, - ..an_envelope() - }; - let params = scrobble_params(&bare, "a-session-key"); - for absent in ["album", "albumArtist", "duration", "mbid"] { - assert!( - !params.iter().any(|(name, _)| *name == absent), - "{absent} must not be sent" - ); - } - } - - /// The endpoint keeps the trailing slash Last.fm documents. - /// - /// `/2.0` answers a redirect, and this client follows none: without the - /// slash every call would fail, and the adapter would read that failure as - /// the destination having moved. - #[test] - fn the_endpoint_keeps_the_slash_last_fm_answers_on() { - let base = super::super::validate_destination("https://ws.audioscrobbler.com", false) - .expect("a valid destination"); - let client = super::super::outbound_client(Duration::from_secs(5)).expect("a client"); - let application = crate::config::LastFmApplication { - api_key: "a-key".to_owned(), - secret: "a-secret".to_owned(), - }; - let target = - LastFm::new(client, &base, application, Duration::from_secs(5)).expect("an adapter"); - assert_eq!( - target.endpoint.as_str(), - "https://ws.audioscrobbler.com/2.0/" - ); - } - - /// A status line, and nothing else, decides. - #[test] - fn the_status_line_earns_the_verdict() { - assert!(matches!( - status_verdict(reqwest::StatusCode::OK, None), - ScrobbleVerdict::Accepted - )); - assert!(matches!( - status_verdict(reqwest::StatusCode::FORBIDDEN, None), - ScrobbleVerdict::AuthBroken - )); - assert!(matches!( - status_verdict(reqwest::StatusCode::BAD_REQUEST, None), - ScrobbleVerdict::PermanentReject - )); - assert!(matches!( - status_verdict(reqwest::StatusCode::TOO_MANY_REQUESTS, None), - ScrobbleVerdict::Retryable { - after: Some(Duration::ZERO) - } - )); - } - - /// An adapter aimed at a listener of the test's own. - async fn reaching(body: axum::body::Body) -> (LastFm, tokio::task::JoinHandle<()>) { - let (url, server) = crate::remote::outbound::tests::answering(body).await; - let base = super::super::validate_destination(&url, true).expect("a destination"); - let client = super::super::outbound_client(Duration::from_secs(5)).expect("a client"); - let application = crate::config::LastFmApplication { - api_key: uuid::Uuid::new_v4().to_string(), - secret: uuid::Uuid::new_v4().to_string(), - }; - let target = - LastFm::new(client, &base, application, Duration::from_secs(10)).expect("an adapter"); - (target, server) - } - - /// A session answer that is well formed and says what it should, but is - /// padded past the ceiling, is not read: the exchange fails as an outage - /// rather than holding the whole body (RFC-023 decision 1). - /// - /// Finite on purpose, and parseable: without the ceiling this same answer - /// is read and yields its key, so the test tells the ceiling from its - /// absence by the outcome and not only by the clock. - #[tokio::test] - async fn a_session_answer_over_the_ceiling_is_not_read() { - use crate::services::{LastFmSessionExchange, ServiceError}; - let key = uuid::Uuid::new_v4().simple().to_string(); - let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); - let (target, server) = reaching(axum::body::Body::from(format!( - "{{\"session\":{{\"key\":\"{key}\"}}{padding}}}" - ))) - .await; - let token = uuid::Uuid::new_v4().to_string(); - let exchanged = target.exchange(&token).await; - server.abort(); - assert!( - matches!(exchanged, Err(ServiceError::Unavailable)), - "an oversized answer must not be read" - ); - - // And the same answer within the ceiling is read, so the refusal above - // is the ceiling's and not the listener's. - let (target, server) = reaching(axum::body::Body::from(format!( - "{{\"session\":{{\"key\":\"{key}\"}}}}" - ))) - .await; - let exchanged = target.exchange(&token).await; - server.abort(); - assert_eq!(exchanged.ok().as_deref(), Some(key.as_str())); - } - - /// The same for the token the CLI journey asks for first. - #[tokio::test] - async fn a_token_answer_over_the_ceiling_is_not_read() { - use crate::services::{LastFmSessionExchange, ServiceError}; - let token = uuid::Uuid::new_v4().simple().to_string(); - let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); - let (target, server) = reaching(axum::body::Body::from(format!( - "{{\"token\":\"{token}\"{padding}}}" - ))) - .await; - let minted = target.request_token().await; - server.abort(); - assert!(matches!(minted, Err(ServiceError::Unavailable))); - } - - /// A listen whose acknowledgement is over the ceiling is judged by its - /// status line, as one that will not parse is. - /// - /// The body says `"error": 9`, a dead session key, so a reader without the - /// ceiling breaks the link over it; that difference is what this tells - /// apart. - #[tokio::test] - async fn a_scrobble_answer_over_the_ceiling_leaves_the_status_line_deciding() { - let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); - let (target, server) = - reaching(axum::body::Body::from(format!("{{\"error\":9{padding}}}"))).await; - let envelope = an_envelope(); - let session_key = uuid::Uuid::new_v4().to_string(); - let verdict = target.submit(&envelope, &session_key).await; - server.abort(); - assert!(matches!(verdict, ScrobbleVerdict::Accepted)); - } -} +mod tests; diff --git a/src/remote/scrobblers/lastfm/tests.rs b/src/remote/scrobblers/lastfm/tests.rs new file mode 100644 index 00000000..dcac63fa --- /dev/null +++ b/src/remote/scrobblers/lastfm/tests.rs @@ -0,0 +1,237 @@ +use super::*; + +fn an_envelope() -> ScrobbleEnvelope { + ScrobbleEnvelope { + played_at: 1_700_000_123_456, + title: "Zenith".to_owned(), + artists: vec!["Nova Kern".to_owned(), "Juno Vale".to_owned()], + album: Some("Convergence".to_owned()), + album_artist: Some("Nova Kern".to_owned()), + duration_ms: Some(245_678), + musicbrainz_recording_id: Some("d2b9c1f0-0000-4000-8000-000000000001".to_owned()), + } +} + +/// The signature is Last.fm's own recipe, checked against a value computed +/// by hand from it. +/// +/// Sorted by name, name and value concatenated with nothing between them, +/// the shared secret appended, MD5 of the UTF-8 bytes. Every one of those is +/// a place to be wrong in a way no test that only signed and compared +/// against itself would ever notice — which is why the expectation here is a +/// literal rather than a second call to `signature`. +#[test] +fn the_signature_follows_the_recipe_last_fm_publishes() { + use md5::Digest as _; + let params = [ + ("method", "auth.getSession".to_owned()), + ("api_key", "the-key".to_owned()), + ("token", "the-token".to_owned()), + ]; + // "api_keythe-keymethodauth.getSessiontokenthe-token" + secret. + let expected = hex::encode(md5::Md5::digest( + b"api_keythe-keymethodauth.getSessiontokenthe-tokenthe-secret", + )); + assert_eq!(signature(¶ms, "the-secret"), expected); +} + +/// `format` and `callback` are outside the signature. +/// +/// Last.fm's rule, and it is not cosmetic: including either produces a +/// signature they refuse, and the failure would arrive as an authentication +/// error that looks exactly like a bad session key. +#[test] +fn the_shape_of_the_answer_is_not_part_of_the_signature() { + let bare = [("method", "track.scrobble".to_owned())]; + let dressed = [ + ("method", "track.scrobble".to_owned()), + ("format", "json".to_owned()), + ("callback", "https://example.test/back".to_owned()), + ]; + assert_eq!( + signature(&bare, "the-secret"), + signature(&dressed, "the-secret") + ); +} + +/// The order the parameters were built in does not reach the signature. +#[test] +fn the_signature_sorts_before_it_concatenates() { + let one_way = [ + ("track", "Zenith".to_owned()), + ("artist", "Nova Kern".to_owned()), + ]; + let the_other = [ + ("artist", "Nova Kern".to_owned()), + ("track", "Zenith".to_owned()), + ]; + assert_eq!( + signature(&one_way, "the-secret"), + signature(&the_other, "the-secret") + ); +} + +/// The instants are seconds, and every credit is carried. +#[test] +fn the_submission_carries_seconds_and_every_credit() { + let params = scrobble_params(&an_envelope(), "a-session-key"); + let value = |name: &str| { + params + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| value.as_str()) + }; + assert_eq!(value("timestamp"), Some("1700000123")); + assert_eq!(value("duration"), Some("245")); + assert_eq!(value("artist"), Some("Nova Kern, Juno Vale")); + assert_eq!(value("track"), Some("Zenith")); + assert_eq!(value("album"), Some("Convergence")); + assert_eq!(value("albumArtist"), Some("Nova Kern")); + assert_eq!(value("sk"), Some("a-session-key")); +} + +/// What this server does not know is not sent. +#[test] +fn what_is_unknown_is_left_out_rather_than_sent_empty() { + let bare = ScrobbleEnvelope { + album: None, + album_artist: None, + duration_ms: None, + musicbrainz_recording_id: None, + ..an_envelope() + }; + let params = scrobble_params(&bare, "a-session-key"); + for absent in ["album", "albumArtist", "duration", "mbid"] { + assert!( + !params.iter().any(|(name, _)| *name == absent), + "{absent} must not be sent" + ); + } +} + +/// The endpoint keeps the trailing slash Last.fm documents. +/// +/// `/2.0` answers a redirect, and this client follows none: without the +/// slash every call would fail, and the adapter would read that failure as +/// the destination having moved. +#[test] +fn the_endpoint_keeps_the_slash_last_fm_answers_on() { + let base = super::super::validate_destination("https://ws.audioscrobbler.com", false) + .expect("a valid destination"); + let client = super::super::outbound_client(Duration::from_secs(5)).expect("a client"); + let application = crate::config::LastFmApplication { + api_key: "a-key".to_owned(), + secret: "a-secret".to_owned(), + }; + let target = + LastFm::new(client, &base, application, Duration::from_secs(5)).expect("an adapter"); + assert_eq!( + target.endpoint.as_str(), + "https://ws.audioscrobbler.com/2.0/" + ); +} + +/// A status line, and nothing else, decides. +#[test] +fn the_status_line_earns_the_verdict() { + assert!(matches!( + status_verdict(reqwest::StatusCode::OK, None), + ScrobbleVerdict::Accepted + )); + assert!(matches!( + status_verdict(reqwest::StatusCode::FORBIDDEN, None), + ScrobbleVerdict::AuthBroken + )); + assert!(matches!( + status_verdict(reqwest::StatusCode::BAD_REQUEST, None), + ScrobbleVerdict::PermanentReject + )); + assert!(matches!( + status_verdict(reqwest::StatusCode::TOO_MANY_REQUESTS, None), + ScrobbleVerdict::Retryable { + after: Some(Duration::ZERO) + } + )); +} + +/// An adapter aimed at a listener of the test's own. +async fn reaching(body: axum::body::Body) -> (LastFm, tokio::task::JoinHandle<()>) { + let (url, server) = crate::remote::outbound::tests::answering(body).await; + let base = super::super::validate_destination(&url, true).expect("a destination"); + let client = super::super::outbound_client(Duration::from_secs(5)).expect("a client"); + let application = crate::config::LastFmApplication { + api_key: uuid::Uuid::new_v4().to_string(), + secret: uuid::Uuid::new_v4().to_string(), + }; + let target = + LastFm::new(client, &base, application, Duration::from_secs(10)).expect("an adapter"); + (target, server) +} + +/// A session answer that is well formed and says what it should, but is +/// padded past the ceiling, is not read: the exchange fails as an outage +/// rather than holding the whole body (RFC-023 decision 1). +/// +/// Finite on purpose, and parseable: without the ceiling this same answer +/// is read and yields its key, so the test tells the ceiling from its +/// absence by the outcome and not only by the clock. +#[tokio::test] +async fn a_session_answer_over_the_ceiling_is_not_read() { + use crate::services::{LastFmSessionExchange, ServiceError}; + let key = uuid::Uuid::new_v4().simple().to_string(); + let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); + let (target, server) = reaching(axum::body::Body::from(format!( + "{{\"session\":{{\"key\":\"{key}\"}}{padding}}}" + ))) + .await; + let token = uuid::Uuid::new_v4().to_string(); + let exchanged = target.exchange(&token).await; + server.abort(); + assert!( + matches!(exchanged, Err(ServiceError::Unavailable)), + "an oversized answer must not be read" + ); + + // And the same answer within the ceiling is read, so the refusal above + // is the ceiling's and not the listener's. + let (target, server) = reaching(axum::body::Body::from(format!( + "{{\"session\":{{\"key\":\"{key}\"}}}}" + ))) + .await; + let exchanged = target.exchange(&token).await; + server.abort(); + assert_eq!(exchanged.ok().as_deref(), Some(key.as_str())); +} + +/// The same for the token the CLI journey asks for first. +#[tokio::test] +async fn a_token_answer_over_the_ceiling_is_not_read() { + use crate::services::{LastFmSessionExchange, ServiceError}; + let token = uuid::Uuid::new_v4().simple().to_string(); + let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); + let (target, server) = reaching(axum::body::Body::from(format!( + "{{\"token\":\"{token}\"{padding}}}" + ))) + .await; + let minted = target.request_token().await; + server.abort(); + assert!(matches!(minted, Err(ServiceError::Unavailable))); +} + +/// A listen whose acknowledgement is over the ceiling is judged by its +/// status line, as one that will not parse is. +/// +/// The body says `"error": 9`, a dead session key, so a reader without the +/// ceiling breaks the link over it; that difference is what this tells +/// apart. +#[tokio::test] +async fn a_scrobble_answer_over_the_ceiling_leaves_the_status_line_deciding() { + let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); + let (target, server) = + reaching(axum::body::Body::from(format!("{{\"error\":9{padding}}}"))).await; + let envelope = an_envelope(); + let session_key = uuid::Uuid::new_v4().to_string(); + let verdict = target.submit(&envelope, &session_key).await; + server.abort(); + assert!(matches!(verdict, ScrobbleVerdict::Accepted)); +} diff --git a/src/remote/scrobblers/listenbrainz/mod.rs b/src/remote/scrobblers/listenbrainz/mod.rs index dc26d59c..8474d0fd 100644 --- a/src/remote/scrobblers/listenbrainz/mod.rs +++ b/src/remote/scrobblers/listenbrainz/mod.rs @@ -299,165 +299,4 @@ fn reset_in(response: &reqwest::Response) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - fn envelope() -> ScrobbleEnvelope { - ScrobbleEnvelope { - // 2026-09-13T11:32:42Z, and the millisecond remainder is there on - // purpose: it must not survive the conversion. - played_at: 1_789_648_362_750, - title: "Never Gonna Give You Up".into(), - artists: vec!["Rick Astley".into(), "The Guest".into()], - album: Some("Whenever You Need Somebody".into()), - album_artist: Some("Rick Astley".into()), - duration_ms: Some(222_000), - musicbrainz_recording_id: Some("98255a8c-017a-4bc7-8dd6-1fa36124572b".into()), - } - } - - /// The one conversion a listening history would keep forever if it were - /// wrong. - #[test] - fn a_listen_is_dated_in_seconds_and_not_milliseconds() { - assert_eq!(played_at_seconds(1_789_648_362_750), 1_789_648_362); - // Truncation towards zero would answer 0 here and -1 is correct; no - // listen predates 1970, which is why this is worth pinning rather than - // assuming. - assert_eq!(played_at_seconds(-1), -1); - - let body = serde_json::to_value(submission(&envelope())).unwrap(); - assert_eq!(body["payload"][0]["listened_at"], 1_789_648_362); - } - - #[test] - fn the_body_is_the_shape_listenbrainz_documents() { - let body = serde_json::to_value(submission(&envelope())).unwrap(); - assert_eq!(body["listen_type"], "single"); - assert_eq!(body["payload"].as_array().unwrap().len(), 1); - - let metadata = &body["payload"][0]["track_metadata"]; - assert_eq!(metadata["track_name"], "Never Gonna Give You Up"); - assert_eq!(metadata["release_name"], "Whenever You Need Somebody"); - // Every credit, in tag order: the envelope records what was heard, and - // a guest on the track was heard. - assert_eq!(metadata["artist_name"], "Rick Astley, The Guest"); - assert_eq!(metadata["additional_info"]["duration_ms"], 222_000); - assert_eq!( - metadata["additional_info"]["recording_mbid"], - "98255a8c-017a-4bc7-8dd6-1fa36124572b" - ); - } - - /// An absent field is absent, rather than present and null: the destination - /// reads `additional_info` as a set of hints, and a null hint is a claim. - #[test] - fn what_the_listen_does_not_know_is_left_out_entirely() { - let bare = ScrobbleEnvelope { - album: None, - duration_ms: None, - musicbrainz_recording_id: None, - ..envelope() - }; - let body = serde_json::to_value(submission(&bare)).unwrap(); - let metadata = &body["payload"][0]["track_metadata"]; - assert!(metadata.get("release_name").is_none()); - assert!(metadata["additional_info"].get("duration_ms").is_none()); - assert!(metadata["additional_info"].get("recording_mbid").is_none()); - // And what it always knows stays. - assert_eq!( - metadata["additional_info"]["submission_client"], - CLIENT_NAME - ); - } - - /// A rate limit may ask in either of the two forms RFC 9110 allows — and - /// two more that are not a request to wait at all. - /// - /// Decision 6 says a delay the destination asks for is honoured. Only - /// ListenBrainz's own vendor header was read, so a `429` from the nginx or - /// the CDN in front of a self-hosted instance — which answers with the - /// standard header — was heard as "no delay given". - #[test] - fn a_rate_limit_may_ask_in_seconds_or_in_a_date() { - fn answered(header: &str, value: &str) -> Option { - let response = axum::http::Response::builder() - .status(429) - .header(header, value) - .body(String::new()) - .unwrap(); - asked_wait(&reqwest::Response::from(response)) - } - - // The vendor's header, in whole seconds. - assert_eq!( - answered(RESET_IN_HEADER, "12"), - Some(Duration::from_secs(12)) - ); - // The standard one, as delta-seconds. - assert_eq!(answered("retry-after", "30"), Some(Duration::from_secs(30))); - - // And as an HTTP date, which is the form a proxy answers with. Read as - // a distance from now, so it is asserted as a window rather than a - // value: the clock moves between building the header and reading it. - let soon = chrono::Utc::now() + chrono::Duration::seconds(120); - let asked = answered("retry-after", &soon.to_rfc2822()).expect("a future date is a wait"); - assert!( - asked > Duration::from_secs(110) && asked <= Duration::from_secs(120), - "a date two minutes out must read as about two minutes, got {asked:?}" - ); - - // A moment already gone is not a request to wait, and neither is a - // header nobody can parse. Both read as absent — which sends the entry - // to the queue's own backoff, not to an immediate retry. - let past = chrono::Utc::now() - chrono::Duration::seconds(120); - assert_eq!(answered("retry-after", &past.to_rfc2822()), None); - assert_eq!(answered("retry-after", "in a little while"), None); - // RFC 9110's delta-seconds is `1*DIGIT`, so a signed number is not one. - // `u64::from_str` accepts a leading `+`, which read `+30` as a wait - // nobody had spelled in a form this is allowed to understand. - assert_eq!(answered("retry-after", "+30"), None); - assert_eq!(answered("x-unrelated", "30"), None); - } - - #[test] - fn each_answer_earns_the_verdict_the_queue_can_act_on() { - use reqwest::StatusCode; - let no_wait = None; - - assert_eq!( - status_verdict(StatusCode::OK, no_wait), - ScrobbleVerdict::Accepted - ); - // A bad token breaks the link rather than the listen. - assert_eq!( - status_verdict(StatusCode::UNAUTHORIZED, no_wait), - ScrobbleVerdict::AuthBroken - ); - // A refused payload is terminal: no retry can fix it. - assert_eq!( - status_verdict(StatusCode::BAD_REQUEST, no_wait), - ScrobbleVerdict::PermanentReject - ); - // And a rate limit carries the destination's own answer back, exactly - // as it was given — bounding it is the queue's business, not this - // function's. - assert_eq!( - status_verdict(StatusCode::TOO_MANY_REQUESTS, Some(Duration::from_secs(12))), - ScrobbleVerdict::Retryable { - after: Some(Duration::from_secs(12)) - } - ); - // A server fault says nothing about the listen. - assert_eq!( - status_verdict(StatusCode::BAD_GATEWAY, no_wait), - ScrobbleVerdict::Retryable { after: None } - ); - // A redirect is not followed, so it reaches here: the operator's - // destination moved, and the listen waits for them to notice. - assert_eq!( - status_verdict(StatusCode::FOUND, no_wait), - ScrobbleVerdict::Retryable { after: None } - ); - } -} +mod tests; diff --git a/src/remote/scrobblers/listenbrainz/tests.rs b/src/remote/scrobblers/listenbrainz/tests.rs new file mode 100644 index 00000000..7bb3a90d --- /dev/null +++ b/src/remote/scrobblers/listenbrainz/tests.rs @@ -0,0 +1,160 @@ +use super::*; + +fn envelope() -> ScrobbleEnvelope { + ScrobbleEnvelope { + // 2026-09-13T11:32:42Z, and the millisecond remainder is there on + // purpose: it must not survive the conversion. + played_at: 1_789_648_362_750, + title: "Never Gonna Give You Up".into(), + artists: vec!["Rick Astley".into(), "The Guest".into()], + album: Some("Whenever You Need Somebody".into()), + album_artist: Some("Rick Astley".into()), + duration_ms: Some(222_000), + musicbrainz_recording_id: Some("98255a8c-017a-4bc7-8dd6-1fa36124572b".into()), + } +} + +/// The one conversion a listening history would keep forever if it were +/// wrong. +#[test] +fn a_listen_is_dated_in_seconds_and_not_milliseconds() { + assert_eq!(played_at_seconds(1_789_648_362_750), 1_789_648_362); + // Truncation towards zero would answer 0 here and -1 is correct; no + // listen predates 1970, which is why this is worth pinning rather than + // assuming. + assert_eq!(played_at_seconds(-1), -1); + + let body = serde_json::to_value(submission(&envelope())).unwrap(); + assert_eq!(body["payload"][0]["listened_at"], 1_789_648_362); +} + +#[test] +fn the_body_is_the_shape_listenbrainz_documents() { + let body = serde_json::to_value(submission(&envelope())).unwrap(); + assert_eq!(body["listen_type"], "single"); + assert_eq!(body["payload"].as_array().unwrap().len(), 1); + + let metadata = &body["payload"][0]["track_metadata"]; + assert_eq!(metadata["track_name"], "Never Gonna Give You Up"); + assert_eq!(metadata["release_name"], "Whenever You Need Somebody"); + // Every credit, in tag order: the envelope records what was heard, and + // a guest on the track was heard. + assert_eq!(metadata["artist_name"], "Rick Astley, The Guest"); + assert_eq!(metadata["additional_info"]["duration_ms"], 222_000); + assert_eq!( + metadata["additional_info"]["recording_mbid"], + "98255a8c-017a-4bc7-8dd6-1fa36124572b" + ); +} + +/// An absent field is absent, rather than present and null: the destination +/// reads `additional_info` as a set of hints, and a null hint is a claim. +#[test] +fn what_the_listen_does_not_know_is_left_out_entirely() { + let bare = ScrobbleEnvelope { + album: None, + duration_ms: None, + musicbrainz_recording_id: None, + ..envelope() + }; + let body = serde_json::to_value(submission(&bare)).unwrap(); + let metadata = &body["payload"][0]["track_metadata"]; + assert!(metadata.get("release_name").is_none()); + assert!(metadata["additional_info"].get("duration_ms").is_none()); + assert!(metadata["additional_info"].get("recording_mbid").is_none()); + // And what it always knows stays. + assert_eq!( + metadata["additional_info"]["submission_client"], + CLIENT_NAME + ); +} + +/// A rate limit may ask in either of the two forms RFC 9110 allows — and +/// two more that are not a request to wait at all. +/// +/// Decision 6 says a delay the destination asks for is honoured. Only +/// ListenBrainz's own vendor header was read, so a `429` from the nginx or +/// the CDN in front of a self-hosted instance — which answers with the +/// standard header — was heard as "no delay given". +#[test] +fn a_rate_limit_may_ask_in_seconds_or_in_a_date() { + fn answered(header: &str, value: &str) -> Option { + let response = axum::http::Response::builder() + .status(429) + .header(header, value) + .body(String::new()) + .unwrap(); + asked_wait(&reqwest::Response::from(response)) + } + + // The vendor's header, in whole seconds. + assert_eq!( + answered(RESET_IN_HEADER, "12"), + Some(Duration::from_secs(12)) + ); + // The standard one, as delta-seconds. + assert_eq!(answered("retry-after", "30"), Some(Duration::from_secs(30))); + + // And as an HTTP date, which is the form a proxy answers with. Read as + // a distance from now, so it is asserted as a window rather than a + // value: the clock moves between building the header and reading it. + let soon = chrono::Utc::now() + chrono::Duration::seconds(120); + let asked = answered("retry-after", &soon.to_rfc2822()).expect("a future date is a wait"); + assert!( + asked > Duration::from_secs(110) && asked <= Duration::from_secs(120), + "a date two minutes out must read as about two minutes, got {asked:?}" + ); + + // A moment already gone is not a request to wait, and neither is a + // header nobody can parse. Both read as absent — which sends the entry + // to the queue's own backoff, not to an immediate retry. + let past = chrono::Utc::now() - chrono::Duration::seconds(120); + assert_eq!(answered("retry-after", &past.to_rfc2822()), None); + assert_eq!(answered("retry-after", "in a little while"), None); + // RFC 9110's delta-seconds is `1*DIGIT`, so a signed number is not one. + // `u64::from_str` accepts a leading `+`, which read `+30` as a wait + // nobody had spelled in a form this is allowed to understand. + assert_eq!(answered("retry-after", "+30"), None); + assert_eq!(answered("x-unrelated", "30"), None); +} + +#[test] +fn each_answer_earns_the_verdict_the_queue_can_act_on() { + use reqwest::StatusCode; + let no_wait = None; + + assert_eq!( + status_verdict(StatusCode::OK, no_wait), + ScrobbleVerdict::Accepted + ); + // A bad token breaks the link rather than the listen. + assert_eq!( + status_verdict(StatusCode::UNAUTHORIZED, no_wait), + ScrobbleVerdict::AuthBroken + ); + // A refused payload is terminal: no retry can fix it. + assert_eq!( + status_verdict(StatusCode::BAD_REQUEST, no_wait), + ScrobbleVerdict::PermanentReject + ); + // And a rate limit carries the destination's own answer back, exactly + // as it was given — bounding it is the queue's business, not this + // function's. + assert_eq!( + status_verdict(StatusCode::TOO_MANY_REQUESTS, Some(Duration::from_secs(12))), + ScrobbleVerdict::Retryable { + after: Some(Duration::from_secs(12)) + } + ); + // A server fault says nothing about the listen. + assert_eq!( + status_verdict(StatusCode::BAD_GATEWAY, no_wait), + ScrobbleVerdict::Retryable { after: None } + ); + // A redirect is not followed, so it reaches here: the operator's + // destination moved, and the listen waits for them to notice. + assert_eq!( + status_verdict(StatusCode::FOUND, no_wait), + ScrobbleVerdict::Retryable { after: None } + ); +} diff --git a/src/remote/scrobblers/maloja/mod.rs b/src/remote/scrobblers/maloja/mod.rs index 0868e378..6856f1a1 100644 --- a/src/remote/scrobblers/maloja/mod.rs +++ b/src/remote/scrobblers/maloja/mod.rs @@ -256,160 +256,4 @@ fn status_verdict(status: reqwest::StatusCode, retry_after: Option) -> } #[cfg(test)] -mod tests { - use super::*; - - fn an_envelope() -> ScrobbleEnvelope { - ScrobbleEnvelope { - played_at: 1_700_000_123_456, - title: "Zenith".to_owned(), - artists: vec!["Nova Kern".to_owned(), "Juno Vale".to_owned()], - album: Some("Convergence".to_owned()), - album_artist: Some("Nova Kern".to_owned()), - duration_ms: Some(245_678), - musicbrainz_recording_id: Some("d2b9c1f0-0000-4000-8000-000000000001".to_owned()), - } - } - - /// Every credit arrives as a credit. - /// - /// This is the whole reason decision 11 puts Maloja second. A body that - /// joined the artists would work, and would quietly turn a duo into a band - /// nobody has heard of in somebody's statistics. - #[test] - fn the_envelope_pours_in_without_losing_a_credit() { - let envelope = an_envelope(); - let body = serde_json::to_value(submission(&envelope, "a-key")).unwrap(); - assert_eq!( - body["artists"], - serde_json::json!(["Nova Kern", "Juno Vale"]) - ); - assert_eq!(body["albumartists"], serde_json::json!(["Nova Kern"])); - assert_eq!(body["album"], "Convergence"); - assert_eq!(body["title"], "Zenith"); - } - - /// Both instants are seconds, and both are truncated the same way. - /// - /// Milliseconds handed over unconverted would date every listen about fifty - /// thousand years from now, and a listening history keeps that for good. - #[test] - fn the_instants_are_seconds_and_not_milliseconds() { - let envelope = an_envelope(); - let body = serde_json::to_value(submission(&envelope, "a-key")).unwrap(); - assert_eq!(body["time"], 1_700_000_123); - assert_eq!(body["length"], 245); - - // `div_euclid`, so an instant before 1970 rounds down rather than - // towards zero. No listen has one; the point is not to depend on that. - let ancient = ScrobbleEnvelope { - played_at: -1_500, - ..an_envelope() - }; - let body = serde_json::to_value(submission(&ancient, "a-key")).unwrap(); - assert_eq!(body["time"], -2); - } - - /// What this server does not know is not sent. - /// - /// An absent album and an absent duration are absent, not empty: a - /// destination matching on `album` treats `""` as a title, and Maloja's - /// `duration` is a listened length this server never measured. - #[test] - fn what_is_unknown_is_left_out_rather_than_sent_empty() { - let bare = ScrobbleEnvelope { - album: None, - album_artist: None, - duration_ms: None, - ..an_envelope() - }; - let body = serde_json::to_value(submission(&bare, "a-key")).unwrap(); - let object = body.as_object().unwrap(); - for absent in ["album", "albumartists", "length", "duration"] { - assert!(!object.contains_key(absent), "{absent} must not be sent"); - } - // And what is always known is always sent. - for present in ["key", "title", "artists", "time"] { - assert!(object.contains_key(present), "{present} must be sent"); - } - } - - /// The body carries a secret, so it does not survive being formatted. - /// - /// The failure message deliberately carries neither the key nor the - /// formatted output: if this guard were removed, that output would *be* the - /// credential, and a panic message is a log line. - #[test] - fn a_submission_does_not_print_its_key() { - let envelope = an_envelope(); - let shown = format!("{:?}", submission(&envelope, "a-key-nobody-should-read")); - assert!( - !shown.contains("a-key-nobody-should-read"), - "the debug output carried the key" - ); - assert!(shown.contains("[redacted]"), "the key was not replaced"); - } - - /// A status line, and nothing else, decides. - #[test] - fn the_status_line_earns_the_verdict() { - assert!(matches!( - status_verdict(reqwest::StatusCode::OK, None), - ScrobbleVerdict::Accepted - )); - for refused in [401, 403] { - assert!( - matches!( - status_verdict(reqwest::StatusCode::from_u16(refused).unwrap(), None), - ScrobbleVerdict::AuthBroken - ), - "{refused}" - ); - } - for refused in [400, 413, 422] { - assert!( - matches!( - status_verdict(reqwest::StatusCode::from_u16(refused).unwrap(), None), - ScrobbleVerdict::PermanentReject - ), - "{refused}" - ); - } - // A rate limit with no readable header still rests the link: the status - // says room was asked for, the header only says how much. - assert!(matches!( - status_verdict(reqwest::StatusCode::TOO_MANY_REQUESTS, None), - ScrobbleVerdict::Retryable { - after: Some(Duration::ZERO) - } - )); - // A redirect is not followed, and means the destination moved. - assert!(matches!( - status_verdict(reqwest::StatusCode::FOUND, None), - ScrobbleVerdict::Retryable { after: None } - )); - } - - /// A `200` whose body is over the ceiling is not read, and the status line - /// decides (RFC-023 decision 1). - /// - /// The body is the one refusal this adapter reads, padded: a reader - /// without the ceiling reads all of it and rejects the listen for good, - /// which is the difference this tells apart. - #[tokio::test] - async fn an_answer_over_the_ceiling_leaves_the_status_line_deciding() { - let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); - let (url, server) = crate::remote::outbound::tests::answering(axum::body::Body::from( - format!("{{\"status\":\"failure\"{padding}}}"), - )) - .await; - let base = super::super::validate_destination(&url, true).expect("a destination"); - let client = super::super::outbound_client(Duration::from_secs(5)).expect("a client"); - let target = Maloja::new(client, &base).expect("an adapter"); - let secret = uuid::Uuid::new_v4().to_string(); - let envelope = an_envelope(); - let verdict = target.submit(&envelope, &secret).await; - server.abort(); - assert!(matches!(verdict, ScrobbleVerdict::Accepted)); - } -} +mod tests; diff --git a/src/remote/scrobblers/maloja/tests.rs b/src/remote/scrobblers/maloja/tests.rs new file mode 100644 index 00000000..abb14917 --- /dev/null +++ b/src/remote/scrobblers/maloja/tests.rs @@ -0,0 +1,155 @@ +use super::*; + +fn an_envelope() -> ScrobbleEnvelope { + ScrobbleEnvelope { + played_at: 1_700_000_123_456, + title: "Zenith".to_owned(), + artists: vec!["Nova Kern".to_owned(), "Juno Vale".to_owned()], + album: Some("Convergence".to_owned()), + album_artist: Some("Nova Kern".to_owned()), + duration_ms: Some(245_678), + musicbrainz_recording_id: Some("d2b9c1f0-0000-4000-8000-000000000001".to_owned()), + } +} + +/// Every credit arrives as a credit. +/// +/// This is the whole reason decision 11 puts Maloja second. A body that +/// joined the artists would work, and would quietly turn a duo into a band +/// nobody has heard of in somebody's statistics. +#[test] +fn the_envelope_pours_in_without_losing_a_credit() { + let envelope = an_envelope(); + let body = serde_json::to_value(submission(&envelope, "a-key")).unwrap(); + assert_eq!( + body["artists"], + serde_json::json!(["Nova Kern", "Juno Vale"]) + ); + assert_eq!(body["albumartists"], serde_json::json!(["Nova Kern"])); + assert_eq!(body["album"], "Convergence"); + assert_eq!(body["title"], "Zenith"); +} + +/// Both instants are seconds, and both are truncated the same way. +/// +/// Milliseconds handed over unconverted would date every listen about fifty +/// thousand years from now, and a listening history keeps that for good. +#[test] +fn the_instants_are_seconds_and_not_milliseconds() { + let envelope = an_envelope(); + let body = serde_json::to_value(submission(&envelope, "a-key")).unwrap(); + assert_eq!(body["time"], 1_700_000_123); + assert_eq!(body["length"], 245); + + // `div_euclid`, so an instant before 1970 rounds down rather than + // towards zero. No listen has one; the point is not to depend on that. + let ancient = ScrobbleEnvelope { + played_at: -1_500, + ..an_envelope() + }; + let body = serde_json::to_value(submission(&ancient, "a-key")).unwrap(); + assert_eq!(body["time"], -2); +} + +/// What this server does not know is not sent. +/// +/// An absent album and an absent duration are absent, not empty: a +/// destination matching on `album` treats `""` as a title, and Maloja's +/// `duration` is a listened length this server never measured. +#[test] +fn what_is_unknown_is_left_out_rather_than_sent_empty() { + let bare = ScrobbleEnvelope { + album: None, + album_artist: None, + duration_ms: None, + ..an_envelope() + }; + let body = serde_json::to_value(submission(&bare, "a-key")).unwrap(); + let object = body.as_object().unwrap(); + for absent in ["album", "albumartists", "length", "duration"] { + assert!(!object.contains_key(absent), "{absent} must not be sent"); + } + // And what is always known is always sent. + for present in ["key", "title", "artists", "time"] { + assert!(object.contains_key(present), "{present} must be sent"); + } +} + +/// The body carries a secret, so it does not survive being formatted. +/// +/// The failure message deliberately carries neither the key nor the +/// formatted output: if this guard were removed, that output would *be* the +/// credential, and a panic message is a log line. +#[test] +fn a_submission_does_not_print_its_key() { + let envelope = an_envelope(); + let shown = format!("{:?}", submission(&envelope, "a-key-nobody-should-read")); + assert!( + !shown.contains("a-key-nobody-should-read"), + "the debug output carried the key" + ); + assert!(shown.contains("[redacted]"), "the key was not replaced"); +} + +/// A status line, and nothing else, decides. +#[test] +fn the_status_line_earns_the_verdict() { + assert!(matches!( + status_verdict(reqwest::StatusCode::OK, None), + ScrobbleVerdict::Accepted + )); + for refused in [401, 403] { + assert!( + matches!( + status_verdict(reqwest::StatusCode::from_u16(refused).unwrap(), None), + ScrobbleVerdict::AuthBroken + ), + "{refused}" + ); + } + for refused in [400, 413, 422] { + assert!( + matches!( + status_verdict(reqwest::StatusCode::from_u16(refused).unwrap(), None), + ScrobbleVerdict::PermanentReject + ), + "{refused}" + ); + } + // A rate limit with no readable header still rests the link: the status + // says room was asked for, the header only says how much. + assert!(matches!( + status_verdict(reqwest::StatusCode::TOO_MANY_REQUESTS, None), + ScrobbleVerdict::Retryable { + after: Some(Duration::ZERO) + } + )); + // A redirect is not followed, and means the destination moved. + assert!(matches!( + status_verdict(reqwest::StatusCode::FOUND, None), + ScrobbleVerdict::Retryable { after: None } + )); +} + +/// A `200` whose body is over the ceiling is not read, and the status line +/// decides (RFC-023 decision 1). +/// +/// The body is the one refusal this adapter reads, padded: a reader +/// without the ceiling reads all of it and rejects the listen for good, +/// which is the difference this tells apart. +#[tokio::test] +async fn an_answer_over_the_ceiling_leaves_the_status_line_deciding() { + let padding = " ".repeat(crate::remote::outbound::MAX_ANSWER_BYTES); + let (url, server) = crate::remote::outbound::tests::answering(axum::body::Body::from(format!( + "{{\"status\":\"failure\"{padding}}}" + ))) + .await; + let base = super::super::validate_destination(&url, true).expect("a destination"); + let client = super::super::outbound_client(Duration::from_secs(5)).expect("a client"); + let target = Maloja::new(client, &base).expect("an adapter"); + let secret = uuid::Uuid::new_v4().to_string(); + let envelope = an_envelope(); + let verdict = target.submit(&envelope, &secret).await; + server.abort(); + assert!(matches!(verdict, ScrobbleVerdict::Accepted)); +} diff --git a/src/remote/scrobblers/mod.rs b/src/remote/scrobblers/mod.rs index fcf2b480..37e48afb 100644 --- a/src/remote/scrobblers/mod.rs +++ b/src/remote/scrobblers/mod.rs @@ -316,126 +316,4 @@ pub(super) fn retry_after(response: &reqwest::Response) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_destination_must_be_https_unless_the_operator_said_otherwise() { - assert!(validate_destination("https://api.listenbrainz.org", false).is_ok()); - - // The default refuses plaintext, because the request carries a personal - // token and the operator has not said this is their own network. - assert_eq!( - validate_destination("http://maloja.local", false), - Err(OutboundError::NotHttps) - ); - // And permits it when they have. - assert!(validate_destination("http://maloja.local", true).is_ok()); - - // Nothing else is a destination. - for refused in ["ftp://host", "file:///etc/passwd", "not a url"] { - assert!(validate_destination(refused, true).is_err(), "{refused}"); - } - } - - /// The escape reaches exactly as far as the sentence that justifies it. - #[test] - fn plaintext_is_allowed_only_towards_the_operators_own_network() { - // The shapes decision 10 carved it for: a container named on a Docker - // network, the intranet suffixes, and the addresses that cannot be - // routed across the internet. - for own in [ - "http://maloja.local", - "http://listens.internal", - "http://listens.home.arpa", - "http://127.0.0.1:8080", - "http://10.0.0.2", - "http://172.16.4.9", - "http://192.168.1.50", - "http://169.254.7.7", - "http://[::1]:8080", - "http://[fd00::1]", - "http://[fe80::1]", - // The same private addresses written the IPv4-mapped way. The doc - // above promises "their IPv6 equivalents", and an operator who - // spells one like this was getting a boot failure saying their own - // network was public. - "http://[::ffff:127.0.0.1]", - "http://[::ffff:10.0.0.1]", - "http://[::ffff:c0a8:1]", - ] { - assert!(validate_destination(own, true).is_ok(), "{own}"); - } - - // And never a public host, whatever the flag says: the first request - // to it would carry `Authorization: Token …` in clear. - for public in [ - // A bare label is completed by the resolver's search list, so this - // one names whatever `search` says it does — which the string - // itself cannot tell us. It was accepted until a review made the - // point twice. - "http://maloja", - "http://api.listenbrainz.org", - "http://maloja.example.com", - "http://1.1.1.1", - "http://[2606:4700::1111]", - // A trailing dot and a capital spell the same public name. - "http://API.ListenBrainz.ORG.", - // And the mapped form of a public address is still public. - "http://[::ffff:1.1.1.1]", - // A name that is nothing but dots trims to the empty string, which - // has no dot in it and was therefore reading as "ours". Nobody's - // network is called that; accepting it was an accident of the test - // rather than a decision. - "http://.", - "http://..", - ] { - assert_eq!( - validate_destination(public, true), - Err(OutboundError::PlaintextToPublicHost), - "{public}" - ); - } - - // HTTPS reaches all of them, which is the point: the rule is about a - // credential crossing in clear, not about where it is going. - assert!(validate_destination("https://api.listenbrainz.org", false).is_ok()); - } - - #[test] - fn a_destination_carries_no_credentials_query_or_fragment() { - for refused in [ - "https://user:secret@host", - "https://host?token=secret", - "https://host#fragment", - ] { - assert_eq!( - validate_destination(refused, false), - Err(OutboundError::NotABareOrigin), - "{refused}" - ); - } - // A path is fine: a self-hosted instance may live under one. - assert!(validate_destination("https://host/listenbrainz", false).is_ok()); - } - - #[test] - fn an_endpoint_is_appended_and_never_escapes_the_configured_prefix() { - let base = validate_destination("https://host/listenbrainz", false).unwrap(); - assert_eq!( - endpoint(&base, "/1/submit-listens").unwrap().as_str(), - "https://host/listenbrainz/1/submit-listens", - "a leading slash must not replace the operator's prefix" - ); - - // The same answer whether or not the operator typed a trailing slash. - let slashed = validate_destination("https://host/listenbrainz/", false).unwrap(); - assert_eq!( - endpoint(&slashed, "1/submit-listens").unwrap().as_str(), - endpoint(&base, "1/submit-listens").unwrap().as_str() - ); - - // And no path climbs out of it. - assert!(endpoint(&base, "../elsewhere").is_err()); - } -} +mod tests; diff --git a/src/remote/scrobblers/tests.rs b/src/remote/scrobblers/tests.rs new file mode 100644 index 00000000..446a2612 --- /dev/null +++ b/src/remote/scrobblers/tests.rs @@ -0,0 +1,121 @@ +use super::*; + +#[test] +fn a_destination_must_be_https_unless_the_operator_said_otherwise() { + assert!(validate_destination("https://api.listenbrainz.org", false).is_ok()); + + // The default refuses plaintext, because the request carries a personal + // token and the operator has not said this is their own network. + assert_eq!( + validate_destination("http://maloja.local", false), + Err(OutboundError::NotHttps) + ); + // And permits it when they have. + assert!(validate_destination("http://maloja.local", true).is_ok()); + + // Nothing else is a destination. + for refused in ["ftp://host", "file:///etc/passwd", "not a url"] { + assert!(validate_destination(refused, true).is_err(), "{refused}"); + } +} + +/// The escape reaches exactly as far as the sentence that justifies it. +#[test] +fn plaintext_is_allowed_only_towards_the_operators_own_network() { + // The shapes decision 10 carved it for: a container named on a Docker + // network, the intranet suffixes, and the addresses that cannot be + // routed across the internet. + for own in [ + "http://maloja.local", + "http://listens.internal", + "http://listens.home.arpa", + "http://127.0.0.1:8080", + "http://10.0.0.2", + "http://172.16.4.9", + "http://192.168.1.50", + "http://169.254.7.7", + "http://[::1]:8080", + "http://[fd00::1]", + "http://[fe80::1]", + // The same private addresses written the IPv4-mapped way. The doc + // above promises "their IPv6 equivalents", and an operator who + // spells one like this was getting a boot failure saying their own + // network was public. + "http://[::ffff:127.0.0.1]", + "http://[::ffff:10.0.0.1]", + "http://[::ffff:c0a8:1]", + ] { + assert!(validate_destination(own, true).is_ok(), "{own}"); + } + + // And never a public host, whatever the flag says: the first request + // to it would carry `Authorization: Token …` in clear. + for public in [ + // A bare label is completed by the resolver's search list, so this + // one names whatever `search` says it does — which the string + // itself cannot tell us. It was accepted until a review made the + // point twice. + "http://maloja", + "http://api.listenbrainz.org", + "http://maloja.example.com", + "http://1.1.1.1", + "http://[2606:4700::1111]", + // A trailing dot and a capital spell the same public name. + "http://API.ListenBrainz.ORG.", + // And the mapped form of a public address is still public. + "http://[::ffff:1.1.1.1]", + // A name that is nothing but dots trims to the empty string, which + // has no dot in it and was therefore reading as "ours". Nobody's + // network is called that; accepting it was an accident of the test + // rather than a decision. + "http://.", + "http://..", + ] { + assert_eq!( + validate_destination(public, true), + Err(OutboundError::PlaintextToPublicHost), + "{public}" + ); + } + + // HTTPS reaches all of them, which is the point: the rule is about a + // credential crossing in clear, not about where it is going. + assert!(validate_destination("https://api.listenbrainz.org", false).is_ok()); +} + +#[test] +fn a_destination_carries_no_credentials_query_or_fragment() { + for refused in [ + "https://user:secret@host", + "https://host?token=secret", + "https://host#fragment", + ] { + assert_eq!( + validate_destination(refused, false), + Err(OutboundError::NotABareOrigin), + "{refused}" + ); + } + // A path is fine: a self-hosted instance may live under one. + assert!(validate_destination("https://host/listenbrainz", false).is_ok()); +} + +#[test] +fn an_endpoint_is_appended_and_never_escapes_the_configured_prefix() { + let base = validate_destination("https://host/listenbrainz", false).unwrap(); + assert_eq!( + endpoint(&base, "/1/submit-listens").unwrap().as_str(), + "https://host/listenbrainz/1/submit-listens", + "a leading slash must not replace the operator's prefix" + ); + + // The same answer whether or not the operator typed a trailing slash. + let slashed = validate_destination("https://host/listenbrainz/", false).unwrap(); + assert_eq!( + endpoint(&slashed, "1/submit-listens").unwrap().as_str(), + endpoint(&base, "1/submit-listens").unwrap().as_str() + ); + + // And no path climbs out of it. + assert!(endpoint(&base, "../elsewhere").is_err()); +} diff --git a/src/storage/natural_order/mod.rs b/src/storage/natural_order/mod.rs index 9bc51319..2c5874aa 100644 --- a/src/storage/natural_order/mod.rs +++ b/src/storage/natural_order/mod.rs @@ -63,59 +63,4 @@ fn digits(chars: &mut Peekable>) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn sorted(names: &[&str]) -> Vec { - let mut names: Vec = names.iter().map(|name| (*name).to_owned()).collect(); - names.sort_by(|a, b| compare(a, b)); - names - } - - #[test] - fn numbers_sort_by_value() { - assert_eq!( - sorted(&[ - "100 Snooze.m4a", - "2 Paradise.m4a", - "10 Ballad.m4a", - "1 Morning.mp3" - ]), - [ - "1 Morning.mp3", - "2 Paradise.m4a", - "10 Ballad.m4a", - "100 Snooze.m4a" - ] - ); - assert_eq!( - sorted(&["Disc 10", "Disc 9", "Disc 1"]), - ["Disc 1", "Disc 9", "Disc 10"] - ); - } - - #[test] - fn letters_fold_case_beyond_ascii() { - assert_eq!( - sorted(&["b", "Été", "A", "étoile"]), - ["A", "b", "étoile", "Été"] - ); - assert_eq!(compare("abc", "ABD"), Ordering::Less); - } - - #[test] - fn only_identical_names_compare_equal() { - assert_eq!(compare("7", "7"), Ordering::Equal); - assert_ne!(compare("07", "7"), Ordering::Equal); - assert_ne!(compare("Abc", "abc"), Ordering::Equal); - assert_eq!(compare("07", "7"), compare("07", "7")); - assert_eq!(compare("07", "7"), compare("7", "07").reverse()); - } - - #[test] - fn a_run_longer_than_any_integer_still_sorts() { - let huge = "9".repeat(40); - assert_eq!(compare("1", &huge), Ordering::Less); - assert_eq!(compare(&format!("{huge}0"), &huge), Ordering::Greater); - } -} +mod tests; diff --git a/src/storage/natural_order/tests.rs b/src/storage/natural_order/tests.rs new file mode 100644 index 00000000..20afad2a --- /dev/null +++ b/src/storage/natural_order/tests.rs @@ -0,0 +1,54 @@ +use super::*; + +fn sorted(names: &[&str]) -> Vec { + let mut names: Vec = names.iter().map(|name| (*name).to_owned()).collect(); + names.sort_by(|a, b| compare(a, b)); + names +} + +#[test] +fn numbers_sort_by_value() { + assert_eq!( + sorted(&[ + "100 Snooze.m4a", + "2 Paradise.m4a", + "10 Ballad.m4a", + "1 Morning.mp3" + ]), + [ + "1 Morning.mp3", + "2 Paradise.m4a", + "10 Ballad.m4a", + "100 Snooze.m4a" + ] + ); + assert_eq!( + sorted(&["Disc 10", "Disc 9", "Disc 1"]), + ["Disc 1", "Disc 9", "Disc 10"] + ); +} + +#[test] +fn letters_fold_case_beyond_ascii() { + assert_eq!( + sorted(&["b", "Été", "A", "étoile"]), + ["A", "b", "étoile", "Été"] + ); + assert_eq!(compare("abc", "ABD"), Ordering::Less); +} + +#[test] +fn only_identical_names_compare_equal() { + assert_eq!(compare("7", "7"), Ordering::Equal); + assert_ne!(compare("07", "7"), Ordering::Equal); + assert_ne!(compare("Abc", "abc"), Ordering::Equal); + assert_eq!(compare("07", "7"), compare("07", "7")); + assert_eq!(compare("07", "7"), compare("7", "07").reverse()); +} + +#[test] +fn a_run_longer_than_any_integer_still_sorts() { + let huge = "9".repeat(40); + assert_eq!(compare("1", &huge), Ordering::Less); + assert_eq!(compare(&format!("{huge}0"), &huge), Ordering::Greater); +} diff --git a/src/storage/sync/mod.rs b/src/storage/sync/mod.rs index 612f6934..47d8b168 100644 --- a/src/storage/sync/mod.rs +++ b/src/storage/sync/mod.rs @@ -415,48 +415,4 @@ fn parse_uuid(value: String) -> Result { } #[cfg(test)] -mod tests { - use super::MutationIntent; - - #[test] - fn mutation_intents_are_canonical_and_scope_action_target_and_payload() { - let first = MutationIntent::new( - "update", - "playlist:one", - &serde_json::json!({ "name": "Road", "tracks": ["a", "b"] }), - ); - let reordered = MutationIntent::new( - "update", - "playlist:one", - &serde_json::json!({ "tracks": ["a", "b"], "name": "Road" }), - ); - assert_eq!(first.as_bytes(), reordered.as_bytes()); - assert_ne!( - first.as_bytes(), - MutationIntent::new( - "delete", - "playlist:one", - &serde_json::json!({ "name": "Road", "tracks": ["a", "b"] }), - ) - .as_bytes() - ); - assert_ne!( - first.as_bytes(), - MutationIntent::new( - "update", - "playlist:two", - &serde_json::json!({ "name": "Road", "tracks": ["a", "b"] }), - ) - .as_bytes() - ); - assert_ne!( - first.as_bytes(), - MutationIntent::new( - "update", - "playlist:one", - &serde_json::json!({ "name": "Night", "tracks": ["a", "b"] }), - ) - .as_bytes() - ); - } -} +mod tests; diff --git a/src/storage/sync/tests.rs b/src/storage/sync/tests.rs new file mode 100644 index 00000000..bead30c6 --- /dev/null +++ b/src/storage/sync/tests.rs @@ -0,0 +1,43 @@ +use super::MutationIntent; + +#[test] +fn mutation_intents_are_canonical_and_scope_action_target_and_payload() { + let first = MutationIntent::new( + "update", + "playlist:one", + &serde_json::json!({ "name": "Road", "tracks": ["a", "b"] }), + ); + let reordered = MutationIntent::new( + "update", + "playlist:one", + &serde_json::json!({ "tracks": ["a", "b"], "name": "Road" }), + ); + assert_eq!(first.as_bytes(), reordered.as_bytes()); + assert_ne!( + first.as_bytes(), + MutationIntent::new( + "delete", + "playlist:one", + &serde_json::json!({ "name": "Road", "tracks": ["a", "b"] }), + ) + .as_bytes() + ); + assert_ne!( + first.as_bytes(), + MutationIntent::new( + "update", + "playlist:two", + &serde_json::json!({ "name": "Road", "tracks": ["a", "b"] }), + ) + .as_bytes() + ); + assert_ne!( + first.as_bytes(), + MutationIntent::new( + "update", + "playlist:one", + &serde_json::json!({ "name": "Night", "tracks": ["a", "b"] }), + ) + .as_bytes() + ); +} From 62f640bd4a5bee8c733015ac03c6caa343e0e034 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 17:51:53 +0100 Subject: [PATCH 05/13] refactor(remote): split enrichment along its seams 959 lines become four files under remote/enrichment/: model.rs (the providers, outcomes and cached rows), handle.rs (Enrichment and its Endpoints: building the engine, reading the cache, the Deezer portrait search), engine.rs (the fills, their bound and the verdicts), and mod.rs keeping the module doc, MAX_IN_FLIGHT and the Pace that lrclib shares. Code moved verbatim; the engine's fields and three of its methods widen to pub(super) for the handle beside it. Signed-off-by: InstaZDLL --- src/remote/enrichment/engine.rs | 415 +++++++++++++++ src/remote/enrichment/handle.rs | 355 +++++++++++++ src/remote/enrichment/mod.rs | 890 +------------------------------- src/remote/enrichment/model.rs | 140 +++++ 4 files changed, 917 insertions(+), 883 deletions(-) create mode 100644 src/remote/enrichment/engine.rs create mode 100644 src/remote/enrichment/handle.rs create mode 100644 src/remote/enrichment/model.rs diff --git a/src/remote/enrichment/engine.rs b/src/remote/enrichment/engine.rs new file mode 100644 index 00000000..37420522 --- /dev/null +++ b/src/remote/enrichment/engine.rs @@ -0,0 +1,415 @@ +//! The engine behind the handle: one fill per provider and name, paced and +//! bounded, and the verdict an unreached lookup is logged with. + +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use dashmap::DashMap; +use waveflow_core::metadata::{ + deezer::{DeezerClient, DeezerError}, + lastfm::LastfmClient, + name_match::{normalize_name, select_by_name}, + theaudiodb::TheAudioDbClient, + LookupError, +}; + +use crate::{ + remote::portrait::{self, AddressPolicy, Refusal}, + storage::database::Database, +}; + +use super::{ArtistAnswer, CachedSimilar, Outcome, Pace, Provider, StoredPortrait, MAX_IN_FLIGHT}; + +/// How many neighbours are asked for and kept. Subsonic's `getArtistInfo` +/// defaults to twenty; asking for more leaves room for the ones a caller cannot +/// see, which decision 7 drops at read time. +const SIMILAR_LIMIT: u32 = 50; + +pub(super) struct Engine { + pub(super) db: Database, + /// In the operator's order, which is the order fields are merged in. + pub(super) sources: Vec, + pub(super) ttl_ms: i64, + pub(super) miss_ttl_ms: i64, + pub(super) request_timeout: Duration, + /// Where fetched portraits are stored: the artwork store. + pub(super) artwork_dir: PathBuf, + pub(super) image_addresses: AddressPolicy, + /// The fills in flight, by provider and canonical name. A visit that finds + /// its key here schedules nothing: the fill already running answers it. + pub(super) in_flight: DashMap<(Provider, String), ()>, + /// How many fills are waiting or running: the count [`MAX_IN_FLIGHT`] + /// bounds, and the one [`Enrichment::idle`](super::Enrichment::idle) waits on. + pub(super) running: tokio::sync::watch::Sender, +} + +pub(super) struct Source { + pub(super) provider: Provider, + pub(super) client: Client, + pub(super) pace: Pace, +} + +pub(super) enum Client { + LastFm { + client: LastfmClient, + key: String, + }, + Deezer { + client: DeezerClient, + image_hosts: Vec, + }, + TheAudioDb { + client: TheAudioDbClient, + image_hosts: Vec, + }, +} + +impl Engine { + /// Starts a fill for one provider and one name, unless one is running or + /// [`MAX_IN_FLIGHT`] are. + pub(super) fn schedule(self: Arc, index: usize, name: &str, canonical: &str) { + let key = (self.sources[index].provider, canonical.to_owned()); + // A place is reserved before the key is claimed, in one step under the + // counter's lock: a length read then an insert would let two visits on + // two threads both see one place left and both take it. + let reserved = self.running.send_if_modified(|count| { + let free = *count < MAX_IN_FLIGHT; + if free { + *count += 1; + } + free + }); + if !reserved { + return; + } + if self.in_flight.insert(key.clone(), ()).is_some() { + self.running.send_modify(|count| *count -= 1); + return; + } + // Released however the fill ends — a panic, or a task dropped before + // its first poll included, which is why it is built here rather than + // inside the task. A key left behind would stop that name from ever + // being asked again. + let flight = Flight { engine: self, key }; + let name = name.to_owned(); + tokio::spawn(async move { + flight.engine.fill(index, &name, &flight.key.1).await; + drop(flight); + }); + } + + async fn fill(&self, index: usize, name: &str, canonical: &str) { + let source = &self.sources[index]; + let answer = match &source.client { + Client::LastFm { client, key } => self.ask_lastfm(source, client, key, name).await, + Client::Deezer { + client, + image_hosts, + } => self.ask_deezer(source, client, image_hosts, name).await, + Client::TheAudioDb { + client, + image_hosts, + } => self.ask_theaudiodb(source, client, image_hosts, name).await, + }; + let answer = match answer { + Ok(answer) => answer, + Err(verdict) => { + tracing::warn!( + provider = source.provider.as_str(), + verdict = %verdict, + "artist lookup unreached; nothing cached" + ); + return; + } + }; + let fetched_at = crate::auth::authentication::now_ms(); + let outcome = answer.outcome(); + let ttl = match outcome { + Outcome::Hit => self.ttl_ms, + Outcome::Miss => self.miss_ttl_ms, + }; + if let Err(error) = self + .db + .write_metadata_artist( + source.provider, + canonical, + &answer, + fetched_at, + fetched_at.saturating_add(ttl), + ) + .await + { + tracing::warn!( + provider = source.provider.as_str(), + error = %error, + "artist lookup answered but could not be cached" + ); + return; + } + tracing::debug!( + provider = source.provider.as_str(), + outcome = outcome.as_str(), + "artist lookup cached" + ); + } + + /// `artist.getInfo` and `artist.getSimilar`. Both must be reached for the + /// answer to count: half an answer written as a whole one would cache the + /// missing half as "nothing" for the TTL. + async fn ask_lastfm( + &self, + source: &Source, + client: &LastfmClient, + key: &str, + name: &str, + ) -> Result { + source.pace.wait().await; + let info = self.bounded(client.artist_get_info(name, key)).await?; + source.pace.wait().await; + let similar = self + .bounded(client.artist_get_similar(name, key, SIMILAR_LIMIT)) + .await?; + let own = waveflow_core::scanner::canonical_name(name); + let similar = similar + .into_iter() + .filter_map(|neighbour| { + let name_canonical = waveflow_core::scanner::canonical_name(&neighbour.name); + (!name_canonical.is_empty() && name_canonical != own).then(|| CachedSimilar { + name: neighbour.name, + name_canonical, + score: f64::from(neighbour.match_score), + }) + }) + .collect(); + let (bio_summary, bio_full) = info + .map(|info| (info.bio_summary, info.bio_full)) + .unwrap_or_default(); + Ok(ArtistAnswer { + bio_summary, + bio_full, + similar, + image: None, + fanart: None, + }) + } + + /// The artist search, then the picture it names — decision 8. + /// + /// No artist by that name, or one without a picture, is a miss. So is a + /// picture the fetcher refuses for what it is — off the allowlist, at a + /// refused address, not an image, too large — since asking again would + /// get the same answer. A picture host that could not be reached is + /// unreached, like the search, and writes nothing. + async fn ask_deezer( + &self, + source: &Source, + client: &DeezerClient, + image_hosts: &[String], + name: &str, + ) -> Result { + source.pace.wait().await; + let hits = self.bounded(client.search_artist(name)).await?; + let picture = select_by_name(hits, &normalize_name(name), |hit| Some(hit.name.as_str())) + .and_then(|hit| hit.best_picture()); + let Some(picture) = picture else { + return Ok(ArtistAnswer::default()); + }; + let fetched = portrait::fetch( + &picture, + image_hosts, + self.image_addresses, + self.request_timeout, + ) + .await; + let portrait = match fetched { + Ok(portrait) => portrait, + Err(refusal) if refusal.is_answer() => { + tracing::warn!( + provider = source.provider.as_str(), + verdict = %refusal, + "artist portrait refused; cached as a miss" + ); + return Ok(ArtistAnswer::default()); + } + Err(refusal) => return Err(Verdict::Image(refusal)), + }; + portrait::store(&self.artwork_dir, &portrait) + .await + .map_err(|_| Verdict::Store)?; + Ok(ArtistAnswer { + image: Some(StoredPortrait { + hash: portrait.hash, + format: portrait.format, + byte_size: portrait.bytes.len() as i64, + }), + ..ArtistAnswer::default() + }) + } + + /// The Deezer source, its client and the hosts its pictures come from. + pub(super) fn deezer(&self) -> Option<(&Source, &DeezerClient, &[String])> { + self.sources.iter().find_map(|source| match &source.client { + Client::Deezer { + client, + image_hosts, + } => Some((source, client, image_hosts.as_slice())), + Client::LastFm { .. } | Client::TheAudioDb { .. } => None, + }) + } + + /// The artist search, then the first fanart that is really one (RFC-014 + /// decision 2). + /// + /// The fanarts are tried in TheAudioDB's order: one the fetcher refuses + /// for what it is gives way to the next, and the first stored ends the + /// search. None accepted, or no artist by that name, is a miss. A picture + /// host that could not be reached is unreached, like the search, and + /// writes nothing: it says nothing about the artist. + async fn ask_theaudiodb( + &self, + source: &Source, + client: &TheAudioDbClient, + image_hosts: &[String], + name: &str, + ) -> Result { + source.pace.wait().await; + // English is asked only because the call takes a language: the + // biography it selects is not kept. + let Some(artist) = self.bounded(client.artist_info(name, "en")).await? else { + return Ok(ArtistAnswer::default()); + }; + for fanart in &artist.fanart_urls { + source.pace.wait().await; + let fetched = portrait::fetch( + fanart, + image_hosts, + self.image_addresses, + self.request_timeout, + ) + .await; + let picture = match fetched { + Ok(picture) => picture, + Err(refusal) if refusal.is_answer() => { + tracing::debug!( + provider = source.provider.as_str(), + verdict = %refusal, + "artist fanart refused; trying the next" + ); + continue; + } + Err(refusal) => return Err(Verdict::Image(refusal)), + }; + portrait::store(&self.artwork_dir, &picture) + .await + .map_err(|_| Verdict::Store)?; + return Ok(ArtistAnswer { + fanart: Some(StoredPortrait { + hash: picture.hash, + format: picture.format, + byte_size: picture.bytes.len() as i64, + }), + ..ArtistAnswer::default() + }); + } + Ok(ArtistAnswer::default()) + } + + /// One request, under the request timeout; anything but an answer is a + /// verdict. + pub(super) async fn bounded( + &self, + request: impl std::future::Future>, + ) -> Result + where + Verdict: From, + { + match tokio::time::timeout(self.request_timeout, request).await { + Ok(Ok(answer)) => Ok(answer), + Ok(Err(error)) => Err(Verdict::from(error)), + Err(_) => Err(Verdict::Timeout), + } + } +} + +/// Releases a fill's key and count when the fill ends. +struct Flight { + engine: Arc, + key: (Provider, String), +} + +impl Drop for Flight { + fn drop(&mut self) { + self.engine.in_flight.remove(&self.key); + self.engine.running.send_modify(|count| *count -= 1); + } +} + +/// Why a lookup was unreached, in words that carry nothing from the request or +/// the response — no URL, no body, no key. +#[derive(Debug)] +pub(super) enum Verdict { + Timeout, + Transport, + Refused { + code: i64, + }, + Status { + status: u16, + }, + TooLarge, + Undecodable, + RateLimited, + /// The picture host, not the provider, was not reached. + Image(Refusal), + /// The picture came back and could not be written to the artwork store. + Store, +} + +impl From for Verdict { + fn from(error: LookupError) -> Self { + match error { + LookupError::Http(error) if error.is_timeout() => Self::Timeout, + LookupError::Http(_) => Self::Transport, + LookupError::Refused { code } => Self::Refused { code }, + LookupError::Status { status } => Self::Status { status }, + LookupError::TooLarge { .. } => Self::TooLarge, + LookupError::Decode(_) => Self::Undecodable, + } + } +} + +impl From for Verdict { + fn from(error: DeezerError) -> Self { + match error { + DeezerError::Http(error) if error.is_timeout() => Self::Timeout, + DeezerError::Http(error) => { + error + .status() + .map_or(Self::Transport, |status| Self::Status { + status: status.as_u16(), + }) + } + DeezerError::Api(error) => Self::Refused { + code: error.code.unwrap_or_default(), + }, + DeezerError::RateLimited => Self::RateLimited, + DeezerError::TooLarge { .. } => Self::TooLarge, + DeezerError::Decode(_) => Self::Undecodable, + } + } +} + +impl std::fmt::Display for Verdict { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Timeout => formatter.write_str("timed out"), + Self::Transport => formatter.write_str("transport failure"), + Self::Refused { code } => write!(formatter, "refused with code {code}"), + Self::Status { status } => write!(formatter, "answered HTTP {status}"), + Self::TooLarge => formatter.write_str("answer over the size ceiling"), + Self::Undecodable => formatter.write_str("answer not understood"), + Self::RateLimited => formatter.write_str("rate-limited"), + Self::Image(refusal) => refusal.fmt(formatter), + Self::Store => formatter.write_str("portrait could not be stored"), + } + } +} diff --git a/src/remote/enrichment/handle.rs b/src/remote/enrichment/handle.rs new file mode 100644 index 00000000..55b4a818 --- /dev/null +++ b/src/remote/enrichment/handle.rs @@ -0,0 +1,355 @@ +//! The public face of the engine: building it from the settings, reading the +//! cache and scheduling fills, and the Deezer portrait search. + +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use dashmap::DashMap; +use waveflow_core::metadata::{ + deezer::{is_placeholder_artist_picture, DeezerArtistHit, DeezerClient}, + lastfm::LastfmClient, + theaudiodb::TheAudioDbClient, +}; + +use crate::{ + config::MetadataSettings, + remote::portrait::{self, AddressPolicy}, + storage::database::Database, +}; + +use super::{ + engine::{Client, Engine, Source}, + CachedArtist, CandidateSearchError, DeezerCandidate, Pace, Provider, StoredPortrait, +}; + +/// Last.fm's API root. A constant, not a setting (decision 9): there is no +/// operator field, so there is no URL to validate. +const LASTFM_BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/"; + +/// Deezer's API root, a constant for the same reason. It asks for no key. +const DEEZER_BASE_URL: &str = "https://api.deezer.com"; + +/// The hosts Deezer serves its pictures from (decision 8). A picture URL +/// naming any other host is dropped without being contacted. +const DEEZER_IMAGE_HOSTS: [&str; 2] = ["e-cdns-images.dzcdn.net", "cdn-images.dzcdn.net"]; + +/// TheAudioDB's API root, a constant like the others (RFC-014 decision 1). The +/// operator's key travels in the path, so this URL is never logged either. +const THEAUDIODB_BASE_URL: &str = "https://www.theaudiodb.com/api/v1/json"; + +/// The hosts TheAudioDB serves its fanart from (RFC-014 decision 2). A fanart +/// URL naming any other host is dropped without being contacted. +const THEAUDIODB_IMAGE_HOSTS: [&str; 2] = ["r2.theaudiodb.com", "www.theaudiodb.com"]; + +/// TheAudioDB's own ceiling, whatever the shared rate says: thirty requests a +/// minute on its free tier (RFC-014 decision 5). The pace is the slower of this +/// and the operator's. +const THEAUDIODB_INTERVAL: Duration = Duration::from_secs(2); + +/// Where each provider is reached. [`Endpoints::default`] is production; the +/// test suite points these at listeners of its own, which is the only reason +/// the type exists. +#[derive(Debug, Clone)] +pub struct Endpoints { + pub lastfm: String, + pub deezer: String, + /// The hosts a Deezer picture may be fetched from. + pub deezer_image_hosts: Vec, + pub theaudiodb: String, + /// The hosts a TheAudioDB fanart may be fetched from. + pub theaudiodb_image_hosts: Vec, + /// The addresses a picture may be fetched from. Only the test suite, + /// whose image host is on loopback, sets anything but the default. + pub image_addresses: AddressPolicy, +} + +impl Default for Endpoints { + fn default() -> Self { + Self { + lastfm: LASTFM_BASE_URL.to_owned(), + deezer: DEEZER_BASE_URL.to_owned(), + deezer_image_hosts: DEEZER_IMAGE_HOSTS.map(str::to_owned).to_vec(), + theaudiodb: THEAUDIODB_BASE_URL.to_owned(), + theaudiodb_image_hosts: THEAUDIODB_IMAGE_HOSTS.map(str::to_owned).to_vec(), + image_addresses: AddressPolicy::default(), + } + } +} + +/// The engine, or its absence. +/// +/// Cheap to clone; every clone drives the same engine. Disabled when no +/// provider is configured, and then every call returns at once having read and +/// sent nothing. +#[derive(Clone)] +pub struct Enrichment { + engine: Option>, +} + +impl Enrichment { + /// Enrichment switched off. + pub fn disabled() -> Self { + Self { engine: None } + } + + /// The engine `settings` describe, against the providers' real addresses. + pub fn new( + db: Database, + settings: &MetadataSettings, + artwork_dir: PathBuf, + ) -> anyhow::Result { + Self::with_endpoints(db, settings, artwork_dir, Endpoints::default()) + } + + /// [`Self::new`], with the providers reached at `endpoints`. + /// + /// Only the configured providers are built. A key placed with no provider + /// naming it builds nothing — the provider list is the switch, and a key + /// is not consent (decision 3). With `WAVEFLOW_METADATA_IMAGES=off`, + /// Deezer is not built either: portraits are all it is asked for. + pub fn with_endpoints( + db: Database, + settings: &MetadataSettings, + artwork_dir: PathBuf, + endpoints: Endpoints, + ) -> anyhow::Result { + let mut sources = Vec::new(); + for &provider in &settings.providers { + let http = crate::remote::outbound::client(settings.request_timeout) + .map_err(|_| anyhow::anyhow!("the outbound client could not be built"))?; + let client = match provider { + Provider::LastFm => { + let key = settings.lastfm_key.clone().ok_or_else(|| { + anyhow::anyhow!("metadata provider lastfm is configured without a key") + })?; + Client::LastFm { + client: LastfmClient::with_http(http, endpoints.lastfm.clone())?, + key, + } + } + Provider::Deezer if !settings.images => continue, + Provider::Deezer => Client::Deezer { + client: DeezerClient::with_http(http, endpoints.deezer.clone()), + image_hosts: endpoints.deezer_image_hosts.clone(), + }, + // Banners are all it is asked for (RFC-014 decision 1). + Provider::TheAudioDb if !settings.images => continue, + Provider::TheAudioDb => { + let key = settings.theaudiodb_key.clone().ok_or_else(|| { + anyhow::anyhow!("metadata provider theaudiodb is configured without a key") + })?; + Client::TheAudioDb { + client: TheAudioDbClient::with_http( + http, + endpoints.theaudiodb.clone(), + key, + ) + .map_err(|_| anyhow::anyhow!("the TheAudioDB address is not https"))?, + image_hosts: endpoints.theaudiodb_image_hosts.clone(), + } + } + }; + let pace = match provider { + Provider::TheAudioDb => { + Pace::with_floor(settings.rate_per_sec, THEAUDIODB_INTERVAL) + } + Provider::LastFm | Provider::Deezer => Pace::new(settings.rate_per_sec), + }; + sources.push(Source { + provider, + client, + pace, + }); + } + if sources.is_empty() { + return Ok(Self::disabled()); + } + Ok(Self { + engine: Some(Arc::new(Engine { + db, + sources, + ttl_ms: duration_ms(settings.ttl), + miss_ttl_ms: duration_ms(settings.miss_ttl), + request_timeout: settings.request_timeout, + artwork_dir, + image_addresses: endpoints.image_addresses, + in_flight: DashMap::new(), + running: tokio::sync::watch::channel(0).0, + })), + }) + } + + pub fn is_enabled(&self) -> bool { + self.engine.is_some() + } + + /// How many providers are configured — the number of rows a name has + /// once every one of them has answered. + pub fn provider_count(&self) -> usize { + self.engine + .as_ref() + .map_or(0, |engine| engine.sources.len()) + } + + /// Whether a portrait may be searched for on Deezer: the operator named + /// it and left images on. Asked at every call, never remembered by a + /// search (RFC-013 decision 4). + pub(crate) fn searches_deezer(&self) -> bool { + self.engine + .as_ref() + .is_some_and(|engine| engine.deezer().is_some()) + } + + /// Deezer's artists for `query`, in its order, at most `limit` of them, + /// each with its picture fetched and stored as a preview (RFC-013 + /// decision 4). + /// + /// Every request — the search and each picture — keeps Deezer's pace, + /// the one enrichment keeps, so a picker cannot make the server ask + /// faster than the operator allowed. A hit with no real picture is not + /// offered, nor one whose picture the fetcher refuses for what it is: + /// there would be nothing to choose. A picture host that cannot be + /// reached fails the whole search, as the search itself failing does. + pub(crate) async fn deezer_candidates( + &self, + query: &str, + limit: usize, + ) -> Result, CandidateSearchError> { + let engine = self + .engine + .as_ref() + .ok_or(CandidateSearchError::NotConfigured)?; + let (source, client, image_hosts) = + engine.deezer().ok_or(CandidateSearchError::NotConfigured)?; + source.pace.wait().await; + let hits = engine + .bounded(client.search_artist(query)) + .await + .map_err(|verdict| { + tracing::warn!( + provider = source.provider.as_str(), + verdict = %verdict, + "portrait candidates unreached" + ); + CandidateSearchError::Unreached + })?; + let mut candidates: Vec = Vec::new(); + for hit in hits { + if candidates.len() >= limit { + break; + } + if candidates.iter().any(|offered| offered.deezer_id == hit.id) { + continue; + } + let Some(picture) = preview_picture(&hit) else { + continue; + }; + source.pace.wait().await; + let fetched = portrait::fetch( + &picture, + image_hosts, + engine.image_addresses, + engine.request_timeout, + ) + .await; + let preview = match fetched { + Ok(preview) => preview, + Err(refusal) if refusal.is_answer() => continue, + Err(refusal) => { + tracing::warn!( + provider = source.provider.as_str(), + verdict = %refusal, + "portrait candidate preview unreached" + ); + return Err(CandidateSearchError::Unreached); + } + }; + portrait::store(&engine.artwork_dir, &preview) + .await + .map_err(|_| CandidateSearchError::Unreached)?; + candidates.push(DeezerCandidate { + deezer_id: hit.id, + name: hit.name, + fans: hit.nb_fan, + preview: StoredPortrait { + hash: preview.hash, + format: preview.format, + byte_size: preview.bytes.len() as i64, + }, + }); + } + Ok(candidates) + } + + /// What the cache holds about the artist called `name`, one row per + /// configured provider that has one, in the operator's order — and a fill + /// scheduled for every configured provider whose row is missing or stale. + /// + /// Stale rows are returned, not withheld (decision 5): a slightly old + /// biography beats none, and serving it keeps a provider outage invisible. + /// Nothing here waits on a provider. + pub async fn artist(&self, name: &str) -> Result, sqlx::Error> { + let Some(engine) = &self.engine else { + return Ok(Vec::new()); + }; + let canonical = waveflow_core::scanner::canonical_name(name); + if canonical.is_empty() { + return Ok(Vec::new()); + } + let rows = engine.db.metadata_artist_rows(&canonical).await?; + let now = crate::auth::authentication::now_ms(); + let mut ordered = Vec::new(); + for (index, source) in engine.sources.iter().enumerate() { + let row = rows.iter().find(|row| row.provider == source.provider); + if row.is_none_or(|row| row.expires_at <= now) { + Arc::clone(engine).schedule(index, name, &canonical); + } + ordered.extend(row.cloned()); + } + // A portrait already fetched, and an artist row that does not show + // it: an artist re-identified by a PID spec change, or one scanned + // since. Linked again from the cache, without a second download. A + // repair, not the answer: if it fails, the page is still served and + // the next visit tries again. + if let Some(hash) = ordered.iter().find_map(|row| row.image_hash.as_deref()) { + if let Err(error) = engine.db.link_artist_portrait(&canonical, hash).await { + tracing::warn!(%error, "cached artist portrait could not be linked"); + } + } + // And the banner, by the same reasoning (RFC-014 decision 3). + if let Some(hash) = ordered.iter().find_map(|row| row.fanart_hash.as_deref()) { + if let Err(error) = engine.db.link_artist_fanart(&canonical, hash).await { + tracing::warn!(%error, "cached artist banner could not be linked"); + } + } + Ok(ordered) + } + + /// Waits until no fill is running. + /// + /// For the test suite, which has to know a fill is over before it can say + /// what the fill wrote — or that it wrote nothing. + #[doc(hidden)] + pub async fn idle(&self) { + let Some(engine) = &self.engine else { + return; + }; + let mut running = engine.running.subscribe(); + // The sender lives as long as the engine, which `self` holds. + let _ = running.wait_for(|count| *count == 0).await; + } +} + +/// The picture a candidate is previewed at, and kept at if chosen: Deezer's +/// big size, 500 pixels, since the preview is what becomes the portrait. The +/// largest real one when that size is missing; none for Deezer's placeholder, +/// which every size shares. +fn preview_picture(hit: &DeezerArtistHit) -> Option { + hit.picture_big + .clone() + .filter(|url| !is_placeholder_artist_picture(url)) + .or_else(|| hit.best_picture()) +} + +fn duration_ms(duration: Duration) -> i64 { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) +} diff --git a/src/remote/enrichment/mod.rs b/src/remote/enrichment/mod.rs index 543eecba..11ac3476 100644 --- a/src/remote/enrichment/mod.rs +++ b/src/remote/enrichment/mod.rs @@ -20,55 +20,15 @@ //! Last.fm request carries the operator's key in its query string; neither the //! URL nor a response body is ever written to a log (decision 9). -use std::{ - path::PathBuf, - sync::Arc, - time::{Duration, Instant}, -}; +use std::time::{Duration, Instant}; -use dashmap::DashMap; -use waveflow_core::metadata::{ - deezer::{is_placeholder_artist_picture, DeezerArtistHit, DeezerClient, DeezerError}, - lastfm::LastfmClient, - name_match::{normalize_name, select_by_name}, - theaudiodb::TheAudioDbClient, - LookupError, -}; +mod engine; +mod handle; +mod model; -use crate::{ - config::MetadataSettings, - remote::portrait::{self, AddressPolicy, Refusal}, - storage::database::Database, -}; - -/// Last.fm's API root. A constant, not a setting (decision 9): there is no -/// operator field, so there is no URL to validate. -const LASTFM_BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/"; - -/// Deezer's API root, a constant for the same reason. It asks for no key. -const DEEZER_BASE_URL: &str = "https://api.deezer.com"; - -/// The hosts Deezer serves its pictures from (decision 8). A picture URL -/// naming any other host is dropped without being contacted. -const DEEZER_IMAGE_HOSTS: [&str; 2] = ["e-cdns-images.dzcdn.net", "cdn-images.dzcdn.net"]; - -/// TheAudioDB's API root, a constant like the others (RFC-014 decision 1). The -/// operator's key travels in the path, so this URL is never logged either. -const THEAUDIODB_BASE_URL: &str = "https://www.theaudiodb.com/api/v1/json"; - -/// The hosts TheAudioDB serves its fanart from (RFC-014 decision 2). A fanart -/// URL naming any other host is dropped without being contacted. -const THEAUDIODB_IMAGE_HOSTS: [&str; 2] = ["r2.theaudiodb.com", "www.theaudiodb.com"]; - -/// TheAudioDB's own ceiling, whatever the shared rate says: thirty requests a -/// minute on its free tier (RFC-014 decision 5). The pace is the slower of this -/// and the operator's. -const THEAUDIODB_INTERVAL: Duration = Duration::from_secs(2); - -/// How many neighbours are asked for and kept. Subsonic's `getArtistInfo` -/// defaults to twenty; asking for more leaves room for the ones a caller cannot -/// see, which decision 7 drops at read time. -const SIMILAR_LIMIT: u32 = 50; +pub use handle::{Endpoints, Enrichment}; +pub(crate) use model::{ArtistAnswer, CandidateSearchError, DeezerCandidate, StoredPortrait}; +pub use model::{CachedArtist, CachedSimilar, Outcome, Provider}; /// How many fills may be waiting or running at once, across providers. /// @@ -80,827 +40,6 @@ const SIMILAR_LIMIT: u32 = 50; /// does anyway. pub const MAX_IN_FLIGHT: usize = 64; -/// A provider an operator can name in `WAVEFLOW_METADATA_PROVIDERS`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Provider { - LastFm, - /// Portraits only: Deezer has no biography, and its related artists are - /// not asked for. - Deezer, - /// Banners only (RFC-014): TheAudioDB's fanart. Its biographies wait on a - /// language this server does not know. - TheAudioDb, -} - -impl Provider { - pub fn parse(value: &str) -> Option { - match value { - "lastfm" => Some(Self::LastFm), - "deezer" => Some(Self::Deezer), - "theaudiodb" => Some(Self::TheAudioDb), - _ => None, - } - } - - /// The name in the setting, in the cache's `provider` column, and in logs. - pub fn as_str(self) -> &'static str { - match self { - Self::LastFm => "lastfm", - Self::Deezer => "deezer", - Self::TheAudioDb => "theaudiodb", - } - } -} - -/// What a provider said, when it said something. Unreached has no variant: it -/// is the absence of a row. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Outcome { - Hit, - Miss, -} - -impl Outcome { - pub fn parse(value: &str) -> Option { - match value { - "hit" => Some(Self::Hit), - "miss" => Some(Self::Miss), - _ => None, - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Hit => "hit", - Self::Miss => "miss", - } - } -} - -/// One provider's cached row for one name. -#[derive(Debug, Clone)] -pub struct CachedArtist { - pub provider: Provider, - pub outcome: Outcome, - pub bio_summary: Option, - pub bio_full: Option, - /// The portrait this provider supplied, in the artwork store. Cleared by - /// the database when the sweeper takes the artwork row. - pub image_hash: Option, - /// The banner this provider supplied (RFC-014), cleared the same way. - pub fanart_hash: Option, - pub fetched_at: i64, - pub expires_at: i64, -} - -/// A portrait already in the artwork store, waiting for its row. -#[derive(Debug)] -pub(crate) struct StoredPortrait { - pub(crate) hash: String, - pub(crate) format: &'static str, - pub(crate) byte_size: i64, -} - -/// A Deezer artist offered as a portrait (RFC-013 decision 4), its preview -/// already in the artwork store. The row naming the preview is the caller's to -/// write, with whatever names it. -#[derive(Debug)] -pub(crate) struct DeezerCandidate { - pub(crate) deezer_id: i64, - pub(crate) name: String, - pub(crate) fans: Option, - pub(crate) preview: StoredPortrait, -} - -/// Why a search for candidates gave none. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CandidateSearchError { - /// Deezer is not among the operator's providers, or images are off. - NotConfigured, - /// Deezer, or a picture host it named, could not be reached, or a preview - /// could not be stored. - Unreached, -} - -/// One neighbour in a provider's cached list. -#[derive(Debug, Clone)] -pub struct CachedSimilar { - pub name: String, - pub name_canonical: String, - pub score: f64, -} - -/// What one fill brought back from one provider, before it is written. -#[derive(Debug, Default)] -pub(crate) struct ArtistAnswer { - pub(crate) bio_summary: Option, - pub(crate) bio_full: Option, - pub(crate) similar: Vec, - pub(crate) image: Option, - /// The banner, from TheAudioDB's fanart (RFC-014). - pub(crate) fanart: Option, -} - -impl ArtistAnswer { - /// A hit carries something; an answer with nothing in it is a miss. - pub(crate) fn outcome(&self) -> Outcome { - if self.bio_summary.is_some() - || self.bio_full.is_some() - || !self.similar.is_empty() - || self.image.is_some() - || self.fanart.is_some() - { - Outcome::Hit - } else { - Outcome::Miss - } - } -} - -/// Where each provider is reached. [`Endpoints::default`] is production; the -/// test suite points these at listeners of its own, which is the only reason -/// the type exists. -#[derive(Debug, Clone)] -pub struct Endpoints { - pub lastfm: String, - pub deezer: String, - /// The hosts a Deezer picture may be fetched from. - pub deezer_image_hosts: Vec, - pub theaudiodb: String, - /// The hosts a TheAudioDB fanart may be fetched from. - pub theaudiodb_image_hosts: Vec, - /// The addresses a picture may be fetched from. Only the test suite, - /// whose image host is on loopback, sets anything but the default. - pub image_addresses: AddressPolicy, -} - -impl Default for Endpoints { - fn default() -> Self { - Self { - lastfm: LASTFM_BASE_URL.to_owned(), - deezer: DEEZER_BASE_URL.to_owned(), - deezer_image_hosts: DEEZER_IMAGE_HOSTS.map(str::to_owned).to_vec(), - theaudiodb: THEAUDIODB_BASE_URL.to_owned(), - theaudiodb_image_hosts: THEAUDIODB_IMAGE_HOSTS.map(str::to_owned).to_vec(), - image_addresses: AddressPolicy::default(), - } - } -} - -/// The engine, or its absence. -/// -/// Cheap to clone; every clone drives the same engine. Disabled when no -/// provider is configured, and then every call returns at once having read and -/// sent nothing. -#[derive(Clone)] -pub struct Enrichment { - engine: Option>, -} - -struct Engine { - db: Database, - /// In the operator's order, which is the order fields are merged in. - sources: Vec, - ttl_ms: i64, - miss_ttl_ms: i64, - request_timeout: Duration, - /// Where fetched portraits are stored: the artwork store. - artwork_dir: PathBuf, - image_addresses: AddressPolicy, - /// The fills in flight, by provider and canonical name. A visit that finds - /// its key here schedules nothing: the fill already running answers it. - in_flight: DashMap<(Provider, String), ()>, - /// How many fills are waiting or running: the count [`MAX_IN_FLIGHT`] - /// bounds, and the one [`Enrichment::idle`] waits on. - running: tokio::sync::watch::Sender, -} - -struct Source { - provider: Provider, - client: Client, - pace: Pace, -} - -enum Client { - LastFm { - client: LastfmClient, - key: String, - }, - Deezer { - client: DeezerClient, - image_hosts: Vec, - }, - TheAudioDb { - client: TheAudioDbClient, - image_hosts: Vec, - }, -} - -impl Enrichment { - /// Enrichment switched off. - pub fn disabled() -> Self { - Self { engine: None } - } - - /// The engine `settings` describe, against the providers' real addresses. - pub fn new( - db: Database, - settings: &MetadataSettings, - artwork_dir: PathBuf, - ) -> anyhow::Result { - Self::with_endpoints(db, settings, artwork_dir, Endpoints::default()) - } - - /// [`Self::new`], with the providers reached at `endpoints`. - /// - /// Only the configured providers are built. A key placed with no provider - /// naming it builds nothing — the provider list is the switch, and a key - /// is not consent (decision 3). With `WAVEFLOW_METADATA_IMAGES=off`, - /// Deezer is not built either: portraits are all it is asked for. - pub fn with_endpoints( - db: Database, - settings: &MetadataSettings, - artwork_dir: PathBuf, - endpoints: Endpoints, - ) -> anyhow::Result { - let mut sources = Vec::new(); - for &provider in &settings.providers { - let http = crate::remote::outbound::client(settings.request_timeout) - .map_err(|_| anyhow::anyhow!("the outbound client could not be built"))?; - let client = match provider { - Provider::LastFm => { - let key = settings.lastfm_key.clone().ok_or_else(|| { - anyhow::anyhow!("metadata provider lastfm is configured without a key") - })?; - Client::LastFm { - client: LastfmClient::with_http(http, endpoints.lastfm.clone())?, - key, - } - } - Provider::Deezer if !settings.images => continue, - Provider::Deezer => Client::Deezer { - client: DeezerClient::with_http(http, endpoints.deezer.clone()), - image_hosts: endpoints.deezer_image_hosts.clone(), - }, - // Banners are all it is asked for (RFC-014 decision 1). - Provider::TheAudioDb if !settings.images => continue, - Provider::TheAudioDb => { - let key = settings.theaudiodb_key.clone().ok_or_else(|| { - anyhow::anyhow!("metadata provider theaudiodb is configured without a key") - })?; - Client::TheAudioDb { - client: TheAudioDbClient::with_http( - http, - endpoints.theaudiodb.clone(), - key, - ) - .map_err(|_| anyhow::anyhow!("the TheAudioDB address is not https"))?, - image_hosts: endpoints.theaudiodb_image_hosts.clone(), - } - } - }; - let pace = match provider { - Provider::TheAudioDb => { - Pace::with_floor(settings.rate_per_sec, THEAUDIODB_INTERVAL) - } - Provider::LastFm | Provider::Deezer => Pace::new(settings.rate_per_sec), - }; - sources.push(Source { - provider, - client, - pace, - }); - } - if sources.is_empty() { - return Ok(Self::disabled()); - } - Ok(Self { - engine: Some(Arc::new(Engine { - db, - sources, - ttl_ms: duration_ms(settings.ttl), - miss_ttl_ms: duration_ms(settings.miss_ttl), - request_timeout: settings.request_timeout, - artwork_dir, - image_addresses: endpoints.image_addresses, - in_flight: DashMap::new(), - running: tokio::sync::watch::channel(0).0, - })), - }) - } - - pub fn is_enabled(&self) -> bool { - self.engine.is_some() - } - - /// How many providers are configured — the number of rows a name has - /// once every one of them has answered. - pub fn provider_count(&self) -> usize { - self.engine - .as_ref() - .map_or(0, |engine| engine.sources.len()) - } - - /// Whether a portrait may be searched for on Deezer: the operator named - /// it and left images on. Asked at every call, never remembered by a - /// search (RFC-013 decision 4). - pub(crate) fn searches_deezer(&self) -> bool { - self.engine - .as_ref() - .is_some_and(|engine| engine.deezer().is_some()) - } - - /// Deezer's artists for `query`, in its order, at most `limit` of them, - /// each with its picture fetched and stored as a preview (RFC-013 - /// decision 4). - /// - /// Every request — the search and each picture — keeps Deezer's pace, - /// the one enrichment keeps, so a picker cannot make the server ask - /// faster than the operator allowed. A hit with no real picture is not - /// offered, nor one whose picture the fetcher refuses for what it is: - /// there would be nothing to choose. A picture host that cannot be - /// reached fails the whole search, as the search itself failing does. - pub(crate) async fn deezer_candidates( - &self, - query: &str, - limit: usize, - ) -> Result, CandidateSearchError> { - let engine = self - .engine - .as_ref() - .ok_or(CandidateSearchError::NotConfigured)?; - let (source, client, image_hosts) = - engine.deezer().ok_or(CandidateSearchError::NotConfigured)?; - source.pace.wait().await; - let hits = engine - .bounded(client.search_artist(query)) - .await - .map_err(|verdict| { - tracing::warn!( - provider = source.provider.as_str(), - verdict = %verdict, - "portrait candidates unreached" - ); - CandidateSearchError::Unreached - })?; - let mut candidates: Vec = Vec::new(); - for hit in hits { - if candidates.len() >= limit { - break; - } - if candidates.iter().any(|offered| offered.deezer_id == hit.id) { - continue; - } - let Some(picture) = preview_picture(&hit) else { - continue; - }; - source.pace.wait().await; - let fetched = portrait::fetch( - &picture, - image_hosts, - engine.image_addresses, - engine.request_timeout, - ) - .await; - let preview = match fetched { - Ok(preview) => preview, - Err(refusal) if refusal.is_answer() => continue, - Err(refusal) => { - tracing::warn!( - provider = source.provider.as_str(), - verdict = %refusal, - "portrait candidate preview unreached" - ); - return Err(CandidateSearchError::Unreached); - } - }; - portrait::store(&engine.artwork_dir, &preview) - .await - .map_err(|_| CandidateSearchError::Unreached)?; - candidates.push(DeezerCandidate { - deezer_id: hit.id, - name: hit.name, - fans: hit.nb_fan, - preview: StoredPortrait { - hash: preview.hash, - format: preview.format, - byte_size: preview.bytes.len() as i64, - }, - }); - } - Ok(candidates) - } - - /// What the cache holds about the artist called `name`, one row per - /// configured provider that has one, in the operator's order — and a fill - /// scheduled for every configured provider whose row is missing or stale. - /// - /// Stale rows are returned, not withheld (decision 5): a slightly old - /// biography beats none, and serving it keeps a provider outage invisible. - /// Nothing here waits on a provider. - pub async fn artist(&self, name: &str) -> Result, sqlx::Error> { - let Some(engine) = &self.engine else { - return Ok(Vec::new()); - }; - let canonical = waveflow_core::scanner::canonical_name(name); - if canonical.is_empty() { - return Ok(Vec::new()); - } - let rows = engine.db.metadata_artist_rows(&canonical).await?; - let now = crate::auth::authentication::now_ms(); - let mut ordered = Vec::new(); - for (index, source) in engine.sources.iter().enumerate() { - let row = rows.iter().find(|row| row.provider == source.provider); - if row.is_none_or(|row| row.expires_at <= now) { - Arc::clone(engine).schedule(index, name, &canonical); - } - ordered.extend(row.cloned()); - } - // A portrait already fetched, and an artist row that does not show - // it: an artist re-identified by a PID spec change, or one scanned - // since. Linked again from the cache, without a second download. A - // repair, not the answer: if it fails, the page is still served and - // the next visit tries again. - if let Some(hash) = ordered.iter().find_map(|row| row.image_hash.as_deref()) { - if let Err(error) = engine.db.link_artist_portrait(&canonical, hash).await { - tracing::warn!(%error, "cached artist portrait could not be linked"); - } - } - // And the banner, by the same reasoning (RFC-014 decision 3). - if let Some(hash) = ordered.iter().find_map(|row| row.fanart_hash.as_deref()) { - if let Err(error) = engine.db.link_artist_fanart(&canonical, hash).await { - tracing::warn!(%error, "cached artist banner could not be linked"); - } - } - Ok(ordered) - } - - /// Waits until no fill is running. - /// - /// For the test suite, which has to know a fill is over before it can say - /// what the fill wrote — or that it wrote nothing. - #[doc(hidden)] - pub async fn idle(&self) { - let Some(engine) = &self.engine else { - return; - }; - let mut running = engine.running.subscribe(); - // The sender lives as long as the engine, which `self` holds. - let _ = running.wait_for(|count| *count == 0).await; - } -} - -impl Engine { - /// Starts a fill for one provider and one name, unless one is running or - /// [`MAX_IN_FLIGHT`] are. - fn schedule(self: Arc, index: usize, name: &str, canonical: &str) { - let key = (self.sources[index].provider, canonical.to_owned()); - // A place is reserved before the key is claimed, in one step under the - // counter's lock: a length read then an insert would let two visits on - // two threads both see one place left and both take it. - let reserved = self.running.send_if_modified(|count| { - let free = *count < MAX_IN_FLIGHT; - if free { - *count += 1; - } - free - }); - if !reserved { - return; - } - if self.in_flight.insert(key.clone(), ()).is_some() { - self.running.send_modify(|count| *count -= 1); - return; - } - // Released however the fill ends — a panic, or a task dropped before - // its first poll included, which is why it is built here rather than - // inside the task. A key left behind would stop that name from ever - // being asked again. - let flight = Flight { engine: self, key }; - let name = name.to_owned(); - tokio::spawn(async move { - flight.engine.fill(index, &name, &flight.key.1).await; - drop(flight); - }); - } - - async fn fill(&self, index: usize, name: &str, canonical: &str) { - let source = &self.sources[index]; - let answer = match &source.client { - Client::LastFm { client, key } => self.ask_lastfm(source, client, key, name).await, - Client::Deezer { - client, - image_hosts, - } => self.ask_deezer(source, client, image_hosts, name).await, - Client::TheAudioDb { - client, - image_hosts, - } => self.ask_theaudiodb(source, client, image_hosts, name).await, - }; - let answer = match answer { - Ok(answer) => answer, - Err(verdict) => { - tracing::warn!( - provider = source.provider.as_str(), - verdict = %verdict, - "artist lookup unreached; nothing cached" - ); - return; - } - }; - let fetched_at = crate::auth::authentication::now_ms(); - let outcome = answer.outcome(); - let ttl = match outcome { - Outcome::Hit => self.ttl_ms, - Outcome::Miss => self.miss_ttl_ms, - }; - if let Err(error) = self - .db - .write_metadata_artist( - source.provider, - canonical, - &answer, - fetched_at, - fetched_at.saturating_add(ttl), - ) - .await - { - tracing::warn!( - provider = source.provider.as_str(), - error = %error, - "artist lookup answered but could not be cached" - ); - return; - } - tracing::debug!( - provider = source.provider.as_str(), - outcome = outcome.as_str(), - "artist lookup cached" - ); - } - - /// `artist.getInfo` and `artist.getSimilar`. Both must be reached for the - /// answer to count: half an answer written as a whole one would cache the - /// missing half as "nothing" for the TTL. - async fn ask_lastfm( - &self, - source: &Source, - client: &LastfmClient, - key: &str, - name: &str, - ) -> Result { - source.pace.wait().await; - let info = self.bounded(client.artist_get_info(name, key)).await?; - source.pace.wait().await; - let similar = self - .bounded(client.artist_get_similar(name, key, SIMILAR_LIMIT)) - .await?; - let own = waveflow_core::scanner::canonical_name(name); - let similar = similar - .into_iter() - .filter_map(|neighbour| { - let name_canonical = waveflow_core::scanner::canonical_name(&neighbour.name); - (!name_canonical.is_empty() && name_canonical != own).then(|| CachedSimilar { - name: neighbour.name, - name_canonical, - score: f64::from(neighbour.match_score), - }) - }) - .collect(); - let (bio_summary, bio_full) = info - .map(|info| (info.bio_summary, info.bio_full)) - .unwrap_or_default(); - Ok(ArtistAnswer { - bio_summary, - bio_full, - similar, - image: None, - fanart: None, - }) - } - - /// The artist search, then the picture it names — decision 8. - /// - /// No artist by that name, or one without a picture, is a miss. So is a - /// picture the fetcher refuses for what it is — off the allowlist, at a - /// refused address, not an image, too large — since asking again would - /// get the same answer. A picture host that could not be reached is - /// unreached, like the search, and writes nothing. - async fn ask_deezer( - &self, - source: &Source, - client: &DeezerClient, - image_hosts: &[String], - name: &str, - ) -> Result { - source.pace.wait().await; - let hits = self.bounded(client.search_artist(name)).await?; - let picture = select_by_name(hits, &normalize_name(name), |hit| Some(hit.name.as_str())) - .and_then(|hit| hit.best_picture()); - let Some(picture) = picture else { - return Ok(ArtistAnswer::default()); - }; - let fetched = portrait::fetch( - &picture, - image_hosts, - self.image_addresses, - self.request_timeout, - ) - .await; - let portrait = match fetched { - Ok(portrait) => portrait, - Err(refusal) if refusal.is_answer() => { - tracing::warn!( - provider = source.provider.as_str(), - verdict = %refusal, - "artist portrait refused; cached as a miss" - ); - return Ok(ArtistAnswer::default()); - } - Err(refusal) => return Err(Verdict::Image(refusal)), - }; - portrait::store(&self.artwork_dir, &portrait) - .await - .map_err(|_| Verdict::Store)?; - Ok(ArtistAnswer { - image: Some(StoredPortrait { - hash: portrait.hash, - format: portrait.format, - byte_size: portrait.bytes.len() as i64, - }), - ..ArtistAnswer::default() - }) - } - - /// The Deezer source, its client and the hosts its pictures come from. - fn deezer(&self) -> Option<(&Source, &DeezerClient, &[String])> { - self.sources.iter().find_map(|source| match &source.client { - Client::Deezer { - client, - image_hosts, - } => Some((source, client, image_hosts.as_slice())), - Client::LastFm { .. } | Client::TheAudioDb { .. } => None, - }) - } - - /// The artist search, then the first fanart that is really one (RFC-014 - /// decision 2). - /// - /// The fanarts are tried in TheAudioDB's order: one the fetcher refuses - /// for what it is gives way to the next, and the first stored ends the - /// search. None accepted, or no artist by that name, is a miss. A picture - /// host that could not be reached is unreached, like the search, and - /// writes nothing: it says nothing about the artist. - async fn ask_theaudiodb( - &self, - source: &Source, - client: &TheAudioDbClient, - image_hosts: &[String], - name: &str, - ) -> Result { - source.pace.wait().await; - // English is asked only because the call takes a language: the - // biography it selects is not kept. - let Some(artist) = self.bounded(client.artist_info(name, "en")).await? else { - return Ok(ArtistAnswer::default()); - }; - for fanart in &artist.fanart_urls { - source.pace.wait().await; - let fetched = portrait::fetch( - fanart, - image_hosts, - self.image_addresses, - self.request_timeout, - ) - .await; - let picture = match fetched { - Ok(picture) => picture, - Err(refusal) if refusal.is_answer() => { - tracing::debug!( - provider = source.provider.as_str(), - verdict = %refusal, - "artist fanart refused; trying the next" - ); - continue; - } - Err(refusal) => return Err(Verdict::Image(refusal)), - }; - portrait::store(&self.artwork_dir, &picture) - .await - .map_err(|_| Verdict::Store)?; - return Ok(ArtistAnswer { - fanart: Some(StoredPortrait { - hash: picture.hash, - format: picture.format, - byte_size: picture.bytes.len() as i64, - }), - ..ArtistAnswer::default() - }); - } - Ok(ArtistAnswer::default()) - } - - /// One request, under the request timeout; anything but an answer is a - /// verdict. - async fn bounded( - &self, - request: impl std::future::Future>, - ) -> Result - where - Verdict: From, - { - match tokio::time::timeout(self.request_timeout, request).await { - Ok(Ok(answer)) => Ok(answer), - Ok(Err(error)) => Err(Verdict::from(error)), - Err(_) => Err(Verdict::Timeout), - } - } -} - -/// Releases a fill's key and count when the fill ends. -struct Flight { - engine: Arc, - key: (Provider, String), -} - -impl Drop for Flight { - fn drop(&mut self) { - self.engine.in_flight.remove(&self.key); - self.engine.running.send_modify(|count| *count -= 1); - } -} - -/// Why a lookup was unreached, in words that carry nothing from the request or -/// the response — no URL, no body, no key. -#[derive(Debug)] -enum Verdict { - Timeout, - Transport, - Refused { - code: i64, - }, - Status { - status: u16, - }, - TooLarge, - Undecodable, - RateLimited, - /// The picture host, not the provider, was not reached. - Image(Refusal), - /// The picture came back and could not be written to the artwork store. - Store, -} - -impl From for Verdict { - fn from(error: LookupError) -> Self { - match error { - LookupError::Http(error) if error.is_timeout() => Self::Timeout, - LookupError::Http(_) => Self::Transport, - LookupError::Refused { code } => Self::Refused { code }, - LookupError::Status { status } => Self::Status { status }, - LookupError::TooLarge { .. } => Self::TooLarge, - LookupError::Decode(_) => Self::Undecodable, - } - } -} - -impl From for Verdict { - fn from(error: DeezerError) -> Self { - match error { - DeezerError::Http(error) if error.is_timeout() => Self::Timeout, - DeezerError::Http(error) => { - error - .status() - .map_or(Self::Transport, |status| Self::Status { - status: status.as_u16(), - }) - } - DeezerError::Api(error) => Self::Refused { - code: error.code.unwrap_or_default(), - }, - DeezerError::RateLimited => Self::RateLimited, - DeezerError::TooLarge { .. } => Self::TooLarge, - DeezerError::Decode(_) => Self::Undecodable, - } - } -} - -impl std::fmt::Display for Verdict { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Timeout => formatter.write_str("timed out"), - Self::Transport => formatter.write_str("transport failure"), - Self::Refused { code } => write!(formatter, "refused with code {code}"), - Self::Status { status } => write!(formatter, "answered HTTP {status}"), - Self::TooLarge => formatter.write_str("answer over the size ceiling"), - Self::Undecodable => formatter.write_str("answer not understood"), - Self::RateLimited => formatter.write_str("rate-limited"), - Self::Image(refusal) => refusal.fmt(formatter), - Self::Store => formatter.write_str("portrait could not be stored"), - } - } -} - /// Spaces one provider's requests to its rate: a token bucket that holds one /// token, so there is never a burst to spend. /// @@ -942,18 +81,3 @@ impl Pace { tokio::time::sleep_until(slot.into()).await; } } - -/// The picture a candidate is previewed at, and kept at if chosen: Deezer's -/// big size, 500 pixels, since the preview is what becomes the portrait. The -/// largest real one when that size is missing; none for Deezer's placeholder, -/// which every size shares. -fn preview_picture(hit: &DeezerArtistHit) -> Option { - hit.picture_big - .clone() - .filter(|url| !is_placeholder_artist_picture(url)) - .or_else(|| hit.best_picture()) -} - -fn duration_ms(duration: Duration) -> i64 { - i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) -} diff --git a/src/remote/enrichment/model.rs b/src/remote/enrichment/model.rs new file mode 100644 index 00000000..97b316b7 --- /dev/null +++ b/src/remote/enrichment/model.rs @@ -0,0 +1,140 @@ +//! What the cache holds and what a fill brings back: the providers, their +//! outcomes, the cached rows, and the portraits and candidates on their way to +//! the artwork store. + +/// A provider an operator can name in `WAVEFLOW_METADATA_PROVIDERS`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Provider { + LastFm, + /// Portraits only: Deezer has no biography, and its related artists are + /// not asked for. + Deezer, + /// Banners only (RFC-014): TheAudioDB's fanart. Its biographies wait on a + /// language this server does not know. + TheAudioDb, +} + +impl Provider { + pub fn parse(value: &str) -> Option { + match value { + "lastfm" => Some(Self::LastFm), + "deezer" => Some(Self::Deezer), + "theaudiodb" => Some(Self::TheAudioDb), + _ => None, + } + } + + /// The name in the setting, in the cache's `provider` column, and in logs. + pub fn as_str(self) -> &'static str { + match self { + Self::LastFm => "lastfm", + Self::Deezer => "deezer", + Self::TheAudioDb => "theaudiodb", + } + } +} + +/// What a provider said, when it said something. Unreached has no variant: it +/// is the absence of a row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Hit, + Miss, +} + +impl Outcome { + pub fn parse(value: &str) -> Option { + match value { + "hit" => Some(Self::Hit), + "miss" => Some(Self::Miss), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Hit => "hit", + Self::Miss => "miss", + } + } +} + +/// One provider's cached row for one name. +#[derive(Debug, Clone)] +pub struct CachedArtist { + pub provider: Provider, + pub outcome: Outcome, + pub bio_summary: Option, + pub bio_full: Option, + /// The portrait this provider supplied, in the artwork store. Cleared by + /// the database when the sweeper takes the artwork row. + pub image_hash: Option, + /// The banner this provider supplied (RFC-014), cleared the same way. + pub fanart_hash: Option, + pub fetched_at: i64, + pub expires_at: i64, +} + +/// A portrait already in the artwork store, waiting for its row. +#[derive(Debug)] +pub(crate) struct StoredPortrait { + pub(crate) hash: String, + pub(crate) format: &'static str, + pub(crate) byte_size: i64, +} + +/// A Deezer artist offered as a portrait (RFC-013 decision 4), its preview +/// already in the artwork store. The row naming the preview is the caller's to +/// write, with whatever names it. +#[derive(Debug)] +pub(crate) struct DeezerCandidate { + pub(crate) deezer_id: i64, + pub(crate) name: String, + pub(crate) fans: Option, + pub(crate) preview: StoredPortrait, +} + +/// Why a search for candidates gave none. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CandidateSearchError { + /// Deezer is not among the operator's providers, or images are off. + NotConfigured, + /// Deezer, or a picture host it named, could not be reached, or a preview + /// could not be stored. + Unreached, +} + +/// One neighbour in a provider's cached list. +#[derive(Debug, Clone)] +pub struct CachedSimilar { + pub name: String, + pub name_canonical: String, + pub score: f64, +} + +/// What one fill brought back from one provider, before it is written. +#[derive(Debug, Default)] +pub(crate) struct ArtistAnswer { + pub(crate) bio_summary: Option, + pub(crate) bio_full: Option, + pub(crate) similar: Vec, + pub(crate) image: Option, + /// The banner, from TheAudioDB's fanart (RFC-014). + pub(crate) fanart: Option, +} + +impl ArtistAnswer { + /// A hit carries something; an answer with nothing in it is a miss. + pub(crate) fn outcome(&self) -> Outcome { + if self.bio_summary.is_some() + || self.bio_full.is_some() + || !self.similar.is_empty() + || self.image.is_some() + || self.fanart.is_some() + { + Outcome::Hit + } else { + Outcome::Miss + } + } +} From 7c53cbe72ba482879b64412de255d6ea7fca59d6 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 17:52:18 +0100 Subject: [PATCH 06/13] refactor(src): point comments that name a file at its new home Seven comments cited src/config.rs, src/database.rs, src/podcast.rs, src/scrobblers/lastfm.rs and catalog.rs, which are now directories under config/, storage/, remote/; and one still cited tests/scrobbling.rs, the target #355 turned into a directory. Signed-off-by: InstaZDLL --- src/api/scrobbling/mod.rs | 2 +- src/config/tests.rs | 2 +- src/remote/scrobblers/mod.rs | 2 +- src/services/lastfm.rs | 2 +- src/services/library_events.rs | 2 +- src/services/podcasts/mod.rs | 2 +- tests/migrations.rs | 2 +- tests/scrobbling/lastfm_journey.rs | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/api/scrobbling/mod.rs b/src/api/scrobbling/mod.rs index 76f5c6f8..b548d679 100644 --- a/src/api/scrobbling/mod.rs +++ b/src/api/scrobbling/mod.rs @@ -1,7 +1,7 @@ //! Where an account links a destination, and answers for what could not be sent. //! //! RFC-010 decision 9: a destination's base URL belongs to the deployment and -//! lives in `src/config.rs`, but an *authorisation* belongs to a person, so it +//! lives in `src/config/`, but an *authorisation* belongs to a person, so it //! is posed here rather than by an environment variable. The same gestures exist //! on the CLI for an operator preparing a server without a browser — both call //! the same `DomainServices` methods, so the two surfaces cannot drift. diff --git a/src/config/tests.rs b/src/config/tests.rs index 01e441a2..7b0fe180 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -164,7 +164,7 @@ fn public_url_is_reduced_to_a_safe_http_origin() { /// whatever a person pastes into an issue. The repository's rule about /// secrets has no exception for an error path. /// -/// It used to live in `tests/scrobbling.rs` against `initialize`, which is +/// It used to live in `tests/scrobbling/` against `initialize`, which is /// where the guard used to be. Named destinations moved the refusal here, /// to the reading of the variable, and the test follows the guard rather /// than staying where it was and passing for another reason. diff --git a/src/remote/scrobblers/mod.rs b/src/remote/scrobblers/mod.rs index 37e48afb..4cb2cab3 100644 --- a/src/remote/scrobblers/mod.rs +++ b/src/remote/scrobblers/mod.rs @@ -69,7 +69,7 @@ pub fn validate_destination_name(name: &str) -> Result<(), OutboundError> { /// The canonical spelling of a destination, as an identity rather than as an /// address. /// -/// **Not `normalize_public_url` (`src/config.rs`), and the difference is +/// **Not `normalize_public_url` (`src/config/access.rs`), and the difference is /// load-bearing.** That one answers an *origin* — scheme, host, port — and /// throws the path away, because that is what a share link needs. A /// destination is not an origin: [`validate_destination`] accepts a path, so diff --git a/src/services/lastfm.rs b/src/services/lastfm.rs index faf00a78..23366d2f 100644 --- a/src/services/lastfm.rs +++ b/src/services/lastfm.rs @@ -109,7 +109,7 @@ pub(super) fn lastfm_unavailability( /// Turns a request token into a session key. /// -/// Implemented in `src/scrobblers/lastfm.rs`, because that is the only place in +/// Implemented in `src/remote/scrobblers/lastfm/`, because that is the only place in /// this server that makes an outbound request. Registered like a /// [`ScrobbleTarget`] and for the same reason: a test drives the whole journey /// against a double, and a server with no application registers neither. diff --git a/src/services/library_events.rs b/src/services/library_events.rs index 7b9a4502..287c0859 100644 --- a/src/services/library_events.rs +++ b/src/services/library_events.rs @@ -1,7 +1,7 @@ //! Reading a library's change feed. //! //! The counterpart of the RFC-003 journal for the half of the server that had -//! no feed at all. Written by scans in `catalog.rs`; read here. +//! no feed at all. Written by scans in `storage/catalog/`; read here. use super::*; use crate::storage::sync::MAX_SYNC_LIMIT; diff --git a/src/services/podcasts/mod.rs b/src/services/podcasts/mod.rs index 43460941..b3eae33b 100644 --- a/src/services/podcasts/mod.rs +++ b/src/services/podcasts/mod.rs @@ -135,7 +135,7 @@ pub struct EpisodeBookmark { pub enum PodcastFailure { Service(ServiceError), /// The feed could not be fetched or read, as a word from the fixed list - /// `src/podcast.rs` defines. Nothing was stored. + /// `src/remote/podcast/` defines. Nothing was stored. Feed(String), } diff --git a/tests/migrations.rs b/tests/migrations.rs index 69b24a9e..44fd08c2 100644 --- a/tests/migrations.rs +++ b/tests/migrations.rs @@ -15,7 +15,7 @@ //! nobody can correct. //! //! Not the first migration test: `a_populated_database_survives_a_table_rebuild` -//! in `src/database.rs` already applies a prefix and migrates from there. It is +//! in `src/storage/database/tests.rs` already applies a prefix and migrates from there. It is //! pinned to **one** boundary — the participants rebuild it was written for — //! and asserts raw counts. This asks the general question instead, from every //! schema a tag actually shipped, and answers it through the services rather diff --git a/tests/scrobbling/lastfm_journey.rs b/tests/scrobbling/lastfm_journey.rs index 6ac975b9..19cfcace 100644 --- a/tests/scrobbling/lastfm_journey.rs +++ b/tests/scrobbling/lastfm_journey.rs @@ -680,7 +680,7 @@ async fn a_journey_without_a_browser_needs_no_public_address() { /// The web journey never asks for a token of its own. /// -/// This is the mistake `src/scrobblers/lastfm.rs` warns about in as many +/// This is the mistake `src/remote/scrobblers/lastfm/` warns about in as many /// words: the two halves of Last.fm's protocol resemble each other enough to /// be mixed up, and a server that minted a token and then received a different /// one would hold the wrong one. Until `auth.getToken` existed there was From 4b4fb7ade79985f49288455d490639174cfaf950 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 17:53:51 +0100 Subject: [PATCH 07/13] docs(src): resolve the intra-doc links rustdoc could not AlbumOrder, DomainServices::catalog_snapshot, DomainServices::artist_page, ShareClear and the smart playlists' EVALUATE are now linked by full path, so no import is needed for a link. A link to a cfg(test) function cannot resolve in a doc build and keeps its words without the brackets. Links to private items are correct and resolve with --document-private-items; lib.rs allows rustdoc::private_intra_doc_links and says why, so the gate catches only a link that resolves nowhere. Signed-off-by: InstaZDLL --- src/api/catalog.rs | 2 +- src/lib.rs | 7 +++++++ src/services/model/browse.rs | 2 +- src/services/model/catalog.rs | 4 ++-- src/services/model/playlists.rs | 2 +- src/services/scrobbling/mod.rs | 2 +- src/services/smart_playlists/mod.rs | 2 +- src/services/smart_playlists/rules.rs | 8 ++++---- 8 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/api/catalog.rs b/src/api/catalog.rs index cd8a23f1..541598bb 100644 --- a/src/api/catalog.rs +++ b/src/api/catalog.rs @@ -12,7 +12,7 @@ pub struct BrowseQuery { } /// Album discovery parameters. `sort` accepts the same vocabulary as the -/// Subsonic `type` parameter — both surfaces resolve to [`AlbumOrder`], so the +/// Subsonic `type` parameter — both surfaces resolve to [`AlbumOrder`](crate::services::AlbumOrder), so the /// web client can build a home screen ("recently added", "most played") in one /// call instead of paging the whole catalogue and sorting locally. #[derive(Debug, Deserialize)] diff --git a/src/lib.rs b/src/lib.rs index 42c6ac74..532ba9dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,12 @@ //! WaveFlow Server v2 library surface. +// The library exists so the integration suite drives the router `main` +// serves, and its doc comments link private items freely: a constant, a +// projection macro, the method a public one defers to. Those links resolve +// in an editor and under `cargo doc --document-private-items`; what CI's +// `cargo doc` gate catches is a link that resolves nowhere. +#![allow(rustdoc::private_intra_doc_links)] + pub mod api; mod app; pub mod auth; diff --git a/src/services/model/browse.rs b/src/services/model/browse.rs index 1c04255d..249ebab1 100644 --- a/src/services/model/browse.rs +++ b/src/services/model/browse.rs @@ -98,7 +98,7 @@ impl Default for BrowsePage { /// /// This is the single implementation of the ten Subsonic `getAlbumList2` modes, /// and it lives in the domain services rather than in the facade for two -/// reasons. The facade used to sort in Rust over [`DomainServices::catalog_snapshot`], +/// reasons. The facade used to sort in Rust over [`DomainServices::catalog_snapshot`](crate::services::DomainServices::catalog_snapshot), /// which materialises every folder, artist, album *and track* the tenant can /// see on each call — `byGenre` then rescanned every track once per album, and /// `songCount` needed the whole track list just to be counted. And the native diff --git a/src/services/model/catalog.rs b/src/services/model/catalog.rs index 5561f225..00e6682f 100644 --- a/src/services/model/catalog.rs +++ b/src/services/model/catalog.rs @@ -328,7 +328,7 @@ pub struct ArtistDetail { /// while the artist is not one of the album's own artists — "Appears on" /// (RFC-022 decision 3). /// - /// Filled by [`DomainServices::artist_page`] alone, like the enrichment: + /// Filled by [`DomainServices::artist_page`](crate::services::DomainServices::artist_page) alone, like the enrichment: /// the Subsonic `getArtist` path has nowhere to put it, and a client that /// syncs every artist would pay for a query it never reads. #[serde(skip_serializing_if = "Option::is_none")] @@ -336,7 +336,7 @@ pub struct ArtistDetail { /// What metadata enrichment says about this artist — RFC-011 decision 11. /// /// Always present on `GET /api/v2/artists/{id}`, whose answer comes from - /// [`DomainServices::artist_page`]. Left out of the Subsonic `getArtist` + /// [`DomainServices::artist_page`](crate::services::DomainServices::artist_page). Left out of the Subsonic `getArtist` /// path, which renders its own nodes: clients that sync call it for every /// artist, and asking providers about the whole catalogue at sync time is /// the prefill decision 6 refuses. diff --git a/src/services/model/playlists.rs b/src/services/model/playlists.rs index e06f2551..1118fecf 100644 --- a/src/services/model/playlists.rs +++ b/src/services/model/playlists.rs @@ -82,7 +82,7 @@ pub enum UnresolvedReason { Duplicate, } -/// Optional fields a playlist update may blank out. See [`ShareClear`]. +/// Optional fields a playlist update may blank out. See [`ShareClear`](crate::services::ShareClear). #[derive(Debug, Clone, Copy, Default)] pub struct PlaylistClear { pub comment: bool, diff --git a/src/services/scrobbling/mod.rs b/src/services/scrobbling/mod.rs index 09f321e6..2abb308e 100644 --- a/src/services/scrobbling/mod.rs +++ b/src/services/scrobbling/mod.rs @@ -33,7 +33,7 @@ pub enum ScrobbleProvider { /// `LastFm` into `last_fm` — names the `CHECK` constraint refuses and /// `FromStr` cannot read, so the API would publish a destination no client /// could send back in. One rule per variant, and no interaction between - /// two. [`tests::the_four_spellings_of_a_destination_agree`] holds them + /// two. `tests::the_four_spellings_of_a_destination_agree` holds them /// together. #[serde(rename = "listenbrainz")] ListenBrainz, diff --git a/src/services/smart_playlists/mod.rs b/src/services/smart_playlists/mod.rs index cd806326..070cff42 100644 --- a/src/services/smart_playlists/mod.rs +++ b/src/services/smart_playlists/mod.rs @@ -10,7 +10,7 @@ //! nothing that was refused is stored. The service methods take the parsed //! type, never the JSON, so there is no way to reach the database with rules //! that skipped the parse. -//! 2. **One static statement.** [`EVALUATE`] is a single literal, assembled by +//! 2. **One static statement.** [`EVALUATE`](crate::services::smart_playlists::statement::EVALUATE) is a single literal, assembled by //! `concat!` like every projection here. The rules reach it as *one bound //! value* — a JSON document SQLite takes apart with `json_each` — and every //! field name, operator and logical connective in it is compared against a diff --git a/src/services/smart_playlists/rules.rs b/src/services/smart_playlists/rules.rs index 509b6c27..993ab525 100644 --- a/src/services/smart_playlists/rules.rs +++ b/src/services/smart_playlists/rules.rs @@ -263,7 +263,7 @@ impl SmartOperator { impl SmartSort { /// The sort keys, in the order their codes are numbered: entry `i` sorts /// ascending under code `2i + 1` and descending under `2i + 2`. The codes - /// are what [`EVALUATE`] compares against, so this order and the `ORDER BY` + /// are what [`EVALUATE`](crate::services::smart_playlists::statement::EVALUATE) compares against, so this order and the `ORDER BY` /// list there are one table written twice; `the_sort_codes_match_the_statement` /// holds them together. const ALL: [(&'static str, SmartSort); 11] = [ @@ -281,7 +281,7 @@ impl SmartSort { ]; } -/// The code [`EVALUATE`] reads for "no sort": its fixed tie-break alone. +/// The code [`EVALUATE`](crate::services::smart_playlists::statement::EVALUATE) reads for "no sort": its fixed tie-break alone. const SORT_NONE: i64 = 0; /// The code for a random order, whatever direction was asked. pub(super) const SORT_RANDOM: i64 = 99; @@ -379,7 +379,7 @@ impl SmartRules { serde_json::to_string(self).expect("rule serialization cannot fail") } - /// The code [`EVALUATE`] orders by. + /// The code [`EVALUATE`](crate::services::smart_playlists::statement::EVALUATE) orders by. pub(super) fn sort_code(&self) -> i64 { match self.sort { None => SORT_NONE, @@ -528,7 +528,7 @@ fn date_ms(value: &str) -> Option { Some(date.and_hms_opt(0, 0, 0)?.and_utc().timestamp_millis()) } -/// One condition as [`EVALUATE`] binds it. +/// One condition as [`EVALUATE`](crate::services::smart_playlists::statement::EVALUATE) binds it. /// /// `o` is the elementary comparison: `eq`, `ne`, `has`, `hasnt`, `sw`, `ew` for /// text; `eq`, `ne`, `gt`, `lt`, `bt` for numbers; `ge` and `lt` for dates. From 03e3fa07941095454724d2975695c3034020cf03 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 17:56:22 +0100 Subject: [PATCH 08/13] refactor(tests): turn the ten integration targets past 1,000 lines into directories Renames, plus what a move from tests/x.rs to tests/x/main.rs needs: the shared support module is named by path, credits' include_str! climbs one more level, and Cargo.toml points each [[test]] at its main.rs. The targets, and the twenty-three of them, are unchanged. Signed-off-by: InstaZDLL --- Cargo.toml | 20 +++++++++---------- tests/{canvas.rs => canvas/main.rs} | 1 + tests/{cli.rs => cli/main.rs} | 1 + tests/{credits.rs => credits/main.rs} | 3 ++- tests/{discovery.rs => discovery/main.rs} | 1 + tests/{migrations.rs => migrations/main.rs} | 0 tests/{quality.rs => quality/main.rs} | 1 + tests/{service.rs => service/main.rs} | 1 + .../main.rs} | 1 + .../main.rs} | 1 + tests/{sync.rs => sync/main.rs} | 1 + 11 files changed, 20 insertions(+), 11 deletions(-) rename tests/{canvas.rs => canvas/main.rs} (99%) rename tests/{cli.rs => cli/main.rs} (99%) rename tests/{credits.rs => credits/main.rs} (99%) rename tests/{discovery.rs => discovery/main.rs} (99%) rename tests/{migrations.rs => migrations/main.rs} (100%) rename tests/{quality.rs => quality/main.rs} (99%) rename tests/{service.rs => service/main.rs} (99%) rename tests/{subsonic_browse.rs => subsonic_browse/main.rs} (99%) rename tests/{subsonic_fields.rs => subsonic_fields/main.rs} (99%) rename tests/{sync.rs => sync/main.rs} (99%) diff --git a/Cargo.toml b/Cargo.toml index fbeffd52..e062e8c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,7 +109,7 @@ tokio-tungstenite = "0.30" [[test]] name = "migrations" -path = "tests/migrations.rs" +path = "tests/migrations/main.rs" [[test]] name = "auth" @@ -133,11 +133,11 @@ path = "tests/subsonic_contract/main.rs" [[test]] name = "subsonic_fields" -path = "tests/subsonic_fields.rs" +path = "tests/subsonic_fields/main.rs" [[test]] name = "subsonic_browse" -path = "tests/subsonic_browse.rs" +path = "tests/subsonic_browse/main.rs" [[test]] name = "subsonic_methods" @@ -149,7 +149,7 @@ path = "tests/native_api/main.rs" [[test]] name = "sync" -path = "tests/sync.rs" +path = "tests/sync/main.rs" [[test]] name = "media" @@ -157,11 +157,11 @@ path = "tests/media/main.rs" [[test]] name = "service" -path = "tests/service.rs" +path = "tests/service/main.rs" [[test]] name = "cli" -path = "tests/cli.rs" +path = "tests/cli/main.rs" [profile.release] codegen-units = 1 @@ -174,7 +174,7 @@ path = "tests/uploads/main.rs" [[test]] name = "canvas" -path = "tests/canvas.rs" +path = "tests/canvas/main.rs" [[test]] name = "scrobbling" @@ -186,15 +186,15 @@ path = "tests/enrichment/main.rs" [[test]] name = "credits" -path = "tests/credits.rs" +path = "tests/credits/main.rs" [[test]] name = "quality" -path = "tests/quality.rs" +path = "tests/quality/main.rs" [[test]] name = "discovery" -path = "tests/discovery.rs" +path = "tests/discovery/main.rs" [[test]] name = "smart_playlists" diff --git a/tests/canvas.rs b/tests/canvas/main.rs similarity index 99% rename from tests/canvas.rs rename to tests/canvas/main.rs index fd7a665f..9c0360a6 100644 --- a/tests/canvas.rs +++ b/tests/canvas/main.rs @@ -19,6 +19,7 @@ use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. #[allow(dead_code)] +#[path = "../support/mod.rs"] mod support; use support::*; diff --git a/tests/cli.rs b/tests/cli/main.rs similarity index 99% rename from tests/cli.rs rename to tests/cli/main.rs index 8308a561..2834cba5 100644 --- a/tests/cli.rs +++ b/tests/cli/main.rs @@ -11,6 +11,7 @@ use waveflow_server::auth::authentication::now_ms; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. #[allow(dead_code)] +#[path = "../support/mod.rs"] mod support; use support::*; diff --git a/tests/credits.rs b/tests/credits/main.rs similarity index 99% rename from tests/credits.rs rename to tests/credits/main.rs index 996a15b7..7f1ae0a4 100644 --- a/tests/credits.rs +++ b/tests/credits/main.rs @@ -6,6 +6,7 @@ //! with a password, so nothing in it hands a literal to a hash. #[allow(dead_code)] +#[path = "../support/mod.rs"] mod support; use axum::body::Body; @@ -636,7 +637,7 @@ async fn the_feed_announces_what_a_split_moved() { /// repeats a credit and one whose credits share a part. #[tokio::test] async fn the_migrations_copy_gives_what_a_scan_gives() { - let migration = include_str!("../migrations-v2/20261024020000_artist_credit_decision.sql"); + let migration = include_str!("../../migrations-v2/20261024020000_artist_credit_decision.sql"); let copy = &migration[migration .find("INSERT INTO track_credit") .expect("the migration copies the participants")..]; diff --git a/tests/discovery.rs b/tests/discovery/main.rs similarity index 99% rename from tests/discovery.rs rename to tests/discovery/main.rs index 08efa2c9..983a0e1a 100644 --- a/tests/discovery.rs +++ b/tests/discovery/main.rs @@ -10,6 +10,7 @@ //! is reached through tokens and Subsonic keys drawn at run time. #[allow(dead_code)] +#[path = "../support/mod.rs"] mod support; use std::{ diff --git a/tests/migrations.rs b/tests/migrations/main.rs similarity index 100% rename from tests/migrations.rs rename to tests/migrations/main.rs diff --git a/tests/quality.rs b/tests/quality/main.rs similarity index 99% rename from tests/quality.rs rename to tests/quality/main.rs index a91d9679..9a223c60 100644 --- a/tests/quality.rs +++ b/tests/quality/main.rs @@ -10,6 +10,7 @@ //! token, as in `tests/credits.rs`: nothing here hands a literal to a hash. #[allow(dead_code)] +#[path = "../support/mod.rs"] mod support; use std::path::{Path, PathBuf}; diff --git a/tests/service.rs b/tests/service/main.rs similarity index 99% rename from tests/service.rs rename to tests/service/main.rs index 10ea8a99..0bb1adb1 100644 --- a/tests/service.rs +++ b/tests/service/main.rs @@ -13,6 +13,7 @@ use waveflow_server::storage::database::AccountRole; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. #[allow(dead_code)] +#[path = "../support/mod.rs"] mod support; use support::*; diff --git a/tests/subsonic_browse.rs b/tests/subsonic_browse/main.rs similarity index 99% rename from tests/subsonic_browse.rs rename to tests/subsonic_browse/main.rs index 10015eb9..96664f2f 100644 --- a/tests/subsonic_browse.rs +++ b/tests/subsonic_browse/main.rs @@ -16,6 +16,7 @@ use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. #[allow(dead_code)] +#[path = "../support/mod.rs"] mod support; use support::*; diff --git a/tests/subsonic_fields.rs b/tests/subsonic_fields/main.rs similarity index 99% rename from tests/subsonic_fields.rs rename to tests/subsonic_fields/main.rs index 27d99990..b165ce25 100644 --- a/tests/subsonic_fields.rs +++ b/tests/subsonic_fields/main.rs @@ -14,6 +14,7 @@ use waveflow_server::storage::database::LibraryVisibility; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. #[allow(dead_code)] +#[path = "../support/mod.rs"] mod support; use support::*; diff --git a/tests/sync.rs b/tests/sync/main.rs similarity index 99% rename from tests/sync.rs rename to tests/sync/main.rs index 4d5161ec..2c18cc9a 100644 --- a/tests/sync.rs +++ b/tests/sync/main.rs @@ -26,6 +26,7 @@ use waveflow_server::storage::sync::MAX_SYNC_LIMIT; // Not every target uses every fixture, and a shared module is not dead // code for being partly unused here. #[allow(dead_code)] +#[path = "../support/mod.rs"] mod support; use support::*; From 9931da8823630c7c2cb7e4200751d881cb7db9c1 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 18:00:15 +0100 Subject: [PATCH 09/13] refactor(tests): split the ten targets along their subjects Each main.rs keeps the imports, the support module and the fixtures more than one file uses; every other file takes one subject with the helpers only it calls, behind use super::*. Items move verbatim and in order, and no line was lost: the trimmed lines of each old file and its directory differ only by the new //! headers, the use lines and the mod lines. Comments that named a file now name its new home, and three that pointed 'above' at what moved to a sibling name it. Signed-off-by: InstaZDLL --- src/quality/bandwidth/mod.rs | 2 +- tests/canvas/ingest.rs | 589 +++++++++++ tests/canvas/main.rs | 1334 +------------------------ tests/canvas/routes.rs | 650 ++++++++++++ tests/canvas/sweep.rs | 106 ++ tests/cli/key.rs | 566 +++++++++++ tests/cli/library.rs | 212 ++++ tests/cli/main.rs | 1277 +----------------------- tests/cli/scrobbling.rs | 511 ++++++++++ tests/credits/main.rs | 881 +--------------- tests/credits/page.rs | 416 ++++++++ tests/credits/splits.rs | 472 +++++++++ tests/discovery/listening.rs | 358 +++++++ tests/discovery/lyrics.rs | 475 +++++++++ tests/discovery/main.rs | 998 +------------------ tests/discovery/similar.rs | 171 ++++ tests/migrations/copies.rs | 461 +++++++++ tests/migrations/main.rs | 860 +--------------- tests/migrations/upgrades.rs | 400 ++++++++ tests/quality/analysis.rs | 380 +++++++ tests/quality/main.rs | 953 +----------------- tests/quality/routes.rs | 579 +++++++++++ tests/scrobbling/instances.rs | 2 +- tests/service/lifecycle.rs | 145 +++ tests/service/main.rs | 1018 +------------------ tests/service/probes.rs | 423 ++++++++ tests/service/web.rs | 457 +++++++++ tests/subsonic_browse/album_lists.rs | 428 ++++++++ tests/subsonic_browse/browse.rs | 233 +++++ tests/subsonic_browse/main.rs | 1317 +----------------------- tests/subsonic_browse/search.rs | 295 ++++++ tests/subsonic_browse/top_songs.rs | 370 +++++++ tests/subsonic_fields/albums.rs | 293 ++++++ tests/subsonic_fields/credits.rs | 535 ++++++++++ tests/subsonic_fields/main.rs | 1123 +-------------------- tests/subsonic_fields/media_items.rs | 302 ++++++ tests/sync/claims.rs | 331 ++++++ tests/sync/journal.rs | 850 ++++++++++++++++ tests/sync/library_feed.rs | 209 ++++ tests/sync/main.rs | 1382 +------------------------- 40 files changed, 11259 insertions(+), 11105 deletions(-) create mode 100644 tests/canvas/ingest.rs create mode 100644 tests/canvas/routes.rs create mode 100644 tests/canvas/sweep.rs create mode 100644 tests/cli/key.rs create mode 100644 tests/cli/library.rs create mode 100644 tests/cli/scrobbling.rs create mode 100644 tests/credits/page.rs create mode 100644 tests/credits/splits.rs create mode 100644 tests/discovery/listening.rs create mode 100644 tests/discovery/lyrics.rs create mode 100644 tests/discovery/similar.rs create mode 100644 tests/migrations/copies.rs create mode 100644 tests/migrations/upgrades.rs create mode 100644 tests/quality/analysis.rs create mode 100644 tests/quality/routes.rs create mode 100644 tests/service/lifecycle.rs create mode 100644 tests/service/probes.rs create mode 100644 tests/service/web.rs create mode 100644 tests/subsonic_browse/album_lists.rs create mode 100644 tests/subsonic_browse/browse.rs create mode 100644 tests/subsonic_browse/search.rs create mode 100644 tests/subsonic_browse/top_songs.rs create mode 100644 tests/subsonic_fields/albums.rs create mode 100644 tests/subsonic_fields/credits.rs create mode 100644 tests/subsonic_fields/media_items.rs create mode 100644 tests/sync/claims.rs create mode 100644 tests/sync/journal.rs create mode 100644 tests/sync/library_feed.rs diff --git a/src/quality/bandwidth/mod.rs b/src/quality/bandwidth/mod.rs index fe471e93..7d64bb1a 100644 --- a/src/quality/bandwidth/mod.rs +++ b/src/quality/bandwidth/mod.rs @@ -7,7 +7,7 @@ //! the search are carried here unchanged; what is added is //! [`BandwidthMeter::finish_with_spectrum`], which hands back the averaged //! spectrum the meter already computed. Two tests hold the port to the -//! original: the public constants must be equal, and `tests/quality.rs` measures +//! original: the public constants must be equal, and `tests/quality/analysis.rs` measures //! the same signals through both and compares. //! //! Every lossy encoder low-passes before it encodes: LAME cuts a 128 kbps diff --git a/tests/canvas/ingest.rs b/tests/canvas/ingest.rs new file mode 100644 index 00000000..2b4883d3 --- /dev/null +++ b/tests/canvas/ingest.rs @@ -0,0 +1,589 @@ +//! Taking a canvas in: stored once and shared by reference, owned by the +//! library, refused when it is not a short loop or crosses the quota, and +//! stripped of any sound. + +use super::*; + +/// An mp4 carrying sound and no picture. A canvas without a video stream is not +/// a canvas, whatever else it is. +fn soundtrack_only(dir: &std::path::Path) -> Vec { + let path = dir.join("audio-only.mp4"); + let output = std::process::Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:a", + "aac", + ]) + .arg(&path) + .output() + .expect("ffmpeg must be on PATH"); + assert!(output.status.success()); + std::fs::read(&path).unwrap() +} + +#[tokio::test] +async fn a_canvas_is_stored_once_shared_by_reference_and_erased_with_the_last_link() { + let (temp, config, state) = canvas_app(|_| {}).await; + let fixture = fixture(&config, &state, "canvas-store", 2).await; + let bytes = canvas_bytes(temp.path(), "loop.mp4", "1", "black"); + + let placed = state + .services + .place_canvas(fixture.owner, fixture.tracks[0], &bytes, None) + .await + .unwrap(); + assert_eq!(placed.format, "mp4"); + assert_eq!(placed.byte_size, bytes.len() as i64); + assert_eq!(stored_files(&config), vec![placed.file_name()]); + + // The alias resolves the link, and the fingerprint resolves the content. + // Both answer the same blob to somebody entitled to the track. + assert_eq!( + state + .services + .canvas_for_track(fixture.owner, fixture.tracks[0]) + .await + .unwrap(), + Some(placed.clone()) + ); + assert_eq!( + state + .services + .canvas_for_user(fixture.owner, &placed.hash) + .await + .unwrap(), + Some(placed.clone()) + ); + + // The twelve tracks of an album that share one loop share the bytes. Here + // there are two, and one file. + let second = state + .services + .place_canvas(fixture.owner, fixture.tracks[1], &bytes, None) + .await + .unwrap(); + assert_eq!(second, placed); + assert_eq!(stored_files(&config), vec![placed.file_name()]); + + // Removing one link removes a row, not a blob: the other track still names + // it, and a file erased here would be a dead link for somebody else. + state + .services + .remove_canvas(fixture.owner, fixture.tracks[0], None) + .await + .unwrap(); + assert_eq!( + state + .services + .canvas_for_track(fixture.owner, fixture.tracks[0]) + .await + .unwrap(), + None + ); + assert_eq!(stored_files(&config), vec![placed.file_name()]); + assert!(state + .services + .canvas_for_user(fixture.owner, &placed.hash) + .await + .unwrap() + .is_some()); + + // The last one takes the bytes with it. + state + .services + .remove_canvas(fixture.owner, fixture.tracks[1], None) + .await + .unwrap(); + assert!(stored_files(&config).is_empty()); + assert_eq!( + state + .services + .canvas_for_user(fixture.owner, &placed.hash) + .await + .unwrap(), + None + ); + + // Removing what is not there is not a silent success: there was nothing to + // take away, and the caller asked to take something away. + assert!(matches!( + state + .services + .remove_canvas(fixture.owner, fixture.tracks[1], None) + .await, + Err(waveflow_server::services::ServiceError::NotFound) + )); +} + +#[tokio::test] +async fn replacing_a_canvas_releases_the_one_it_replaced() { + let (temp, config, state) = canvas_app(|_| {}).await; + let fixture = fixture(&config, &state, "canvas-replace", 2).await; + let first = canvas_bytes(temp.path(), "first.mp4", "1", "black"); + let second = canvas_bytes(temp.path(), "second.mp4", "1", "white"); + + let first = state + .services + .place_canvas(fixture.owner, fixture.tracks[0], &first, None) + .await + .unwrap(); + let second = state + .services + .place_canvas(fixture.owner, fixture.tracks[0], &second, None) + .await + .unwrap(); + assert_ne!(first.hash, second.hash); + // A replacement is a removal too. The loop the track used to carry lost its + // last reference, so its row and its bytes are gone. + assert_eq!(stored_files(&config), vec![second.file_name()]); + assert_eq!( + state + .services + .canvas_for_user(fixture.owner, &first.hash) + .await + .unwrap(), + None + ); + + // Unless another track still holds it. Both tracks take the first loop, + // then the first track is given the second one back: the blob survives the + // replacement because track two is still naming it. + let first_bytes = std::fs::read(temp.path().join("first.mp4")).unwrap(); + let second_bytes = std::fs::read(temp.path().join("second.mp4")).unwrap(); + for track in &fixture.tracks { + assert_eq!( + state + .services + .place_canvas(fixture.owner, *track, &first_bytes, None) + .await + .unwrap() + .hash, + first.hash + ); + } + assert_eq!(stored_files(&config), vec![first.file_name()]); + + state + .services + .place_canvas(fixture.owner, fixture.tracks[0], &second_bytes, None) + .await + .unwrap(); + let mut expected = vec![first.file_name(), second.file_name()]; + expected.sort(); + assert_eq!(stored_files(&config), expected); +} + +#[tokio::test] +async fn a_canvas_belongs_to_the_library_and_not_to_whoever_knows_its_name() { + let (temp, config, state) = canvas_app(|_| {}).await; + let owner = fixture(&config, &state, "canvas-owner", 1).await; + let stranger = fixture(&config, &state, "canvas-stranger", 1).await; + let bytes = canvas_bytes(temp.path(), "loop.mp4", "1", "black"); + + let placed = state + .services + .place_canvas(owner.owner, owner.tracks[0], &bytes, None) + .await + .unwrap(); + + // A hash identifies content and proves nothing. Two accounts that are + // strangers to each other can hold the same loop, so knowing the + // fingerprint establishes no more than knowing a track id does. + assert_eq!( + state + .services + .canvas_for_user(stranger.owner, &placed.hash) + .await + .unwrap(), + None + ); + // A track of another library is missing and belongs to someone else, and + // both answer the same thing. + assert_eq!( + state + .services + .canvas_for_track(stranger.owner, owner.tracks[0]) + .await + .unwrap(), + None + ); + assert!(matches!( + state + .services + .place_canvas(stranger.owner, owner.tracks[0], &bytes, None) + .await, + Err(waveflow_server::services::ServiceError::NotFound) + )); + assert!(matches!( + state + .services + .remove_canvas(stranger.owner, owner.tracks[0], None) + .await, + Err(waveflow_server::services::ServiceError::NotFound) + )); + + // And the stranger placing on their own track shares the blob without + // learning anything about the neighbour who already held it. + let theirs = state + .services + .place_canvas(stranger.owner, stranger.tracks[0], &bytes, None) + .await + .unwrap(); + assert_eq!(theirs.hash, placed.hash); + assert_eq!(stored_files(&config), vec![placed.file_name()]); +} + +#[tokio::test] +async fn a_closed_library_takes_no_canvas_but_still_gives_one_back() { + let (temp, config, state) = canvas_app(|_| {}).await; + let fixture = fixture(&config, &state, "canvas-closed", 1).await; + let bytes = canvas_bytes(temp.path(), "loop.mp4", "1", "black"); + state + .services + .place_canvas(fixture.owner, fixture.tracks[0], &bytes, None) + .await + .unwrap(); + + state + .db + .set_library_accepts_canvas(fixture.owner, fixture.library, false, now_ms()) + .await + .unwrap(); + + // The flag answers "may a member spend the operator's disk", so it closes + // the door that spends it. + assert!(matches!( + state + .services + .place_canvas(fixture.owner, fixture.tracks[0], &bytes, None) + .await, + Err(waveflow_server::services::ServiceError::Forbidden) + )); + // Taking something away never spends it, and closing a library must not + // strand what it already holds. + state + .services + .remove_canvas(fixture.owner, fixture.tracks[0], None) + .await + .unwrap(); + assert!(stored_files(&config).is_empty()); +} + +#[tokio::test] +async fn what_is_not_a_short_loop_is_refused_and_leaves_nothing_behind() { + let (temp, config, state) = canvas_app(|canvas| canvas.max_duration_secs = 2).await; + let library = fixture(&config, &state, "canvas-refused", 1).await; + + // Bytes ffprobe cannot read at all. A verdict on the offer, not an outage. + assert!(matches!( + state + .services + .place_canvas( + library.owner, + library.tracks[0], + b"this is not a container", + None + ) + .await, + Err(waveflow_server::services::ServiceError::Invalid) + )); + + // A container on the list, carrying sound and no picture. + assert!(matches!( + state + .services + .place_canvas( + library.owner, + library.tracks[0], + &soundtrack_only(temp.path()), + None + ) + .await, + Err(waveflow_server::services::ServiceError::Invalid) + )); + + // A loop that is not short. Without this bound, "a short loop" becomes + // video hosting, which is a different product with different costs. + let long = canvas_bytes(temp.path(), "long.mp4", "5", "black"); + assert!(matches!( + state + .services + .place_canvas(library.owner, library.tracks[0], &long, None) + .await, + Err(waveflow_server::services::ServiceError::Invalid) + )); + + // Larger than the ceiling, refused before anything is written. + let (temp2, config2, state2) = canvas_app(|canvas| { + canvas.max_bytes = 1024; + canvas.library_quota_bytes = 1024 * 1024; + }) + .await; + let small = fixture(&config2, &state2, "canvas-ceiling", 1).await; + let ordinary = canvas_bytes(temp2.path(), "loop.mp4", "1", "black"); + assert!(ordinary.len() > 1024, "the fixture must exceed the ceiling"); + assert!(matches!( + state2 + .services + .place_canvas(small.owner, small.tracks[0], &ordinary, None) + .await, + Err(waveflow_server::services::ServiceError::Invalid) + )); + + // Nothing refused leaves anything behind — no staged file, no blob, no row. + assert!(stored_files(&config).is_empty()); + assert!(stored_files(&config2).is_empty()); + assert_eq!( + state + .services + .canvas_for_track(library.owner, library.tracks[0]) + .await + .unwrap(), + None + ); +} + +#[tokio::test] +async fn the_quota_counts_distinct_blobs_and_refuses_the_one_that_would_cross_it() { + let first_size = { + let probe = tempfile::tempdir().unwrap(); + canvas_bytes(probe.path(), "probe.mp4", "1", "black").len() as i64 + }; + // Room for one canvas of that size and not two. The second placement is the + // one that has to be refused, and a quota nothing ever meets proves nothing. + let (temp, config, state) = canvas_app(|canvas| { + canvas.max_bytes = first_size * 2; + canvas.library_quota_bytes = first_size + first_size / 2; + }) + .await; + let fixture = fixture(&config, &state, "canvas-quota", 3).await; + let first = canvas_bytes(temp.path(), "first.mp4", "1", "black"); + let second = canvas_bytes(temp.path(), "second.mp4", "1", "white"); + // The boundary this test is about, stated rather than assumed, and stated + // against the limits the app actually runs under rather than against the + // arithmetic that chose them. `first_size` came from a separate ffmpeg run, + // so it sized the quota but it is not what the quota will charge: that is + // `first.len()`. + let first_used = first.len() as i64; + let second_used = second.len() as i64; + assert!( + first_used <= config.canvas.library_quota_bytes, + "the first canvas has to fit, or the refusal below proves nothing" + ); + assert!( + second_used <= config.canvas.max_bytes, + "the second has to pass the per-canvas ceiling, so what stops it is the quota" + ); + assert!( + first_used + second_used > config.canvas.library_quota_bytes, + "and it has to be the one that crosses" + ); + + let placed = state + .services + .place_canvas(fixture.owner, fixture.tracks[0], &first, None) + .await + .unwrap(); + + // The same blob on another track is free: an album's shared loop is billed + // once however many tracks name it, which is the deduplication showing up + // in the price. + state + .services + .place_canvas(fixture.owner, fixture.tracks[1], &first, None) + .await + .unwrap(); + assert_eq!(stored_files(&config), vec![placed.file_name()]); + + // A different blob is not. + assert!(matches!( + state + .services + .place_canvas(fixture.owner, fixture.tracks[2], &second, None) + .await, + Err(waveflow_server::services::ServiceError::Conflict) + )); + // And the refusal left no bytes: the file was written before the row, so a + // transaction that says no has to take them back. + assert_eq!(stored_files(&config), vec![placed.file_name()]); +} + +/// RFC-009 decision 9. A canvas plays over a track that is already playing and +/// the desktop's `