Explorar el Código

feat: worked on convert Spotify songs functionality, playlist replacements

Kristian Vos hace 2 años
padre
commit
41edb86697

+ 2 - 0
backend/logic/actions/playlists.js

@@ -1192,6 +1192,8 @@ export default {
 	addSongToPlaylist: isLoginRequired(async function addSongToPlaylist(session, isSet, mediaSource, playlistId, cb) {
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 
+		console.log(55, isSet, mediaSource, playlistId);
+
 		async.waterfall(
 			[
 				next => {

+ 2 - 0
backend/logic/media.js

@@ -374,6 +374,8 @@ class _MediaModule extends CoreClass {
 					(song, next) => {
 						if (song && song.duration > 0) return next(true, song);
 
+						console.log(123, payload);
+
 						if (payload.mediaSource.startsWith("youtube:")) {
 							const youtubeId = payload.mediaSource.split(":")[1];
 

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

@@ -28,7 +28,8 @@ const modalComponents = shallowRef(
 		editSong: "EditSong/index.vue",
 		viewYoutubeVideo: "ViewYoutubeVideo.vue",
 		bulkEditPlaylist: "BulkEditPlaylist.vue",
-		convertSpotifySongs: "ConvertSpotifySongs.vue"
+		convertSpotifySongs: "ConvertSpotifySongs.vue",
+		replaceSpotifySongs: "ReplaceSpotifySongs.vue"
 	})
 );
 </script>

+ 118 - 3
frontend/src/components/modals/ConvertSpotifySongs.vue

@@ -63,7 +63,7 @@ const gettingMissingAlternativeMedia = ref(false);
 
 const replacingAllSpotifySongs = ref(false);
 
-const currentConvertType = ref<"track" | "album" | "artist">("track");
+const currentConvertType = ref<"track" | "album" | "artist">("album");
 const showReplaceButtonPerAlternative = ref(false);
 const hideSpotifySongsWithNoAlternativesFound = ref(false);
 
@@ -91,6 +91,8 @@ const youtubeVideoUrlRegex =
 	/^(https?:\/\/)?(www\.)?(m\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;
 const youtubeVideoIdRegex = /^([\w-]{11})$/;
 
+const youtubePlaylistUrlRegex = /[\\?&]list=([^&#]*)/;
+
 const filteredSpotifySongs = computed(() =>
 	hideSpotifySongsWithNoAlternativesFound.value
 		? spotifySongs.value.filter(
@@ -314,6 +316,44 @@ const replaceSpotifySong = (oldMediaSource, newMediaSource) => {
 	);
 };
 
+const openReplaceAlbumModal = (spotifyAlbumId, youtubePlaylistId) => {
+	console.log(spotifyAlbumId, youtubePlaylistId);
+
+	if (
+		!spotifyAlbums[spotifyAlbumId] ||
+		!spotifyAlbums[spotifyAlbumId].rawData
+	)
+		return new Toast("Album hasn't loaded yet.");
+
+	openModal({
+		modal: "replaceSpotifySongs",
+		props: {
+			playlistId: props.playlistId,
+			youtubePlaylistId,
+			spotifyTracks: spotifyAlbums[spotifyAlbumId].songs.map(
+				mediaSource => spotifyTracks[mediaSource]
+			)
+		}
+	});
+};
+
+const openReplaceAlbumModalFromUrl = spotifyAlbumId => {
+	const replacementUrl = replaceSongUrlMap[`album:${spotifyAlbumId}`];
+
+	console.log(spotifyAlbumId, replacementUrl);
+
+	let youtubePlaylistId = null;
+
+	const youtubePlaylistUrlRegexMatches =
+		youtubePlaylistUrlRegex.exec(replacementUrl);
+	if (youtubePlaylistUrlRegexMatches)
+		youtubePlaylistId = youtubePlaylistUrlRegexMatches[0];
+
+	console.log("Open modal for ", youtubePlaylistId);
+
+	openReplaceAlbumModal(spotifyAlbumId, youtubePlaylistId);
+};
+
 const replaceSongFromUrl = spotifyMediaSource => {
 	const replacementUrl = replaceSongUrlMap[spotifyMediaSource];
 
@@ -1352,7 +1392,7 @@ onMounted(() => {
 						v-if="currentConvertType === 'album'"
 					>
 						<h4>Spotify albums</h4>
-						<h4>Alternative songs</h4>
+						<h4>Alternative albums (playlists)</h4>
 
 						<template
 							v-for="spotifyAlbum in filteredSpotifyAlbums"
@@ -1409,6 +1449,7 @@ onMounted(() => {
 										spotifyMediaSource
 									"
 									:song="{
+										mediaSource: spotifyMediaSource,
 										title: spotifyTracks[spotifyMediaSource]
 											.name,
 										artists:
@@ -1439,6 +1480,15 @@ onMounted(() => {
 							<div
 								class="convert-table-cell convert-table-cell-right"
 							>
+								<p
+									v-if="
+										!alternativeAlbumsPerAlbum[
+											spotifyAlbum.albumId
+										]
+									"
+								>
+									No alternatives loaded
+								</p>
 								<div
 									class="alternative-album-items"
 									v-if="
@@ -1447,6 +1497,15 @@ onMounted(() => {
 										]
 									"
 								>
+									<p
+										v-if="
+											alternativeAlbumsPerAlbum[
+												spotifyAlbum.albumId
+											].youtubePlaylistIds.length === 0
+										"
+									>
+										No alternative playlists were found
+									</p>
 									<div
 										class="alternative-album-item"
 										v-for="youtubePlaylistId in alternativeAlbumsPerAlbum[
@@ -1464,11 +1523,67 @@ onMounted(() => {
 										</p>
 										<button
 											class="button is-primary is-fullwidth"
+											@click="
+												openReplaceAlbumModal(
+													spotifyAlbum.albumId,
+													youtubePlaylistId
+												)
+											"
 										>
-											Match songs using this playlist
+											Open replace modal
 										</button>
 									</div>
 								</div>
+
+								<div
+									v-if="
+										showReplacementInputs ||
+										(alternativeAlbumsPerAlbum[
+											spotifyAlbum.albumId
+										] &&
+											alternativeAlbumsPerAlbum[
+												spotifyAlbum.albumId
+											].youtubePlaylistIds.length === 0)
+									"
+								>
+									<div>
+										<label class="label">
+											Enter replacement playlist URL
+										</label>
+										<div
+											class="control is-grouped input-with-button"
+										>
+											<p class="control is-expanded">
+												<input
+													class="input"
+													type="text"
+													placeholder="Enter your playlist URL here..."
+													v-model="
+														replaceSongUrlMap[
+															`album:${spotifyAlbum.albumId}`
+														]
+													"
+													@keyup.enter="
+														openReplaceAlbumModalFromUrl(
+															spotifyAlbum.albumId
+														)
+													"
+												/>
+											</p>
+											<p class="control">
+												<a
+													class="button is-info"
+													@click="
+														openReplaceAlbumModalFromUrl(
+															spotifyAlbum.albumId
+														)
+													"
+													>Open replace modal</a
+												>
+											</p>
+										</div>
+									</div>
+								</div>
 							</div>
 						</template>
 					</div>

+ 447 - 0
frontend/src/components/modals/ReplaceSpotifySongs.vue

@@ -0,0 +1,447 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, onMounted, onBeforeUnmount } from "vue";
+import Toast from "toasters";
+import { DraggableList } from "vue-draggable-list";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useModalsStore } from "@/stores/modals";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const SongItem = defineAsyncComponent(
+	() => import("@/components/SongItem.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true },
+	spotifyAlbum: { type: Object, default: () => ({}) },
+	spotifyTracks: { type: Array, default: () => [] },
+	playlistId: { type: String },
+	youtubePlaylistId: { type: String }
+});
+
+const { socket } = useWebsocketsStore();
+
+const { closeCurrentModal } = useModalsStore();
+
+const isImportingPlaylist = ref(false);
+const hasImportedPlaylist = ref(false);
+const trackSongs = ref([]);
+
+const playlistSongs = ref([]);
+
+const localSpotifyTracks = ref([]);
+
+const replacingAllSpotifySongs = ref(false);
+
+const replaceAllSpotifySongs = async () => {
+	if (replacingAllSpotifySongs.value) return;
+	replacingAllSpotifySongs.value = true;
+
+	const replaceArray = [];
+
+	localSpotifyTracks.value.forEach((spotifyTrack, index) => {
+		const spotifyMediaSource = `spotify:${spotifyTrack.trackId}`;
+
+		if (trackSongs.value[index].length === 1) {
+			const replacementMediaSource =
+				trackSongs.value[index][0].mediaSource;
+
+			if (!spotifyMediaSource || !replacementMediaSource) return;
+
+			replaceArray.push([spotifyMediaSource, replacementMediaSource]);
+		}
+	});
+
+	if (replaceArray.length === 0) {
+		new Toast(
+			"No songs can be replaced, please make sure each track has one song dragged into the box"
+		);
+		return;
+	}
+
+	const promises = replaceArray.map(
+		([spotifyMediaSource, replacementMediaSource]) =>
+			new Promise<void>(resolve => {
+				socket.dispatch(
+					"playlists.replaceSongInPlaylist",
+					spotifyMediaSource,
+					replacementMediaSource,
+					props.playlistId,
+					res => {
+						console.log(
+							"playlists.replaceSongInPlaylist response",
+							res
+						);
+
+						if (res.status === "success") {
+							const spotifyTrackId =
+								spotifyMediaSource.split(":")[1];
+
+							const trackIndex =
+								localSpotifyTracks.value.findIndex(
+									spotifyTrack =>
+										spotifyTrack.trackId === spotifyTrackId
+								);
+
+							localSpotifyTracks.value.splice(trackIndex, 1);
+							trackSongs.value.splice(trackIndex, 1);
+						}
+
+						resolve();
+					}
+				);
+			})
+	);
+
+	Promise.allSettled(promises).finally(() => {
+		replacingAllSpotifySongs.value = false;
+
+		if (localSpotifyTracks.value.length === 0) closeCurrentModal();
+	});
+};
+
+const replaceSpotifySong = (oldMediaSource, newMediaSource) => {
+	socket.dispatch(
+		"playlists.replaceSongInPlaylist",
+		oldMediaSource,
+		newMediaSource,
+		props.playlistId,
+		res => {
+			console.log("playlists.replaceSongInPlaylist response", res);
+
+			if (res.status === "success") {
+				const spotifyTrackId = oldMediaSource.split(":")[1];
+
+				const trackIndex = localSpotifyTracks.value.findIndex(
+					spotifyTrack => spotifyTrack.trackId === spotifyTrackId
+				);
+
+				localSpotifyTracks.value.splice(trackIndex, 1);
+				trackSongs.value.splice(trackIndex, 1);
+
+				if (localSpotifyTracks.value.length === 0) closeCurrentModal();
+			}
+		}
+	);
+};
+
+const tryToAutoMove = () => {
+	const songs = playlistSongs.value;
+
+	localSpotifyTracks.value.forEach((spotifyTrack, index) => {
+		songs.forEach(playlistSong => {
+			if (
+				playlistSong.title
+					.toLowerCase()
+					.trim()
+					.indexOf(spotifyTrack.name.toLowerCase().trim()) !== -1
+			) {
+				songs.splice(songs.indexOf(playlistSong), 1);
+				trackSongs.value[index].push(playlistSong);
+			}
+		});
+	});
+};
+
+const importPlaylist = () => {
+	if (hasImportedPlaylist.value)
+		return new Toast("A playlist has already imported.");
+	if (isImportingPlaylist.value)
+		return new Toast("A playlist is already importing.");
+
+	isImportingPlaylist.value = true;
+
+	// don't give starting import message instantly in case of instant error
+	setTimeout(() => {
+		if (isImportingPlaylist.value) {
+			new Toast(
+				"Starting to import your playlist. This can take some time to do."
+			);
+		}
+	}, 750);
+
+	return socket.dispatch(
+		"youtube.requestSet",
+		`https://youtube.com/playlist?list=${props.youtubePlaylistId}`,
+		false,
+		true,
+		res => {
+			const mediaSources = res.videos.map(
+				video => `youtube:${video.youtubeId}`
+			);
+
+			socket.dispatch(
+				"songs.getSongsFromMediaSources",
+				mediaSources,
+				res => {
+					if (res.status === "success") {
+						const { songs } = res.data;
+
+						playlistSongs.value = songs;
+
+						songs.forEach(() => {
+							trackSongs.value.push([]);
+						});
+
+						hasImportedPlaylist.value = true;
+						isImportingPlaylist.value = false;
+
+						tryToAutoMove();
+						return;
+					}
+
+					new Toast("Could not get songs.");
+				}
+			);
+
+			return new Toast({ content: res.message, timeout: 20000 });
+		}
+	);
+};
+
+onMounted(() => {
+	localSpotifyTracks.value = props.spotifyTracks;
+
+	importPlaylist();
+});
+
+onBeforeUnmount(() => {});
+</script>
+
+<template>
+	<div>
+		<modal
+			title="Replace Spotify Songs"
+			class="replace-spotify-songs-modal"
+			size="wide"
+		>
+			<template #body>
+				<div class="playlist-songs">
+					<h4>YouTube songs</h4>
+					<p v-if="isImportingPlaylist">Importing playlist...</p>
+					<draggable-list
+						v-if="playlistSongs.length > 0"
+						v-model:list="playlistSongs"
+						item-key="mediaSource"
+						:group="`replace-spotify-album-${modalUuid}-songs`"
+					>
+						<template #item="{ element }">
+							<song-item
+								:key="`playlist-song-${element.mediaSource}`"
+								:song="element"
+							>
+							</song-item>
+						</template>
+					</draggable-list>
+				</div>
+				<div class="track-boxes">
+					<div
+						class="track-box"
+						v-for="(track, index) in localSpotifyTracks"
+						:key="track.trackId"
+					>
+						<div class="track-position-title">
+							<p>
+								{{ track.name }} -
+								{{ track.artists.join(", ") }}
+							</p>
+						</div>
+						<!-- :data-track-index="index" -->
+						<div class="track-box-songs-drag-area">
+							<draggable-list
+								v-model:list="trackSongs[index]"
+								item-key="mediaSource"
+								:group="`replace-spotify-album-${modalUuid}-songs`"
+							>
+								<template #item="{ element }">
+									<song-item
+										:key="`track-song-${element.mediaSource}`"
+										:song="element"
+									>
+									</song-item>
+									<button
+										class="button is-primary is-fullwidth"
+										@click="
+											replaceSpotifySong(
+												`spotify:${track.trackId}`,
+												element.mediaSource
+											)
+										"
+									>
+										Replace Spotify song with this song
+									</button>
+								</template>
+							</draggable-list>
+						</div>
+					</div>
+				</div>
+			</template>
+			<template #footer>
+				<button class="button is-primary" @click="tryToAutoMove()">
+					Try to auto move
+				</button>
+				<button
+					class="button is-primary"
+					@click="replaceAllSpotifySongs()"
+				>
+					Replace all songs
+				</button>
+			</template>
+		</modal>
+	</div>
+</template>
+
+<style lang="less">
+.night-mode {
+	.spotify-album-container,
+	.playlist-songs,
+	.track-boxes {
+		background-color: var(--dark-grey-3) !important;
+		border: 0 !important;
+		.tab {
+			border: 0 !important;
+		}
+	}
+
+	.api-result {
+		background-color: var(--dark-grey-3) !important;
+	}
+
+	.api-result .tracks .track:hover,
+	.api-result .tracks .track:focus,
+	.discogs-album .tracks .track:hover,
+	.discogs-album .tracks .track:focus {
+		background-color: var(--dark-grey-2) !important;
+	}
+
+	.api-result .bottom-row img,
+	.discogs-album .bottom-row img {
+		filter: invert(100%);
+	}
+
+	.label,
+	p,
+	strong {
+		color: var(--light-grey-2);
+	}
+}
+
+.replace-spotify-songs-modal {
+	.modal-card-title {
+		text-align: center;
+		margin-left: 24px;
+	}
+
+	.modal-card {
+		width: 100%;
+		height: 100%;
+
+		.modal-card-body {
+			padding: 16px;
+			display: flex;
+			flex-direction: row;
+			flex-wrap: wrap;
+			justify-content: space-evenly;
+		}
+
+		.modal-card-foot {
+			.button {
+				margin: 0;
+				&:not(:first-of-type) {
+					margin-left: 5px;
+				}
+			}
+
+			div div {
+				margin-right: 5px;
+			}
+			.right {
+				display: flex;
+				margin-left: auto;
+				margin-right: 0;
+			}
+		}
+	}
+}
+</style>
+
+<style lang="less" scoped>
+.break {
+	flex-basis: 100%;
+	height: 0;
+	border: 1px solid var(--dark-grey);
+	margin-top: 16px;
+	margin-bottom: 16px;
+}
+
+.spotify-album-container,
+.playlist-songs {
+	width: 500px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: @border-radius;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+
+	h4 {
+		margin: 0;
+		margin-bottom: 16px;
+	}
+
+	button {
+		margin: 5px 0;
+	}
+}
+
+.track-boxes {
+	width: 500px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: @border-radius;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+
+	.track-box:first-child {
+		margin-top: 0;
+		border-radius: @border-radius @border-radius 0 0;
+	}
+
+	.track-box:last-child {
+		border-radius: 0 0 @border-radius @border-radius;
+	}
+
+	.track-box {
+		border: 0.5px solid var(--black);
+		margin-top: -1px;
+		line-height: 16px;
+		display: flex;
+		flex-flow: column;
+
+		.track-position-title {
+			display: flex;
+
+			span {
+				font-weight: 600;
+				display: inline-block;
+				margin-top: 7px;
+				margin-bottom: 7px;
+				margin-left: 7px;
+			}
+
+			p {
+				display: inline-block;
+				margin: 7px;
+				flex: 1;
+			}
+		}
+
+		.track-box-songs-drag-area {
+			flex: 1;
+			min-height: 100px;
+			display: flex;
+			flex-direction: column;
+		}
+	}
+}
+</style>