浏览代码

feature: WIP basic early version of modal to import artists using YT and MB data for data

Kristian Vos 1 月之前
父节点
当前提交
a8cc933b54

+ 1 - 0
frontend/src/components/ModalManager.vue

@@ -16,6 +16,7 @@ const modalComponents = shallowRef(
 		editNews: "EditNews.vue",
 		editArtist: "EditArtist.vue",
 		manageStation: "ManageStation/index.vue",
+		importArtistMB: "ImportArtistMB.vue",
 		editPlaylist: "EditPlaylist/index.vue",
 		createPlaylist: "CreatePlaylist.vue",
 		report: "Report.vue",

+ 114 - 0
frontend/src/components/TempYoutubeChannelCard.vue

@@ -0,0 +1,114 @@
+<script setup lang="ts">
+import utils from "@/utils";
+
+defineProps({
+	youtubeChannel: { type: Object, default: () => ({}) }
+});
+</script>
+
+<template>
+	<div class="youtube-channel flex flex-row" v-if="youtubeChannel.rawData">
+		<img
+			:src="youtubeChannel.rawData.snippet.thumbnails.high.url"
+			:alt="`${youtubeChannel.title} art`"
+			loading="lazy"
+		/>
+		<div class="text flex flex-column">
+			<p>{{ youtubeChannel.title }}</p>
+			<p>
+				{{
+					utils.getNumberRounded(
+						youtubeChannel.rawData.statistics.subscriberCount
+					)
+				}}
+				subscribers
+			</p>
+			<p>{{ youtubeChannel.rawData.statistics.videoCount }} videos</p>
+			<p>
+				{{
+					utils.getNumberRounded(
+						youtubeChannel.rawData.statistics.viewCount
+					)
+				}}
+				views
+			</p>
+		</div>
+		<div class="flex flex-column button-icons">
+			<a
+				target="_blank"
+				:href="`https://www.youtube.com/channel/${youtubeChannel.channelId}`"
+				content="View on Youtube"
+				v-tippy
+			>
+				<div class="youtube-icon"></div>
+			</a>
+			<tippy theme="info">
+				<i class="material-icons">info</i>
+
+				<template #content>
+					<div>
+						<ul>
+							<li>
+								<p>
+									Channel ID: {{ youtubeChannel.channelId }}
+								</p>
+							</li>
+							<li>
+								<p>
+									Custom URL: {{ youtubeChannel.customUrl }}
+								</p>
+							</li>
+							<li>
+								<p>Title: {{ youtubeChannel.title }}</p>
+							</li>
+							<li>
+								<p>
+									Branding country:
+									{{
+										youtubeChannel.rawData.brandingSettings
+											.channel.country
+									}}
+								</p>
+							</li>
+							<li>
+								<p>Last data fetch: unknown/TODO</p>
+							</li>
+						</ul>
+					</div>
+				</template>
+			</tippy>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.youtube-channel {
+	padding: 8px;
+	gap: 8px;
+	border-radius: @border-radius;
+	background-color: var(--dark-grey-2);
+
+	img {
+		height: 76px;
+	}
+
+	.text {
+		flex: 1;
+
+		p:first-child {
+			font-size: 16px;
+		}
+
+		p:nth-child(2),
+		p:nth-child(3),
+		p:nth-child(4) {
+			font-size: 12px;
+		}
+	}
+
+	.button-icons {
+		button {
+		}
+	}
+}
+</style>

+ 207 - 0
frontend/src/components/TempYoutubeVideoCard.vue

@@ -0,0 +1,207 @@
+<script setup lang="ts">
+import utils from "@/utils";
+
+defineProps({
+	youtubeVideo: { type: Object, default: () => ({}) },
+	hideYoutubeInfo: { type: Boolean, default: true }
+});
+</script>
+
+<template>
+	<div
+		class="flex flex-row youtube-video"
+		v-if="youtubeVideo.rawData"
+		:class="{ 'youtube-video-hide': youtubeVideo.hide }"
+	>
+		<img
+			:src="youtubeVideo.rawData.snippet.thumbnails.medium.url"
+			loading="lazy"
+			alt=""
+		/>
+		<div class="text flex flex-column">
+			<!-- <p>{{ youtubeVideo.title }}</p>
+			<p>
+				{{
+					utils.getNumberRounded(
+						youtubeVideo.rawData.statistics.viewCount
+					)
+				}}
+				views - {{ youtubeVideo.author }}
+			</p> -->
+
+			<p class="title">{{ youtubeVideo.title }}</p>
+			<p class="small-title">{{ youtubeVideo.rawData.snippet.title }}</p>
+			<p>
+				{{
+					utils.getNumberRounded(
+						youtubeVideo.rawData.statistics.viewCount
+					)
+				}}
+				views - {{ youtubeVideo.author }}
+			</p>
+		</div>
+		<div class="button-icons flex flex-column">
+			<a
+				target="_blank"
+				:href="`https://www.youtube.com/watch?v=${youtubeVideo.youtubeId}`"
+				content="View on Youtube"
+				v-tippy
+			>
+				<div class="youtube-icon"></div>
+			</a>
+			<p class="youtube-video-duration">
+				{{ utils.formatTime(youtubeVideo.duration) }}
+			</p>
+			<tippy theme="info" v-if="!hideYoutubeInfo">
+				<i class="material-icons">info</i>
+
+				<template #content>
+					<div>
+						<ul>
+							<li>
+								<p>
+									Title:
+									{{ youtubeVideo.title }}
+								</p>
+							</li>
+							<li>
+								<p>
+									Author:
+									{{ youtubeVideo.author }}
+								</p>
+							</li>
+							<li>
+								<p>
+									Duration:
+									{{ youtubeVideo.duration }}
+								</p>
+							</li>
+							<li>
+								<p>
+									Uploaded at:
+									{{ youtubeVideo.uploadedAt }}
+								</p>
+							</li>
+							<li>
+								<p>
+									YouTube ID:
+									{{ youtubeVideo.youtubeId }}
+								</p>
+							</li>
+							<li>
+								<p>
+									Comment #:
+									{{
+										youtubeVideo.rawData.statistics
+											.commentCount
+									}}
+								</p>
+							</li>
+							<li>
+								<p>
+									Like #:
+									{{
+										youtubeVideo.rawData.statistics
+											.likeCount
+									}}
+								</p>
+							</li>
+							<li>
+								<p>
+									View #:
+									{{
+										youtubeVideo.rawData.statistics
+											.viewCount
+									}}
+								</p>
+							</li>
+							<li>
+								<p>
+									Category ID:
+									{{
+										youtubeVideo.rawData.snippet.categoryId
+									}}
+								</p>
+							</li>
+							<li
+								v-if="
+									youtubeVideo.rawData.contentDetails
+										.regionRestriction?.allowed
+								"
+							>
+								<p>
+									Regions allowed:
+									<span>
+										<span
+											v-for="countryCode in youtubeVideo
+												.rawData.contentDetails
+												.regionRestriction.allowed"
+											:key="countryCode"
+											>{{
+												utils.getEmojiFlagForCountryCode(
+													countryCode
+												)
+											}}</span
+										>
+									</span>
+								</p>
+							</li>
+							<li v-if="youtubeVideo.author.includes(' - Topic')">
+								<p>
+									Tags:
+									{{
+										youtubeVideo.rawData.snippet.tags.join(
+											" - "
+										)
+									}}
+								</p>
+							</li>
+						</ul>
+					</div>
+				</template>
+			</tippy>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.youtube-video {
+	background-color: var(--dark-grey-2);
+	border-radius: @border-radius;
+	gap: 8px;
+	min-width: 350px;
+
+	img {
+		height: 72px;
+	}
+
+	.text {
+		padding: 4px 0;
+		justify-content: space-between;
+		flex: 1;
+
+		p.title {
+			font-size: 14px;
+		}
+
+		p.small-title {
+			font-size: 11px;
+			opacity: 0.6;
+		}
+
+		p:last-child {
+			font-size: 10px;
+		}
+	}
+
+	.button-icons {
+		.youtube-video-duration {
+			font-size: 10px;
+		}
+	}
+
+	&.youtube-video-hide {
+		opacity: 0.5;
+	}
+}
+</style>

+ 896 - 0
frontend/src/components/modals/ImportArtistMB.vue

@@ -0,0 +1,896 @@
+<script setup lang="ts">
+/* eslint vue/no-unused-vars: 1 */
+/* eslint @typescript-eslint/no-unused-vars: 1 */
+
+import Toast from "toasters";
+import {
+	defineAsyncComponent,
+	ref,
+	reactive,
+	computed,
+	onMounted,
+	watch
+} from "vue";
+import { GenericResponse } from "@musare_types/actions/GenericActions";
+import VueJsonPretty from "vue-json-pretty";
+import { storeToRefs } from "pinia";
+import { useForm } from "@/composables/useForm";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useModalsStore } from "@/stores/modals";
+import { useLongJobsStore } from "@/stores/longJobs";
+import { useImportArtistStore } from "@/stores/importArtist";
+import "vue-json-pretty/lib/styles.css";
+import utils from "@/utils";
+// import { TempDraggableList } from "vue-draggable-list";
+const TempDraggableList = defineAsyncComponent(
+	() => import("@/components/TempDraggableList.vue")
+);
+const TempYoutubeChannelCard = defineAsyncComponent(
+	() => import("@/components/TempYoutubeChannelCard.vue")
+);
+const TempYoutubeVideoCard = defineAsyncComponent(
+	() => import("@/components/TempYoutubeVideoCard.vue")
+);
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const SaveButton = defineAsyncComponent(
+	() => import("@/components/SaveButton.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true },
+	artistId: { type: String, default: null }
+});
+
+const importArtistStore = useImportArtistStore({
+	modalUuid: props.modalUuid
+})();
+const {
+	artist,
+	youtubeChannels,
+	hideYoutubeChannel,
+	youtubeVideoMap,
+	youtubeVideoIds,
+	linkedVideos,
+	recordingsReleasesReleaseGroups,
+	filterYoutubeVideos,
+	recordingFilters,
+	recordingSort,
+	youtubeVideosSort,
+	youtubeVideoTitleChanges,
+	youtubeVideoFilters,
+	hideYoutubeInfo,
+	hideMusicbrainzInfo,
+	youtubeChannelIds,
+	// getters
+	recordings,
+	youtubeVideoMapAdjusted,
+	filteredRecordings,
+	filteredYoutubeVideoIds,
+	filteredYoutubeVideosIdsLength
+} = storeToRefs(importArtistStore);
+const {
+	setArtist,
+	setYoutubeChannels,
+	setYoutubeVideos,
+	setMusicBrainzRecordingsReleasesReleaseGroups,
+	linkVideos
+} = importArtistStore;
+
+const { socket } = useWebsocketsStore();
+
+const { closeCurrentModal } = useModalsStore();
+
+onMounted(() => {
+	socket.onConnect(() => {
+		if (props.artistId) {
+			socket.dispatch(`artists.getArtistFromId`, props.artistId, res => {
+				// res: GetArtistResponse
+				if (res.status === "success") {
+					setArtist(res.data.artist);
+
+					socket.dispatch(
+						`youtube.getChannelsById`,
+						youtubeChannelIds.value,
+						res => {
+							// TODO handle fail
+							const { data } = res;
+
+							setYoutubeChannels(data);
+						}
+					);
+
+					socket.dispatch(
+						`youtube.getVideosForChannelIds`,
+						youtubeChannelIds.value,
+						res => {
+							console.log(333222111, res);
+
+							const { data } = res;
+
+							setYoutubeVideos(data);
+						}
+					);
+
+					socket.dispatch(
+						`albums.getMusicBrainzRecordingsReleasesReleaseGroups`,
+						artist.value.musicbrainzIdentifier,
+						res => {
+							const { data } = res;
+
+							setMusicBrainzRecordingsReleasesReleaseGroups(
+								data.releases
+							);
+						}
+					);
+				} else {
+					new Toast("Artist with that ID not found.");
+					closeCurrentModal();
+				}
+			});
+		}
+	});
+});
+
+const drag = ref(false);
+
+const repositionYoutubeVideo = () => {
+	console.log("Reposition youtube video");
+};
+</script>
+
+<template>
+	<modal
+		class="import-artist-mb-modal"
+		title="Import Artist MB"
+		:size="'wide'"
+		:split="true"
+	>
+		<template #body>
+			<div class="columns flex flex-row w-full">
+				<div class="column-left flex flex-column">
+					<div class="card artist-info-card flex flex-row">
+						<img src="/assets/notes.png" alt="Temp" />
+						<div>
+							<p>{{ artist.name }}</p>
+							<p>
+								MB:
+								<a
+									:href="`https://musicbrainz.org/artist/${artist.musicbrainzIdentifier}`"
+									>{{ artist.musicbrainzIdentifier }}</a
+								>
+							</p>
+						</div>
+					</div>
+					<div class="card recordings-card flex flex-column">
+						<div
+							class="flex flex-row"
+							style="justify-content: space-between"
+						>
+							<p class="card-title">Recordings to display</p>
+							<button class="button temp-button">
+								<i class="material-icons">filter_alt</i>
+							</button>
+						</div>
+						<div>
+							Filter
+							<div>
+								<input
+									type="checkbox"
+									name="hideNullLength"
+									id="hideNullLength"
+									v-model="recordingFilters.hideNullLength"
+								/>
+								<label for="hideNullLength"
+									>Hide null length</label
+								>
+							</div>
+							<div>
+								<input
+									type="checkbox"
+									name="hidePartOf"
+									id="hidePartOf"
+									v-model="recordingFilters.hidePartOf"
+								/>
+								<label for="hidePartOf">Hide part of</label>
+							</div>
+						</div>
+						<div>
+							Sort
+							<div>
+								<select
+									name="recordingSort"
+									id="recordingSort"
+									v-model="recordingSort"
+								>
+									<option value="title">Title</option>
+									<option value="length">Length</option>
+								</select>
+							</div>
+						</div>
+						<div>
+							Hide info
+							<div>
+								<input
+									type="checkbox"
+									name="hideMusicbrainzInfo"
+									id="hideMusicbrainzInfo"
+									v-model="hideMusicbrainzInfo"
+								/>
+								<label for="hideMusicbrainzInfo"
+									>Hide MusicBrainz info</label
+								>
+							</div>
+						</div>
+						<div class="flex flex-column recordings">
+							<div
+								v-for="recording in filteredRecordings"
+								:key="recording.id"
+								class="recording flex flex-row"
+								:class="recording.hide ? 'recording-hide' : ''"
+							>
+								<p>
+									<span>{{ recording.title }}</span>
+									<span v-if="recording.disambiguation"
+										>({{ recording.disambiguation }})</span
+									>
+								</p>
+								<span>{{
+									utils.formatTime(recording.length / 1000)
+								}}</span>
+								<div class="icons">
+									<tippy
+										theme="info"
+										v-if="!hideMusicbrainzInfo"
+									>
+										<i class="material-icons">info</i>
+
+										<template #content>
+											<div>
+												<ul>
+													<li>
+														<p>
+															Title:
+															{{
+																recording.title
+															}}
+														</p>
+													</li>
+													<li>
+														<p>
+															Video:
+															{{
+																recording.video
+																	? "true"
+																	: "false"
+															}}
+														</p>
+													</li>
+													<li>
+														<p>
+															ID:
+															{{ recording.id }}
+														</p>
+													</li>
+													<li>
+														<p>
+															Length:
+															{{
+																recording.length
+															}}
+														</p>
+													</li>
+													<li
+														v-if="
+															recording.disambiguation
+														"
+													>
+														<p>
+															Disambiguation:
+															{{
+																recording.disambiguation
+															}}
+														</p>
+													</li>
+												</ul>
+											</div>
+										</template>
+									</tippy>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="column-center flex flex-column">
+					<div class="card linking-card flex flex-column">
+						<div
+							class="flex flex-row"
+							style="justify-content: space-between"
+						>
+							<p class="card-title">Linking</p>
+							<div class="button-icons flex flex-row">
+								<i class="material-icons">settings</i>
+								<i class="material-icons">lock_open</i>
+							</div>
+						</div>
+						<div>
+							<button
+								class="button is-primary"
+								@click="linkVideos"
+							>
+								Link
+							</button>
+						</div>
+						<div class="recordings flex flex-column">
+							<div
+								class="recording flex flex-column"
+								v-for="recording in filteredRecordings"
+								:key="recording.id"
+								v-show="!recording.hide"
+							>
+								<div class="flex flex-row">
+									<p>
+										{{ recording.title }}
+										<span v-if="recording.disambiguation"
+											>({{
+												recording.disambiguation
+											}})</span
+										>
+									</p>
+									<span>{{
+										utils.formatTime(
+											recording.length / 1000
+										)
+									}}</span>
+									<div class="button-icons flex flex-row">
+										<tippy
+											theme="info"
+											v-if="!hideMusicbrainzInfo"
+										>
+											<i class="material-icons">info</i>
+
+											<template #content>
+												<div>
+													<ul>
+														<li>
+															<p>
+																Title:
+																{{
+																	recording.title
+																}}
+															</p>
+														</li>
+														<li>
+															<p>
+																Video:
+																{{
+																	recording.video
+																		? "true"
+																		: "false"
+																}}
+															</p>
+														</li>
+														<li>
+															<p>
+																ID:
+																{{
+																	recording.id
+																}}
+															</p>
+														</li>
+														<li>
+															<p>
+																Length:
+																{{
+																	recording.length
+																}}
+															</p>
+														</li>
+														<li
+															v-if="
+																recording.disambiguation
+															"
+														>
+															<p>
+																Disambiguation:
+																{{
+																	recording.disambiguation
+																}}
+															</p>
+														</li>
+													</ul>
+												</div>
+											</template>
+										</tippy>
+										<button class="button temp-button">
+											<i class="material-icons"
+												>lock_open</i
+											>
+										</button>
+									</div>
+								</div>
+								<div class="youtube-videos flex flex-column">
+									<!-- <div class="youtube-video no-youtube-video" v-if="!linkedVideos[recording.id] || linkedVideos[recording.id].length === 0">
+										<p>No videos linked</p>
+									</div> -->
+
+									<temp-draggable-list
+										v-if="linkedVideos[recording.id]"
+										v-model:list="
+											linkedVideos[recording.id]
+										"
+										@start="drag = true"
+										@end="drag = false"
+										@update="repositionYoutubeVideo"
+										group="mb-youtube-videos"
+										:unique="true"
+										debug-name="linked-videos"
+									>
+										<template
+											#item="{
+												element: youtubeVideoId,
+												index
+											}"
+										>
+											<temp-youtube-video-card
+												:youtube-video="
+													youtubeVideoMapAdjusted[
+														youtubeVideoId
+													]
+												"
+												:hide-youtube-info="
+													hideYoutubeInfo
+												"
+												:key="youtubeVideoId"
+											></temp-youtube-video-card>
+										</template>
+										<template #empty-list-placeholder>
+											<div
+												class="youtube-video no-youtube-video"
+											>
+												<p>No videos linked</p>
+											</div>
+										</template>
+									</temp-draggable-list>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="column-right flex flex-column">
+					<div class="card youtube-channels-card">
+						<p class="card-title">
+							YouTube channels / playlists card
+						</p>
+						<div class="youtube-channels flex flex-column">
+							<temp-youtube-channel-card
+								v-for="youtubeChannel in youtubeChannels"
+								:youtube-channel="youtubeChannel"
+								:key="youtubeChannel.channelId"
+							>
+							</temp-youtube-channel-card>
+						</div>
+					</div>
+					<div class="card youtube-videos-card">
+						<p class="card-title">
+							YouTube videos card ({{
+								filteredYoutubeVideosIdsLength
+							}}
+							/ {{ youtubeVideoIds.length }})
+						</p>
+						<div>
+							Filter
+							<div>
+								<input
+									type="checkbox"
+									name="teaser"
+									v-model="youtubeVideoFilters.teaser"
+								/>
+								<label for="teaser">Teaser</label>
+							</div>
+							<div>
+								<input
+									type="checkbox"
+									name="under45"
+									v-model="youtubeVideoFilters.under45"
+								/>
+								<label for="under45">Under 45s</label>
+							</div>
+							<div>
+								<input
+									type="checkbox"
+									name="live"
+									v-model="youtubeVideoFilters.live"
+								/>
+								<label for="live">Live</label>
+							</div>
+							<div>
+								<input
+									type="checkbox"
+									name="tour"
+									v-model="youtubeVideoFilters.tour"
+								/>
+								<label for="tour">Tour</label>
+							</div>
+						</div>
+						<div>
+							Name
+							<div>
+								<input
+									type="checkbox"
+									name="artistDash"
+									v-model="
+										youtubeVideoTitleChanges.artistDash
+									"
+								/>
+								<label for="artistDash">artistDash</label>
+							</div>
+							<div>
+								<input
+									type="checkbox"
+									name="parantheses"
+									v-model="
+										youtubeVideoTitleChanges.parantheses
+									"
+								/>
+								<label for="parantheses">parantheses</label>
+							</div>
+							<div>
+								<input
+									type="checkbox"
+									name="brackets"
+									v-model="youtubeVideoTitleChanges.brackets"
+								/>
+								<label for="brackets">brackets</label>
+							</div>
+							<div>
+								<input
+									type="checkbox"
+									name="commonPhrases"
+									v-model="
+										youtubeVideoTitleChanges.commonPhrases
+									"
+								/>
+								<label for="commonPhrases"
+									>common phrases (official mv, official
+									audio, official video)</label
+								>
+							</div>
+						</div>
+						<div>
+							Sort
+							<div>
+								<select
+									name="youtubeVideosSort"
+									id="youtubeVideosSort"
+									v-model="youtubeVideosSort"
+								>
+									<option value="title">Title</option>
+									<option value="length">Length</option>
+								</select>
+							</div>
+						</div>
+						<div>
+							Hide info
+							<div>
+								<input
+									type="checkbox"
+									name="hideYoutubeInfo"
+									id="hideYoutubeInfo"
+									v-model="hideYoutubeInfo"
+								/>
+								<label for="hideYoutubeInfo"
+									>Hide YouTube info</label
+								>
+							</div>
+						</div>
+						<div>
+							Hide YouTube channels
+							<div
+								v-for="youtubeChannel in youtubeChannels"
+								:key="youtubeChannel.rawData.id"
+							>
+								<input
+									type="checkbox"
+									:name="youtubeChannel.rawData.id"
+									:id="youtubeChannel.rawData.id"
+									v-model="
+										hideYoutubeChannel[
+											youtubeChannel.rawData.id
+										]
+									"
+								/>
+								<label :for="youtubeChannel.rawData.id"
+									>Hide {{ youtubeChannel.title }}</label
+								>
+							</div>
+						</div>
+						<div>
+							Filter
+							<input type="text" v-model="filterYoutubeVideos" />
+						</div>
+
+						<div class="flex flex-column youtube-videos">
+							<temp-draggable-list
+								v-if="filteredYoutubeVideoIds.length > 0"
+								v-model:list="filteredYoutubeVideoIds"
+								@start="drag = true"
+								@end="drag = false"
+								@update="repositionYoutubeVideo"
+								:read-only="true"
+								group="mb-youtube-videos"
+								debug-name="youtube-videos"
+							>
+								<template
+									#item="{ element: youtubeVideoId, index }"
+								>
+									<temp-youtube-video-card
+										:youtube-video="
+											youtubeVideoMapAdjusted[
+												youtubeVideoId
+											]
+										"
+										:hide-youtube-info="hideYoutubeInfo"
+										:key="youtubeVideoId"
+									></temp-youtube-video-card>
+								</template>
+							</temp-draggable-list>
+						</div>
+					</div>
+				</div>
+			</div>
+		</template>
+		<template #footer>
+			<div>
+				<!-- <save-button
+					v-if="!importMode"
+					:default-message="`${createArtist ? 'Create' : 'Update'} Artist`"
+					@clicked="saveArtist()"
+				/>
+				<save-button
+					v-if="!importMode"
+					:default-message="`${createArtist ? 'Create' : 'Update'} and close`"
+					@clicked="saveArtist(true)"
+				/>
+				<button
+					v-if="!createArtist"
+					class="button is-primary"
+					@click="toggleImportMode()"
+				>
+					<span v-if="importMode">Edit Mode</span>
+					<span v-else>Import Mode</span>
+				</button> -->
+			</div>
+		</template>
+	</modal>
+</template>
+
+<style lang="less">
+// .night-mode {
+// 	.edit-artist-modal {
+// 		.vjs-tree-node.is-highlight,
+// 		.vjs-tree-node:hover {
+// 			background: black;
+// 		}
+// 	}
+// }
+
+.import-artist-mb-modal {
+	.modal-card {
+		width: 100% !important;
+		// height: 100%;
+	}
+}
+</style>
+
+<style lang="less" scoped>
+.flex {
+	display: flex;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.flex-row {
+	flex-direction: row;
+}
+
+.w-full {
+	width: 100%;
+}
+
+.card {
+	background-color: var(--dark-grey-3);
+	border-radius: @border-radius;
+}
+
+.columns {
+	gap: 16px;
+	max-height: 100%;
+}
+
+.column-left,
+.column-right {
+	width: 25%;
+	max-height: 100%;
+	overflow: auto;
+}
+
+.column-center {
+	flex: 1;
+}
+
+.column-left,
+.column-center,
+.column-right {
+	gap: 16px;
+}
+
+.artist-info-card {
+	padding: 8px;
+	gap: 8px;
+
+	// Temp
+	img {
+		aspect-ratio: 1/1;
+		max-width: 100px;
+	}
+
+	p:first-of-type {
+		font-size: 20px;
+	}
+
+	p:last-of-type {
+		font-size: 14px;
+	}
+}
+
+.card-title {
+	font-size: 20px;
+	line-height: 24px;
+}
+
+.recordings-card {
+	padding: 8px;
+	height: 100%;
+	gap: 16px;
+	// font-size: 20px;
+
+	.recordings {
+		gap: 8px;
+
+		.recording {
+			background-color: var(--dark-grey-2);
+			border-radius: @border-radius;
+			padding: 8px;
+			font-size: 16px;
+			gap: 8px;
+			align-items: center;
+
+			> p {
+				flex: 1;
+				line-height: 24px;
+				display: flex;
+				flex-direction: column;
+				// gap: 4px;
+
+				span:nth-child(2) {
+					font-size: 12px;
+					line-height: 16px;
+				}
+			}
+
+			.icons {
+				height: 24px;
+			}
+
+			&.recording-hide {
+				opacity: 0.5;
+			}
+		}
+	}
+}
+
+.linking-card {
+	padding: 8px;
+	height: 100%;
+	gap: 16px;
+
+	> div > p {
+		// font-size: 20px;
+		// line-height: 24px;
+
+		.button-icons {
+			gap: 8px;
+			padding: 8px;
+		}
+	}
+
+	.recordings {
+		gap: 8px;
+		overflow: auto;
+		max-height: 100%;
+
+		.recording {
+			background-color: var(--dark-grey-2);
+			border-radius: @border-radius;
+			padding: 8px;
+			gap: 8px;
+
+			> div:first-child {
+				gap: 8px;
+				align-items: center;
+
+				p {
+					font-size: 16px;
+					line-height: 20px;
+					flex: 1;
+
+					span {
+						font-size: 12px;
+						line-height: 20px;
+					}
+				}
+
+				> span {
+					font-size: 14px;
+					line-height: 20px;
+				}
+
+				.button-icons {
+				}
+			}
+
+			.youtube-videos {
+				gap: 8px;
+
+				.youtube-video {
+					background-color: var(--dark-grey);
+					border-radius: @border-radius;
+					gap: 8px;
+					min-width: 350px;
+				}
+
+				.no-youtube-video {
+					padding: 8px;
+				}
+			}
+		}
+	}
+}
+
+.youtube-channels-card {
+	padding: 8px;
+
+	.youtube-channels {
+		gap: 8px;
+		margin-top: 16px;
+	}
+}
+
+.youtube-videos-card {
+	padding: 8px;
+
+	.youtube-videos {
+		gap: 8px;
+		margin-top: 16px;
+
+		.youtube-video {
+			background-color: var(--dark-grey);
+			border-radius: @border-radius;
+			gap: 8px;
+			min-width: 350px;
+		}
+	}
+}
+
+.temp-button {
+	padding: 0;
+	margin: 0;
+	background: none;
+	color: white;
+	outline: none;
+	border: none;
+}
+</style>

+ 362 - 0
frontend/src/stores/importArtist.ts

@@ -0,0 +1,362 @@
+import { defineStore } from "pinia";
+import { ref, computed, reactive, watch } from "vue";
+import {
+	ArtistTemp,
+	YoutubeVideoTemp,
+	ReleaseTemp,
+	YoutubeChannelTemp
+} from "@/types/artist";
+
+const commonPhrases = ["Official MV", "Official Audio", "Official Video"];
+
+const measureStart = name => {
+	performance.mark(`${name}-started`);
+	console.log(`[MEASURE] START: ${name}`);
+};
+
+const measureFinish = name => {
+	performance.mark(`${name}-finished`);
+	const measure = performance.measure(
+		`${name}-duration`,
+		`${name}-started`,
+		`${name}-finished`
+	);
+	console.log(`[MEASURE] FINISH: ${name} - ${measure.duration}ms`);
+};
+
+type LinkedVideos = { [k: string]: string[] };
+type YoutubeVideoMap = { [k: string]: YoutubeVideoTemp };
+type HideYoutubeChannel = { [k: string]: boolean };
+
+export const useImportArtistStore = ({ modalUuid }: { modalUuid: string }) =>
+	defineStore(`importArtist-${modalUuid}`, () => {
+		const artist = ref<ArtistTemp | Partial<ArtistTemp>>({});
+		const youtubeChannels = ref<YoutubeChannelTemp[]>([]);
+		const hideYoutubeChannel = ref<HideYoutubeChannel>({});
+		const youtubeVideoMap = ref<YoutubeVideoMap>({});
+		const youtubeVideoIds = ref<string[]>([]);
+		const linkedVideos = ref<LinkedVideos>({});
+		const recordingsReleasesReleaseGroups = ref<ReleaseTemp[]>([]);
+		const filterYoutubeVideos = ref<string>("");
+		const recordingFilters = reactive<{
+			hideNullLength: boolean;
+			hidePartOf: boolean;
+		}>({
+			hideNullLength: true,
+			hidePartOf: true
+		});
+		const recordingSort = ref<"title" | "length">("title");
+		const youtubeVideosSort = ref<"title" | "length">("title");
+		const youtubeVideoTitleChanges = reactive<{
+			artistDash: boolean;
+			parantheses: boolean;
+			brackets: boolean;
+			commonPhrases: boolean;
+		}>({
+			artistDash: true,
+			parantheses: false,
+			brackets: false,
+			commonPhrases: true
+		});
+		const youtubeVideoFilters = reactive<{
+			teaser: boolean;
+			under45: boolean;
+			live: boolean;
+			tour: boolean;
+		}>({
+			teaser: true,
+			under45: true,
+			live: true,
+			tour: true
+		});
+		const hideYoutubeInfo = ref<boolean>(true);
+		const hideMusicbrainzInfo = ref<boolean>(true);
+		const youtubeChannelIds = ref<string[]>([]);
+
+		const recordings = computed(() => {
+			measureStart("computed recordings");
+			const recordingIds = [];
+			const recordings = [];
+			recordingsReleasesReleaseGroups.value.forEach(release => {
+				release.media.forEach(media => {
+					media.tracks.forEach(track => {
+						const { recording } = track;
+						if (!recordingIds.includes(recording.id)) {
+							recordingIds.push(recording.id);
+							recordings.push(recording);
+						}
+					});
+				});
+			});
+			measureFinish("computed recordings");
+			return recordings;
+		});
+		const youtubeVideoMapAdjusted = computed(() => {
+			measureStart("computed youtubeVideoMapAdjusted");
+			const result = Object.fromEntries(
+				youtubeVideoIds.value.map(youtubeVideoId => {
+					const youtubeVideo = youtubeVideoMap.value[youtubeVideoId];
+
+					let title = youtubeVideo.title.trim();
+
+					if (youtubeVideoTitleChanges.artistDash)
+						title = title.replace(
+							new RegExp(`${artist.value.name} - `, "i"),
+							""
+						);
+					if (youtubeVideoTitleChanges.parantheses)
+						title = title.replaceAll(/\(.+\)/g, "");
+					if (youtubeVideoTitleChanges.brackets)
+						title = title.replaceAll(/\[.+\]/g, "");
+					if (youtubeVideoTitleChanges.commonPhrases) {
+						commonPhrases.forEach(commonPhrase => {
+							title = title.replace(
+								new RegExp(`\\(${commonPhrase}\\)`, "i"),
+								""
+							);
+						});
+					}
+
+					title = title.trim();
+
+					let hide = false;
+					const lowerTitle = title.toLowerCase();
+					if (
+						youtubeVideoFilters.teaser &&
+						lowerTitle.includes("teaser")
+					)
+						hide = true;
+					if (youtubeVideoFilters.live && lowerTitle.includes("live"))
+						hide = true;
+					if (youtubeVideoFilters.tour && lowerTitle.includes("tour"))
+						hide = true;
+					if (
+						youtubeVideoFilters.under45 &&
+						youtubeVideo.duration < 45
+					)
+						hide = true;
+					// if (hideYoutubeChannel.value[youtubeVideo.rawData.snippet.channelId]) hide = true;
+
+					console.log(youtubeVideoId, "hide", hide);
+
+					return [
+						youtubeVideoId,
+						{
+							...youtubeVideo,
+							title,
+							hide
+						}
+					];
+				})
+			);
+
+			measureFinish("computed youtubeVideoMapAdjusted");
+			return result;
+		});
+		const filteredRecordings = computed(() => {
+			measureStart("computed filteredRecordings");
+			const result = recordings.value
+				.map(recording => {
+					let hide = false;
+					if (
+						recordingFilters.hideNullLength &&
+						recording.length === null
+					)
+						hide = true;
+					if (
+						recordingFilters.hidePartOf &&
+						recording.title.toLowerCase().includes("part of")
+					)
+						hide = true;
+					return {
+						...recording,
+						hide
+					};
+				})
+				.sort((recordingA, recordingB) => {
+					if (recordingSort.value === "title")
+						return `${recordingA.title} (${recordingA.disambiguation})`.localeCompare(
+							`${recordingB.title} (${recordingB.disambiguation})`
+						);
+					if (recordingSort.value === "length")
+						return recordingA.length - recordingB.length;
+					return 0;
+				});
+
+			measureFinish("computed filteredRecordings");
+			return result;
+		});
+		const filteredYoutubeVideoIds = computed(() => {
+			measureStart("computed filteredYoutubeVideoIds");
+			const result = youtubeVideoIds.value
+				.filter(youtubeVideoId => {
+					const youtubeVideo = youtubeVideoMap.value[youtubeVideoId];
+					if (
+						hideYoutubeChannel.value[
+							youtubeVideo.rawData.snippet.channelId
+						]
+					)
+						return false;
+					if (filterYoutubeVideos.value)
+						return youtubeVideo.title
+							.trim()
+							.toLowerCase()
+							.includes(filterYoutubeVideos.value.toLowerCase());
+					return true;
+				})
+				.sort((youtubeVideoIdA, youtubeVideoIdB) => {
+					const youtubeVideoA =
+						youtubeVideoMap.value[youtubeVideoIdA];
+					const youtubeVideoB =
+						youtubeVideoMap.value[youtubeVideoIdB];
+					if (youtubeVideosSort.value === "title")
+						return youtubeVideoA.title.localeCompare(
+							youtubeVideoB.title
+						);
+					if (youtubeVideosSort.value === "length")
+						return youtubeVideoA.duration - youtubeVideoB.duration;
+					return 0;
+				})
+				.sort((youtubeVideoIdA, youtubeVideoIdB) => {
+					const youtubeVideoA =
+						youtubeVideoMap.value[youtubeVideoIdA];
+					const youtubeVideoB =
+						youtubeVideoMap.value[youtubeVideoIdB];
+					if (youtubeVideoA.hide && !youtubeVideoB.hide) return 1;
+					if (!youtubeVideoA.hide && youtubeVideoB.hide) return -1;
+					return 0;
+				});
+			measureFinish("computed filteredYoutubeVideoIds");
+			return result;
+		});
+		const filteredYoutubeVideosIdsLength = computed(() => {
+			measureStart("computed filteredYoutubeVideosIdsLength");
+			const result = filteredYoutubeVideoIds.value.filter(
+				youtubeVideoId => !youtubeVideoMap.value[youtubeVideoId].hide
+			).length;
+			measureFinish("computed filteredYoutubeVideosIdsLength");
+			return result;
+		});
+
+		const setArtist = ({
+			name,
+			musicbrainzIdentifier,
+			youtubeChannels
+		}) => {
+			artist.value = {
+				name,
+				musicbrainzIdentifier
+			};
+			youtubeChannelIds.value = youtubeChannels.map(
+				youtubeChannel => youtubeChannel.youtubeChannelId
+			);
+		};
+		const setYoutubeChannels = (_youtubeChannels: YoutubeChannelTemp[]) => {
+			youtubeChannels.value = _youtubeChannels;
+			youtubeChannels.value.forEach(youtubeChannel => {
+				console.log(1221122, youtubeChannel);
+				hideYoutubeChannel.value[youtubeChannel.rawData.id] = true;
+			});
+		};
+		const setYoutubeVideos = (youtubeVideos: YoutubeVideoTemp[]) => {
+			// youtubeVideos = youtubeVideos.slice(0, 10);
+			measureStart("setYoutubeVideos method part one");
+			youtubeVideoIds.value = youtubeVideos.map(
+				youtubeVideo => youtubeVideo.youtubeId
+			);
+			measureFinish("setYoutubeVideos method part one");
+
+			measureStart("setYoutubeVideos method part two");
+			youtubeVideoMap.value = Object.fromEntries(
+				youtubeVideos.map(youtubeVideo => [
+					youtubeVideo.youtubeId,
+					youtubeVideo
+				])
+			);
+			measureFinish("setYoutubeVideos method part two");
+		};
+		const setMusicBrainzRecordingsReleasesReleaseGroups = (
+			_recordingsReleasesReleaseGroups: ReleaseTemp[]
+		) => {
+			// _recordingsReleasesReleaseGroups = _recordingsReleasesReleaseGroups.slice(0, 10);
+			console.log(
+				"albums.getMusicBrainzRecordingsReleasesReleaseGroups",
+				_recordingsReleasesReleaseGroups
+			);
+			recordingsReleasesReleaseGroups.value =
+				_recordingsReleasesReleaseGroups;
+		};
+		const linkVideos = () => {
+			filteredRecordings.value.forEach(recording => {
+				if (recording.hide) return;
+				const lowerRecordingTitle = recording.title
+					.toLowerCase()
+					.trim();
+
+				filteredYoutubeVideoIds.value.forEach(youtubeVideoId => {
+					const youtubeVideo =
+						youtubeVideoMapAdjusted.value[youtubeVideoId];
+					if (youtubeVideo.hide) return;
+					if (
+						lowerRecordingTitle ===
+						youtubeVideo.title.toLowerCase().trim()
+					) {
+						if (
+							linkedVideos.value[recording.id]?.includes(
+								youtubeVideo.youtubeId
+							)
+						)
+							return;
+						if (!linkedVideos.value[recording.id])
+							linkedVideos.value[recording.id] = [];
+						linkedVideos.value[recording.id].push(
+							youtubeVideo.youtubeId
+						);
+					}
+				});
+			});
+			// TODO take into consideration duration
+		};
+
+		watch(
+			() => recordings.value,
+			() => {
+				console.log(111222333, "recordings watch");
+				recordings.value.forEach(recording => {
+					if (!linkedVideos.value[recording.id])
+						linkedVideos.value[recording.id] = [];
+				});
+			}
+		);
+
+		return {
+			artist,
+			youtubeChannels,
+			hideYoutubeChannel,
+			youtubeVideoMap,
+			youtubeVideoIds,
+			linkedVideos,
+			recordingsReleasesReleaseGroups,
+			filterYoutubeVideos,
+			recordingFilters,
+			recordingSort,
+			youtubeVideosSort,
+			youtubeVideoTitleChanges,
+			youtubeVideoFilters,
+			hideYoutubeInfo,
+			hideMusicbrainzInfo,
+			youtubeChannelIds,
+			// getters
+			recordings,
+			youtubeVideoMapAdjusted,
+			filteredRecordings,
+			filteredYoutubeVideoIds,
+			filteredYoutubeVideosIdsLength,
+			// methods
+			setArtist,
+			setYoutubeChannels,
+			setYoutubeVideos,
+			setMusicBrainzRecordingsReleasesReleaseGroups,
+			linkVideos
+		};
+	});

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

@@ -0,0 +1,34 @@
+export interface ArtistTemp {
+	name: string;
+	musicbrainzIdentifier: string;
+}
+
+export interface YoutubeVideoTemp {
+	youtubeId: string;
+	title: string;
+	duration: number;
+	rawData: {
+		snippet: {
+			channelId: string;
+		};
+	};
+	hide: boolean;
+}
+
+export interface ReleaseTemp {
+	media: {
+		tracks: {
+			recording: {
+				id: string;
+			};
+		}[];
+	}[];
+}
+
+export interface YoutubeChannelTemp {
+	channelId: string;
+	title: string;
+	rawData: {
+		id: string;
+	};
+}