1
0

2 Commity d08b95b676 ... 7d980dc571

Autor SHA1 Správa Dátum
  Kristian Vos 7d980dc571 feat: add dedicated create artist modal 1 mesiac pred
  Kristian Vos 4974edc9b8 refactor: add types for various musicbrainz types, and expand youtube video type 1 mesiac pred

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

@@ -16,6 +16,7 @@ const modalComponents = shallowRef(
 		createStation: "CreateStation.vue",
 		editNews: "EditNews.vue",
 		editArtist: "EditArtist.vue",
+		createArtist: "CreateArtist.vue",
 		editAlbum: "EditAlbum.vue",
 		manageStation: "ManageStation/index.vue",
 		importArtistMB: "ImportArtistMB.vue",

+ 357 - 0
frontend/src/components/modals/CreateArtist.vue

@@ -0,0 +1,357 @@
+<script setup lang="ts">
+import Toast from "toasters";
+import { defineAsyncComponent, ref, computed } from "vue";
+import { useRouter } from "vue-router";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useModalsStore } from "@/stores/modals";
+import { MusicBrainzArtistTemp } from "@/types/artist";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+// const SaveButton = defineAsyncComponent(
+// 	() => import("@/components/SaveButton.vue")
+// );
+
+defineProps({
+	modalUuid: { type: String, required: true }
+});
+
+const { socket } = useWebsocketsStore();
+const { closeCurrentModal } = useModalsStore();
+const router = useRouter();
+
+const musicbrainzArtistSearchQuery = ref("");
+const musicbrainzArtists = ref<MusicBrainzArtistTemp[]>([]);
+const selectedMusicbrainzArtistId = ref("");
+
+type GetMusicbrainzArtistResponse = {
+	data: MusicBrainzArtistTemp;
+};
+
+type CreateArtistResponse = {
+	data: {
+		artistId: string;
+	};
+	message: string;
+	status: "success" | "error";
+};
+
+const createArtist = async () => {
+	const musicbrainzIdentifier = selectedMusicbrainzArtistId.value;
+
+	const musicbrainzArtistRes = (await socket.dispatchAsync(
+		"artists.getMusicbrainzArtist",
+		musicbrainzIdentifier
+	)) as { res: GetMusicbrainzArtistResponse };
+	const { data: musicbrainzData } = musicbrainzArtistRes.res;
+	if (!musicbrainzData) {
+		new Toast("Failed to get MusicBrainz artist data.");
+		return null;
+	}
+
+	const { aliases, id } = musicbrainzData;
+	let { name } = musicbrainzData;
+	aliases.forEach(alias => {
+		if (alias.locale === "en" && alias.primary === true) name = alias.name;
+	});
+
+	const data = {
+		name,
+		musicbrainzIdentifier: id,
+		musicbrainzData
+	};
+
+	const createArtistRes = (await socket.dispatchAsync(
+		"artists.create",
+		data
+	)) as { res: CreateArtistResponse };
+	if (createArtistRes.res.status !== "success") {
+		new Toast("Failed to create artist.");
+		new Toast(createArtistRes.res.message);
+		return null;
+	}
+
+	const { artistId } = createArtistRes.res.data;
+
+	return artistId;
+};
+
+const alternativeNameMap = computed(() => {
+	const map = {};
+	musicbrainzArtists.value.forEach(musicbrainzArtist => {
+		const { aliases, id } = musicbrainzArtist;
+		let name;
+		if (!aliases) return;
+		aliases.forEach(alias => {
+			if (alias.locale === "en" && alias.primary === true)
+				name = alias.name;
+		});
+		if (musicbrainzArtist.name === name) return;
+		if (name) map[id] = name;
+	});
+	return map;
+});
+
+const createArtistAndEdit = async () => {
+	const artistId = await createArtist();
+	if (!artistId) return;
+	closeCurrentModal();
+	router.push({
+		path: "/admin/artists",
+		query: {
+			artistId
+		}
+	});
+};
+
+const createArtistAndClose = async () => {
+	const artistId = await createArtist();
+	if (!artistId) return;
+	closeCurrentModal();
+};
+
+const searchMusicbrainzArtists = () => {
+	socket.dispatch(
+		"artists.searchMusicbrainzArtists",
+		musicbrainzArtistSearchQuery.value,
+		res => {
+			// TODO handle error
+			const { data } = res;
+			const { musicbrainzArtists: _musicbrainzArtists } = data;
+			musicbrainzArtists.value = _musicbrainzArtists;
+		}
+	);
+};
+
+const selectMusicbrainzArtist = musicbrainzArtistId => {
+	selectedMusicbrainzArtistId.value = musicbrainzArtistId;
+};
+</script>
+
+<template>
+	<modal
+		class="create-artist-modal"
+		title="Create Artist"
+		size="slim"
+		:split="false"
+	>
+		<template #body>
+			<div class="flex flex-column w-full gap-4">
+				<div class="flex flex-column gap-4 w-full">
+					<div class="control is-grouped w-full">
+						<div
+							class="musicbrainz-artist-search-query-container w-full"
+						>
+							<label class="label"
+								>MusicBrainz artist search query</label
+							>
+							<p class="control has-addons">
+								<input
+									class="input"
+									type="text"
+									v-model="musicbrainzArtistSearchQuery"
+									placeholder="Enter artist name..."
+									@keyup.enter="searchMusicbrainzArtists()"
+								/>
+							</p>
+						</div>
+					</div>
+					<button
+						class="button is-primary"
+						@click="searchMusicbrainzArtists()"
+					>
+						Search MusicBrainz artists
+					</button>
+				</div>
+				<div class="musicbrainz-artists">
+					<div
+						class="musicbrainz-artist"
+						:class="
+							musicbrainzArtist.id === selectedMusicbrainzArtistId
+								? 'active-musicbrainz-artist'
+								: ''
+						"
+						v-for="musicbrainzArtist in musicbrainzArtists"
+						:key="musicbrainzArtist.id"
+						@click="selectMusicbrainzArtist(musicbrainzArtist.id)"
+					>
+						<p class="musicbrainz-artist-title">
+							<a
+								:href="`https://musicbrainz.org/artist/${musicbrainzArtist.id}`"
+								target="_blank"
+							>
+								{{ musicbrainzArtist.name }}
+							</a>
+							<span class="musicbrainz-artist-disambiguation">
+								{{ musicbrainzArtist.disambiguation }}
+							</span>
+							<span>
+								{{ musicbrainzArtist.type }}
+							</span>
+						</p>
+						<p
+							v-if="alternativeNameMap[musicbrainzArtist.id]"
+							class="musicbrainz-artist-alternative-title"
+						>
+							<span class="material-icons">list_alt</span>
+							<span>
+								{{ alternativeNameMap[musicbrainzArtist.id] }}
+							</span>
+						</p>
+						<p
+							v-if="musicbrainzArtist.area"
+							class="musicbrainz-artist-area"
+						>
+							<span class="material-icons">public</span>
+							<span>
+								{{ musicbrainzArtist.area.name }}
+							</span>
+							<span v-if="musicbrainzArtist.area.disambigutation">
+								({{ musicbrainzArtist.area.disambigutation }})
+							</span>
+							<span v-if="musicbrainzArtist.country">
+								({{ musicbrainzArtist.country }})
+							</span>
+						</p>
+						<p v-else-if="musicbrainzArtist.country">
+							<span class="material-icons">public</span>
+							<span>
+								{{ musicbrainzArtist.country }}
+							</span>
+						</p>
+						<p
+							v-if="
+								musicbrainzArtist['life-span'].begin ||
+								musicbrainzArtist['life-span'].ended
+							"
+							class="musicbrainz-artist-life-span"
+						>
+							<span class="material-icons">schedule</span>
+							<span>{{
+								musicbrainzArtist["life-span"].begin ??
+								"unknown"
+							}}</span
+							>-<span>{{
+								musicbrainzArtist["life-span"].ended ?? "now"
+							}}</span>
+						</p>
+						<p class="musicbrainz-artist-score">
+							<span class="material-icons">percent</span>
+							<span> Score: {{ musicbrainzArtist.score }} </span>
+						</p>
+
+						<div
+							class="musicbrainz-artist-tags"
+							v-if="
+								musicbrainzArtist.tags &&
+								musicbrainzArtist.tags.length
+							"
+						>
+							<span class="material-icons">label</span>
+							<span
+								v-for="tag in musicbrainzArtist.tags"
+								:key="tag.name"
+								class="musicbrainz-artist-tag"
+							>
+								{{ tag.name }} ({{ tag.count }})
+							</span>
+						</div>
+					</div>
+				</div>
+			</div>
+		</template>
+		<template #footer>
+			<div>
+				<button
+					v-if="selectedMusicbrainzArtistId"
+					class="button is-primary"
+					@click="createArtistAndEdit()"
+				>
+					<span>Create and edit</span>
+				</button>
+				<button
+					v-if="selectedMusicbrainzArtistId"
+					class="button is-primary"
+					@click="createArtistAndClose()"
+				>
+					<span>Create and close</span>
+				</button>
+			</div>
+		</template>
+	</modal>
+</template>
+
+<style lang="less" scoped>
+.flex {
+	display: flex;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.gap-4 {
+	gap: 1rem;
+}
+
+.w-full {
+	width: 100%;
+}
+
+.musicbrainz-artists {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+
+	.musicbrainz-artist {
+		padding: 8px;
+		border-radius: 8px;
+		background-color: var(--dark-grey);
+		display: flex;
+		flex-direction: column;
+		gap: 4px;
+
+		&.active-musicbrainz-artist {
+			outline: 1px solid var(--white);
+		}
+
+		.musicbrainz-artist-title {
+			display: flex;
+			gap: 8px;
+			line-height: 20px;
+
+			a {
+				font-size: 20px;
+			}
+
+			.musicbrainz-artist-disambiguation {
+				flex: 1;
+			}
+		}
+
+		.musicbrainz-artist-alternative-title,
+		.musicbrainz-artist-area,
+		.musicbrainz-artist-life-span,
+		.musicbrainz-artist-score {
+			display: flex;
+			flex-direction: row;
+			gap: 4px;
+			line-height: 24px;
+		}
+
+		.musicbrainz-artist-tags {
+			display: flex;
+			flex-wrap: wrap;
+			gap: 4px;
+			line-height: 24px;
+
+			.musicbrainz-artist-tag {
+				background-color: var(--dark-grey-2);
+				padding: 4px 8px;
+				border-radius: 16px;
+				line-height: 16px;
+				font-size: 16px;
+			}
+		}
+	}
+}
+</style>

+ 1 - 2
frontend/src/pages/Admin/Artists.vue

@@ -137,8 +137,7 @@ const remove = (id: string) => {
 					class="is-primary button"
 					@click="
 						openModal({
-							modal: 'editArtist',
-							props: { createArtist: true }
+							modal: 'createArtist'
 						})
 					"
 				>

+ 199 - 10
frontend/src/types/artist.ts

@@ -3,28 +3,43 @@ export interface ArtistTemp {
 	musicbrainzIdentifier: string;
 }
 
+// YouTube temporary types
 export interface YoutubeVideoTemp {
 	youtubeId: string;
 	title: string;
 	duration: number;
 	rawData: {
 		snippet: {
+			categoryId: string;
 			channelId: string;
+			channelTitle: string;
+			description: string;
+			liveBroadcastContent: string;
+			publishedAt: string; // or Date?
+			tags: string[];
+			thumbnails: unknown;
+			localized: {
+				description: string;
+				title: string;
+			};
+		};
+		statistics: {
+			commentCount: number;
+			likeCount: number;
+			viewCount: number;
+		};
+		status: {
+			embeddable: boolean;
+			license: string;
+			madeForKids: boolean;
+			privacyStatus: string;
+			publicStatsViewable: boolean;
+			uploadStatus: string;
 		};
 	};
 	hide: boolean;
 }
 
-export interface ReleaseTemp {
-	media: {
-		tracks: {
-			recording: {
-				id: string;
-			};
-		}[];
-	}[];
-}
-
 export interface YoutubeChannelTemp {
 	channelId: string;
 	title: string;
@@ -32,3 +47,177 @@ export interface YoutubeChannelTemp {
 		id: string;
 	};
 }
+
+// MusicBrainz temporary types
+export interface WorkTemp {
+	id: string;
+	iswcs: string[];
+	attributes: unknown;
+	type: string;
+	language: string;
+	disambiguation: string;
+	languages: string[];
+	"type-id": string;
+	title: string;
+}
+
+export interface RelationTemp {
+	"type-id": string;
+	begin: null;
+	"source-credit": string;
+	end: null;
+	"target-credit": string;
+	direction: "forward" | string;
+	"attributes-values": unknown;
+	"target-type": string;
+	type: string;
+	attributes: unknown[];
+	ended: boolean;
+	"attributes-ids": unknown;
+}
+
+export interface URLTemp {
+	resource: string;
+	id: string;
+}
+
+export interface URLRelationTemp extends RelationTemp {
+	"target-type": "url";
+	url: URLTemp;
+}
+
+export interface WorkRelationTemp extends RelationTemp {
+	"target-type": "work";
+	work: WorkTemp;
+}
+
+export interface RecordingTemp {
+	disambiguation: string;
+	"first-release-date": string;
+	id: string;
+	length: number;
+	title: string;
+	video: boolean;
+	relations: (URLRelationTemp | WorkRelationTemp)[];
+	"artist-credit"?: {
+		artist: {
+			country: string;
+			disambiguation: string;
+			id: string;
+			name: string;
+			"sort-name": string;
+			type: string;
+			"type-id": string;
+		};
+	}[];
+}
+
+export interface TrackTemp {
+	id: string;
+	length: number;
+	number: string;
+	position: number;
+	recording: RecordingTemp;
+	title: string;
+}
+
+export interface MediaTemp {
+	format: "CD";
+	"format-id": string;
+	position: number;
+	title: string;
+	"track-count": number;
+	"track-offset": number;
+	tracks?: TrackTemp[]; // If track-count is 0, this is undefined
+}
+
+export interface AreaTemp {
+	disambigutation: string;
+	id: string;
+	"iso-3166-1-codes": string[];
+	name: string;
+	"sort-name": string;
+	type: null;
+	"type-id": null;
+}
+
+export interface ReleaseGroupTemp {
+	disambiguation: string;
+	"first-release-date": string;
+	id: string;
+	"primary-type": "Album";
+	"primary-type-id": string;
+	"secondary-types": string[];
+	"secondary-type-ids": string[];
+	title: string;
+	relations: URLRelationTemp[];
+}
+
+export interface ReleaseEventTemp {
+	area: AreaTemp;
+	date: string;
+}
+
+export interface CoverArtArchiveTemp {
+	artwork: boolean;
+	back: boolean;
+	count: number;
+	darkened: boolean;
+	front: boolean;
+}
+
+export interface ReleaseTemp {
+	status: "Official" | string;
+	"text-representation": {
+		language: "eng" | string;
+		script: "Latn" | string;
+	};
+	"packaging-id": string;
+	"status-id": string;
+	quality: "normal" | string;
+	country: string;
+	packaging: string;
+	asin: string;
+	disambiguation: string;
+	id: string;
+	media: MediaTemp[];
+	date: string;
+	title: string;
+	"release-group": ReleaseGroupTemp;
+	barcode: string;
+	"release-events": ReleaseEventTemp[];
+	"cover-art-archive": CoverArtArchiveTemp;
+	relations: URLRelationTemp[];
+}
+
+export interface MusicBrainzArtistTemp {
+	id: string;
+	type: string; // E.g. Group
+	"type-id": string;
+	score: number;
+	name: string;
+	"sort-name": string;
+	country: string;
+	area: AreaTemp;
+	"begin-area": AreaTemp;
+	disambiguation: string;
+	ipis: string[];
+	isnis: string[];
+	"life-span": {
+		begin: string; //E.g. "2005"
+		ended: null;
+	};
+	aliases: {
+		"sort-name": string;
+		name: string;
+		locale: null;
+		type: null;
+		primary: null;
+		"begin-date": null;
+		"end-date": null;
+	}[];
+	tags: {
+		count: number;
+		name: string;
+	}[];
+}