Compare commits

..

No commits in common. 'master' and 'editsong' have entirely different histories.

  1. 10
      KNOWN_ISSUES
  2. 6
      client/src/api/endpoints/data.ts
  3. 4
      client/src/api/endpoints/query.ts
  4. 12
      client/src/api/endpoints/resources.ts
  5. 9
      client/src/api/types/resources.ts
  6. 118
      client/src/components/common/EditItemDialog.tsx
  7. 93
      client/src/components/common/EditableText.tsx
  8. 93
      client/src/components/querybuilder/QBAddElemMenu.tsx
  9. 63
      client/src/components/querybuilder/QBLeafElem.tsx
  10. 16
      client/src/components/querybuilder/QBNodeElem.tsx
  11. 1
      client/src/components/querybuilder/QBPlaceholder.tsx
  12. 13
      client/src/components/querybuilder/QueryBuilder.tsx
  13. 297
      client/src/components/tables/ResultsTable.tsx
  14. 99
      client/src/components/windows/album/AlbumWindow.tsx
  15. 98
      client/src/components/windows/artist/ArtistWindow.tsx
  16. 13
      client/src/components/windows/manage_links/BatchLinkDialog.tsx
  17. 23
      client/src/components/windows/manage_links/LinksStatusWidget.tsx
  18. 231
      client/src/components/windows/query/QueryWindow.tsx
  19. 85
      client/src/components/windows/tag/TagWindow.tsx
  20. 120
      client/src/components/windows/track/EditTrackDialog.tsx
  21. 29
      client/src/components/windows/track/TrackWindow.tsx
  22. 59
      client/src/lib/backend/queries.tsx
  23. 2
      client/src/lib/backend/tags.tsx
  24. 4
      client/src/lib/integration/spotify/SpotifyClientCreds.tsx
  25. 52
      client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx
  26. 165
      client/src/lib/query/Query.tsx
  27. 26
      server/db/Album.ts
  28. 8
      server/db/Artist.ts
  29. 4
      server/db/Data.ts
  30. 12
      server/db/Tag.ts
  31. 32
      server/db/Track.ts

@ -3,15 +3,7 @@ last updated: 0a9bec1c874a9b62000b4156ae75e23991121ed0
- Youtube web scraper integration broken - Youtube web scraper integration broken
- Spotify integration only finds artists, not albums or tracks - Spotify integration only finds artists, not albums or tracks
- Tag management shows only top-level tags - Tag management shows only top-level tags
- (Maybe) patch requests broken? - (Maybe) editing of items broken?
- Checked and fixed track
- Lots of front-end typescript warnings - Lots of front-end typescript warnings
- When not logged in, an exception may occur trying to visit a page - When not logged in, an exception may occur trying to visit a page
instead of redirecting properly instead of redirecting properly
- Google Play Music still listed although the service has been
terminated by Google
- during batch linking, linking dialog closes when clicking outside.
this shouldn't happen.
- during batch linking, if the page is left or the dialog closes,
the jobs are still continuing to execute. This shouldn't happen.
- no way to exit the edit dialog

@ -7,7 +7,7 @@
// Upon import, they might be replaced, and upon export, they might be randomly // Upon import, they might be replaced, and upon export, they might be randomly
// generated. // generated.
import { Album, Id, AlbumRefs, Artist, ArtistRefs, Tag, TagParentId, Track, TrackRefs, isTrackRefs, isAlbumRefs, isArtistRefs, isTagParentId } from "../types/resources"; import { Album, Id, AlbumRefs, Artist, ArtistRefs, Tag, TagRefs, Track, TrackRefs, isTrackRefs, isAlbumRefs, isArtistRefs, isTagRefs } from "../types/resources";
// The import/export DB format is just a set of lists of objects. // The import/export DB format is just a set of lists of objects.
// Each object has an ID and references others by ID. // Each object has an ID and references others by ID.
@ -17,7 +17,7 @@ export interface DBDataFormat {
tracks: (Track & Id & TrackRefs)[], tracks: (Track & Id & TrackRefs)[],
albums: (Album & Id & AlbumRefs)[], albums: (Album & Id & AlbumRefs)[],
artists: (Artist & Id & ArtistRefs)[], artists: (Artist & Id & ArtistRefs)[],
tags: (Tag & Id & TagParentId)[], tags: (Tag & Id & TagRefs)[],
} }
// Get a full export of a user's database (GET). // Get a full export of a user's database (GET).
@ -49,7 +49,7 @@ export const checkDBImportRequest: (v: any) => boolean = (v: any) => {
return prev && isArtistRefs(cur); return prev && isArtistRefs(cur);
}, true) && }, true) &&
v.tags.reduce((prev: boolean, cur: any) => { v.tags.reduce((prev: boolean, cur: any) => {
return prev && isTagParentId(cur); return prev && isTagRefs(cur);
}, true); }, true);
} }

@ -1,6 +1,6 @@
// Query for items (POST). // Query for items (POST).
import { Album, Id, Artist, Tag, Track, Name, StoreLinks, TagParentId, AlbumRefs, TrackDetails, ArtistDetails } from "../types/resources"; import { Album, Id, Artist, Tag, Track, Name, StoreLinks, TagRefs, AlbumRefs, TrackDetails, ArtistDetails } from "../types/resources";
export const QueryEndpoint = '/query'; export const QueryEndpoint = '/query';
@ -79,7 +79,7 @@ export interface QueryRequest {
// Query response structure // Query response structure
export type QueryResponseTrackDetails = (Track & Name & StoreLinks & TrackDetails & Id); export type QueryResponseTrackDetails = (Track & Name & StoreLinks & TrackDetails & Id);
export type QueryResponseArtistDetails = (Artist & Name & StoreLinks & Id); export type QueryResponseArtistDetails = (Artist & Name & StoreLinks & Id);
export type QueryResponseTagDetails = (Tag & Name & TagParentId & Id); export type QueryResponseTagDetails = (Tag & Name & TagRefs & Id);
export type QueryResponseAlbumDetails = (Album & Name & StoreLinks & Id); export type QueryResponseAlbumDetails = (Album & Name & StoreLinks & Id);
export interface QueryResponse { export interface QueryResponse {
tracks: QueryResponseTrackDetails[] | number[] | number, // Details | IDs | count, depending on QueryResponseType tracks: QueryResponseTrackDetails[] | number[] | number, // Details | IDs | count, depending on QueryResponseType

@ -12,11 +12,11 @@ import {
TrackRefs, TrackRefs,
ArtistRefs, ArtistRefs,
AlbumRefs, AlbumRefs,
TagParentId, TagRefs,
isTrackRefs, isTrackRefs,
isAlbumRefs, isAlbumRefs,
isArtistRefs, isArtistRefs,
isTagParentId, isTagRefs,
isName, isName,
Name, Name,
isTrack, isTrack,
@ -83,9 +83,9 @@ export const checkPostAlbumRequest: (v: any) => boolean = (v: any) => isAlbumRef
// Post new tag (POST). // Post new tag (POST).
export const PostTagEndpoint = "/tag"; export const PostTagEndpoint = "/tag";
export type PostTagRequest = (Tag & TagParentId & Name); export type PostTagRequest = (Tag & TagRefs & Name);
export interface PostTagResponse { id: number }; export interface PostTagResponse { id: number };
export const checkPostTagRequest: (v: any) => boolean = (v: any) => isTagParentId(v) && isName(v); export const checkPostTagRequest: (v: any) => boolean = (v: any) => isTagRefs(v) && isName(v);
// Post new integration (POST). // Post new integration (POST).
export const PostIntegrationEndpoint = "/integration"; export const PostIntegrationEndpoint = "/integration";
@ -113,9 +113,9 @@ export const checkPutAlbumRequest: (v: any) => boolean = (v: any) => isAlbumRefs
// Replace tag (PUT). // Replace tag (PUT).
export const PutTagEndpoint = "/tag/:id"; export const PutTagEndpoint = "/tag/:id";
export type PutTagRequest = (Tag & TagParentId); export type PutTagRequest = (Tag & TagRefs);
export type PutTagResponse = void; export type PutTagResponse = void;
export const checkPutTagRequest: (v: any) => boolean = (v: any) => isTagParentId(v) && isName(v);; export const checkPutTagRequest: (v: any) => boolean = (v: any) => isTagRefs(v) && isName(v);;
// Replace integration (PUT). // Replace integration (PUT).
export const PutIntegrationEndpoint = "/integration/:id"; export const PutIntegrationEndpoint = "/integration/:id";

@ -147,17 +147,12 @@ export interface Tag {
id?: number, id?: number,
parentId?: number | null, parentId?: number | null,
parent?: (Tag & Id) | null, parent?: (Tag & Id) | null,
childIds?: number[],
} }
export interface TagParentId { export interface TagRefs {
parentId: number | null, parentId: number | null,
} }
export interface TagChildIds {
childIds: number[],
}
export interface TagDetails { export interface TagDetails {
parent: (Tag & Id) | null, parent: (Tag & Id) | null,
} }
@ -166,7 +161,7 @@ export function isTag(q: any): q is Tag {
return q.mbApi_typename && q.mbApi_typename === "tag"; return q.mbApi_typename && q.mbApi_typename === "tag";
} }
export function isTagParentId(q: any): q is TagParentId { export function isTagRefs(q: any): q is TagRefs {
return isTag(q) && 'parentId' in q; return isTag(q) && 'parentId' in q;
} }

@ -1,118 +0,0 @@
import React, { useState } from 'react';
import { Button, Dialog, DialogActions, Divider, Typography, Box, TextField, IconButton } from "@material-ui/core";
import { ExternalLinksEditor } from './ExternalLinksEditor';
import UndoIcon from '@material-ui/icons/Undo';
import { ResourceType } from '../../api/api';
let _ = require('lodash')
export enum EditablePropertyType {
Text = 0,
}
export interface EditableProperty {
metadataKey: string,
title: string,
type: EditablePropertyType
}
function EditTextProperty(props: {
title: string,
originalValue: string,
currentValue: string,
onChange: (v: string) => void
}) {
return <Box display="flex" alignItems="center" width="100%">
<TextField
// Here we "abuse" the label to show the original title.
// emptying the text box means going back to the original.
variant="outlined"
value={props.currentValue}
label={props.title}
helperText={(props.currentValue != props.originalValue) &&
"Current: " + props.originalValue || undefined}
error={(props.currentValue != props.originalValue)}
onChange={(e: any) => {
props.onChange((e.target.value == "") ?
props.originalValue : e.target.value)
}}
fullWidth={true}
/>
{props.currentValue != props.originalValue && <IconButton
onClick={() => {
props.onChange(props.originalValue)
}}
><UndoIcon /></IconButton>}
</Box>
}
function PropertyEditor(props: {
originalMetadata: any,
currentMetadata: any,
onChange: (metadata: any) => void,
editableProperties: EditableProperty[]
}) {
return <Box display="flex" width="100%">
{props.editableProperties.map(
(p: EditableProperty) => {
if (p.type == EditablePropertyType.Text) {
return <EditTextProperty
title={p.title}
originalValue={props.originalMetadata[p.metadataKey]}
currentValue={props.currentMetadata[p.metadataKey]}
onChange={(v: string) => props.onChange({ ...props.currentMetadata, [p.metadataKey]: v })}
/>
}
return undefined;
}
)}
</Box >
}
export default function EditItemDialog(props: {
open: boolean,
onClose: () => void,
onSubmit: (v: any) => void,
id: number,
metadata: any,
defaultExternalLinksQuery: string,
editableProperties: EditableProperty[],
resourceType: ResourceType,
editStoreLinks: boolean,
}) {
let [editingMetadata, setEditingMetadata] = useState<any>(props.metadata);
return <Dialog
maxWidth="lg"
fullWidth
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<Typography variant="h5">Properties</Typography>
<PropertyEditor
originalMetadata={props.metadata}
currentMetadata={editingMetadata}
onChange={setEditingMetadata}
editableProperties={props.editableProperties}
/>
{props.editStoreLinks && <><Divider />
<Typography variant="h5">External Links</Typography>
<ExternalLinksEditor
metadata={editingMetadata}
original={props.metadata}
onChange={(v: any) => setEditingMetadata(v)}
defaultQuery={props.defaultExternalLinksQuery}
resourceType={props.resourceType}
/></>}
<Divider />
{!_.isEqual(editingMetadata, props.metadata) && <DialogActions>
<Button variant="contained" color="secondary"
onClick={() => {
props.onSubmit(editingMetadata);
props.onClose();
}}>Save all changes</Button>
<Button variant="outlined"
onClick={() => setEditingMetadata(props.metadata)}>Discard changes</Button>
</DialogActions>}
</Dialog>
}

@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Box, IconButton, TextField } from '@material-ui/core';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
import UndoIcon from '@material-ui/icons/Undo';
import { useTheme } from '@material-ui/core/styles';
// This component is an editable text. It shows up as normal text,
// but will display an edit icon on hover. When clicked, this
// enables a text input to make a new suggestion.
// The text can show a striked-through version of the old text,
// with the new value next to it and an undo button.
export interface IProps {
defaultValue: string,
changedValue: string | null, // Null == not changed
editingValue: string | null, // Null == not editing
editingLabel: string,
onChangeEditingValue: (v: string | null) => void,
onChangeChangedValue: (v: string | null) => void,
}
export default function EditableText(props: IProps) {
let editingValue = props.editingValue;
let defaultValue = props.defaultValue;
let changedValue = props.changedValue;
let onChangeEditingValue = props.onChangeEditingValue;
let onChangeChangedValue = props.onChangeChangedValue;
let editing = editingValue !== null;
let editingLabel = props.editingLabel;
const theme = useTheme();
const [hovering, setHovering] = useState<Boolean>(false);
const editButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => onChangeEditingValue(changedValue || defaultValue)}
>
<EditIcon />
</IconButton>
</Box>
const discardChangesButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => {
onChangeChangedValue(null);
onChangeEditingValue(null);
}}
>
<UndoIcon />
</IconButton>
</Box>
if (editing) {
return <Box display="flex" alignItems="center">
<TextField
variant="outlined"
value={editingValue || ""}
label={editingLabel}
inputProps={{ style: { fontSize: '2rem' } }}
onChange={(e: any) => onChangeEditingValue(e.target.value)}
/>
<IconButton
onClick={() => {
onChangeChangedValue(editingValue === defaultValue ? null : editingValue);
onChangeEditingValue(null);
}}
><CheckIcon /></IconButton>
</Box>
} else if (changedValue) {
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>
<del style={{ color: theme.palette.text.secondary }}>{defaultValue}</del>
{changedValue}
{editButton}
{discardChangesButton}
</Box>
}
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>{defaultValue}{editButton}</Box>;
}

