Bulk update operations on tracks

This commit is contained in:
Paul Bienkowski 2022-09-22 20:09:54 +02:00
parent cbab83e6e3
commit 4fe7d45dec
2 changed files with 213 additions and 93 deletions

View file

@ -3,7 +3,7 @@ import re
from json import load as jsonload from json import load as jsonload
from os.path import join, exists, isfile from os.path import join, exists, isfile
from sqlalchemy import select, func from sqlalchemy import select, func, and_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from obs.api.db import Track, User, Comment, DuplicateTrackFileError 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) 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") @api.post("/tracks")
@read_api_key @read_api_key
@require_auth @require_auth

View file

@ -1,8 +1,10 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { import {
Accordion, Accordion,
Button, Button,
Checkbox,
Confirm,
Header, Header,
Icon, Icon,
Item, Item,
@ -15,7 +17,7 @@ import {
} from "semantic-ui-react"; } from "semantic-ui-react";
import { useObservable } from "rxjs-hooks"; import { useObservable } from "rxjs-hooks";
import { Link } from "react-router-dom"; 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 { map, switchMap, distinctUntilChanged } from "rxjs/operators";
import _ from "lodash"; import _ from "lodash";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -23,7 +25,7 @@ import { useTranslation } from "react-i18next";
import type { ProcessingStatus, Track, UserDevice } from "types"; import type { ProcessingStatus, Track, UserDevice } from "types";
import { Page, FormattedDate, Visibility } from "components"; import { Page, FormattedDate, Visibility } from "components";
import api from "api"; import api from "api";
import { formatDistance, formatDuration } from "utils"; import { useCallbackRef, formatDistance, formatDuration } from "utils";
const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = { const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = {
error: "red", error: "red",
@ -150,11 +152,23 @@ function TrackFilters({
); );
} }
function TracksTable() { function TracksTable({ title }) {
const [orderBy, setOrderBy] = useState("recordedAt"); const [orderBy, setOrderBy] = useState("recordedAt");
const [reversed, setReversed] = useState(false); const [reversed, setReversed] = useState(false);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<Filters>({}); const [filters, setFilters] = useState<Filters>({});
const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>(
{}
);
const toggleTrackSelection = useCallbackRef(
(slug: string, selected?: boolean) => {
const newSelected = selected ?? !selectedTracks[slug];
setSelectedTracks(
_.pickBy({ ...selectedTracks, [slug]: newSelected }, _.identity)
);
}
);
const query = _.pickBy( const query = _.pickBy(
{ {
@ -168,12 +182,17 @@ function TracksTable() {
(x) => x != null (x) => x != null
); );
const forceUpdate$ = useMemo(() => new BehaviorSubject(null), []);
const tracks: Track[] | null = useObservable( const tracks: Track[] | null = useObservable(
(_$, inputs$) => (_$, inputs$) =>
inputs$.pipe( combineLatest([
map(([query]) => query), inputs$.pipe(
distinctUntilChanged(_.isEqual), map(([query]) => query),
switchMap((query) => distinctUntilChanged(_.isEqual)
),
forceUpdate$,
]).pipe(
switchMap(([query]) =>
concat( concat(
of(null), of(null),
from(api.get("/tracks/feed", { query }).then((r) => r.tracks)) from(api.get("/tracks/feed", { query }).then((r) => r.tracks))
@ -201,88 +220,163 @@ function TracksTable() {
const p = { orderBy, setOrderBy, reversed, setReversed }; 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 ( return (
<div style={{ clear: "both" }}> <>
<Loader content={t("general.loading")} active={tracks == null} /> <div style={{ float: "right" }}>
<Dropdown disabled={noneSelected} text="Bulk actions" floating button>
<Dropdown.Menu>
<Dropdown.Header>
Selection of {selectedCount} tracks
</Dropdown.Header>
<Dropdown.Item onClick={() => bulkAction("makePrivate")}>
Make private
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("makePublic")}>
Make public
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("reprocess")}>
Reprocess
</Dropdown.Item>
<Dropdown.Item onClick={() => setShowBulkDelete(true)}>
Delete
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Link component={UploadButton} to="/upload" />
</div>
<Accordion styled> <Header as="h2">{title}</Header>
<Accordion.Title <div style={{ clear: "both" }}>
active={showFilters} <Loader content={t("general.loading")} active={tracks == null} />
index={0}
onClick={() => setShowFilters(!showFilters)}
>
<Icon name="dropdown" />
Filters
</Accordion.Title>
<Accordion.Content active={showFilters}>
<TrackFilters {...{ filters, setFilters, deviceNames }} />
</Accordion.Content>
</Accordion>
<Table compact> <Accordion>
<Table.Header> <Accordion.Title
<Table.Row> active={showFilters}
<SortableHeader {...p} name="title"> index={0}
Title onClick={() => setShowFilters(!showFilters)}
</SortableHeader> >
<SortableHeader {...p} name="recordedAt"> <Icon name="dropdown" />
Recorded at Filters
</SortableHeader> </Accordion.Title>
<SortableHeader {...p} name="visibility"> <Accordion.Content active={showFilters}>
Visibility <TrackFilters {...{ filters, setFilters, deviceNames }} />
</SortableHeader> </Accordion.Content>
<SortableHeader {...p} name="length" textAlign="right"> </Accordion>
Length
</SortableHeader>
<SortableHeader {...p} name="duration" textAlign="right">
Duration
</SortableHeader>
<SortableHeader {...p} name="user_device_id">
Device
</SortableHeader>
</Table.Row>
</Table.Header>
<Table.Body> <Confirm
{tracks?.map((track: Track) => ( open={showBulkDelete}
<Table.Row key={track.slug}> onCancel={() => setShowBulkDelete(false)}
<Table.Cell> onConfirm={() => bulkAction("delete")}
{track.processingStatus == null ? null : ( content={`Are you sure you want to delete ${selectedCount} tracks?`}
<ProcessingStatusLabel status={track.processingStatus} /> confirmButton={t("general.delete")}
)} cancelButton={t("general.cancel")}
<Item.Header as={Link} to={`/tracks/${track.slug}`}> />
{track.title || t("general.unnamedTrack")}
</Item.Header>
</Table.Cell>
<Table.Cell> <Table compact>
<FormattedDate date={track.recordedAt} /> <Table.Header>
</Table.Cell> <Table.Row>
<Table.HeaderCell>
<Checkbox
checked={allSelected}
indeterminate={!allSelected && !noneSelected}
onClick={() => (noneSelected ? selectAll() : selectNone())}
/>
</Table.HeaderCell>
<Table.Cell> <SortableHeader {...p} name="title">
{track.public == null ? null : ( Title
<Visibility public={track.public} /> </SortableHeader>
)} <SortableHeader {...p} name="recordedAt">
</Table.Cell> Recorded at
</SortableHeader>
<Table.Cell textAlign="right"> <SortableHeader {...p} name="visibility">
{formatDistance(track.length)} Visibility
</Table.Cell> </SortableHeader>
<SortableHeader {...p} name="length" textAlign="right">
<Table.Cell textAlign="right"> Length
{formatDuration(track.duration)} </SortableHeader>
</Table.Cell> <SortableHeader {...p} name="duration" textAlign="right">
Duration
<Table.Cell> </SortableHeader>
{track.userDeviceId <SortableHeader {...p} name="user_device_id">
? deviceNames?.[track.userDeviceId] ?? "..." Device
: null} </SortableHeader>
</Table.Cell>
</Table.Row> </Table.Row>
))} </Table.Header>
</Table.Body>
</Table> <Table.Body>
</div> {tracks?.map((track: Track) => (
<Table.Row key={track.slug}>
<Table.Cell>
<Checkbox
onClick={(e) => toggleTrackSelection(track.slug)}
checked={selectedTracks[track.slug] ?? false}
/>
</Table.Cell>
<Table.Cell>
{track.processingStatus == null ? null : (
<ProcessingStatusLabel status={track.processingStatus} />
)}
<Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title || t("general.unnamedTrack")}
</Item.Header>
</Table.Cell>
<Table.Cell>
<FormattedDate date={track.recordedAt} />
</Table.Cell>
<Table.Cell>
{track.public == null ? null : (
<Visibility public={track.public} />
)}
</Table.Cell>
<Table.Cell textAlign="right">
{formatDistance(track.length)}
</Table.Cell>
<Table.Cell textAlign="right">
{formatDuration(track.duration)}
</Table.Cell>
<Table.Cell>
{track.userDeviceId
? deviceNames?.[track.userDeviceId] ?? "..."
: null}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</div>
</>
); );
} }
@ -296,12 +390,7 @@ function UploadButton({ navigate, ...props }) {
[navigate] [navigate]
); );
return ( return (
<Button <Button onClick={onClick} {...props} color="green">
onClick={onClick}
{...props}
color="green"
style={{ float: "right" }}
>
{t("TracksPage.upload")} {t("TracksPage.upload")}
</Button> </Button>
); );
@ -315,9 +404,7 @@ const MyTracksPage = connect((state) => ({ login: (state as any).login }))(
return ( return (
<Page title={title}> <Page title={title}>
<Link component={UploadButton} to="/upload" /> <TracksTable {...{ title }} />
<Header as="h2">{title}</Header>
<TracksTable />
</Page> </Page>
); );
} }