21 次代码提交 5bc14cd82e ... c05f64fcbb

作者 SHA1 备注 提交日期
  Kristian Vos c05f64fcbb feature: add job/action to fetch MusicBrainz data for an artist 1 月之前
  Kristian Vos 83230e87f3 feature: add job/action to fetch YT videos for YT channel ids 1 月之前
  Kristian Vos b7b6ea3f26 Merge branch 'staging' into albums-artists-wip 4 月之前
  Owen Diffey d4ddc6e03e Merge branch 'staging' 4 月之前
  Owen Diffey d5ab576978 chore: Update package version to v3.12.0 4 月之前
  Owen Diffey a7f400fac8 chore: Add v3.12.0 changelog 4 月之前
  Kristian Vos cf40cd4ee4 chore: lint fixes 4 月之前
  Kristian Vos 43151cb550 fix: settings page (on production mode primarily/solely) had race condition where inputs wouldn't be filled 4 月之前
  Owen Diffey 2fd4345740 fix(AdvancedTable): Reordering table headers ineffective 4 月之前
  Owen Diffey cd727a79a2 refactor: Hide oidc sub from users list if oidc disabled 4 月之前
  Owen Diffey 9f6110dcd2 fix(musare.sh): Pull images used in builds 4 月之前
  Owen Diffey 3c8caf17f8 chore: Update package version to v3.12.0-rc1 5 月之前
  Owen Diffey 82e87358d4 chore: Add v3.12.0-rc1 changelog 5 月之前
  Owen Diffey 979d273d83 fix: Eslint fixes 5 月之前
  Owen Diffey b19f9a2803 chore: Update packages 5 月之前
  Owen Diffey a5a1e0a57b fix(AdvancedTable): Hidden columns table header visible 5 月之前
  Kristian Vos d0e8f0cf83 fix: adding to playlist from YouTube search didn't work 5 月之前
  Owen Diffey ab3dbee934 chore: Update config version 5 月之前
  Owen Diffey e3e1e913df refactor: Remove unused steps from remove account modal 5 月之前
  Owen Diffey 1683f9dbc2 refactor: Update preference select styling 5 月之前
  Owen Diffey ad210fd85b feat: Disable change password with OIDC 5 月之前

+ 62 - 0
CHANGELOG.md

@@ -1,5 +1,67 @@
 # Changelog
 
+## [v3.12.0] - 2025-02-09
+
+This release includes all changes from v3.12.0-rc1, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Changed
+
+- refactor: Hide OIDC sub from admin users list if OIDC disabled
+
+### Fixed
+
+- fix: Pull images during musare.sh build
+- fix: Reordering AdvancedTable table headers ineffective
+- fix: Settings page had race condition where inputs wouldn't be filled
+
+## [v3.12.0-rc1] - 2025-01-19
+
+### **Breaking Changes**
+
+This release includes breaking changes to our docker setup, in particular the
+usage of named volumes and the removal of many redundant configuration options.
+
+In addition to this, GitHub authentication has been removed. If your instance
+has GitHub users, keep this in mind. If a user only has GitHub currently, you
+could instruct them to set a password before updating, or they can reset their
+password after updating if this is enabled on your instance.
+
+Before updating or pulling changes please make a full backup,
+and after updating restore using the [Utility Script](./.wiki/Utility_Script.md).
+Please refer to the [Configuration documentation](.wiki/Configuration.md)
+for more information on how you should now configure docker.
+
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: Add env config change check to musare.sh update
+- chore: Add backend debug
+- chore: Add vscode settings and extensions
+- feat: OIDC authentication
+- feat: Add default station and playlist privacy preferences
+- feat: Add privacy option to create station modal
+- feat: Add configuration option to retrict site to logged in users
+
+### Changed
+
+- refactor: Use node alpine docker images
+- refactor: Use non-root user in docker
+- refactor: Separates docker environment builds and combines modes into APP_ENV
+- refactor: Remove unnecessary configuration options
+- refactor: Split docker networks
+- refactor: Improve musare.sh handling and styling
+- chore: Update to node 22
+- refactor: Move users actions logic to module jobs
+- refactor: Remove GitHub authentication
+
+### Fixed
+
+- fix: Station undefined in autorequestExcludedMediaSources
+- fix: Advanced table hidden columns table header visible
+- fix: Adding song to playlist from YouTube search in EditPlaylist wouldn't work
+
 ## [v3.11.0] - 2024-03-02
 
 This release includes all changes from v3.11.0-rc1, in addition to the following.

+ 1 - 1
backend/config/default.json

