From ac614213f3d250935ac44162279e44455b675db3 Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Wed, 20 Sep 2023 20:12:07 +0000 Subject: [PATCH 01/56] feat: Add sorting and pagination to fresh release API --- listenbrainz/db/fresh_releases.py | 37 +++++++++++++-------- listenbrainz/webserver/views/api_tools.py | 11 ++++++ listenbrainz/webserver/views/explore_api.py | 28 ++++++++++++++-- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/listenbrainz/db/fresh_releases.py b/listenbrainz/db/fresh_releases.py index 1f35f03f0f..2f1f49b537 100644 --- a/listenbrainz/db/fresh_releases.py +++ b/listenbrainz/db/fresh_releases.py @@ -11,24 +11,31 @@ from listenbrainz.db.model.fresh_releases import FreshRelease -def get_sitewide_fresh_releases(pivot_release_date: date, release_date_window_days: int) -> List[FreshRelease]: +def get_sitewide_fresh_releases(pivot_release_date: date, release_date_window_days: int, offset: int, limit: int, sort: str, past: bool, future: bool) -> List[FreshRelease]: """ Fetch fresh and recent releases from the MusicBrainz DB with a given window that is days number of days into the past and days number of days into the future. Args: pivot_release_date: The release_date around which to fetch the fresh releases. release_date_window_days: The number of days into the past and future to show releases for. Must be - between 1 and 30 days. If an invalid value is passed, 30 days is used. + between 1 and 90 days. If an invalid value is passed, 90 days is used. + offset: The offset into the result set to start fetching results from. + limit: The maximum number of results to fetch. + sort: The sort order of the results. Must be one of "release_date", "artist_credit_name" or "release_name". Returns: A list of FreshReleases objects """ - if release_date_window_days > 30 or release_date_window_days < 1: - release_date_window_days = 30 + if release_date_window_days > 90 or release_date_window_days < 1: + release_date_window_days = 90 - from_date = pivot_release_date + timedelta(days=-release_date_window_days) - to_date = pivot_release_date + timedelta(days=release_date_window_days) + from_date = pivot_release_date + timedelta(days=-release_date_window_days) if past else pivot_release_date + to_date = pivot_release_date + timedelta(days=release_date_window_days) if future else pivot_release_date + + sort_order = ["release_date", "artist_credit_name", "release_name"] + sort_order = sort_order[sort_order.index(sort):] + sort_order[:sort_order.index(sort)] + sort_order_str = ", ".join(sort_order) query = """ WITH releases AS ( @@ -44,6 +51,7 @@ def get_sitewide_fresh_releases(pivot_release_date: date, release_date_window_da , array_agg(distinct a.gid) AS artist_mbids , rgpt.name AS release_group_primary_type , rgst.name AS release_group_secondary_type + , COUNT(*) OVER () AS total_count FROM release rl JOIN release_group rg ON rl.release_group = rg.id @@ -78,20 +86,22 @@ def get_sitewide_fresh_releases(pivot_release_date: date, release_date_window_da , release_group_secondary_type ORDER BY rg.id , release_date - ) SELECT * + ) SELECT *, + total_count AS total_count FROM releases - ORDER BY release_date - , artist_credit_name - , release_name - """ + ORDER BY {sort_order_str} + LIMIT %s OFFSET %s; + """.format(sort_order_str=sort_order_str) + print(query) with psycopg2.connect(current_app.config["MB_DATABASE_URI"]) as conn, \ conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as curs: - curs.execute(query, (from_date, to_date)) + curs.execute(query, (from_date, to_date, limit, offset)) result = {str(row["release_mbid"]): dict(row) for row in curs.fetchall()} covers = get_caa_ids_for_release_mbids(curs, result.keys()) fresh_releases = [] + total_count = 0 for mbid, row in result.items(): row["caa_id"] = covers[mbid]["caa_id"] if covers[mbid]["caa_release_mbid"]: @@ -99,8 +109,9 @@ def get_sitewide_fresh_releases(pivot_release_date: date, release_date_window_da else: row["caa_release_mbid"] = None fresh_releases.append(FreshRelease(**row)) + total_count = row["total_count"] - return fresh_releases + return fresh_releases, total_count def insert_fresh_releases(database: str, docs: list[dict]): diff --git a/listenbrainz/webserver/views/api_tools.py b/listenbrainz/webserver/views/api_tools.py index 80fa8514c0..1216a01916 100644 --- a/listenbrainz/webserver/views/api_tools.py +++ b/listenbrainz/webserver/views/api_tools.py @@ -420,6 +420,17 @@ def _parse_int_arg(name, default=None): else: return default +def _parse_bool_arg(name, default=None): + value = request.args.get(name) + if value: + if value == "true": + return True + elif value == "false": + return False + else: + raise APIBadRequest("Invalid %s argument: %s" % (name, value)) + else: + return default def _validate_get_endpoint_params() -> Tuple[int, int, int]: """ Validates parameters for listen GET endpoints like /username/listens and /username/feed/events diff --git a/listenbrainz/webserver/views/explore_api.py b/listenbrainz/webserver/views/explore_api.py index 458f04a0e4..af7249ac2d 100644 --- a/listenbrainz/webserver/views/explore_api.py +++ b/listenbrainz/webserver/views/explore_api.py @@ -6,7 +6,7 @@ import listenbrainz.db.fresh_releases from listenbrainz.webserver.decorators import crossdomain from listenbrainz.webserver.errors import APIBadRequest, APIInternalServerError -from listenbrainz.webserver.views.api_tools import _parse_int_arg +from listenbrainz.webserver.views.api_tools import _parse_int_arg, _parse_bool_arg from listenbrainz.db.color import get_releases_for_color from troi.patches.lb_radio import LBRadioPatch from troi.core import generate_playlist @@ -16,6 +16,7 @@ DEFAULT_NUMBER_OF_RELEASES = 25 # 5x5 grid DEFAULT_CACHE_EXPIRE_TIME = 3600 * 24 # 1 day HUESOUND_PAGE_CACHE_KEY = "huesound.%s.%d" +RELEASES_PER_PAGE = 30 explore_api_bp = Blueprint('explore_api_v1', __name__) @@ -44,6 +45,10 @@ def get_fresh_releases(): :param release_date: Fresh releases will be shown around this pivot date. Must be in YYYY-MM-DD format :param days: The number of days of fresh releases to show. Max 30 days. + :param page: The page number of results to show. Default 1. + :param sort: The sort order of the results. Must be one of "release_date", "artist_credit_name" or "release_name". Default "release_date". + :param past: Whether to show releases in the past. Default True. + :param future: Whether to show releases in the future. Default True. :statuscode 200: fetch succeeded :statuscode 400: invalid date or number of days passed. :resheader Content-Type: *application/json* @@ -52,6 +57,17 @@ def get_fresh_releases(): days = _parse_int_arg("days", DEFAULT_NUMBER_OF_FRESH_RELEASE_DAYS) if days < 1 or days > MAX_NUMBER_OF_FRESH_RELEASE_DAYS: raise APIBadRequest(f"days must be between 1 and {MAX_NUMBER_OF_FRESH_RELEASE_DAYS}.") + + page = _parse_int_arg("page", 1) + if page < 1: + raise APIBadRequest("page must be greater than 0.") + + sort = request.args.get("sort", "release_date") + if sort not in ("release_date", "artist_credit_name", "release_name"): + raise APIBadRequest("sort must be one of 'release_date', 'artist_credit_name' or 'release_name'.") + + past = _parse_bool_arg("past", True) + future = _parse_bool_arg("future", True) release_date = request.args.get("release_date", "") if release_date != "": @@ -63,12 +79,18 @@ def get_fresh_releases(): release_date = datetime.date.today() try: - db_releases = listenbrainz.db.fresh_releases.get_sitewide_fresh_releases(release_date, days) + offset = (page - 1) * RELEASES_PER_PAGE + db_releases, total_count = listenbrainz.db.fresh_releases.get_sitewide_fresh_releases(release_date, days, offset, RELEASES_PER_PAGE, sort, past, future) except Exception as e: current_app.logger.error("Server failed to get latest release: {}".format(e)) raise APIInternalServerError("Server failed to get latest release") - return jsonify([r.to_dict() for r in db_releases]) + return jsonify({ + "payload": { + "releases": [r.to_dict() for r in db_releases], + "total_count": total_count, + } + }) @explore_api_bp.route("/color/", methods=["GET", "OPTIONS"]) From 3f4e28a4a3d60d90c2fc22afa679f2bd77489724 Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Wed, 20 Sep 2023 21:39:35 +0000 Subject: [PATCH 02/56] feat: Remove unnecessary print statement --- listenbrainz/db/fresh_releases.py | 1 - 1 file changed, 1 deletion(-) diff --git a/listenbrainz/db/fresh_releases.py b/listenbrainz/db/fresh_releases.py index 2f1f49b537..fbdc4a6c6b 100644 --- a/listenbrainz/db/fresh_releases.py +++ b/listenbrainz/db/fresh_releases.py @@ -92,7 +92,6 @@ def get_sitewide_fresh_releases(pivot_release_date: date, release_date_window_da ORDER BY {sort_order_str} LIMIT %s OFFSET %s; """.format(sort_order_str=sort_order_str) - print(query) with psycopg2.connect(current_app.config["MB_DATABASE_URI"]) as conn, \ conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as curs: curs.execute(query, (from_date, to_date, limit, offset)) From 9494aa61492addbd8f2bfd4bc351a95ab57d11d1 Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Thu, 21 Sep 2023 20:09:54 +0000 Subject: [PATCH 03/56] feat: Fetch tags with fresh releases --- listenbrainz/db/fresh_releases.py | 9 ++++++++- listenbrainz/db/model/fresh_releases.py | 3 +++ listenbrainz/webserver/views/explore_api.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/listenbrainz/db/fresh_releases.py b/listenbrainz/db/fresh_releases.py index fbdc4a6c6b..004d125306 100644 --- a/listenbrainz/db/fresh_releases.py +++ b/listenbrainz/db/fresh_releases.py @@ -51,7 +51,8 @@ def get_sitewide_fresh_releases(pivot_release_date: date, release_date_window_da , array_agg(distinct a.gid) AS artist_mbids , rgpt.name AS release_group_primary_type , rgst.name AS release_group_secondary_type - , COUNT(*) OVER () AS total_count + , array_agg(distinct t.name) AS tags + , COUNT(*) AS total_count FROM release rl JOIN release_group rg ON rl.release_group = rg.id @@ -69,6 +70,10 @@ def get_sitewide_fresh_releases(pivot_release_date: date, release_date_window_da ON acn.artist_credit = ac.id JOIN artist a ON acn.artist = a.id + LEFT JOIN release_tag rt + ON rt.release = rl.id + LEFT JOIN tag t + ON t.id = rt.tag WHERE make_date(rgm.first_release_date_year, rgm.first_release_date_month, rgm.first_release_date_day) >= %s @@ -102,6 +107,8 @@ def get_sitewide_fresh_releases(pivot_release_date: date, release_date_window_da fresh_releases = [] total_count = 0 for mbid, row in result.items(): + if row["tags"] == [None]: + row["tags"] = [] row["caa_id"] = covers[mbid]["caa_id"] if covers[mbid]["caa_release_mbid"]: row["caa_release_mbid"] = uuid.UUID(covers[mbid]["caa_release_mbid"]) diff --git a/listenbrainz/db/model/fresh_releases.py b/listenbrainz/db/model/fresh_releases.py index 5756405d2e..b6d90d0226 100644 --- a/listenbrainz/db/model/fresh_releases.py +++ b/listenbrainz/db/model/fresh_releases.py @@ -56,6 +56,9 @@ class FreshRelease(BaseModel): # The release group's secondary type release_group_secondary_type: Optional[ReleaseGroupSecondaryType] + # The array of tags for this release + tags: List[str] + # The cover art archive id of the release's front cover art if it has any caa_id: Optional[int] diff --git a/listenbrainz/webserver/views/explore_api.py b/listenbrainz/webserver/views/explore_api.py index af7249ac2d..fbd4bda767 100644 --- a/listenbrainz/webserver/views/explore_api.py +++ b/listenbrainz/webserver/views/explore_api.py @@ -12,7 +12,7 @@ from troi.core import generate_playlist DEFAULT_NUMBER_OF_FRESH_RELEASE_DAYS = 14 -MAX_NUMBER_OF_FRESH_RELEASE_DAYS = 30 +MAX_NUMBER_OF_FRESH_RELEASE_DAYS = 90 DEFAULT_NUMBER_OF_RELEASES = 25 # 5x5 grid DEFAULT_CACHE_EXPIRE_TIME = 3600 * 24 # 1 day HUESOUND_PAGE_CACHE_KEY = "huesound.%s.%d" From b081c481248338868ebc2516c69c90fe152c9695 Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Fri, 22 Sep 2023 09:43:59 +0000 Subject: [PATCH 04/56] feat: Add sorting and display options --- frontend/css/fresh-releases.less | 82 ++++++++- frontend/css/main.less | 1 + frontend/css/switch.less | 54 ++++++ frontend/js/src/components/Switch.tsx | 28 +++ .../explore/fresh-releases/FreshReleases.tsx | 109 +++++++++-- .../explore/fresh-releases/ReleaseCard.tsx | 96 +++++++--- .../explore/fresh-releases/ReleaseFilters.tsx | 174 ++++++++++++------ frontend/js/src/utils/APIService.ts | 12 ++ frontend/js/src/utils/types.d.ts | 1 + 9 files changed, 445 insertions(+), 112 deletions(-) create mode 100644 frontend/css/switch.less create mode 100644 frontend/js/src/components/Switch.tsx diff --git a/frontend/css/fresh-releases.less b/frontend/css/fresh-releases.less index db96ff055c..4e45658a5c 100644 --- a/frontend/css/fresh-releases.less +++ b/frontend/css/fresh-releases.less @@ -14,8 +14,17 @@ } #fr-pill-row { + display: flex; + justify-content: space-between; + border-bottom: 1px solid @asphalt; + padding-bottom: 0.5rem; + margin-bottom: 1rem; +} + +#fr-row { display: flex; justify-content: center; + align-items: center; } .releases-page { @@ -196,17 +205,80 @@ flex-direction: column; padding: 1rem; + .release-item { + position: relative; + } + + .release-information { + display: flex; + flex-direction: column; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + } + + .tags { + font-size: smaller; + } + + .cover-art-info { + width: 100%; + background-color: rgba(53, 48, 112, 0.90); + padding: 0.5rem; + color: white; + text-align: center; + display: flex; + justify-content: space-between; + margin-top: 1px; + } + + .cover-art-info:last-child { + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + } + + .release-coverart-container:hover .hover-backdrop { + background: linear-gradient(18deg, rgba(0, 0, 0, 0.80) -15.26%, rgba(0, 0, 0, 0.16) 119.11%); + } + + .hover-backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 1; + + svg { + width: 6rem; + height: 6rem; + color: #D9D9D9; + opacity: 0.6; + } + } + + .release-coverart-container:hover .hover-backdrop { + opacity: 1; + } + .release-date { text-transform: uppercase; - text-align: center; - font-weight: 700; + text-align: right; + font-weight: 500; + font-size: smaller; cursor: default; } .release-coverart { width: 12em; height: 12em; - border-radius: 4px; + border-radius: 8px; user-select: none; aspect-ratio: 1; object-fit: cover; @@ -237,9 +309,7 @@ } .release-type-chip { - padding: 2px 4px; - color: @asphalt; - text-align: center; + text-align: left; text-transform: uppercase; font-weight: 500; font-size: smaller; diff --git a/frontend/css/main.less b/frontend/css/main.less index bd7eed572c..b3b003cd47 100644 --- a/frontend/css/main.less +++ b/frontend/css/main.less @@ -26,6 +26,7 @@ @import "explore.less"; @import "search-track.less"; @import "stats.less"; +@import "switch.less"; @import "new-navbar.less"; @import "tags.less"; @import "stats-art-creator.less"; diff --git a/frontend/css/switch.less b/frontend/css/switch.less new file mode 100644 index 0000000000..8ac4215c59 --- /dev/null +++ b/frontend/css/switch.less @@ -0,0 +1,54 @@ +.toggle { + cursor: pointer; + display: inline-block; +} + +.toggle-switch { + display: inline-block; + background: #8D8D8D; + border-radius: 16px; + width: 42px; + height: 14px; + position: relative; + vertical-align: middle; + transition: background 0.25s; +} +.toggle-switch:before, +.toggle-switch:after { + content: ""; +} +.toggle-switch:before { + display: block; + background: #D9D9D9; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); + width: 20px; + height: 20px; + position: absolute; + top: -3px; + left: 0px; + transition: left 0.25s; +} +.toggle:hover .toggle-switch:before { + background: #D9D9D9; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5); +} +.toggle-checkbox:checked + .toggle-switch { + background: #1E1E1E; +} +.toggle-checkbox:checked + .toggle-switch:before { + left: 23px; + background: #8D8D8D; +} + +.toggle-checkbox { + position: absolute; + visibility: hidden; +} + +.toggle-label { + margin-left: 10px; + position: relative; + font-weight: 400; + // top: 2px; +} diff --git a/frontend/js/src/components/Switch.tsx b/frontend/js/src/components/Switch.tsx new file mode 100644 index 0000000000..cdf172268c --- /dev/null +++ b/frontend/js/src/components/Switch.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +type SwitchProps = { + id: string; + value: string | undefined; + checked: boolean; + onChange: (event: React.ChangeEvent) => void; + switchLabel: string | undefined; +}; + +export default function Switch(props: SwitchProps) { + const { id, value, onChange, checked, switchLabel } = props; + + return ( +