mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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.19;Node.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
@@ -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 或格式问题。
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: '用戶輔助認證',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: '音乐' })
|
||||
})
|
||||
})
|
||||
@@ -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: '音乐',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '音乐',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user