@@ -1,5 +1,5 @@
 {
-	"configVersion": 12,
+	"configVersion": 13,
 	"migration": false,
 	"secret": "default",
 	"port": 8080,

+ 1 - 1
backend/config/template.json

@@ -1,5 +1,5 @@
 {
-	"configVersion": 12,
+	"configVersion": 13,
 	"migration": false,
 	"secret": "CHANGE_ME",
 	"url": {

+ 1 - 1
backend/index.js

@@ -7,7 +7,7 @@ import fs from "fs";
 import * as readline from "node:readline";
 import packageJson from "./package.json" with { type: "json" };
 
-const REQUIRED_CONFIG_VERSION = 12;
+const REQUIRED_CONFIG_VERSION = 13;
 
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {

+ 30 - 0
backend/logic/actions/albums.js

@@ -7,6 +7,7 @@ import moduleManager from "../../index";
 
 const DBModule = moduleManager.modules.db;
 const UtilsModule = moduleManager.modules.utils;
+const MusicBrainzModule = moduleManager.modules.musicbrainz;
 
 export default {
 	/**
@@ -111,5 +112,34 @@ export default {
 				}
 			);
 		}
+	),
+
+	/**
+	 * Gets MusicBrainz recordings release release groups data
+	 * @param session
+	 * @param artistId - the MusicBrainz artist id
+	 * @param {Function} cb
+	 */
+	getMusicBrainzRecordingsReleasesReleaseGroups: useHasPermission(
+		"apis.searchDiscogs",
+		async function getMusicBrainzRecordingsReleasesReleaseGroups(session, artistId, cb) {
+			// TODO update permission
+			const response = await MusicBrainzModule.runJob(
+				"GET_RECORDINGS_RELEASES_RELEASE_GROUPS",
+				{ artistId },
+				this
+			);
+
+			this.log(
+				"SUCCESS",
+				"ALBUMS_GET_MUSICBRAINZ_RECORDINGS_RELEASES_RELEASE_GROUPS",
+				`User "${session.userId}" got MusicBrainz recordings releases release groups for artist "${artistId}".`
+			);
+
+			return cb({
+				status: "success",
+				data: response
+			});
+		}
 	)
 };

+ 54 - 0
backend/logic/actions/youtube.js

@@ -452,6 +452,31 @@ export default {
 			});
 	}),
 
+	/**
+	 * Get YouTube videos for channel ids
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} channelIds - the channel ids
+	 * @param {Function} cb - gets called with the result
+	 * @returns {{status: string, data: object}}
+	 */
+	getVideosForChannelIds: useHasPermission("youtube.getChannel", function getVideo(session, channelIds, cb) {
+		return YouTubeModule.runJob("GET_VIDEOS_FOR_CHANNEL_IDS", { channelIds }, this)
+			.then(res => {
+				this.log("SUCCESS", "YOUTUBE_GET_VIDEOS_FOR_CHANNEL_IDS", `Fetching videos was successful.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully fetched YouTube videos",
+					data: res.videos
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "YOUTUBE_GET_VIDEOS_FOR_CHANNEL_IDS", `Fetching videos failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
 	/**
 	 * Get a YouTube channel from ID
 	 * @param {object} session - the session object automatically added by the websocket
@@ -481,6 +506,35 @@ export default {
 			});
 	}),
 
+	/**
+	 * Get a YouTube channels from ID
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} channelIds - the YouTube channel ids to get
+	 * @param {Function} cb - gets called with the result
+	 * @returns {{status: string, data: object}}
+	 */
+	getChannelsById: useHasPermission("youtube.getChannel", function getChannel(session, channelIds, cb) {
+		return YouTubeModule.runJob("GET_CHANNELS_FROM_IDS", { channelIds }, this)
+			.then(res => {
+				if (res.channels.length === 0) {
+					this.log("ERROR", "YOUTUBE_GET_CHANNELS_FROM_IDS", `Fetching channel failed.`);
+					return cb({ status: "error", message: "Failed to get channel" });
+				}
+
+				this.log("SUCCESS", "YOUTUBE_GET_CHANNELS_FROM_IDS", `Fetching channels was successful.`);
+				return cb({
+					status: "success",
+					message: "Successfully fetched YouTube channels",
+					data: res.channels
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "YOUTUBE_GET_CHANNELS_FROM_IDS", `Fetching channels failed. "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	}),
+
 	/**
 	 * Remove YouTube videos
 	 * @param {object} session - the session object automatically added by the websocket

+ 89 - 1
backend/logic/musicbrainz.js

@@ -34,12 +34,13 @@ class RateLimitter {
 
 let MusicBrainzModule;
 let DBModule;
+let CacheModule;
 
 class _MusicBrainzModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
 		super("musicbrainz", {
-			concurrency: 10
+			concurrency: 1
 		});
 
 		MusicBrainzModule = this;
@@ -51,6 +52,7 @@ class _MusicBrainzModule extends CoreClass {
 	 */
 	async initialize() {
 		DBModule = this.moduleManager.modules.db;
+		CacheModule = this.moduleManager.modules.cache;
 
 		this.genericApiRequestModel = this.GenericApiRequestModel = await DBModule.runJob("GET_MODEL", {
 			modelName: "genericApiRequest"
@@ -85,6 +87,10 @@ class _MusicBrainzModule extends CoreClass {
 		MusicBrainzModule.rateLimiter.restart();
 
 		const responseData = await new Promise((resolve, reject) => {
+			console.log(
+				"INFO",
+				`Making MusicBrainz API request to ${url} with ${new URLSearchParams(params).toString()}`
+			);
 			MusicBrainzModule.axios
 				.get(url, {
 					params,
@@ -117,6 +123,88 @@ class _MusicBrainzModule extends CoreClass {
 
 		return responseData;
 	}
+
+	/**
+	 * Gets a list of recordings, releases and release groups for a given artist
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.artistId - MusicBrainz artist id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_RECORDINGS_RELEASES_RELEASE_GROUPS(payload) {
+		const { artistId } = payload;
+		// TODO this job caches a response to prevent overloading the API, but doesn't do anything to prevent the same job for the same artistId from running more than once at the same time
+
+		const existingResponse = await CacheModule.runJob(
+			"HGET",
+			{
+				table: "musicbrainzRecordingsReleasesReleaseGroups",
+				key: artistId
+			},
+			this
+		);
+		if (existingResponse) return existingResponse;
+
+		const fetchDate = new Date();
+		let maxReleases = 0;
+		let releases = [];
+		do {
+			const offset = releases.length;
+			// eslint-disable-next-line no-await-in-loop
+			const response = await MusicBrainzModule.runJob(
+				"GET_RECORDINGS_RELEASES_RELEASE_GROUPS_PAGE",
+				{ artistId, offset },
+				this
+			);
+			maxReleases = response["release-count"];
+			releases = [...releases, ...response.releases];
+			if (response.releases.length === 0) break;
+		} while (maxReleases >= releases.length);
+
+		const response = {
+			releases,
+			fetchDate
+		};
+
+		await CacheModule.runJob(
+			"HSET",
+			{
+				table: "musicbrainzRecordingsReleasesReleaseGroups",
+				key: artistId,
+				value: JSON.stringify(response)
+			},
+			this
+		);
+
+		return response;
+	}
+
+	/**
+	 * Gets a list of recordings, releases and release groups for a given artist, for a given page
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.artistId - MusicBrainz artist id
+	 * @param {string} payload.offset - offset by how much
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_RECORDINGS_RELEASES_RELEASE_GROUPS_PAGE(payload) {
+		const { artistId, offset } = payload;
+
+		const response = await MusicBrainzModule.runJob(
+			"API_CALL",
+			{
+				url: `https://musicbrainz.org/ws/2/release`,
+				params: {
+					fmt: "json",
+					artist: artistId,
+					inc: "release-groups+recordings",
+					limit: 100,
+					offset
+				}
+			},
+			this
+		);
+
+		return response;
+	}
 }
 
 export default new _MusicBrainzModule();

+ 3 - 3
backend/logic/spotify.js

@@ -773,7 +773,7 @@ class _SpotifyModule extends CoreClass {
 						result
 					}
 				});
-			} catch (err) {
+			} catch {
 				this.publishProgress({
 					status: "working",
 					message: `Failed to get alternative artist source for ${artistId}`,
@@ -862,7 +862,7 @@ class _SpotifyModule extends CoreClass {
 						result
 					}
 				});
-			} catch (err) {
+			} catch {
 				this.publishProgress({
 					status: "working",
 					message: `Failed to get alternative album source for ${albumId}`,
@@ -935,7 +935,7 @@ class _SpotifyModule extends CoreClass {
 						result
 					}
 				});
-			} catch (err) {
+			} catch {
 				this.publishProgress({
 					status: "working",
 					message: `Failed to get alternative media for ${mediaSource}`,

+ 17 - 0
backend/logic/youtube.js

@@ -2102,6 +2102,23 @@ class _YouTubeModule extends CoreClass {
 			got: gotChannels.length
 		};
 	}
+
+	/**
+	 * Gets YouTube videos currently in the database for the provided YouTube channel ids
+	 * Doesn't make any API requests to update/refetch/fetch new YouTube videos
+	 * @param {object} payload - an object containing the payload
+	 * @param {Array} payload.channelIds - list of YouTube channel ids
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_VIDEOS_FOR_CHANNEL_IDS(payload) {
+		const { channelIds } = payload;
+
+		const videos = await YouTubeModule.youtubeVideoModel.find({ "rawData.snippet.channelId": { $in: channelIds } });
+
+		return {
+			videos: videos.map(video => video.toObject())
+		};
+	}
 }
 
 export default new _YouTubeModule();

文件差异内容过多而无法显示
+ 238 - 305
backend/package-lock.json


+ 12 - 12
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.11.0",
+  "version": "3.12.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
@@ -16,17 +16,17 @@
   },
   "dependencies": {
     "async": "^3.2.6",
-    "axios": "^1.7.7",
+    "axios": "^1.7.9",
     "bcrypt": "^5.1.1",
     "bluebird": "^3.7.2",
     "body-parser": "^1.20.3",
     "config": "^3.3.12",
     "cookie-parser": "^1.4.7",
     "cors": "^2.8.5",
-    "express": "^4.21.1",
+    "express": "^4.21.2",
     "extensionless": "^1.9.9",
     "moment": "^2.30.1",
-    "mongoose": "^6.13.3",
+    "mongoose": "^6.13.6",
     "nodemailer": "^6.9.16",
     "redis": "^4.7.0",
     "retry-axios": "^3.1.3",
@@ -37,19 +37,19 @@
     "ws": "^8.18.0"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^7.18.0",
-    "@typescript-eslint/parser": "^7.18.0",
+    "@typescript-eslint/eslint-plugin": "^8.20.0",
+    "@typescript-eslint/parser": "^8.20.0",
     "eslint": "^8.57.1",
     "eslint-config-airbnb-base": "^15.0.0",
-    "eslint-config-prettier": "^9.1.0",
+    "eslint-config-prettier": "^10.0.1",
     "eslint-plugin-import": "^2.31.0",
-    "eslint-plugin-jsdoc": "^50.4.3",
-    "eslint-plugin-prettier": "^5.2.1",
-    "nodemon": "^3.1.7",
-    "prettier": "3.3.3",
+    "eslint-plugin-jsdoc": "^50.6.2",
+    "eslint-plugin-prettier": "^5.2.3",
+    "nodemon": "^3.1.9",
+    "prettier": "3.4.2",
     "trace-unhandled": "^2.0.1",
     "ts-node": "^10.9.2",
-    "typescript": "^5.6.3"
+    "typescript": "^5.7.3"
   },
   "overrides": {
     "@aws-sdk/credential-providers": "npm:dry-uninstall"

文件差异内容过多而无法显示
+ 413 - 169
frontend/package-lock.json


+ 24 - 25
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.11.0",
+  "version": "3.12.0",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",
@@ -22,45 +22,44 @@
   "devDependencies": {
     "@pinia/testing": "^0.1.7",
     "@types/can-autoplay": "^3.0.5",
-    "@types/dompurify": "^3.0.5",
-    "@typescript-eslint/eslint-plugin": "^7.18.0",
-    "@typescript-eslint/parser": "^7.18.0",
-    "@vitest/coverage-v8": "^2.1.4",
+    "@typescript-eslint/eslint-plugin": "^8.20.0",
+    "@typescript-eslint/parser": "^8.20.0",
+    "@vitest/coverage-v8": "^3.0.2",
     "@vue/test-utils": "^2.4.6",
     "eslint": "^8.57.1",
-    "eslint-config-prettier": "^9.1.0",
+    "eslint-config-prettier": "^10.0.1",
     "eslint-plugin-import": "^2.31.0",
-    "eslint-plugin-prettier": "^5.2.1",
-    "eslint-plugin-vue": "^9.30.0",
-    "jsdom": "^25.0.1",
-    "less": "^4.2.0",
-    "prettier": "^3.3.3",
+    "eslint-plugin-prettier": "^5.2.3",
+    "eslint-plugin-vue": "^9.32.0",
+    "jsdom": "^26.0.0",
+    "less": "^4.2.1",
+    "prettier": "^3.4.2",
     "vite-plugin-dynamic-import": "^1.6.0",
-    "vitest": "^2.1.4",
+    "vitest": "^3.0.2",
     "vue-eslint-parser": "^9.4.3",
-    "vue-tsc": "^2.1.10"
+    "vue-tsc": "^2.2.0"
   },
   "dependencies": {
-    "@intlify/unplugin-vue-i18n": "^5.3.0",
-    "@vitejs/plugin-vue": "^5.1.4",
+    "@intlify/unplugin-vue-i18n": "^6.0.3",
+    "@vitejs/plugin-vue": "^5.2.1",
     "can-autoplay": "^3.0.2",
-    "chart.js": "^4.4.6",
+    "chart.js": "^4.4.7",
     "date-fns": "^4.1.0",
-    "dompurify": "^3.1.7",
+    "dompurify": "^3.2.3",
     "eslint-config-airbnb-base": "^15.0.0",
-    "marked": "^15.0.0",
+    "marked": "^15.0.6",
     "normalize.css": "^8.0.1",
-    "pinia": "^2.2.6",
+    "pinia": "^2.3.0",
     "toasters": "^2.3.1",
-    "typescript": "^5.6.3",
-    "vite": "^5.4.10",
-    "vue": "^3.5.12",
+    "typescript": "^5.7.3",
+    "vite": "^6.0.7",
+    "vue": "^3.5.13",
     "vue-chartjs": "^5.3.2",
     "vue-content-loader": "^2.0.1",
     "vue-draggable-list": "^0.2.0",
-    "vue-i18n": "^10.0.4",
+    "vue-i18n": "^11.0.1",
     "vue-json-pretty": "^2.4.0",
-    "vue-router": "^4.4.5",
-    "vue-tippy": "^6.5.0"
+    "vue-router": "^4.5.0",
+    "vue-tippy": "^6.6.0"
   }
 }

+ 27 - 6
frontend/src/components/AdvancedTable.vue

@@ -182,11 +182,32 @@ const bulkPopup = ref();
 const rowElements = ref([]);
 
 const lastPage = computed(() => Math.ceil(count.value / pageSize.value));
-const sortedFilteredColumns = computed(() =>
-	orderedColumns.value.filter(
-		column => shownColumns.value.indexOf(column.name) !== -1
-	)
-);
+const sortedFilteredColumns = computed({
+	get: () =>
+		orderedColumns.value.filter(
+			column => shownColumns.value.indexOf(column.name) !== -1
+		),
+	set: newValue =>
+		orderedColumns.value.sort((columnA, columnB) => {
+			// Always places updatedPlaceholder column in the first position
+			if (columnA.name === "updatedPlaceholder") return -1;
+			if (columnB.name === "updatedPlaceholder") return 1;
+			// Always places select column in the second position
+			if (columnA.name === "select") return -1;
+			if (columnB.name === "select") return 1;
+			// Always places placeholder column in the last position
+			if (columnA.name === "placeholder") return 1;
+			if (columnB.name === "placeholder") return -1;
+
+			const indexA = newValue.indexOf(columnA);
+			const indexB = newValue.indexOf(columnB);
+
+			// If either of the columns is not visible, use default ordering
+			if (indexA === -1 || indexB === -1) return 0;
+
+			return indexA - indexB;
+		})
+});
 const hidableSortedColumns = computed(() =>
 	orderedColumns.value.filter(column => column.hidable)
 );
@@ -1598,7 +1619,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 					<thead>
 						<tr>
 							<draggable-list
-								v-model:list="orderedColumns"
+								v-model:list="sortedFilteredColumns"
 								item-key="name"
 								@update="columnOrderChanged"
 								tag="th"

+ 1 - 1
frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -259,7 +259,7 @@ watch(
 								@click="
 									addYouTubeSongToPlaylist(
 										playlist._id,
-										result.id,
+										`youtube:${result.id}`,
 										index
 									)
 								"

+ 1 - 1
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -152,7 +152,7 @@ const onMusarePlaylistFileChange = () => {
 					"An error occured whilst parsing the playlist file. Is it valid?"
 				);
 			else importMusarePlaylistFileContents.value = parsed;
-		} catch (err) {
+		} catch {
 			new Toast(
 				"An error occured whilst parsing the playlist file. Is it valid?"
 			);

+ 5 - 5
frontend/src/components/modals/EditSong/index.vue

@@ -812,7 +812,7 @@ const getYouTubeData = type => {
 
 			if (title) setValue({ title });
 			else throw new Error("No title found");
-		} catch (e) {
+		} catch {
 			new Toast(
 				"Unable to fetch YouTube video title. Try starting the video."
 			);
@@ -830,7 +830,7 @@ const getYouTubeData = type => {
 
 			if (author) setValue({ addArtist: author });
 			else throw new Error("No video author found");
-		} catch (e) {
+		} catch {
 			new Toast(
 				"Unable to fetch YouTube video author. Try starting the video."
 			);
@@ -847,7 +847,7 @@ const getSoundCloudData = type => {
 				if (title) setValue({ title });
 				else throw new Error("No title found");
 			});
-		} catch (e) {
+		} catch {
 			new Toast("Unable to fetch SoundCloud track title.");
 		}
 	}
@@ -866,7 +866,7 @@ const getSoundCloudData = type => {
 				if (artworkUrl) setValue({ thumbnail: artworkUrl });
 				else throw new Error("No thumbnail found");
 			});
-		} catch (e) {
+		} catch {
 			new Toast("Unable to fetch SoundCloud track artwork.");
 		}
 	}
@@ -879,7 +879,7 @@ const getSoundCloudData = type => {
 				if (user) setValue({ addArtist: user.username });
 				else throw new Error("No artist found");
 			});
-		} catch (e) {
+		} catch {
 			new Toast("Unable to fetch SoundCloud track artist.");
 		}
 	}

+ 7 - 43
frontend/src/components/modals/RemoveAccount.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { defineAsyncComponent, ref } from "vue";
+import { defineAsyncComponent, onMounted, ref } from "vue";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
@@ -60,11 +60,6 @@ const confirmPasswordMatch = () =>
 		else new Toast(res.message);
 	});
 
-const confirmOIDCLink = () => {
-	// TODO
-	step.value = "remove-account";
-};
-
 const remove = () =>
 	socket.dispatch("users.remove", res => {
 		if (res.status === "success") {
@@ -77,6 +72,10 @@ const remove = () =>
 
 		return new Toast(res.message);
 	});
+
+onMounted(() => {
+	if (oidcAuthentication.value) step.value = "remove-account";
+});
 </script>
 
 <template>
@@ -85,7 +84,7 @@ const remove = () =>
 		class="confirm-account-removal-modal"
 	>
 		<template #body>
-			<div id="steps">
+			<div v-if="!oidcAuthentication" id="steps">
 				<p
 					class="step"
 					:class="{ selected: step === 'confirm-identity' }"
@@ -93,22 +92,13 @@ const remove = () =>
 					1
 				</p>
 				<span class="divider"></span>
-				<p
-					class="step"
-					:class="{
-						selected: step === 'export-data'
-					}"
-				>
-					2
-				</p>
-				<span class="divider"></span>
 				<p
 					class="step"
 					:class="{
 						selected: step === 'remove-account'
 					}"
 				>
-					3
+					2
 				</p>
 			</div>
 
@@ -173,32 +163,6 @@ const remove = () =>
 				</div>
 			</div>
 
-			<div
-				class="content-box"
-				v-else-if="oidcAuthentication && step === 'confirm-identity'"
-			>
-				<h2 class="content-box-title">Verify your OIDC</h2>
-				<p class="content-box-description">
-					Check your account is still linked to remove your account.
-				</p>
-
-				<div class="content-box-inputs">
-					<a class="button is-oidc" @click="confirmOIDCLink()">
-						<div class="icon">
-							<img
-								class="invert"
-								src="/assets/social/github.svg"
-							/>
-						</div>
-						&nbsp; Check whether OIDC is linked
-					</a>
-				</div>
-			</div>
-
-			<div v-if="step === 'export-data'">
-				DOWNLOAD A BACKUP OF YOUR DATA BEFORE ITS PERMENATNELY DELETED
-			</div>
-
 			<div
 				class="content-box"
 				id="remove-account-container"

+ 1 - 1
frontend/src/composables/useYoutubeDirect.ts

@@ -40,7 +40,7 @@ export const useYoutubeDirect = () => {
 					return youtubeVideoIdMatch.groups.youtubeId;
 				}
 			}
-		} catch (error) {
+		} catch {
 			return null;
 		}
 

+ 29 - 16
frontend/src/pages/Admin/Users/index.vue

@@ -1,9 +1,11 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref, onMounted } from "vue";
 import { useRoute } from "vue-router";
+import { storeToRefs } from "pinia";
 import { useModalsStore } from "@/stores/modals";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
+import { useConfigStore } from "@/stores/config";
 
 const AdvancedTable = defineAsyncComponent(
 	() => import("@/components/AdvancedTable.vue")
@@ -12,6 +14,9 @@ const ProfilePicture = defineAsyncComponent(
 	() => import("@/components/ProfilePicture.vue")
 );
 
+const configStore = useConfigStore();
+const { oidcAuthentication } = storeToRefs(configStore);
+
 const route = useRoute();
 
 const columnDefault = ref<TableColumn>({
@@ -63,14 +68,18 @@ const columns = ref<TableColumn[]>([
 		minWidth: 230,
 		defaultWidth: 230
 	},
-	{
-		name: "oidcSub",
-		displayName: "OIDC sub",
-		properties: ["services.oidc.sub"],
-		sortProperty: "services.oidc.sub",
-		minWidth: 115,
-		defaultWidth: 115
-	},
+	...(oidcAuthentication.value
+		? [
+				{
+					name: "oidcSub",
+					displayName: "OIDC sub",
+					properties: ["services.oidc.sub"],
+					sortProperty: "services.oidc.sub",
+					minWidth: 115,
+					defaultWidth: 115
+				}
+			]
+		: []),
 	{
 		name: "role",
 		displayName: "Role",
@@ -126,13 +135,17 @@ const filters = ref<TableFilter[]>([
 		filterTypes: ["contains", "exact", "regex"],
 		defaultFilterType: "contains"
 	},
-	{
-		name: "oidcSub",
-		displayName: "OIDC sub",
-		property: "services.oidc.sub",
-		filterTypes: ["contains", "exact", "regex"],
-		defaultFilterType: "contains"
-	},
+	...(oidcAuthentication.value
+		? [
+				{
+					name: "oidcSub",
+					displayName: "OIDC sub",
+					property: "services.oidc.sub",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				}
+			]
+		: []),
 	{
 		name: "role",
 		displayName: "Role",
@@ -266,7 +279,7 @@ onMounted(() => {
 					slotProps.item._id
 				}}</span>
 			</template>
-			<template #column-oidcSub="slotProps">
+			<template v-if="oidcAuthentication" #column-oidcSub="slotProps">
 				<span
 					v-if="slotProps.item.services.oidc"
 					:title="slotProps.item.services.oidc.sub"

+ 9 - 8
frontend/src/pages/Settings/Tabs/Account.vue

@@ -26,7 +26,7 @@ const { socket } = useWebsocketsStore();
 const saveButton = ref();
 
 const { userId } = storeToRefs(userAuthStore);
-const { originalUser, modifiedUser } = settingsStore;
+const { originalUser, modifiedUser } = storeToRefs(settingsStore);
 
 const validation = reactive({
 	username: {
@@ -50,7 +50,7 @@ const onInput = inputName => {
 };
 
 const changeEmail = () => {
-	const email = modifiedUser.email.address;
+	const email = modifiedUser.value.email.address;
 	if (!_validation.isLength(email, 3, 254))
 		return new Toast("Email must have between 3 and 254 characters.");
 	if (
@@ -79,7 +79,7 @@ const changeEmail = () => {
 };
 
 const changeUsername = () => {
-	const { username } = modifiedUser;
+	const { username } = modifiedUser.value;
 
 	if (!_validation.isLength(username, 2, 32))
 		return new Toast("Username must have between 2 and 32 characters.");
@@ -119,9 +119,10 @@ const changeUsername = () => {
 };
 
 const saveChanges = () => {
-	const usernameChanged = modifiedUser.username !== originalUser.username;
+	const usernameChanged =
+		modifiedUser.value.username !== originalUser.value.username;
 	const emailAddressChanged =
-		modifiedUser.email.address !== originalUser.email.address;
+		modifiedUser.value.email.address !== originalUser.value.email.address;
 
 	if (usernameChanged) changeUsername();
 
@@ -141,7 +142,7 @@ const removeActivities = () => {
 };
 
 watch(
-	() => modifiedUser.username,
+	() => modifiedUser.value.username,
 	value => {
 		// const value = newModifiedUser.username;
 
@@ -151,7 +152,7 @@ watch(
 			validation.username.valid = false;
 		} else if (
 			!_validation.regex.azAZ09_.test(value) &&
-			value !== originalUser.username // Sometimes a username pulled from OIDC won't succeed validation
+			value !== originalUser.value.username // Sometimes a username pulled from OIDC won't succeed validation
 		) {
 			validation.username.message =
 				"Invalid format. Allowed characters: a-z, A-Z, 0-9 and _.";
@@ -168,7 +169,7 @@ watch(
 );
 
 watch(
-	() => modifiedUser.email.address,
+	() => modifiedUser.value.email?.address,
 	value => {
 		// const value = newModifiedUser.email.address;
 

+ 13 - 17
frontend/src/pages/Settings/Tabs/Preferences.vue

@@ -208,25 +208,21 @@ onMounted(() => {
 			</label>
 		</p>
 
-		<div class="control is-grouped input-with-label">
-			<div class="control select">
-				<select v-model="localDefaultStationPrivacy">
-					<option value="public">Public</option>
-					<option value="unlisted">Unlisted</option>
-					<option value="private">Private</option>
-				</select>
-			</div>
-			<label class="label"> Default station privacy </label>
+		<label class="label">Default station privacy</label>
+		<div class="control select">
+			<select v-model="localDefaultStationPrivacy">
+				<option value="public">Public</option>
+				<option value="unlisted">Unlisted</option>
+				<option value="private">Private</option>
+			</select>
 		</div>
 
-		<div class="control is-grouped input-with-label">
-			<div class="control select">
-				<select v-model="localDefaultPlaylistPrivacy">
-					<option value="public">Public</option>
-					<option value="private">Private</option>
-				</select>
-			</div>
-			<label class="label"> Default playlist privacy </label>
+		<label class="label">Default playlist privacy</label>
+		<div class="control select">
+			<select v-model="localDefaultPlaylistPrivacy">
+				<option value="public">Public</option>
+				<option value="private">Private</option>
+			</select>
 		</div>
 		<SaveButton ref="saveButton" @clicked="saveChanges()" />
 	</div>

+ 14 - 11
frontend/src/pages/Settings/Tabs/Profile.vue

@@ -22,13 +22,15 @@ const { socket } = useWebsocketsStore();
 const saveButton = ref();
 
 const { userId } = storeToRefs(userAuthStore);
-const { originalUser, modifiedUser } = settingsStore;
+const { originalUser, modifiedUser } = storeToRefs(settingsStore);
 
 const { updateOriginalUser } = settingsStore;
 
 const changeName = () => {
-	modifiedUser.name = modifiedUser.name.replaceAll(/ +/g, " ").trim();
-	const { name } = modifiedUser;
+	modifiedUser.value.name = modifiedUser.value.name
+		.replaceAll(/ +/g, " ")
+		.trim();
+	const { name } = modifiedUser.value;
 
 	if (!validation.isLength(name, 1, 64))
 		return new Toast("Name must have between 1 and 64 characters.");
@@ -62,7 +64,7 @@ const changeName = () => {
 };
 
 const changeLocation = () => {
-	const { location } = modifiedUser;
+	const { location } = modifiedUser.value;
 
 	if (!validation.isLength(location, 0, 50))
 		return new Toast("Location must have between 0 and 50 characters.");
@@ -92,7 +94,7 @@ const changeLocation = () => {
 };
 
 const changeBio = () => {
-	const { bio } = modifiedUser;
+	const { bio } = modifiedUser.value;
 
 	if (!validation.isLength(bio, 0, 200))
 		return new Toast("Bio must have between 0 and 200 characters.");
@@ -117,7 +119,7 @@ const changeBio = () => {
 };
 
 const changeAvatar = () => {
-	const { avatar } = modifiedUser;
+	const { avatar } = modifiedUser.value;
 
 	saveButton.value.status = "disabled";
 
@@ -139,12 +141,13 @@ const changeAvatar = () => {
 };
 
 const saveChanges = () => {
-	const nameChanged = modifiedUser.name !== originalUser.name;
-	const locationChanged = modifiedUser.location !== originalUser.location;
-	const bioChanged = modifiedUser.bio !== originalUser.bio;
+	const nameChanged = modifiedUser.value.name !== originalUser.value.name;
+	const locationChanged =
+		modifiedUser.value.location !== originalUser.value.location;
+	const bioChanged = modifiedUser.value.bio !== originalUser.value.bio;
 	const avatarChanged =
-		modifiedUser.avatar.type !== originalUser.avatar.type ||
-		modifiedUser.avatar.color !== originalUser.avatar.color;
+		modifiedUser.value.avatar.type !== originalUser.value.avatar.type ||
+		modifiedUser.value.avatar.color !== originalUser.value.avatar.color;
 
 	if (nameChanged) changeName();
 	if (locationChanged) changeLocation();

+ 79 - 77
frontend/src/pages/Settings/Tabs/Security.vue

@@ -104,85 +104,87 @@ watch(validation, newValidation => {
 
 <template>
 	<div class="content security-tab">
-		<h4 class="section-title">Change password</h4>
-
-		<p class="section-description">
-			You will need to know your previous password
-		</p>
-
-		<hr class="section-horizontal-rule" />
-
-		<p class="control is-expanded margin-top-zero">
-			<label for="old-password">Previous password</label>
-		</p>
-
-		<div id="password-visibility-container">
-			<input
-				class="input"
-				id="old-password"
-				ref="oldPassword"
-				type="password"
-				placeholder="Enter your old password here..."
-				v-model="validation.oldPassword.value"
-			/>
-			<a @click="togglePasswordVisibility('oldPassword')">
-				<i class="material-icons">
-					{{
-						!validation.oldPassword.visible
-							? "visibility"
-							: "visibility_off"
-					}}
-				</i>
-			</a>
-		</div>
+		<template v-if="!oidcAuthentication">
+			<h4 class="section-title">Change password</h4>
 
-		<p class="control is-expanded">
-			<label for="new-password">New password</label>
-		</p>
-
-		<div id="password-visibility-container">
-			<input
-				class="input"
-				id="new-password"
-				type="password"
-				ref="newPassword"
-				placeholder="Enter new password here..."
-				v-model="validation.newPassword.value"
-				@keyup.enter="changePassword()"
-				@keypress="onInput('newPassword')"
-				@paste="onInput('newPassword')"
-			/>
-
-			<a @click="togglePasswordVisibility('newPassword')">
-				<i class="material-icons">
-					{{
-						!validation.newPassword.visible
-							? "visibility"
-							: "visibility_off"
-					}}
-				</i>
-			</a>
-		</div>
+			<p class="section-description">
+				You will need to know your previous password
+			</p>
+
+			<hr class="section-horizontal-rule" />
+
+			<p class="control is-expanded margin-top-zero">
+				<label for="old-password">Previous password</label>
+			</p>
+
+			<div id="password-visibility-container">
+				<input
+					class="input"
+					id="old-password"
+					ref="oldPassword"
+					type="password"
+					placeholder="Enter your old password here..."
+					v-model="validation.oldPassword.value"
+				/>
+				<a @click="togglePasswordVisibility('oldPassword')">
+					<i class="material-icons">
+						{{
+							!validation.oldPassword.visible
+								? "visibility"
+								: "visibility_off"
+						}}
+					</i>
+				</a>
+			</div>
+
+			<p class="control is-expanded">
+				<label for="new-password">New password</label>
+			</p>
+
+			<div id="password-visibility-container">
+				<input
+					class="input"
+					id="new-password"
+					type="password"
+					ref="newPassword"
+					placeholder="Enter new password here..."
+					v-model="validation.newPassword.value"
+					@keyup.enter="changePassword()"
+					@keypress="onInput('newPassword')"
+					@paste="onInput('newPassword')"
+				/>
+
+				<a @click="togglePasswordVisibility('newPassword')">
+					<i class="material-icons">
+						{{
+							!validation.newPassword.visible
+								? "visibility"
+								: "visibility_off"
+						}}
+					</i>
+				</a>
+			</div>
+
+			<transition name="fadein-helpbox">
+				<input-help-box
+					:entered="validation.newPassword.entered"
+					:valid="validation.newPassword.valid"
+					:message="validation.newPassword.message"
+				/>
+			</transition>
+
+			<p class="control">
+				<button
+					id="change-password-button"
+					class="button is-success"
+					@click.prevent="changePassword()"
+				>
+					Change password
+				</button>
+			</p>
 
-		<transition name="fadein-helpbox">
-			<input-help-box
-				:entered="validation.newPassword.entered"
-				:valid="validation.newPassword.valid"
-				:message="validation.newPassword.message"
-			/>
-		</transition>
-
-		<p class="control">
-			<button
-				id="change-password-button"
-				class="button is-success"
-				@click.prevent="changePassword()"
-			>
-				Change password
-			</button>
-		</p>
-
-		<div class="section-margin-bottom" />
+			<div class="section-margin-bottom" />
+		</template>
 
 		<div>
 			<h4 class="section-title">Log out everywhere</h4>

+ 1 - 1
frontend/src/pages/Station/Sidebar/Users.vue

@@ -67,7 +67,7 @@ const copyToClipboard = async () => {
 		await navigator.clipboard.writeText(
 			configStore.urls.client + route.fullPath
 		);
-	} catch (err) {
+	} catch {
 		new Toast("Failed to copy to clipboard.");
 	}
 };

+ 1 - 1
musare.sh

@@ -173,7 +173,7 @@ runDockerCommand()
 
     if [[ ${2} == "build" && ${buildServices} != "" ]]; then
         # shellcheck disable=SC2086
-        ${dockerCompose} build ${buildServices}
+        ${dockerCompose} build --pull ${buildServices}
     fi
 
     if [[ ${2} == "ps" || ${2} == "logs" ]]; then

部分文件因为文件数量过多而无法显示