Repository navigation
feat: choose an artist's portrait and banner by hand #309
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| -- An artist's portrait and banner, chosen by hand (RFC-013 decision 2). | ||
| -- | ||
| -- A table of its own and never `artist.artwork_hash`: RFC-011's portrait fill | ||
| -- rewrites that column whenever it differs, so a choice stored there would be | ||
| -- replaced by the provider's the next time anyone opened the page. Keyed by | ||
| -- the artist row, which is per library: each library's owner chooses for | ||
| -- their own. The artwork it names is kept by the sweep for as long as it is | ||
| -- named; no `ON DELETE` on that key, so a sweep that forgot would fail rather | ||
| -- than leave a choice pointing at nothing. | ||
| CREATE TABLE artist_image_choice ( | ||
| artist_id TEXT NOT NULL REFERENCES artist(id) ON DELETE CASCADE, | ||
| kind TEXT NOT NULL CHECK (kind IN ('portrait', 'banner')), | ||
| artwork_hash TEXT NOT NULL REFERENCES artwork(hash), | ||
| source TEXT NOT NULL CHECK (source IN ('upload', 'deezer')), | ||
| set_by TEXT NOT NULL, | ||
| set_at INTEGER NOT NULL, | ||
| PRIMARY KEY (artist_id, kind) | ||
| ) STRICT; | ||
|
|
||
| CREATE INDEX artist_image_choice_artwork ON artist_image_choice(artwork_hash); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| //! An artist's portrait and banner, chosen by hand (RFC-013). | ||
| //! | ||
| //! A choice lives in `artist_image_choice`, never in `artist.artwork_hash`, | ||
| //! which RFC-011's portrait fill rewrites. It belongs to the library: set by | ||
| //! those who may correct its metadata, seen by every member. Every write takes | ||
| //! the writer gate and records the artist library event (RFC-007), as a | ||
| //! fetched portrait does, so clients re-read the artist on their own. | ||
|
|
||
| use std::str::FromStr; | ||
|
|
||
| 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; | ||
|
|
||
| /// Which of the two images. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub enum ArtistImageKind { | ||
| Portrait, | ||
| Banner, | ||
| } | ||
|
|
||
| impl ArtistImageKind { | ||
| /// `None` for anything but `portrait` and `banner`, so a route can answer | ||
| /// an unknown kind as it answers an unknown path. | ||
| pub fn parse(value: &str) -> Option<Self> { | ||
| match value { | ||
| "portrait" => Some(Self::Portrait), | ||
| "banner" => Some(Self::Banner), | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
| fn as_str(self) -> &'static str { | ||
| match self { | ||
| Self::Portrait => "portrait", | ||
| Self::Banner => "banner", | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// The artist's library, if the caller may choose its images: `NotFound` for | ||
| /// an artist they cannot see and `Forbidden` for one they may only read, both | ||
| /// answered as the same `404`. | ||
| async fn chooser_library( | ||
| tx: &mut Transaction<'_, Sqlite>, | ||
| user_id: Uuid, | ||
| artist_id: Uuid, | ||
| ) -> Result<Uuid, ServiceError> { | ||
| let row = sqlx::query( | ||
| "SELECT ar.library_id, m.role FROM artist ar \ | ||
| JOIN library_member m ON m.library_id=ar.library_id \ | ||
| WHERE m.user_id=? AND ar.id=?", | ||
| ) | ||
| .bind(user_id.to_string()) | ||
| .bind(artist_id.to_string()) | ||
| .fetch_optional(&mut **tx) | ||
| .await? | ||
| .ok_or(ServiceError::NotFound)?; | ||
| let role = LibraryRole::from_str(row.try_get::<&str, _>("role")?) | ||
| .map_err(|_| ServiceError::Invalid)?; | ||
| if !role.may_write_metadata() { | ||
| return Err(ServiceError::Forbidden); | ||
| } | ||
| Uuid::parse_str(row.try_get::<&str, _>("library_id")?).map_err(|_| ServiceError::Invalid) | ||
| } | ||
|
|
||
| async fn record_artist_change( | ||
| tx: &mut Transaction<'_, Sqlite>, | ||
| library_id: Uuid, | ||
| artist_id: Uuid, | ||
| now: i64, | ||
| ) -> Result<(), sqlx::Error> { | ||
| crate::catalog::record_library_event( | ||
| tx, | ||
| crate::catalog::LibraryChange { | ||
| library_id, | ||
| entity_type: "artist", | ||
| entity_id: artist_id, | ||
| action: "upsert", | ||
| payload: serde_json::json!({}), | ||
| changed_at: now, | ||
| origin_device_id: None, | ||
| }, | ||
| ) | ||
| .await | ||
| } | ||
|
|
||
| impl DomainServices { | ||
| /// The chosen banner and what was chosen, for the artist page. Read after | ||
| /// access to the artist was established by the caller. | ||
| pub(super) async fn artist_image_choices( | ||
| &self, | ||
| artist_id: Uuid, | ||
| ) -> Result<(Option<String>, ImageChoices), ServiceError> { | ||
| let rows = sqlx::query( | ||
| "SELECT kind, artwork_hash, source, set_at FROM artist_image_choice \ | ||
| WHERE artist_id=?", | ||
| ) | ||
| .bind(artist_id.to_string()) | ||
| .fetch_all(self.db.pool()) | ||
| .await?; | ||
| let mut banner = None; | ||
| let mut choices = ImageChoices::default(); | ||
| for row in rows { | ||
| let choice = ImageChoice { | ||
| source: row.try_get("source")?, | ||
| set_at: row.try_get("set_at")?, | ||
| }; | ||
| match row.try_get::<&str, _>("kind")? { | ||
| "portrait" => choices.portrait = Some(choice), | ||
| "banner" => { | ||
| banner = Some(row.try_get("artwork_hash")?); | ||
| choices.banner = Some(choice); | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
| Ok((banner, choices)) | ||
| } | ||
|
|
||
| /// Choose an artist's portrait or banner from an image someone sent. | ||
| /// | ||
| /// `Invalid` for bytes that are not JPEG, PNG or WebP within 10 MiB — | ||
| /// recognised from the bytes, never from a name. The file is stored once | ||
| /// per content, before the writer gate is taken (it is file I/O); the row | ||
| /// and the choice are written under it, with the artist event. | ||
| pub async fn choose_artist_image( | ||
| &self, | ||
| user_id: Uuid, | ||
| artist_id: Uuid, | ||
| kind: ArtistImageKind, | ||
| bytes: Vec<u8>, | ||
| ) -> Result<(), ServiceError> { | ||
| let image = Portrait::from_bytes(bytes).ok_or(ServiceError::Invalid)?; | ||
| // Asked before the file is written, so someone who may not choose | ||
| // cannot fill the store with images nothing will ever name. Asked again | ||
| // under the gate below, where the answer is the one the write keeps. | ||
| { | ||
| let mut read = self.db.pool().begin().await?; | ||
| chooser_library(&mut read, user_id, artist_id).await?; | ||
| } | ||
| crate::portrait::store(&self.artwork_dir, &image) | ||
| .await | ||
| .map_err(|_| ServiceError::Unavailable)?; | ||
| let _writer = self.db.writer_guard().await; | ||
| let mut tx = self.db.pool().begin().await?; | ||
| let library_id = chooser_library(&mut tx, user_id, artist_id).await?; | ||
| let now = now_ms(); | ||
| sqlx::query( | ||
| "INSERT INTO artwork (hash, format, source, byte_size, created_at) \ | ||
| VALUES (?, ?, 'upload', ?, ?) ON CONFLICT (hash) DO NOTHING", | ||
| ) | ||
| .bind(&image.hash) | ||
| .bind(image.format) | ||
| .bind(image.bytes.len() as i64) | ||
| .bind(now) | ||
| .execute(&mut *tx) | ||
| .await?; | ||
| sqlx::query( | ||
| "INSERT INTO artist_image_choice \ | ||
| (artist_id, kind, artwork_hash, source, set_by, set_at) \ | ||
| VALUES (?, ?, ?, 'upload', ?, ?) \ | ||
| ON CONFLICT(artist_id, kind) DO UPDATE SET \ | ||
| artwork_hash=excluded.artwork_hash, source=excluded.source, \ | ||
| set_by=excluded.set_by, set_at=excluded.set_at", | ||
| ) | ||
| .bind(artist_id.to_string()) | ||
| .bind(kind.as_str()) | ||
| .bind(&image.hash) | ||
| .bind(user_id.to_string()) | ||
| .bind(now) | ||
| .execute(&mut *tx) | ||
| .await?; | ||
| record_artist_change(&mut tx, library_id, artist_id, now).await?; | ||
| tx.commit().await?; | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Go back to the automatic image: for the portrait, whatever the files or | ||
| /// a provider gave; for the banner, none. The image itself stays in the | ||
| /// store until the sweep finds nothing naming it. `NotFound` when nothing | ||
| /// was chosen. | ||
| pub async fn remove_artist_image( | ||
| &self, | ||
| user_id: Uuid, | ||
| artist_id: Uuid, | ||
| kind: ArtistImageKind, | ||
| ) -> Result<(), ServiceError> { | ||
| let _writer = self.db.writer_guard().await; | ||
| let mut tx = self.db.pool().begin().await?; | ||
| let library_id = chooser_library(&mut tx, user_id, artist_id).await?; | ||
| let removed = sqlx::query("DELETE FROM artist_image_choice WHERE artist_id=? AND kind=?") | ||
| .bind(artist_id.to_string()) | ||
| .bind(kind.as_str()) | ||
| .execute(&mut *tx) | ||
| .await? | ||
| .rows_affected(); | ||
| if removed == 0 { | ||
| return Err(ServiceError::NotFound); | ||
| } | ||
| record_artist_change(&mut tx, library_id, artist_id, now_ms()).await?; | ||
| tx.commit().await?; | ||
| Ok(()) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.