feat(fm): let each web user link their own NetEase account for personal FM (#164)

With several people sharing one bot, personal FM always followed the one
account the bot was logged in with. Each signed-in (non-guest) web user
can now scan a QR code under Settings → 账户 to link their own NetEase
account; FM they start from the WebUI then comes from their account.

- user_music_cookies table (per user + platform, dropped with the user).
- NeteaseProvider.pollQrLogin returns the cookie without storing it, so
  a personal login can never replace the bot's shared account;
  checkQrCodeStatus is now built on it. withCookie gives a view bound to
  another account.
- /api/me/music/netease: status / qrcode / qrcode/status / unlink, acting
  only on req.user. The cookie never leaves the server.
- POST /api/player/:botId/fm uses the caller's linked account for
  NetEase. Songs still resolve through the shared provider when played.

TeamSpeak chat !fm keeps using the shared account: chat users are not
tied to web accounts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TIANYAO ZHANGandClaude Opus 5.5 committed 2026-09-27 22:02:31 +08:00
1 parent 8ff51ea6e0
commit ac4a12d8bd
11 files changed
+652 -11

No files matched your search

+2
View File
@@ -48,6 +48,7 @@
</form>
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
<PersonalNeteaseAccount v-if="providerOn('netease') && !session.isGuest.value" />
</section>
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
@@ -1161,6 +1162,7 @@ import { Icon } from '@iconify/vue';
import axios from 'axios';
import AvatarUpload from '../components/AvatarUpload.vue';
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
import PersonalNeteaseAccount from '../components/PersonalNeteaseAccount.vue';
import QRCode from 'qrcode';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';