8 コミット f0d8ac0ebc ... 6cf362a901

作者 SHA1 メッセージ 日付
  Kristian Vos 6cf362a901 feat: testing/example implementation for the entity filter group view 1 ヶ月 前
  Kristian Vos 455f4af5cd fix: small fix/change in formatTime util function 1 ヶ月 前
  Kristian Vos 27b9111d43 feat: open EditArtist modal on artist admin page if artistId query param is set 1 ヶ月 前
  Kristian Vos 90e52e897c refactor: added some more columns to artists admin AdvancedTable 1 ヶ月 前
  Kristian Vos b516c6416c refactor: modified/fixed deleting artists on admin page 1 ヶ月 前
  Kristian Vos b48d604598 refactor: moved measureStart/measureFinish util functions, added randomNumber util function 1 ヶ月 前
  Kristian Vos 4ae10384eb refactor: improve the getEmojiFlagForCountryCode util function for non-country codes 1 ヶ月 前
  Kristian Vos c925563194 refactor: include the jsonata library 1 ヶ月 前

+ 400 - 0
frontend/src/components/EntityFilterGroupView.vue

@@ -0,0 +1,400 @@
+<script setup lang="ts">
+import { storeToRefs } from "pinia";
+import { useEntityFilterGroupViewStore } from "@/stores/entityFilterGroupView";
+import { Source, SourceItem } from "@/types/artist";
+
+defineSlots<{
+	source(props: { source: Source }): any;
+	sourceItem(props: { sourceItem: SourceItem }): any;
+}>();
+
+const props = defineProps<{
+	viewUuid: string;
+}>();
+
+const entityFilterGroupViewStore = useEntityFilterGroupViewStore({
+	viewUuid: props.viewUuid
+})();
+const {
+	sourceIds,
+	sourceMap,
+	sourceItemIds,
+	sourceItemMap,
+	sectionIds,
+	sectionMap,
+	sectionEntityMap,
+	entityMap,
+	filterMap,
+	filterIds,
+	activeFilterId,
+	selectedEntityIds,
+	filteredEntityIds,
+	filterMode,
+	sectionCollapseMap
+} = storeToRefs(entityFilterGroupViewStore);
+const {
+	ungroupEntity,
+	groupEntities,
+	toggleSelectEntity,
+	selectAll,
+	deselectAll,
+	applyFilter,
+	moveEntities,
+	startFilterMode,
+	stopFilterMode,
+	selectNextFilter,
+	toggleCollapseSection
+} = entityFilterGroupViewStore;
+</script>
+
+<template>
+	<div class="flex flex-column entity-filter-group-view">
+		<div class="flex flex-column sources-container">
+			<p class="container-title">Sources:</p>
+			<div class="flex flex-column sources">
+				<div
+					class="source"
+					v-for="sourceId in sourceIds"
+					:key="sourceId"
+				>
+					<slot name="source" :source="sourceMap[sourceId]">
+						<p>Missing slot content for source - {{ sourceId }}</p>
+					</slot>
+				</div>
+			</div>
+		</div>
+		<hr v-if="false" />
+		<div class="flex flex-column source-items-container" v-if="false">
+			<p class="container-title">Source items:</p>
+			<div class="flex flex-column source-items">
+				<div
+					class="source-item"
+					v-for="sourceItemId in sourceItemIds"
+					:key="sourceItemId"
+				>
+					<slot
+						name="sourceItem"
+						:source-item="sourceItemMap[sourceItemId]"
+					>
+						<p>
+							Missing slot content for source item -
+							{{ sourceItemId }}
+						</p>
+					</slot>
+				</div>
+			</div>
+		</div>
+		<hr />
+		<div class="flex flex-column filters-container">
+			<p class="container-title">Filters:</p>
+			<button
+				v-if="!filterMode"
+				class="button is-warning"
+				@click="startFilterMode()"
+			>
+				Start filter mode
+			</button>
+			<button
+				v-if="filterMode"
+				class="button is-success"
+				@click="selectNextFilter()"
+			>
+				Select next filter
+			</button>
+			<button
+				v-if="filterMode"
+				class="button is-warning"
+				@click="stopFilterMode()"
+			>
+				Stop filter mode
+			</button>
+			<div class="flex flex-column filters">
+				<div
+					class="flex flex-column filter"
+					v-for="filterId in filterIds"
+					:key="filterId"
+				>
+					<p>
+						{{ filterMap[filterId].title }}
+						<span v-if="activeFilterId === filterId">ACTIVE</span>
+					</p>
+					<button
+						class="button is-danger"
+						@click="applyFilter(filterId)"
+					>
+						Filter
+					</button>
+				</div>
+				<div class="flex flex-column filter">
+					<p>No filter <span v-if="!activeFilterId">ACTIVE</span></p>
+					<button class="button is-danger" @click="applyFilter(null)">
+						No filter
+					</button>
+				</div>
+			</div>
+		</div>
+		<hr />
+		<button
+			v-if="selectedEntityIds.length >= 2"
+			@click="groupEntities(selectedEntityIds)"
+			class="button is-warning"
+		>
+			Group selected entities
+		</button>
+		<hr />
+		<div class="flex flex-row move-buttons">
+			<button
+				class="button is-primary"
+				v-for="sectionId in sectionIds"
+				:key="sectionId"
+				@click="moveEntities(selectedEntityIds, sectionId)"
+			>
+				Move selected entities to {{ sectionMap[sectionId].name }}
+			</button>
+		</div>
+		<hr />
+		<div class="flex flex-column sections-container">
+			<p class="container-title">Sections:</p>
+			<div class="flex flex-column sections">
+				<div
+					class="flex flex-column section"
+					v-for="sectionId in sectionIds"
+					:key="sectionId"
+				>
+					<p class="flex flex-row section-name">
+						<span
+							class="material-icons"
+							@click="toggleCollapseSection(sectionId)"
+							>{{
+								sectionCollapseMap[sectionId]
+									? "expand_more"
+									: "expand_less"
+							}}</span
+						>
+						<span>
+							{{ sectionMap[sectionId].name }}
+						</span>
+					</p>
+					<div
+						class="flex flex-row buttons"
+						v-if="sectionEntityMap[sectionId].length > 0"
+						v-show="!sectionCollapseMap[sectionId]"
+					>
+						<button
+							class="button is-primary"
+							@click="selectAll(sectionId)"
+						>
+							Select all
+						</button>
+						<button
+							class="button is-primary"
+							@click="deselectAll()"
+						>
+							Deselect all
+						</button>
+					</div>
+					<p
+						v-if="sectionEntityMap[sectionId].length === 0"
+						v-show="!sectionCollapseMap[sectionId]"
+					>
+						No source items
+					</p>
+					<div
+						v-else
+						class="flex flex-column entities"
+						v-show="!sectionCollapseMap[sectionId]"
+					>
+						<template
+							v-for="entityId in sectionEntityMap[sectionId]"
+							:key="entityId"
+						>
+							<template
+								v-if="
+									entityMap[entityId].type === 'single' &&
+									!filteredEntityIds.includes(entityId)
+								"
+							>
+								<div class="entity-single flex flex-row">
+									<input
+										type="checkbox"
+										class="checkbox"
+										:checked="
+											selectedEntityIds.includes(entityId)
+										"
+										@click="toggleSelectEntity(entityId)"
+									/>
+									<div class="entity-source-item">
+										<slot
+											name="sourceItem"
+											:source-item="
+												sourceItemMap[
+													entityMap[entityId]
+														.sourceItemId
+												]
+											"
+										>
+											Missing slot content for entity
+											source item -
+											{{
+												entityMap[entityId].sourceItemId
+											}}
+										</slot>
+									</div>
+								</div>
+							</template>
+							<template
+								v-else-if="
+									entityMap[entityId].type === 'group' &&
+									!filteredEntityIds.includes(entityId)
+								"
+							>
+								<div class="flex flex-column entity-group">
+									<button
+										class="button is-primary"
+										@click="ungroupEntity(entityId)"
+									>
+										Ungroup
+									</button>
+									<div
+										v-for="sourceItemId in entityMap[
+											entityId
+										].sourceItemIds"
+										:key="sourceItemId"
+										class="entity-source-item"
+									>
+										<slot
+											name="sourceItem"
+											:source-item="
+												sourceItemMap[sourceItemId]
+											"
+										>
+											Missing slot content for entity
+											source item - {{ sourceItemId }}
+										</slot>
+									</div>
+								</div>
+							</template>
+						</template>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.flex {
+	display: flex;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.flex-row {
+	flex-direction: row;
+}
+
+.w-full {
+	width: 100%;
+}
+
+.entity-filter-group-view {
+	gap: 16px;
+
+	.move-buttons {
+		gap: 8px;
+
+		.button {
+			flex: 1;
+		}
+	}
+}
+
+.container-title {
+	font-size: 1.5rem;
+}
+
+.sources-container {
+	gap: 8px;
+
+	.sources {
+		gap: 8px;
+
+		.source {
+			border: 1px solid white;
+			padding: 8px;
+		}
+	}
+}
+
+.source-items-container {
+	gap: 8px;
+
+	.source-items {
+		gap: 8px;
+
+		.source-item {
+			border: 1px solid white;
+			padding: 8px;
+		}
+	}
+}
+
+.filters-container {
+	gap: 8px;
+
+	.filters {
+		gap: 8px;
+
+		.filter {
+			border: 1px solid white;
+			padding: 8px;
+		}
+	}
+}
+
+.sections-container {
+	gap: 8px;
+
+	.sections {
+		gap: 8px;
+
+		.section {
+			border: 1px solid white;
+			padding: 8px;
+			gap: 8px;
+
+			.section-name {
+				font-size: 1.25rem;
+				gap: 8px;
+			}
+
+			.buttons {
+				gap: 8px;
+			}
+
+			.entities {
+				gap: 8px;
+
+				.entity-single {
+					gap: 8px;
+					align-items: center;
+
+					.entity-source-item {
+						flex: 1;
+					}
+				}
+
+				.entity-group {
+					gap: 8px;
+
+					padding: 8px;
+					border: 2px dotted var(--light-grey-3);
+					border-radius: @border-radius;
+				}
+			}
+		}
+	}
+}
+</style>

+ 1 - 2
frontend/src/components/TempMusicBrainzRecordingCard.vue

@@ -6,8 +6,7 @@ import { RecordingTemp, ReleaseGroupTemp } from "@/types/artist";
 const props = defineProps({
 	recording: {
 		type: Object as PropType<RecordingTemp>,
-		required: true,
-		default: () => ({})
+		required: true
 	},
 	recordingImage: { type: String, required: true, default: "" },
 	containerClass: { type: String, default: "" },

+ 4 - 1
frontend/src/components/TempYoutubeVideoCard.vue

@@ -6,6 +6,7 @@ const { openModal } = useModalsStore();
 
 defineProps({
 	youtubeVideo: { type: Object, default: () => ({}) },
+	overrideTitle: { type: String, default: null },
 	hideYoutubeInfo: { type: Boolean, default: true },
 	durationClass: { type: String, default: "" }
 });
@@ -24,7 +25,9 @@ defineProps({
 			alt=""
 		/>
 		<div class="text flex flex-column">
-			<p class="title">{{ youtubeVideo.title }}</p>
+			<p class="title">
+				{{ overrideTitle ? overrideTitle : youtubeVideo.title }}
+			</p>
 			<p class="small-title">{{ youtubeVideo.rawData.snippet.title }}</p>
 			<p>
 				{{

+ 245 - 0
frontend/src/components/modals/ImportArtist2/Views/EntityTest.vue

@@ -0,0 +1,245 @@
+<script setup lang="ts">
+import { defineAsyncComponent, onMounted } from "vue";
+import { useEntityFilterGroupViewStore } from "@/stores/entityFilterGroupView";
+import { Filter, Section } from "@/types/artist";
+import utils from "@/utils";
+
+const EntityFilterGroupView = defineAsyncComponent(
+	() => import("@/components/EntityFilterGroupView.vue")
+);
+
+/**
+ * Sources = sources of source items, e.g. each YouTube channel is a source for videos, or a MusicBrainz artist is a source for recordings
+ * Source items = items for sources, e.g. videos or recordings
+ * Sections = sections of source items, e.g. triage, music, non-music
+ * Entities = entities are single and group things that link to source items
+ */
+
+type TestSourceData = {
+	name: string;
+};
+
+type TestSourceItemData = {
+	name: string;
+	number: number;
+};
+
+type TestSource = {
+	id: string;
+	data: TestSourceData;
+};
+
+type TestSourceItem = {
+	id: string;
+	sourceId: string;
+	data: TestSourceItemData;
+};
+
+defineProps({
+	modalUuid: { type: String, required: true }
+});
+
+const viewUuid = utils.guid();
+const entityFilterGroupViewStore = useEntityFilterGroupViewStore({
+	viewUuid
+})();
+// const { data1 } = storeToRefs(entityFilterGroupViewStore);
+const { setFilterData, setInitialData } = entityFilterGroupViewStore;
+
+// const sourceMap = ref<{ [sourceId: string]: TestSource }>({
+// 	source1: {
+// 		id: "source1",
+// 		data: {
+// 			name: "Source 1"
+// 		}
+// 	},
+// 	source2: {
+// 		id: "source2",
+// 		data: {
+// 			name: "Source 2"
+// 		}
+// 	}
+// });
+// const sourceItemMap = ref<{ [sourceItemId: string]: TestSourceItem }>({
+// 	sourceItem1: {
+// 		id: "sourceItem1",
+// 		sourceId: "source1",
+// 		data: {
+// 			name: "Source item 1"
+// 		}
+// 	},
+// 	sourceItem2: {
+// 		id: "sourceItem2",
+// 		sourceId: "source1",
+// 		data: {
+// 			name: "Source item 2"
+// 		}
+// 	},
+// 	sourceItem3: {
+// 		id: "sourceItem3",
+// 		sourceId: "source2",
+// 		data: {
+// 			name: "Source item 3"
+// 		}
+// 	},
+// 	sourceItem4: {
+// 		id: "sourceItem4",
+// 		sourceId: "source2",
+// 		data: {
+// 			name: "Source item 4"
+// 		}
+// 	}
+// });
+const sectionMap: { [sectionId: string]: Section } = {
+	triage: {
+		id: "triage",
+		name: "Triage"
+	},
+	music: {
+		id: "music",
+		name: "Music"
+	},
+	"non-music": {
+		id: "non-music",
+		name: "Non-music"
+	}
+};
+// const initialEntityMap = ref<{ [entityId: string]: Entity }>({
+// 	entity1: {
+// 		type: "single",
+// 		id: "entity1",
+// 		sourceItemId: "sourceItem1"
+// 	},
+// 	entity2: {
+// 		type: "single",
+// 		id: "entity2",
+// 		sourceItemId: "sourceItem2"
+// 	},
+// 	entity3: {
+// 		type: "group",
+// 		id: "entity3",
+// 		sourceItemIds: ["sourceItem3", "sourceItem4"]
+// 	}
+// });
+// const initialEntitySectionMap = ref<{ [entityId: string]: string }>({
+// 	entity1: "triage",
+// 	entity2: "triage",
+// 	entity3: "triage"
+// });
+
+const MAX_SOURCES = 3;
+const MAX_SOURCE_ITEMS = 50;
+
+const filterMap: { [filterId: string]: Filter } = {
+	// Example for combining: data.title ~> /some_title/ or (data.title ~> /other_title/i and data.number >= 45)
+	under45: {
+		id: "under45",
+		title: "Under 45",
+		query: `data.number <= 45`
+		// TODO automatically set triage to sort based on number for this filter
+	},
+	teaser: {
+		id: "teaser",
+		title: "Teaser",
+		query: `data.name ~> /teaser/i`
+	},
+	behindTheScenes: {
+		id: "behindTheScenes",
+		title: "Behind the scenes",
+		query: `data.name ~> /behind the scenes/i`
+	}
+};
+
+onMounted(() => {
+	const sourceIds: string[] = [];
+	const sourceMap: { [sourceId: string]: TestSource } = {};
+	for (let i = 1; i <= MAX_SOURCES; i += 1) {
+		const sourceId = `some-unique-source-id-${i}`;
+		sourceMap[sourceId] = {
+			id: sourceId,
+			data: {
+				name: `Some source ${i}`
+			}
+		};
+		sourceIds.push(sourceId);
+	}
+
+	const sourceItemMap: { [sourceItemId: string]: TestSourceItem } = {};
+	for (let i = 1; i <= MAX_SOURCE_ITEMS; i += 1) {
+		const sourceId = sourceIds[utils.randomNumber(0, MAX_SOURCES)];
+		const sourceItemId = `some-unique-source-item-id-${i}`;
+		const extra1 = utils.randomNumber(0, 100) > 75 ? "teaser" : "";
+		const extra2 =
+			utils.randomNumber(0, 100) > 75 ? "behind the scenes" : "";
+		sourceItemMap[sourceItemId] = {
+			id: sourceItemId,
+			sourceId,
+			data: {
+				name: `Some source item ${i} ${extra1} ${extra2}`,
+				number: utils.randomNumber(1, 100)
+			}
+		};
+	}
+
+	setFilterData({ filterMap });
+	setInitialData({
+		sourceMap,
+		sourceItemMap,
+		sectionMap,
+		initialEntityMap: {},
+		initialEntitySectionMap: {}
+	});
+});
+</script>
+
+<template>
+	<div class="entity-test-view section flex flex-column">
+		<entity-filter-group-view :view-uuid="viewUuid">
+			<template #source="{ source }">
+				<div class="source">
+					{{ source.id }} - {{ source.data.name }}
+				</div>
+			</template>
+			<template #sourceItem="{ sourceItem }">
+				<div class="flex flex-row source-item">
+					<span>
+						{{ sourceItem.id }} -
+						{{ sourceItem.data.name }}
+					</span>
+					<span>{{ sourceItem.data.number }}</span>
+				</div>
+			</template>
+		</entity-filter-group-view>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.flex {
+	display: flex;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.flex-row {
+	flex-direction: row;
+}
+
+.w-full {
+	width: 100%;
+}
+
+.source {
+	background: var(--dark-grey-2);
+	padding: 8px;
+	border-radius: @border-radius;
+}
+
+.source-item {
+	background: var(--dark-grey-2);
+	padding: 8px;
+	border-radius: @border-radius;
+	justify-content: space-between;
+}
+</style>

+ 140 - 19
frontend/src/components/modals/ImportArtist2/Views/MusicbrainzRecordings.vue

@@ -19,13 +19,20 @@ const importArtistStore = useImportArtistStore({
 })();
 const {
 	artist,
-	sortedRecordings,
+	sortedRecordingEntities,
 	imageForRecordingIdMap,
 	releaseGroupIdsForRecordingIdMap,
 	releaseGroupMap,
-	imageForReleaseGroupIdMap
+	imageForReleaseGroupIdMap,
+	collapsedRecordingEntityGroupKeys
 } = storeToRefs(importArtistStore);
-const { setMusicBrainzRecordingsReleasesReleaseGroups } = importArtistStore;
+const {
+	setMusicBrainzRecordingsReleasesReleaseGroups,
+	recordingEntitiesGroupAuto,
+	toggleCollapseRecordingEntityGroup,
+	recordingEntitiesCollapseAll,
+	recordingEntitiesExpandAll
+} = importArtistStore;
 
 const loadedMusicBrainzData = ref(false);
 
@@ -56,24 +63,113 @@ onMounted(() => {
 		loadedMusicBrainzData.value = true;
 	});
 });
+
+// TODO add way to mark a spelling error for a recording
+// TODO add manual way to group/ungroup
+// TODO add different sections for entities - triage, music, non-music, excluded
 </script>
 
 <template>
-	<div class="musicbrainz-recordings-view section">
-		<div class="flex flex-column recordings">
-			<temp-music-brainz-recording-card
-				v-for="recording in sortedRecordings"
-				:key="recording.id"
-				:recording="recording"
-				:recording-image="imageForRecordingIdMap[recording.id]"
-				:enable-release-groups="true"
-				:release-group-ids="
-					releaseGroupIdsForRecordingIdMap[recording.id]
-				"
-				:release-group-map="releaseGroupMap"
-				:release-group-image-map="imageForReleaseGroupIdMap"
-				:hide-musicbrainz-info="true"
-			/>
+	<div class="musicbrainz-recordings-view section flex flex-column">
+		<div class="buttons flex flex-row">
+			<button
+				class="button is-primary"
+				@click="recordingEntitiesGroupAuto()"
+			>
+				Group auto
+			</button>
+			<button
+				class="button is-primary"
+				@click="recordingEntitiesCollapseAll()"
+			>
+				Collapse all
+			</button>
+			<button
+				class="button is-primary"
+				@click="recordingEntitiesExpandAll()"
+			>
+				Expand all
+			</button>
+		</div>
+		<hr />
+		<div class="flex flex-column recording-entities">
+			<template
+				v-for="recordingEntity in sortedRecordingEntities"
+				:key="recordingEntity.key"
+			>
+				<div v-if="recordingEntity.type === 'single'">
+					<temp-music-brainz-recording-card
+						:recording="recordingEntity.recording as RecordingTemp"
+						:recording-image="
+							imageForRecordingIdMap[recordingEntity.recording.id]
+						"
+						:enable-release-groups="true"
+						:release-group-ids="
+							releaseGroupIdsForRecordingIdMap[
+								recordingEntity.recording.id
+							]
+						"
+						:release-group-map="releaseGroupMap"
+						:release-group-image-map="imageForReleaseGroupIdMap"
+						:hide-musicbrainz-info="true"
+					/>
+				</div>
+				<div
+					v-if="recordingEntity.type === 'group'"
+					class="flex flex-column recording-entity-group"
+				>
+					<p class="recording-entity-group-title flex flex-row">
+						<span
+							class="material-icons"
+							@click="
+								toggleCollapseRecordingEntityGroup(
+									recordingEntity.key
+								)
+							"
+							>{{
+								collapsedRecordingEntityGroupKeys[
+									recordingEntity.key
+								]
+									? "expand_more"
+									: "expand_less"
+							}}</span
+						>
+						<span
+							>{{ recordingEntity.title }} ({{
+								recordingEntity.recordings.length
+							}})</span
+						>
+					</p>
+					<div
+						v-show="
+							!collapsedRecordingEntityGroupKeys[
+								recordingEntity.key
+							]
+						"
+						class="flex flex-column recording-entity-group-recordings"
+					>
+						<temp-music-brainz-recording-card
+							v-for="recording in recordingEntity.recordings"
+							:key="recording.id"
+							:recording="recording as RecordingTemp"
+							:recording-image="
+								imageForRecordingIdMap[recording.id]
+							"
+							:enable-release-groups="true"
+							:release-group-ids="
+								releaseGroupIdsForRecordingIdMap[recording.id]
+							"
+							:release-group-map="releaseGroupMap"
+							:release-group-image-map="imageForReleaseGroupIdMap"
+							:hide-musicbrainz-info="true"
+						/>
+					</div>
+				</div>
+			</template>
+		</div>
+		<hr />
+		<div>
+			<p>Entities: {{ sortedRecordingEntities.length }}</p>
 		</div>
 	</div>
 </template>
@@ -96,8 +192,33 @@ onMounted(() => {
 }
 
 .musicbrainz-recordings-view {
-	.recordings {
+	gap: 8px;
+
+	.buttons {
+		gap: 8px;
+	}
+
+	.recording-entities {
 		gap: 8px;
+
+		.recording-entity-group {
+			gap: 8px;
+			padding: 8px;
+			border: 2px dotted var(--light-grey-3);
+			border-radius: @border-radius;
+
+			.recording-entity-group-title {
+				gap: 8px;
+
+				.material-icons {
+					cursor: pointer;
+				}
+			}
+
+			.recording-entity-group-recordings {
+				gap: 8px;
+			}
+		}
 	}
 }
 </style>

+ 120 - 12
frontend/src/components/modals/ImportArtist2/Views/YoutubeVideos.vue

@@ -20,9 +20,23 @@ const { socket } = useWebsocketsStore();
 const importArtistStore = useImportArtistStore({
 	modalUuid: props.modalUuid
 })();
-const { youtubeChannelIds, youtubeChannels, youtubeVideoIds, youtubeVideoMap } =
-	storeToRefs(importArtistStore);
-const { setYoutubeChannels, setYoutubeVideos } = importArtistStore;
+const {
+	youtubeChannelIds,
+	youtubeChannels,
+	// youtubeVideoIds,
+	// youtubeVideoMap,
+	youtubeVideoMapAdjusted,
+	collapsedVideoEntityGroupKeys,
+	sortedVideoEntities
+} = storeToRefs(importArtistStore);
+const {
+	setYoutubeChannels,
+	setYoutubeVideos,
+	videoEntitiesGroupAuto,
+	toggleCollapseVideoEntityGroup,
+	videoEntitiesCollapseAll,
+	videoEntitiesExpandAll
+} = importArtistStore;
 
 const loadedYoutubeVideos = ref(false);
 
@@ -60,13 +74,81 @@ onMounted(() => {
 			></temp-youtube-channel-card>
 		</div>
 		<hr />
-		<div class="flex flex-column videos">
-			<temp-youtube-video-card
-				v-for="youtubeVideoId in youtubeVideoIds"
-				:key="youtubeVideoId"
-				:youtube-video="youtubeVideoMap[youtubeVideoId]"
-				:hide-youtube-info="true"
-			></temp-youtube-video-card>
+		<div class="buttons flex flex-row">
+			<button class="button is-primary" @click="videoEntitiesGroupAuto()">
+				Group auto
+			</button>
+			<button
+				class="button is-primary"
+				@click="videoEntitiesCollapseAll()"
+			>
+				Collapse all
+			</button>
+			<button class="button is-primary" @click="videoEntitiesExpandAll()">
+				Expand all
+			</button>
+		</div>
+		<hr />
+		<div class="flex flex-column video-entities">
+			<template
+				v-for="videoEntity in sortedVideoEntities"
+				:key="videoEntity.key"
+			>
+				<div v-if="videoEntity.type === 'single'">
+					<temp-youtube-video-card
+						:youtube-video="
+							youtubeVideoMapAdjusted[videoEntity.videoId]
+						"
+						:override-title="
+							youtubeVideoMapAdjusted[videoEntity.videoId]
+								.titleNormalizedNew
+						"
+						:hide-youtube-info="true"
+					></temp-youtube-video-card>
+				</div>
+				<div
+					v-if="videoEntity.type === 'group'"
+					class="flex flex-column video-entity-group"
+				>
+					<p class="video-entity-group-title flex flex-row">
+						<span
+							class="material-icons"
+							@click="
+								toggleCollapseVideoEntityGroup(videoEntity.key)
+							"
+							>{{
+								collapsedVideoEntityGroupKeys[videoEntity.key]
+									? "expand_more"
+									: "expand_less"
+							}}</span
+						>
+						<span
+							>{{ videoEntity.title }} ({{
+								videoEntity.videoIds.length
+							}})</span
+						>
+					</p>
+					<div
+						v-show="!collapsedVideoEntityGroupKeys[videoEntity.key]"
+						class="flex flex-column video-entity-group-videos"
+					>
+						<temp-youtube-video-card
+							v-for="videoId in videoEntity.videoIds"
+							:key="videoId"
+							:youtube-video="youtubeVideoMapAdjusted[videoId]"
+							:override-title="
+								youtubeVideoMapAdjusted[videoId]
+									.titleNormalizedNew
+							"
+							:hide-youtube-info="true"
+						></temp-youtube-video-card>
+					</div>
+				</div>
+			</template>
+		</div>
+		<hr />
+		<div>
+			<p>Entities: {{ sortedVideoEntities.length }}</p>
 		</div>
 	</div>
 </template>
@@ -91,9 +173,35 @@ onMounted(() => {
 .youtube-videos-view {
 	gap: 8px;
 
-	.channels,
-	.videos {
+	.channels {
+		gap: 8px;
+	}
+
+	.buttons {
 		gap: 8px;
 	}
+
+	.video-entities {
+		gap: 8px;
+
+		.video-entity-group {
+			gap: 8px;
+			padding: 8px;
+			border: 2px dotted var(--light-grey-3);
+			border-radius: @border-radius;
+
+			.video-entity-group-title {
+				gap: 8px;
+
+				.material-icons {
+					cursor: pointer;
+				}
+			}
+
+			.video-entity-group-videos {
+				gap: 8px;
+			}
+		}
+	}
 }
 </style>

+ 9 - 2
frontend/src/components/modals/ImportArtist2/index.vue

@@ -14,6 +14,9 @@ const MusicbrainzRecordingsView = defineAsyncComponent(
 const YoutubeVideosView = defineAsyncComponent(
 	() => import("@/components/modals/ImportArtist2/Views/YoutubeVideos.vue")
 );
+const EntityTestView = defineAsyncComponent(
+	() => import("@/components/modals/ImportArtist2/Views/EntityTest.vue")
+);
 
 const props = defineProps({
 	modalUuid: { type: String, required: true },
@@ -41,8 +44,8 @@ onMounted(() => {
 });
 
 const selectedView = ref<
-	"musicbrainz-recordings" | "youtube-videos" | "linking"
->("musicbrainz-recordings");
+	"musicbrainz-recordings" | "youtube-videos" | "linking" | "entity-test"
+>("entity-test");
 </script>
 
 <template>
@@ -54,6 +57,10 @@ const selectedView = ref<
 	>
 		<template #body>
 			<div class="flex flex-column w-full">
+				<entity-test-view
+					v-if="selectedView === 'entity-test'"
+					:modal-uuid="modalUuid"
+				/>
 				<musicbrainz-recordings-view
 					v-if="
 						artist.musicbrainzIdentifier &&

+ 3 - 0
frontend/src/index.html

@@ -117,6 +117,9 @@
 
 		<script src="https://www.youtube.com/iframe_api"></script>
 
+		<!-- TODO remove this or load it smarter, e.g. only where needed -->
+		<script src="https://cdn.jsdelivr.net/npm/jsonata/jsonata.min.js"></script>
+
 		<!--Musare version: {{ version }}-->
 		<!--
 			Git info

+ 182 - 6
frontend/src/pages/Admin/Artists.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref } from "vue";
+import { defineAsyncComponent, ref, onMounted } from "vue";
 import Toast from "toasters";
+import { useRoute } from "vue-router";
 import { GenericResponse } from "@musare_types/actions/GenericActions";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
@@ -19,6 +20,9 @@ const UserLink = defineAsyncComponent(
 );
 
 const { socket } = useWebsocketsStore();
+const route = useRoute();
+
+const enableRemove = ref(false);
 
 const columnDefault = ref<TableColumn>({
 	sortable: true,
@@ -37,8 +41,15 @@ const columns = ref<TableColumn[]>([
 		sortable: false,
 		hidable: false,
 		resizable: false,
-		minWidth: 85,
-		defaultWidth: 85
+		minWidth: enableRemove.value ? 129 : 85,
+		defaultWidth: enableRemove.value ? 129 : 85
+	},
+	{
+		name: "id",
+		displayName: "ID",
+		properties: ["_id"],
+		sortProperty: "_id",
+		defaultWidth: 215
 	},
 	{
 		name: "name",
@@ -50,7 +61,94 @@ const columns = ref<TableColumn[]>([
 		name: "musicbrainzIdentifier",
 		displayName: "MusicBrainz identifier",
 		properties: ["musicbrainzIdentifier"],
-		sortProperty: "musicbrainzIdentifier"
+		sortProperty: "musicbrainzIdentifier",
+		defaultWidth: 300
+	},
+	{
+		name: "musicbrainzDataType",
+		displayName: "MB Type",
+		properties: ["musicbrainzData.type"],
+		sortProperty: "musicbrainzData.type",
+		minWidth: 100,
+		defaultWidth: 100
+	},
+	{
+		name: "musicbrainzDataName",
+		displayName: "MB Name",
+		properties: ["musicbrainzData.name"],
+		sortProperty: "musicbrainzData.name",
+		minWidth: 100,
+		defaultWidth: 100
+	},
+	{
+		name: "musicbrainzDataCountry",
+		displayName: "MB Country",
+		properties: ["musicbrainzData.country"],
+		sortProperty: "musicbrainzData.country",
+		minWidth: 100,
+		defaultWidth: 100
+	},
+	{
+		name: "musicbrainzDataDisambiguation",
+		displayName: "MB Disambiguation",
+		properties: ["musicbrainzData.disambiguation"],
+		sortProperty: "musicbrainzData.disambiguation",
+		minWidth: 100,
+		defaultWidth: 100
+	},
+	{
+		name: "musicbrainzDataLifespanStart",
+		displayName: "MB Lifespan Start",
+		properties: ["musicbrainzData.life-span.start"],
+		sortProperty: "musicbrainzData.life-span.start",
+		minWidth: 100,
+		defaultWidth: 100,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "musicbrainzDataLifespanEnd",
+		displayName: "MB Lifespan End",
+		properties: ["musicbrainzData.life-span.end"],
+		sortProperty: "musicbrainzData.life-span.end",
+		minWidth: 100,
+		defaultWidth: 100,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "musicbrainzDataAreaName",
+		displayName: "MB Area Name",
+		properties: ["musicbrainzData.area.name"],
+		sortProperty: "musicbrainzData.area.name",
+		minWidth: 100,
+		defaultWidth: 100,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "musicbrainzDataAreaType",
+		displayName: "MB Area Type",
+		properties: ["musicbrainzData.area.type"],
+		sortProperty: "musicbrainzData.area.type",
+		minWidth: 100,
+		defaultWidth: 100,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "musicbrainzDataBeginAreaName",
+		displayName: "MB Begin Area Name",
+		properties: ["musicbrainzData.begin-area.name"],
+		sortProperty: "musicbrainzData.begin-area.name",
+		minWidth: 100,
+		defaultWidth: 100,
+		defaultVisibility: "hidden"
+	},
+	{
+		name: "musicbrainzDataBeginAreaType",
+		displayName: "MB Begin Area Type",
+		properties: ["musicbrainzData.begin-area.type"],
+		sortProperty: "musicbrainzData.begin-area.type",
+		minWidth: 100,
+		defaultWidth: 100,
+		defaultVisibility: "hidden"
 	},
 	{
 		name: "createdBy",
@@ -116,11 +214,22 @@ const { hasPermission } = useUserAuthStore();
 
 const remove = (id: string) => {
 	socket.dispatch(
-		"artist.remove",
+		"artists.remove",
 		id,
 		(res: GenericResponse) => new Toast(res.message)
 	);
 };
+
+onMounted(() => {
+	const { artistId } = route.query;
+
+	if (artistId) {
+		openModal({
+			modal: "editArtist",
+			props: { artistId }
+		});
+	}
+});
 </script>
 
 <template>
@@ -171,7 +280,7 @@ const remove = (id: string) => {
 						edit
 					</button>
 					<quick-confirm
-						v-if="hasPermission('artist.remove')"
+						v-if="hasPermission('artist.remove') && enableRemove"
 						@confirm="remove(slotProps.item._id)"
 						:disabled="slotProps.item.removed"
 					>
@@ -185,6 +294,11 @@ const remove = (id: string) => {
 					</quick-confirm>
 				</div>
 			</template>
+			<template #column-id="slotProps">
+				<span :title="slotProps.item._id">{{
+					slotProps.item._id
+				}}</span>
+			</template>
 			<template #column-name="slotProps">
 				<span :title="slotProps.item.name">{{
 					slotProps.item.name
@@ -195,6 +309,68 @@ const remove = (id: string) => {
 					slotProps.item.musicbrainzIdentifier
 				}}</span>
 			</template>
+			<template #column-musicbrainzDataType="slotProps">
+				<span :title="slotProps.item.musicbrainzData.type">{{
+					slotProps.item.musicbrainzData.type
+				}}</span>
+			</template>
+
+			<template #column-musicbrainzDataName="slotProps">
+				<span :title="slotProps.item.musicbrainzData.name">{{
+					slotProps.item.musicbrainzData.name
+				}}</span>
+			</template>
+			<template #column-musicbrainzDataCountry="slotProps">
+				<span :title="slotProps.item.musicbrainzData.country">{{
+					slotProps.item.musicbrainzData.country
+				}}</span>
+			</template>
+			<template #column-musicbrainzDataDisambiguation="slotProps">
+				<span :title="slotProps.item.musicbrainzData.disambiguation">{{
+					slotProps.item.musicbrainzData.disambiguation
+				}}</span>
+			</template>
+			<template #column-musicbrainzDataLifespanStart="slotProps">
+				<span
+					:title="slotProps.item.musicbrainzData['life-span'].start"
+					>{{
+						slotProps.item.musicbrainzData["life-span"].start
+					}}</span
+				>
+			</template>
+			<template #column-musicbrainzDataLifespanEnd="slotProps">
+				<span
+					:title="slotProps.item.musicbrainzData['life-span'].end"
+					>{{ slotProps.item.musicbrainzData["life-span"].end }}</span
+				>
+			</template>
+			<template #column-musicbrainzDataAreaName="slotProps">
+				<span :title="slotProps.item.musicbrainzData.area.name">{{
+					slotProps.item.musicbrainzData.area.name
+				}}</span>
+			</template>
+			<template #column-musicbrainzDataAreaType="slotProps">
+				<span :title="slotProps.item.musicbrainzData.area.type">{{
+					slotProps.item.musicbrainzData.area.type
+				}}</span>
+			</template>
+			<template #column-musicbrainzDataBeginAreaName="slotProps">
+				<span
+					:title="slotProps.item.musicbrainzData['begin-area'].name"
+					>{{
+						slotProps.item.musicbrainzData["begin-area"].name
+					}}</span
+				>
+			</template>
+			<template #column-musicbrainzDataBeginAreaType="slotProps">
+				<span
+					:title="slotProps.item.musicbrainzData['begin-area'].type"
+					>{{
+						slotProps.item.musicbrainzData["begin-area"].type
+					}}</span
+				>
+			</template>
+
 			<template #column-createdBy="slotProps">
 				<user-link
 					:user-id="slotProps.item.createdBy"

+ 446 - 0
frontend/src/stores/entityFilterGroupView.ts

@@ -0,0 +1,446 @@
+import { defineStore } from "pinia";
+import { ref, computed, watch, toRaw, nextTick } from "vue";
+import utils from "@/utils";
+
+import {
+	Source,
+	SourceItem,
+	Section,
+	Entity,
+	EntitySingle,
+	EntityGroup,
+	Filter
+} from "@/types/onlyForTesting";
+
+const { measureStart, measureFinish } = utils;
+
+export const useEntityFilterGroupViewStore = ({
+	viewUuid
+}: {
+	viewUuid: string;
+}) =>
+	defineStore(`entityFilterGroupView-${viewUuid}`, () => {
+		// ### REFS ###
+		// Loaded dynamically per artist, not changed after setting initial data
+		const sourceMap = ref<{ [sourceId: string]: Source }>({});
+		const sourceItemMap = ref<{ [sourceItemId: string]: SourceItem }>({});
+		// Hardcoded per modal view, not changed after setting initial data
+		const sectionMap = ref<{ [sectionId: string]: Section }>({});
+		// Initial data, is changed by this store and later saved
+		const initialEntityMap = ref<{ [entityId: string]: Entity }>({});
+		const initialEntitySectionMap = ref<{ [entityId: string]: string }>({}); // Entity id map with section id, so one section id per entity id
+
+		const entityMap = ref<{ [entityId: string]: Entity }>({});
+		const entitySectionMap = ref<{ [entityId: string]: string }>({});
+		const selectedEntityIds = ref<string[]>([]);
+
+		const filterMap = ref<{ [filterId: string]: Filter }>({});
+		const activeFilterId = ref<string | null>(null);
+		const filteredEntityIds = ref<string[]>([]);
+		const filterMode = ref<boolean>(false);
+		const collapsedSectionIds = ref<string[]>([]);
+
+		// ### COMPUTED ###
+		// Computed ids lists that should only be computed once
+		const sourceIds = computed(() => Object.keys(sourceMap.value));
+		const sourceItemIds = computed(() => Object.keys(sourceItemMap.value));
+		const sectionIds = computed(() => Object.keys(sectionMap.value));
+		// Computed after setting initial data
+		const entityIds = computed(() => Object.keys(entityMap.value));
+		// Computed after setting filters
+		const filterIds = computed(() => Object.keys(filterMap.value));
+
+		const sectionEntityMap = computed<{ [sectionId: string]: string[] }>(
+			() => {
+				const _map: { [sectionId: string]: string[] } = {};
+				sectionIds.value.forEach(sectionId => {
+					_map[sectionId] = [];
+				});
+				entityIds.value.forEach(entityId => {
+					const sectionId = entitySectionMap.value[entityId];
+					if (!sectionId) return;
+					_map[sectionId].push(entityId);
+				});
+				return _map;
+			}
+		);
+
+		const sectionCollapseMap = computed<{ [sectionId: string]: boolean }>(
+			() => {
+				const _map: { [sectionId: string]: boolean } = {};
+				sectionIds.value.forEach(sectionId => {
+					_map[sectionId] =
+						collapsedSectionIds.value.includes(sectionId);
+				});
+				return _map;
+			}
+		);
+
+		// ### FUNCTIONS ###
+		const setFilterData = ({
+			filterMap: _filterMap
+		}: {
+			filterMap: { [filterId: string]: Filter };
+		}) => {
+			measureStart("setFilters");
+			filterMap.value = structuredClone(_filterMap);
+			measureFinish("setFilters");
+		};
+
+		const setInitialData = ({
+			sourceMap: _sourceMap,
+			sourceItemMap: _sourceItemMap,
+			sectionMap: _sectionMap,
+			initialEntityMap: _initialEntityMap,
+			initialEntitySectionMap: _initialEntitySectionMap
+		}: {
+			sourceMap: { [sourceId: string]: Source };
+			sourceItemMap: { [sourceItemId: string]: SourceItem };
+			sectionMap: { [sectionId: string]: Section };
+			initialEntityMap: { [entityId: string]: Entity };
+			initialEntitySectionMap: { [entityId: string]: string };
+		}) => {
+			measureStart("setInitialData");
+			sourceMap.value = structuredClone(_sourceMap);
+			sourceItemMap.value = structuredClone(_sourceItemMap);
+			sectionMap.value = structuredClone(_sectionMap);
+			initialEntityMap.value = structuredClone(_initialEntityMap);
+			initialEntitySectionMap.value = structuredClone(
+				_initialEntitySectionMap
+			);
+
+			const [firstSectionId] = sectionIds.value;
+
+			entityMap.value = initialEntityMap.value;
+			entitySectionMap.value = initialEntitySectionMap.value;
+
+			const _entityMap = structuredClone(toRaw(entityMap.value));
+			const _entitySectionMap = structuredClone(
+				toRaw(entitySectionMap.value)
+			);
+
+			const sourceItemIdsInEntities = [];
+			entityIds.value.forEach(entityId => {
+				const entity = _entityMap[entityId];
+				if (entity.type === "single")
+					sourceItemIdsInEntities.push(entity.sourceItemId);
+				else if (entity.type === "group")
+					sourceItemIdsInEntities.push(...entity.sourceItemIds);
+			});
+
+			// Create new single entities from source items that do not have an entity yet
+			sourceItemIds.value.forEach(sourceItemId => {
+				if (sourceItemIdsInEntities.includes(sourceItemId)) return;
+				const entityId = utils.guid();
+				const entity: EntitySingle = {
+					type: "single",
+					id: entityId,
+					sourceItemId
+				};
+				_entityMap[entityId] = entity;
+			});
+
+			const _entityIds = Object.keys(_entityMap);
+
+			// Add entities that are not in any section to the first section
+			_entityIds.forEach(entityId => {
+				if (!_entitySectionMap[entityId])
+					_entitySectionMap[entityId] = firstSectionId;
+			});
+
+			entityMap.value = _entityMap;
+			entitySectionMap.value = _entitySectionMap;
+			measureFinish("setInitialData");
+		};
+
+		const ungroupEntity = entityId => {
+			const entity = entityMap.value[entityId];
+			if (!entity) return;
+			if (entity.type !== "group") return;
+
+			const { sourceItemIds } = entity;
+			const sectionId = entitySectionMap.value[entityId];
+			const ungroupedEntities: EntitySingle[] = sourceItemIds.map(
+				sourceItemId => ({
+					type: "single",
+					id: utils.guid(),
+					sourceItemId
+				})
+			);
+
+			// Copy the maps we need to change for performance reasons
+			const _entitySectionMap = structuredClone(
+				toRaw(entitySectionMap.value)
+			);
+			const _entityMap = structuredClone(toRaw(entityMap.value));
+
+			// Clean up old group entity
+			delete _entityMap[entityId];
+			delete _entitySectionMap[entityId];
+			// Add the new single entities
+			ungroupedEntities.forEach(entity => {
+				_entitySectionMap[entity.id] = sectionId;
+				_entityMap[entity.id] = entity;
+			});
+
+			// Commit the changed maps
+			entitySectionMap.value = _entitySectionMap;
+			entityMap.value = _entityMap;
+		};
+
+		const groupEntities = (_entityIds: string[]) => {
+			console.log("groupEntities");
+			const validEntityIds = [...new Set(_entityIds)].filter(
+				entityId =>
+					entityIds.value.includes(entityId) &&
+					entityMap.value[entityId].type === "single"
+			);
+			if (validEntityIds.length < 2) return;
+			const sectionIds = [
+				...new Set(
+					validEntityIds.map(
+						entityId => entitySectionMap.value[entityId]
+					)
+				)
+			].filter(sectionId => sectionId);
+			if (sectionIds.length !== 1) return;
+			const [sectionId] = sectionIds;
+			if (sectionId !== "music") return;
+
+			const sourceItemIds = validEntityIds.map(
+				entityId =>
+					(entityMap.value[entityId] as EntitySingle).sourceItemId
+			);
+
+			const entityId = utils.guid();
+			const entity: EntityGroup = {
+				type: "group",
+				id: entityId,
+				sourceItemIds
+			};
+
+			// Copy the maps we need to change for performance reasons
+			const _entitySectionMap = structuredClone(
+				toRaw(entitySectionMap.value)
+			);
+			const _entityMap = structuredClone(toRaw(entityMap.value));
+
+			// Clean up old single entities
+			validEntityIds.forEach(entityId => {
+				delete _entitySectionMap[entityId];
+				delete _entityMap[entityId];
+			});
+			// Add the new group entity
+			_entitySectionMap[entityId] = sectionId;
+			_entityMap[entityId] = entity;
+
+			// Commit the changed maps
+			entitySectionMap.value = _entitySectionMap;
+			entityMap.value = _entityMap;
+			console.log("groupEntities end");
+		};
+
+		const toggleSelectEntity = entityId => {
+			const index = selectedEntityIds.value.indexOf(entityId);
+			if (index < 0) selectedEntityIds.value.push(entityId);
+			else selectedEntityIds.value.splice(index, 1);
+		};
+
+		const selectAll = sectionId => {
+			const entityIds = sectionEntityMap.value[sectionId];
+			const _selectedEntityIds = structuredClone(entityIds).filter(
+				entityId => !filteredEntityIds.value.includes(entityId)
+			);
+			selectedEntityIds.value = _selectedEntityIds;
+		};
+
+		const deselectAll = () => {
+			selectedEntityIds.value = [];
+		};
+
+		// Calculates the entity ids that will be hidden
+		const calculateFilteredEntityIds = async activeFilterId => {
+			if (!activeFilterId) return [];
+			utils.measureStart("calculateFilteredEntityIds");
+			const activeFilter = filterMap.value[activeFilterId];
+
+			const _filteredEntityIds = [];
+
+			const promises = entityIds.value.map(async entityId => {
+				const sectionId = entitySectionMap.value[entityId];
+
+				// Don't filter entities that are not in the triage section
+				if (sectionId !== "triage") return;
+
+				const entity = entityMap.value[entityId];
+				// Filter all group entities
+				if (entity.type === "group") {
+					_filteredEntityIds.push(entityId);
+					return;
+				}
+
+				const sourceItem = sourceItemMap.value[entity.sourceItemId];
+				const matched = await jsonata(activeFilter.query).evaluate(
+					sourceItem
+				);
+
+				console.log(`Matched: ${matched ? "true" : "false"}`);
+				console.log(activeFilter.query, sourceItem);
+
+				// Don't filter matched entities
+				if (!matched) _filteredEntityIds.push(entityId);
+			});
+			await Promise.all(promises);
+
+			utils.measureFinish("calculateFilteredEntityIds");
+
+			return _filteredEntityIds;
+		};
+
+		const applyFilter = async (filterId: string | null) => {
+			activeFilterId.value = filterId;
+			if (!filterId) filteredEntityIds.value = [];
+			else
+				filteredEntityIds.value = await calculateFilteredEntityIds(
+					activeFilterId.value
+				);
+		};
+
+		const stopFilterMode = async () => {
+			filterMode.value = false;
+			await applyFilter(null);
+			// Unselect all
+			selectedEntityIds.value = [];
+			// Un-collapse all sections
+			collapsedSectionIds.value = [];
+		};
+
+		const selectNextFilter = async () => {
+			const currentIndex = filterIds.value.indexOf(activeFilterId.value);
+			const nextIndex = currentIndex + 1;
+			if (nextIndex > filterIds.value.length - 1) await stopFilterMode();
+			else {
+				// Show only triage section
+				collapsedSectionIds.value = sectionIds.value.filter(
+					sectionId => sectionId !== "triage"
+				);
+				// Unselect all
+				selectedEntityIds.value = [];
+				await applyFilter(filterIds.value[nextIndex]);
+
+				// Wait one tick for the watcher to catch up to the new active filter
+				// await nextTick();
+
+				// Check if one or more single entities are in triage
+				// If no, select the next filter
+				// If yes, select all not-hidden entities in triage
+				const entityIds = sectionEntityMap.value.triage.filter(
+					entityId => !filteredEntityIds.value.includes(entityId)
+				);
+				console.log("Select next filter entity ids", entityIds);
+				if (entityIds.length === 0) await selectNextFilter();
+				else selectedEntityIds.value = entityIds;
+			}
+		};
+
+		const startFilterMode = async () => {
+			filterMode.value = true;
+			// Show only triage section
+			collapsedSectionIds.value = sectionIds.value.filter(
+				sectionId => sectionId !== "triage"
+			);
+			// Unselect all
+			selectedEntityIds.value = [];
+			// Start filtering
+			await applyFilter(null);
+			await selectNextFilter();
+		};
+
+		const moveEntities = async (_entityIds: string[], toSectionId) => {
+			const validEntityIds = [...new Set(_entityIds)].filter(
+				entityId =>
+					entityIds.value.includes(entityId) &&
+					entityMap.value[entityId].type === "single"
+			);
+			if (validEntityIds.length < 2) return;
+			const sectionIds = [
+				...new Set(
+					validEntityIds.map(
+						entityId => entitySectionMap.value[entityId]
+					)
+				)
+			].filter(sectionId => sectionId);
+			if (sectionIds.length !== 1) return;
+			const [sectionId] = sectionIds;
+			// DUPLICATE CODE ABOVE
+
+			if (sectionId === toSectionId) return;
+
+			const _entitySectionMap = structuredClone(
+				toRaw(entitySectionMap.value)
+			);
+			validEntityIds.forEach(entityId => {
+				_entitySectionMap[entityId] = toSectionId;
+			});
+
+			entitySectionMap.value = _entitySectionMap;
+
+			// For filter mode, if we have no entities left in triage, skip to the next filter automatically
+			if (filterMode.value) {
+				const entityIds = sectionEntityMap.value.triage.filter(
+					entityId => !filteredEntityIds.value.includes(entityId)
+				);
+				if (entityIds.length === 0) await selectNextFilter();
+			}
+		};
+
+		const toggleCollapseSection = sectionId => {
+			if (sectionCollapseMap.value[sectionId])
+				collapsedSectionIds.value.splice(
+					collapsedSectionIds.value.indexOf(sectionId),
+					1
+				);
+			else collapsedSectionIds.value.push(sectionId);
+		};
+
+		watch(entityIds, entityIds => {
+			// Clean up selected entity ids, by removing selected entity ids for entities that no longer exist
+			const _selectedEntityIds = selectedEntityIds.value.filter(
+				entityId => entityIds.includes(entityId)
+			);
+			if (selectedEntityIds.value.length !== _selectedEntityIds.length)
+				selectedEntityIds.value = _selectedEntityIds;
+		});
+
+		return {
+			sourceIds,
+			sourceMap,
+			sourceItemIds,
+			sourceItemMap,
+			sectionIds,
+			sectionMap,
+			sectionEntityMap,
+			entityMap,
+			entityIds,
+			filterMap,
+			filterIds,
+			activeFilterId,
+			selectedEntityIds,
+			filteredEntityIds,
+			filterMode,
+			sectionCollapseMap,
+			setFilterData,
+			setInitialData,
+			ungroupEntity,
+			groupEntities,
+			toggleSelectEntity,
+			selectAll,
+			deselectAll,
+			applyFilter,
+			moveEntities,
+			startFilterMode,
+			stopFilterMode,
+			selectNextFilter,
+			toggleCollapseSection
+		};
+	});

+ 391 - 43
frontend/src/stores/importArtist.ts

@@ -6,7 +6,9 @@ import {
 	ReleaseTemp,
 	YoutubeChannelTemp,
 	RecordingTemp,
-	ReleaseGroupTemp
+	ReleaseGroupTemp,
+	RecordingEntity,
+	VideoEntity
 } from "@/types/artist";
 import { useConfigStore } from "@/stores/config";
 
@@ -117,6 +119,16 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 
 		const showOnlyVideosWithoutAutoLinks = ref(false);
 
+		const recordingEntities = ref<RecordingEntity[]>([]);
+		const collapsedRecordingEntityGroupKeys = ref<{
+			[entityGroupKey: string]: boolean;
+		}>({});
+
+		const videoEntities = ref<VideoEntity[]>([]);
+		const collapsedVideoEntityGroupKeys = ref<{
+			[entityGroupKey: string]: boolean;
+		}>({});
+
 		const releaseGroupIdsForRecordingIdMap = computed<{
 			[recordingId: string]: string[];
 		}>(() => {
@@ -143,6 +155,53 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 			return map;
 		});
 
+		const youtubeVideoTitleNormalizedNew = title =>
+			title
+				.replaceAll(/\(.+\)/g, "")
+				.replaceAll(/\[.+\]/g, "")
+				.replaceAll(/\{.+\}/g, "")
+				// .replace(new RegExp(`.*${artist.value.name}.* - `, "i"), "")
+				.replace(
+					new RegExp(
+						`^[\\p{L}0-9&, ]*${artist.value.name}[\\p{L}0-9&, ]* - `,
+						"i"
+					),
+					""
+				)
+				// .replace(new RegExp(` - .*${artist.value.name}.*`, "i"), "")
+				.replace(
+					new RegExp(
+						` - [\\p{L}0-9&, ]*${artist.value.name}[\\p{L}0-9&, ]*$`, // Doesn't work if an extra artist has a "-" in it, and adding it to the regex matches too much
+						"ui"
+					),
+					""
+				)
+				.replaceAll(/-[\p{L}0-9 ]+-$/gu, "") // For things like "Some title -Instrumental-", where it ends with that
+				.replaceAll(/ - demo/gi, "")
+				.replaceAll(/[^\p{L}0-9 ]/gu, "")
+				.replaceAll(/[ ]{2,}/g, " ")
+				.trim();
+		const musicbrainzRecordingTitleNormalizedNew = title =>
+			title
+				.replaceAll(/\(.+\)/g, "")
+				.replaceAll(/\[.+\]/g, "")
+				.replaceAll(/\{.+\}/g, "")
+				.replaceAll(/-[\p{L}0-9 ]+-$/gu, "") // For things like "Some title -Instrumental-", where it ends with that
+				.replaceAll(/ - demo/gi, "")
+				.replaceAll(/[^\p{L}0-9 ]/gu, "")
+				.replaceAll(/[ ]{2,}/g, " ")
+				.trim();
+		const youtubeVideoTitleNormalizedNewExtra = title =>
+			youtubeVideoTitleNormalizedNew(title)
+				.toLowerCase()
+				.replaceAll(/[^\p{L}0-9]/gu, "") // Also removes spaces
+				.trim();
+		const musicbrainzRecordingTitleNormalizedNewExtra = title =>
+			musicbrainzRecordingTitleNormalizedNew(title)
+				.toLowerCase()
+				.replaceAll(/[^\p{L}0-9]/gu, "") // Also removes spaces
+				.trim();
+
 		const recordingSortTitleFn = (recordingA, recordingB) =>
 			`${recordingA.title} (${recordingA.disambiguation})`.localeCompare(
 				`${recordingB.title} (${recordingB.disambiguation})`
@@ -377,6 +436,13 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 							.toLowerCase()
 							.trim()
 							.replaceAll(/[^\p{L}0-9 ]/gu, "");
+					const titleNormalizedNew = youtubeVideoTitleNormalizedNew(
+						youtubeVideo.rawData.snippet.title
+					);
+					const titleNormalizedNewExtra =
+						youtubeVideoTitleNormalizedNewExtra(
+							youtubeVideo.rawData.snippet.title
+						);
 
 					return [
 						youtubeVideoId,
@@ -385,6 +451,8 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 							title,
 							strippedAdjustedYoutubeVideoTitle,
 							strippedYoutubeVideoTitle,
+							titleNormalizedNew,
+							titleNormalizedNewExtra,
 							hide
 						}
 					];
@@ -682,6 +750,16 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 				])
 			);
 			measureFinish("setYoutubeVideos method part two");
+
+			measureStart("setYoutubeVideos set videoEntities");
+			videoEntities.value = youtubeVideoIds.value.map(videoId => ({
+				type: "single",
+				key: videoId,
+				sortTitle:
+					youtubeVideoMapAdjusted.value[videoId].titleNormalizedNew,
+				videoId
+			}));
+			measureFinish("setYoutubeVideos set videoEntities");
 		};
 		const resetLinkingData = () => {
 			recordingSort.value = "title";
@@ -731,10 +809,16 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 			);
 			measureStart("setMusicBrainzRecordingsReleasesReleaseGroups");
 
+			measureStart(
+				"setMusicBrainzRecordingsReleasesReleaseGroups set recordingsReleasesReleaseGroups"
+			);
 			recordingsReleasesReleaseGroups.value = [
 				...artistReleases,
 				...trackArtistReleases
 			];
+			measureFinish(
+				"setMusicBrainzRecordingsReleasesReleaseGroups set recordingsReleasesReleaseGroups"
+			);
 
 			const _recordingMap = {};
 			const _releaseMap = {};
@@ -751,13 +835,25 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 				_releaseGroupMap[releaseGroup.id] = releaseGroup;
 			});
 
+			measureStart(
+				"setMusicBrainzRecordingsReleasesReleaseGroups set maps"
+			);
 			recordingMap.value = _recordingMap;
 			releaseMap.value = _releaseMap;
 			releaseGroupMap.value = _releaseGroupMap;
+			measureFinish(
+				"setMusicBrainzRecordingsReleasesReleaseGroups set maps"
+			);
 
+			measureStart(
+				"setMusicBrainzRecordingsReleasesReleaseGroups set ids"
+			);
 			recordingIds.value = Object.keys(recordingMap.value);
 			releaseIds.value = Object.keys(releaseMap.value);
 			releaseGroupIds.value = Object.keys(releaseGroupMap.value);
+			measureFinish(
+				"setMusicBrainzRecordingsReleasesReleaseGroups set ids"
+			);
 
 			// E.g. various artist release groups
 			const trackArtistReleaseIds = trackArtistReleases.map(
@@ -769,54 +865,67 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 			measureStart(
 				"setMusicBrainzRecordingsReleasesReleaseGroups trackArtistOnlyRecordingIds"
 			);
-			// TODO improve performance of below. The if () return; are meant to improve performance, but don't do too much
-			trackArtistOnlyRecordingIds.value = recordingIds.value.filter(
-				recordingId => {
-					let dontInclude = false;
-					trackArtistReleaseIds.forEach(releaseId => {
-						if (dontInclude) return;
-						const release = releaseMap.value[releaseId];
-						release.media.forEach(media => {
-							if (dontInclude) return;
-							media.tracks?.forEach(track => {
-								if (dontInclude) return;
-								const { recording } = track;
-								if (recording.id === recordingId)
-									dontInclude = true;
-							});
-						});
+			// TODO improve performance of below if it's still needed
+			const _trackArtistOnlyRecordingIdsDontIncludeMap = {};
+			const _trackArtistOnlyRecordingIdsIncludeMap = {};
+			recordingIds.value.forEach(recordingId => {
+				_trackArtistOnlyRecordingIdsDontIncludeMap[recordingId] = false;
+				_trackArtistOnlyRecordingIdsIncludeMap[recordingId] = false;
+			});
+			trackArtistReleaseIds.forEach(releaseId => {
+				const release = releaseMap.value[releaseId];
+				release.media.forEach(media => {
+					media.tracks?.forEach(track => {
+						const { recording } = track;
+						_trackArtistOnlyRecordingIdsDontIncludeMap[
+							recording.id
+						] = true;
 					});
-
-					let include = false;
-					nonTrackArtistReleaseIds.forEach(releaseId => {
-						if (include) return;
-						const release = releaseMap.value[releaseId];
-						release.media.forEach(media => {
-							if (include) return;
-							media.tracks?.forEach(track => {
-								if (include) return;
-								const { recording } = track;
-								if (recording.id === recordingId)
-									include = true;
-							});
-						});
+				});
+			});
+			nonTrackArtistReleaseIds.forEach(releaseId => {
+				const release = releaseMap.value[releaseId];
+				release.media.forEach(media => {
+					media.tracks?.forEach(track => {
+						const { recording } = track;
+						_trackArtistOnlyRecordingIdsIncludeMap[recording.id] =
+							true;
 					});
-
-					// TODO use the below to test if the above is all needed
-					// console.log(
-					// 	`dontInclude: ${dontInclude}, include: ${include}, return: ${dontInclude && !include}`
-					// );
-
-					//
-					return dontInclude && !include;
-
-					// return dontInclude;
+				});
+			});
+			trackArtistOnlyRecordingIds.value = recordingIds.value.filter(
+				recordingId => {
+					if (
+						_trackArtistOnlyRecordingIdsIncludeMap[recordingId] &&
+						_trackArtistOnlyRecordingIdsDontIncludeMap[recordingId]
+					) {
+						console.log(recordingId);
+					}
+					return (
+						_trackArtistOnlyRecordingIdsDontIncludeMap[
+							recordingId
+						] &&
+						!_trackArtistOnlyRecordingIdsIncludeMap[recordingId]
+					);
 				}
 			);
+
 			measureFinish(
 				"setMusicBrainzRecordingsReleasesReleaseGroups trackArtistOnlyRecordingIds"
 			);
-			console.log(trackArtistOnlyRecordingIds.value);
+
+			measureStart(
+				"setMusicBrainzRecordingsReleasesReleaseGroups set recordingEntities"
+			);
+			recordingEntities.value = sortedRecordings.value.map(recording => ({
+				type: "single",
+				key: recording.id,
+				sortTitle: recording.title,
+				recording
+			}));
+			measureFinish(
+				"setMusicBrainzRecordingsReleasesReleaseGroups set recordingEntities"
+			);
 
 			measureFinish("setMusicBrainzRecordingsReleasesReleaseGroups");
 		};
@@ -1054,6 +1163,231 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 			}
 		);
 
+		const toggleCollapseRecordingEntityGroup = recordingEntityGroupKey => {
+			measureStart("toggleCollapseRecordingEntityGroup");
+			console.log(
+				`toggleCollapseRecordingEntityGroup: ${recordingEntityGroupKey} - new value: ${!collapsedRecordingEntityGroupKeys.value[recordingEntityGroupKey]}`
+			);
+			collapsedRecordingEntityGroupKeys.value[recordingEntityGroupKey] =
+				!collapsedRecordingEntityGroupKeys.value[
+					recordingEntityGroupKey
+				];
+			measureFinish("toggleCollapseRecordingEntityGroup");
+		};
+
+		const recordingEntitiesCollapseAll = () => {
+			measureStart("recordingEntitiesCollapseAll");
+			console.log("recordingEntitiesCollapseAll");
+			const _collapsedRecordingEntityGroupKeys = {};
+			recordingEntities.value.forEach(recordingEntity => {
+				if (recordingEntity.type !== "group") return;
+				_collapsedRecordingEntityGroupKeys[recordingEntity.key] = true;
+			});
+			collapsedRecordingEntityGroupKeys.value =
+				_collapsedRecordingEntityGroupKeys;
+			measureFinish("recordingEntitiesCollapseAll");
+		};
+
+		const recordingEntitiesExpandAll = () => {
+			measureStart("recordingEntitiesExpandAll");
+			console.log("recordingEntitiesExpandAll");
+			const _collapsedRecordingEntityGroupKeys = {};
+			recordingEntities.value.forEach(recordingEntity => {
+				if (recordingEntity.type !== "group") return;
+				_collapsedRecordingEntityGroupKeys[recordingEntity.key] = false;
+			});
+			collapsedRecordingEntityGroupKeys.value =
+				_collapsedRecordingEntityGroupKeys;
+			measureFinish("recordingEntitiesExpandAll");
+		};
+
+		const recordingEntitiesGroupAuto = () => {
+			measureStart("recordingEntitiesGroupAuto");
+			const _recordingGroupMap: {
+				[recordingGroupKey: string]: RecordingTemp[];
+			} = {};
+			const _recordingEntities = [];
+
+			sortedRecordings.value.forEach(recording => {
+				const recordingGroupKey =
+					musicbrainzRecordingTitleNormalizedNewExtra(
+						recording.title
+					);
+				if (!_recordingGroupMap[recordingGroupKey])
+					_recordingGroupMap[recordingGroupKey] = [];
+				_recordingGroupMap[recordingGroupKey].push(recording);
+			});
+
+			Object.keys(_recordingGroupMap).forEach(recordingGroupKey => {
+				const recordingGroup = _recordingGroupMap[recordingGroupKey];
+				if (recordingGroup.length === 1)
+					delete _recordingGroupMap[recordingGroupKey];
+			});
+			const groupedRecordingIds = Object.values(_recordingGroupMap)
+				.map(recordingGroup =>
+					recordingGroup.map(recording => recording.id)
+				)
+				.flat();
+			const ungroupedRecordings = sortedRecordings.value.filter(
+				recording => !groupedRecordingIds.includes(recording.id)
+			);
+
+			Object.keys(_recordingGroupMap).forEach(recordingGroupKey => {
+				const recordingGroup = _recordingGroupMap[recordingGroupKey];
+				_recordingEntities.push({
+					type: "group",
+					key: recordingGroup[0].id,
+					sortTitle: recordingGroupKey,
+					title: musicbrainzRecordingTitleNormalizedNew(
+						recordingGroup[0].title
+					),
+					recordings: recordingGroup
+				});
+			});
+			ungroupedRecordings.forEach(recording => {
+				_recordingEntities.push({
+					type: "single",
+					key: recording.id,
+					sortTitle: musicbrainzRecordingTitleNormalizedNewExtra(
+						recording.title
+					),
+					recording
+				});
+			});
+
+			recordingEntities.value = _recordingEntities.sort();
+			console.log(_recordingEntities);
+			measureFinish("recordingEntitiesGroupAuto");
+			recordingEntitiesCollapseAll();
+		};
+
+		const sortedRecordingEntities = computed(() => {
+			measureStart("computed sortedRecordingEntities");
+			const result = recordingEntities.value.toSorted(
+				(recordingEntityA, recordingEntityB) =>
+					recordingEntityA.sortTitle.localeCompare(
+						recordingEntityB.sortTitle
+					)
+			);
+			measureFinish("computed sortedRecordingEntities");
+
+			return result;
+		});
+
+		// --------- videos
+		const toggleCollapseVideoEntityGroup = videoEntityGroupKey => {
+			measureStart("toggleCollapseVideoEntityGroup");
+			console.log(
+				`toggleCollapseVideoEntityGroup: ${videoEntityGroupKey} - new value: ${!collapsedVideoEntityGroupKeys.value[videoEntityGroupKey]}`
+			);
+			collapsedVideoEntityGroupKeys.value[videoEntityGroupKey] =
+				!collapsedVideoEntityGroupKeys.value[videoEntityGroupKey];
+			measureFinish("toggleCollapseVideoEntityGroup");
+		};
+
+		const videoEntitiesCollapseAll = () => {
+			measureStart("videoEntitiesCollapseAll");
+			console.log("videoEntitiesCollapseAll");
+			const _collapsedVideoEntityGroupKeys = {};
+			videoEntities.value.forEach(videoEntity => {
+				if (videoEntity.type !== "group") return;
+				_collapsedVideoEntityGroupKeys[videoEntity.key] = true;
+			});
+			collapsedVideoEntityGroupKeys.value =
+				_collapsedVideoEntityGroupKeys;
+			measureFinish("videoEntitiesCollapseAll");
+		};
+
+		const videoEntitiesExpandAll = () => {
+			measureStart("videoEntitiesExpandAll");
+			console.log("videoEntitiesExpandAll");
+			const _collapsedVideoEntityGroupKeys = {};
+			videoEntities.value.forEach(videoEntity => {
+				if (videoEntity.type !== "group") return;
+				_collapsedVideoEntityGroupKeys[videoEntity.key] = false;
+			});
+			collapsedVideoEntityGroupKeys.value =
+				_collapsedVideoEntityGroupKeys;
+			measureFinish("videoEntitiesExpandAll");
+		};
+
+		const videoEntitiesGroupAuto = () => {
+			measureStart("videoEntitiesGroupAuto");
+			const _videoIdGroupMap: {
+				[videoGroupKey: string]: string[];
+			} = {};
+			const _videoEntities = [];
+
+			youtubeVideoIds.value.forEach(videoId => {
+				const videoGroupKey = youtubeVideoMap.value[videoId].title
+					.toLowerCase()
+					.replace(new RegExp(`.*${artist.value.name}.* - `, "i"), "")
+					.replace(new RegExp(` - .*${artist.value.name}.*`, "i"), "")
+					.replaceAll(/\(.+\)/g, "")
+					.replaceAll(/\[.+\]/g, "")
+					.replaceAll(/\{.+\}/g, "")
+					.replaceAll(/-[\p{L}0-9 ]+-/gu, "") // For things like "Some title -Instrumental-"
+					.replaceAll(" - demo", "")
+					.replaceAll(/[^\p{L}0-9]/gu, "") // Also removes spaces
+					.trim();
+				if (!_videoIdGroupMap[videoGroupKey])
+					_videoIdGroupMap[videoGroupKey] = [];
+				_videoIdGroupMap[videoGroupKey].push(videoId);
+			});
+
+			Object.keys(_videoIdGroupMap).forEach(videoGroupKey => {
+				const videoGroup = _videoIdGroupMap[videoGroupKey];
+				if (videoGroup.length === 1)
+					delete _videoIdGroupMap[videoGroupKey];
+			});
+			const groupedVideoIds = Object.values(_videoIdGroupMap)
+				// .map(videoGroup =>
+				// 	videoGroup.map(video => video.id)
+				// )
+				.flat();
+			const ungroupedVideoIds = youtubeVideoIds.value.filter(
+				videoId => !groupedVideoIds.includes(videoId)
+			);
+
+			Object.keys(_videoIdGroupMap).forEach(videoGroupKey => {
+				const videoIdGroup = _videoIdGroupMap[videoGroupKey];
+				_videoEntities.push({
+					type: "group",
+					key: `${videoIdGroup[0]}-group`,
+					sortTitle: videoGroupKey,
+					title: youtubeVideoMapAdjusted.value[videoIdGroup[0]]
+						.titleNormalizedNew,
+					videoIds: videoIdGroup
+				});
+			});
+			ungroupedVideoIds.forEach(videoId => {
+				_videoEntities.push({
+					type: "single",
+					key: videoId,
+					sortTitle:
+						youtubeVideoMapAdjusted.value[videoId]
+							.titleNormalizedNewExtra,
+					videoId
+				});
+			});
+
+			videoEntities.value = _videoEntities.sort();
+			console.log(_videoEntities);
+			measureFinish("videoEntitiesGroupAuto");
+			videoEntitiesCollapseAll();
+		};
+
+		const sortedVideoEntities = computed(() => {
+			measureStart("computed sortedVideoEntities");
+			const result = videoEntities.value.toSorted(
+				(videoEntityA, videoEntityB) =>
+					videoEntityA.sortTitle.localeCompare(videoEntityB.sortTitle)
+			);
+			measureFinish("computed sortedVideoEntities");
+
+			return result;
+		});
+
 		return {
 			artist,
 			youtubeChannels,
@@ -1099,6 +1433,12 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 			autoLinkedRecordingsMap,
 			linkedRecordingsMap,
 			showOnlyVideosWithoutAutoLinks,
+			sortedRecordingEntities,
+			recordingEntities,
+			collapsedRecordingEntityGroupKeys,
+			sortedVideoEntities,
+			videoEntities,
+			collapsedVideoEntityGroupKeys,
 			recordingsPreviousPage,
 			recordingsNextPage,
 			// methods
@@ -1111,6 +1451,14 @@ export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
 			toggleHideRecording,
 			toggleShowRecordingReleaseGroup,
 			toggleLockRecording,
-			resetLinkingData
+			resetLinkingData,
+			recordingEntitiesGroupAuto,
+			toggleCollapseRecordingEntityGroup,
+			recordingEntitiesCollapseAll,
+			recordingEntitiesExpandAll,
+			videoEntitiesGroupAuto,
+			toggleCollapseVideoEntityGroup,
+			videoEntitiesCollapseAll,
+			videoEntitiesExpandAll
 		};
 	});

+ 79 - 0
frontend/src/types/artist.ts

@@ -224,3 +224,82 @@ export interface MusicBrainzArtistTemp {
 		name: string;
 	}[];
 }
+
+// Custom
+export type RecordingEntityGroup = {
+	type: "group";
+	key: string;
+	sortTitle: string;
+	title: string;
+	recordings: RecordingTemp[];
+};
+
+export type RecordingEntitySingle = {
+	type: "single";
+	key: string;
+	sortTitle: string;
+	recording: RecordingTemp;
+};
+
+export type RecordingEntity = RecordingEntityGroup | RecordingEntitySingle;
+
+export type VideoEntityGroup = {
+	type: "group";
+	key: string;
+	sortTitle: string;
+	title: string;
+	videoIds: string[];
+};
+
+export type VideoEntitySingle = {
+	type: "single";
+	key: string;
+	sortTitle: string;
+	videoId: string;
+};
+
+export type VideoEntity = VideoEntityGroup | VideoEntitySingle;
+
+// For entity filter group view
+export type Source = {
+	id: string;
+	data: {
+		// TODO later change this to unknown
+		name: string;
+	};
+};
+
+export type SourceItem = {
+	id: string;
+	sourceId: string;
+	data: {
+		// TODO later change this to unknown
+		name: string;
+		number: number;
+	};
+};
+
+export type Section = {
+	id: string;
+	name: string;
+};
+
+export type EntitySingle = {
+	type: "single";
+	id: string;
+	sourceItemId: string;
+};
+
+export type EntityGroup = {
+	type: "group";
+	id: string;
+	sourceItemIds: string[];
+};
+
+export type Entity = EntitySingle | EntityGroup;
+
+export type Filter = {
+	id: string;
+	title: string;
+	query: string;
+};

+ 1 - 0
frontend/src/types/global.d.ts

@@ -22,6 +22,7 @@ declare global {
 		  };
 	var soundcloudIframeLockUuid: string;
 	var soundcloudIframeLockUuids: Set<string>;
+	var jsonata: any;
 }
 
 export {};

+ 27 - 1
frontend/src/utils.ts

@@ -15,7 +15,7 @@ export default {
 	formatTime: (originalDuration: number) => {
 		if (originalDuration <= 0) return "0:00";
 
-		let duration = originalDuration;
+		let duration = Math.ceil(originalDuration);
 		let hours: number | string = Math.floor(duration / (60 * 60));
 		duration -= hours * 60 * 60;
 		let minutes: number | string = Math.floor(duration / 60);
@@ -87,6 +87,15 @@ export default {
 	},
 	// Based on https://stackoverflow.com/a/42235254 - ported to JavaScript
 	getEmojiFlagForCountryCode: (countryCode: string) => {
+		// XE = Europe
+		// XW = World
+		// XX = no country, custom, questionmark
+		if (countryCode.toLowerCase() === "xe") countryCode = "EU";
+		if (countryCode.toLowerCase() === "xw")
+			return String.fromCodePoint(0x1f30f);
+		if (countryCode.toLowerCase() === "xx")
+			return String.fromCodePoint(0x2753);
+
 		const flagOffset = 0x1f1e6;
 		const asciiOffset = 0x41;
 
@@ -97,5 +106,22 @@ export default {
 		return (
 			String.fromCodePoint(firstChar) + String.fromCodePoint(secondChar)
 		);
+	},
+	measureStart: name => {
+		performance.mark(`${name}-started`);
+		console.log(`[MEASURE] START: ${name}`);
+	},
+	measureFinish: name => {
+		performance.mark(`${name}-finished`);
+		const measure = performance.measure(
+			`${name}-duration`,
+			`${name}-started`,
+			`${name}-finished`
+		);
+		console.log(`[MEASURE] FINISH: ${name} - ${measure.duration}ms`);
+	},
+	randomNumber: (min: number, max: number): number => {
+		if (min - max > 0) return -1;
+		return Math.floor(min + Math.random() * max);
 	}
 };