|
@@ -0,0 +1,518 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { useForm } from "@/composables/useForm";
|
|
|
+import Toast from "toasters";
|
|
|
+import { defineAsyncComponent, ref, onMounted } from "vue";
|
|
|
+import { GenericResponse } from "@musare_types/actions/GenericActions";
|
|
|
+import { useWebsocketsStore } from "@/stores/websockets";
|
|
|
+import { useModalsStore } from "@/stores/modals";
|
|
|
+import { useLongJobsStore } from "@/stores/longJobs";
|
|
|
+import VueJsonPretty from "vue-json-pretty";
|
|
|
+import "vue-json-pretty/lib/styles.css";
|
|
|
+
|
|
|
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
|
|
|
+const SaveButton = defineAsyncComponent(
|
|
|
+ () => import("@/components/SaveButton.vue")
|
|
|
+);
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ modalUuid: { type: String, required: true },
|
|
|
+ createArtist: { type: Boolean, default: false },
|
|
|
+ artistId: { type: String, default: null },
|
|
|
+ sector: { type: String, default: "admin" }
|
|
|
+});
|
|
|
+
|
|
|
+const { socket } = useWebsocketsStore();
|
|
|
+
|
|
|
+const { closeCurrentModal } = useModalsStore();
|
|
|
+const { setJob } = useLongJobsStore();
|
|
|
+
|
|
|
+const createdBy = ref();
|
|
|
+const createdAt = ref(0);
|
|
|
+const hideMusicbrainzData = ref(true);
|
|
|
+
|
|
|
+const relatedSongs = ref([]);
|
|
|
+
|
|
|
+const refreshRelatedSongs = () => {}
|
|
|
+const refreshRelatedAlbums = () => {}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ socket.onConnect(() => {
|
|
|
+ if (props.artistId && !props.createArtist) {
|
|
|
+ socket.dispatch(
|
|
|
+ `artists.getArtistFromId`,
|
|
|
+ props.artistId,
|
|
|
+ (res) => { // res: GetArtistResponse
|
|
|
+ if (res.status === "success") {
|
|
|
+ setOriginalValue({
|
|
|
+ name: res.data.artist.name,
|
|
|
+ musicbrainzIdentifier: res.data.artist.musicbrainzIdentifier,
|
|
|
+ musicbrainzData: res.data.artist.musicbrainzData ?? {},
|
|
|
+ youtubeChannels: res.data.artist.youtubeChannels ?? [],
|
|
|
+ spotifyArtists: res.data.artist.spotifyArtists ?? [],
|
|
|
+ soundcloudArtists: res.data.artist.soundcloudArtists ?? [],
|
|
|
+ });
|
|
|
+ createdBy.value = res.data.artist.createdBy;
|
|
|
+ createdAt.value = res.data.artist.createdAt;
|
|
|
+
|
|
|
+ refreshRelatedSongs();
|
|
|
+ refreshRelatedAlbums();
|
|
|
+ } else {
|
|
|
+ new Toast("Artist with that ID not found.");
|
|
|
+ closeCurrentModal();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ }
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+const {
|
|
|
+ inputs,
|
|
|
+ save,
|
|
|
+ setOriginalValue,
|
|
|
+ } = useForm(
|
|
|
+ {
|
|
|
+ name: {
|
|
|
+ value: "",
|
|
|
+ },
|
|
|
+ musicbrainzIdentifier: {
|
|
|
+ value: "",
|
|
|
+ },
|
|
|
+ musicbrainzData: {
|
|
|
+ value: {},
|
|
|
+ },
|
|
|
+ youtubeChannels: {
|
|
|
+ value: [],
|
|
|
+ },
|
|
|
+ spotifyArtists: {
|
|
|
+ value: [],
|
|
|
+ },
|
|
|
+ soundcloudArtists: {
|
|
|
+ value: [],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ({ status, messages, values }, resolve, reject) => {
|
|
|
+ if (status === "success") {
|
|
|
+ const data = {
|
|
|
+ name: values.name,
|
|
|
+ musicbrainzIdentifier: values.musicbrainzIdentifier,
|
|
|
+ musicbrainzData: values.musicbrainzData,
|
|
|
+ youtubeChannels: values.youtubeChannels,
|
|
|
+ spotifyArtists: values.spotifyArtists,
|
|
|
+ soundcloudArtists: values.soundcloudArtists,
|
|
|
+ };
|
|
|
+ const cb = (res: GenericResponse) => {
|
|
|
+ new Toast(res.message);
|
|
|
+ if (res.status === "success") resolve();
|
|
|
+ else reject(new Error(res.message));
|
|
|
+ };
|
|
|
+ if (props.createArtist) socket.dispatch("artists.create", data, cb);
|
|
|
+ else socket.dispatch("artists.update", props.artistId, data, cb);
|
|
|
+ } else {
|
|
|
+ if (status === "unchanged") new Toast(messages.unchanged);
|
|
|
+ else if (status === "error")
|
|
|
+ Object.values(messages).forEach(message => {
|
|
|
+ new Toast({ content: message, timeout: 8000 });
|
|
|
+ });
|
|
|
+ resolve();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ modalUuid: props.modalUuid,
|
|
|
+ },
|
|
|
+);
|
|
|
+
|
|
|
+const saveArtist = (close?: boolean) => {
|
|
|
+ save(() => {
|
|
|
+ if (close) {
|
|
|
+ closeCurrentModal();
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const getMusicbrainzArtistData = (musicbrainzIdentifier) => {
|
|
|
+ socket.dispatch("artists.getMusicbrainzArtist", musicbrainzIdentifier, (res) => {
|
|
|
+ new Toast("Successfully got data");
|
|
|
+ inputs.value["musicbrainzData"].value = res.data;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+const addYoutubeChannel = () => {
|
|
|
+ inputs.value["youtubeChannels"].value.push({
|
|
|
+ youtubeChannelId: "",
|
|
|
+ comment: "",
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+const removeYoutubeChannel = index => {
|
|
|
+ inputs.value["youtubeChannels"].value.splice(index, 1);
|
|
|
+}
|
|
|
+
|
|
|
+const addSpotifyArtist = () => {
|
|
|
+ inputs.value["spotifyArtists"].value.push({
|
|
|
+ spotifyArtistId: "",
|
|
|
+ comment: "",
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+const removeSpotifyArtist = index => {
|
|
|
+ inputs.value["spotifyArtists"].value.splice(index, 1);
|
|
|
+}
|
|
|
+
|
|
|
+const addSoundcloudArtist = () => {
|
|
|
+ inputs.value["soundcloudArtists"].value.push({
|
|
|
+ soundcloudArtistId: "",
|
|
|
+ comment: "",
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+const removeSoundcloudArtist = index => {
|
|
|
+ inputs.value["soundcloudArtists"].value.splice(index, 1);
|
|
|
+}
|
|
|
+
|
|
|
+const importYoutubeChannel = youtubeChannelId => {
|
|
|
+ let id;
|
|
|
+ let title;
|
|
|
+
|
|
|
+ const youtubeChannelUrl = `https://www.youtube.com/channel/${youtubeChannelId}`;
|
|
|
+
|
|
|
+ socket.dispatch(
|
|
|
+ "youtube.requestSetAdmin",
|
|
|
+ youtubeChannelUrl,
|
|
|
+ false, // Import only music = false
|
|
|
+ 0, // Max = 0, so import all
|
|
|
+ true, // Return video's = true
|
|
|
+ {
|
|
|
+ cb: () => {
|
|
|
+ console.log("CB done");
|
|
|
+ },
|
|
|
+ onProgress: res => {
|
|
|
+ console.log(123, res);
|
|
|
+ if (res.status === "started") {
|
|
|
+ id = res.id;
|
|
|
+ title = res.title;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (id)
|
|
|
+ setJob({
|
|
|
+ id,
|
|
|
+ name: title,
|
|
|
+ ...res
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+const fillMissingUrls = musicbrainzIdentifier => {
|
|
|
+ socket.dispatch("artists.getMusicbrainzRelatedUrls", musicbrainzIdentifier, res => {
|
|
|
+ const youtubeUrls = res.data.relations.filter(relation => relation.type === "youtube").map(relation => relation.url.resource);
|
|
|
+
|
|
|
+ const promises = [];
|
|
|
+
|
|
|
+ youtubeUrls.forEach(youtubeUrl => {
|
|
|
+ promises.push(new Promise(resolve => {
|
|
|
+ socket.dispatch("artists.getIdFromUrl", "youtube", youtubeUrl, res => {
|
|
|
+ console.log(555, res);
|
|
|
+
|
|
|
+ if (res.status === "success") {
|
|
|
+ const youtubeChannelId = res.channelId;
|
|
|
+
|
|
|
+ const existingYoutubeChannelIds = inputs.value["youtubeChannels"].value.map(youtubeChannel => youtubeChannel.youtubeChannelId);
|
|
|
+ if (!existingYoutubeChannelIds.includes(youtubeChannelId)) {
|
|
|
+ inputs.value["youtubeChannels"].value.push({
|
|
|
+ youtubeChannelId,
|
|
|
+ comment: `MusicBrainz URL artist relation ${'test'}`,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ resolve(null);
|
|
|
+ });
|
|
|
+ }));
|
|
|
+ });
|
|
|
+
|
|
|
+ Promise.all(promises);
|
|
|
+ });
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <modal
|
|
|
+ class="edit-artist-modal"
|
|
|
+ :title="createArtist ? 'Create Artist' : 'Edit Artist'"
|
|
|
+ :size="'wide'"
|
|
|
+ :split="true"
|
|
|
+ >
|
|
|
+ <template #body>
|
|
|
+ <div class="flex flex-row w-full">
|
|
|
+ <div class="flex flex-column gap-4 w-2/3">
|
|
|
+ <div>
|
|
|
+ <div class="control is-grouped">
|
|
|
+ <div class="name-container">
|
|
|
+ <label class="label">Name</label>
|
|
|
+ <p class="control has-addons">
|
|
|
+ <input
|
|
|
+ class="input"
|
|
|
+ type="text"
|
|
|
+ :ref="el => (inputs['name'].ref = el)"
|
|
|
+ v-model="inputs['name'].value"
|
|
|
+ placeholder="Enter artist name..."
|
|
|
+ />
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="control is-grouped gap-4">
|
|
|
+ <div class="musicbrainz-identifier-container">
|
|
|
+ <label class="label">MusicBrainz identifier</label>
|
|
|
+ <input
|
|
|
+ class="input"
|
|
|
+ type="text"
|
|
|
+ :ref="el => (inputs['musicbrainzIdentifier'].ref = el)"
|
|
|
+ v-model="inputs['musicbrainzIdentifier'].value"
|
|
|
+ placeholder="Enter MusicBrainz identifier..."
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ class="button is-primary button-bottom"
|
|
|
+ @click="getMusicbrainzArtistData(inputs['musicbrainzIdentifier'].value)"
|
|
|
+ >
|
|
|
+ Get MusicBrainz artist data
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ class="button is-primary button-bottom"
|
|
|
+ @click="fillMissingUrls(inputs['musicbrainzIdentifier'].value)"
|
|
|
+ >
|
|
|
+ Fill artists/channels from MusicBrainz
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="flex flex-row gap-4">
|
|
|
+ <p class="text-vcenter">MusicBrainz data</p>
|
|
|
+ <button
|
|
|
+ class="button is-primary"
|
|
|
+ @click="hideMusicbrainzData = !hideMusicbrainzData"
|
|
|
+ >
|
|
|
+ <span v-show="hideMusicbrainzData">Show MusicBrainz data</span>
|
|
|
+ <span v-show="!hideMusicbrainzData">Hide MusicBrainz data</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <vue-json-pretty
|
|
|
+ :data="inputs['musicbrainzData'].value"
|
|
|
+ :show-length="true"
|
|
|
+ v-if="!hideMusicbrainzData"
|
|
|
+ ></vue-json-pretty>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p>YouTube channels</p>
|
|
|
+ <div class="flex flex-column gap-4">
|
|
|
+ <template
|
|
|
+ v-for="(youtubeChannel, index) in inputs['youtubeChannels'].value"
|
|
|
+ :key="`${index}`"
|
|
|
+ >
|
|
|
+ <div class="control is-grouped gap-4">
|
|
|
+ <div class="name-container">
|
|
|
+ <label class="label">YouTube channel ID</label>
|
|
|
+ <input
|
|
|
+ class="input"
|
|
|
+ type="text"
|
|
|
+ v-model="youtubeChannel.youtubeChannelId"
|
|
|
+ placeholder="Enter YouTube channel ID..."
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="name-container">
|
|
|
+ <label class="label">Comment</label>
|
|
|
+ <input
|
|
|
+ class="input"
|
|
|
+ type="text"
|
|
|
+ v-model="youtubeChannel.comment"
|
|
|
+ placeholder="Enter comment..."
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ class="button is-primary button-bottom"
|
|
|
+ @click="removeYoutubeChannel(index)"
|
|
|
+ >
|
|
|
+ Remove
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ class="button is-primary button-bottom"
|
|
|
+ @click="importYoutubeChannel(youtubeChannel.youtubeChannelId)"
|
|
|
+ >
|
|
|
+ Import
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ class="button is-primary"
|
|
|
+ @click="addYoutubeChannel()"
|
|
|
+ >
|
|
|
+ Add YouTube channel
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p>Spotify artists</p>
|
|
|
+ <div class="flex flex-column gap-4">
|
|
|
+ <template
|
|
|
+ v-for="(spotifyArtist, index) in inputs['spotifyArtists'].value"
|
|
|
+ :key="`${index}`"
|
|
|
+ >
|
|
|
+ <div class="control is-grouped gap-4">
|
|
|
+ <div class="name-container">
|
|
|
+ <label class="label">Spotify artist ID</label>
|
|
|
+ <input
|
|
|
+ class="input"
|
|
|
+ type="text"
|
|
|
+ v-model="spotifyArtist.spotifyArtistId"
|
|
|
+ placeholder="Enter Spotify artist ID..."
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="name-container">
|
|
|
+ <label class="label">Comment</label>
|
|
|
+ <input
|
|
|
+ class="input"
|
|
|
+ type="text"
|
|
|
+ v-model="spotifyArtist.comment"
|
|
|
+ placeholder="Enter comment..."
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ class="button is-primary button-bottom"
|
|
|
+ @click="removeSpotifyArtist(index)"
|
|
|
+ >
|
|
|
+ Remove
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ class="button is-primary"
|
|
|
+ @click="addSpotifyArtist()"
|
|
|
+ >
|
|
|
+ Add Spotify artist
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <p>SoundCloud artists</p>
|
|
|
+ <div class="flex flex-column gap-4">
|
|
|
+ <template
|
|
|
+ v-for="(soundcloudArtist, index) in inputs['soundcloudArtists'].value"
|
|
|
+ :key="`${index}`"
|
|
|
+ >
|
|
|
+ <div class="control is-grouped gap-4">
|
|
|
+ <div class="name-container">
|
|
|
+ <label class="label">SoundCloud artist ID</label>
|
|
|
+ <input
|
|
|
+ class="input"
|
|
|
+ type="text"
|
|
|
+ v-model="soundcloudArtist.soundcloudArtistId"
|
|
|
+ placeholder="Enter Soundcloud artist ID..."
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="name-container">
|
|
|
+ <label class="label">Comment</label>
|
|
|
+ <input
|
|
|
+ class="input"
|
|
|
+ type="text"
|
|
|
+ v-model="soundcloudArtist.comment"
|
|
|
+ placeholder="Enter comment..."
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ class="button is-primary button-bottom"
|
|
|
+ @click="removeSoundcloudArtist(index)"
|
|
|
+ >
|
|
|
+ Remove
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ class="button is-primary"
|
|
|
+ @click="addSoundcloudArtist()"
|
|
|
+ >
|
|
|
+ Add Soundcloud artist
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="flex flex-column w-1/3">
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <template #footer>
|
|
|
+ <div>
|
|
|
+ <save-button
|
|
|
+ :default-message="`${createArtist ? 'Create' : 'Update'} Artist`"
|
|
|
+ @clicked="saveArtist()"
|
|
|
+ />
|
|
|
+ <save-button
|
|
|
+ :default-message="`${createArtist ? 'Create' : 'Update'} and close`"
|
|
|
+ @clicked="saveArtist(true)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </modal>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style lang="less">
|
|
|
+.night-mode {
|
|
|
+ .edit-artist-modal {
|
|
|
+ .vjs-tree-node.is-highlight, .vjs-tree-node:hover {
|
|
|
+ background: black;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|
|
|
+<style lang="less" scoped>
|
|
|
+.flex {
|
|
|
+ display: flex;
|
|
|
+}
|
|
|
+
|
|
|
+.flex-column {
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.flex-row {
|
|
|
+ flex-direction: row;
|
|
|
+}
|
|
|
+
|
|
|
+.button-bottom {
|
|
|
+ align-self: flex-end;
|
|
|
+}
|
|
|
+
|
|
|
+.musicbrainz-identifier-container {
|
|
|
+ input {
|
|
|
+ width: 21rem;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.text-vcenter {
|
|
|
+ align-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.gap-4 {
|
|
|
+ gap: 1rem;
|
|
|
+}
|
|
|
+
|
|
|
+.w-1\/2 {
|
|
|
+ width: 50%;
|
|
|
+}
|
|
|
+
|
|
|
+.w-1\/3 {
|
|
|
+ width: calc(100% / 3);
|
|
|
+}
|
|
|
+
|
|
|
+.w-2\/3 {
|
|
|
+ width: calc((100% / 3) * 2);
|
|
|
+}
|
|
|
+
|
|
|
+.w-full {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+</style>
|