Bulk update operations on tracks
This commit is contained in:
parent
cbab83e6e3
commit
4fe7d45dec
|
@ -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
|
||||
|
|
|
@ -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<ProcessingStatus, SemanticCOLORS> = {
|
||||
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<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(
|
||||
{
|
||||
|
@ -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 (
|
||||
<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>
|
||||
<Accordion.Title
|
||||
active={showFilters}
|
||||
index={0}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Icon name="dropdown" />
|
||||
Filters
|
||||
</Accordion.Title>
|
||||
<Accordion.Content active={showFilters}>
|
||||
<TrackFilters {...{ filters, setFilters, deviceNames }} />
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
<Header as="h2">{title}</Header>
|
||||
<div style={{ clear: "both" }}>
|
||||
<Loader content={t("general.loading")} active={tracks == null} />
|
||||
|
||||
<Table compact>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<SortableHeader {...p} name="title">
|
||||
Title
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="recordedAt">
|
||||
Recorded at
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="visibility">
|
||||
Visibility
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="length" textAlign="right">
|
||||
Length
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="duration" textAlign="right">
|
||||
Duration
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="user_device_id">
|
||||
Device
|
||||
</SortableHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Accordion>
|
||||
<Accordion.Title
|
||||
active={showFilters}
|
||||
index={0}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Icon name="dropdown" />
|
||||
Filters
|
||||
</Accordion.Title>
|
||||
<Accordion.Content active={showFilters}>
|
||||
<TrackFilters {...{ filters, setFilters, deviceNames }} />
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
|
||||
<Table.Body>
|
||||
{tracks?.map((track: Track) => (
|
||||
<Table.Row key={track.slug}>
|
||||
<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>
|
||||
<Confirm
|
||||
open={showBulkDelete}
|
||||
onCancel={() => setShowBulkDelete(false)}
|
||||
onConfirm={() => bulkAction("delete")}
|
||||
content={`Are you sure you want to delete ${selectedCount} tracks?`}
|
||||
confirmButton={t("general.delete")}
|
||||
cancelButton={t("general.cancel")}
|
||||
/>
|
||||
|
||||
<Table.Cell>
|
||||
<FormattedDate date={track.recordedAt} />
|
||||
</Table.Cell>
|
||||
<Table compact>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={!allSelected && !noneSelected}
|
||||
onClick={() => (noneSelected ? selectAll() : selectNone())}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
|
||||
<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>
|
||||
<SortableHeader {...p} name="title">
|
||||
Title
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="recordedAt">
|
||||
Recorded at
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="visibility">
|
||||
Visibility
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="length" textAlign="right">
|
||||
Length
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="duration" textAlign="right">
|
||||
Duration
|
||||
</SortableHeader>
|
||||
<SortableHeader {...p} name="user_device_id">
|
||||
Device
|
||||
</SortableHeader>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{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]
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
color="green"
|
||||
style={{ float: "right" }}
|
||||
>
|
||||
<Button onClick={onClick} {...props} color="green">
|
||||
{t("TracksPage.upload")}
|
||||
</Button>
|
||||
);
|
||||
|
@ -315,9 +404,7 @@ const MyTracksPage = connect((state) => ({ login: (state as any).login }))(
|
|||
|
||||
return (
|
||||
<Page title={title}>
|
||||
<Link component={UploadButton} to="/upload" />
|
||||
<Header as="h2">{title}</Header>
|
||||
<TracksTable />
|
||||
<TracksTable {...{ title }} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue