From 539576fd7e05001502939df56a602d104485d440 Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Tue, 31 Dec 2024 11:35:27 +0000 Subject: [PATCH 01/18] feat: Add API Route for generating playlist Cover Art --- .../components/ReleaseTimeline.tsx | 4 +- listenbrainz/webserver/views/art_api.py | 79 ++++++++++++++++++- listenbrainz/webserver/views/playlist.py | 46 ++++++++++- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/frontend/js/src/explore/fresh-releases/components/ReleaseTimeline.tsx b/frontend/js/src/explore/fresh-releases/components/ReleaseTimeline.tsx index 13f57b5535..b716340293 100644 --- a/frontend/js/src/explore/fresh-releases/components/ReleaseTimeline.tsx +++ b/frontend/js/src/explore/fresh-releases/components/ReleaseTimeline.tsx @@ -129,7 +129,9 @@ export default function ReleaseTimeline(props: ReleaseTimelineProps) { const { releases, order, direction } = props; const [currentValue, setCurrentValue] = React.useState(); - const [marks, setMarks] = React.useState<{ [key: number]: React.ReactNode }>({}); + const [marks, setMarks] = React.useState<{ [key: number]: React.ReactNode }>( + {} + ); const screenMd = useMediaQuery("(max-width: 992px)"); // @screen-md diff --git a/listenbrainz/webserver/views/art_api.py b/listenbrainz/webserver/views/art_api.py index 67b33a9bd5..cf40a4367f 100755 --- a/listenbrainz/webserver/views/art_api.py +++ b/listenbrainz/webserver/views/art_api.py @@ -4,16 +4,18 @@ import listenbrainz.db.user as db_user import listenbrainz.db.year_in_music as db_yim +import listenbrainz.db.playlist as db_playlist from brainzutils.ratelimit import ratelimit from flask import request, render_template, Blueprint, current_app from listenbrainz.art.cover_art_generator import CoverArtGenerator -from listenbrainz.webserver import db_conn +from listenbrainz.webserver import db_conn, ts_conn from listenbrainz.webserver.decorators import crossdomain -from listenbrainz.webserver.errors import APIBadRequest, APIInternalServerError -from listenbrainz.webserver.views.api_tools import is_valid_uuid, _parse_bool_arg -from listenbrainz.webserver.views.playlist_api import PLAYLIST_TRACK_EXTENSION_URI +from listenbrainz.webserver.errors import APIBadRequest, APIInternalServerError, APINotFound +from listenbrainz.webserver.views.api_tools import is_valid_uuid, _parse_bool_arg, validate_auth_header +from listenbrainz.webserver.views.playlist_api import PLAYLIST_TRACK_EXTENSION_URI, fetch_playlist_recording_metadata +from listenbrainz.webserver.views.playlist import get_cover_art_options art_api_bp = Blueprint('art_api_v1', __name__) @@ -782,3 +784,72 @@ def cover_art_yim(user_name, year: int = 2024): return "", 204 return svg, 200, {"Content-Type": "image/svg+xml"} + + +@art_api_bp.route("/playlist///", methods=["POST", "OPTIONS"]) +@crossdomain +@ratelimit() +def playlist_cover_art_generate(playlist_mbid, dimension, layout): + """ + Create a cover art grid SVG file from the playlist. + + :param playlist_mbid: The mbid of the playlist for whom to create the cover art. + :type playlist_mbid: ``str`` + :param dimension: The dimension to use for this grid. A grid of dimension 3 has 3 images across + and 3 images down, for a total of 9 images. + :type dimension: ``int`` + :param layout: The layout to be used for this grid. Layout 0 is always a simple grid, but other layouts + may have image images be of different sizes. See https://art.listenbrainz.org for examples + of the available layouts. + :type layout: ``int`` + :statuscode 200: cover art created successfully. + :statuscode 400: Invalid JSON or invalid options in JSON passed. See error message for details. + :resheader Content-Type: *image/svg+xml* + + See the bottom of this document for constants relating to this method. + """ + user = validate_auth_header() + + try: + grid_design = CoverArtGenerator.GRID_TILE_DESIGNS[dimension][layout] + except IndexError: + return APIBadRequest(f"layout {layout} is not available for dimension {dimension}.") + + playlist = db_playlist.get_by_mbid(db_conn, ts_conn, playlist_mbid, True) + if playlist is None or not playlist.is_visible_by(user["id"]): + raise APINotFound("Cannot find playlist: %s" % playlist_mbid) + + # Check if the playlist has enough tracks to fill the grid + if len(playlist.recordings) < len(grid_design): + raise APIBadRequest("Playlist is too small to generate cover art") + + # Fetch the metadata for the playlist recordings + fetch_playlist_recording_metadata(playlist) + + cac = CoverArtGenerator(current_app.config["MB_DATABASE_URI"], dimension, 250) + if (validation_error := cac.validate_parameters()) is not None: + raise APIBadRequest(validation_error) + + # Get cover art options and repeat images if needed + images = get_cover_art_options(playlist) + images = _repeat_images(images, len(grid_design)) + + # Generate the cover art images + cover_art_images = cac.generate_from_caa_ids(images, layout=layout, cover_art_size=250) + + # Get the playlist name and description + title = playlist.name + desc = playlist.description + image_size = 250 + + # Render the cover art image template + return render_template("art/svg-templates/simple-grid.svg", + background=cac.background, + images=cover_art_images, + title=title, + desc=desc, + entity="release", + width=image_size, + height=image_size), 200, { + 'Content-Type': 'image/svg+xml' + } diff --git a/listenbrainz/webserver/views/playlist.py b/listenbrainz/webserver/views/playlist.py index b2ac6445b1..0750d1f409 100644 --- a/listenbrainz/webserver/views/playlist.py +++ b/listenbrainz/webserver/views/playlist.py @@ -6,6 +6,7 @@ from listenbrainz.webserver.views.api_tools import is_valid_uuid from listenbrainz.webserver.views.playlist_api import fetch_playlist_recording_metadata import listenbrainz.db.playlist as db_playlist +from listenbrainz.art.cover_art_generator import CoverArtGenerator playlist_bp = Blueprint("playlist", __name__) @@ -35,7 +36,37 @@ def playlist_page(playlist_mbid: str): return render_template("index.html", og_meta_tags=og_meta_tags) -@playlist_bp.post("//") +def get_cover_art_options(playlist: db_playlist.Playlist) -> list[dict]: + selected_image_ids = set() + images = [] + + for track in playlist.recordings: + track = track.dict() + additional_metadata = track.get("additional_metadata") + + if not additional_metadata: + continue + + caa_id = additional_metadata.get("caa_id") + caa_release_mbid = additional_metadata.get("caa_release_mbid") + if not (caa_id and caa_release_mbid): + continue + + unique_key = f"{caa_id}-{caa_release_mbid}" + if unique_key not in selected_image_ids: + selected_image_ids.add(unique_key) + images.append({ + "caa_id": caa_id, + "caa_release_mbid": caa_release_mbid, + "title": track.get("title"), + "entity_mbid": str(track.get("mbid")), + "artist": track.get("artist_credit") + }) + + return images + + +@playlist_bp.route("//", methods=["POST"]) @web_listenstore_needed def load_playlist(playlist_mbid: str): """Load a single playlist by id @@ -53,6 +84,19 @@ def load_playlist(playlist_mbid: str): fetch_playlist_recording_metadata(playlist) + images = get_cover_art_options(playlist) + options = [] + + for dimension, designs in CoverArtGenerator.GRID_TILE_DESIGNS.items(): + for layout_idx, design in enumerate(designs): + image_count = len(design) + if len(images) >= image_count: + options.append({ + "dimension": dimension, + "layout": layout_idx + }) + return jsonify({ "playlist": playlist.serialize_jspf(), + "coverArtGridOptions": options }) From 8ce7054295b78abfa9b719ae2325b5522fd4d68b Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Sun, 26 Jan 2025 07:43:09 +0000 Subject: [PATCH 02/18] feat: Add customisable cover art for playlist --- listenbrainz/art/cover_art_generator.py | 5 +- listenbrainz/db/playlist.py | 9 +++- listenbrainz/webserver/views/art_api.py | 6 +-- listenbrainz/webserver/views/playlist.py | 48 +++++++++++++++++++- listenbrainz/webserver/views/playlist_api.py | 9 ++++ 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/listenbrainz/art/cover_art_generator.py b/listenbrainz/art/cover_art_generator.py index 8abf3ee746..c28b222472 100755 --- a/listenbrainz/art/cover_art_generator.py +++ b/listenbrainz/art/cover_art_generator.py @@ -18,7 +18,7 @@ MAX_IMAGE_SIZE = 1024 #: Minimum dimension -MIN_DIMENSION = 2 +MIN_DIMENSION = 1 #: Maximum dimension MAX_DIMENSION = 5 @@ -39,6 +39,9 @@ class CoverArtGenerator: # the bounding box of these cells. The cover art in question will be placed inside this # area. GRID_TILE_DESIGNS = { + 1: [ + ["0"], + ], 2: [ ["0", "1", "2", "3"], ], diff --git a/listenbrainz/db/playlist.py b/listenbrainz/db/playlist.py index 61ff9cd141..d4914bbda3 100644 --- a/listenbrainz/db/playlist.py +++ b/listenbrainz/db/playlist.py @@ -750,9 +750,16 @@ def update_playlist(db_conn, ts_conn, playlist: model_playlist.Playlist): SET name = :name , description = :description , public = :public + , additional_metadata = :additional_metadata WHERE id = :id """) - params = playlist.dict(include={'id', 'name', 'description', 'public'}) + params = { + 'id': playlist.id, + 'name': playlist.name, + 'description': playlist.description, + 'public': playlist.public, + 'additional_metadata': orjson.dumps(playlist.additional_metadata or {}).decode('utf-8') + } ts_conn.execute(query, params) # Unconditionally add collaborators, this allows us to delete all collaborators # if [] is passed in. diff --git a/listenbrainz/webserver/views/art_api.py b/listenbrainz/webserver/views/art_api.py index cf40a4367f..e61e1242e2 100755 --- a/listenbrainz/webserver/views/art_api.py +++ b/listenbrainz/webserver/views/art_api.py @@ -826,7 +826,7 @@ def playlist_cover_art_generate(playlist_mbid, dimension, layout): # Fetch the metadata for the playlist recordings fetch_playlist_recording_metadata(playlist) - cac = CoverArtGenerator(current_app.config["MB_DATABASE_URI"], dimension, 250) + cac = CoverArtGenerator(current_app.config["MB_DATABASE_URI"], dimension, 500) if (validation_error := cac.validate_parameters()) is not None: raise APIBadRequest(validation_error) @@ -835,12 +835,12 @@ def playlist_cover_art_generate(playlist_mbid, dimension, layout): images = _repeat_images(images, len(grid_design)) # Generate the cover art images - cover_art_images = cac.generate_from_caa_ids(images, layout=layout, cover_art_size=250) + cover_art_images = cac.generate_from_caa_ids(images, layout=layout, cover_art_size=500) # Get the playlist name and description title = playlist.name desc = playlist.description - image_size = 250 + image_size = 500 # Render the cover art image template return render_template("art/svg-templates/simple-grid.svg", diff --git a/listenbrainz/webserver/views/playlist.py b/listenbrainz/webserver/views/playlist.py index 0750d1f409..b42b31a540 100644 --- a/listenbrainz/webserver/views/playlist.py +++ b/listenbrainz/webserver/views/playlist.py @@ -6,6 +6,7 @@ from listenbrainz.webserver.views.api_tools import is_valid_uuid from listenbrainz.webserver.views.playlist_api import fetch_playlist_recording_metadata import listenbrainz.db.playlist as db_playlist +from listenbrainz.db.model import playlist as model_playlist from listenbrainz.art.cover_art_generator import CoverArtGenerator playlist_bp = Blueprint("playlist", __name__) @@ -66,6 +67,27 @@ def get_cover_art_options(playlist: db_playlist.Playlist) -> list[dict]: return images +def get_cover_art_for_playlist(playlist: model_playlist.Playlist, images: list[dict], selected_cover_art: dict): + cac = CoverArtGenerator(current_app.config["MB_DATABASE_URI"], selected_cover_art["dimension"], 500) + if (validation_error := cac.validate_parameters()) is not None: + return jsonify({"error": validation_error}), 400 + + cover_art_images = cac.generate_from_caa_ids(images, layout=selected_cover_art["layout"], cover_art_size=500) + title = playlist.name + desc = playlist.description + + return render_template( + "art/svg-templates/simple-grid.svg", + background="transparent", + images=cover_art_images, + title=title, + desc=desc, + entity="album", + width=500, + height=500 + ) + + @playlist_bp.route("//", methods=["POST"]) @web_listenstore_needed def load_playlist(playlist_mbid: str): @@ -96,7 +118,29 @@ def load_playlist(playlist_mbid: str): "layout": layout_idx }) + serialized_playlist = playlist.serialize_jspf() + + selected_cover_art = playlist.additional_metadata.get("cover_art") + if not selected_cover_art and len(options) > 0: + sorted_options = sorted( + options, + key=lambda x: (-x["dimension"], x["layout"]) + ) + selected_cover_art = sorted_options[0] + + serialized_playlist["cover_art"] = selected_cover_art + + try: + if selected_cover_art: + cover_art = get_cover_art_for_playlist(playlist, images, selected_cover_art) + else: + cover_art = None + except Exception: + current_app.logger.error("Error generating cover art for playlist:", exc_info=True) + cover_art = None + return jsonify({ - "playlist": playlist.serialize_jspf(), - "coverArtGridOptions": options + "playlist": serialized_playlist, + "coverArtGridOptions": options, + "coverArt": cover_art }) diff --git a/listenbrainz/webserver/views/playlist_api.py b/listenbrainz/webserver/views/playlist_api.py index 7139485a18..cf3e12edd7 100644 --- a/listenbrainz/webserver/views/playlist_api.py +++ b/listenbrainz/webserver/views/playlist_api.py @@ -500,6 +500,15 @@ def edit_playlist(playlist_mbid): log_raise_400("Collaborator {} doesn't exist".format(collaborator)) collaborator_ids.append(users[collaborator.lower()]["id"]) + try: + if "additional_metadata" in data["playlist"]["extension"][PLAYLIST_EXTENSION_URI]: + if playlist.additional_metadata is None: + playlist.additional_metadata = {} + + playlist.additional_metadata = data["playlist"]["extension"][PLAYLIST_EXTENSION_URI]["additional_metadata"] + except KeyError: + pass + playlist.collaborators = collaborators playlist.collaborator_ids = collaborator_ids From 4bea8f39eda2279a0f816b3cb8b608fbd4693c5d Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Sun, 26 Jan 2025 07:44:06 +0000 Subject: [PATCH 03/18] feat: Revamp Playlist page --- frontend/css/entity-pages.less | 143 ++++++------ frontend/js/src/playlists/Playlist.tsx | 302 +++++++++++++++---------- 2 files changed, 256 insertions(+), 189 deletions(-) diff --git a/frontend/css/entity-pages.less b/frontend/css/entity-pages.less index c427651f14..1b4632aba0 100644 --- a/frontend/css/entity-pages.less +++ b/frontend/css/entity-pages.less @@ -1,83 +1,86 @@ @white-gradient: linear-gradient(to bottom, transparent, #fff 65%); -#entity-page { +.entity-page-header { --bg-color: #e7e4e4; // default color - .entity-page-header { - padding: 2em; - gap: 2em; - background: linear-gradient(45deg, var(--bg-color), transparent 60%); - flex-wrap: wrap; - justify-content: center; - .cover-art { - aspect-ratio: 1; + padding: 2em; + gap: 2em; + background: linear-gradient(45deg, var(--bg-color), transparent 60%); + flex-wrap: wrap; + justify-content: center; + .cover-art { + aspect-ratio: 1; + height: 100%; + max-height: 400px; + max-width: 400px; + flex-grow: 1; + flex-shrink: 0; + border-radius: 5px; + text-align: center; + > * { + max-width: inherit; height: 100%; - max-height: 400px; - max-width: 400px; - flex-grow: 1; - flex-shrink: 0; - border-radius: 5px; - text-align: center; - > * { - max-width: inherit; - height: 100%; - margin-bottom: 0.5em; - } + margin-bottom: 0.5em; } - .artist-info { - flex-grow: 2; - display: flex; - flex-direction: column; - min-width: 15em; - max-height: 29em; + } + .artist-info, + .playlist-info { + flex-grow: 2; + display: flex; + flex-direction: column; + min-width: 15em; + max-height: 29em; + overflow: hidden; + > *:first-child { + line-height: 1em; + } + .wikipedia-extract { + margin-top: 2em; overflow: hidden; - > *:first-child { - line-height: 1em; - } - .wikipedia-extract { - margin-top: 2em; - overflow: hidden; - .content { - max-height: calc(100% - 1.5em); - overflow-y: hidden; - mask-image: linear-gradient(180deg, #000 60%, transparent 98%); - -webkit-mask-image: linear-gradient( - 180deg, - #000 60%, - transparent 98% - ); - } - } - .details { - margin-top: 0; + .content { + max-height: calc(100% - 1.5em); + overflow-y: hidden; + mask-image: linear-gradient(180deg, #000 60%, transparent 98%); + -webkit-mask-image: linear-gradient(180deg, #000 60%, transparent 98%); } } - .lb-radio-button { - align-self: flex-end; - .tags-list { - overflow: hidden; - text-overflow: ellipsis; - max-width: 10em; - } - .btn { - font-size: 1.3em; - } + .details { + margin-top: 0; } - .right-side { - flex-grow: 0.8; - display: flex; - flex-direction: column; - justify-content: space-between; - max-width: 230px; - margin-left: auto; + } + .lb-radio-button { + align-self: flex-end; + .tags-list { + overflow: hidden; + text-overflow: ellipsis; + max-width: 10em; } - .entity-rels { - display: flex; - flex-wrap: wrap; - align-content: flex-end; - justify-content: flex-end; - min-width: 240px; + .btn { + font-size: 1.3em; } } + .right-side { + flex-grow: 0.8; + display: flex; + flex-direction: column; + justify-content: space-between; + max-width: 230px; + margin-left: auto; + } + .entity-rels { + display: flex; + flex-wrap: wrap; + align-content: flex-end; + justify-content: flex-end; + min-width: 240px; + } +} + +.header-with-line .play-tracks-button { + order: 3; + margin-left: 2em; +} + +#entity-page { .top-listeners { max-width: 400px; .listener { @@ -107,10 +110,6 @@ .tags { margin-top: 0.5em; } - .header-with-line .play-tracks-button { - order: 3; - margin-left: 2em; - } .entity-page-content { display: flex; flex-wrap: wrap; diff --git a/frontend/js/src/playlists/Playlist.tsx b/frontend/js/src/playlists/Playlist.tsx index feda50dfd3..3d30a37f41 100644 --- a/frontend/js/src/playlists/Playlist.tsx +++ b/frontend/js/src/playlists/Playlist.tsx @@ -3,7 +3,12 @@ import { findIndex } from "lodash"; import * as React from "react"; -import { faCog, faPlusCircle, faRss } from "@fortawesome/free-solid-svg-icons"; +import { + faCog, + faPlayCircle, + faPlusCircle, + faRss, +} from "@fortawesome/free-solid-svg-icons"; import { sanitizeUrl } from "@braintree/sanitize-url"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; @@ -34,10 +39,19 @@ import { } from "./utils"; import { useBrainzPlayerDispatch } from "../common/brainzplayer/BrainzPlayerContext"; import SyndicationFeedModal from "../components/SyndicationFeedModal"; -import { getBaseUrl } from "../utils/utils"; +import { getAlbumArtFromReleaseGroupMBID, getBaseUrl } from "../utils/utils"; + +type CoverArtGridOptions = { + dimention: number; + layout: number; +}; export type PlaylistPageProps = { - playlist: JSPFObject; + playlist: JSPFObject & { + cover_art: CoverArtGridOptions; + }; + coverArtGridOptions: CoverArtGridOptions[]; + coverArt: string; }; export interface PlaylistPageState { @@ -66,7 +80,11 @@ export default function PlaylistPage() { const dispatch = useBrainzPlayerDispatch(); const navigate = useNavigate(); // Loader data - const { playlist: playlistProps } = useLoaderData() as PlaylistPageProps; + const { + playlist: playlistProps, + coverArtGridOptions, + coverArt, + } = useLoaderData() as PlaylistPageProps; // React-SortableJS expects an 'id' attribute and we can't change it, so add it to each object playlistProps?.playlist?.track?.forEach( (jspfTrack: JSPFTrack, index: number) => { @@ -82,6 +100,9 @@ export default function PlaylistPage() { const [playlist, setPlaylist] = React.useState( playlistProps?.playlist || {} ); + const [coverArtConfig, setCoverArtConfig] = React.useState< + CoverArtGridOptions + >(playlistProps?.cover_art || {}); const { track: tracks } = playlist; // Functions @@ -344,70 +365,31 @@ export default function PlaylistPage() { )} -
+
-
-
-

{playlist.title}

-
- - -
- {customFields?.public && ( - - )} -
-

+ className="cover-art" + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ + __html: sanitize( + coverArt ?? + "" + ), + }} + title={`Cover art for ${playlist.title}`} + /> +

