From 4fe7d45dec2ee28256d7d75bb789fbc3b7d9cdf0 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Thu, 22 Sep 2022 20:09:54 +0200 Subject: [PATCH] Bulk update operations on tracks --- api/obs/api/routes/tracks.py | 35 +++- frontend/src/pages/MyTracksPage.tsx | 271 ++++++++++++++++++---------- 2 files changed, 213 insertions(+), 93 deletions(-) diff --git a/api/obs/api/routes/tracks.py b/api/obs/api/routes/tracks.py index e9df89c..b004c3e 100644 --- a/api/obs/api/routes/tracks.py +++ b/api/obs/api/routes/tracks.py @@ -3,7 +3,7 @@ import re from json import load as jsonload from os.path import join, exists, isfile -from sqlalchemy import select, func +from sqlalchemy import select, func, and_ from sqlalchemy.orm import joinedload from obs.api.db import Track, User, Comment, DuplicateTrackFileError @@ -128,6 +128,39 @@ async def get_feed(req): return await _return_tracks(req, extend_query, limit, offset, order_by) +@api.post("/tracks/bulk") +@require_auth +async def tracks_bulk_action(req): + body = req.json + action = body["action"] + track_slugs = body["tracks"] + + if action not in ("delete", "makePublic", "makePrivate", "reprocess"): + raise InvalidUsage("invalid action") + + query = select(Track).where( + and_(Track.author_id == req.ctx.user.id, Track.slug.in_(track_slugs)) + ) + + for track in (await req.ctx.db.execute(query)).scalars(): + if action == "delete": + await req.ctx.db.delete(track) + elif action == "makePublic": + if not track.public: + track.queue_processing() + track.public = True + elif action == "makePrivate": + if track.public: + track.queue_processing() + track.public = False + elif action == "reprocess": + track.queue_processing() + + await req.ctx.db.commit() + + return empty() + + @api.post("/tracks") @read_api_key @require_auth diff --git a/frontend/src/pages/MyTracksPage.tsx b/frontend/src/pages/MyTracksPage.tsx index f8615f3..104abde 100644 --- a/frontend/src/pages/MyTracksPage.tsx +++ b/frontend/src/pages/MyTracksPage.tsx @@ -1,8 +1,10 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { connect } from "react-redux"; import { Accordion, Button, + Checkbox, + Confirm, Header, Icon, Item, @@ -15,7 +17,7 @@ import { } from "semantic-ui-react"; import { useObservable } from "rxjs-hooks"; import { Link } from "react-router-dom"; -import { of, from, concat } from "rxjs"; +import { of, from, concat, BehaviorSubject, combineLatest } from "rxjs"; import { map, switchMap, distinctUntilChanged } from "rxjs/operators"; import _ from "lodash"; import { useTranslation } from "react-i18next"; @@ -23,7 +25,7 @@ import { useTranslation } from "react-i18next"; import type { ProcessingStatus, Track, UserDevice } from "types"; import { Page, FormattedDate, Visibility } from "components"; import api from "api"; -import { formatDistance, formatDuration } from "utils"; +import { useCallbackRef, formatDistance, formatDuration } from "utils"; const COLOR_BY_STATUS: Record = { error: "red", @@ -150,11 +152,23 @@ function TrackFilters({ ); } -function TracksTable() { +function TracksTable({ title }) { const [orderBy, setOrderBy] = useState("recordedAt"); const [reversed, setReversed] = useState(false); const [showFilters, setShowFilters] = useState(false); const [filters, setFilters] = useState({}); + const [selectedTracks, setSelectedTracks] = useState>( + {} + ); + + const toggleTrackSelection = useCallbackRef( + (slug: string, selected?: boolean) => { + const newSelected = selected ?? !selectedTracks[slug]; + setSelectedTracks( + _.pickBy({ ...selectedTracks, [slug]: newSelected }, _.identity) + ); + } + ); const query = _.pickBy( { @@ -168,12 +182,17 @@ function TracksTable() { (x) => x != null ); + const forceUpdate$ = useMemo(() => new BehaviorSubject(null), []); const tracks: Track[] | null = useObservable( (_$, inputs$) => - inputs$.pipe( - map(([query]) => query), - distinctUntilChanged(_.isEqual), - switchMap((query) => + combineLatest([ + inputs$.pipe( + map(([query]) => query), + distinctUntilChanged(_.isEqual) + ), + forceUpdate$, + ]).pipe( + switchMap(([query]) => concat( of(null), from(api.get("/tracks/feed", { query }).then((r) => r.tracks)) @@ -201,88 +220,163 @@ function TracksTable() { const p = { orderBy, setOrderBy, reversed, setReversed }; + const selectedCount = Object.keys(selectedTracks).length; + const noneSelected = selectedCount === 0; + const allSelected = selectedCount === tracks?.length; + const selectAll = () => { + setSelectedTracks( + Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? []) + ); + }; + const selectNone = () => { + setSelectedTracks({}); + }; + + const bulkAction = async (action: string) => { + await api.post("/tracks/bulk", { + body: { + action, + tracks: Object.keys(selectedTracks), + }, + }); + + setShowBulkDelete(false); + setSelectedTracks({}); + forceUpdate$.next(null); + }; + const [showBulkDelete, setShowBulkDelete] = useState(false); + return ( -
- + <> +
+ + + + Selection of {selectedCount} tracks + + bulkAction("makePrivate")}> + Make private + + bulkAction("makePublic")}> + Make public + + bulkAction("reprocess")}> + Reprocess + + setShowBulkDelete(true)}> + Delete + + + + +
- - setShowFilters(!showFilters)} - > - - Filters - - - - - +
{title}
+
+ - - - - - Title - - - Recorded at - - - Visibility - - - Length - - - Duration - - - Device - - - + + setShowFilters(!showFilters)} + > + + Filters + + + + + - - {tracks?.map((track: Track) => ( - - - {track.processingStatus == null ? null : ( - - )} - - {track.title || t("general.unnamedTrack")} - - + setShowBulkDelete(false)} + onConfirm={() => bulkAction("delete")} + content={`Are you sure you want to delete ${selectedCount} tracks?`} + confirmButton={t("general.delete")} + cancelButton={t("general.cancel")} + /> - - - +
+ + + + (noneSelected ? selectAll() : selectNone())} + /> + - - {track.public == null ? null : ( - - )} - - - - {formatDistance(track.length)} - - - - {formatDuration(track.duration)} - - - - {track.userDeviceId - ? deviceNames?.[track.userDeviceId] ?? "..." - : null} - + + Title + + + Recorded at + + + Visibility + + + Length + + + Duration + + + Device + - ))} - -
-
+ + + + {tracks?.map((track: Track) => ( + + + toggleTrackSelection(track.slug)} + checked={selectedTracks[track.slug] ?? false} + /> + + + {track.processingStatus == null ? null : ( + + )} + + {track.title || t("general.unnamedTrack")} + + + + + + + + + {track.public == null ? null : ( + + )} + + + + {formatDistance(track.length)} + + + + {formatDuration(track.duration)} + + + + {track.userDeviceId + ? deviceNames?.[track.userDeviceId] ?? "..." + : null} + + + ))} + + +
+ ); } @@ -296,12 +390,7 @@ function UploadButton({ navigate, ...props }) { [navigate] ); return ( - ); @@ -315,9 +404,7 @@ const MyTracksPage = connect((state) => ({ login: (state as any).login }))( return ( - -
{title}
- +
); }