@ -3,10 +3,7 @@ import { Menu, MenuItem } from '@material-ui/core';
import NestedMenuItem from "material-ui-nested-menu-item"; import NestedMenuItem from "material-ui-nested-menu-item";
import { QueryElem, QueryLeafBy, QueryLeafOp, TagQueryInfo } from '../../lib/query/Query'; import { QueryElem, QueryLeafBy, QueryLeafOp, TagQueryInfo } from '../../lib/query/Query';
import QBSelectWithRequest from './QBSelectWithRequest'; import QBSelectWithRequest from './QBSelectWithRequest';
import { Requests, QueryBuilderTag } from './QueryBuilder'; import { Requests } from './QueryBuilder';
import SpotifyClientCreds from '../../lib/integration/spotify/SpotifyClientCreds';
import { IntegrationUrls, IntegrationWith, QueryNodeOp } from '../../api/api';
import { $enum } from 'ts-enum-util';
export interface MenuProps { export interface MenuProps {
anchorEl: null | HTMLElement, anchorEl: null | HTMLElement,
@ -15,19 +12,19 @@ export interface MenuProps {
requestFunctions: Requests, requestFunctions: Requests,
} }
export function createTagInfo(tag: QueryBuilderTag, allTags: QueryBuilderTag[]): TagQueryInfo { export function createTagInfo(tag: any, allTags: any[]): TagQueryInfo {
const resolveName: (t: QueryBuilderTag) => string[] = (t: QueryBuilderTag) => { const resolveName: (t: any) => string[] = (t: any) => {
if (t.parentId) { if (t.parentId) {
const parent = allTags.filter((o: QueryBuilderTag) => o.id === t.parentId)[0]; const parent = allTags.filter((o: any) => o.tagId === t.parentId)[0];
return resolveName(parent).concat(t.name); return [resolveName(parent), t.name];
} }
return [t.name]; return [t.name];
} }
const resolveChildren: (t: QueryBuilderTag) => Set<number> = (t: QueryBuilderTag) => { const resolveChildren: (t: any) => Set<number> = (t: any) => {
if (t.childIds.length > 0) { if (t.childIds.length > 0) {
const childSets: Set<number>[] = allTags.filter((o: QueryBuilderTag) => t.childIds.includes(o.id)) const childSets: Set<number>[] = allTags.filter((o: any) => t.childIds.includes(o.tagId))
.map((child: QueryBuilderTag) => resolveChildren(child)); .map((child: any) => resolveChildren(child));
var r = new Set<number>(); var r = new Set<number>();
childSets.forEach((c: any) => { childSets.forEach((c: any) => {
@ -36,7 +33,7 @@ export function createTagInfo(tag: QueryBuilderTag, allTags: QueryBuilderTag[]):
return r; return r;
} }
return new Set([t.id]); return new Set([t.tagId]);
} }
return { return {
@ -50,14 +47,13 @@ export function QBAddElemMenu(props: MenuProps) {
let onClose = props.onClose; let onClose = props.onClose;
interface TagItemProps { interface TagItemProps {
tag: QueryBuilderTag, tag: any,
allTags: QueryBuilderTag[], allTags: any[],
} }
const TagItem = (_props: TagItemProps) => { const TagItem = (_props: TagItemProps) => {
if (_props.tag.childIds.length > 0) { if (_props.tag.childIds.length > 0) {
const children = _props.allTags.filter( const children = _props.allTags.filter(
(tag: QueryBuilderTag) => (tag: any) => _props.tag.childIds.includes(tag.tagId)
_props.tag.childIds.includes(tag.id)
); );
return <NestedMenuItem return <NestedMenuItem
@ -72,19 +68,12 @@ export function QBAddElemMenu(props: MenuProps) {
}); });
}} }}
> >
{children.map((child: QueryBuilderTag) => <TagItem tag={child} allTags={_props.allTags} />)} {children.map((child: any) => <TagItem tag={child} allTags={_props.allTags} />)}
</NestedMenuItem> </NestedMenuItem>
} }
return <MenuItem return <MenuItem
onClick={() => { onClick={() => {
console.log("onCreateQuery: adding:", {
a: QueryLeafBy.TagInfo,
leafOp: QueryLeafOp.Equals,
b: createTagInfo(_props.tag, _props.allTags),
});
onClose(); onClose();
props.onCreateQuery({ props.onCreateQuery({
a: QueryLeafBy.TagInfo, a: QueryLeafBy.TagInfo,
@ -98,7 +87,7 @@ export function QBAddElemMenu(props: MenuProps) {
} }
const BaseTagsItem = (_props: any) => { const BaseTagsItem = (_props: any) => {
const [tags, setTags] = useState<QueryBuilderTag[] | null>(null); const [tags, setTags] = useState<any[] | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -108,53 +97,13 @@ export function QBAddElemMenu(props: MenuProps) {
return tags ? return tags ?
<> <>
{tags.filter((tag: QueryBuilderTag) => !tag.parentId).map((tag: QueryBuilderTag) => { {tags.filter((tag: any) => !tag.parentId).map((tag: any) => {
return <TagItem tag={tag} allTags={tags} /> return <TagItem tag={tag} allTags={tags} />
})} })}
</> </>
: <>...</> : <>...</>
} }
const LinksItem = (_props: any) => {
let createLinksQuery = (store: IntegrationWith, isLinked: boolean) => {
let isLinkedQuery : QueryElem = {
a: QueryLeafBy.StoreLinks,
leafOp: QueryLeafOp.Like,
b: '%' + IntegrationUrls[store] + '%'
};
if (isLinked) {
return isLinkedQuery;
}
return {
operands: [isLinkedQuery],
nodeOp: QueryNodeOp.Not,
};
};
return <>
{$enum(IntegrationWith).getValues().map((store: IntegrationWith) => {
return <NestedMenuItem
label={store}
parentMenuOpen={Boolean(anchorEl)}
>
<MenuItem
onClick={() => {
onClose();
props.onCreateQuery(createLinksQuery(store, true));
}}
>Linked</MenuItem>
<MenuItem
onClick={() => {
onClose();
props.onCreateQuery(createLinksQuery(store, false));
}}
>Not Linked</MenuItem>
</NestedMenuItem>
})}
</>
}
return <Menu return <Menu
anchorEl={anchorEl} anchorEl={anchorEl}
keepMounted keepMounted
@ -222,17 +171,5 @@ export function QBAddElemMenu(props: MenuProps) {
> >
<BaseTagsItem /> <BaseTagsItem />
</NestedMenuItem> </NestedMenuItem>
<NestedMenuItem
label="Metadata"
parentMenuOpen={Boolean(anchorEl)}
>
{/*TODO: generalize for other types of metadata in a scalable way*/}
<NestedMenuItem
label="Links"
parentMenuOpen={Boolean(anchorEl)}
>
<LinksItem />
</NestedMenuItem>
</NestedMenuItem>
</Menu > </Menu >
} }

@ -1,12 +1,10 @@
import React from 'react'; import React from 'react';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo, isLeafElem } from '../../lib/query/Query'; import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo } from '../../lib/query/Query';
import { Chip, Typography, IconButton, Box } from '@material-ui/core'; import { Chip, Typography, IconButton, Box } from '@material-ui/core';
import { QBPlaceholder } from './QBPlaceholder'; import { QBPlaceholder } from './QBPlaceholder';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
import { Requests } from './QueryBuilder'; import { Requests } from './QueryBuilder';
import stringifyList from '../../lib/stringifyList'; import stringifyList from '../../lib/stringifyList';
import { IntegrationUrls, IntegrationWith } from '../../api/api';
import { $enum } from 'ts-enum-util';
export interface ElemChipProps { export interface ElemChipProps {
label: any, label: any,
@ -23,9 +21,8 @@ export function LabeledElemChip(props: ElemChipProps) {
export interface LeafProps { export interface LeafProps {
elem: QueryLeafElem, elem: QueryLeafElem,
onReplace: (q: QueryElem | null) => void, onReplace: (q: QueryElem) => void,
extraElements?: any, extraElements?: any,
modifier?: Modifier,
} }
export function QBQueryElemArtistEquals(props: LeafProps) { export function QBQueryElemArtistEquals(props: LeafProps) {
@ -81,47 +78,6 @@ export function QBQueryElemTagEquals(props: LeafProps) {
/> />
} }
export function QBQueryElemStoreLinked(props: LeafProps) {
// The store match string should be "%STORE%"
let storeUrl: string = (props.elem.b as string).replace(/%/g, '');
let store: string = '';
for (const [key, value] of Object.entries(IntegrationUrls)) {
if (value == storeUrl) {
store = key;
}
}
if (store == '') {
throw "Could not find store name for 'Store Linked' element";
}
if (props.modifier && props.modifier == Modifier.Not) {
return <LabeledElemChip
label={"No link to " + store}
extraElements={props.extraElements}
/>
}
return <LabeledElemChip
label={"Has link to " + store}
extraElements={props.extraElements}
/>
}
export function isStoreLinkedLeafElem(e: QueryElem): boolean {
if (isLeafElem(e) &&
e.leafOp === QueryLeafOp.Like &&
e.a === QueryLeafBy.StoreLinks) {
// There are multiple kinds of ops done on
// on storelinks. We need to examine the match
// string.
let isLinked_matchstrings: string[] =
$enum(IntegrationWith).getValues().map(
(store: IntegrationWith) => '%' + IntegrationUrls[store] + '%');
if (isLinked_matchstrings.includes(e.b as string)) {
return true;
}
}
return false;
}
export interface DeleteButtonProps { export interface DeleteButtonProps {
onClick?: (e: any) => void, onClick?: (e: any) => void,
} }
@ -136,17 +92,8 @@ export function QBQueryElemDeleteButton(props: DeleteButtonProps) {
</IconButton> </IconButton>
} }
// Modifiers are used to encode a node op's meaning
// into a leaf op element for visual representation.
// E.g. a NOT modifier can be added to show a "artist"
// leaf as "not by artist".
export enum Modifier {
Not = "NOT",
}
export interface IProps { export interface IProps {
elem: QueryLeafElem, elem: QueryLeafElem,
modifier?: Modifier,
onReplace: (q: QueryElem | null) => void, onReplace: (q: QueryElem | null) => void,
editingQuery: boolean, editingQuery: boolean,
requestFunctions: Requests, requestFunctions: Requests,
@ -217,13 +164,7 @@ export function QBLeafElem(props: IProps) {
onReplace={props.onReplace} onReplace={props.onReplace}
requestFunctions={props.requestFunctions} requestFunctions={props.requestFunctions}
/> />
} else if (isStoreLinkedLeafElem(e)) {
return <QBQueryElemStoreLinked
{...props}
extraElements={extraElements}
/>;
} }
console.log("Unsupported leaf element:", e);
throw new Error("Unsupported leaf element"); throw new Error("Unsupported leaf element");
} }

@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import QBOrBlock from './QBOrBlock'; import QBOrBlock from './QBOrBlock';
import QBAndBlock from './QBAndBlock'; import QBAndBlock from './QBAndBlock';
import { QueryNodeElem, QueryNodeOp, QueryElem, simplify, QueryLeafElem, isLeafElem } from '../../lib/query/Query'; import { QueryNodeElem, QueryNodeOp, QueryElem, simplify } from '../../lib/query/Query';
import { QBQueryElem } from './QBQueryElem'; import { QBQueryElem } from './QBQueryElem';
import { Requests } from './QueryBuilder'; import { Requests } from './QueryBuilder';
import { Modifier, QBLeafElem } from './QBLeafElem';
export interface NodeProps { export interface NodeProps {
elem: QueryNodeElem, elem: QueryNodeElem,
@ -23,8 +22,7 @@ export function QBNodeElem(props: NodeProps) {
} else { } else {
ops.splice(idx, 1); ops.splice(idx, 1);
} }
let newq = { operands: ops, nodeOp: e.nodeOp }; let newNode = simplify({ operands: ops, nodeOp: e.nodeOp });
let newNode = simplify(newq, null);
props.onReplace(newNode); props.onReplace(newNode);
} }
@ -41,17 +39,7 @@ export function QBNodeElem(props: NodeProps) {
return <QBAndBlock>{children}</QBAndBlock> return <QBAndBlock>{children}</QBAndBlock>
} else if (e.nodeOp === QueryNodeOp.Or) { } else if (e.nodeOp === QueryNodeOp.Or) {
return <QBOrBlock>{children}</QBOrBlock> return <QBOrBlock>{children}</QBOrBlock>
} else if (e.nodeOp === QueryNodeOp.Not &&
isLeafElem(e.operands[0])) {
return <QBLeafElem
elem={e.operands[0] as QueryLeafElem}
onReplace={props.onReplace}
editingQuery={props.editingQuery}
requestFunctions={props.requestFunctions}
modifier={Modifier.Not}
/>
} }
console.log("Unsupported node element:", e);
throw new Error("Unsupported node element"); throw new Error("Unsupported node element");
} }

@ -19,7 +19,6 @@ export function QBPlaceholder(props: IProps & any) {
setAnchorEl(null); setAnchorEl(null);
}; };
const onCreate = (q: QueryElem) => { const onCreate = (q: QueryElem) => {
console.log("Replacing placeholder by:", q);
props.onReplace(q); props.onReplace(q);
}; };

@ -3,15 +3,19 @@ import { Box } from '@material-ui/core';
import QBQueryButton from './QBEditButton'; import QBQueryButton from './QBEditButton';
import { QBQueryElem } from './QBQueryElem'; import { QBQueryElem } from './QBQueryElem';
import { QueryElem, addPlaceholders, removePlaceholders, simplify } from '../../lib/query/Query'; import { QueryElem, addPlaceholders, removePlaceholders, simplify } from '../../lib/query/Query';
import { Tag, TagChildIds, TagParentId, Name, Id } from '../../api/api';
export type QueryBuilderTag = (Tag & TagChildIds & TagParentId & Name & Id); export interface TagItem {
name: string,
id: number,
childIds: number[],
parentId?: number,
}
export interface Requests { export interface Requests {
getArtists: (filter: string) => Promise<string[]>, getArtists: (filter: string) => Promise<string[]>,
getAlbums: (filter: string) => Promise<string[]>, getAlbums: (filter: string) => Promise<string[]>,
getTrackNames: (filter: string) => Promise<string[]>, getTrackNames: (filter: string) => Promise<string[]>,
getTags: () => Promise<QueryBuilderTag[]>, getTags: () => Promise<TagItem[]>,
} }
export interface IProps { export interface IProps {
@ -23,13 +27,12 @@ export interface IProps {
} }
export default function QueryBuilder(props: IProps) { export default function QueryBuilder(props: IProps) {
const simpleQuery = simplify(props.query, null); const simpleQuery = simplify(props.query);
const showQuery = props.editing ? const showQuery = props.editing ?
addPlaceholders(simpleQuery, null) : simpleQuery; addPlaceholders(simpleQuery, null) : simpleQuery;
const onReplace = (q: any) => { const onReplace = (q: any) => {
const newQ = removePlaceholders(q); const newQ = removePlaceholders(q);
console.log("Removed placeholders:", q, newQ)
props.onChangeEditing(false); props.onChangeEditing(false);
props.onChangeQuery(newQ); props.onChangeQuery(newQ);
} }

@ -2,69 +2,34 @@ import React from 'react';
import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyles, TableBody, Chip, Box, Button } from '@material-ui/core'; import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyles, TableBody, Chip, Box, Button } from '@material-ui/core';
import stringifyList from '../../lib/stringifyList'; import stringifyList from '../../lib/stringifyList';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { Artist, QueryResponseTrackDetails, Tag, Name, Id, TagDetails, QueryResponseArtistDetails, QueryResponseAlbumDetails } from '../../api/api'; import { Artist, QueryResponseTrackDetails, Tag, Name } from '../../api/api';
import { isTemplateHead } from 'typescript';
function getFullTagNames(item: any, function getTagNames (track: QueryResponseTrackDetails) : string[][] {
getTagName: (tag: any) => string,
getTagParent: (tag: any) => any,
getItemTags: (item: any) => any[]): string[][] {
// Recursively resolve the name. // Recursively resolve the name.
const resolveTag = (tag: any) => { const resolveTag = (tag: any) => {
var r = [getTagName(tag)]; var r = [tag.name];
const parent = getTagParent(tag); if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
if (parent) { r = resolveTag(parent).concat(r); }
return r; return r;
} }
return (getItemTags(item) || []).map((tag: Tag) => resolveTag(tag)); return track.tags.map((tag: Tag) => resolveTag(tag));
} }
function getFullTagIds(item: any, function getTagIds (track: QueryResponseTrackDetails) : number[][] {
getTagId: (tag: any) => number, // Recursively resolve the id.
getTagParent: (tag: any) => any,
getItemTags: (item: any) => any[]): number[][] {
// Recursively resolve the name.
const resolveTag = (tag: any) => { const resolveTag = (tag: any) => {
var r = [getTagId(tag)]; var r = [tag.tagId];
const parent = getTagParent(tag); if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
if (parent) { r = resolveTag(parent).concat(r); }
return r; return r;
} }
return (getItemTags(item) || []).map((tag: Tag) => resolveTag(tag)); return track.tags.map((tag: any) => resolveTag(tag));
}
export enum ColumnType {
Text = 0,
Tags,
}
export interface TextColumnData {
}
export interface TagsColumnData {
}
export interface ColumnDescription {
type: ColumnType,
title: string,
getText?: (item: any) => string,
getMaybeOnClick?: (item: any) => () => void,
getTags?: (item: any) => any[],
getTagName?: (tag: any) => string,
getTagId?: (tag: any) => number,
getTagParent?: (tag: any) => any,
getTagOnClick?: (tag: any) => () => void,
} }
export function RenderItem(props: { export default function TrackTable(props: {
columnDescription: ColumnDescription, tracks: QueryResponseTrackDetails[]
item: any
}) { }) {
let { columnDescription: cd, item } = props; const history = useHistory();
const classes = makeStyles({ const classes = makeStyles({
button: { button: {
@ -73,59 +38,6 @@ export function RenderItem(props: {
paddingLeft: '0', paddingLeft: '0',
textAlign: 'left', textAlign: 'left',
}, },
})();
const TextCell = (props: any) => {
return <TableCell padding="none" {...props}>
<Button className={classes.button} fullWidth={true} onClick={props._onClick}>
<Box
width="100%"
display="flex"
alignItems="center"
paddingLeft="16px"
>
{props.children}
</Box>
</Button>
</TableCell>;
}
switch (props.columnDescription.type) {
case ColumnType.Text:
const text = cd.getText && cd.getText(item) || "Unknown";
const onClick = cd.getMaybeOnClick && cd.getMaybeOnClick(item) || null;
return <TextCell align="left" _onClick={onClick}>{text}</TextCell>
break;
case ColumnType.Tags:
const tags: any[] = cd.getTags && cd.getTags(item) || [];
const fullTagNames: string[][] = getFullTagNames(
item,
cd.getTagName || (() => "Unknown"),
cd.getTagParent || (() => null),
cd.getTags || (() => []),
);
return <>{fullTagNames.map((tag: string[], i: number) => {
const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => {
return (idx === 0) ? e : " / " + e;
})
return <Box ml={0.5} mr={0.5}>
<Chip size="small"
label={fullTag}
onClick={cd.getTagOnClick && cd.getTagOnClick(tags[tags.length - 1])}
/>
</Box>;
})}</>;
break;
default:
throw 'Unknown column type';
}
}
export function ItemsTable(props: {
items: any[],
columns: ColumnDescription[],
}) {
const classes = makeStyles({
table: { table: {
minWidth: 650, minWidth: 650,
}, },
@ -136,136 +48,83 @@ export function ItemsTable(props: {
<Table className={classes.table} aria-label="a dense table"> <Table className={classes.table} aria-label="a dense table">
<TableHead> <TableHead>
<TableRow> <TableRow>
{props.columns.map((c: ColumnDescription) => <TableCell align="left">Title</TableCell>
<TableCell align="left">{c.title}</TableCell>)} <TableCell align="left">Artist</TableCell>
<TableCell align="left">Album</TableCell>
<TableCell align="left">Tags</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{props.items.map((item: any, idx: number) => { {props.tracks.map((track: QueryResponseTrackDetails) => {
return <TableRow key={idx}> const name = track.name;
{props.columns.map((c: ColumnDescription) => // TODO: display artists and albums separately!
<RenderItem const artistNames = track.artists
columnDescription={c}
item={item}
/>)}
</TableRow>;
})}
</TableBody>
</Table>
</TableContainer>
);
}
export function TracksTable(props: {
tracks: QueryResponseTrackDetails[]
}) {
const history = useHistory();
return <ItemsTable
items={props.tracks}
columns={[
{
title: 'Title', type: ColumnType.Text, getText: (i: QueryResponseTrackDetails) => i.name,
getMaybeOnClick: (i: QueryResponseTrackDetails) => () => {
history.push('/track/' + i.id);
},
},
{
title: 'Artist', type: ColumnType.Text,
getText: (i: QueryResponseTrackDetails) => {
const artistNames = i.artists
.filter( (a: Artist) => a.name ) .filter( (a: Artist) => a.name )
.map( (a: (Artist & Name)) => a.name ); .map( (a: (Artist & Name)) => a.name );
return stringifyList(artistNames); const artist = stringifyList(artistNames);
},
getMaybeOnClick: (i: QueryResponseTrackDetails) => () => {
// TODO
const mainArtistId = const mainArtistId =
(i.artists.length > 0 && i.artists[0].id) || undefined; (track.artists.length > 0 && track.artists[0].id) || undefined;
history.push('/artist/' + mainArtistId || 'undefined'); const album = track.album?.name || undefined;
}, const albumId = track.album?.id || undefined;
}, const trackId = track.id;
{ const tagIds = getTagIds(track);
title: 'Album', type: ColumnType.Text, getText: (i: QueryResponseTrackDetails) => i.album?.name || "Unknown",
getMaybeOnClick: (i: QueryResponseTrackDetails) => () => { const onClickArtist = () => {
history.push('/album/' + i.album?.id || 'undefined'); history.push('/artist/' + mainArtistId);
},
},
{
title: 'Tags', type: ColumnType.Tags,
getTags: (i: QueryResponseTrackDetails) => i.tags,
getTagId: (t: Tag & Id) => t.id,
getTagName: (t: Tag & Name) => t.name,
getTagParent: (t: Tag & TagDetails) => t.parent,
getTagOnClick: (t: Tag & Id) => () => { history.push('/tag/' + t.id) }
}
]}
/>
} }
export function ArtistsTable(props: { const onClickAlbum = () => {
artists: QueryResponseArtistDetails[] history.push('/album/' + albumId || '');
}) { }
const history = useHistory();
return <ItemsTable const onClickTrack = () => {
items={props.artists} history.push('/track/' + trackId);
columns={[
{
title: 'Name', type: ColumnType.Text, getText: (i: QueryResponseArtistDetails) => i.name,
getMaybeOnClick: (i: QueryResponseArtistDetails) => () => {
history.push('/artist/' + i.id);
},
},
{
title: 'Tags', type: ColumnType.Tags,
getTags: (i: QueryResponseArtistDetails) => (i.tags || []),
getTagId: (t: Tag & Id) => t.id,
getTagName: (t: Tag & Name) => t.name,
getTagParent: (t: Tag & TagDetails) => t.parent,
getTagOnClick: (t: Tag & Id) => () => { history.push('/tag/' + t.id) }
} }
]}
/> const onClickTag = (id: number, name: string) => {
history.push('/tag/' + id);
} }
export function AlbumsTable(props: { const tags = getTagNames(track).map((tag: string[], i: number) => {
albums: QueryResponseAlbumDetails[] const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => {
}) { return (idx === 0) ? e : " / " + e;
const history = useHistory(); })
return <Box ml={0.5} mr={0.5}>
<Chip size="small"
label={fullTag}
onClick={() => onClickTag(tagIds[i][tagIds[i].length - 1], fullTag)}
/>
</Box>
});
return <ItemsTable const TextCell = (props: any) => {
items={props.albums} return <TableCell padding="none" {...props}>
columns={[ <Button className={classes.button} fullWidth={true} onClick={props._onClick}>
{ <Box
title: 'Name', type: ColumnType.Text, getText: (i: QueryResponseAlbumDetails) => i.name, width="100%"
getMaybeOnClick: (i: QueryResponseAlbumDetails) => () => { display="flex"
history.push('/album/' + i.id); alignItems="center"
}, paddingLeft="16px"
}, >
{ {props.children}
title: 'Artist', type: ColumnType.Text, </Box>
getText: (i: QueryResponseAlbumDetails) => { </Button>
const artistNames = (i.artists || []) </TableCell>;
.filter((a: Artist) => a.name)
.map((a: Artist) => a.name || "Unknown");
return stringifyList(artistNames);
},
getMaybeOnClick: (i: QueryResponseAlbumDetails) => () => {
// TODO
const mainArtistId =
((i.artists || []).length > 0 && (i.artists || [])[0].id) || undefined;
history.push('/artist/' + mainArtistId || 'undefined');
},
},
{
title: 'Tags', type: ColumnType.Tags,
getTags: (i: QueryResponseTrackDetails) => i.tags,
getTagId: (t: Tag & Id) => t.id,
getTagName: (t: Tag & Name) => t.name,
getTagParent: (t: Tag & TagDetails) => t.parent,
getTagOnClick: (t: Tag & Id) => () => { history.push('/tag/' + t.id) }
} }
]}
/> return <TableRow key={name}>
<TextCell align="left" _onClick={onClickTrack}>{name}</TextCell>
<TextCell align="left" _onClick={onClickArtist}>{artist}</TextCell>
{album ? <TextCell align="left" _onClick={onClickAlbum}>{album}</TextCell> : <TextCell/>}
<TableCell padding="none" align="left" width="25%">
<Box display="flex" alignItems="center">
{tags}
</Box>
</TableCell>
</TableRow>
})}
</TableBody>
</Table>
</TableContainer>
);
} }

@ -4,16 +4,16 @@ import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../../api/api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { ColumnType, ItemsTable, TracksTable } from '../../tables/ResultsTable'; import EditableText from '../../common/EditableText';
import { modifyAlbum, modifyTrack } from '../../../lib/saveChanges'; import SubmitChangesButton from '../../common/SubmitChangesButton';
import TrackTable from '../../tables/ResultsTable';
import { modifyAlbum } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, queryTracks } from '../../../lib/backend/queries'; import { queryAlbums, queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth'; import { useAuth } from '../../../lib/useAuth';
import { Album, Name, Id, StoreLinks, AlbumRefs, Artist, Tag, Track, ResourceType } from '../../../api/api'; import { Album, Name, Id, StoreLinks, AlbumRefs } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export type AlbumMetadata = serverApi.QueryResponseAlbumDetails; export type AlbumMetadata = serverApi.QueryResponseAlbumDetails;
export type AlbumMetadataChanges = serverApi.PatchAlbumRequest; export type AlbumMetadataChanges = serverApi.PatchAlbumRequest;
@ -77,11 +77,9 @@ export function AlbumWindowControlled(props: {
let { id: albumId, metadata, pendingChanges, tracksOnAlbum } = props.state; let { id: albumId, metadata, pendingChanges, tracksOnAlbum } = props.state;
let { dispatch } = props; let { dispatch } = props;
let auth = useAuth(); let auth = useAuth();
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the album's metadata. // Effect to get the album's metadata.
useEffect(() => { useEffect(() => {
if (metadata === null) {
getAlbumMetadata(albumId) getAlbumMetadata(albumId)
.then((m: AlbumMetadata) => { .then((m: AlbumMetadata) => {
dispatch({ dispatch({
@ -90,8 +88,7 @@ export function AlbumWindowControlled(props: {
}); });
}) })
.catch((e: any) => { handleNotLoggedIn(auth, e) }) .catch((e: any) => { handleNotLoggedIn(auth, e) })
} }, [albumId, dispatch]);
}, [albumId, dispatch, metadata]);
// Effect to get the album's tracks. // Effect to get the album's tracks.
useEffect(() => { useEffect(() => {
@ -113,7 +110,23 @@ export function AlbumWindowControlled(props: {
})(); })();
}, [tracksOnAlbum, albumId, dispatch]); }, [tracksOnAlbum, albumId, dispatch]);
const name = <Typography variant="h4">{metadata?.name || "(Unknown album name)"}</Typography> const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: AlbumWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link); const store = whichStore(link);
@ -128,6 +141,23 @@ export function AlbumWindowControlled(props: {
</a> </a>
}); });
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
modifyAlbum(props.state.id, pendingChanges || { mbApi_typename: 'album' })
.then(() => {
setApplying(false);
props.dispatch({
type: AlbumWindowStateActions.Reload
})
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
m={1} m={1}
@ -149,13 +179,14 @@ export function AlbumWindowControlled(props: {
{storeLinks} {storeLinks}
</Box> </Box>
</Box> </Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>} </Box>}
</Box> </Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box <Box
m={1} m={1}
width="80%" width="80%"
@ -163,42 +194,10 @@ export function AlbumWindowControlled(props: {
<Box display="flex" flexDirection="column" alignItems="left"> <Box display="flex" flexDirection="column" alignItems="left">
<Typography>Tracks in this album in your library:</Typography> <Typography>Tracks in this album in your library:</Typography>
</Box> </Box>
{props.state.tracksOnAlbum && <TracksTable tracks={props.state.tracksOnAlbum}/>} {props.state.tracksOnAlbum && <TrackTable
tracks={props.state.tracksOnAlbum}
/>}
{!props.state.tracksOnAlbum && <CircularProgress />} {!props.state.tracksOnAlbum && <CircularProgress />}
</Box> </Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchAlbumRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
tracks: undefined,
artists: undefined,
tags: undefined,
trackIds: v.trackIds || v.tracks?.map(
(a: (Track & Id)) => { return a.id }
) || undefined,
artistIds: v.artistIds || v.artists?.map(
(a: (Artist & Id)) => { return a.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map(
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyAlbum(albumId, v_modified)
.then(() => dispatch({
type: AlbumWindowStateActions.Reload
}))
}}
id={albumId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Album}
editStoreLinks={true}
/>}
</Box> </Box>
} }

@ -4,16 +4,15 @@ import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../../api/api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { ColumnType, ItemsTable, TracksTable } from '../../tables/ResultsTable'; import EditableText from '../../common/EditableText';
import { modifyAlbum, modifyArtist } from '../../../lib/saveChanges'; import SubmitChangesButton from '../../common/SubmitChangesButton';
import TrackTable from '../../tables/ResultsTable';
import { modifyArtist } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, queryTracks } from '../../../lib/backend/queries'; import { queryArtists, queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request'; import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth'; import { useAuth } from '../../../lib/useAuth';
import { Track, Id, Artist, Tag, ResourceType, Album } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export type ArtistMetadata = serverApi.QueryResponseArtistDetails; export type ArtistMetadata = serverApi.QueryResponseArtistDetails;
export type ArtistMetadataChanges = serverApi.PatchArtistRequest; export type ArtistMetadataChanges = serverApi.PatchArtistRequest;
@ -82,11 +81,9 @@ export function ArtistWindowControlled(props: {
let { metadata, id: artistId, pendingChanges, tracksByArtist } = props.state; let { metadata, id: artistId, pendingChanges, tracksByArtist } = props.state;
let { dispatch } = props; let { dispatch } = props;
let auth = useAuth(); let auth = useAuth();
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the artist's metadata. // Effect to get the artist's metadata.
useEffect(() => { useEffect(() => {
if (metadata === null) {
getArtistMetadata(artistId) getArtistMetadata(artistId)
.then((m: ArtistMetadata) => { .then((m: ArtistMetadata) => {
dispatch({ dispatch({
@ -95,8 +92,7 @@ export function ArtistWindowControlled(props: {
}); });
}) })
.catch((e: any) => { handleNotLoggedIn(auth, e) }) .catch((e: any) => { handleNotLoggedIn(auth, e) })
} }, [artistId, dispatch]);
}, [artistId, dispatch, metadata]);
// Effect to get the artist's tracks. // Effect to get the artist's tracks.
useEffect(() => { useEffect(() => {
@ -118,7 +114,23 @@ export function ArtistWindowControlled(props: {
})(); })();
}, [tracksByArtist, dispatch, artistId]); }, [tracksByArtist, dispatch, artistId]);
const name = <Typography variant="h4">{metadata?.name || "(Unknown artist)"}</Typography> const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: ArtistWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => { const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link); const store = whichStore(link);
@ -133,6 +145,23 @@ export function ArtistWindowControlled(props: {
</a> </a>
}); });
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
modifyArtist(props.state.id, pendingChanges || { mbApi_typename: 'artist' })
.then(() => {
setApplying(false);
props.dispatch({
type: ArtistWindowStateActions.Reload
})
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
m={1} m={1}
@ -154,13 +183,14 @@ export function ArtistWindowControlled(props: {
{storeLinks} {storeLinks}
</Box> </Box>
</Box> </Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>} </Box>}
</Box> </Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box <Box
m={1} m={1}
width="80%" width="80%"
@ -168,42 +198,10 @@ export function ArtistWindowControlled(props: {
<Box display="flex" flexDirection="column" alignItems="left"> <Box display="flex" flexDirection="column" alignItems="left">
<Typography>Tracks by this artist in your library:</Typography> <Typography>Tracks by this artist in your library:</Typography>
</Box> </Box>
{props.state.tracksByArtist && <TracksTable tracks={props.state.tracksByArtist}/>} {props.state.tracksByArtist && <TrackTable
tracks={props.state.tracksByArtist}
/>}
{!props.state.tracksByArtist && <CircularProgress />} {!props.state.tracksByArtist && <CircularProgress />}
</Box> </Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchArtistRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
tracks: undefined,
albums: undefined,
tags: undefined,
albumIds: v.albumIds || v.albums?.map(
(a: (Album & Id)) => { return a.id }
) || undefined,
trackIds: v.trackIds || v.tracks?.map(
(t: (Track & Id)) => { return t.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map(
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyArtist(artistId, v_modified)
.then(() => dispatch({
type: ArtistWindowStateActions.Reload
}))
}}
id={artistId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Artist}
editStoreLinks={true}
/>}
</Box> </Box>
} }

@ -5,14 +5,13 @@ import { $enum } from 'ts-enum-util';
import { IntegrationState, useIntegrations } from '../../../lib/integration/useIntegrations'; import { IntegrationState, useIntegrations } from '../../../lib/integration/useIntegrations';
import { IntegrationWith, ImplIntegratesWith, IntegrationImpl, ResourceType, QueryResponseType, IntegrationUrls } from '../../../api/api'; import { IntegrationWith, ImplIntegratesWith, IntegrationImpl, ResourceType, QueryResponseType, IntegrationUrls } from '../../../api/api';
import { start } from 'repl'; import { start } from 'repl';
import { QueryFor, QueryLeafBy, QueryLeafOp, QueryNodeOp, queryNot, simplify } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp, QueryNodeOp, queryNot } from '../../../lib/query/Query';
import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries'; import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries';
import asyncPool from "tiny-async-pool"; import asyncPool from "tiny-async-pool";
import { getTrack } from '../../../lib/backend/tracks'; import { getTrack } from '../../../lib/backend/tracks';
import { getAlbum } from '../../../lib/backend/albums'; import { getAlbum } from '../../../lib/backend/albums';
import { getArtist } from '../../../lib/backend/artists'; import { getArtist } from '../../../lib/backend/artists';
import { modifyAlbum, modifyArtist, modifyTrack } from '../../../lib/saveChanges'; import { modifyAlbum, modifyArtist, modifyTrack } from '../../../lib/saveChanges';
import { QueryItemType } from '../query/QueryWindow';
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
@ -50,6 +49,11 @@ async function makeTasks(
linkAlbums: boolean, linkAlbums: boolean,
addTaskCb: (t: Task) => void, addTaskCb: (t: Task) => void,
) { ) {
let whichProp: any = {
[ResourceType.Track]: QueryLeafBy.TrackStoreLinks,
[ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks,
[ResourceType.Album]: QueryLeafBy.AlbumStoreLinks,
}
let whichElem: any = { let whichElem: any = {
[ResourceType.Track]: 'tracks', [ResourceType.Track]: 'tracks',
[ResourceType.Artist]: 'artists', [ResourceType.Artist]: 'artists',
@ -60,12 +64,11 @@ async function makeTasks(
return; return;
} }
let store = maybeStore as IntegrationWith; let store = maybeStore as IntegrationWith;
let doForType = async (type: ResourceType) => { let doForType = async (type: ResourceType) => {
let ids: number[] = ((await queryItems( let ids: number[] = ((await queryItems(
type, [type],
queryNot({ queryNot({
a: QueryLeafBy.StoreLinks, a: whichProp[type],
leafOp: QueryLeafOp.Like, leafOp: QueryLeafOp.Like,
b: `%${IntegrationUrls[store]}%`, b: `%${IntegrationUrls[store]}%`,
}), }),

@ -2,7 +2,7 @@ import { Box, LinearProgress, Typography } from '@material-ui/core';
import React, { useCallback, useEffect, useReducer, useState } from 'react'; import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { $enum } from 'ts-enum-util'; import { $enum } from 'ts-enum-util';
import { IntegrationWith, ResourceType, QueryElemProperty, QueryResponseType, IntegrationUrls } from '../../../api/api'; import { IntegrationWith, ResourceType, QueryElemProperty, QueryResponseType, IntegrationUrls } from '../../../api/api';
import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries'; import { queryItems } from '../../../lib/backend/queries';
import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import StoreLinkIcon from '../../common/StoreLinkIcon'; import StoreLinkIcon from '../../common/StoreLinkIcon';
@ -21,15 +21,20 @@ export default function LinksStatusWidget(props: {
let [linkedCounts, setLinkedCounts] = useState<Record<string, Counts>>({}); let [linkedCounts, setLinkedCounts] = useState<Record<string, Counts>>({});
let queryStoreCount = async (store: IntegrationWith, type: ResourceType) => { let queryStoreCount = async (store: IntegrationWith, type: ResourceType) => {
let whichProp: any = {
[ResourceType.Track]: QueryLeafBy.TrackStoreLinks,
[ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks,
[ResourceType.Album]: QueryLeafBy.AlbumStoreLinks,
}
let whichElem: any = { let whichElem: any = {
[ResourceType.Track]: 'tracks', [ResourceType.Track]: 'tracks',
[ResourceType.Artist]: 'artists', [ResourceType.Artist]: 'artists',
[ResourceType.Album]: 'albums', [ResourceType.Album]: 'albums',
} }
let r: any = await queryItems( let r: any = await queryItems(
type, [type],
{ {
a: QueryLeafBy.StoreLinks, a: whichProp[type],
leafOp: QueryLeafOp.Like, leafOp: QueryLeafOp.Like,
b: `%${IntegrationUrls[store]}%`, b: `%${IntegrationUrls[store]}%`,
}, },
@ -44,11 +49,13 @@ export default function LinksStatusWidget(props: {
// Start retrieving total counts // Start retrieving total counts
useEffect(() => { useEffect(() => {
(async () => { (async () => {
let counts: Counts = { let counts: any = await queryItems(
albums: await queryAlbums(undefined, undefined, undefined, QueryResponseType.Count) as number, [ResourceType.Track, ResourceType.Artist, ResourceType.Album],
tracks: await queryTracks(undefined, undefined, undefined, QueryResponseType.Count) as number, undefined,
artists: await queryArtists(undefined, undefined, undefined, QueryResponseType.Count) as number, undefined,
} undefined,
QueryResponseType.Count
);
console.log("Got total counts: ", counts) console.log("Got total counts: ", counts)
setTotalCounts(counts); setTotalCounts(counts);
} }

@ -1,51 +1,29 @@
import React, { useEffect, useReducer, useCallback } from 'react'; import React, { useEffect, useReducer, useCallback } from 'react';
import { Box, LinearProgress, Typography } from '@material-ui/core'; import { Box, LinearProgress } from '@material-ui/core';
import { QueryElem, QueryLeafBy, QueryLeafElem, QueryLeafOp } from '../../../lib/query/Query'; import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import QueryBuilder, { QueryBuilderTag } from '../../querybuilder/QueryBuilder'; import QueryBuilder from '../../querybuilder/QueryBuilder';
import { AlbumsTable, ArtistsTable, ColumnType, ItemsTable, TracksTable } from '../../tables/ResultsTable'; import TrackTable from '../../tables/ResultsTable';
import { queryArtists, queryTracks, queryAlbums, queryTags } from '../../../lib/backend/queries'; import { queryArtists, queryTracks, queryAlbums, queryTags } from '../../../lib/backend/queries';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import { QueryResponseType, QueryResponseAlbumDetails, QueryResponseTagDetails, QueryResponseArtistDetails, QueryResponseTrackDetails, Artist, Name } from '../../../api/api'; import { QueryResponseType, QueryResponseAlbumDetails, QueryResponseTagDetails, QueryResponseArtistDetails, QueryResponseTrackDetails} from '../../../api/api';
import { ServerStreamResponseOptions } from 'http2'; import { ServerStreamResponseOptions } from 'http2';
import { TrackChangesSharp } from '@material-ui/icons';
import { v4 as genUuid } from 'uuid';
import stringifyList from '../../../lib/stringifyList';
var _ = require('lodash'); var _ = require('lodash');
export enum QueryItemType {
Artists,
Tracks,
Albums,
Tags,
};
export interface ResultsForQuery { export interface ResultsForQuery {
kind: QueryItemType, for: QueryElem,
results: ( results: any[],
QueryResponseAlbumDetails[] | };
QueryResponseArtistDetails[] |
QueryResponseTagDetails[] |
QueryResponseTrackDetails[]
),
}
export interface QueryWindowState extends WindowState { export interface QueryWindowState extends WindowState {
editingQuery: boolean, // Is the editor in "edit mode" editingQuery: boolean,
query: QueryElem | null, // The actual on-screen query query: QueryElem | null,
resultsForQuery: ResultsForQuery | null,
includeTypes: QueryItemType[], // which item types do we actually request results for?
// Whenever queries change, new requests are fired to the server.
// Each request gets a unique id hash.
// In this results record, we store the query IDs which
// we want to show results for.
resultsForQueries: Record<string, ResultsForQuery | null>;
} }
export enum QueryWindowStateActions { export enum QueryWindowStateActions {
FiredNewQueries = "firedNewQueries", SetQuery = "setQuery",
SetEditingQuery = "setEditingQuery", SetEditingQuery = "setEditingQuery",
ReceivedResult = "receivedResult", SetResultsForQuery = "setResultsForQuery",
} }
async function getArtistNames(filter: string) { async function getArtistNames(filter: string) {
@ -87,73 +65,32 @@ async function getTrackNames(filter: string) {
return [...(new Set([...(tracks.map((s: any) => s.name))]))]; return [...(new Set([...(tracks.map((s: any) => s.name))]))];
} }
async function getTagItems(): Promise<QueryBuilderTag[]> { async function getTagItems(): Promise<any> {
let tags: QueryResponseTagDetails[] = (await queryTags( let tags: any = await queryTags(
undefined, undefined,
0, -1, QueryResponseType.Details 0, -1, QueryResponseType.Details
)) as QueryResponseTagDetails[]; );
// We need to resolve the child ids.
let tags_with_children : QueryBuilderTag[] = tags.map((t: QueryResponseTagDetails) => {
return {
...t,
childIds: tags.filter((t2: QueryResponseTagDetails) => t2.parentId === t.id)
.map((t2: QueryResponseTagDetails) => t2.id)
}
})
return tags_with_children;
}
export interface FireNewQueriesData {
query: QueryElem | null,
includeTypes: QueryItemType[],
resultIds: string[],
}
export interface ReceivedResultData { return tags;
result: ResultsForQuery,
id: string,
} }
export function QueryWindowReducer(state: QueryWindowState, action: any) { export function QueryWindowReducer(state: QueryWindowState, action: any) {
switch (action.type) { switch (action.type) {
case QueryWindowStateActions.ReceivedResult: case QueryWindowStateActions.SetQuery:
var arr = action.value as ReceivedResultData; return { ...state, query: action.value }
if (Object.keys(state.resultsForQueries).includes(arr.id)) {
//console.log("Storing result:", arr);
var _n = _.cloneDeep(state);
_n.resultsForQueries[arr.id] = arr.result;
return _n;
}
//console.log("Discarding result:", arr);
return state;
case QueryWindowStateActions.FiredNewQueries:
var newState: QueryWindowState = _.cloneDeep(state);
let _action = action.value as FireNewQueriesData;
// Invalidate results
newState.resultsForQueries = {};
// Add a null result for each of the new IDs.
// Results will be added in as they come.
_action.resultIds && _action.resultIds.forEach((r: string) => {
newState.resultsForQueries[r] = null;
})
newState.query = _action.query;
newState.includeTypes = _action.includeTypes;
return newState;
case QueryWindowStateActions.SetEditingQuery: case QueryWindowStateActions.SetEditingQuery:
return { ...state, editingQuery: action.value } return { ...state, editingQuery: action.value }
case QueryWindowStateActions.SetResultsForQuery:
return { ...state, resultsForQuery: action.value }
default: default:
throw new Error("Unimplemented QueryWindow state update.") throw new Error("Unimplemented QueryWindow state update.")
} }
} }
export default function QueryWindow(props: {}) { export default function QueryWindow(props: {}) {
const [state, dispatch] = useReducer(QueryWindowReducer, { const [state, dispatch] = useReducer(QueryWindowReducer, {
editingQuery: false, editingQuery: false,
query: null, query: null,
resultsForQueries: {}, resultsForQuery: null,
includeTypes: [QueryItemType.Tracks, QueryItemType.Artists, QueryItemType.Albums, QueryItemType.Tags],
}); });
return <QueryWindowControlled state={state} dispatch={dispatch} /> return <QueryWindowControlled state={state} dispatch={dispatch} />
@ -163,71 +100,45 @@ export function QueryWindowControlled(props: {
state: QueryWindowState, state: QueryWindowState,
dispatch: (action: any) => void, dispatch: (action: any) => void,
}) { }) {
let { query, editingQuery, resultsForQueries, includeTypes } = props.state; let { query, editingQuery: editing, resultsForQuery: resultsFor } = props.state;
let { dispatch } = props; let { dispatch } = props;
// Call this function to fire new queries and prepare to receive their results. let setQuery = (q: QueryElem | null) => {
// This will also set the query into the window state. props.dispatch({ type: QueryWindowStateActions.SetQuery, value: q });
const doQueries = async (_query: QueryElem | null, itemTypes: QueryItemType[]) => { }
var promises: Promise<any>[] = []; let setEditingQuery = (e: boolean) => {
var ids: string[] = itemTypes.map((i: any) => genUuid()); props.dispatch({ type: QueryWindowStateActions.SetEditingQuery, value: e });
var query_fns = { }
[QueryItemType.Albums]: queryAlbums, let setResultsForQuery = useCallback((r: ResultsForQuery | null) => {
[QueryItemType.Artists]: queryArtists, dispatch({ type: QueryWindowStateActions.SetResultsForQuery, value: r });
[QueryItemType.Tracks]: queryTracks, }, [dispatch]);
[QueryItemType.Tags]: queryTags,
};
let stateUpdateData: FireNewQueriesData = {
query: _query,
includeTypes: itemTypes,
resultIds: ids
};
// First dispatch to the state that we are firing new queries. const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query));
// This will update the query on the window page and invalidate const showResults = (query && resultsFor && query === resultsFor.for) ? resultsFor.results : [];
// any previous results on-screen.
dispatch({
type: QueryWindowStateActions.FiredNewQueries,
value: stateUpdateData
})
if (_query) { const doQuery = useCallback(async (_query: QueryElem) => {
console.log("Dispatching queries for:", _query); const tracks: QueryResponseTrackDetails[] = await queryTracks(
itemTypes.forEach((itemType: QueryItemType, idx: number) => {
(promises as any[]).push(
(async () => {
let results = (await query_fns[itemType](
_query, _query,
0, // TODO: pagination 0,
100, 100, //TODO: pagination
QueryResponseType.Details QueryResponseType.Details
)) as ( ) as QueryResponseTrackDetails[];
QueryResponseAlbumDetails[] |
QueryResponseArtistDetails[] |
QueryResponseTagDetails[] |
QueryResponseTrackDetails[]);
let r: ReceivedResultData = { if (_.isEqual(query, _query)) {
id: ids[idx], setResultsForQuery({
result: { for: _query,
kind: itemType, results: tracks,
results: results
}
};
dispatch({ type: QueryWindowStateActions.ReceivedResult, value: r })
})()
);
}) })
} }
}, [query, setResultsForQuery]);
await Promise.all(promises); useEffect(() => {
}; if (query) {
doQuery(query);
let setEditingQuery = (e: boolean) => { } else {
props.dispatch({ type: QueryWindowStateActions.SetEditingQuery, value: e }); setResultsForQuery(null);
} }
}, [query, doQuery, setResultsForQuery]);
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
@ -236,10 +147,8 @@ export function QueryWindowControlled(props: {
> >
<QueryBuilder <QueryBuilder
query={query} query={query}
onChangeQuery={(q: QueryElem | null) => { onChangeQuery={setQuery}
doQueries(q, includeTypes) editing={editing}
}}
editing={editingQuery}
onChangeEditing={setEditingQuery} onChangeEditing={setEditingQuery}
requestFunctions={{ requestFunctions={{
getArtists: getArtistNames, getArtists: getArtistNames,
@ -253,38 +162,10 @@ export function QueryWindowControlled(props: {
m={1} m={1}
width="80%" width="80%"
> >
{(() => { <TrackTable
var rr = Object.values(resultsForQueries); tracks={showResults}
rr = rr.sort((r: ResultsForQuery | null) => { />
if (r === null) { return 99; } {loading && <LinearProgress />}
return {
[QueryItemType.Tracks]: 0,
[QueryItemType.Albums]: 1,
[QueryItemType.Artists]: 2,
[QueryItemType.Tags]: 3
}[r.kind];
});
// TODO: the sorting is not working
return rr.map((r: ResultsForQuery | null) => <>
{r !== null && r.kind == QueryItemType.Tracks && <>
<Typography variant="h5">Tracks</Typography>
<TracksTable tracks={r.results as QueryResponseTrackDetails[]}/>
</>}
{r !== null && r.kind == QueryItemType.Albums && <>
<Typography variant="h5">Albums</Typography>
<AlbumsTable albums={r.results as QueryResponseAlbumDetails[]}/>
</>}
{r !== null && r.kind == QueryItemType.Artists && <>
<Typography variant="h5">Artists</Typography>
<ArtistsTable artists={r.results as QueryResponseArtistDetails[]}/>
</>}
{r !== null && r.kind == QueryItemType.Tags && <>
<Typography variant="h5">Tags</Typography>
<Typography>Found {r.results.length} tags.</Typography>
</>}
{r === null && <LinearProgress />}
</>);
})()}
</Box> </Box>
</Box> </Box>
} }

@ -4,14 +4,13 @@ import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import * as serverApi from '../../../api/api'; import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows'; import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon'; import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { ItemsTable, ColumnType, TracksTable } from '../../tables/ResultsTable'; import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import TrackTable from '../../tables/ResultsTable';
import { modifyTag } from '../../../lib/backend/tags'; import { modifyTag } from '../../../lib/backend/tags';
import { queryTags, queryTracks } from '../../../lib/backend/queries'; import { queryTags, queryTracks } from '../../../lib/backend/queries';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { Id, Track, Tag, ResourceType, Album } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export interface FullTagMetadata extends serverApi.QueryResponseTagDetails { export interface FullTagMetadata extends serverApi.QueryResponseTagDetails {
fullName: string[], fullName: string[],
@ -94,11 +93,9 @@ export function TagWindowControlled(props: {
let pendingChanges = props.state.pendingChanges; let pendingChanges = props.state.pendingChanges;
let { id: tagId, tracksWithTag } = props.state; let { id: tagId, tracksWithTag } = props.state;
let dispatch = props.dispatch; let dispatch = props.dispatch;
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the tag's metadata. // Effect to get the tag's metadata.
useEffect(() => { useEffect(() => {
if (metadata === null) {
getTagMetadata(tagId) getTagMetadata(tagId)
.then((m: TagMetadata) => { .then((m: TagMetadata) => {
dispatch({ dispatch({
@ -106,8 +103,7 @@ export function TagWindowControlled(props: {
value: m value: m
}); });
}) })
} }, [tagId, dispatch]);
}, [tagId, dispatch, metadata]);
// Effect to get the tag's tracks. // Effect to get the tag's tracks.
useEffect(() => { useEffect(() => {
@ -128,8 +124,23 @@ export function TagWindowControlled(props: {
})(); })();
}, [tracksWithTag, tagId, dispatch]); }, [tracksWithTag, tagId, dispatch]);
const name = <Typography variant="h4">{metadata?.name || "(Unknown tag name)"}</Typography> const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: TagWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const fullName = <Box display="flex" alignItems="center"> const fullName = <Box display="flex" alignItems="center">
{metadata?.fullName.map((n: string, i: number) => { {metadata?.fullName.map((n: string, i: number) => {
if (metadata?.fullName && i === metadata?.fullName.length - 1) { if (metadata?.fullName && i === metadata?.fullName.length - 1) {
@ -142,6 +153,22 @@ export function TagWindowControlled(props: {
})} })}
</Box> </Box>
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
modifyTag(props.state.id, pendingChanges || { mbApi_typename: 'tag' })
.then(() => {
setApplying(false);
props.dispatch({
type: TagWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap"> return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box <Box
m={1} m={1}
@ -159,11 +186,12 @@ export function TagWindowControlled(props: {
{fullName} {fullName}
</Box> </Box>
</Box>} </Box>}
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box> </Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box> </Box>
<Box <Box
m={1} m={1}
@ -172,33 +200,10 @@ export function TagWindowControlled(props: {
<Box display="flex" flexDirection="column" alignItems="left"> <Box display="flex" flexDirection="column" alignItems="left">
<Typography>Tracks with this tag in your library:</Typography> <Typography>Tracks with this tag in your library:</Typography>
</Box> </Box>
{props.state.tracksWithTag && <TracksTable tracks={props.state.tracksWithTag}/>} {props.state.tracksWithTag && <TrackTable
tracks={props.state.tracksWithTag}
/>}
{!props.state.tracksWithTag && <CircularProgress />} {!props.state.tracksWithTag && <CircularProgress />}
</Box> </Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchTagRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified: serverApi.PatchTagRequest = {
mbApi_typename: 'tag',
name: v.name,
parent: undefined,
parentId: v.parentId || v.parent?.id || undefined,
};
modifyTag(tagId, v_modified)
.then(() => dispatch({
type: TagWindowStateActions.Reload
}))
}}
id={tagId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Artist}
editStoreLinks={false}
/>}
</Box> </Box>
} }

@ -1,33 +1,33 @@
import { IntegrationWith, Name, ResourceType, StoreLinks } from '../../api/api';
import { IntegrationState, useIntegrations } from '../../lib/integration/useIntegrations';
import StoreLinkIcon, { whichStore } from './StoreLinkIcon';
import { $enum } from "ts-enum-util";
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { IntegrationAlbum, IntegrationArtist, IntegrationFeature, IntegrationTrack } from '../../lib/integration/Integration'; import { AppBar, Box, Button, Dialog, DialogActions, Divider, FormControl, FormControlLabel, IconButton, Link, List, ListItem, ListItemIcon, ListItemText, MenuItem, Radio, RadioGroup, Select, Tab, Tabs, TextField, Typography } from "@material-ui/core";
import { Box, List, ListItem, ListItemIcon, ListItemText, IconButton, Typography, FormControl, FormControlLabel, MenuItem, Radio, RadioGroup, Select, TextField } from '@material-ui/core'; import { TrackMetadata } from "./TrackWindow";
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import CheckIcon from '@material-ui/icons/Check'; import CheckIcon from '@material-ui/icons/Check';
import SearchIcon from '@material-ui/icons/Search'; import SearchIcon from '@material-ui/icons/Search';
import CancelIcon from '@material-ui/icons/Cancel'; import CancelIcon from '@material-ui/icons/Cancel';
import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
import { $enum } from "ts-enum-util";
import { useIntegrations, IntegrationsState, IntegrationState } from '../../../lib/integration/useIntegrations';
import { IntegrationFeature, IntegrationTrack } from '../../../lib/integration/Integration';
import { TabPanel } from '@material-ui/lab';
import { v1 } from 'uuid';
import { IntegrationWith } from '../../../api/api';
let _ = require('lodash') let _ = require('lodash')
export type ItemWithExternalLinksProperties = StoreLinks & Name;
export function ProvideLinksWidget(props: { export function ProvideLinksWidget(props: {
providers: IntegrationState[], providers: IntegrationState[],
metadata: ItemWithExternalLinksProperties, metadata: TrackMetadata,
store: IntegrationWith, store: IntegrationWith,
onChange: (link: string | undefined) => void, onChange: (link: string | undefined) => void,
defaultQuery: string,
resourceType: ResourceType,
}) { }) {
let defaultQuery = `${props.metadata.name}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.album && ` ${props.metadata.album.name}`}`;
let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>( let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>(
props.providers.length > 0 ? 0 : undefined props.providers.length > 0 ? 0 : undefined
); );
let [query, setQuery] = useState<string>(props.defaultQuery) let [query, setQuery] = useState<string>(defaultQuery)
let [results, setResults] = useState< let [results, setResults] = useState<IntegrationTrack[] | undefined>(undefined);
IntegrationTrack[] | IntegrationAlbum[] | IntegrationArtist[] | undefined>(undefined);
let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ? let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ?
props.providers[selectedProviderIdx] : undefined; props.providers[selectedProviderIdx] : undefined;
@ -39,7 +39,7 @@ export function ProvideLinksWidget(props: {
// Ensure results are cleared when input state changes. // Ensure results are cleared when input state changes.
useEffect(() => { useEffect(() => {
setResults(undefined); setResults(undefined);
setQuery(props.defaultQuery); setQuery(defaultQuery);
}, [props.store, props.providers, props.metadata]) }, [props.store, props.providers, props.metadata])
return <Box display="flex" flexDirection="column" alignItems="left"> return <Box display="flex" flexDirection="column" alignItems="left">
@ -63,44 +63,17 @@ export function ProvideLinksWidget(props: {
/> />
<IconButton <IconButton
onClick={() => { onClick={() => {
switch (props.resourceType) {
case ResourceType.Track:
selectedProvider?.integration.searchTrack(query, 10) selectedProvider?.integration.searchTrack(query, 10)
.then((tracks: IntegrationTrack[]) => setResults(tracks)) .then((tracks: IntegrationTrack[]) => setResults(tracks))
break;
case ResourceType.Album:
selectedProvider?.integration.searchAlbum(query, 10)
.then((albums: IntegrationAlbum[]) => setResults(albums))
break;
case ResourceType.Artist:
selectedProvider?.integration.searchArtist(query, 10)
.then((artists: IntegrationArtist[]) => setResults(artists))
break;
}
}} }}
><SearchIcon /></IconButton> ><SearchIcon /></IconButton>
{results && results.length > 0 && <Typography>Suggestions:</Typography>} {results && results.length > 0 && <Typography>Suggestions:</Typography>}
<FormControl> <FormControl>
<RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}> <RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}>
{results && (results as any).map((result: IntegrationTrack | IntegrationAlbum | IntegrationArtist, idx: number) => { {results && results.map((result: IntegrationTrack, idx: number) => {
var pretty = ""; let pretty = `"${result.title}"
switch (props.resourceType) { ${result.artist && ` by ${result.artist.name}`}
case ResourceType.Track: ${result.album && ` (${result.album.name})`}`;
let rt = result as IntegrationTrack;
pretty = `"${rt.title}"
${rt.artist && ` by ${rt.artist.name}`}
${rt.album && ` (${rt.album.name})`}`;
break;
case ResourceType.Album:
let ral = result as IntegrationAlbum;
pretty = `"${ral.name}"
${ral.artist && ` by ${ral.artist.name}`}`;
break;
case ResourceType.Artist:
let rar = result as IntegrationArtist;
pretty = rar.name || "(Unknown Artist)";
break;
}
return <FormControlLabel return <FormControlLabel
value={result.url || idx} value={result.url || idx}
control={<Radio checked={(result.url || idx) === currentLink} />} control={<Radio checked={(result.url || idx) === currentLink} />}
@ -119,16 +92,14 @@ export function ProvideLinksWidget(props: {
} }
export function ExternalLinksEditor(props: { export function ExternalLinksEditor(props: {
metadata: ItemWithExternalLinksProperties, metadata: TrackMetadata,
original: ItemWithExternalLinksProperties, original: TrackMetadata,
onChange: (v: any) => void, onChange: (v: TrackMetadata) => void,
defaultQuery: string,
resourceType: ResourceType,
}) { }) {
let [selectedIdx, setSelectedIdx] = useState<number>(0); let [selectedIdx, setSelectedIdx] = useState<number>(0);
let integrations = useIntegrations(); let integrations = useIntegrations();
let getLinksSet = (metadata: ItemWithExternalLinksProperties) => { let getLinksSet = (metadata: TrackMetadata) => {
return $enum(IntegrationWith).getValues().reduce((prev: any, store: string) => { return $enum(IntegrationWith).getValues().reduce((prev: any, store: string) => {
var maybeLink: string | null = null; var maybeLink: string | null = null;
metadata.storeLinks && metadata.storeLinks.forEach((link: string) => { metadata.storeLinks && metadata.storeLinks.forEach((link: string) => {
@ -213,10 +184,51 @@ export function ExternalLinksEditor(props: {
}) })
} }
}} }}
defaultQuery={props.defaultQuery}
resourceType={props.resourceType}
/> />
} }
</Box> </Box>
</Box > </Box >
} }
export default function EditTrackDialog(props: {
open: boolean,
onClose: () => void,
onSubmit: (v: TrackMetadata) => void,
id: number,
metadata: TrackMetadata,
}) {
enum EditTrackTabs {
Details = 0,
ExternalLinks,
}
let [editingMetadata, setEditingMetadata] = useState<TrackMetadata>(props.metadata);
return <Dialog
maxWidth="lg"
fullWidth
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<Typography variant="h5">Properties</Typography>
<Typography>Under construction</Typography>
<Divider />
<Typography variant="h5">External Links</Typography>
<ExternalLinksEditor
metadata={editingMetadata}
original={props.metadata}
onChange={(v: TrackMetadata) => setEditingMetadata(v)}
/>
<Divider />
{!_.isEqual(editingMetadata, props.metadata) && <DialogActions>
<Button variant="contained" color="secondary"
onClick={() => {
props.onSubmit(editingMetadata);
props.onClose();
}}>Save all changes</Button>
<Button variant="outlined"
onClick={() => setEditingMetadata(props.metadata)}>Discard changes</Button>
</DialogActions>}
</Dialog>
}

@ -11,11 +11,10 @@ import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryTracks } from '../../../lib/backend/queries'; import { queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import EditTrackDialog from './EditTrackDialog';
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from '@material-ui/icons/Edit';
import { modifyTrack } from '../../../lib/saveChanges'; import { modifyTrack } from '../../../lib/saveChanges';
import { getTrack } from '../../../lib/backend/tracks'; import { getTrack } from '../../../lib/backend/tracks';
import { Artist, Id, ResourceType, Tag } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
export type TrackMetadata = serverApi.QueryResponseTrackDetails; export type TrackMetadata = serverApi.QueryResponseTrackDetails;
@ -70,7 +69,7 @@ export function TrackWindowControlled(props: {
} }
}, [trackId, dispatch, metadata]); }, [trackId, dispatch, metadata]);
const title = <Typography variant="h4">{metadata?.name || "(Unknown track title)"}</Typography> const title = <Typography variant="h4">{metadata?.name || "(Unknown title)"}</Typography>
const artists = metadata?.artists && metadata?.artists.map((artist: (serverApi.Artist & serverApi.Name)) => { const artists = metadata?.artists && metadata?.artists.map((artist: (serverApi.Artist & serverApi.Name)) => {
return <Typography> return <Typography>
@ -139,37 +138,17 @@ export function TrackWindowControlled(props: {
</Box> </Box>
</Box>} </Box>}
</Box> </Box>
{metadata && <EditItemDialog {metadata && <EditTrackDialog
open={editing} open={editing}
onClose={() => { setEditing(false); }} onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchTrackRequest) => { onSubmit={(v: serverApi.PatchTrackRequest) => {
// Remove any details about linked resources and leave only their IDs. modifyTrack(trackId, v)
let v_modified = {
...v,
album: undefined,
artists: undefined,
tags: undefined,
albumId: v.albumId || v.album?.id || undefined,
artistIds: v.artistIds || v.artists?.map (
(a: (Artist & Id)) => { return a.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map (
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyTrack(trackId, v_modified)
.then(() => dispatch({ .then(() => dispatch({
type: TrackWindowStateActions.Reload type: TrackWindowStateActions.Reload
})) }))
}} }}
id={trackId} id={trackId}
metadata={metadata} metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Title', type: EditablePropertyType.Text },
]}
resourceType={ResourceType.Track}
editStoreLinks={true}
defaultExternalLinksQuery={`${metadata.name}${metadata.artists && ` ${metadata.artists[0].name}`}${metadata.album && ` ${metadata.album.name}`}`}
/>} />}
</Box> </Box>
} }

@ -1,53 +1,26 @@
import * as serverApi from '../../api/api'; import * as serverApi from '../../api/api';
import { QueryElem, QueryFor, simplify, toApiQuery } from '../query/Query'; import { QueryElem, toApiQuery } from '../query/Query';
import backendRequest from './request'; import backendRequest from './request';
export async function queryItems( export async function queryItems(
type: serverApi.ResourceType, types: serverApi.ResourceType[],
query: QueryElem | undefined, query: QueryElem | undefined,
offset: number | undefined, offset: number | undefined,
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponse> { ): Promise<serverApi.QueryResponse> {
const queryForMapping : any = { console.log("Types:", types);
[serverApi.ResourceType.Album]: QueryFor.Albums,
[serverApi.ResourceType.Artist]: QueryFor.Artists,
[serverApi.ResourceType.Tag]: QueryFor.Tags,
[serverApi.ResourceType.Track]: QueryFor.Tracks,
};
const simplified = simplify(query || null, queryForMapping[type]);
if (simplified === null && query != undefined) {
// Invalid query, return no results.
if (responseType === serverApi.QueryResponseType.Count) {
return (async () => { return {
tracks: 0,
artists: 0,
tags: 0,
albums: 0,
}; })();
} else {
return (async () => { return {
tracks: [],
artists: [],
tags: [],
albums: [],
}; })();
}
}
var q: serverApi.QueryRequest = { var q: serverApi.QueryRequest = {
query: simplified ? toApiQuery(simplified, queryForMapping[type]) : {}, query: query ? toApiQuery(query) : {},
offsetsLimits: { offsetsLimits: {
artistOffset: (type == serverApi.ResourceType.Artist) ? (offset || 0) : undefined, artistOffset: (types.includes(serverApi.ResourceType.Artist)) ? (offset || 0) : undefined,
artistLimit: (type == serverApi.ResourceType.Artist) ? (limit || -1) : undefined, artistLimit: (types.includes(serverApi.ResourceType.Artist)) ? (limit || -1) : undefined,
albumOffset: (type == serverApi.ResourceType.Album) ? (offset || 0) : undefined, albumOffset: (types.includes(serverApi.ResourceType.Album)) ? (offset || 0) : undefined,
albumLimit: (type == serverApi.ResourceType.Album) ? (limit || -1) : undefined, albumLimit: (types.includes(serverApi.ResourceType.Album)) ? (limit || -1) : undefined,
trackOffset: (type == serverApi.ResourceType.Track) ? (offset || 0) : undefined, trackOffset: (types.includes(serverApi.ResourceType.Track)) ? (offset || 0) : undefined,
trackLimit: (type == serverApi.ResourceType.Track) ? (limit || -1) : undefined, trackLimit: (types.includes(serverApi.ResourceType.Track)) ? (limit || -1) : undefined,
tagOffset: (type == serverApi.ResourceType.Tag) ? (offset || 0) : undefined, tagOffset: (types.includes(serverApi.ResourceType.Tag)) ? (offset || 0) : undefined,
tagLimit: (type == serverApi.ResourceType.Tag) ? (limit || -1) : undefined, tagLimit: (types.includes(serverApi.ResourceType.Tag)) ? (limit || -1) : undefined,
}, },
ordering: { ordering: {
orderBy: { orderBy: {
@ -77,7 +50,7 @@ export async function queryArtists(
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseArtistDetails[] | number[] | number> { ): Promise<serverApi.QueryResponseArtistDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Artist, query, offset, limit, responseType); let r = await queryItems([serverApi.ResourceType.Artist], query, offset, limit, responseType);
return r.artists; return r.artists;
} }
@ -87,7 +60,7 @@ export async function queryAlbums(
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseAlbumDetails[] | number[] | number> { ): Promise<serverApi.QueryResponseAlbumDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Album, query, offset, limit, responseType); let r = await queryItems([serverApi.ResourceType.Album], query, offset, limit, responseType);
return r.albums; return r.albums;
} }
@ -97,7 +70,7 @@ export async function queryTracks(
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseTrackDetails[] | number[] | number> { ): Promise<serverApi.QueryResponseTrackDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Track, query, offset, limit, responseType); let r = await queryItems([serverApi.ResourceType.Track], query, offset, limit, responseType);
return r.tracks; return r.tracks;
} }
@ -107,6 +80,6 @@ export async function queryTags(
limit: number | undefined, limit: number | undefined,
responseType: serverApi.QueryResponseType, responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseTagDetails[] | number[] | number> { ): Promise<serverApi.QueryResponseTagDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Tag, query, offset, limit, responseType); let r = await queryItems([serverApi.ResourceType.Tag], query, offset, limit, responseType);
return r.tags; return r.tags;
} }

@ -17,7 +17,7 @@ export async function createTag(details: serverApi.PostTagRequest) {
export async function modifyTag(id: number, details: serverApi.PatchTagRequest) { export async function modifyTag(id: number, details: serverApi.PatchTagRequest) {
const requestOpts = { const requestOpts = {
method: 'PATCH', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details), body: JSON.stringify(details),
}; };

@ -40,10 +40,8 @@ export default class SpotifyClientCreds extends Integration {
`/integrations/${this.integrationId}/v1/search?q=queens&type=artist`); `/integrations/${this.integrationId}/v1/search?q=queens&type=artist`);
if (!response.ok) { if (!response.ok) {
throw new Error("Spotify Client Credentials test failed: " + JSON.stringify(response)); throw new Error("Spttify Client Credentials test failed: " + JSON.stringify(response));
} }
console.log("Spotify test response:", await response.json())
} }
async searchTrack(query: string, limit: number): Promise<IntegrationTrack[]> { async searchTrack(query: string, limit: number): Promise<IntegrationTrack[]> {

@ -6,9 +6,6 @@ import { runInNewContext } from 'vm';
import { TextRotateVertical } from '@material-ui/icons'; import { TextRotateVertical } from '@material-ui/icons';
import AlbumWindow from '../../../components/windows/album/AlbumWindow'; import AlbumWindow from '../../../components/windows/album/AlbumWindow';
import { isUndefined } from 'util'; import { isUndefined } from 'util';
import { keys } from '@material-ui/core/styles/createBreakpoints';
import stringifyList from '../../stringifyList';
import { convertCompilerOptionsFromJson } from 'typescript';
let _ = require('lodash'); let _ = require('lodash');
enum SearchType { enum SearchType {
@ -68,38 +65,20 @@ export function extractInitialData(text: string): any | undefined {
// Return either one that worked. // Return either one that worked.
let result = json1 || json2; let result = json1 || json2;
//console.log("initial data:", result); console.log("initial data:", result);
return result; return result;
} }
// Helper function to recursively find key-value pairs in an Object.
function findRecursive (obj : Object | any[],
match_fn : (keys: any[], keys_str: string, value: any) => boolean,
find_inside_matches: boolean,
prev_keys : any[] = []) : any[] {
var retval : any[] = [];
for (const [key, value] of Object.entries(obj)) {
var keys : any[] = prev_keys.concat([key]);
let keys_str : string = keys.map((k:any) => String(k)).join('.');
if (match_fn (keys, keys_str, value)) {
retval.push(value);
if (!find_inside_matches) {
continue;
}
}
if (typeof value === 'object' && value !== null) {
retval = retval.concat(findRecursive(value, match_fn, find_inside_matches, keys));
}
}
return retval;
}
export function parseItems(initialData: any): { export function parseItems(initialData: any): {
tracks: IntegrationTrack[], tracks: IntegrationTrack[],
albums: IntegrationAlbum[], albums: IntegrationAlbum[],
artists: IntegrationArtist[], artists: IntegrationArtist[],
} { } {
try { try {
var musicResponsiveListItemRenderers: { type: 'track' | 'album' | 'artist', content: any }[] = [];
console.log('initialData', initialData)
let retval: any = { let retval: any = {
tracks: [], tracks: [],
albums: [], albums: [],
@ -182,29 +161,25 @@ export function parseItems(initialData: any): {
return artist; return artist;
} }
// Gather all the items. // Scrape for songs, artists and albums.
var musicResponsiveListItemRenderers = _.get(initialData, 'contents.sectionListRenderer.contents', []).forEach((contents: any) => {
findRecursive(initialData, (keys: any[], keys_str : string, val: any) => { _.get(contents, 'musicShelfRenderer.contents', []).forEach((_contents: any) => {
return keys_str.match(/.*musicResponsiveListItemRenderer$/g) !== null; let runs = _.get(_contents, 'musicResponsiveListItemRenderer.flexColumns').map((column: any) => {
}, false);
musicResponsiveListItemRenderers.forEach((renderer: any) => {
let runs = _.get(renderer, 'flexColumns').map((column: any) => {
return _.get(column, 'musicResponsiveListItemFlexColumnRenderer.text.runs'); return _.get(column, 'musicResponsiveListItemFlexColumnRenderer.text.runs');
}).flat(); }).flat();
switch (_.get(renderer, 'flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text', '')) { switch (_.get(_contents, 'musicResponsiveListItemRenderer.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text', '')) {
case "Song": { case "Song": {
retval.tracks.push(parseTrack(renderer, runs)); retval.tracks.push(parseTrack(_.get(_contents, 'musicResponsiveListItemRenderer'), runs));
break; break;
} }
case "Artist": { case "Artist": {
retval.artists.push(parseArtist(renderer, runs)); retval.artists.push(parseArtist(_.get(_contents, 'musicResponsiveListItemRenderer'), runs));
break; break;
} }
case "Album": case "Album":
case "Single": { case "Single": {
retval.albums.push(parseAlbum(renderer, runs)); retval.albums.push(parseAlbum(_.get(_contents, 'musicResponsiveListItemRenderer'), runs));
break; break;
} }
default: { default: {
@ -212,6 +187,7 @@ export function parseItems(initialData: any): {
} }
} }
}) })
});
return retval; return retval;
} catch (e) { } catch (e) {

@ -1,29 +1,23 @@
import * as serverApi from '../../api/api'; import * as serverApi from '../../api/api';
export enum QueryFor {
Artists = "artists",
Albums = "albums",
Tags = "tags",
Tracks = "tracks",
}
export enum QueryLeafBy { export enum QueryLeafBy {
ArtistName = "artistName", ArtistName = 0,
ArtistId = "artistId", ArtistId,
AlbumName = "albumName", AlbumName,
AlbumId = "albumId", AlbumId,
TagInfo = "tagInfo", TagInfo,
TagId = "tagId", TagId,
TrackName = "trackName", TrackName,
TrackId = "trackId", TrackId,
StoreLinks = "storeLinks", TrackStoreLinks,
NotApplicable = "n/a", // Some query nodes don't need an operand. ArtistStoreLinks,
AlbumStoreLinks,
} }
export enum QueryLeafOp { export enum QueryLeafOp {
Equals = "equals", Equals = 0,
Like = "like", Like,
Placeholder = "placeholder", // Special op which indicates that this leaf is not filled in yet. Placeholder, // Special op which indicates that this leaf is not filled in yet.
} }
export interface TagQueryInfo { export interface TagQueryInfo {
@ -46,9 +40,9 @@ export function isLeafElem(q: QueryElem): q is QueryLeafElem {
} }
export enum QueryNodeOp { export enum QueryNodeOp {
And = "AND", And = 0,
Or = "OR", Or,
Not = "NOT", Not,
} }
export interface QueryNodeElem { export interface QueryNodeElem {
@ -83,44 +77,6 @@ export function queryNot(arg: QueryElem) {
export type QueryElem = QueryLeafElem | QueryNodeElem; export type QueryElem = QueryLeafElem | QueryNodeElem;
function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null):
serverApi.QueryElemProperty | null {
return {
[QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName,
[QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName,
[QueryLeafBy.AlbumName]: serverApi.QueryElemProperty.albumName,
[QueryLeafBy.AlbumId]: serverApi.QueryElemProperty.albumId,
[QueryLeafBy.ArtistId]: serverApi.QueryElemProperty.artistId,
[QueryLeafBy.TagId]: serverApi.QueryElemProperty.tagId,
[QueryLeafBy.TrackId]: serverApi.QueryElemProperty.trackId,
[QueryLeafBy.StoreLinks]:
(queryFor == QueryFor.Albums) ? serverApi.QueryElemProperty.albumStoreLinks :
(queryFor == QueryFor.Artists) ? serverApi.QueryElemProperty.artistStoreLinks :
(queryFor == QueryFor.Tracks) ? serverApi.QueryElemProperty.trackStoreLinks :
null,
[QueryLeafBy.TagInfo]: null,
[QueryLeafBy.NotApplicable]: null,
}[l];
}
function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null):
serverApi.QueryLeafOp | null {
return {
[QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
[QueryLeafOp.Like]: serverApi.QueryLeafOp.Like,
[QueryLeafOp.Placeholder]: null,
}[l];
}
function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null):
serverApi.QueryNodeOp | null {
return {
[QueryNodeOp.And]: serverApi.QueryNodeOp.And,
[QueryNodeOp.Or]: serverApi.QueryNodeOp.Or,
[QueryNodeOp.Not]: serverApi.QueryNodeOp.Not,
}[l];
}
// Take a query and add placeholders. The placeholders are empty // Take a query and add placeholders. The placeholders are empty
// leaves. They should be placed so that all possible node combinations // leaves. They should be placed so that all possible node combinations
// from the existing nodes could have an added combinational leaf. // from the existing nodes could have an added combinational leaf.
@ -131,9 +87,9 @@ export function addPlaceholders(
inNode: null | QueryNodeOp, inNode: null | QueryNodeOp,
): QueryElem { ): QueryElem {
const makePlaceholder: () => QueryElem = () => { const makePlaceholder = () => {
return { return {
a: QueryLeafBy.NotApplicable, a: 0,
leafOp: QueryLeafOp.Placeholder, leafOp: QueryLeafOp.Placeholder,
b: "" b: ""
} }
@ -147,19 +103,7 @@ export function addPlaceholders(
if (q == null) { if (q == null) {
return makePlaceholder(); return makePlaceholder();
} else if (isNodeElem(q) && q.nodeOp == QueryNodeOp.Not && } else if (isNodeElem(q)) {
isLeafElem(q.operands[0]) &&
inNode !== null) {
// Not only modifies its sub-node, so this is handled like a leaf.
return { operands: [q, makePlaceholder()], nodeOp: otherOp[inNode] };
} else if (isNodeElem(q) && q.nodeOp == QueryNodeOp.Not &&
isLeafElem(q.operands[0]) &&
inNode === null) {
// Not only modifies its sub-node, so this is handled like a leaf.
return { operands: [q, makePlaceholder()], nodeOp: QueryNodeOp.And };
} else if (isNodeElem(q) && q.nodeOp != QueryNodeOp.Not) {
// Combinational operators.
var operands = q.operands.map((op: any, idx: number) => { var operands = q.operands.map((op: any, idx: number) => {
return addPlaceholders(op, q.nodeOp); return addPlaceholders(op, q.nodeOp);
}); });
@ -207,7 +151,7 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
if (newOperands.length === 0) { if (newOperands.length === 0) {
return null; return null;
} }
if ((newOperands.length === 1 && [QueryNodeOp.Or, QueryNodeOp.And].includes(q.nodeOp))) { if (newOperands.length === 1) {
return newOperands[0]; return newOperands[0];
} }
return { operands: newOperands, nodeOp: q.nodeOp }; return { operands: newOperands, nodeOp: q.nodeOp };
@ -218,31 +162,44 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
return q; return q;
} }
// Note: null means an invalidating node. It should make the whole query invalid, so it should export function simplify(q: QueryElem | null): QueryElem | null {
// be propagated to the root.
export function simplify(q: QueryElem | null, queryFor: QueryFor | null): QueryElem | null {
if (q && isNodeElem(q)) { if (q && isNodeElem(q)) {
var newOperands: (QueryElem | null)[] = q.operands.map((op: QueryElem) => simplify(op, queryFor)); var newOperands: QueryElem[] = [];
if (newOperands.filter((op: QueryElem | null) => op === null).length > 0) { q.operands.forEach((o: QueryElem) => {
console.log("nullifying op:", q, queryFor) const s = simplify(o);
return null; if (s !== null) { newOperands.push(s); }
})
if (newOperands.length === 0) { return null; }
if (newOperands.length === 1) { return newOperands[0]; }
return { operands: newOperands, nodeOp: q.nodeOp };
} }
return { operands: newOperands as QueryElem[], nodeOp: q.nodeOp }; return q;
} }
// Nullify any queries that contain operations which are invalid export function toApiQuery(q: QueryElem) : serverApi.Query {
// for the current queried object type. const propsMapping: any = {
if (q && isLeafElem(q) && queryFor !== null && [QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName,
(mapToServerLeafOp(q.leafOp, queryFor) === null || [QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName,
mapToServerProperty(q.a, queryFor) === null)) { [QueryLeafBy.AlbumName]: serverApi.QueryElemProperty.albumName,
return null; [QueryLeafBy.AlbumId]: serverApi.QueryElemProperty.albumId,
[QueryLeafBy.ArtistId]: serverApi.QueryElemProperty.artistId,
[QueryLeafBy.TagId]: serverApi.QueryElemProperty.tagId,
[QueryLeafBy.TrackId]: serverApi.QueryElemProperty.trackId,
[QueryLeafBy.TrackStoreLinks]: serverApi.QueryElemProperty.trackStoreLinks,
[QueryLeafBy.ArtistStoreLinks]: serverApi.QueryElemProperty.artistStoreLinks,
[QueryLeafBy.AlbumStoreLinks]: serverApi.QueryElemProperty.albumStoreLinks,
} }
const leafOpsMapping: any = {
return q; [QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
[QueryLeafOp.Like]: serverApi.QueryLeafOp.Like,
}
const nodeOpsMapping: any = {
[QueryNodeOp.And]: serverApi.QueryNodeOp.And,
[QueryNodeOp.Or]: serverApi.QueryNodeOp.Or,
[QueryNodeOp.Not]: serverApi.QueryNodeOp.Not,
} }
export function toApiQuery(q: QueryElem, queryFor: QueryFor | null): serverApi.Query {
if(isLeafElem(q) && isTagQueryInfo(q.b)) { if(isLeafElem(q) && isTagQueryInfo(q.b)) {
// Special case for tag queries by ID // Special case for tag queries by ID
const r: serverApi.QueryElem = { const r: serverApi.QueryElem = {
@ -252,29 +209,17 @@ export function toApiQuery(q: QueryElem, queryFor: QueryFor | null): serverApi.Q
} }
return r; return r;
} else if(isLeafElem(q)) { } else if(isLeafElem(q)) {
// If the property to operate on is non-existent
// (e.g. store links for a tag query), throw.
let a = mapToServerProperty(q.a, queryFor);
let op = mapToServerLeafOp(q.leafOp, queryFor);
if (a === null || op === null) {
console.log("Error details:", q, queryFor);
throw 'Found a null leaf in query tree. Was it simplified first?';
}
// "Regular" queries // "Regular" queries
const r: serverApi.QueryElem = { const r: serverApi.QueryElem = {
prop: a, prop: propsMapping[q.a],
propOperator: op, propOperator: leafOpsMapping[q.leafOp],
propOperand: q.b, propOperand: q.b,
} }
return r; return r;
} else if(isNodeElem(q)) { } else if(isNodeElem(q)) {
let op = mapToServerNodeOp(q.nodeOp, queryFor);
if (op === null) {
throw 'Found a null node in query tree. Was it simplified first?'
}
const r = { const r = {
children: q.operands.map((op: any) => toApiQuery(op, queryFor)), children: q.operands.map((op: any) => toApiQuery(op)),
childrenOperator: op childrenOperator: nodeOpsMapping[q.nodeOp]
} }
return r; return r;
} }

@ -1,5 +1,5 @@
import Knex from "knex"; import Knex from "knex";
import { Album, AlbumRefs, Id, Name, AlbumDetails, StoreLinks, Tag, TagParentId, Track, Artist } from "../../client/src/api/api"; import { Album, AlbumRefs, Id, Name, AlbumDetails, StoreLinks, Tag, TagRefs, Track, Artist } from "../../client/src/api/api";
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson"; import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types"; import { DBError, DBErrorKind } from "../endpoints/types";
@ -14,7 +14,7 @@ export async function getAlbum(id: number, userId: number, knex: Knex):
// Start transfers for tracks, tags and artists. // Start transfers for tracks, tags and artists.
// Also request the album itself. // Also request the album itself.
const tagsPromise: Promise<(Tag & Id & Name & TagParentId)[]> = const tagsPromise: Promise<(Tag & Id & Name & TagRefs)[]> =
knex.select('tagId') knex.select('tagId')
.from('albums_tags') .from('albums_tags')
.where({ 'albumId': id }) .where({ 'albumId': id })
@ -23,8 +23,8 @@ export async function getAlbum(id: number, userId: number, knex: Knex):
knex.select(['id', 'name', 'parentId']) knex.select(['id', 'name', 'parentId'])
.from('tags') .from('tags')
.whereIn('id', ids) .whereIn('id', ids)
.then((tags: (Id & Name & TagParentId)[]) => .then((tags: (Id & Name & TagRefs)[]) =>
tags.map((tag : (Id & Name & TagParentId)) => tags.map((tag : (Id & Name & TagRefs)) =>
{ return {...tag, mbApi_typename: "tag"}} { return {...tag, mbApi_typename: "tag"}}
)) ))
); );
@ -174,9 +174,9 @@ export async function modifyAlbum(userId: number, albumId: number, album: Album,
const artistIdsPromise: Promise<number[] | undefined> = const artistIdsPromise: Promise<number[] | undefined> =
album.artistIds ? album.artistIds ?
trx.select('artistId') trx.select('artistId')
.from('artists') .from('artists_albums')
.whereIn('id', album.artistIds) .whereIn('artistId', album.artistIds)
.then((as: any) => as.map((a: any) => a['id'])) .then((as: any) => as.map((a: any) => a['artistId']))
: (async () => undefined)(); : (async () => undefined)();
// Start retrieving tracks if we are modifying those. // Start retrieving tracks if we are modifying those.
@ -192,18 +192,18 @@ export async function modifyAlbum(userId: number, albumId: number, album: Album,
const tagIdsPromise = const tagIdsPromise =
album.tagIds ? album.tagIds ?
trx.select('id') trx.select('id')
.from('tags') .from('albums_tags')
.whereIn('id', album.tagIds) .whereIn('tagId', album.tagIds)
.then((ts: any) => ts.map((t: any) => t['id'])) : .then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => undefined)(); (async () => undefined)();
// Wait for the requests to finish. // Wait for the requests to finish.
var [oldAlbum, artists, tags, tracks] = await Promise.all([albumIdPromise, artistIdsPromise, tagIdsPromise, trackIdsPromise]);; var [oldAlbum, artists, tags, tracks] = await Promise.all([albumIdPromise, artistIdsPromise, tagIdsPromise, trackIdsPromise]);;
// Check that we found all objects we need. // Check that we found all objects we need.
if ((album.artistIds && (!artists || !_.isEqual(artists.sort(), (album.artistIds || []).sort()))) || if ((!artists || !_.isEqual(artists.sort(), (album.artistIds || []).sort())) ||
(album.tagIds && (!tags || !_.isEqual(tags.sort(), (album.tagIds || []).sort()))) || (!tags || !_.isEqual(tags.sort(), (album.tagIds || []).sort())) ||
(album.trackIds && (!tracks || !_.isEqual(tracks.sort(), (album.trackIds || []).sort()))) || (!tracks || !_.isEqual(tracks.sort(), (album.trackIds || []).sort())) ||
!oldAlbum) { !oldAlbum) {
throw makeNotFoundError(); throw makeNotFoundError();
} }

@ -1,5 +1,5 @@
import Knex from "knex"; import Knex from "knex";
import { Artist, ArtistDetails, Tag, Track, TagParentId, Id, Name, StoreLinks, Album, ArtistRefs } from "../../client/src/api/api"; import { Artist, ArtistDetails, Tag, Track, TagRefs, Id, Name, StoreLinks, Album, ArtistRefs } from "../../client/src/api/api";
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson"; import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types"; import { DBError, DBErrorKind } from "../endpoints/types";
@ -11,7 +11,7 @@ export async function getArtist(id: number, userId: number, knex: Knex):
Promise<(Artist & ArtistDetails & Name & StoreLinks)> { Promise<(Artist & ArtistDetails & Name & StoreLinks)> {
// Start transfers for tags and albums. // Start transfers for tags and albums.
// Also request the artist itself. // Also request the artist itself.
const tagsPromise: Promise<(Tag & Name & Id & TagParentId)[]> = const tagsPromise: Promise<(Tag & Name & Id & TagRefs)[]> =
knex.select('tagId') knex.select('tagId')
.from('artists_tags') .from('artists_tags')
.where({ 'artistId': id }) .where({ 'artistId': id })
@ -20,8 +20,8 @@ export async function getArtist(id: number, userId: number, knex: Knex):
knex.select(['id', 'name', 'parentId']) knex.select(['id', 'name', 'parentId'])
.from('tags') .from('tags')
.whereIn('id', ids) .whereIn('id', ids)
.then((tags: (Id & Name & TagParentId)[]) => .then((tags: (Id & Name & TagRefs)[]) =>
tags.map((tag : (Id & Name & TagParentId)) => tags.map((tag : (Id & Name & TagRefs)) =>
{ return {...tag, mbApi_typename: "tag"}} { return {...tag, mbApi_typename: "tag"}}
)) ))
); );

@ -1,5 +1,5 @@
import Knex from "knex"; import Knex from "knex";
import { Track, TrackRefs, Id, Name, StoreLinks, Album, AlbumRefs, Artist, ArtistRefs, Tag, TagParentId, isTrackRefs, isAlbumRefs, DBImportResponse, IDMappings } from "../../client/src/api/api"; import { Track, TrackRefs, Id, Name, StoreLinks, Album, AlbumRefs, Artist, ArtistRefs, Tag, TagRefs, isTrackRefs, isAlbumRefs, DBImportResponse, IDMappings } from "../../client/src/api/api";
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson"; import asJson from "../lib/asJson";
import { createArtist } from "./Artist"; import { createArtist } from "./Artist";
@ -60,7 +60,7 @@ export async function exportDB(userId: number, knex: Knex): Promise<api.DBDataFo
} }
})); }));
let tagsPromise: Promise<(Tag & Id & Name & TagParentId)[]> = let tagsPromise: Promise<(Tag & Id & Name & TagRefs)[]> =
knex.select('name', 'parentId', 'id') knex.select('name', 'parentId', 'id')
.from('tags') .from('tags')
.where({ 'user': userId }) .where({ 'user': userId })

@ -1,7 +1,7 @@
import Knex from "knex"; import Knex from "knex";
import { isConstructorDeclaration } from "typescript"; import { isConstructorDeclaration } from "typescript";
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import { Tag, TagParentId, TagDetails, Id, Name } from "../../client/src/api/api"; import { Tag, TagRefs, TagDetails, Id, Name } from "../../client/src/api/api";
import { DBError, DBErrorKind } from "../endpoints/types"; import { DBError, DBErrorKind } from "../endpoints/types";
import { makeNotFoundError } from "./common"; import { makeNotFoundError } from "./common";
@ -35,7 +35,7 @@ export async function getTagChildrenRecursive(id: number,
} }
// Returns the id of the created tag. // Returns the id of the created tag.
export async function createTag(userId: number, tag: (Tag & Name & TagParentId), knex: Knex): Promise<number> { export async function createTag(userId: number, tag: (Tag & Name & TagRefs), knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => { return await knex.transaction(async (trx) => {
// If applicable, retrieve the parent tag. // If applicable, retrieve the parent tag.
const maybeMatches: any[] | null = const maybeMatches: any[] | null =
@ -124,13 +124,13 @@ export async function deleteTag(userId: number, tagId: number, knex: Knex) {
} }
export async function getTag(userId: number, tagId: number, knex: Knex): Promise<(Tag & TagDetails & Name)> { export async function getTag(userId: number, tagId: number, knex: Knex): Promise<(Tag & TagDetails & Name)> {
const tagPromise: Promise<(Tag & Id & Name & TagParentId) | null> = const tagPromise: Promise<(Tag & Id & Name & TagRefs) | null> =
knex.select(['id', 'name', 'parentId']) knex.select(['id', 'name', 'parentId'])
.from('tags') .from('tags')
.where({ 'user': userId }) .where({ 'user': userId })
.where({ 'id': tagId }) .where({ 'id': tagId })
.then((r: (Id & Name & TagParentId)[] | undefined) => r ? r[0] : null) .then((r: (Id & Name & TagRefs)[] | undefined) => r ? r[0] : null)
.then((r: (Id & Name & TagParentId) | null) => { .then((r: (Id & Name & TagRefs) | null) => {
if (r) { if (r) {
return { ...r, mbApi_typename: 'tag'}; return { ...r, mbApi_typename: 'tag'};
} }
@ -139,7 +139,7 @@ export async function getTag(userId: number, tagId: number, knex: Knex): Promise
const parentPromise: Promise<(Tag & Id & Name & TagDetails) | null> = const parentPromise: Promise<(Tag & Id & Name & TagDetails) | null> =
tagPromise tagPromise
.then((r: (Tag & Id & Name & TagParentId) | null) => .then((r: (Tag & Id & Name & TagRefs) | null) =>
(r && r.parentId) ? ( (r && r.parentId) ? (
getTag(userId, r.parentId, knex) getTag(userId, r.parentId, knex)
.then((rr: (Tag & Name & TagDetails) | null) => .then((rr: (Tag & Name & TagDetails) | null) =>

@ -1,5 +1,5 @@
import Knex from "knex"; import Knex from "knex";
import { Track, TrackRefs, TrackDetails, Id, Name, StoreLinks, Tag, Album, Artist, TagParentId } from "../../client/src/api/api"; import { Track, TrackRefs, TrackDetails, Id, Name, StoreLinks, Tag, Album, Artist, TagRefs } from "../../client/src/api/api";
import * as api from '../../client/src/api/api'; import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson"; import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types"; import { DBError, DBErrorKind } from "../endpoints/types";
@ -11,7 +11,7 @@ export async function getTrack(id: number, userId: number, knex: Knex):
Promise<Track & Name & StoreLinks & TrackDetails> { Promise<Track & Name & StoreLinks & TrackDetails> {
// Start transfers for tracks, tags and artists. // Start transfers for tracks, tags and artists.
// Also request the track itself. // Also request the track itself.
const tagsPromise: Promise<(Tag & Id & Name & TagParentId)[]> = const tagsPromise: Promise<(Tag & Id & Name & TagRefs)[]> =
knex.select('tagId') knex.select('tagId')
.from('tracks_tags') .from('tracks_tags')
.where({ 'trackId': id }) .where({ 'trackId': id })
@ -20,8 +20,8 @@ export async function getTrack(id: number, userId: number, knex: Knex):
knex.select(['id', 'name', 'parentId']) knex.select(['id', 'name', 'parentId'])
.from('tags') .from('tags')
.whereIn('id', ids) .whereIn('id', ids)
.then((tags: (Id & Name & TagParentId)[]) => .then((tags: (Id & Name & TagRefs)[]) =>
tags.map((tag : (Id & Name & TagParentId)) => tags.map((tag : (Id & Name & TagRefs)) =>
{ return {...tag, mbApi_typename: "tag"}} { return {...tag, mbApi_typename: "tag"}}
)) ))
); );
@ -180,19 +180,19 @@ export async function modifyTrack(userId: number, trackId: number, track: Track,
// Start retrieving artists if we are modifying those. // Start retrieving artists if we are modifying those.
const artistIdsPromise: Promise<number[] | undefined> = const artistIdsPromise: Promise<number[] | undefined> =
track.artistIds ? track.artistIds ?
trx.select('id') trx.select('artistId')
.from('artists') .from('tracks_artists')
.whereIn('id', track.artistIds) .whereIn('artistId', track.artistIds)
.then((as: any) => as.map((a: any) => a['id'])) .then((as: any) => as.map((a: any) => a['artistId']))
: (async () => undefined)(); : (async () => undefined)();
// Start retrieving tags if we are modifying those. // Start retrieving tags if we are modifying those.
const tagIdsPromise = const tagIdsPromise =
track.tagIds ? track.tagIds ?
trx.select('id') trx.select('id')
.from('tags') .from('tracks_tags')
.whereIn('id', track.tagIds) .whereIn('tagId', track.tagIds)
.then((ts: any) => ts.map((t: any) => t['id'])) : .then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => undefined)(); (async () => undefined)();
// Start retrieving album if we are modifying that. // Start retrieving album if we are modifying that.
@ -205,15 +205,15 @@ export async function modifyTrack(userId: number, trackId: number, track: Track,
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) : .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) :
(async () => undefined)(); (async () => undefined)();
let blablums = await trx.select('id').from('albums');
// Wait for the requests to finish. // Wait for the requests to finish.
var [oldTrack, artists, tags, album] = await Promise.all([trackIdPromise, artistIdsPromise, tagIdsPromise, albumIdPromise]);; var [oldTrack, artists, tags, album] = await Promise.all([trackIdPromise, artistIdsPromise, tagIdsPromise, albumIdPromise]);;
console.log("Patch track: ", oldTrack, artists, tags, album);
// Check that we found all objects we need. // Check that we found all objects we need.
if ((track.artistIds && (!artists || !_.isEqual(artists.sort(), (track.artistIds || []).sort()))) || if ((!artists || !_.isEqual(artists.sort(), (track.artistIds || []).sort())) ||
(track.tagIds && (!tags || !_.isEqual(tags.sort(), (track.tagIds || []).sort()))) || (!tags || !_.isEqual(tags.sort(), (track.tagIds || []).sort())) ||
(track.albumId && !album) || (!album && track.albumId) ||
!oldTrack) { !oldTrack) {
throw makeNotFoundError(); throw makeNotFoundError();
} }

Loading…
Cancel
Save