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 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
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue