Browse Source

feat: started adding support for converting Spotify tracks by album

Kristian Vos 2 years ago
parent
commit
8c8bce2f97

+ 20 - 2
backend/logic/actions/spotify.js

@@ -14,7 +14,7 @@ const SpotifyModule = moduleManager.modules.spotify;
 
 export default {
 	/**
-	 * Fetches new SoundCloud API key
+	 * Fetches tracks from media sources
 	 *
 	 * @returns {{status: string, data: object}}
 	 */
@@ -40,5 +40,23 @@ export default {
 					return cb({ status: "error", message: err });
 				});
 		}
-	)
+	),
+
+	/**
+	 * Fetches albums from ids
+	 *
+	 * @returns {{status: string, data: object}}
+	 */
+	getAlbumsFromIds: useHasPermission("admin.view.spotify", function getTracksFromMediaSources(session, albumIds, cb) {
+		SpotifyModule.runJob("GET_ALBUMS_FROM_IDS", { albumIds }, this)
+			.then(albums => {
+				this.log("SUCCESS", "SPOTIFY_GET_ALBUMS_FROM_IDS", `Getting albums from ids was successful.`);
+				return cb({ status: "success", data: { albums } });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SPOTIFY_GET_ALBUMS_FROM_IDS", `Getting albums from ids failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	})
 };

+ 5 - 0
backend/logic/db/index.js

@@ -22,6 +22,7 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	stationHistory: 2,
 	soundcloudTrack: 1,
 	spotifyTrack: 1,
+	spotifyAlbum: 1,
 	genericApiRequest: 1
 };
 
@@ -83,6 +84,7 @@ class _DBModule extends CoreClass {
 						stationHistory: {},
 						soundcloudTrack: {},
 						spotifyTrack: {},
+						spotifyAlbum: {},
 						genericApiRequest: {}
 					};
 
@@ -111,6 +113,7 @@ class _DBModule extends CoreClass {
 					await importSchema("stationHistory");
 					await importSchema("soundcloudTrack");
 					await importSchema("spotifyTrack");
+					await importSchema("spotifyAlbum");
 					await importSchema("genericApiRequest");
 
 					this.models = {
@@ -131,6 +134,7 @@ class _DBModule extends CoreClass {
 						stationHistory: mongoose.model("stationHistory", this.schemas.stationHistory),
 						soundcloudTrack: mongoose.model("soundcloudTrack", this.schemas.soundcloudTrack),
 						spotifyTrack: mongoose.model("spotifyTrack", this.schemas.spotifyTrack),
+						spotifyAlbum: mongoose.model("spotifyAlbum", this.schemas.spotifyAlbum),
 						genericApiRequest: mongoose.model("genericApiRequest", this.schemas.genericApiRequest)
 					};
 
@@ -287,6 +291,7 @@ class _DBModule extends CoreClass {
 					this.models.stationHistory.syncIndexes();
 					this.models.soundcloudTrack.syncIndexes();
 					this.models.spotifyTrack.syncIndexes();
+					this.models.spotifyAlbum.syncIndexes();
 					this.models.genericApiRequest.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();

+ 8 - 0
backend/logic/db/schemas/spotifyAlbum.js

@@ -0,0 +1,8 @@
+export default {
+	albumId: { type: String, unique: true },
+
+	rawData: { type: Object },
+
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 119 - 2
backend/logic/spotify.js

@@ -96,6 +96,9 @@ class _SpotifyModule extends CoreClass {
 		this.spotifyTrackModel = this.SpotifyTrackModel = await DBModule.runJob("GET_MODEL", {
 			modelName: "spotifyTrack"
 		});
+		this.spotifyAlbumModel = this.SpotifyAlbumModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "spotifyAlbum"
+		});
 
 		return new Promise((resolve, reject) => {
 			if (!config.has("apis.spotify") || !config.get("apis.spotify.enabled")) {
@@ -183,6 +186,36 @@ class _SpotifyModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Perform Spotify API get albums request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.albumIds - the album ids to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_ALBUMS(payload) {
+		return new Promise((resolve, reject) => {
+			const { albumIds } = payload;
+
+			SpotifyModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api.spotify.com/v1/albums`,
+					params: {
+						ids: albumIds.join(",")
+					}
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
 	/**
 	 * Perform Spotify API get track request
 	 *
@@ -249,7 +282,7 @@ class _SpotifyModule extends CoreClass {
 	API_CALL(payload) {
 		return new Promise((resolve, reject) => {
 			// const { url, params, quotaCost } = payload;
-			const { url } = payload;
+			const { url, params } = payload;
 
 			SpotifyModule.runJob("GET_API_TOKEN", {}, this)
 				.then(spotifyApiToken => {
@@ -258,7 +291,8 @@ class _SpotifyModule extends CoreClass {
 							headers: {
 								Authorization: `Bearer ${spotifyApiToken}`
 							},
-							timeout: SpotifyModule.requestTimeout
+							timeout: SpotifyModule.requestTimeout,
+							params
 						})
 						.then(response => {
 							if (response.data.error) {
@@ -268,6 +302,7 @@ class _SpotifyModule extends CoreClass {
 							}
 						})
 						.catch(err => {
+							console.log(4443311, err);
 							reject(err);
 						});
 				})
@@ -338,6 +373,36 @@ class _SpotifyModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Create Spotify albums
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.spotifyAlbums - the Spotify albums
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async CREATE_ALBUMS(payload) {
+		const { spotifyAlbums } = payload;
+
+		if (!Array.isArray(spotifyAlbums)) throw new Error("Invalid spotifyAlbums type");
+
+		const albumIds = spotifyAlbums.map(spotifyAlbum => spotifyAlbum.albumId);
+
+		const existingAlbums = (await SpotifyModule.spotifyAlbumModel.find({ albumId: albumIds })).map(
+			album => album._doc
+		);
+		const existingAlbumIds = existingAlbums.map(existingAlbum => existingAlbum.albumId);
+
+		const newSpotifyAlbums = spotifyAlbums.filter(
+			spotifyAlbum => existingAlbumIds.indexOf(spotifyAlbum.albumId) === -1
+		);
+
+		if (newSpotifyAlbums.length === 0) return existingAlbums;
+
+		await SpotifyModule.spotifyAlbumModel.insertMany(newSpotifyAlbums);
+
+		return existingAlbums.concat(newSpotifyAlbums);
+	}
+
 	/**
 	 * Gets tracks from media sources
 	 *
@@ -384,6 +449,58 @@ class _SpotifyModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Gets albums from ids
+	 *
+	 * @param {object} payload
+	 * @returns {Promise}
+	 */
+	async GET_ALBUMS_FROM_IDS(payload) {
+		const { albumIds } = payload;
+
+		console.log(albumIds);
+
+		const existingAlbums = (await SpotifyModule.spotifyAlbumModel.find({ albumId: albumIds })).map(
+			album => album._doc
+		);
+		const existingAlbumIds = existingAlbums.map(existingAlbum => existingAlbum.albumId);
+
+		console.log(existingAlbums);
+
+		const missingAlbumIds = albumIds.filter(albumId => existingAlbumIds.indexOf(albumId) === -1);
+
+		if (missingAlbumIds.length === 0) return existingAlbums;
+
+		console.log(missingAlbumIds);
+
+		const jobsToRun = [];
+
+		const chunkSize = 2;
+		while (missingAlbumIds.length > 0) {
+			const chunkedMissingAlbumIds = missingAlbumIds.splice(0, chunkSize);
+
+			jobsToRun.push(SpotifyModule.runJob("API_GET_ALBUMS", { albumIds: chunkedMissingAlbumIds }, this));
+		}
+
+		const jobResponses = await Promise.all(jobsToRun);
+
+		console.log(jobResponses);
+
+		const newAlbums = jobResponses
+			.map(jobResponse => jobResponse.response.data.albums)
+			.flat()
+			.map(album => ({
+				albumId: album.id,
+				rawData: album
+			}));
+
+		console.log(newAlbums);
+
+		await SpotifyModule.runJob("CREATE_ALBUMS", { spotifyAlbums: newAlbums }, this);
+
+		return existingAlbums.concat(newAlbums);
+	}
+
 	/**
 	 * Get Spotify track
 	 *

+ 265 - 5
frontend/src/components/modals/ConvertSpotifySongs.vue

@@ -36,6 +36,7 @@ const playlist = ref(null);
 const spotifySongs = ref([]);
 
 const spotifyTracks = reactive({});
+const spotifyAlbums = reactive({});
 const spotifyArtists = reactive({});
 
 const loadingPlaylist = ref(false);
@@ -44,6 +45,9 @@ const loadedPlaylist = ref(false);
 const loadingSpotifyTracks = ref(false);
 const loadedSpotifyTracks = ref(false);
 
+const loadingSpotifyAlbums = ref(false);
+const loadedSpotifyAlbums = ref(false);
+
 const gettingAllAlternativeMediaPerTrack = ref(false);
 const gotAllAlternativeMediaPerTrack = ref(false);
 const alternativeMediaPerTrack = reactive({});
@@ -55,7 +59,7 @@ const gettingMissingAlternativeMedia = ref(false);
 
 const replacingAllSpotifySongs = ref(false);
 
-const currentConvertType = ref("track");
+const currentConvertType = ref<"track" | "album" | "artist">("album");
 const showReplaceButtonPerAlternative = ref(false);
 const hideSpotifySongsWithNoAlternativesFound = ref(false);
 
@@ -68,6 +72,11 @@ const showExtra = ref(false);
 
 const collectAlternativeMediaSourcesOrigins = ref(false);
 
+const minimumSongsPerAlbum = ref(2);
+const sortAlbumMode = ref<
+	"SONG_COUNT_ASC" | "SONG_COUNT_DESC" | "NAME_DESC" | "NAME_ASC"
+>("SONG_COUNT_ASC");
+
 const filteredSpotifySongs = computed(() =>
 	hideSpotifySongsWithNoAlternativesFound.value
 		? spotifySongs.value.filter(
@@ -81,6 +90,42 @@ const filteredSpotifySongs = computed(() =>
 		: spotifySongs.value
 );
 
+const filteredSpotifyAlbums = computed(() => {
+	let albums = Object.values(spotifyAlbums);
+
+	albums = albums.filter(
+		album => album.songs.length >= minimumSongsPerAlbum.value
+	);
+
+	let sortFn = null;
+	if (sortAlbumMode.value === "SONG_COUNT_ASC")
+		sortFn = (albumA, albumB) => albumA.songs.length - albumB.songs.length;
+	else if (sortAlbumMode.value === "SONG_COUNT_DESC")
+		sortFn = (albumA, albumB) => albumB.songs.length - albumA.songs.length;
+	else if (loadedSpotifyAlbums.value && sortAlbumMode.value === "NAME_ASC")
+		sortFn = (albumA, albumB) => {
+			const nameA = albumA.rawData?.name?.toLowerCase();
+			const nameB = albumB.rawData?.name?.toLowerCase();
+
+			if (nameA === nameB) return 0;
+			if (nameA < nameB) return -1;
+			if (nameA > nameB) return 1;
+		};
+	else if (loadedSpotifyAlbums.value && sortAlbumMode.value === "NAME_DESC")
+		sortFn = (albumA, albumB) => {
+			const nameA = albumA.rawData?.name?.toLowerCase();
+			const nameB = albumB.rawData?.name?.toLowerCase();
+
+			if (nameA === nameB) return 0;
+			if (nameA > nameB) return -1;
+			if (nameA < nameB) return 1;
+		};
+
+	if (sortFn) albums = albums.sort(sortFn);
+
+	return albums;
+});
+
 const missingMediaSources = computed(() => {
 	const missingMediaSources = [];
 
@@ -329,6 +374,40 @@ const getAlternativeMedia = () => {
 	);
 };
 
+const loadSpotifyAlbums = () =>
+	new Promise<void>(resolve => {
+		console.debug(TAG, "Loading Spotify albums");
+
+		loadingSpotifyAlbums.value = true;
+
+		const albumIds = filteredSpotifyAlbums.value.map(
+			album => album.albumId
+		);
+
+		socket.dispatch("spotify.getAlbumsFromIds", albumIds, res => {
+			console.debug(TAG, "Get albums response", res);
+
+			if (res.status !== "success") {
+				new Toast(res.message);
+				closeCurrentModal();
+				return;
+			}
+
+			const { albums } = res.data;
+
+			albums.forEach(album => {
+				spotifyAlbums[album.albumId].rawData = album.rawData;
+			});
+
+			console.debug(TAG, "Loaded Spotify albums");
+
+			loadedSpotifyAlbums.value = true;
+			loadingSpotifyAlbums.value = false;
+
+			resolve();
+		});
+	});
+
 const loadSpotifyTracks = () =>
 	new Promise<void>(resolve => {
 		console.debug(TAG, "Loading Spotify tracks");
@@ -354,10 +433,25 @@ const loadSpotifyTracks = () =>
 				Object.entries(tracks).forEach(([mediaSource, track]) => {
 					spotifyTracks[mediaSource] = track;
 
-					track.artistIds.forEach((artistId, artistIndex) => {
+					const { albumId, albumImageUrl, artistIds, artists } =
+						track;
+
+					if (albumId) {
+						if (!spotifyAlbums[albumId])
+							spotifyAlbums[albumId] = {
+								albumId,
+								albumImageUrl,
+								songs: []
+							};
+
+						spotifyAlbums[albumId].songs.push(mediaSource);
+					}
+
+					artistIds.forEach((artistId, artistIndex) => {
 						if (!spotifyArtists[artistId]) {
 							spotifyArtists[artistId] = {
-								name: track.artists[artistIndex],
+								artistId,
+								name: artists[artistIndex],
 								songs: [],
 								expanded: false
 							};
@@ -539,7 +633,8 @@ onMounted(() => {
 								v-if="
 									loadedSpotifyTracks &&
 									!gettingAllAlternativeMediaPerTrack &&
-									!gotAllAlternativeMediaPerTrack
+									!gotAllAlternativeMediaPerTrack &&
+									currentConvertType === 'track'
 								"
 								class="button is-primary"
 								@click="getAlternativeMedia()"
@@ -548,6 +643,7 @@ onMounted(() => {
 							</button>
 							<button
 								v-if="
+									currentConvertType === 'track' &&
 									gotAllAlternativeMediaPerTrack &&
 									!gettingMissingAlternativeMedia &&
 									missingMediaSources.length > 0
@@ -557,6 +653,19 @@ onMounted(() => {
 							>
 								Get missing alternative media
 							</button>
+
+							<button
+								v-if="
+									loadedSpotifyTracks &&
+									!loadingSpotifyAlbums &&
+									!loadedSpotifyAlbums &&
+									currentConvertType === 'album'
+								"
+								class="button is-primary"
+								@click="loadSpotifyAlbums()"
+							>
+								Get Spotify albums
+							</button>
 						</div>
 
 						<div class="options">
@@ -656,7 +765,10 @@ onMounted(() => {
 								</p>
 							</div>
 
-							<div class="control">
+							<div
+								class="control"
+								v-if="currentConvertType === 'track'"
+							>
 								<label class="label"
 									>Preferred track mode</label
 								>
@@ -687,6 +799,43 @@ onMounted(() => {
 									</select>
 								</p>
 							</div>
+
+							<div
+								class="small-section"
+								v-if="currentConvertType === 'album'"
+							>
+								<label class="label"
+									>Minimum songs per album</label
+								>
+								<div class="control is-expanded">
+									<input
+										class="input"
+										type="number"
+										min="1"
+										v-model="minimumSongsPerAlbum"
+									/>
+								</div>
+							</div>
+
+							<div class="control">
+								<label class="label">Sort album mode</label>
+								<p class="control is-expanded select">
+									<select v-model="sortAlbumMode">
+										<option value="SONG_COUNT_ASC">
+											Song count (ascending)
+										</option>
+										<option value="SONG_COUNT_DESC">
+											Song count (descending)
+										</option>
+										<option value="NAME_ASC">
+											Name (ascending)
+										</option>
+										<option value="NAME_DESC">
+											Name (descending)
+										</option>
+									</select>
+								</p>
+							</div>
 						</div>
 
 						<div class="info">
@@ -716,6 +865,20 @@ onMounted(() => {
 								Spotify tracks loaded:
 								{{ Object.keys(spotifyTracks).length }}
 							</p>
+
+							<p>
+								Loading Spotify albums:
+								{{ loadingSpotifyAlbums }}
+							</p>
+							<p>
+								Loaded Spotify albums: {{ loadedSpotifyAlbums }}
+							</p>
+
+							<p>
+								Spotify albums:
+								{{ Object.keys(spotifyAlbums).length }}
+							</p>
+
 							<p>
 								Spotify artists:
 								{{ Object.keys(spotifyArtists).length }}
@@ -959,6 +1122,103 @@ onMounted(() => {
 							</div>
 						</template>
 					</div>
+
+					<div
+						class="convert-table convert-song-by-album"
+						v-if="currentConvertType === 'album'"
+					>
+						<h4>Spotify albums</h4>
+						<h4>Alternative songs</h4>
+
+						<template
+							v-for="spotifyAlbum in filteredSpotifyAlbums"
+							:key="spotifyAlbum"
+						>
+							<div
+								class="convert-table-cell convert-table-cell-left"
+							>
+								<p>Album ID: {{ spotifyAlbum.albumId }}</p>
+								<p v-if="loadingSpotifyAlbums">
+									Loading album info...
+								</p>
+								<p
+									v-else-if="
+										loadedSpotifyAlbums &&
+										!spotifyAlbum.rawData
+									"
+								>
+									Failed to load album info...
+								</p>
+								<template v-else-if="loadedSpotifyAlbums">
+									<p>Name: {{ spotifyAlbum.rawData.name }}</p>
+									<p>
+										Label: {{ spotifyAlbum.rawData.label }}
+									</p>
+									<p>
+										Popularity:
+										{{ spotifyAlbum.rawData.popularity }}
+									</p>
+									<p>
+										Release date:
+										{{ spotifyAlbum.rawData.release_date }}
+									</p>
+									<p>
+										Artists:
+										{{
+											spotifyAlbum.rawData.artists
+												.map(artist => artist.name)
+												.join(", ")
+										}}
+									</p>
+									<p>
+										UPC:
+										{{
+											spotifyAlbum.rawData.external_ids
+												.upc
+										}}
+									</p>
+								</template>
+								<song-item
+									v-for="spotifyMediaSource in spotifyAlbum.songs"
+									:key="
+										spotifyAlbum.albumId +
+										spotifyMediaSource
+									"
+									:song="{
+										title: spotifyTracks[spotifyMediaSource]
+											.name,
+										artists:
+											spotifyTracks[spotifyMediaSource]
+												.artists,
+										duration:
+											spotifyTracks[spotifyMediaSource]
+												.duration,
+										thumbnail:
+											spotifyTracks[spotifyMediaSource]
+												.albumImageUrl
+									}"
+								>
+									<template #leftIcon>
+										<a
+											:href="`https://open.spotify.com/track/${
+												spotifyMediaSource.split(':')[1]
+											}`"
+											target="_blank"
+										>
+											<div
+												class="spotify-icon left-icon"
+											></div>
+										</a>
+									</template>
+								</song-item>
+							</div>
+							<div
+								class="convert-table-cell convert-table-cell-right"
+							>
+								<p>Test</p>
+							</div>
+						</template>
+					</div>
 				</template>
 			</template>
 		</modal>