+

{playlist.title}

+
+
{customFields?.public ? "Public " : "Private "} playlist by{" "} {playlist.creator} -

-
-
- {playlist.track?.length} tracks - {totalDurationForDisplay && ( - <> - {totalDurationForDisplay} - )} -
-
Created: {new Date(playlist.date).toLocaleString()}
+
+
+
+
{customFields?.collaborators && Boolean(customFields.collaborators.length) && (
@@ -424,13 +406,26 @@ export default function PlaylistPage() { ))}
)} - {customFields?.last_modified_at && ( +
+
+ {playlist.track?.length} tracks + {totalDurationForDisplay && ( + <> - {totalDurationForDisplay} + )} +
+ +
Created: {new Date(playlist.date).toLocaleString()}
+
+ {customFields?.last_modified_at && ( +
Last modified:{" "} {new Date(customFields.last_modified_at).toLocaleString()}
- )} - {customFields?.copied_from && ( +
+ )} + {customFields?.copied_from && ( + - )} -
- {playlist.annotation && ( + + )} +
+ {playlist.annotation && ( +
- )} -
-
- {userHasRightToEdit && tracks && tracks.length > 10 && ( - +
+
+ - )} -
- {tracks && tracks.length > 0 ? ( - - setPlaylist({ ...playlist, track: newState }) - } + {customFields?.public && ( + )} - {userHasRightToEdit && ( - - - -   Add a track - - - +
+
+
+
+
+

+ Tracks + {Boolean(playlist.track?.length) && ( + )} +

+
+ {userHasRightToEdit && tracks && tracks.length > 10 && ( + + )} +
+ {tracks && tracks.length > 0 ? ( + + setPlaylist({ ...playlist, track: newState }) + } + > + {tracks.map((track: JSPFTrack, index) => { + return ( + + ); + })} + + ) : ( +
+

Nothing in this playlist yet

+
+ )} + {userHasRightToEdit && ( + + + +   Add a track + + + + )}
From d6df1b20a9ce070e31989a55f2c1214aefc9c8a4 Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Sun, 26 Jan 2025 08:01:09 +0000 Subject: [PATCH 04/18] feat: Add method to fetch playlist cover art in APIService --- frontend/js/src/utils/APIService.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frontend/js/src/utils/APIService.ts b/frontend/js/src/utils/APIService.ts index d27f0c140f..63fe39a206 100644 --- a/frontend/js/src/utils/APIService.ts +++ b/frontend/js/src/utils/APIService.ts @@ -1822,4 +1822,21 @@ export default class APIService { await this.checkStatus(response); return response.json(); }; + + getPlaylistCoverArt = async ( + playlist_mbid: string, + dimension: number, + layout: number, + userToken: string + ): Promise => { + const url = `${this.APIBaseURI}/art/playlist/${playlist_mbid}/${dimension}/${layout}`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Token ${userToken}`, + }, + }); + await this.checkStatus(response); + return response.text(); + }; } From 4d7892895fbd2d0d82c29d7b35a7271d245d70fe Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Sun, 26 Jan 2025 08:01:43 +0000 Subject: [PATCH 05/18] format: lint --- listenbrainz/webserver/views/playlist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/listenbrainz/webserver/views/playlist.py b/listenbrainz/webserver/views/playlist.py index b42b31a540..6744fe231a 100644 --- a/listenbrainz/webserver/views/playlist.py +++ b/listenbrainz/webserver/views/playlist.py @@ -67,7 +67,7 @@ def get_cover_art_options(playlist: db_playlist.Playlist) -> list[dict]: return images -def get_cover_art_for_playlist(playlist: model_playlist.Playlist, images: list[dict], selected_cover_art: dict): +def get_cover_art_for_playlist(playlist: model_playlist.Playlist, images: list[dict], selected_cover_art: dict): cac = CoverArtGenerator(current_app.config["MB_DATABASE_URI"], selected_cover_art["dimension"], 500) if (validation_error := cac.validate_parameters()) is not None: return jsonify({"error": validation_error}), 400 From 630d797f62b8fa9d336246f12412ef48507b1157 Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Tue, 4 Feb 2025 17:22:54 +0000 Subject: [PATCH 06/18] feat: Add playlist cover art selection UI and styling --- frontend/css/playlists.less | 46 +++++++++++++++++++ .../img/playlist-cover-art/cover-art_1-0.svg | 4 ++ .../img/playlist-cover-art/cover-art_2-0.svg | 7 +++ .../img/playlist-cover-art/cover-art_3-0.svg | 12 +++++ .../img/playlist-cover-art/cover-art_3-1.svg | 11 +++++ .../img/playlist-cover-art/cover-art_3-2.svg | 9 ++++ .../img/playlist-cover-art/cover-art_4-0.svg | 19 ++++++++ .../img/playlist-cover-art/cover-art_4-1.svg | 16 +++++++ .../img/playlist-cover-art/cover-art_4-2.svg | 13 ++++++ .../img/playlist-cover-art/cover-art_4-3.svg | 11 +++++ .../img/playlist-cover-art/cover-art_5-0.svg | 28 +++++++++++ .../img/playlist-cover-art/cover-art_5-1.svg | 14 ++++++ frontend/js/src/playlists/Playlist.tsx | 11 ++--- .../components/CreateOrEditPlaylistModal.tsx | 44 +++++++++++++++++- .../src/playlists/components/PlaylistMenu.tsx | 10 +++- .../recommendations/RecommendationsPage.tsx | 8 ++-- frontend/js/src/utils/types.d.ts | 8 +++- 17 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 frontend/img/playlist-cover-art/cover-art_1-0.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_2-0.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_3-0.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_3-1.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_3-2.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_4-0.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_4-1.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_4-2.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_4-3.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_5-0.svg create mode 100644 frontend/img/playlist-cover-art/cover-art_5-1.svg diff --git a/frontend/css/playlists.less b/frontend/css/playlists.less index 076a14b1d0..17ee43154c 100644 --- a/frontend/css/playlists.less +++ b/frontend/css/playlists.less @@ -231,3 +231,49 @@ align-self: center; min-width: 3em; } + +#CreateOrEditPlaylistModal { + .cover-art-grid { + display: flex; + gap: 10px; + } + + .cover-art-option { + position: relative; + cursor: pointer; + } + + .cover-art-radio { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + .cover-art-image { + width: 100%; + height: auto; + border: 2px solid transparent; + border-radius: 4px; + transition: all 0.2s; + } + + .cover-art-radio:checked + .cover-art-image { + border-color: #353070; + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(53, 48, 112, 0.3); + border-radius: 2px; + } + } + + .cover-art-radio:focus + .cover-art-image { + box-shadow: 0 0 0 2px rgba(53, 48, 112, 0.5); + } +} diff --git a/frontend/img/playlist-cover-art/cover-art_1-0.svg b/frontend/img/playlist-cover-art/cover-art_1-0.svg new file mode 100644 index 0000000000..14a2da6d83 --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_1-0.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_2-0.svg b/frontend/img/playlist-cover-art/cover-art_2-0.svg new file mode 100644 index 0000000000..c5ecce6056 --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_2-0.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_3-0.svg b/frontend/img/playlist-cover-art/cover-art_3-0.svg new file mode 100644 index 0000000000..fe1091b81e --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_3-0.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_3-1.svg b/frontend/img/playlist-cover-art/cover-art_3-1.svg new file mode 100644 index 0000000000..df7f31a239 --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_3-1.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_3-2.svg b/frontend/img/playlist-cover-art/cover-art_3-2.svg new file mode 100644 index 0000000000..1ea960d681 --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_3-2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_4-0.svg b/frontend/img/playlist-cover-art/cover-art_4-0.svg new file mode 100644 index 0000000000..5fbde1bc9b --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_4-0.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_4-1.svg b/frontend/img/playlist-cover-art/cover-art_4-1.svg new file mode 100644 index 0000000000..dbddd61449 --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_4-1.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_4-2.svg b/frontend/img/playlist-cover-art/cover-art_4-2.svg new file mode 100644 index 0000000000..1baeca95e4 --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_4-2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_4-3.svg b/frontend/img/playlist-cover-art/cover-art_4-3.svg new file mode 100644 index 0000000000..df7f31a239 --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_4-3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_5-0.svg b/frontend/img/playlist-cover-art/cover-art_5-0.svg new file mode 100644 index 0000000000..f096c98602 --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_5-0.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/playlist-cover-art/cover-art_5-1.svg b/frontend/img/playlist-cover-art/cover-art_5-1.svg new file mode 100644 index 0000000000..ce44e4204d --- /dev/null +++ b/frontend/img/playlist-cover-art/cover-art_5-1.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/js/src/playlists/Playlist.tsx b/frontend/js/src/playlists/Playlist.tsx index 3d30a37f41..1d7b65ba83 100644 --- a/frontend/js/src/playlists/Playlist.tsx +++ b/frontend/js/src/playlists/Playlist.tsx @@ -39,12 +39,7 @@ import { } from "./utils"; import { useBrainzPlayerDispatch } from "../common/brainzplayer/BrainzPlayerContext"; import SyndicationFeedModal from "../components/SyndicationFeedModal"; -import { getAlbumArtFromReleaseGroupMBID, getBaseUrl } from "../utils/utils"; - -type CoverArtGridOptions = { - dimention: number; - layout: number; -}; +import { getBaseUrl } from "../utils/utils"; export type PlaylistPageProps = { playlist: JSPFObject & { @@ -93,6 +88,8 @@ export default function PlaylistPage() { } ); + const currentCoverArt = playlistProps?.cover_art; + // Ref const socketRef = React.useRef(null); @@ -465,6 +462,8 @@ export default function PlaylistPage() { { @@ -28,7 +30,12 @@ export default NiceModal.create((props: CreateOrEditPlaylistModalProps) => { }, [modal]); const { currentUser, APIService } = React.useContext(GlobalAppContext); - const { playlist, initialTracks } = props; + const { + playlist, + initialTracks, + coverArtGridOptions, + currentCoverArt, + } = props; const customFields = getPlaylistExtension(props.playlist); const playlistId = getPlaylistId(playlist); const isEdit = Boolean(playlistId); @@ -37,6 +44,9 @@ export default NiceModal.create((props: CreateOrEditPlaylistModalProps) => { const [description, setDescription] = React.useState( playlist?.annotation ?? "" ); + const [selectedCoverArt, setSelectedCoverArt] = React.useState( + currentCoverArt ?? coverArtGridOptions?.[0] + ); const [isPublic, setIsPublic] = React.useState(customFields?.public ?? true); const [collaborators, setCollaborators] = React.useState( customFields?.collaborators ?? [] @@ -174,6 +184,9 @@ export default NiceModal.create((props: CreateOrEditPlaylistModalProps) => { [MUSICBRAINZ_JSPF_PLAYLIST_EXTENSION]: { public: isPublic, collaborators: collaboratorsWithoutOwner, + additional_metadata: { + cover_art: selectedCoverArt, + }, }, }, }; @@ -300,6 +313,33 @@ export default NiceModal.create((props: CreateOrEditPlaylistModalProps) => { onChange={(event) => setDescription(event.target.value)} />
+ {isEdit && coverArtGridOptions && ( +
+ +
+ {coverArtGridOptions?.map((option, index) => ( + + ))} +
+
+ )}