feat(music): add MusicAlbumView, MusicArtistView, and MusicDetailView components

- Implement MusicAlbumView for displaying album details, including tracks and artist links.
- Create MusicArtistView to show artist information and their albums.
- Develop MusicDetailView for individual music tracks, integrating album details and related artists.
- Introduce MusicDetailLayout for consistent layout across music-related views.
- Add MusicArtistSlideView for displaying related artists in a slide format.
- Enhance user experience with loading states and error handling in NoDataFound component.
This commit is contained in:
jxxghp
2026-08-08 12:56:22 +08:00
parent 02e3314853
commit 70bb6e1040
36 changed files with 3084 additions and 494 deletions
+3 -3
View File
@@ -62,7 +62,7 @@ jobs:
- name: Lint
run: yarn lint
typecheck-and-coverage:
typecheck-and-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -82,5 +82,5 @@ jobs:
- name: Typecheck
run: yarn typecheck
- name: Unit tests with coverage
run: yarn test:coverage
- name: Unit tests
run: yarn test:run
+8 -8
View File
@@ -9,7 +9,7 @@
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
| Vitest | 单元测试、组件测试和按需覆盖率报告 | 生产构建与真实浏览器行为 |
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线;SonarJS 不整包展开 recommended,而只显式启用安全和正确性的高信号规则,避免插件升级隐式扩大检查范围。
@@ -22,7 +22,7 @@ Prettier 独立运行,ESLint 中与格式重叠的规则保持关闭。任何
- 最低兼容目标为 Node.js 20.19Node.js 20.19、22 和 24 的开发环境可以安装依赖并运行项目命令。
- 推荐开发环境和 CI 主环境使用 Node.js 24。
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build完整覆盖率门禁在 Node.js 24 执行。
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build主测试 job 使用 Node.js 24 执行单元测试
- Yarn 1 与 `yarn.lock` 继续作为依赖安装事实源,CI 使用 `yarn --frozen-lockfile`
- Node 只用于依赖安装、开发、测试和前端构建;MoviePilot 正式 Docker 镜像使用预构建前端产物,不因开发工具链升级而增加 Node 运行时。
@@ -60,7 +60,7 @@ ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业
5. 只检查 JavaScript、TypeScript、Vue 与相关测试文件;Markdown、JSON、YAML、TOML、构建产物和生成文件不在首阶段扩展范围内。
6. 默认 lint 只读,并提供单独的 `lint:fix`
7. 对确有价值但存在存量的问题使用明确基线或分阶段启用,不通过全仓自动修复制造大面积混合变更。
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:coverage` CI 自动执行。
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:run` CI 自动执行。
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline;修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`
@@ -70,7 +70,7 @@ ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业
yarn --frozen-lockfile
yarn lint
yarn typecheck
yarn test:coverage
yarn test:run
yarn build
```
@@ -80,7 +80,7 @@ yarn build
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job
1. lint 与 `typecheck-and-coverage` 使用不同 job,保持静态检查和测试覆盖率职责独立。
1. lint 与 `typecheck-and-tests` 使用不同 job,保持静态检查和单元测试职责独立。
2. 初始阶段作为普通 check 运行,不立即配置 required check。
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
@@ -135,7 +135,7 @@ Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 2
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
- `yarn lint``yarn typecheck``yarn test:coverage``yarn build` 在格式收敛后全部通过。
- `yarn lint``yarn typecheck``yarn test:run``yarn build` 在格式收敛后全部通过。
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
- workflow 已以非 required 状态稳定运行多个 PR。
@@ -146,7 +146,7 @@ yarn --frozen-lockfile
yarn format:all:check
yarn lint
yarn typecheck
yarn test:coverage
yarn test:run
yarn build
```
@@ -159,7 +159,7 @@ PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代
1. 只对本次新增或修改的文件执行 Prettier。
2. 运行全仓只读 lint。
3. 运行 typecheck。
4. 按影响面运行聚焦测试;提交 PR 前运行覆盖率门禁。
4. 按影响面运行聚焦测试;覆盖率报告按需本地运行,不作为 Actions 门禁。
5. 涉及构建配置、依赖、Vue SFC 或模块联邦时运行生产 build。
编辑器设置不得成为通过验证的前提。文档、Agent 指令和 PR 说明应引用 `yarn` 命令,而不是“在 VS Code 保存一次”或依赖某个 IDE 扩展完成修复。
+3 -3
View File
@@ -72,13 +72,13 @@ tests/
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts``coverage.include`
6. 提交前运行测试、覆盖率、类型检查、lint 和生产构建。
6. 提交前按影响面运行测试、类型检查、lint 和生产构建;覆盖率报告按需本地执行
## 配置边界
Vitest 收集 `src/**/__tests__/**/*.spec.ts``tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护,覆盖率报告写入 `coverage/`。覆盖率配置保留给本地质量分析,不作为 GitHub Actions 的单元测试门禁
## 命令与 CI
@@ -92,4 +92,4 @@ yarn lint
yarn build
```
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`typecheck-and-coverage` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`typecheck-and-tests` job 执行类型检查和单元测试(`yarn test:run`),不要求覆盖率达标;覆盖率可按需在本地运行 `yarn test:coverage`独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
+4
View File
@@ -97,6 +97,10 @@ export const mediaServerOptions = [
value: 'ugreen',
title: i18n.global.t('setting.system.ugreen'),
},
{
value: 'navidrome',
title: i18n.global.t('setting.system.navidrome'),
},
]
export const mediaServerDict = mediaServerOptions.reduce(
+152 -3
View File
@@ -440,10 +440,18 @@ export interface MediaInfo {
artists?: string[]
// 音乐艺术家展示文本
artist?: string
// 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 音乐艺术家标准ID,顺序与 artists 一致
artist_ids?: string[]
// 专辑
album?: string
// 专辑艺术家
album_artist?: string
// 所属专辑标准ID
album_id?: string
// 专辑主类型:Album、EP、Single 等
album_type?: string
// 发行版本
version?: string
// 音轨号
@@ -462,6 +470,145 @@ export interface MediaInfo {
listen_count?: number
}
// 音乐可浏览实体类型
export type MusicEntityType = 'recording' | 'album' | 'artist'
// 音乐专辑下的单个发行版本
export interface MusicRelease {
// 发行版本标准ID
media_id?: string
// 发行版本名称
title?: string
// 发行日期
date?: string
// 发行年份
year?: number
// 发行地区
country?: string
// 发行状态:Official、Promotion 等
status?: string
// 包装形式
packaging?: string
// 介质格式:CD、Vinyl 等
formats?: string[]
// 音轨总数
track_count?: number
// 封面
cover_url?: string
}
// 音乐专辑详情
export interface MusicAlbumInfo {
// 类型,固定为音乐
type?: string
// 实体类型,固定为 album
music_type?: MusicEntityType
// 来源
source?: string
// 专辑标准ID
media_id?: string
// 专辑名称
title?: string
// 艺术家列表
artists?: string[]
// 艺术家展示文本
artist?: string
// 艺术家标准ID,顺序与 artists 一致
artist_ids?: string[]
// 专辑名称,与 title 一致,便于复用音乐展示组件
album?: string
// 专辑主类型
album_type?: string
// 专辑副类型
secondary_types?: string[]
// 首次发行年份
year?: number
// 首次发行日期
release_date?: string
// 音轨总数
total_tracks?: number
// 专辑总时长(秒)
duration?: number
// 封面
cover_url?: string
// 风格
genres?: string[]
// 标签
tags?: string[]
// 主类型与副类型组合文本
category?: string
// 10 分制评分
rating?: number
// 评分人数
rating_votes?: number
// 详情页面
detail_link?: string
// 专辑内的音乐
tracks?: MediaInfo[]
// 同一专辑的其它发行版本
releases?: MusicRelease[]
// 海报
poster_path?: string
// 背景图
backdrop_path?: string
// 摘要
overview?: string
}
// 音乐艺术家详情
export interface MusicArtistInfo {
// 类型,固定为音乐
type?: string
// 实体类型,固定为 artist
music_type?: MusicEntityType
// 来源
source?: string
// 艺术家标准ID
media_id?: string
// 艺术家名称
name?: string
// 名称,与 name 一致,便于复用通用展示组件
title?: string
// 排序名称
sort_name?: string
// 消歧义说明
disambiguation?: string
// 艺术家类型:Person、Group 等
artist_type?: string
// 性别
gender?: string
// 国家代码
country?: string
// 地区
area?: string
// 出道或成立日期
begin_date?: string
// 解散或去世日期
end_date?: string
// 是否已结束活动
ended?: boolean
// 活跃时间区间文本
life_span?: string
// 风格
genres?: string[]
// 标签
tags?: string[]
// 别名
aliases?: string[]
// 与当前艺术家的关系文本
relation?: string
// 艺术家图片
image_url?: string
// 详情页面
detail_link?: string
// 外部站点链接
external_links?: Record<string, string>
// 海报
poster_path?: string
// 摘要
overview?: string
}
// 季信息
export interface MediaSeason {
// 上映日期
@@ -1139,8 +1286,10 @@ export interface MediaStatistic {
tv_count: number
// 电视剧总集数,未获取时为 null
episode_count: number | null
// 用户数量
user_count: number
// 音乐总数
music_count: number
// 用户数量,仪表板已改为展示音乐数量,仅保留接口字段兼容
user_count?: number
// 本月新增电影数量
movie_count_month: number
// 本月新增电视剧数量
@@ -1484,7 +1633,7 @@ export interface StorageConf {
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen/navidrome
type: string
// 配置
config: { [key: string]: any }
+6 -5
View File
@@ -48,8 +48,8 @@ const infoItems = ref([
amount: '0',
},
{
avatar: 'mdi-account',
title: t('common.user'),
avatar: 'mdi-music-box-multiple',
title: t('mediaType.music'),
amount: '0',
},
])
@@ -87,6 +87,7 @@ const getIcon = computed(() => {
case 'plex':
return getLogoUrl('plex')
default:
// Navidrome 等未内置图标的服务器统一回退到通用媒体服务器图标
return getLogoUrl('mediaserver')
}
})
@@ -118,9 +119,9 @@ async function loadMediaStatistic() {
amount: res.tv_count.toLocaleString(),
},
{
avatar: 'mdi-account',
title: t('common.user'),
amount: res.user_count.toLocaleString(),
avatar: 'mdi-music-box-multiple',
title: t('mediaType.music'),
amount: (res.music_count || 0).toLocaleString(),
},
]
}
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts" setup>
import type { MusicArtistInfo } from '@/api/types'
import { buildMusicArtistRoute, getMusicArtistSubtitle } from '@/utils/music'
const router = useRouter()
const props = defineProps({
artist: Object as PropType<MusicArtistInfo>,
width: String,
})
// 退
const imageLoadError = ref(false)
const imageUrl = computed(() => props.artist?.image_url || props.artist?.poster_path || '')
const showImage = computed(() => Boolean(imageUrl.value) && !imageLoadError.value)
const subtitle = computed(() => getMusicArtistSubtitle(props.artist))
/** 打开艺术家详情页。 */
function goArtistDetail() {
if (!props.artist?.media_id) return
router.push(buildMusicArtistRoute(props.artist.media_id, props.artist.name, props.artist.source))
}
</script>
<template>
<VHover>
<template #default="hover">
<div v-bind="hover.props" class="music-artist-card-hover-area">
<VCard
:width="props.width"
class="app-hover-lift-card"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
@click.stop="goArtistDetail"
>
<div class="music-artist-card relative cursor-pointer">
<div style="padding-block-end: 150%">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
<VAvatar size="100" color="surface-variant">
<VImg v-if="showImage" :src="imageUrl" cover @error="imageLoadError = true" />
<VIcon v-else icon="mdi-account-music" size="48" color="medium-emphasis" />
</VAvatar>
</div>
<div class="w-full truncate text-center font-bold">{{ props.artist?.name }}</div>
<div class="overflow-hidden whitespace-normal text-center text-sm text-ellipsis line-clamp-2">
{{ subtitle }}
</div>
</div>
</div>
</div>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
.music-artist-card-hover-area {
inline-size: 100%;
}
.music-artist-card {
background-image: linear-gradient(45deg, rgba(var(--v-theme-background), 0.3), rgba(var(--v-theme-surface), 0.3) 60%);
}
</style>
+223
View File
@@ -0,0 +1,223 @@
<script lang="ts" setup>
import type { MediaInfo } from '@/api/types'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
import {
buildMusicAlbumRoute,
buildMusicArtistRoute,
buildMusicDetailRoute,
buildMusicResourceRoute,
formatMusicDuration,
getMusicArtistLinks,
} from '@/utils/music'
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
music: Object as PropType<MediaInfo>,
})
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
//
const imageLoadError = ref(false)
//
const isSubscribed = ref(false)
//
const artistLinks = computed(() => getMusicArtistLinks(props.music))
// MusicBrainz
const metaChips = computed(() => {
const chips: string[] = []
const category = props.music?.category || props.music?.album_type
if (category) chips.push(category)
const releaseDate = props.music?.release_date || props.music?.year?.toString()
if (releaseDate) chips.push(releaseDate)
const duration = formatMusicDuration(props.music?.duration)
if (duration) chips.push(duration)
if (props.music?.track_number) chips.push(t('music.trackNumber', { number: props.music.track_number }))
if (props.music?.listen_count)
chips.push(t('music.listenCountValue', { count: props.music.listen_count.toLocaleString() }))
return chips
})
const coverUrl = computed(() => props.music?.cover_url || props.music?.poster_path || '')
function getSubscribeStatusKey() {
return `${getMediaSubscribeId(props.music)}::all`
}
const subscribeActions = useMediaSubscribe({
media: () => props.music,
canSubscribe: () => canSubscribe.value,
isSubscribed,
getSubscribeStatusKey,
})
/** 查询当前音乐是否已订阅,用于决定心形图标是实心还是空心。 */
async function checkSubscribeStatus() {
if (!canSubscribe.value || !props.music?.media_id) return
try {
isSubscribed.value = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(), () =>
subscribeActions.checkSubscribe(null),
)
} catch (error) {
console.error(error)
}
}
/** 打开音乐详情页。 */
function goDetail() {
if (!props.music) return
router.push(buildMusicDetailRoute(props.music))
}
/** 打开所属专辑详情页。 */
function goAlbum() {
if (!props.music?.album_id) return
router.push(buildMusicAlbumRoute(props.music.album_id, props.music.album, props.music.source))
}
/** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) {
if (!artistId) return
router.push(buildMusicArtistRoute(artistId, name, props.music?.source))
}
/** 使用音乐元数据身份进入站点资源精确搜索页。 */
function goResource() {
if (!props.music) return
const target = buildMusicResourceRoute(props.music)
if (target) router.push(target)
}
watch(() => props.music?.media_id, checkSubscribeStatus)
onMounted(checkSubscribeStatus)
</script>
<template>
<VCard class="music-card h-100 cursor-pointer" @click="goDetail">
<div class="d-flex pa-4 ga-4">
<VImg
v-if="coverUrl && !imageLoadError"
:src="coverUrl"
width="104"
height="104"
cover
rounded="lg"
class="flex-grow-0 music-card-cover"
@error="imageLoadError = true"
/>
<VSheet v-else width="104" height="104" rounded="lg" class="d-flex align-center justify-center flex-grow-0">
<VIcon icon="mdi-album" size="48" color="medium-emphasis" />
</VSheet>
<div class="music-card-body flex-grow-1">
<div class="d-flex align-start ga-2">
<div class="music-card-title text-h6">{{ props.music?.title }}</div>
<VChip v-if="props.music?.version" size="x-small" variant="tonal" class="flex-grow-0 mt-1">
{{ props.music.version }}
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis music-card-artists">
<template v-if="artistLinks.length">
<template v-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`">
<span v-if="index > 0"> / </span>
<a
v-if="artist.id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goArtist(artist.id, artist.name)"
@keydown.enter.stop="goArtist(artist.id, artist.name)"
>{{ artist.name }}</a
>
<span v-else>{{ artist.name }}</span>
</template>
</template>
<span v-else>{{ t('common.unknown') }}</span>
</div>
<div v-if="props.music?.album" class="text-caption text-medium-emphasis mt-1 music-card-album">
{{ t('music.album') }}
<a
v-if="props.music.album_id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goAlbum"
@keydown.enter.stop="goAlbum"
>{{ props.music.album }}</a
>
<span v-else>{{ props.music.album }}</span>
</div>
<div class="d-flex flex-wrap ga-2 mt-3">
<VChip v-for="chip in metaChips" :key="chip" size="small" variant="tonal">{{ chip }}</VChip>
</div>
</div>
<div class="music-card-actions d-flex flex-column align-center ga-1">
<IconBtn
v-if="canSubscribe"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'medium-emphasis'"
:aria-label="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
@click.stop="subscribeActions.handleSubscribe()"
/>
<IconBtn
v-if="canSearch"
icon="mdi-magnify"
color="medium-emphasis"
:aria-label="t('music.searchResources')"
@click.stop="goResource"
/>
</div>
</div>
</VCard>
</template>
<style scoped>
.music-card-body {
min-inline-size: 0;
}
.music-card-title {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.35;
}
.music-card-artists,
.music-card-album {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.music-card-link {
color: rgb(var(--v-theme-primary));
cursor: pointer;
text-decoration: none;
}
.music-card-link:hover {
text-decoration: underline;
}
.music-card-actions {
flex: 0 0 auto;
}
</style>
+38 -4
View File
@@ -12,6 +12,7 @@ import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { buildMusicDetailRoute } from '@/utils/music'
import noImage from '@images/no-image.jpeg'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
@@ -57,6 +58,10 @@ const $toast = useToast()
//
const imageLoaded = ref(false)
// 使
const backdropLoadError = ref(false)
const posterLoadError = ref(false)
//
const subscribeState = ref<string>(props.media?.state ?? 'P')
@@ -155,6 +160,17 @@ function imageLoadHandler() {
imageLoaded.value = true
}
// poster fallback
function backdropErrorHandler() {
backdropLoadError.value = true
imageLoaded.value = true
}
// 使
function posterErrorHandler() {
posterLoadError.value = true
}
// model """"
function getPercentage() {
const total = props.media?.total_episode || 0
@@ -395,15 +411,25 @@ watch(
},
)
// 沿
watch(
() => [props.media?.id, props.media?.backdrop, props.media?.poster],
() => {
imageLoaded.value = false
backdropLoadError.value = false
posterLoadError.value = false
},
)
// backdrop
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
const url = backdropLoadError.value ? noImage : props.media?.backdrop || props.media?.poster || noImage
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
//
const posterUrl = computed(() => {
const url = props.media?.poster
const url = posterLoadError.value ? noImage : props.media?.poster || noImage
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
@@ -484,7 +510,14 @@ function handleCardClick() {
</IconBtn>
</div>
<template #image v-if="display.smAndUp.value">
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<VImg
:src="backdropUrl || posterUrl"
aspect-ratio="3/2"
cover
@load="imageLoadHandler"
@error="backdropErrorHandler"
position="top"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
@@ -504,6 +537,7 @@ function handleCardClick() {
cover
position="top"
@load="imageLoadHandler"
@error="backdropErrorHandler"
>
<template #placeholder>
<VSkeletonLoader class="h-full w-full" />
@@ -603,7 +637,7 @@ function handleCardClick() {
v-if="imageLoaded"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
<VImg :src="posterUrl" aspect-ratio="2/3" cover @error="posterErrorHandler">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
@@ -141,6 +141,18 @@ describe('SubscribeCard display and progress', () => {
expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
})
it('uses the poster as the background fallback and replaces failed images with the placeholder', async () => {
const { container, media } = await renderCard({ backdrop: undefined })
const image = container.querySelector<HTMLImageElement>('img')
expect(image).not.toBeNull()
expect((image as HTMLImageElement).src).toContain(media.poster || '')
await fireEvent.error(image as HTMLImageElement)
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('no-image'))
})
it.each([
['regular progress', 10, 4, '6 / 10', '60'],
['negative missing episodes', 10, -2, '10 / 10', '100'],
@@ -496,6 +496,92 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'navidrome'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
:hint="t('mediaserver.usernameHint')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
:hint="t('mediaserver.passwordHint')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="mediaServerInfo.sync_interval"
type="number"
min="0"
step="1"
clearable
:label="t('mediaserver.syncInterval')"
:hint="t('mediaserver.syncIntervalHint', { interval: props.defaultSyncInterval ?? 6 })"
persistent-hint
suffix="h"
prepend-inner-icon="mdi-sync"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
<VCol cols="12" md="6">
<VTextField
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts" setup>
import type { MediaInfo } from '@/api/types'
import { buildMusicDetailRoute, formatMusicDuration, getMusicArtistLinks } from '@/utils/music'
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
//
tracks: {
type: Array as PropType<MediaInfo[]>,
default: () => [],
},
// ID
activeMediaId: String,
//
hideArtists: {
type: Boolean,
default: false,
},
})
//
const discs = computed(() => {
const groups = new Map<number, MediaInfo[]>()
props.tracks.forEach(track => {
const disc = track.disc_number || 1
const existing = groups.get(disc)
if (existing) existing.push(track)
else groups.set(disc, [track])
})
return [...groups.entries()].map(([disc, tracks]) => ({ disc, tracks }))
})
const showDiscTitles = computed(() => discs.value.length > 1)
/** 打开曲目对应的音乐详情页。 */
function goTrack(track: MediaInfo) {
if (!track.media_id) return
router.push(buildMusicDetailRoute(track))
}
/** 返回曲目上展示的艺术家文本。 */
function getTrackArtists(track: MediaInfo) {
return getMusicArtistLinks(track)
.map(artist => artist.name)
.join(' / ')
}
</script>
<template>
<div class="music-track-list">
<div v-for="group in discs" :key="group.disc">
<div v-if="showDiscTitles" class="music-track-disc text-caption text-medium-emphasis">
{{ t('music.discNumber', { number: group.disc }) }}
</div>
<VList density="compact" bg-color="transparent">
<VListItem
v-for="track in group.tracks"
:key="track.media_id || track.title"
class="music-track-item"
:class="{ 'music-track-item--active': track.media_id === props.activeMediaId }"
@click="goTrack(track)"
>
<template #prepend>
<span class="music-track-number text-caption text-medium-emphasis">
{{ track.track_number || '-' }}
</span>
</template>
<VListItemTitle class="music-track-title">{{ track.title }}</VListItemTitle>
<VListItemSubtitle v-if="!props.hideArtists && getTrackArtists(track)" class="text-caption">
{{ getTrackArtists(track) }}
</VListItemSubtitle>
<template #append>
<span class="text-caption text-medium-emphasis">{{ formatMusicDuration(track.duration) }}</span>
</template>
</VListItem>
</VList>
</div>
</div>
</template>
<style scoped>
.music-track-disc {
font-weight: 700;
margin-block-start: 0.75rem;
text-transform: uppercase;
}
.music-track-item {
border-radius: var(--app-control-radius);
cursor: pointer;
}
.music-track-item--active {
background: rgba(var(--v-theme-primary), 0.12);
}
.music-track-number {
display: inline-block;
inline-size: 2rem;
text-align: end;
margin-inline-end: 0.75rem;
}
.music-track-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+50 -11
View File
@@ -412,35 +412,72 @@ export default {
},
music: {
title: 'Music Search',
back: 'Back',
subtitle: 'Find music metadata, then search sites, download, and subscribe',
searchDescription: 'Search tracks, albums, or artists',
searchPlaceholder: 'Enter a track, artist, or “artist - track”',
search: 'Search Music',
resultsFor: 'Music results for "{query}"',
searchFromGlobal: 'Use the global search at the top to look up a song, album or artist',
searchDescription: 'Search songs, albums or artists',
noResults: 'No matching music found',
searchResources: 'Search Resources',
subscribe: 'Subscribe',
subscribeSuccess: 'Music subscription added',
unsubscribe: 'Unsubscribe',
album: 'Album',
artist: 'Artist',
albumArtist: 'Album Artist',
albumType: 'Album Type',
albumTracks: 'Tracks in This Album',
albumNotFound: 'Album not found',
albums: 'Albums',
eps: 'EPs',
singles: 'Singles',
tracks: 'Tracks',
track: 'Track',
trackTotal: 'Tracks',
trackNumber: 'Track {number}',
trackCount: '{count} tracks',
discNumber: 'Disc {number}',
releases: 'Releases',
releaseDate: 'Release Date',
duration: 'Duration',
category: 'Type',
listenCount: 'Listen Count',
source: 'MusicBrainz',
ratingVotes: 'Rating Votes',
listenCount: 'Listens',
listenCountValue: '{count} listens',
relatedArtists: 'Related Artists',
artistAlbums: "Artist's Albums",
artistType: 'Artist Type',
artistNotFound: 'Artist not found',
gender: 'Gender',
area: 'Area',
lifeSpan: 'Active Period',
aliases: 'Aliases',
filter: {
mode: 'Mode',
modeChart: 'Top Charts',
modeFresh: 'Fresh Releases',
entity: 'Chart',
entityRecording: 'Top Tracks',
entityAlbum: 'Top Albums',
period: 'Period',
sort: 'Sort',
cover: 'Cover',
scope: 'Scope',
days: 'Time Window',
daysValue: 'Last {days} days',
thisWeek: 'This Week',
thisMonth: 'This Month',
thisYear: 'This Year',
lastWeek: 'Last Week',
lastMonth: 'Last Month',
lastQuarter: 'Last Quarter',
lastHalfYear: 'Last Half Year',
lastYear: 'Last Year',
allTime: 'All Time',
mostListened: 'Most Listened',
leastListened: 'Least Listened',
sortReleaseDate: 'Release Date',
sortArtist: 'Artist',
sortReleaseName: 'Album Name',
released: 'Released',
upcoming: 'Upcoming',
all: 'All',
withCover: 'With Cover',
withCover: 'With Cover Only',
minListenCount: 'Minimum Listens',
},
},
@@ -1107,6 +1144,7 @@ export default {
episodes: 'Episodes',
users: 'Users',
activeUsers: 'Active users',
musicLibrary: 'Music library',
noSchedulers: 'No Background Services',
transferQueue: 'Transfer Queue',
transferProgress: '{completed} / {total} files',
@@ -2040,6 +2078,7 @@ export default {
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: 'Ugreen',
navidrome: 'Navidrome Music Server',
reloadSuccess: 'System configuration has taken effect',
reloadFailed: 'Failed to reload system!',
auxAuthEnable: 'User Auxiliary Authentication',
+47 -8
View File
@@ -404,33 +404,70 @@ export default {
},
music: {
title: '音乐搜索',
back: '返回',
subtitle: '搜索音乐元数据,并进入站点资源搜索、下载和订阅流程',
resultsFor: '“{query}”的音乐搜索结果',
searchFromGlobal: '在顶部全局搜索中输入歌曲、专辑或艺术家进入音乐搜索',
searchDescription: '搜索歌曲、专辑或艺术家',
searchPlaceholder: '输入歌曲、艺术家或“艺术家 - 歌曲”',
search: '搜索音乐',
noResults: '没有找到匹配的音乐',
searchResources: '搜索资源',
subscribe: '订阅',
subscribeSuccess: '音乐订阅添加成功',
unsubscribe: '取消订阅',
album: '专辑',
artist: '艺术家',
albumArtist: '专辑艺术家',
albumType: '专辑类型',
albumTracks: '专辑内的音乐',
albumNotFound: '没有找到该专辑',
albums: '专辑',
eps: 'EP',
singles: '单曲',
tracks: '曲目',
track: '音轨',
trackTotal: '曲目数',
trackNumber: '第 {number} 首',
trackCount: '{count} 首',
discNumber: '碟 {number}',
releases: '发行版本',
releaseDate: '发行日期',
duration: '时长',
category: '类型',
ratingVotes: '评分人数',
listenCount: '收听次数',
source: 'MusicBrainz',
listenCountValue: '收听 {count} 次',
relatedArtists: '关联艺术家',
artistAlbums: '艺术家的专辑',
artistType: '艺术家类型',
artistNotFound: '没有找到该艺术家',
gender: '性别',
area: '地区',
lifeSpan: '活跃时间',
aliases: '别名',
filter: {
mode: '模式',
modeChart: '热门榜单',
modeFresh: '新发行',
entity: '榜单',
entityRecording: '热门单曲',
entityAlbum: '热门专辑',
period: '周期',
sort: '排序',
cover: '封面',
scope: '范围',
days: '时间窗口',
daysValue: '近 {days} 天',
thisWeek: '本周',
thisMonth: '本月',
thisYear: '今年',
lastWeek: '过去一周',
lastMonth: '过去一月',
lastQuarter: '过去一季',
lastHalfYear: '过去半年',
lastYear: '过去一年',
allTime: '全部时间',
mostListened: '收听最多',
leastListened: '收听最少',
sortReleaseDate: '发行日期',
sortArtist: '艺术家',
sortReleaseName: '专辑名称',
released: '已发行',
upcoming: '即将发行',
all: '全部',
withCover: '仅有封面',
minListenCount: '最低收听次数',
@@ -1098,6 +1135,7 @@ export default {
episodes: '剧集',
users: '用户',
activeUsers: '活跃用户',
musicLibrary: '音乐媒体库',
noSchedulers: '没有后台服务',
transferQueue: '整理队列',
transferProgress: '{completed} / {total} 个文件',
@@ -2018,6 +2056,7 @@ export default {
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '绿联影视',
navidrome: 'Navidrome 音乐服务器',
reloadSuccess: '系统配置已生效',
reloadFailed: '重载系统失败!',
auxAuthEnable: '用户辅助认证',
+51 -12
View File
@@ -403,34 +403,71 @@ export default {
settings: '設定',
},
music: {
title: '音樂搜',
back: '返回',
subtitle: '搜索音樂元數據,並進入站點資源搜索、下載和訂閱流程',
searchDescription: '搜歌曲、專輯或藝術家',
searchPlaceholder: '輸入歌曲、藝術家或「藝術家 - 歌曲」',
search: '搜索音樂',
noResults: '沒有找到匹配的音樂',
searchResources: '搜索資源',
title: '音樂搜',
resultsFor: '「{query}」的音樂搜尋結果',
searchFromGlobal: '在頂部全域搜尋中輸入歌曲、專輯或藝術家進入音樂搜尋',
searchDescription: '搜歌曲、專輯或藝術家',
noResults: '沒有找到符合的音樂',
searchResources: '搜尋資源',
subscribe: '訂閱',
subscribeSuccess: '音樂訂閱添加成功',
unsubscribe: '取消訂閱',
album: '專輯',
artist: '藝術家',
albumArtist: '專輯藝術家',
albumType: '專輯類型',
albumTracks: '專輯內的音樂',
albumNotFound: '沒有找到該專輯',
albums: '專輯',
eps: 'EP',
singles: '單曲',
tracks: '曲目',
track: '音軌',
trackTotal: '曲目數',
trackNumber: '第 {number} 首',
trackCount: '{count} 首',
discNumber: '碟 {number}',
releases: '發行版本',
releaseDate: '發行日期',
duration: '時長',
category: '類型',
ratingVotes: '評分人數',
listenCount: '收聽次數',
source: 'MusicBrainz',
listenCountValue: '收聽 {count} 次',
relatedArtists: '關聯藝術家',
artistAlbums: '藝術家的專輯',
artistType: '藝術家類型',
artistNotFound: '沒有找到該藝術家',
gender: '性別',
area: '地區',
lifeSpan: '活躍時間',
aliases: '別名',
filter: {
mode: '模式',
modeChart: '熱門榜單',
modeFresh: '新發行',
entity: '榜單',
entityRecording: '熱門單曲',
entityAlbum: '熱門專輯',
period: '週期',
sort: '排序',
cover: '封面',
scope: '範圍',
days: '時間視窗',
daysValue: '近 {days} 天',
thisWeek: '本週',
thisMonth: '本月',
thisYear: '今年',
lastWeek: '過去一週',
lastMonth: '過去一月',
lastQuarter: '過去一季',
lastHalfYear: '過去半年',
lastYear: '過去一年',
allTime: '全部時間',
mostListened: '收聽最多',
leastListened: '收聽最少',
sortReleaseDate: '發行日期',
sortArtist: '藝術家',
sortReleaseName: '專輯名稱',
released: '已發行',
upcoming: '即將發行',
all: '全部',
withCover: '僅有封面',
minListenCount: '最低收聽次數',
@@ -1098,6 +1135,7 @@ export default {
episodes: '劇集',
users: '用戶',
activeUsers: '活躍用戶',
musicLibrary: '音樂媒體庫',
noSchedulers: '沒有後台服務',
transferQueue: '整理隊列',
transferProgress: '{completed} / {total} 個文件',
@@ -2017,6 +2055,7 @@ export default {
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '綠聯影視',
navidrome: 'Navidrome 音樂伺服器',
reloadSuccess: '系統配置已生效',
reloadFailed: '重載系統失敗!',
auxAuthEnable: '用戶輔助認證',
+164
View File
@@ -0,0 +1,164 @@
import MusicAlbumPage from '@/pages/music-album.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
delete: (...args: unknown[]) => mocks.apiDelete(...args),
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
}),
}))
const album = {
album_type: 'Album',
artist: 'Queen',
artists: ['Queen'],
artist_ids: ['artist-1'],
category: 'Album',
cover_url: 'https://coverartarchive.org/release-group/release-group-1/front-500',
duration: 2580,
genres: ['rock', 'art rock'],
media_id: 'release-group-1',
music_type: 'album',
rating: 8.5,
rating_votes: 44,
release_date: '1975-11-21',
releases: [
{
media_id: 'release-1',
title: 'A Night at the Opera',
date: '1975-11-21',
country: 'GB',
formats: ['12" Vinyl'],
track_count: 12,
},
],
source: 'musicbrainz',
title: 'A Night at the Opera',
total_tracks: 2,
tracks: [
{
media_id: 'recording-1',
title: 'Death on Two Legs',
track_number: 1,
disc_number: 1,
duration: 224,
source: 'musicbrainz',
},
{
media_id: 'recording-2',
title: 'Bohemian Rhapsody',
track_number: 2,
disc_number: 1,
duration: 355,
source: 'musicbrainz',
},
],
type: '音乐',
year: 1975,
}
/** 按请求路径分派专辑详情与订阅状态查询。 */
function mockAlbumRequests(subscribed = false) {
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/album/release-group-1') return Promise.resolve(album)
if (path.startsWith('subscribe/media/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
}
return Promise.resolve([])
})
mocks.apiPost.mockResolvedValue({ data: { id: 1 }, success: true })
}
/** 渲染专辑详情页,统一提供超级用户权限与路由身份。 */
function renderAlbumPage() {
return renderWithProviders(MusicAlbumPage, {
initialRoute: '/music/album?source=musicbrainz&mediaid=release-group-1',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
})
}
describe('music album page', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiDelete.mockReset()
mockAlbumRequests()
})
it('loads the album with its tracks and release variants', async () => {
await renderAlbumPage()
expect(await screen.findByRole('heading', { name: 'A Night at the Opera' })).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {
params: { source: 'musicbrainz' },
})
expect(screen.getByText('Bohemian Rhapsody')).toBeInTheDocument()
expect(screen.getByText('5:55')).toBeInTheDocument()
expect(screen.getByText('rock')).toBeInTheDocument()
expect(screen.getByText(/12" Vinyl/)).toBeInTheDocument()
})
it('opens the music detail page from a track row', async () => {
const { router } = await renderAlbumPage()
await fireEvent.click(await screen.findByText('Bohemian Rhapsody'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/detail'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'recording-2' })
})
it('opens the artist page from the header artist link', async () => {
const { router } = await renderAlbumPage()
await fireEvent.click(await screen.findByRole('link', { name: 'Queen' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
})
it('subscribes the whole album from the heart action', async () => {
await renderAlbumPage()
await fireEvent.click(await screen.findByRole('button', { name: '订阅' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/',
expect.objectContaining({
media_id: 'release-group-1',
media_source: 'musicbrainz',
name: 'A Night at the Opera',
type: '音乐',
year: '1975',
}),
),
)
})
it('switches the heart action to unsubscribe when already subscribed', async () => {
mockAlbumRequests(true)
await renderAlbumPage()
expect(await screen.findByRole('button', { name: '取消订阅' })).toBeInTheDocument()
})
})
+93
View File
@@ -0,0 +1,93 @@
import MusicArtistPage from '@/pages/music-artist.vue'
import { screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
},
}))
const artist = {
aliases: ['皇后乐队'],
area: 'United Kingdom',
artist_type: 'Group',
begin_date: '1970-06-27',
country: 'GB',
detail_link: 'https://musicbrainz.org/artist/artist-1',
disambiguation: 'UK rock group',
external_links: { 'official homepage': 'http://www.queenonline.com/' },
genres: ['rock', 'glam rock'],
life_span: '1970-06-27',
media_id: 'artist-1',
music_type: 'artist',
name: 'Queen',
source: 'musicbrainz',
type: '音乐',
}
/** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */
function renderArtistPage() {
return renderWithProviders(MusicArtistPage, {
initialRoute: '/music/artist?source=musicbrainz&mediaid=artist-1',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
})
}
describe('music artist page', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/artist/artist-1') return Promise.resolve(artist)
return Promise.resolve([])
})
})
it('loads the artist profile with area, active period and aliases', async () => {
await renderArtistPage()
expect(await screen.findByRole('heading', { name: 'Queen' })).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledWith('music/artist/artist-1', {
params: { source: 'musicbrainz' },
})
expect(screen.getByText('UK rock group')).toBeInTheDocument()
expect(screen.getAllByText('United Kingdom').length).toBeGreaterThan(0)
expect(screen.getByText('皇后乐队')).toBeInTheDocument()
expect(screen.getByText('rock')).toBeInTheDocument()
})
it('lists album, EP and single sections that link to the browse list', async () => {
await renderArtistPage()
await waitFor(() => expect(screen.getByText('专辑')).toBeInTheDocument())
expect(screen.getByText('EP')).toBeInTheDocument()
expect(screen.getByText('单曲')).toBeInTheDocument()
const albumsLink = screen.getByText('专辑').closest('a')
expect(albumsLink?.getAttribute('href')).toContain('/browse/music/artist/artist-1/albums')
expect(albumsLink?.getAttribute('href')).toContain('album_type=album')
})
it('exposes the MusicBrainz and official homepage links', async () => {
await renderArtistPage()
expect(await screen.findByText('MusicBrainz')).toBeInTheDocument()
expect(screen.getByText('official homepage')).toBeInTheDocument()
})
it('routes the resource search action to the site resource page', async () => {
const { router } = await renderArtistPage()
const searchButton = await screen.findByRole('button', { name: '搜索资源' })
searchButton.click()
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ keyword: 'Queen', type: '音乐' })
})
})
+158 -36
View File
@@ -4,14 +4,18 @@ import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
delete: (...args: unknown[]) => mocks.apiDelete(...args),
},
}))
@@ -22,54 +26,172 @@ vi.mock('vue-toastification', () => ({
}),
}))
const recording = {
album: '叶惠美',
album_id: 'release-group-1',
album_artist: '周杰伦',
artist: '周杰伦',
artists: ['周杰伦'],
artist_ids: ['artist-1'],
category: 'Album',
cover_url: 'https://coverartarchive.org/release-group/release-group-1/front-500',
duration: 269,
genres: ['mandopop'],
isrc: 'TWA470301234',
media_id: 'recording-1',
music_type: 'recording',
release_date: '2003-07-31',
source: 'musicbrainz',
title: '晴天',
type: '音乐',
year: 2003,
}
const album = {
album_type: 'Album',
artist: '周杰伦',
artists: ['周杰伦'],
artist_ids: ['artist-1'],
media_id: 'release-group-1',
music_type: 'album',
release_date: '2003-07-31',
source: 'musicbrainz',
title: '叶惠美',
total_tracks: 2,
tracks: [
{ media_id: 'recording-1', title: '晴天', track_number: 1, disc_number: 1, duration: 269, source: 'musicbrainz' },
{
media_id: 'recording-2',
title: '以父之名',
track_number: 2,
disc_number: 1,
duration: 341,
source: 'musicbrainz',
},
],
type: '音乐',
}
/** 按请求路径分派单曲详情、专辑详情和订阅状态查询。 */
function mockDetailRequests(subscribed = false) {
mocks.apiPost.mockImplementation((path: string) => {
if (path === 'music/recognize') return Promise.resolve(recording)
return Promise.resolve({ data: { id: 1 }, success: true })
})
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/album/release-group-1') return Promise.resolve(album)
if (path.startsWith('subscribe/media/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
}
return Promise.resolve([])
})
}
/** 渲染音乐详情页,统一提供超级用户权限与路由身份。 */
function renderMusicDetailPage() {
return renderWithProviders(MusicDetailPage, {
initialRoute: '/music/detail?source=musicbrainz&mediaid=recording-1&title=晴天',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
})
}
describe('music detail page', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiDelete.mockReset()
mockDetailRequests()
})
it('loads the recording detail and its album tracks', async () => {
await renderMusicDetailPage()
expect(await screen.findByRole('heading', { name: '晴天' })).toBeInTheDocument()
expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', {
source: 'musicbrainz',
media_id: 'recording-1',
})
await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {
params: { source: 'musicbrainz' },
}),
)
expect(await screen.findByText('以父之名')).toBeInTheDocument()
expect(screen.getByText('TWA470301234')).toBeInTheDocument()
})
it('opens the album page from the album fact link', async () => {
const { router } = await renderMusicDetailPage()
await fireEvent.click(await screen.findByText('叶惠美'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-1' })
})
it('opens the artist page from the header artist link', async () => {
const { router } = await renderMusicDetailPage()
await fireEvent.click(await screen.findByRole('link', { name: '周杰伦' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
})
it('creates a subscription from the heart action', async () => {
await renderMusicDetailPage()
await fireEvent.click(await screen.findByRole('button', { name: '订阅' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/',
expect.objectContaining({
media_id: 'recording-1',
media_source: 'musicbrainz',
name: '晴天',
type: '音乐',
}),
),
)
})
it('switches the heart action to unsubscribe when already subscribed', async () => {
mockDetailRequests(true)
await renderMusicDetailPage()
expect(await screen.findByRole('button', { name: '取消订阅' })).toBeInTheDocument()
})
it('redirects an album identity to the album page', async () => {
mocks.apiPost.mockImplementation((path: string) => {
if (path === 'music/recognize') {
return Promise.resolve({
album: '叶惠美',
artist: '周杰伦',
artists: ['周杰伦'],
cover_url: 'https://coverartarchive.org/release-group/example/front-500',
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
source: 'musicbrainz',
title: '晴天',
type: '音乐',
year: 2003,
})
return Promise.resolve({ ...album, media_id: 'release-group-1', title: '叶惠美' })
}
return Promise.resolve({ data: { id: 1 }, success: true })
})
const { router } = await renderWithProviders(MusicDetailPage, {
initialRoute: '/music/detail?source=musicbrainz&mediaid=release-group-1',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
})
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-1' })
})
it('loads details and connects resource search and subscription actions', async () => {
const { router } = await renderWithProviders(MusicDetailPage, {
initialRoute:
'/music/detail?source=musicbrainz&mediaid=977e6978-139d-425c-bb98-6b0c62d1e45e&title=晴天',
global: {
stubs: { NoDataFound: true },
},
})
it('routes the resource search action to the site resource page', async () => {
const { router } = await renderMusicDetailPage()
expect(await screen.findByText('晴天')).toBeInTheDocument()
expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', {
source: 'musicbrainz',
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
})
await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' }))
await fireEvent.click(screen.getByRole('button', { name: '订阅' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith('subscribe/', {
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
media_source: 'musicbrainz',
name: '晴天',
type: '音乐',
year: '2003',
}),
)
await fireEvent.click(screen.getByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1',
type: '音乐',
})
})
})
+119 -52
View File
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
@@ -14,6 +15,7 @@ vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
delete: (...args: unknown[]) => mocks.apiDelete(...args),
},
}))
@@ -24,71 +26,106 @@ vi.mock('vue-toastification', () => ({
}),
}))
const musicResult = {
album: '叶惠美',
album_id: 'release-group-1',
album_type: 'Album',
artist: '周杰伦',
artists: ['周杰伦'],
artist_ids: ['artist-1'],
category: 'Album',
duration: 269,
media_id: 'recording-1',
music_type: 'recording',
release_date: '2003-07-31',
source: 'musicbrainz',
title: '晴天',
type: '音乐',
year: 2003,
}
/** 按请求路径分派音乐搜索与订阅状态查询。 */
function mockSearchAndSubscribeState(subscribed: boolean) {
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/search') return Promise.resolve([musicResult])
if (path.startsWith('subscribe/media/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
}
return Promise.resolve([])
})
}
/** 渲染音乐搜索结果页,统一提供超级用户权限与路由关键词。 */
function renderMusicPage() {
return renderWithProviders(MusicPage, {
initialRoute: '/music?query=晴天',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, VPageContentTitle: true } },
})
}
describe('music page', () => {
beforeEach(() => {
mocks.apiGet.mockResolvedValue([
{
album: '叶惠美',
artist: '周杰伦',
artists: ['周杰伦'],
media_id: 'recording-1',
source: 'musicbrainz',
title: '晴天',
type: '音乐',
year: 2003,
},
])
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiDelete.mockReset()
mockSearchAndSubscribeState(false)
mocks.apiPost.mockResolvedValue({ data: { id: 1 }, success: true })
})
it('searches metadata and connects resource search and subscription actions', async () => {
const { router } = await renderWithProviders(MusicPage, {
initialRoute: '/music?query=晴天',
global: {
stubs: {
NoDataFound: true,
VPageContentTitle: true,
},
},
})
it('searches from the route keyword without an in-page search box', async () => {
await renderMusicPage()
await waitFor(() =>
expect(mocks.apiGet).toHaveBeenCalledWith('music/search', {
params: { count: 30, query: '晴天' },
}),
)
const resourceButton = await screen.findByRole('button', { name: '搜索资源' })
await fireEvent.click(resourceButton)
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1',
type: '音乐',
})
await router.push('/music?query=晴天')
await fireEvent.click(screen.getByRole('button', { name: '订阅' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith('subscribe/', {
media_id: 'recording-1',
media_source: 'musicbrainz',
name: '晴天',
type: '音乐',
year: '2003',
}),
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('音乐订阅添加成功')
expect(screen.queryByRole('button', { name: '搜索音乐' })).not.toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('opens a MusicBrainz result on the dedicated music detail page', async () => {
const { router } = await renderWithProviders(MusicPage, {
initialRoute: '/music?query=晴天',
global: {
stubs: {
NoDataFound: true,
VPageContentTitle: true,
},
},
})
it('shows album, artist, release date and duration on the result card', async () => {
await renderMusicPage()
expect(await screen.findByText('晴天')).toBeInTheDocument()
expect(screen.getByText('周杰伦')).toBeInTheDocument()
expect(screen.getByText('叶惠美')).toBeInTheDocument()
expect(screen.getByText('2003-07-31')).toBeInTheDocument()
expect(screen.getByText('4:29')).toBeInTheDocument()
expect(screen.getByText('Album')).toBeInTheDocument()
})
it('offers a subscribe action when the music is not subscribed yet', async () => {
await renderMusicPage()
const subscribeButton = await screen.findByRole('button', { name: '订阅' })
await fireEvent.click(subscribeButton)
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/',
expect.objectContaining({
media_id: 'recording-1',
media_source: 'musicbrainz',
name: '晴天',
type: '音乐',
}),
),
)
})
it('switches the heart action to unsubscribe when already subscribed', async () => {
mockSearchAndSubscribeState(true)
await renderMusicPage()
expect(await screen.findByRole('button', { name: '取消订阅' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '订阅' })).not.toBeInTheDocument()
})
it('opens the music detail page from a result card', async () => {
const { router } = await renderMusicPage()
await fireEvent.click(await screen.findByText('晴天'))
@@ -99,4 +136,34 @@ describe('music page', () => {
title: '晴天',
})
})
it('opens the album page from the result card album link', async () => {
const { router } = await renderMusicPage()
await fireEvent.click(await screen.findByText('叶惠美'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-1' })
})
it('opens the artist page from the result card artist link', async () => {
const { router } = await renderMusicPage()
await fireEvent.click(await screen.findByText('周杰伦'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
})
it('routes the resource search action to the site resource page', async () => {
const { router } = await renderMusicPage()
await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:recording-1',
type: '音乐',
})
})
})
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import MusicAlbumView from '@/views/discover/MusicAlbumView.vue'
//
const route = useRoute()
// MusicBrainz Release Group ID
const mediaid = computed(() => route.query?.mediaid?.toString())
//
const source = computed(() => route.query?.source?.toString() || 'musicbrainz')
</script>
<template>
<div>
<MusicAlbumView :mediaid="mediaid" :source="source" />
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import MusicArtistView from '@/views/discover/MusicArtistView.vue'
//
const route = useRoute()
// MusicBrainz Artist ID
const mediaid = computed(() => route.query?.mediaid?.toString())
//
const source = computed(() => route.query?.source?.toString() || 'musicbrainz')
</script>
<template>
<div>
<MusicArtistView :mediaid="mediaid" :source="source" />
</div>
</template>
+8 -138
View File
@@ -1,148 +1,18 @@
<script setup lang="ts">
import api from '@/api'
import type { ApiResponse, MediaInfo } from '@/api/types'
import { buildMusicResourceRoute, getMusicKey } from '@/utils/music'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import MusicDetailView from '@/views/discover/MusicDetailView.vue'
const { t } = useI18n()
//
const route = useRoute()
const router = useRouter()
const toast = useToast()
const loading = ref(false)
const subscribing = ref(false)
const imageError = ref(false)
const music = ref<MediaInfo>()
// MusicBrainz ID
const mediaid = computed(() => route.query?.mediaid?.toString())
const source = computed(() => route.query.source?.toString() || '')
const mediaId = computed(() => route.query.mediaid?.toString() || '')
/** 加载路由指定的音乐详情。 */
async function loadMusicDetail() {
if (!source.value || !mediaId.value) return
loading.value = true
imageError.value = false
try {
music.value = await api.post('music/recognize', {
source: source.value,
media_id: mediaId.value,
})
} catch (error) {
console.error(error)
music.value = undefined
} finally {
loading.value = false
}
}
/** 从详情页进入现有站点资源搜索。 */
function searchResources() {
if (!music.value) return
const target = buildMusicResourceRoute(music.value)
if (target) router.push(target)
}
/** 从详情页创建音乐订阅,后端会以发行封面写入订阅海报。 */
async function subscribeMusic() {
if (!music.value?.source || !music.value.media_id || subscribing.value) return
subscribing.value = true
try {
const result = (await api.post('subscribe/', {
name: music.value.title,
year: music.value.year?.toString(),
type: '音乐',
media_source: music.value.source,
media_id: music.value.media_id,
})) as ApiResponse<{ id?: number }>
if (result.success) toast.success(t('music.subscribeSuccess'))
else toast.error(result.message || t('common.failed'))
} catch (error) {
console.error(error)
toast.error(t('common.failed'))
} finally {
subscribing.value = false
}
}
/** 将秒数格式化为详情页使用的分钟和秒。 */
function formatDuration(seconds?: number) {
if (!seconds) return ''
const minutes = Math.floor(seconds / 60)
const remainder = Math.floor(seconds % 60)
return `${minutes}:${remainder.toString().padStart(2, '0')}`
}
watch([source, mediaId], loadMusicDetail, { immediate: true })
//
const source = computed(() => route.query?.source?.toString() || 'musicbrainz')
</script>
<template>
<div class="music-detail-page">
<VBtn variant="text" prepend-icon="mdi-arrow-left" class="mb-3" @click="router.back()">
{{ t('music.back') }}
</VBtn>
<VSkeletonLoader v-if="loading" type="image, article, actions" />
<NoDataFound v-else-if="!music" :title="t('music.noResults')" />
<VCard v-else :key="getMusicKey(music)" class="overflow-hidden">
<VRow no-gutters>
<VCol cols="12" md="4" lg="3">
<VImg
v-if="(music.cover_url || music.poster_path) && !imageError"
:src="music.cover_url || music.poster_path"
aspect-ratio="1"
cover
@error="imageError = true"
/>
<VSheet v-else aspect-ratio="1" class="music-detail-cover d-flex align-center justify-center">
<VIcon icon="mdi-album" size="96" color="medium-emphasis" />
</VSheet>
</VCol>
<VCol cols="12" md="8" lg="9">
<VCardItem class="pa-6">
<VCardTitle class="text-h4 text-wrap">{{ music.title }}</VCardTitle>
<VCardSubtitle class="text-h6 mt-2">
{{ music.artist || music.artists?.join(' / ') || t('common.unknown') }}
</VCardSubtitle>
</VCardItem>
<VCardText class="px-6 pb-6">
<VList bg-color="transparent" density="compact">
<VListItem v-if="music.album" :title="t('music.album')" :subtitle="music.album" />
<VListItem v-if="music.album_artist" :title="t('music.albumArtist')" :subtitle="music.album_artist" />
<VListItem v-if="music.release_date || music.year" :title="t('music.releaseDate')" :subtitle="music.release_date || music.year?.toString()" />
<VListItem v-if="music.duration" :title="t('music.duration')" :subtitle="formatDuration(music.duration)" />
<VListItem v-if="music.isrc" title="ISRC" :subtitle="music.isrc" />
<VListItem v-if="music.category" :title="t('music.category')" :subtitle="music.category" />
<VListItem v-if="music.listen_count" :title="t('music.listenCount')" :subtitle="music.listen_count.toLocaleString()" />
</VList>
</VCardText>
<VCardActions class="px-6 pb-6 ga-3">
<VBtn color="primary" variant="tonal" prepend-icon="mdi-magnify" @click="searchResources">
{{ t('music.searchResources') }}
</VBtn>
<VBtn
color="primary"
variant="tonal"
prepend-icon="mdi-rss"
:loading="subscribing"
@click="subscribeMusic"
>
{{ t('music.subscribe') }}
</VBtn>
</VCardActions>
</VCol>
</VRow>
</VCard>
<div>
<MusicDetailView :mediaid="mediaid" :source="source" />
</div>
</template>
<style scoped>
.music-detail-page {
max-width: 1200px;
margin-inline: auto;
}
.music-detail-cover {
min-block-size: 280px;
}
</style>
+18 -149
View File
@@ -1,35 +1,32 @@
<script setup lang="ts">
import api from '@/api'
import type { ApiResponse, MediaInfo } from '@/api/types'
import type { MediaInfo } from '@/api/types'
import MusicCard from '@/components/cards/MusicCard.vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { buildMusicDetailRoute, buildMusicResourceRoute, getMusicKey } from '@/utils/music'
import { getMusicKey } from '@/utils/music'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const toast = useToast()
const query = ref('')
const loading = ref(false)
const searched = ref(false)
const results = ref<MediaInfo[]>([])
const subscribingIds = ref(new Set<string>())
const imageErrorIds = ref(new Set<string>())
if (route.query.query) {
query.value = route.query.query.toString()
}
//
const query = computed(() => route.query.query?.toString().trim() || '')
/** 调用统一音乐元数据接口搜索候选。 */
async function searchMusic() {
const keyword = query.value.trim()
if (!keyword || loading.value) return
if (!query.value) {
results.value = []
searched.value = false
return
}
loading.value = true
searched.value = true
try {
results.value = (await api.get('music/search', { params: { query: keyword, count: 30 } })) || []
results.value = (await api.get('music/search', { params: { query: query.value, count: 30 } })) || []
} catch (error) {
console.error(error)
results.value = []
@@ -38,145 +35,21 @@ async function searchMusic() {
}
}
/** 使用音乐元数据身份进入现有站点资源精确搜索页。 */
function searchResources(item: MediaInfo) {
const target = buildMusicResourceRoute(item)
if (target) router.push(target)
}
/** 打开选中音乐的标准详情页。 */
function viewMusicDetail(item: MediaInfo) {
router.push(buildMusicDetailRoute(item))
}
/** 记录失效封面,让音乐卡片改用专辑占位图标。 */
function markImageError(item: MediaInfo) {
imageErrorIds.value = new Set(imageErrorIds.value).add(getMusicKey(item))
}
/** 将选中的音乐目标写入现有订阅表和订阅调度流程。 */
async function subscribeMusic(item: MediaInfo) {
if (!item.source || !item.media_id) return
const key = getMusicKey(item)
if (subscribingIds.value.has(key)) return
subscribingIds.value = new Set(subscribingIds.value).add(key)
try {
const result = (await api.post('subscribe/', {
name: item.title,
year: item.year?.toString(),
type: '音乐',
media_source: item.source,
media_id: item.media_id,
})) as ApiResponse<{ id?: number }>
if (result.success) {
toast.success(t('music.subscribeSuccess'))
} else {
toast.error(result.message || t('common.failed'))
}
} catch (error) {
console.error(error)
toast.error(t('common.failed'))
} finally {
const nextIds = new Set(subscribingIds.value)
nextIds.delete(key)
subscribingIds.value = nextIds
}
}
/** 将秒数格式化为音乐列表需要的短时长。 */
function formatDuration(seconds?: number) {
if (!seconds) return ''
const minutes = Math.floor(seconds / 60)
const remainder = Math.floor(seconds % 60)
return `${minutes}:${remainder.toString().padStart(2, '0')}`
}
onMounted(() => {
if (query.value) searchMusic()
})
watch(query, searchMusic, { immediate: true })
</script>
<template>
<div class="music-search-page">
<VPageContentTitle :title="t('music.title')" />
<VPageContentTitle :title="query ? t('music.resultsFor', { query }) : t('music.title')" />
<VCard class="mb-6" variant="tonal">
<VCardText>
<div class="text-body-2 text-medium-emphasis mb-4">{{ t('music.subtitle') }}</div>
<div class="d-flex flex-column flex-sm-row ga-3">
<VTextField
v-model="query"
:placeholder="t('music.searchPlaceholder')"
prepend-inner-icon="mdi-music-note"
variant="outlined"
hide-details
clearable
autofocus
@keyup.enter="searchMusic"
/>
<VBtn color="primary" prepend-icon="mdi-magnify" :loading="loading" @click="searchMusic">
{{ t('music.search') }}
</VBtn>
</div>
</VCardText>
</VCard>
<VRow v-if="results.length">
<VSkeletonLoader v-if="loading" type="card, card, card" />
<VRow v-else-if="results.length">
<VCol v-for="item in results" :key="getMusicKey(item)" cols="12" md="6" xl="4">
<VCard class="h-100 cursor-pointer" @click="viewMusicDetail(item)">
<div class="d-flex pa-4 ga-4">
<VImg
v-if="(item.cover_url || item.poster_path) && !imageErrorIds.has(getMusicKey(item))"
:src="item.cover_url || item.poster_path"
width="104"
height="104"
cover
rounded="lg"
class="flex-grow-0"
@error="markImageError(item)"
/>
<VSheet v-else width="104" height="104" rounded="lg" class="d-flex align-center justify-center flex-grow-0">
<VIcon icon="mdi-album" size="48" color="medium-emphasis" />
</VSheet>
<div class="min-w-0 flex-grow-1">
<div class="text-h6 text-truncate">{{ item.title }}</div>
<div class="text-body-2 text-medium-emphasis text-truncate">
{{ item.artist || item.artists?.join(' / ') || t('common.unknown') }}
</div>
<div v-if="item.album" class="text-caption text-medium-emphasis text-truncate mt-1">
{{ t('music.album') }}{{ item.album }}
</div>
<div class="d-flex flex-wrap ga-2 mt-3">
<VChip v-if="item.year" size="small" variant="tonal">{{ item.year }}</VChip>
<VChip v-if="item.duration" size="small" variant="tonal">{{ formatDuration(item.duration) }}</VChip>
<VChip size="small" variant="tonal" color="primary">{{ t('music.source') }}</VChip>
</div>
</div>
</div>
<VCardActions class="px-4 pb-4 pt-0">
<VBtn variant="tonal" color="primary" prepend-icon="mdi-magnify" @click.stop="searchResources(item)">
{{ t('music.searchResources') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
variant="tonal"
prepend-icon="mdi-rss"
:loading="subscribingIds.has(getMusicKey(item))"
@click.stop="subscribeMusic(item)"
>
{{ t('music.subscribe') }}
</VBtn>
</VCardActions>
</VCard>
<MusicCard :music="item" />
</VCol>
</VRow>
<NoDataFound v-else-if="searched && !loading" :title="t('music.noResults')" />
<VSkeletonLoader v-else-if="loading" type="card, card, card" />
<NoDataFound v-else-if="searched" :error-title="t('music.noResults')" />
<NoDataFound v-else :error-title="t('music.title')" :error-description="t('music.searchFromGlobal')" />
</div>
</template>
@@ -185,8 +58,4 @@ onMounted(() => {
max-width: 1440px;
margin-inline: auto;
}
.min-w-0 {
min-width: 0;
}
</style>
+14
View File
@@ -92,6 +92,20 @@ const router = createRouter({
requiresAuth: true,
},
},
{
path: '/music/album',
component: () => import('../pages/music-album.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/music/artist',
component: () => import('../pages/music-artist.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/subscribe/movie',
component: () => import('../pages/subscribe.vue'),
+115
View File
@@ -0,0 +1,115 @@
import {
buildMusicAlbumRoute,
buildMusicArtistRoute,
buildMusicDetailRoute,
buildMusicResourceRoute,
formatMusicDuration,
getMusicArtistLinks,
getMusicArtistSubtitle,
getMusicKey,
} from '@/utils/music'
import { describe, expect, it } from 'vitest'
describe('music utils', () => {
it('routes a recording to the music detail page', () => {
expect(buildMusicDetailRoute({ source: 'musicbrainz', media_id: 'recording-1', title: '晴天' })).toEqual({
path: '/music/detail',
query: { source: 'musicbrainz', mediaid: 'recording-1', title: '晴天' },
})
})
it('routes an album entity to the album page', () => {
expect(
buildMusicDetailRoute({
source: 'musicbrainz',
media_id: 'release-group-1',
music_type: 'album',
title: '叶惠美',
}),
).toEqual({
path: '/music/album',
query: { source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
})
})
it('routes an artist entity to the artist page', () => {
expect(
buildMusicDetailRoute({
source: 'musicbrainz',
media_id: 'artist-1',
music_type: 'artist',
name: 'Queen',
}),
).toEqual({
path: '/music/artist',
query: { source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
})
})
it('falls back to the search page without a standard identity', () => {
expect(buildMusicDetailRoute({ title: '晴天' })).toEqual({
path: '/music',
query: { query: '晴天' },
})
})
it('builds album and artist routes with the default source', () => {
expect(buildMusicAlbumRoute('release-group-1', '叶惠美')).toEqual({
path: '/music/album',
query: { source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
})
expect(buildMusicArtistRoute('artist-1', 'Queen')).toEqual({
path: '/music/artist',
query: { source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
})
})
it('builds the site resource route from the metadata identity', () => {
expect(
buildMusicResourceRoute({
source: 'musicbrainz',
media_id: 'recording-1',
title: '晴天',
year: '2003',
} as never),
).toMatchObject({
path: '/resource',
query: { keyword: 'musicbrainz:recording-1', type: '音乐' },
})
})
it('keeps the entity type inside the list key so albums and tracks never collide', () => {
const track = getMusicKey({ source: 'musicbrainz', media_id: 'same-id' })
const album = getMusicKey({ source: 'musicbrainz', media_id: 'same-id', music_type: 'album' })
expect(track).not.toEqual(album)
})
it('pairs artist names with their standard ids by position', () => {
expect(getMusicArtistLinks({ artists: ['A', 'B'], artist_ids: ['', 'artist-b'] })).toEqual([
{ name: 'A' },
{ name: 'B', id: 'artist-b' },
])
})
it('falls back to the joined artist text when the list is missing', () => {
expect(getMusicArtistLinks({ artist: 'A / B' })).toEqual([{ name: 'A / B' }])
})
it('formats durations with and without hours', () => {
expect(formatMusicDuration(269)).toBe('4:29')
expect(formatMusicDuration(5)).toBe('0:05')
expect(formatMusicDuration(3725)).toBe('1:02:05')
expect(formatMusicDuration(undefined)).toBe('')
})
it('joins the artist relation, type and disambiguation into a subtitle', () => {
expect(
getMusicArtistSubtitle({
relation: 'member of band',
artist_type: 'Person',
disambiguation: 'guitarist',
}),
).toBe('member of band · Person · guitarist')
})
})
+58 -11
View File
@@ -1,15 +1,22 @@
import type { MediaInfo } from '@/api/types'
import type { MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicEntityType } from '@/api/types'
import type { RouteLocationRaw } from 'vue-router'
export interface MusicRouteTarget {
source?: string
media_source?: string
media_id?: string | number
music_type?: MusicEntityType
title?: string
name?: string
year?: string | number
}
// 音乐体系中一位可跳转的艺术家
export interface MusicArtistLink {
name: string
id?: string
}
/** 返回音乐对象可用于路由和订阅的统一来源。 */
export function getMusicSource(item: MusicRouteTarget): string | undefined {
return item.source || item.media_source
@@ -18,31 +25,42 @@ export function getMusicSource(item: MusicRouteTarget): string | undefined {
/** 返回音乐候选在列表和状态缓存中的稳定身份。 */
export function getMusicKey(item: MusicRouteTarget): string {
const source = getMusicSource(item) || 'music'
return `${source}:${item.media_id || `${item.title || item.name}-${item.year || ''}`}`
const entity = item.music_type || 'recording'
return `${source}:${entity}:${item.media_id || `${item.title || item.name}-${item.year || ''}`}`
}
/** 构造音乐详情路由,缺少标准身份时回退到音乐搜索页。 */
/** 构造专辑详情路由。 */
export function buildMusicAlbumRoute(albumId: string, title?: string, source = 'musicbrainz'): RouteLocationRaw {
return { path: '/music/album', query: { source, mediaid: albumId, title } }
}
/** 构造艺术家详情路由。 */
export function buildMusicArtistRoute(artistId: string, name?: string, source = 'musicbrainz'): RouteLocationRaw {
return { path: '/music/artist', query: { source, mediaid: artistId, title: name } }
}
/** 按音乐实体类型构造详情路由,缺少标准身份时回退到音乐搜索页。 */
export function buildMusicDetailRoute(item: MusicRouteTarget): RouteLocationRaw {
const source = getMusicSource(item)
if (!source || !item.media_id) {
return {
path: '/music',
query: { query: item.title || item.name },
}
const mediaId = item.media_id?.toString()
if (!source || !mediaId) {
return { path: '/music', query: { query: item.title || item.name } }
}
if (item.music_type === 'album') return buildMusicAlbumRoute(mediaId, item.title || item.name, source)
if (item.music_type === 'artist') return buildMusicArtistRoute(mediaId, item.name || item.title, source)
return {
path: '/music/detail',
query: {
source,
mediaid: item.media_id.toString(),
mediaid: mediaId,
title: item.title || item.name,
},
}
}
/** 构造音乐元数据身份对应的站点资源搜索路由。 */
export function buildMusicResourceRoute(item: MediaInfo): RouteLocationRaw | undefined {
const source = getMusicSource(item)
export function buildMusicResourceRoute(item: MediaInfo | MusicAlbumInfo): RouteLocationRaw | undefined {
const source = getMusicSource(item as MusicRouteTarget)
if (!source || !item.media_id) return undefined
return {
path: '/resource',
@@ -56,3 +74,32 @@ export function buildMusicResourceRoute(item: MediaInfo): RouteLocationRaw | und
},
}
}
/** 把音乐信息整理成可点击跳转的艺术家列表,ID 与名称按下标对应。 */
export function getMusicArtistLinks(
item?: Pick<MediaInfo, 'artists' | 'artist_ids' | 'artist'> | MusicAlbumInfo,
): MusicArtistLink[] {
if (!item) return []
const names = item.artists?.length ? item.artists : item.artist ? [item.artist] : []
return names.map((name, index) => {
const id = item.artist_ids?.[index]
return id ? { name, id } : { name }
})
}
/** 把秒数格式化为音乐展示使用的时长文本。 */
export function formatMusicDuration(seconds?: number): string {
if (!seconds || seconds <= 0) return ''
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainder = Math.floor(seconds % 60)
const paddedSeconds = remainder.toString().padStart(2, '0')
if (!hours) return `${minutes}:${paddedSeconds}`
return `${hours}:${minutes.toString().padStart(2, '0')}:${paddedSeconds}`
}
/** 返回艺术家卡片和详情页展示用的副标题。 */
export function getMusicArtistSubtitle(artist?: MusicArtistInfo): string {
if (!artist) return ''
return [artist.relation, artist.artist_type, artist.disambiguation].filter(Boolean).join(' · ')
}
@@ -16,7 +16,7 @@ const tvCount = ref(Number(currentSnapshot?.value.tv_count) || 0)
const episodeCount = ref<number | null>(
currentSnapshot?.value.episode_count == null ? null : Number(currentSnapshot.value.episode_count) || 0,
)
const userCount = ref(Number(currentSnapshot?.value.user_count) || 0)
const musicCount = ref(Number(currentSnapshot?.value.music_count) || 0)
const movieCountMonth = ref(Number(currentSnapshot?.value.movie_count_month) || 0)
const tvCountMonth = ref(Number(currentSnapshot?.value.tv_count_month) || 0)
const episodeCountMonth = ref(Number(currentSnapshot?.value.episode_count_month) || 0)
@@ -39,7 +39,7 @@ const animatedEpisodeCount = useAnimatedDashboardNumber(
},
)
const animatedUserCount = useAnimatedDashboardNumber(userCount, {
const animatedMusicCount = useAnimatedDashboardNumber(musicCount, {
delay: 180,
duration: 720,
})
@@ -67,9 +67,9 @@ const statistics = computed(() => [
addition: episodeCountMonth.value,
},
{
title: t('dashboard.users'),
stats: formatDashboardCount(animatedUserCount.value),
icon: 'mdi-account',
title: t('mediaType.music'),
stats: formatDashboardCount(animatedMusicCount.value),
icon: 'mdi-music-box-multiple',
color: 'info',
addition: null,
},
@@ -86,7 +86,7 @@ async function loadMediaStatistic() {
movie_count: Number(res.movie_count) || 0,
tv_count: Number(res.tv_count) || 0,
episode_count: res.episode_count == null ? null : Number(res.episode_count) || 0,
user_count: Number(res.user_count) || 0,
music_count: Number(res.music_count) || 0,
movie_count_month: Number(res.movie_count_month) || 0,
tv_count_month: Number(res.tv_count_month) || 0,
episode_count_month: Number(res.episode_count_month) || 0,
@@ -95,7 +95,7 @@ async function loadMediaStatistic() {
movieCount.value = statistic.movie_count
tvCount.value = statistic.tv_count
episodeCount.value = statistic.episode_count
userCount.value = statistic.user_count
musicCount.value = statistic.music_count
movieCountMonth.value = statistic.movie_count_month
tvCountMonth.value = statistic.tv_count_month
episodeCountMonth.value = statistic.episode_count_month
@@ -132,7 +132,7 @@ onActivated(() => {
<span v-if="item.addition !== null" class="dashboard-stat-addition">
{{ t('dashboard.monthlyAddition', { count: item.addition }) }}
</span>
<span v-else class="dashboard-stat-addition text-medium-emphasis">{{ t('dashboard.activeUsers') }}</span>
<span v-else class="dashboard-stat-addition text-medium-emphasis">{{ t('dashboard.musicLibrary') }}</span>
</div>
</div>
</div>
@@ -35,7 +35,7 @@ describe('AnalyticsMediaStatistic', () => {
movie_count: 12,
tv_count: 34,
episode_count: 56,
user_count: 7,
music_count: 7,
movie_count_month: 1,
tv_count_month: 2,
episode_count_month: 3,
@@ -57,7 +57,7 @@ describe('AnalyticsMediaStatistic', () => {
movie_count: 21,
tv_count: 43,
episode_count: 65,
user_count: 8,
music_count: 8,
movie_count_month: 4,
tv_count_month: 5,
episode_count_month: 6,
@@ -68,7 +68,7 @@ describe('AnalyticsMediaStatistic', () => {
movie_count: 21,
tv_count: 43,
episode_count: 65,
user_count: 8,
music_count: 8,
})
})
@@ -81,7 +81,7 @@ describe('AnalyticsMediaStatistic', () => {
movie_count: 12,
tv_count: 34,
episode_count: null,
user_count: 7,
music_count: 7,
movie_count_month: 1,
tv_count_month: 2,
episode_count_month: 3,
@@ -116,7 +116,7 @@ describe('AnalyticsMediaStatistic', () => {
movie_count: 21,
tv_count: 43,
episode_count: 65,
user_count: 8,
music_count: 8,
movie_count_month: 4,
tv_count_month: 5,
episode_count_month: 6,
@@ -127,7 +127,7 @@ describe('AnalyticsMediaStatistic', () => {
movie_count: 12,
tv_count: 34,
episode_count: 56,
user_count: 7,
music_count: 7,
movie_count_month: 1,
tv_count_month: 2,
episode_count_month: 3,
@@ -140,7 +140,7 @@ describe('AnalyticsMediaStatistic', () => {
movie_count: 21,
tv_count: 43,
episode_count: 65,
user_count: 8,
music_count: 8,
})
})
})
+317
View File
@@ -0,0 +1,317 @@
<script lang="ts" setup>
import api from '@/api'
import type { MediaInfo, MusicAlbumInfo } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import MusicTrackList from '@/components/music/MusicTrackList.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { buildMusicArtistRoute, buildMusicResourceRoute, formatMusicDuration, getMusicArtistLinks } from '@/utils/music'
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
// MusicBrainz Release Group ID
mediaid: String,
//
source: {
type: String,
default: 'musicbrainz',
},
})
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
const isRefreshed = ref(false)
const album = ref<MusicAlbumInfo>()
const isSubscribed = ref(false)
//
const artistLinks = computed(() => getMusicArtistLinks(album.value))
//
const primaryArtistId = computed(() => artistLinks.value.find(artist => artist.id)?.id)
//
const albumMedia = computed<MediaInfo | undefined>(() => {
if (!album.value) return undefined
return {
...album.value,
type: '音乐',
year: album.value.year?.toString(),
} as MediaInfo
})
const attributes = computed(() => {
const values: string[] = []
if (album.value?.category) values.push(album.value.category)
if (album.value?.release_date) values.push(album.value.release_date)
if (album.value?.total_tracks) values.push(t('music.trackCount', { count: album.value.total_tracks }))
const duration = formatMusicDuration(album.value?.duration)
if (duration) values.push(duration)
return values
})
function getSubscribeStatusKey() {
return `${getMediaSubscribeId(albumMedia.value)}::all`
}
const subscribeActions = useMediaSubscribe({
media: () => albumMedia.value,
canSubscribe: () => canSubscribe.value,
isSubscribed,
getSubscribeStatusKey,
})
/** 加载专辑详情、曲目列表和发行版本。 */
async function loadAlbumDetail() {
if (!props.source || !props.mediaid) return
isRefreshed.value = false
try {
album.value = await api.get(`music/album/${props.mediaid}`, { params: { source: props.source } })
} catch (error) {
console.error(error)
album.value = undefined
} finally {
isRefreshed.value = true
}
await checkSubscribeStatus()
}
/** 查询订阅状态,决定心形图标是实心还是空心。 */
async function checkSubscribeStatus() {
if (!canSubscribe.value || !album.value?.media_id) return
try {
isSubscribed.value = await subscribeActions.checkSubscribe(null)
} catch (error) {
console.error(error)
}
}
/** 进入站点资源精确搜索。 */
function goResource() {
if (!album.value) return
const target = buildMusicResourceRoute(album.value)
if (target) router.push(target)
}
/** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) {
if (!artistId) return
router.push(buildMusicArtistRoute(artistId, name, props.source))
}
/** 返回发行版本的介质与地区说明。 */
function getReleaseSubtitle(release: NonNullable<MusicAlbumInfo['releases']>[number]) {
return [release.formats?.join(' + '), release.country, release.packaging, release.status].filter(Boolean).join(' · ')
}
watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<MusicDetailLayout
v-else-if="album"
:cover="album.cover_url || album.poster_path"
:title="album.title"
:attributes="attributes"
>
<template #subtitle>
<template v-if="artistLinks.length">
<template v-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`">
<span v-if="index > 0"> / </span>
<a
v-if="artist.id"
class="music-link"
role="link"
tabindex="0"
@click="goArtist(artist.id, artist.name)"
@keydown.enter="goArtist(artist.id, artist.name)"
>{{ artist.name }}</a
>
<span v-else>{{ artist.name }}</span>
</template>
</template>
<span v-else>{{ t('common.unknown') }}</span>
</template>
<template #actions>
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="goResource">
{{ t('music.searchResources') }}
</VBtn>
<VBtn
v-if="canSubscribe"
variant="tonal"
:color="isSubscribed ? 'error' : 'primary'"
:prepend-icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
@click="subscribeActions.handleSubscribe()"
>
{{ isSubscribed ? t('music.unsubscribe') : t('music.subscribe') }}
</VBtn>
</template>
<template #facts>
<div v-if="album.rating" class="music-ratings">
<VRating :model-value="album.rating" density="compact" length="10" class="ma-2" readonly />
</div>
<div v-if="album.album_type" class="music-fact">
<span>{{ t('music.albumType') }}</span>
<span class="music-fact-value">{{ album.category || album.album_type }}</span>
</div>
<div v-if="album.release_date" class="music-fact">
<span>{{ t('music.releaseDate') }}</span>
<span class="music-fact-value">{{ album.release_date }}</span>
</div>
<div v-if="album.total_tracks" class="music-fact">
<span>{{ t('music.trackTotal') }}</span>
<span class="music-fact-value">{{ album.total_tracks }}</span>
</div>
<div v-if="album.duration" class="music-fact">
<span>{{ t('music.duration') }}</span>
<span class="music-fact-value">{{ formatMusicDuration(album.duration) }}</span>
</div>
<div v-if="album.rating_votes" class="music-fact">
<span>{{ t('music.ratingVotes') }}</span>
<span class="music-fact-value">{{ album.rating_votes }}</span>
</div>
<div class="music-fact music-fact--last">
<span>MusicBrainz ID</span>
<span class="music-fact-value music-fact-id">{{ album.media_id }}</span>
</div>
</template>
<template #body>
<div v-if="album.genres?.length" class="d-flex flex-wrap ga-2">
<VChip v-for="genre in album.genres.slice(0, 12)" :key="genre" size="small" variant="tonal">
{{ genre }}
</VChip>
</div>
<div v-if="album.detail_link" class="mt-4">
<a :href="album.detail_link" target="_blank" rel="noopener noreferrer" class="music-external-link">
<VIcon icon="mdi-link" />
<span class="ms-1">MusicBrainz</span>
</a>
</div>
<template v-if="album.tracks?.length">
<h2 class="music-section-title">{{ t('music.tracks') }}</h2>
<MusicTrackList :tracks="album.tracks" hide-artists />
</template>
<template v-if="album.releases?.length">
<h2 class="music-section-title">{{ t('music.releases') }}</h2>
<VList density="compact" bg-color="transparent">
<VListItem v-for="release in album.releases" :key="release.media_id">
<VListItemTitle>{{ release.title }}</VListItemTitle>
<VListItemSubtitle class="text-caption">{{ getReleaseSubtitle(release) }}</VListItemSubtitle>
<template #append>
<div class="d-flex align-center ga-3">
<span v-if="release.track_count" class="text-caption text-medium-emphasis">
{{ t('music.trackCount', { count: release.track_count }) }}
</span>
<span class="text-caption text-medium-emphasis">{{ release.date }}</span>
</div>
</template>
</VListItem>
</VList>
</template>
</template>
<div v-if="primaryArtistId" class="music-section">
<div class="slider-header">
<RouterLink
:to="buildMusicArtistRoute(primaryArtistId, artistLinks[0]?.name, props.source)"
class="slider-title"
>
<span>{{ t('music.artistAlbums') }}</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div>
<MediaCardSlideView :apipath="`music/artist/${primaryArtistId}/albums`" />
</div>
<div v-if="primaryArtistId" class="music-section">
<MusicArtistSlideView :apipath="`music/artist/${primaryArtistId}/related`" :title="t('music.relatedArtists')" />
</div>
</MusicDetailLayout>
<NoDataFound
v-else
error-code="404"
:error-title="t('music.albumNotFound')"
:error-description="t('error.networkError')"
>
<template #button>
<VBtn prepend-icon="mdi-refresh" variant="tonal" @click="loadAlbumDetail">{{ t('common.retry') }}</VBtn>
</template>
</NoDataFound>
</template>
<style scoped>
.music-link {
color: rgb(var(--v-theme-primary));
cursor: pointer;
text-decoration: none;
}
.music-link:hover {
text-decoration: underline;
}
.music-ratings {
display: flex;
align-items: center;
justify-content: center;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.12);
padding-block: 0.5rem;
}
.music-fact {
display: flex;
justify-content: space-between;
gap: 1rem;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.12);
padding-block: 0.5rem;
padding-inline: 1rem;
}
.music-fact--last {
border-block-end: 0;
}
.music-fact-value {
font-weight: 400;
text-align: end;
overflow-wrap: anywhere;
}
.music-fact-id {
font-size: 0.75rem;
}
.music-external-link {
display: inline-flex;
align-items: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.2);
border-radius: 9999px;
font-size: 0.875rem;
padding-block: 0.25rem;
padding-inline: 0.75rem;
text-decoration: none;
}
.music-section {
margin-block-start: 2rem;
}
.music-section-title {
font-size: 1.25rem;
font-weight: 700;
margin-block: 1.5rem 0.5rem;
}
</style>
@@ -0,0 +1,88 @@
<script lang="ts" setup>
import api from '@/api'
import type { MusicArtistInfo } from '@/api/types'
import MusicArtistCard from '@/components/cards/MusicArtistCard.vue'
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
import { useIntersectionObserver } from '@vueuse/core'
const props = defineProps({
//
apipath: String,
//
title: String,
})
//
const componentLoaded = ref(false)
//
const hasTriedLoading = ref(false)
// 使 shallowRef
const dataList = shallowRef<MusicArtistInfo[]>([])
const containerRef = ref<HTMLElement | null>(null)
/** 拉取关联艺术家列表。 */
async function fetchData() {
if (!props.apipath) return
try {
dataList.value = (await api.get(props.apipath)) || []
} catch (error) {
console.error(error)
} finally {
componentLoaded.value = true
hasTriedLoading.value = true
}
}
const { stop } = useIntersectionObserver(
containerRef,
([{ isIntersecting }]) => {
if (isIntersecting) {
fetchData()
stop()
}
},
{ rootMargin: '300px' },
)
watch(
() => props.apipath,
() => {
dataList.value = []
componentLoaded.value = false
hasTriedLoading.value = false
fetchData()
},
)
onActivated(() => {
if (dataList.value.length === 0 && hasTriedLoading.value) fetchData()
})
</script>
<template>
<div ref="containerRef">
<div v-if="props.title && (!componentLoaded || dataList.length > 0)" class="slider-header">
<span class="slider-title">{{ props.title }}</span>
</div>
<VirtualSlideView
v-if="!componentLoaded || dataList.length > 0"
:items="dataList"
:loading="!componentLoaded"
:get-item-key="item => item.media_id || item.name"
>
<template #item="{ item }">
<MusicArtistCard :artist="item" width="9rem" />
</template>
<template #loading>
<div v-for="i in 10" :key="i" style="width: 9rem">
<VCard class="outline-none overflow-hidden">
<div style="padding-bottom: 150%"></div>
</VCard>
</div>
</template>
</VirtualSlideView>
</div>
</template>
+226
View File
@@ -0,0 +1,226 @@
<script lang="ts" setup>
import api from '@/api'
import type { MusicArtistInfo } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
// MusicBrainz Artist ID
mediaid: String,
//
source: {
type: String,
default: 'musicbrainz',
},
})
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const isRefreshed = ref(false)
const artist = ref<MusicArtistInfo>()
// MusicBrainz Release Group
const albumSections = computed(() => [
{ type: 'album', title: t('music.albums') },
{ type: 'ep', title: t('music.eps') },
{ type: 'single', title: t('music.singles') },
])
const attributes = computed(() => {
const values: string[] = []
if (artist.value?.artist_type) values.push(artist.value.artist_type)
const area = artist.value?.area || artist.value?.country
if (area) values.push(area)
if (artist.value?.life_span) values.push(artist.value.life_span)
return values
})
/** 加载艺术家详情。 */
async function loadArtistDetail() {
if (!props.source || !props.mediaid) return
isRefreshed.value = false
try {
artist.value = await api.get(`music/artist/${props.mediaid}`, { params: { source: props.source } })
} catch (error) {
console.error(error)
artist.value = undefined
} finally {
isRefreshed.value = true
}
}
/** 按艺术家名称进入站点资源搜索。 */
function goResource() {
if (!artist.value?.name) return
router.push({
path: '/resource',
query: {
keyword: artist.value.name,
type: '音乐',
title: artist.value.name,
area: 'title',
result_type: 'torrent',
},
})
}
/** 返回指定专辑类型的浏览列表路由。 */
function getAlbumsBrowseRoute(albumType: string, title: string) {
return `/browse/music/artist/${props.mediaid}/albums?title=${encodeURIComponent(title)}&album_type=${albumType}`
}
watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true })
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<MusicDetailLayout
v-else-if="artist"
:cover="artist.image_url || artist.poster_path"
:title="artist.name"
:attributes="attributes"
rounded
>
<template #subtitle>
<span v-if="artist.disambiguation">{{ artist.disambiguation }}</span>
<span v-else-if="artist.sort_name && artist.sort_name !== artist.name">{{ artist.sort_name }}</span>
</template>
<template #actions>
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="goResource">
{{ t('music.searchResources') }}
</VBtn>
</template>
<template #facts>
<div v-if="artist.artist_type" class="music-fact">
<span>{{ t('music.artistType') }}</span>
<span class="music-fact-value">{{ artist.artist_type }}</span>
</div>
<div v-if="artist.gender" class="music-fact">
<span>{{ t('music.gender') }}</span>
<span class="music-fact-value">{{ artist.gender }}</span>
</div>
<div v-if="artist.area || artist.country" class="music-fact">
<span>{{ t('music.area') }}</span>
<span class="music-fact-value">{{ artist.area || artist.country }}</span>
</div>
<div v-if="artist.life_span" class="music-fact">
<span>{{ t('music.lifeSpan') }}</span>
<span class="music-fact-value">{{ artist.life_span }}</span>
</div>
<div v-if="artist.aliases?.length" class="music-fact">
<span>{{ t('music.aliases') }}</span>
<span class="music-fact-value">{{ artist.aliases.slice(0, 6).join('、') }}</span>
</div>
<div class="music-fact music-fact--last">
<span>MusicBrainz ID</span>
<span class="music-fact-value music-fact-id">{{ artist.media_id }}</span>
</div>
</template>
<template #body>
<div v-if="artist.genres?.length" class="d-flex flex-wrap ga-2">
<VChip v-for="genre in artist.genres.slice(0, 12)" :key="genre" size="small" variant="tonal">
{{ genre }}
</VChip>
</div>
<div class="d-flex flex-wrap ga-2 mt-4">
<a
v-if="artist.detail_link"
:href="artist.detail_link"
target="_blank"
rel="noopener noreferrer"
class="music-external-link"
>
<VIcon icon="mdi-link" />
<span class="ms-1">MusicBrainz</span>
</a>
<a
v-for="(link, name) in artist.external_links || {}"
:key="name"
:href="link"
target="_blank"
rel="noopener noreferrer"
class="music-external-link"
>
<VIcon icon="mdi-open-in-new" />
<span class="ms-1">{{ name }}</span>
</a>
</div>
</template>
<div v-for="section in albumSections" :key="section.type" class="music-section">
<div class="slider-header">
<RouterLink :to="getAlbumsBrowseRoute(section.type, section.title)" class="slider-title">
<span>{{ section.title }}</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div>
<MediaCardSlideView :apipath="`music/artist/${props.mediaid}/albums?album_type=${section.type}`" />
</div>
<div class="music-section">
<MusicArtistSlideView :apipath="`music/artist/${props.mediaid}/related`" :title="t('music.relatedArtists')" />
</div>
</MusicDetailLayout>
<NoDataFound
v-else
error-code="404"
:error-title="t('music.artistNotFound')"
:error-description="t('error.networkError')"
>
<template #button>
<VBtn prepend-icon="mdi-refresh" variant="tonal" @click="loadArtistDetail">{{ t('common.retry') }}</VBtn>
</template>
</NoDataFound>
</template>
<style scoped>
.music-fact {
display: flex;
justify-content: space-between;
gap: 1rem;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.12);
padding-block: 0.5rem;
padding-inline: 1rem;
}
.music-fact--last {
border-block-end: 0;
}
.music-fact-value {
font-weight: 400;
text-align: end;
overflow-wrap: anywhere;
}
.music-fact-id {
font-size: 0.75rem;
}
.music-external-link {
display: inline-flex;
align-items: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.2);
border-radius: 9999px;
font-size: 0.875rem;
padding-block: 0.25rem;
padding-inline: 0.75rem;
text-decoration: none;
}
.music-section {
margin-block-start: 2rem;
}
</style>
+279
View File
@@ -0,0 +1,279 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
//
const props = defineProps({
//
cover: String,
//
title: String,
//
attributes: {
type: Array as PropType<string[]>,
default: () => [],
},
//
rounded: {
type: Boolean,
default: false,
},
})
const theme = useTheme()
//
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
const isGlassTheme = computed(() => theme.name.value === 'glass')
// 退
const imageLoadError = ref(false)
const showCover = computed(() => Boolean(props.cover) && !imageLoadError.value)
watch(
() => props.cover,
() => {
imageLoadError.value = false
},
)
</script>
<template>
<div
class="max-w-8xl mx-auto px-4"
:class="{
'music-detail-transparent': isTransparentTheme,
'music-detail-glass': isGlassTheme,
}"
>
<template v-if="showCover">
<div class="vue-music-back vue-music-back-image absolute left-0 top-0 w-full h-96">
<VImg class="h-96" position="top" :src="props.cover" cover />
</div>
<div class="vue-music-back vue-music-back-overlay absolute left-0 top-0 w-full h-96" />
</template>
<div class="music-page">
<div class="music-header">
<div class="music-poster" :class="{ 'music-poster--rounded': props.rounded }">
<VImg v-if="showCover" :src="props.cover" cover aspect-ratio="1" @error="imageLoadError = true">
<template #placeholder>
<VSkeletonLoader class="h-100 w-100" />
</template>
</VImg>
<VSheet v-else class="music-poster-placeholder d-flex align-center justify-center">
<VIcon :icon="props.rounded ? 'mdi-account-music' : 'mdi-album'" size="72" color="medium-emphasis" />
</VSheet>
</div>
<div class="music-title">
<h1>{{ props.title }}</h1>
<div class="music-subtitle">
<slot name="subtitle" />
</div>
<span v-if="props.attributes.length" class="music-attributes">
<template v-for="(attribute, index) in props.attributes" :key="attribute">
<span v-if="index > 0" class="mx-1">|</span>
<span>{{ attribute }}</span>
</template>
</span>
</div>
<div class="music-actions">
<slot name="actions" />
</div>
</div>
<div class="music-overview">
<div class="music-overview-left">
<slot name="body" />
</div>
<div v-if="$slots.facts" class="music-overview-right">
<div class="music-facts">
<slot name="facts" />
</div>
</div>
</div>
<slot />
</div>
</div>
</template>
<style lang="scss" scoped>
.vue-music-back {
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(180deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%),
linear-gradient(0deg, rgba(var(--v-theme-background), 0) 80%, rgba(var(--v-theme-background), 1) 100%),
linear-gradient(90deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%),
linear-gradient(270deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%);
margin-block-start: calc(-70px - env(safe-area-inset-top));
}
.vue-music-back-image {
background-image: none;
filter: blur(2px);
}
.music-detail-transparent .vue-music-back-overlay,
.music-detail-glass .vue-music-back-overlay {
display: none;
}
.music-detail-transparent .vue-music-back-image,
.music-detail-glass .vue-music-back-image {
mask-image:
linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
mask-composite: intersect;
opacity: 0.82;
}
.music-page {
position: relative;
z-index: 1;
margin-block-start: calc(-4rem - env(safe-area-inset-top));
margin-inline: -1rem;
padding-block-start: calc(4rem + env(safe-area-inset-top));
padding-inline: 1rem;
}
.music-header {
display: flex;
flex-direction: column;
align-items: center;
padding-block-start: 1rem;
}
.music-poster {
overflow: hidden;
border-radius: var(--app-surface-radius);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 10%);
flex: 0 0 auto;
inline-size: 9rem;
}
.music-poster--rounded {
border-radius: 50%;
}
.music-poster-placeholder {
aspect-ratio: 1;
background: rgb(var(--v-theme-surface-variant));
inline-size: 100%;
}
.music-title {
display: flex;
flex: 1 1 0%;
flex-direction: column;
min-inline-size: 0;
margin-block-start: 1rem;
text-align: center;
}
.music-title > h1 {
font-size: 1.5rem;
font-weight: 700;
line-height: 2rem;
overflow-wrap: break-word;
}
.music-subtitle {
font-size: 1rem;
margin-block-start: 0.35rem;
}
.music-attributes {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
font-size: 0.875rem;
margin-block-start: 0.35rem;
opacity: 0.85;
}
.music-actions {
display: flex;
flex-wrap: wrap;
flex-shrink: 0;
gap: 0.5rem;
justify-content: center;
margin-block-start: 1rem;
}
.music-overview {
display: flex;
flex-direction: column;
padding-block: 2rem 1rem;
}
.music-overview-left {
flex: 1 1 0%;
min-inline-size: 0;
}
.music-overview-right {
inline-size: 100%;
margin-block-start: 2rem;
}
.music-facts {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 700;
line-height: 1.25rem;
}
@media (width >= 768px) {
.music-poster {
inline-size: 11rem;
}
}
@media (width >= 1024px) {
.music-overview {
flex-direction: row;
}
.music-overview-left {
margin-inline-end: 2rem;
}
.music-overview-right {
inline-size: 20rem;
margin-block-start: 0;
}
}
@media (width >= 1280px) {
.music-header {
flex-direction: row;
align-items: flex-end;
}
.music-poster {
inline-size: 13rem;
margin-inline-end: 1rem;
}
.music-title {
margin-block-start: 0;
margin-inline-end: 1rem;
text-align: start;
}
.music-title > h1 {
font-size: 2.25rem;
line-height: 2.5rem;
}
.music-attributes {
justify-content: flex-start;
}
.music-actions {
justify-content: flex-end;
margin-block-start: 0;
}
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script lang="ts" setup>
import api from '@/api'
import type { MediaInfo, MusicAlbumInfo } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import MusicArtistSlideView from '@/views/discover/MusicArtistSlideView.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import MusicTrackList from '@/components/music/MusicTrackList.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import {
buildMusicAlbumRoute,
buildMusicArtistRoute,
buildMusicResourceRoute,
formatMusicDuration,
getMusicArtistLinks,
} from '@/utils/music'
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
// MusicBrainz ID
mediaid: String,
//
source: {
type: String,
default: 'musicbrainz',
},
})
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
const isRefreshed = ref(false)
const music = ref<MediaInfo>()
const album = ref<MusicAlbumInfo>()
const isSubscribed = ref(false)
//
const artistLinks = computed(() => getMusicArtistLinks(music.value))
// MusicBrainz
const primaryArtistId = computed(() => artistLinks.value.find(artist => artist.id)?.id)
// MusicBrainz
const attributes = computed(() => {
const values: string[] = []
if (music.value?.category) values.push(music.value.category)
const releaseDate = music.value?.release_date || music.value?.year?.toString()
if (releaseDate) values.push(releaseDate)
const duration = formatMusicDuration(music.value?.duration)
if (duration) values.push(duration)
return values
})
// 便
const albumTracks = computed(() => album.value?.tracks ?? [])
function getSubscribeStatusKey() {
return `${getMediaSubscribeId(music.value)}::all`
}
const subscribeActions = useMediaSubscribe({
media: () => music.value,
canSubscribe: () => canSubscribe.value,
isSubscribed,
getSubscribeStatusKey,
})
/** 加载单曲详情,并按所属专辑补全曲目列表。 */
async function loadMusicDetail() {
if (!props.source || !props.mediaid) return
isRefreshed.value = false
album.value = undefined
try {
music.value = await api.post('music/recognize', {
source: props.source,
media_id: props.mediaid,
})
} catch (error) {
console.error(error)
music.value = undefined
} finally {
isRefreshed.value = true
}
// ID
if (music.value?.music_type === 'album' && music.value.media_id) {
router.replace(buildMusicAlbumRoute(music.value.media_id, music.value.title, props.source))
return
}
await Promise.all([loadAlbum(), checkSubscribeStatus()])
}
/** 加载所属专辑,用于展示专辑信息和专辑内的其它音乐。 */
async function loadAlbum() {
const albumId = music.value?.album_id
if (!albumId) return
try {
album.value = await api.get(`music/album/${albumId}`, { params: { source: props.source } })
} catch (error) {
console.error(error)
}
}
/** 查询订阅状态,决定心形图标是实心还是空心。 */
async function checkSubscribeStatus() {
if (!canSubscribe.value || !music.value?.media_id) return
try {
isSubscribed.value = await subscribeActions.checkSubscribe(null)
} catch (error) {
console.error(error)
}
}
/** 进入站点资源精确搜索。 */
function goResource() {
if (!music.value) return
const target = buildMusicResourceRoute(music.value)
if (target) router.push(target)
}
/** 打开所属专辑详情页。 */
function goAlbum() {
if (!music.value?.album_id) return
router.push(buildMusicAlbumRoute(music.value.album_id, music.value.album, props.source))
}
/** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) {
if (!artistId) return
router.push(buildMusicArtistRoute(artistId, name, props.source))
}
watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<MusicDetailLayout
v-else-if="music"
:cover="music.cover_url || music.poster_path"
:title="music.title"
:attributes="attributes"
>
<template #subtitle>
<template v-if="artistLinks.length">
<template v-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`">
<span v-if="index > 0"> / </span>
<a
v-if="artist.id"
class="music-link"
role="link"
tabindex="0"
@click="goArtist(artist.id, artist.name)"
@keydown.enter="goArtist(artist.id, artist.name)"
>{{ artist.name }}</a
>
<span v-else>{{ artist.name }}</span>
</template>
</template>
<span v-else>{{ t('common.unknown') }}</span>
</template>
<template #actions>
<VBtn v-if="canSearch" variant="tonal" color="primary" prepend-icon="mdi-magnify" @click="goResource">
{{ t('music.searchResources') }}
</VBtn>
<VBtn
v-if="canSubscribe"
variant="tonal"
:color="isSubscribed ? 'error' : 'primary'"
:prepend-icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
@click="subscribeActions.handleSubscribe()"
>
{{ isSubscribed ? t('music.unsubscribe') : t('music.subscribe') }}
</VBtn>
</template>
<template #facts>
<div v-if="music.album" class="music-fact">
<span>{{ t('music.album') }}</span>
<span class="music-fact-value">
<a v-if="music.album_id" class="music-link" role="link" tabindex="0" @click="goAlbum">{{ music.album }}</a>
<span v-else>{{ music.album }}</span>
</span>
</div>
<div v-if="music.album_artist" class="music-fact">
<span>{{ t('music.albumArtist') }}</span>
<span class="music-fact-value">{{ music.album_artist }}</span>
</div>
<div v-if="music.release_date || music.year" class="music-fact">
<span>{{ t('music.releaseDate') }}</span>
<span class="music-fact-value">{{ music.release_date || music.year }}</span>
</div>
<div v-if="music.duration" class="music-fact">
<span>{{ t('music.duration') }}</span>
<span class="music-fact-value">{{ formatMusicDuration(music.duration) }}</span>
</div>
<div v-if="music.track_number" class="music-fact">
<span>{{ t('music.track') }}</span>
<span class="music-fact-value">
{{ music.disc_number ? `${music.disc_number}-${music.track_number}` : music.track_number }}
</span>
</div>
<div v-if="music.isrc" class="music-fact">
<span>ISRC</span>
<span class="music-fact-value">{{ music.isrc }}</span>
</div>
<div v-if="music.listen_count" class="music-fact">
<span>{{ t('music.listenCount') }}</span>
<span class="music-fact-value">{{ music.listen_count.toLocaleString() }}</span>
</div>
<div class="music-fact music-fact--last">
<span>MusicBrainz ID</span>
<span class="music-fact-value music-fact-id">{{ music.media_id }}</span>
</div>
</template>
<template #body>
<div v-if="music.genres?.length" class="d-flex flex-wrap ga-2">
<VChip v-for="genre in music.genres.slice(0, 12)" :key="genre" size="small" variant="tonal">
{{ genre }}
</VChip>
</div>
<div v-if="music.detail_link" class="mt-4">
<a :href="music.detail_link" target="_blank" rel="noopener noreferrer" class="music-external-link">
<VIcon icon="mdi-link" />
<span class="ms-1">MusicBrainz</span>
</a>
</div>
</template>
<div v-if="albumTracks.length" class="music-section">
<div class="slider-header">
<RouterLink
v-if="music.album_id"
:to="buildMusicAlbumRoute(music.album_id, music.album, props.source)"
class="slider-title"
>
<span>{{ t('music.albumTracks') }}</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
<span v-else class="slider-title">{{ t('music.albumTracks') }}</span>
</div>
<MusicTrackList :tracks="albumTracks" :active-media-id="music.media_id" hide-artists />
</div>
<div v-if="primaryArtistId" class="music-section">
<MusicArtistSlideView :apipath="`music/artist/${primaryArtistId}/related`" :title="t('music.relatedArtists')" />
</div>
<div v-if="primaryArtistId" class="music-section">
<div class="slider-header">
<RouterLink
:to="buildMusicArtistRoute(primaryArtistId, artistLinks[0]?.name, props.source)"
class="slider-title"
>
<span>{{ t('music.artistAlbums') }}</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
</RouterLink>
</div>
<MediaCardSlideView :apipath="`music/artist/${primaryArtistId}/albums`" />
</div>
</MusicDetailLayout>
<NoDataFound v-else error-code="404" :error-title="t('music.noResults')" :error-description="t('error.networkError')">
<template #button>
<VBtn prepend-icon="mdi-refresh" variant="tonal" @click="loadMusicDetail">{{ t('common.retry') }}</VBtn>
</template>
</NoDataFound>
</template>
<style scoped>
.music-link {
color: rgb(var(--v-theme-primary));
cursor: pointer;
text-decoration: none;
}
.music-link:hover {
text-decoration: underline;
}
.music-fact {
display: flex;
justify-content: space-between;
gap: 1rem;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.12);
padding-block: 0.5rem;
padding-inline: 1rem;
}
.music-fact--last {
border-block-end: 0;
}
.music-fact-value {
font-weight: 400;
text-align: end;
overflow-wrap: anywhere;
}
.music-fact-id {
font-size: 0.75rem;
}
.music-external-link {
display: inline-flex;
align-items: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.2);
border-radius: 9999px;
font-size: 0.875rem;
padding-block: 0.25rem;
padding-inline: 0.75rem;
text-decoration: none;
}
.music-section {
margin-block-start: 2rem;
}
</style>
+137 -18
View File
@@ -4,16 +4,50 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// ListenBrainz
const mode = ref<'chart' | 'fresh'>('chart')
//
const entity = ref('recording')
//
const rangeName = ref('this_month')
//
const sortBy = ref('listen_count.desc')
//
const freshSort = ref('release_date')
// 90
const freshDays = ref(14)
//
const freshScope = ref<'all' | 'past' | 'future'>('all')
const coverFilter = ref('all')
const minListenCount = ref(0)
const currentKey = ref(0)
const modeOptions = computed(() => ({
chart: t('music.filter.modeChart'),
fresh: t('music.filter.modeFresh'),
}))
const entityOptions = computed(() => ({
recording: t('music.filter.entityRecording'),
album: t('music.filter.entityAlbum'),
}))
const rangeOptions = computed(() => ({
this_week: t('music.filter.thisWeek'),
this_month: t('music.filter.thisMonth'),
this_year: t('music.filter.thisYear'),
week: t('music.filter.lastWeek'),
month: t('music.filter.lastMonth'),
quarter: t('music.filter.lastQuarter'),
half_yearly: t('music.filter.lastHalfYear'),
year: t('music.filter.lastYear'),
all_time: t('music.filter.allTime'),
}))
@@ -22,15 +56,47 @@ const sortOptions = computed(() => ({
'listen_count.asc': t('music.filter.leastListened'),
}))
const filterParams = computed(() => ({
count: 30,
range_name: rangeName.value,
sort_by: sortBy.value,
min_listen_count: Math.max(0, minListenCount.value || 0),
with_cover: coverFilter.value === 'with_cover',
const freshSortOptions = computed(() => ({
release_date: t('music.filter.sortReleaseDate'),
artist_credit_name: t('music.filter.sortArtist'),
release_name: t('music.filter.sortReleaseName'),
}))
watch([rangeName, sortBy, coverFilter, minListenCount], () => {
const freshDaysOptions = computed(() => [
{ value: 7, title: t('music.filter.daysValue', { days: 7 }) },
{ value: 14, title: t('music.filter.daysValue', { days: 14 }) },
{ value: 30, title: t('music.filter.daysValue', { days: 30 }) },
{ value: 60, title: t('music.filter.daysValue', { days: 60 }) },
{ value: 90, title: t('music.filter.daysValue', { days: 90 }) },
])
const freshScopeOptions = computed(() => ({
all: t('music.filter.all'),
past: t('music.filter.released'),
future: t('music.filter.upcoming'),
}))
const filterParams = computed(() => {
const params: Record<string, unknown> = {
count: 30,
mode: mode.value,
with_cover: coverFilter.value === 'with_cover',
}
if (mode.value === 'fresh') {
params.sort = freshSort.value
params.days = freshDays.value
params.past = freshScope.value !== 'future'
params.future = freshScope.value !== 'past'
return params
}
params.entity = entity.value
params.range_name = rangeName.value
params.sort_by = sortBy.value
params.min_listen_count = Math.max(0, minListenCount.value || 0)
return params
})
watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverFilter, minListenCount], () => {
currentKey.value++
})
</script>
@@ -38,21 +104,69 @@ watch([rangeName, sortBy, coverFilter, minListenCount], () => {
<template>
<div class="px-3 music-explore-filters">
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.period') }}</VLabel>
<VChipGroup v-model="rangeName" mandatory>
<VChip v-for="(label, value) in rangeOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.sort') }}</VLabel>
<VChipGroup v-model="sortBy" mandatory>
<VChip v-for="(label, value) in sortOptions" :key="value" :value="value" filter tile>
<VLabel>{{ t('music.filter.mode') }}</VLabel>
<VChipGroup v-model="mode" mandatory>
<VChip v-for="(label, value) in modeOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<template v-if="mode === 'chart'">
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.entity') }}</VLabel>
<VChipGroup v-model="entity" mandatory>
<VChip v-for="(label, value) in entityOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.period') }}</VLabel>
<VChipGroup v-model="rangeName" mandatory>
<VChip v-for="(label, value) in rangeOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.sort') }}</VLabel>
<VChipGroup v-model="sortBy" mandatory>
<VChip v-for="(label, value) in sortOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
</template>
<template v-else>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.sort') }}</VLabel>
<VChipGroup v-model="freshSort" mandatory>
<VChip v-for="(label, value) in freshSortOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="d-flex flex-wrap align-center ga-3 mb-2">
<VLabel>{{ t('music.filter.scope') }}</VLabel>
<VChipGroup v-model="freshScope" mandatory>
<VChip v-for="(label, value) in freshScopeOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
<VSelect
v-model="freshDays"
:items="freshDaysOptions"
:label="t('music.filter.days')"
density="compact"
variant="outlined"
hide-details
class="music-days-filter"
/>
</div>
</template>
<div class="d-flex flex-wrap align-center ga-3 mb-3">
<VLabel>{{ t('music.filter.cover') }}</VLabel>
<VChipGroup v-model="coverFilter" mandatory>
@@ -60,6 +174,7 @@ watch([rangeName, sortBy, coverFilter, minListenCount], () => {
<VChip value="with_cover" filter tile>{{ t('music.filter.withCover') }}</VChip>
</VChipGroup>
<VTextField
v-if="mode === 'chart'"
v-model.number="minListenCount"
:label="t('music.filter.minListenCount')"
type="number"
@@ -78,4 +193,8 @@ watch([rangeName, sortBy, coverFilter, minListenCount], () => {
.music-listen-count-filter {
max-inline-size: 14rem;
}
.music-days-filter {
max-inline-size: 10rem;
}
</style>
+50 -13
View File
@@ -10,24 +10,61 @@ const MediaCardListViewStub = defineComponent({
template: '<pre data-testid="music-params">{{ JSON.stringify(params) }}</pre>',
})
describe('MusicView', () => {
it('provides period, sort, cover and listen-count filters to music exploration', async () => {
const user = userEvent.setup()
await renderWithProviders(MusicView, {
global: {
stubs: { MediaCardListView: MediaCardListViewStub },
},
})
/** 渲染音乐探索筛选,列表区域用桩组件回显请求参数。 */
function renderMusicView() {
return renderWithProviders(MusicView, {
global: { stubs: { MediaCardListView: MediaCardListViewStub } },
})
}
await user.click(screen.getByText('本周'))
describe('MusicView', () => {
it('defaults to the sitewide top tracks chart of the current month', async () => {
await renderMusicView()
const params = screen.getByTestId('music-params')
expect(params).toHaveTextContent('"mode":"chart"')
expect(params).toHaveTextContent('"entity":"recording"')
expect(params).toHaveTextContent('"range_name":"this_month"')
expect(params).toHaveTextContent('"sort_by":"listen_count.desc"')
})
it('provides the official chart entities, periods and listen filters', async () => {
const user = userEvent.setup()
await renderMusicView()
await user.click(screen.getByText('热门专辑'))
await user.click(screen.getByText('过去一季'))
await user.click(screen.getByText('收听最少'))
await user.click(screen.getByText('仅有封面'))
await user.clear(screen.getByLabelText('最低收听次数'))
await user.type(screen.getByLabelText('最低收听次数'), '100')
expect(screen.getByTestId('music-params')).toHaveTextContent('"range_name":"this_week"')
expect(screen.getByTestId('music-params')).toHaveTextContent('"sort_by":"listen_count.asc"')
expect(screen.getByTestId('music-params')).toHaveTextContent('"with_cover":true')
expect(screen.getByTestId('music-params')).toHaveTextContent('"min_listen_count":100')
const params = screen.getByTestId('music-params')
expect(params).toHaveTextContent('"entity":"album"')
expect(params).toHaveTextContent('"range_name":"quarter"')
expect(params).toHaveTextContent('"sort_by":"listen_count.asc"')
expect(params).toHaveTextContent('"with_cover":true')
expect(params).toHaveTextContent('"min_listen_count":100')
})
it('switches to the official fresh releases mode with its own sort options', async () => {
const user = userEvent.setup()
await renderMusicView()
await user.click(screen.getByText('新发行'))
expect(screen.getByText('艺术家')).toBeInTheDocument()
expect(screen.getByText('专辑名称')).toBeInTheDocument()
expect(screen.queryByLabelText('最低收听次数')).not.toBeInTheDocument()
await user.click(screen.getByText('艺术家'))
await user.click(screen.getByText('即将发行'))
const params = screen.getByTestId('music-params')
expect(params).toHaveTextContent('"mode":"fresh"')
expect(params).toHaveTextContent('"sort":"artist_credit_name"')
expect(params).toHaveTextContent('"days":14')
expect(params).toHaveTextContent('"past":false')
expect(params).toHaveTextContent('"future":true')
})
})
+5 -5
View File
@@ -11,8 +11,7 @@ describe('前端测试 workflow', () => {
it('在 PR 与 v3 push 上运行,并将变更文件格式检查限制为 PR', () => {
const workflow = readFileSync(workflowPath, 'utf8')
const formatJob = workflow.match(/\n {2}format:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job
const qualityJob = workflow.match(/\n {2}typecheck-and-coverage:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups
?.job
const qualityJob = workflow.match(/\n {2}typecheck-and-tests:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job
expect(workflow).toContain('permissions:\n contents: read')
expect(workflow).toContain('pull_request:\n branches:\n - v3')
@@ -29,7 +28,8 @@ describe('前端测试 workflow', () => {
expect(formatJob).not.toContain('--write')
expect(qualityJob).toBeDefined()
expect(qualityJob).toContain('run: yarn typecheck')
expect(qualityJob).toContain('run: yarn test:coverage')
expect(qualityJob).toContain('run: yarn test:run')
expect(qualityJob).not.toContain('test:coverage')
expect(workflow).not.toContain('\n unit-tests:\n')
})
@@ -37,10 +37,10 @@ describe('前端测试 workflow', () => {
const testingGuide = readFileSync(testingGuidePath, 'utf8')
const codeQualityGuide = readFileSync(codeQualityGuidePath, 'utf8')
expect(testingGuide).toContain('`typecheck-and-coverage` job')
expect(testingGuide).toContain('`typecheck-and-tests` job')
expect(testingGuide).toContain('推送到 `v3`')
expect(testingGuide).toContain('只在 Pull Request 事件运行')
expect(codeQualityGuide).toContain('lint 与 `typecheck-and-coverage` 使用不同 job')
expect(codeQualityGuide).toContain('lint 与 `typecheck-and-tests` 使用不同 job')
expect(testingGuide).not.toContain('`unit-tests`')
expect(codeQualityGuide).not.toContain('`unit-tests`')
})