feat(v3): add music search and subscription UI

This commit is contained in:
jxxghp
2026-08-07 21:50:22 +08:00
parent 7ea14bc919
commit 6afa2bdc8a
36 changed files with 812 additions and 80 deletions
+53 -28
View File
@@ -33,15 +33,21 @@ export const storageAttributes = [
},
]
export const storageIconDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.icon
return dict
}, {} as Record<string, string>)
export const storageIconDict = storageAttributes.reduce(
(dict, item) => {
dict[item.type] = item.icon
return dict
},
{} as Record<string, string>,
)
export const storageRemoteDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.remote
return dict
}, {} as Record<string, boolean>)
export const storageRemoteDict = storageAttributes.reduce(
(dict, item) => {
dict[item.type] = item.remote
return dict
},
{} as Record<string, boolean>,
)
export const downloaderOptions = [
{
@@ -58,10 +64,13 @@ export const downloaderOptions = [
},
]
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const downloaderDict = downloaderOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
export const mediaServerOptions = [
{
@@ -90,10 +99,13 @@ export const mediaServerOptions = [
},
]
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const mediaServerDict = mediaServerOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
export const innerFilterRules = [
{ title: i18n.global.t('filterRules.specSub'), value: ' SPECSUB ' },
@@ -236,6 +248,10 @@ export const mediaTypeOptions = [
title: i18n.global.t('mediaType.tv'),
value: '电视剧',
},
{
title: i18n.global.t('mediaType.music'),
value: '音乐',
},
{
title: i18n.global.t('mediaType.anime'),
value: '动漫',
@@ -251,10 +267,13 @@ export const mediaTypeOptions = [
]
// 媒体类型字典
export const mediaTypeDict = mediaTypeOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const mediaTypeDict = mediaTypeOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
// 通知开关选项
export const notificationSwitchOptions = [
@@ -297,10 +316,13 @@ export const notificationSwitchOptions = [
]
// 通知开关字典
export const notificationSwitchDict = notificationSwitchOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const notificationSwitchDict = notificationSwitchOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
// 操作步骤选项
export const actionStepOptions = [
@@ -367,7 +389,10 @@ export const actionStepOptions = [
]
// 操作步骤字典
export const actionStepDict = actionStepOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const actionStepDict = actionStepOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
+27 -3
View File
@@ -1,4 +1,4 @@
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | (string & {})
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | (string & {})
// 手动刮削选项
export interface ManualScrapeOptions {
@@ -330,9 +330,9 @@ export interface DownloadHistory {
// 媒体信息
export interface MediaInfo {
// 来源:themoviedb、douban、bangumi、anilist
// 来源:themoviedb、douban、bangumi、anilist、musicbrainz
source?: string
// 类型 电影、电视剧、合集
// 类型 电影、电视剧、音乐、合集
type?: string
// 媒体标题
title?: string
@@ -436,6 +436,30 @@ export interface MediaInfo {
names?: string[]
// 剧集组
episode_group?: string
// 音乐艺术家列表
artists?: string[]
// 音乐艺术家展示文本
artist?: string
// 专辑
album?: string
// 专辑艺术家
album_artist?: string
// 发行版本
version?: string
// 音轨号
track_number?: number
// 碟号
disc_number?: number
// 总音轨数
total_tracks?: number
// 音轨时长(秒)
duration?: number
// ISRC
isrc?: string
// 音乐封面
cover_url?: string
// ListenBrainz 收听次数
listen_count?: number
}
// 季信息
+1
View File
@@ -47,6 +47,7 @@ const typeItems = computed(() => [
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
{ title: t('mediaType.music'), value: '音乐' },
])
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
+2
View File
@@ -46,6 +46,7 @@ const mediaTypeText = computed(() => {
const type = String(media.value.type || '').trim()
if (type === '电影' || type.toLowerCase() === 'movie') return t('mediaType.movie')
if (type === '电视剧' || type.toLowerCase() === 'tv') return t('mediaType.tv')
if (type === '音乐' || type.toLowerCase() === 'music') return t('mediaType.music')
if (type) return type
if (media.value.season || media.value.episode || props.info?.season_episode) return t('mediaType.tv')
return media.value.title ? t('mediaType.movie') : ''
@@ -57,6 +58,7 @@ const mediaTypeIcon = computed(() => {
.toLowerCase()
if (type === '电影' || type === 'movie') return 'mdi-movie-outline'
if (type === '电视剧' || type === 'tv') return 'mdi-television-classic'
if (type === '音乐' || type === 'music') return 'mdi-music-note'
return 'mdi-play-box-outline'
})
+16 -1
View File
@@ -183,6 +183,7 @@ function isSameSubscribeMedia(subscribe: Subscribe) {
function getChipColor(type: string) {
if (type === '电影') return 'border-blue-500 bg-blue-600'
else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600'
else if (type === '音乐') return 'border-pink-500 bg-pink-600'
else return 'border-purple-600 bg-purple-600'
}
@@ -230,6 +231,7 @@ async function querySubscribedSeasons() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
if (props.media?.type === '音乐') return
try {
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
@@ -268,7 +270,14 @@ function goMediaDetail(isHovering = false) {
if (isHovering) {
resetMediaCardDetailState()
if (props.media?.collection_id) {
if (props.media?.type === '音乐') {
router.push({
path: '/music',
query: {
query: [props.media?.artist, props.media?.title].filter(Boolean).join(' - '),
},
})
} else if (props.media?.collection_id) {
// 跳转到合集列表
router.push({
path: `/browse/tmdb/collection/${props.media?.collection_id}`,
@@ -542,6 +551,12 @@ onBeforeUnmount(() => {
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
>
<VIcon v-if="props.media?.source === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
<VIcon
v-else-if="props.media?.source === 'musicbrainz'"
color="#eb743b"
icon="mdi-music-circle"
size="24"
/>
<VImg v-else cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
</VCard>
+8
View File
@@ -283,6 +283,13 @@ function getMediaId() {
// 查看媒体详情
async function viewMediaDetail() {
if (props.media?.type === '音乐') {
router.push({
path: '/music',
query: { query: props.media?.name },
})
return
}
router.push({
path: '/media',
query: {
@@ -360,6 +367,7 @@ const dropdownItems = computed(() => [
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
show: props.media?.type !== '音乐',
},
{
title: t('common.unsubscribe'),
@@ -304,6 +304,40 @@ describe('MediaCard', () => {
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith({ path, query }))
})
it('opens music search and skips media-library existence checks', async () => {
const media = createMediaInfo({
artist: '周杰伦',
media_id: 'recording-1',
mediaid_prefix: 'musicbrainz',
source: 'musicbrainz',
title: '晴天',
tmdb_id: undefined,
type: '音乐',
})
const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('musicbrainz:recording-1', {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
)
const { container } = await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce())
expect(existsRequest).not.toHaveBeenCalled()
await fireEvent.mouseEnter(getHoverArea(container))
await waitFor(() => expect(getCard(container)).toHaveClass('app-hover-lift-card--hovering'))
await fireEvent.click(getCard(container))
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/music',
query: { query: '周杰伦 - 晴天' },
}),
)
})
it('routes directly to resource search when no active sites are available', async () => {
installSearchHandlers([], [3, 5])
const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' })
@@ -72,6 +72,7 @@ const mediaIdLabel = computed(() => {
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'),
musicbrainz: 'MusicBrainz ID',
}
return labels[mediaSource.value]
})
@@ -57,6 +57,7 @@ const mediaIdLabel = computed(() => {
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'),
musicbrainz: 'MusicBrainz ID',
}
return labels[mediaSource.value]
})
@@ -347,6 +347,7 @@ const mediaIdLabel = computed(() => {
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'),
musicbrainz: 'MusicBrainz ID',
}
return labels[mediaSource.value]
})
@@ -1495,6 +1496,7 @@ onUnmounted(() => {
{ title: t('dialog.reorganize.auto'), value: '' },
{ title: t('dialog.reorganize.movie'), value: '电影' },
{ title: t('dialog.reorganize.tv'), value: '电视剧' },
{ title: t('mediaType.music'), value: '音乐' },
]"
:hint="t('dialog.reorganize.mediaTypeHint')"
persistent-hint
+1
View File
@@ -55,6 +55,7 @@ const mediaIdLabel = computed(() => {
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'),
musicbrainz: 'MusicBrainz ID',
}
return labels[mediaSource.value]
})
+37 -3
View File
@@ -96,8 +96,8 @@ const searchOverlayProps = computed(() =>
// 搜索词
const searchWord = ref<string | null>(null)
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist'
type MediaSearchType = 'media' | 'collection' | 'person'
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz'
type MediaSearchType = 'media' | 'music' | 'collection' | 'person'
interface MediaSearchSourceOption {
label: string
@@ -115,6 +115,7 @@ interface MediaSearchAction {
// 三类搜索各自维护来源选择,首次使用均默认 TheMovieDB。
const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchSource>>({
media: 'themoviedb',
music: 'musicbrainz',
collection: 'themoviedb',
person: 'themoviedb',
})
@@ -141,9 +142,15 @@ const mediaSearchSourceOptions = computed<Record<MediaSearchType, MediaSearchSou
name: t('discoverTabs.anilist'),
value: 'anilist' as const,
}
const musicbrainz = {
label: 'MusicBrainz',
name: 'MusicBrainz',
value: 'musicbrainz' as const,
}
return {
media: [themoviedb, douban, bangumi, anilist],
music: [musicbrainz],
collection: [themoviedb],
person: [themoviedb, douban],
}
@@ -158,6 +165,12 @@ const mediaSearchActions = computed(() => {
title: `${t('recommend.categoryMovie')}${t('recommend.categoryTV')}`,
description: t('resource.title'),
},
{
type: 'music',
icon: 'mdi-music-note-search',
title: t('mediaType.music'),
description: t('music.subtitle'),
},
{
type: 'collection',
icon: 'mdi-movie-filter',
@@ -389,6 +402,14 @@ function searchSubtitle() {
function searchMedia(searchType: MediaSearchType) {
if (!searchWord.value || !hasDiscoveryPermission.value) return
saveRecentSearches(searchWord.value)
if (searchType === 'music') {
router.push({
path: '/music',
query: { query: searchWord.value },
})
closeSearch()
return
}
router.push({
path: '/browse/media/search',
query: {
@@ -453,6 +474,13 @@ function goSubscribe(subscribe: Subscribe) {
id: subscribe.id,
},
})
} else if (subscribe.type === '音乐') {
router.push({
path: '/subscribe/music',
query: {
id: subscribe.id,
},
})
} else {
router.push({
path: '/subscribe/tv',
@@ -669,7 +697,13 @@ onMounted(() => {
<template #prepend>
<div class="result-icon-wrapper">
<VIcon
:icon="subscribe.type === '电影' ? 'mdi-movie-roll' : 'mdi-television-classic'"
:icon="
subscribe.type === '电影'
? 'mdi-movie-roll'
: subscribe.type === '音乐'
? 'mdi-music-note'
: 'mdi-television-classic'
"
size="small"
color="medium-emphasis"
/>
+40 -2
View File
@@ -27,6 +27,9 @@ const keyword = ref<string>()
// 选择分类
const selectCategory = ref<number[]>([])
// 选择主媒体类型,用于按站点定义的电影、电视剧、音乐分类浏览。
const selectMediaType = ref<string>()
// 全部分类
const siteCategoryList = ref<SiteCategory[]>()
@@ -66,6 +69,12 @@ const categoryOptions = computed(() => {
})
})
const mediaTypeOptions = computed(() => [
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
{ title: t('mediaType.music'), value: '音乐' },
])
// 总条数
const resourceTotalItems = computed(() => resourceDataList.value.length)
@@ -160,6 +169,7 @@ async function getResourceList() {
params: {
keyword: keyword.value,
cat: selectCategory.value?.join(','),
mtype: selectMediaType.value,
},
})
@@ -248,7 +258,7 @@ onMounted(() => {
<VSheet class="site-resource-filter-panel">
<div class="site-resource-filter-panel__inner">
<VRow class="site-resource-filter-row">
<VCol cols="12" md="4">
<VCol cols="12" md="3">
<VTextField
v-model="keyword"
class="site-resource-filter-input"
@@ -263,7 +273,7 @@ onMounted(() => {
@keyup.enter="getResourceList"
/>
</VCol>
<VCol cols="12" md="5">
<VCol cols="12" md="4">
<VSelect
v-model="selectCategory"
:items="categoryOptions"
@@ -280,6 +290,20 @@ onMounted(() => {
hide-details
/>
</VCol>
<VCol cols="12" md="2">
<VSelect
v-model="selectMediaType"
:items="mediaTypeOptions"
class="site-resource-filter-input"
density="compact"
variant="solo-filled"
flat
clearable
:label="t('common.type')"
prepend-inner-icon="mdi-shape-outline"
hide-details
/>
</VCol>
<VCol cols="12" md="3" class="d-flex align-center">
<VBtn
color="primary"
@@ -345,6 +369,20 @@ onMounted(() => {
@keyup.enter="getResourceList"
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="selectMediaType"
:items="mediaTypeOptions"
class="site-resource-filter-input"
density="compact"
variant="solo-filled"
flat
clearable
:label="t('common.type')"
prepend-inner-icon="mdi-shape-outline"
hide-details
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="selectCategory"
@@ -118,6 +118,7 @@ function getSubscribeDisplayName() {
function getDefaultSubscribeTypeName() {
if (props.type === '电影') return t('mediaType.movie')
if (props.type === '电视剧') return t('mediaType.tv')
if (props.type === '音乐') return t('mediaType.music')
return props.type ?? ''
}
@@ -354,6 +355,7 @@ const targetDirectories = computed(() => {
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑
const isTvSubscribe = computed(() => props.type === '电视剧' || subscribeForm.value.type === '电视剧')
const isMusicSubscribe = computed(() => props.type === '音乐' || subscribeForm.value.type === '音乐')
watch(
() => subscribeForm.value.best_version,
@@ -434,7 +436,7 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow>
<VRow v-if="!isMusicSubscribe">
<VCol cols="12" md="4">
<VAutocomplete
v-model="subscribeForm.quality"
@@ -503,7 +505,7 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow>
<VRow v-if="!isMusicSubscribe">
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.best_version"
@@ -606,7 +608,7 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-if="!props.default">
<VRow v-if="!props.default && !isMusicSubscribe">
<VCol cols="12">
<VTextarea
v-model="subscribeForm.custom_words"
@@ -27,6 +27,10 @@ const typeOptions = ref([
title: t('mediaType.tv'),
value: '电视剧',
},
{
title: t('mediaType.music'),
value: '音乐',
},
])
// 搜索方式下拉框
@@ -26,6 +26,10 @@ const typeOptions = ref([
title: t('mediaType.tv'),
value: '电视剧',
},
{
title: t('mediaType.music'),
value: '音乐',
},
])
// 二级分类策略
+1
View File
@@ -268,6 +268,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 查询系统默认订阅配置。
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
if (!options.canSubscribe()) return undefined
if (currentMedia()?.type === '音乐') return undefined
try {
const media = currentMedia()
+26
View File
@@ -84,6 +84,7 @@ export default {
mediaType: {
movie: 'Movie',
tv: 'TV Show',
music: 'Music',
anime: 'Anime',
collection: 'Collection',
unknown: 'Unknown',
@@ -386,6 +387,7 @@ export default {
download: 'Download',
movieSubscribe: 'Movie Subscription',
tvSubscribe: 'TV Subscription',
musicSubscribe: 'Music Subscription',
history: 'History',
transfer: 'Organize',
rename: 'Rename',
@@ -397,6 +399,7 @@ export default {
explore: 'Explore',
movie: 'Movies',
tv: 'TV Shows',
music: 'Music',
workflow: 'Workflow',
calendar: 'Calendar',
downloadManager: 'Download Manager',
@@ -407,6 +410,19 @@ export default {
userManager: 'User Management',
settings: 'Settings',
},
music: {
title: 'Music Search',
subtitle: 'Find music metadata, then search sites, download, and subscribe',
searchPlaceholder: 'Enter a track, artist, or “artist - track”',
search: 'Search Music',
noResults: 'No matching music found',
searchResources: 'Search Resources',
subscribe: 'Subscribe',
subscribeSuccess: 'Music subscription added',
album: 'Album',
artist: 'Artist',
source: 'MusicBrainz',
},
settingTabs: {
system: {
title: 'System',
@@ -461,6 +477,9 @@ export default {
popular: 'Popular Subscriptions',
share: 'Subscription Shares',
},
music: {
mysub: 'My Subscriptions',
},
},
workflowTabs: {
list: 'My Workflows',
@@ -475,6 +494,7 @@ export default {
douban: 'Douban',
bangumi: 'Bangumi',
anilist: 'AniList',
music: 'Music',
},
user: {
admin: 'Admin',
@@ -1270,12 +1290,14 @@ export default {
categoryMovie: 'Movies',
categoryTV: 'TV Shows',
categoryAnime: 'Anime',
categoryMusic: 'Music',
categoryRankings: 'Rankings',
trendingNow: 'Trending Now',
nowShowing: 'Now Showing',
bangumiDaily: 'Bangumi Daily Release',
anilistTrendingNow: 'AniList TRENDING NOW',
anilistPopularThisSeason: 'AniList POPULAR THIS SEASON',
listenBrainzWeekly: 'Weekly Popular Music',
tmdbHotMovies: 'TMDB Hot Movies',
tmdbHotTVShows: 'TMDB Hot TV Shows',
doubanHotMovies: 'Douban Hot Movies',
@@ -2446,6 +2468,8 @@ export default {
'Using Jinja2 syntax, format reference: https://jinja.palletsprojects.com/en/3.0.x/templates',
tvRenameFormat: 'TV Show Rename Format',
tvRenameFormatHint: 'Using Jinja2 syntax, format reference: https://jinja.palletsprojects.com/en/3.0.x/templates',
musicRenameFormat: 'Music Rename Format',
musicRenameFormatHint: 'Uses Jinja2 syntax with music fields such as artist, album, track, and disc',
saveSuccess: 'Storage settings saved successfully',
saveFailed: 'Failed to save storage settings!',
directorySaveSuccess: 'Directory settings saved successfully',
@@ -2754,6 +2778,7 @@ export default {
resourceSearch: 'Resource Search',
movieSubscribe: 'Movie Subscriptions',
tvSubscribe: 'TV Subscriptions',
musicSubscribe: 'Music Subscriptions',
calendar: 'Subscription Calendar',
subscribeShare: 'Subscription Share',
workflow: 'Workflow',
@@ -2768,6 +2793,7 @@ export default {
resourceSearch: 'Site resource search and result page',
movieSubscribe: 'Movie subscription list and popular movies',
tvSubscribe: 'TV subscription list, popular shows, and share tab',
musicSubscribe: 'Music subscriptions and automatic resource search',
calendar: 'Subscription schedule and calendar view',
subscribeShare: 'Subscription share list access',
workflow: 'Workflow list and shared workflows',
+26
View File
@@ -82,6 +82,7 @@ export default {
mediaType: {
movie: '电影',
tv: '电视剧',
music: '音乐',
anime: '动漫',
collection: '合集',
unknown: '未知',
@@ -378,6 +379,7 @@ export default {
download: '下载',
movieSubscribe: '电影订阅',
tvSubscribe: '电视剧订阅',
musicSubscribe: '音乐订阅',
history: '历史记录',
transfer: '整理',
rename: '重命名',
@@ -389,6 +391,7 @@ export default {
explore: '探索',
movie: '电影',
tv: '电视剧',
music: '音乐',
workflow: '工作流',
calendar: '日历',
downloadManager: '下载管理',
@@ -399,6 +402,19 @@ export default {
userManager: '用户管理',
settings: '设定',
},
music: {
title: '音乐搜索',
subtitle: '搜索音乐元数据,并进入站点资源搜索、下载和订阅流程',
searchPlaceholder: '输入歌曲、艺术家或“艺术家 - 歌曲”',
search: '搜索音乐',
noResults: '没有找到匹配的音乐',
searchResources: '搜索资源',
subscribe: '订阅',
subscribeSuccess: '音乐订阅添加成功',
album: '专辑',
artist: '艺术家',
source: 'MusicBrainz',
},
settingTabs: {
system: {
title: '系统',
@@ -452,6 +468,9 @@ export default {
popular: '热门订阅',
share: '订阅分享',
},
music: {
mysub: '我的订阅',
},
},
workflowTabs: {
list: '我的工作流',
@@ -466,6 +485,7 @@ export default {
douban: '豆瓣',
bangumi: 'Bangumi',
anilist: 'AniList',
music: '音乐',
},
user: {
admin: '管理员',
@@ -1260,12 +1280,14 @@ export default {
categoryMovie: '电影',
categoryTV: '电视剧',
categoryAnime: '动漫',
categoryMusic: '音乐',
categoryRankings: '榜单',
trendingNow: '流行趋势',
nowShowing: '正在热映',
bangumiDaily: 'Bangumi每日放送',
anilistTrendingNow: 'AniList 当前趋势',
anilistPopularThisSeason: 'AniList 本季热门',
listenBrainzWeekly: '本周热门音乐',
tmdbHotMovies: 'TMDB热门电影',
tmdbHotTVShows: 'TMDB热门电视剧',
doubanHotMovies: '豆瓣热门电影',
@@ -2398,6 +2420,8 @@ export default {
movieRenameFormatHint: '使用Jinja2语法,格式参考:https://jinja.palletsprojects.com/en/3.0.x/templates',
tvRenameFormat: '电视剧重命名格式',
tvRenameFormatHint: '使用Jinja2语法,格式参考:https://jinja.palletsprojects.com/en/3.0.x/templates',
musicRenameFormat: '音乐重命名格式',
musicRenameFormatHint: '使用Jinja2语法,可使用 artist、album、track、disc 等音乐字段',
saveSuccess: '存储设置保存成功',
saveFailed: '存储设置保存失败!',
directorySaveSuccess: '目录设置保存成功',
@@ -2703,6 +2727,7 @@ export default {
resourceSearch: '资源搜索',
movieSubscribe: '电影订阅',
tvSubscribe: '电视剧订阅',
musicSubscribe: '音乐订阅',
calendar: '订阅日历',
subscribeShare: '订阅分享',
workflow: '工作流',
@@ -2717,6 +2742,7 @@ export default {
resourceSearch: '站点资源搜索与资源结果页',
movieSubscribe: '电影订阅列表和热门电影',
tvSubscribe: '电视剧订阅列表、热门剧集和分享标签',
musicSubscribe: '音乐订阅列表与自动资源搜索',
calendar: '订阅排期与日历视图',
subscribeShare: '订阅分享列表访问',
workflow: '工作流列表与分享入口',
+26
View File
@@ -82,6 +82,7 @@ export default {
mediaType: {
movie: '電影',
tv: '電視劇',
music: '音樂',
anime: '動漫',
collection: '合集',
unknown: '未知',
@@ -378,6 +379,7 @@ export default {
download: '下載',
movieSubscribe: '電影訂閱',
tvSubscribe: '電視劇訂閱',
musicSubscribe: '音樂訂閱',
history: '歷史記錄',
transfer: '整理',
rename: '重命名',
@@ -389,6 +391,7 @@ export default {
explore: '探索',
movie: '電影',
tv: '電視劇',
music: '音樂',
workflow: '工作流',
calendar: '日曆',
downloadManager: '下載管理',
@@ -399,6 +402,19 @@ export default {
userManager: '用戶管理',
settings: '設定',
},
music: {
title: '音樂搜索',
subtitle: '搜索音樂元數據,並進入站點資源搜索、下載和訂閱流程',
searchPlaceholder: '輸入歌曲、藝術家或「藝術家 - 歌曲」',
search: '搜索音樂',
noResults: '沒有找到匹配的音樂',
searchResources: '搜索資源',
subscribe: '訂閱',
subscribeSuccess: '音樂訂閱添加成功',
album: '專輯',
artist: '藝術家',
source: 'MusicBrainz',
},
settingTabs: {
system: {
title: '系統',
@@ -452,6 +468,9 @@ export default {
popular: '熱門訂閱',
share: '訂閱分享',
},
music: {
mysub: '我的訂閱',
},
},
workflowTabs: {
list: '我的工作流',
@@ -466,6 +485,7 @@ export default {
douban: '豆瓣',
bangumi: 'Bangumi',
anilist: 'AniList',
music: '音樂',
},
user: {
admin: '管理員',
@@ -1258,12 +1278,14 @@ export default {
categoryMovie: '電影',
categoryTV: '電視劇',
categoryAnime: '動漫',
categoryMusic: '音樂',
categoryRankings: '榜單',
trendingNow: '流行趨勢',
nowShowing: '正在熱映',
bangumiDaily: 'Bangumi每日放送',
anilistTrendingNow: 'AniList 當前趨勢',
anilistPopularThisSeason: 'AniList 本季熱門',
listenBrainzWeekly: '本週熱門音樂',
tmdbHotMovies: 'TMDB熱門電影',
tmdbHotTVShows: 'TMDB熱門電視劇',
doubanHotMovies: '豆瓣熱門電影',
@@ -2397,6 +2419,8 @@ export default {
movieRenameFormatHint: '使用Jinja2語法,格式參考:https://jinja.palletsprojects.com/en/3.0.x/templates',
tvRenameFormat: '電視劇重命名格式',
tvRenameFormatHint: '使用Jinja2語法,格式參考:https://jinja.palletsprojects.com/en/3.0.x/templates',
musicRenameFormat: '音樂重命名格式',
musicRenameFormatHint: '使用Jinja2語法,可使用 artist、album、track、disc 等音樂字段',
saveSuccess: '存儲設置保存成功',
saveFailed: '存儲設置保存失敗!',
directorySaveSuccess: '目錄設置保存成功',
@@ -2702,6 +2726,7 @@ export default {
resourceSearch: '資源搜索',
movieSubscribe: '電影訂閱',
tvSubscribe: '電視劇訂閱',
musicSubscribe: '音樂訂閱',
calendar: '訂閱日曆',
subscribeShare: '訂閱分享',
workflow: '工作流',
@@ -2716,6 +2741,7 @@ export default {
resourceSearch: '站點資源搜索與資源結果頁',
movieSubscribe: '電影訂閱列表和熱門電影',
tvSubscribe: '電視劇訂閱列表、熱門劇集和分享標籤',
musicSubscribe: '音樂訂閱列表與自動資源搜索',
calendar: '訂閱排期與日曆視圖',
subscribeShare: '訂閱分享列表存取',
workflow: '工作流列表與分享入口',
+35 -18
View File
@@ -104,6 +104,7 @@ async function renderDiscover() {
BangumiView: BuiltInViewStub,
DoubanView: BuiltInViewStub,
ExtraSourceView: ExtraSourceViewStub,
MusicView: BuiltInViewStub,
TheMovieDbView: BuiltInViewStub,
VScrollToTopBtn: true,
},
@@ -156,10 +157,7 @@ describe('discover page', () => {
it('uses local order, merges sources by prefix, and keeps unconfigured tabs stable', async () => {
const configRequested = vi.fn()
localStorage.setItem(
'MP_DISCOVER_TAB_ORDER',
JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }]),
)
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }]))
server.use(
discoverOrderConfigHandler([], 200, configRequested),
discoverSourcesHandler([
@@ -178,10 +176,18 @@ describe('discover page', () => {
'TheMovieDb',
'Bangumi',
'AniList',
'音乐',
]),
)
expect(configRequested).not.toHaveBeenCalled()
expect(getHeaderItems().map(item => item.tab)).toEqual(['douban', 'custom', 'themoviedb', 'bangumi', 'anilist'])
expect(getHeaderItems().map(item => item.tab)).toEqual([
'douban',
'custom',
'themoviedb',
'bangumi',
'anilist',
'musicbrainz',
])
})
it('loads remote order when local order is absent and backfills localStorage', async () => {
@@ -201,6 +207,7 @@ describe('discover page', () => {
'TheMovieDb',
'豆瓣',
'AniList',
'音乐',
'自定义来源',
]),
)
@@ -216,7 +223,9 @@ describe('discover page', () => {
const { componentError } = await renderDiscover()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', 'AniList']))
await waitFor(() =>
expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', 'AniList', '音乐']),
)
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
expect(componentError).not.toHaveBeenCalled()
})
@@ -224,15 +233,19 @@ describe('discover page', () => {
it('keeps built-in and extra sources usable when the order config request fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(
discoverOrderConfigHandler(null, 500),
discoverSourcesHandler([createSource('可用扩展源', 'available')]),
)
server.use(discoverOrderConfigHandler(null, 500), discoverSourcesHandler([createSource('可用扩展源', 'available')]))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('可用扩展源'))
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', 'AniList', '可用扩展源'])
expect(getHeaderItems().map(item => item.title)).toEqual([
'TheMovieDb',
'豆瓣',
'Bangumi',
'AniList',
'音乐',
'可用扩展源',
])
expect(getHeaderConfig().modelValue.value).toBe('themoviedb')
})
@@ -276,10 +289,7 @@ describe('discover page', () => {
it('removes a withdrawn source and falls back to the first sorted tab after reactivation', async () => {
let sources = [createSource('已撤销来源', 'withdrawn')]
const requested = vi.fn()
localStorage.setItem(
'MP_DISCOVER_TAB_ORDER',
JSON.stringify([{ name: '已撤销来源' }, { name: 'TheMovieDb' }]),
)
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([{ name: '已撤销来源' }, { name: 'TheMovieDb' }]))
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
@@ -319,7 +329,14 @@ describe('discover page', () => {
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
expect(getHeaderItems().map(item => item.title)).toContain('缓存来源')
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', 'AniList', '缓存来源'])
expect(getHeaderItems().map(item => item.title)).toEqual([
'TheMovieDb',
'豆瓣',
'Bangumi',
'AniList',
'音乐',
'缓存来源',
])
})
it('replaces the header metadata when a source with the same prefix changes', async () => {
@@ -354,11 +371,11 @@ describe('discover page', () => {
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems()).toHaveLength(5))
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('自定义来源'))
getHeaderConfig().appendButtons[0].action()
const { events, tabs } = getDialogCall()
const reorderedTabs = [tabs[4], tabs[1], tabs[0], tabs[3], tabs[2]]
const reorderedTabs = [tabs[4], tabs[1], tabs[0], tabs[3], tabs[2], tabs[5]]
await events.save(reorderedTabs)
const expectedOrder = reorderedTabs.map(item => ({ name: item.name }))
+81
View File
@@ -0,0 +1,81 @@
import MusicPage from '@/pages/music.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(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
}),
}))
describe('music page', () => {
beforeEach(() => {
mocks.apiGet.mockResolvedValue([
{
album: '叶惠美',
artist: '周杰伦',
artists: ['周杰伦'],
media_id: 'recording-1',
source: 'musicbrainz',
title: '晴天',
type: '音乐',
year: 2003,
},
])
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,
},
},
})
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('音乐订阅添加成功')
})
})
+2 -1
View File
@@ -139,9 +139,10 @@ describe('recommend page', () => {
await renderRecommend()
expect(await screen.findByText('自定义来源')).toBeInTheDocument()
expect(screen.getAllByTestId('recommend-view')).toHaveLength(4)
expect(screen.getAllByTestId('recommend-view')).toHaveLength(5)
expect(screen.getByText('AniList 当前趋势')).toBeInTheDocument()
expect(screen.getByText('AniList 本季热门')).toBeInTheDocument()
expect(screen.getByText('本周热门音乐')).toBeInTheDocument()
expect(screen.queryByText('重复来源')).not.toBeInTheDocument()
expect(remoteConfigRequests).toBe(0)
})
+6
View File
@@ -4,6 +4,7 @@ import TheMovieDbView from '@/views/discover/TheMovieDbView.vue'
import DoubanView from '@/views/discover/DoubanView.vue'
import BangumiView from '@/views/discover/BangumiView.vue'
import AniListView from '@/views/discover/AniListView.vue'
import MusicView from '@/views/discover/MusicView.vue'
import ExtraSourceView from '@/views/discover/ExtraSourceView.vue'
import { DiscoverSource } from '@/api/types'
import api from '@/api'
@@ -256,6 +257,11 @@ onActivated(async () => {
<AniListView />
</div>
</VWindowItem>
<VWindowItem value="musicbrainz">
<div>
<MusicView />
</div>
</VWindowItem>
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
<div>
<ExtraSourceView :source="item" />
+193
View File
@@ -0,0 +1,193 @@
<script setup lang="ts">
import api from '@/api'
import type { ApiResponse, MediaInfo } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
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>())
if (route.query.query) {
query.value = route.query.query.toString()
}
/** 返回音乐候选在列表中的稳定身份。 */
function getMusicKey(item: MediaInfo) {
return `${item.source || 'music'}:${item.media_id || `${item.artist}-${item.title}-${item.album}`}`
}
/** 调用统一音乐元数据接口搜索候选。 */
async function searchMusic() {
const keyword = query.value.trim()
if (!keyword || loading.value) return
loading.value = true
searched.value = true
try {
results.value = (await api.get('music/search', { params: { query: keyword, count: 30 } })) || []
} catch (error) {
console.error(error)
results.value = []
} finally {
loading.value = false
}
}
/** 使用音乐元数据身份进入现有站点资源精确搜索页。 */
function searchResources(item: MediaInfo) {
if (!item.source || !item.media_id) return
router.push({
path: '/resource',
query: {
keyword: `${item.source}:${item.media_id}`,
type: '音乐',
title: item.title,
year: item.year,
area: 'title',
result_type: 'torrent',
},
})
}
/** 将选中的音乐目标写入现有订阅表和订阅调度流程。 */
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()
})
</script>
<template>
<div class="music-search-page">
<VPageContentTitle :title="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">
<VCol v-for="item in results" :key="getMusicKey(item)" cols="12" md="6" xl="4">
<VCard class="h-100">
<div class="d-flex pa-4 ga-4">
<VImg
v-if="item.cover_url || item.poster_path"
:src="item.cover_url || item.poster_path"
width="104"
height="104"
cover
rounded="lg"
class="flex-grow-0"
/>
<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" prepend-icon="mdi-magnify" @click="searchResources(item)">
{{ t('music.searchResources') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
prepend-icon="mdi-rss"
:loading="subscribingIds.has(getMusicKey(item))"
@click="subscribeMusic(item)"
>
{{ t('music.subscribe') }}
</VBtn>
</VCardActions>
</VCard>
</VCol>
</VRow>
<NoDataFound v-else-if="searched && !loading" :title="t('music.noResults')" />
<VSkeletonLoader v-else-if="loading" type="card, card, card" />
</div>
</template>
<style scoped>
.music-search-page {
max-width: 1440px;
margin-inline: auto;
}
.min-w-0 {
min-width: 0;
}
</style>
+4 -3
View File
@@ -17,7 +17,9 @@ import {
type RecommendViewSource,
} from '@/utils/recommendSources'
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
const ContentToggleSettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
)
const { appMode } = usePWA()
@@ -70,7 +72,7 @@ function openRecommendSettings() {
const builtInRecommendSources = createBuiltInRecommendSources(t)
const viewList = reactive<RecommendViewSource[]>([...builtInRecommendSources])
const newlyAddedBuiltInPaths = new Set(['anilist/trending', 'anilist/popular-this-season'])
const newlyAddedBuiltInPaths = new Set(['anilist/trending', 'anilist/popular-this-season', 'recommend/music_weekly'])
//
const filteredViews = computed(() => {
@@ -328,5 +330,4 @@ onActivated(async () => {
font-size: 1rem;
margin-block-end: 16px;
}
</style>
+2
View File
@@ -576,6 +576,7 @@ function buildSearchStreamUrl(params: SearchParams, requestToken?: string) {
setSearchParam(url.searchParams, 'sites', params.sites)
} else {
setSearchParam(url.searchParams, 'keyword', params.keyword)
setSearchParam(url.searchParams, 'mtype', params.type)
setSearchParam(url.searchParams, 'sites', params.sites)
}
@@ -826,6 +827,7 @@ async function requestSearchResults(params: SearchParams, requestToken?: string)
result = await api.get(`search/title`, {
params: {
keyword: params.keyword,
...(params.type ? { mtype: params.type } : {}),
sites: params.sites,
_ts: requestToken,
},
+35 -9
View File
@@ -9,7 +9,7 @@ import { useUserStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
import { getSubscribeMovieTabs, getSubscribeMusicTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
//
const { t } = useI18n()
@@ -50,9 +50,9 @@ const subscribeBatchState = ref<SubscribeBatchState>({
const subscribeTabs = computed(() => {
if (subType === '电影') {
return getSubscribeMovieTabs(t)
} else {
return getSubscribeTvTabs(t)
}
if (subType === '音乐') return getSubscribeMusicTabs(t)
return getSubscribeTvTabs(t)
})
//
@@ -84,7 +84,7 @@ function isValidSubscribeSortBy(value: string | null): value is SubscribeSortBy
if (!value) return false
const sortValues: SubscribeSortBy[] = ['custom', 'last_update', 'date']
if (subType !== '电') {
if (subType === '电视剧') {
sortValues.push('lack_episode')
}
@@ -123,10 +123,31 @@ const subscribeSortBy = ref<SubscribeSortBy | ''>(loadSubscribeSortBy())
const shareKeyword = ref('')
const shareKeywordInput = ref('')
interface SubscribeFilterOption {
value: string
label: string
icon: string
color?: string
}
//
const filterOptions = computed(() => {
const filterOptions = computed<SubscribeFilterOption[]>(() => {
const allOption: SubscribeFilterOption = {
value: 'all',
label: t('common.all'),
icon: 'mdi-filter-multiple-outline',
}
if (subType === '音乐') {
return [
allOption,
{ value: 'pending', label: t('subscribe.pending'), icon: 'mdi-help-circle', color: 'secondary' },
{ value: 'paused', label: t('subscribe.paused'), icon: 'mdi-pause-circle', color: 'error' },
]
}
const baseOptions = [
{ value: 'all', label: t('common.all'), icon: 'mdi-filter-multiple-outline' },
allOption,
{ value: 'best_version', label: t('subscribe.bestVersion'), icon: 'mdi-refresh', color: 'warning' },
]
@@ -158,7 +179,7 @@ const sortOptions = computed<Array<{ value: SubscribeSortBy; label: string }>>((
{ value: 'date', label: t('subscribe.sort.addTime') },
]
if (subType !== '电') {
if (subType === '电视剧') {
options.push({ value: 'lack_episode', label: t('subscribe.sort.lackEpisode') })
}
@@ -212,9 +233,14 @@ const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub' && canAdmin.value)
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub' && canAdmin.value && subType !== '音乐')
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && canAdmin.value)
const showShareStatisticsAction = computed(() => activeTab.value === 'share' && canSubscribe.value)
const subscribeRoutePath = computed(() => {
if (subType === '电影') return '/subscribe/movie'
if (subType === '音乐') return '/subscribe/music'
return '/subscribe/tv'
})
function openDefaultRuleDialog() {
openSharedDialog(
@@ -638,7 +664,7 @@ onMounted(() => {
</VMenu>
</Teleport>
<Teleport to="body" v-if="!appMode && route.path.startsWith(`/subscribe/${subType === '电影' ? 'movie' : 'tv'}`)">
<Teleport to="body" v-if="!appMode && route.path.startsWith(subscribeRoutePath)">
<div class="compact-fab-stack">
<VFab
v-if="subscribeBatchState.enabled"
+41
View File
@@ -31,6 +31,17 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
permission: 'search',
feature: PERMISSION_FEATURE.SEARCH_RESOURCE,
},
{
title: t('navItems.music'),
full_title: t('music.title'),
icon: 'mdi-music-note-search',
iconColor: 'primary',
to: '/music',
header: t('menu.start'),
admin: false,
permission: 'search',
feature: PERMISSION_FEATURE.SEARCH_RESOURCE,
},
{
title: t('navItems.recommend'),
icon: 'mdi-star-outline',
@@ -81,6 +92,19 @@ export function getNavMenus(t: Composer['t']): NavMenu[] {
feature: PERMISSION_FEATURE.SUBSCRIBE_TV,
tabs: getSubscribeTvTabs(t),
},
{
title: t('navItems.music'),
full_title: t('navItems.musicSubscribe'),
icon: 'mdi-music-note',
iconColor: 'primary',
to: '/subscribe/music',
header: t('menu.subscribe'),
admin: false,
footer: false,
permission: 'subscribe',
feature: PERMISSION_FEATURE.SUBSCRIBE_MUSIC,
tabs: getSubscribeMusicTabs(t),
},
{
title: t('navItems.workflow'),
full_title: t('navItems.workflow'),
@@ -188,6 +212,7 @@ export function getRecommendTabs(t: Composer['t']): NavMenuTabItem[] {
{ title: t('recommend.categoryMovie'), icon: 'mdi-movie', tab: t('recommend.categoryMovie') },
{ title: t('recommend.categoryTV'), icon: 'mdi-television-classic', tab: t('recommend.categoryTV') },
{ title: t('recommend.categoryAnime'), icon: 'mdi-animation', tab: t('recommend.categoryAnime') },
{ title: t('recommend.categoryMusic'), icon: 'mdi-music-note', tab: t('recommend.categoryMusic') },
{ title: t('recommend.categoryRankings'), icon: 'mdi-trophy', tab: t('recommend.categoryRankings') },
]
}
@@ -277,6 +302,17 @@ export function getSubscribeTvTabs(t: Composer['t']): NavMenuTabItem[] {
]
}
/** 返回音乐订阅页的业务标签。 */
export function getSubscribeMusicTabs(t: Composer['t']): NavMenuTabItem[] {
return [
{
title: t('subscribeTabs.music.mysub'),
tab: 'mysub',
icon: 'mdi-bell-check',
},
]
}
/** 返回插件管理页的业务标签。 */
export function getPluginTabs(t: Composer['t']): NavMenuTabItem[] {
return [
@@ -316,6 +352,11 @@ export function getDiscoverTabs(t: Composer['t']): NavMenuTabItem[] {
tab: 'anilist',
icon: 'mdi-alpha-a-circle-outline',
},
{
title: t('discoverTabs.music'),
tab: 'musicbrainz',
icon: 'mdi-music-note-outline',
},
]
}
+22
View File
@@ -75,6 +75,16 @@ const router = createRouter({
feature: PERMISSION_FEATURE.SEARCH_RESOURCE,
},
},
{
path: '/music',
component: () => import('../pages/music.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
permission: 'search',
feature: PERMISSION_FEATURE.SEARCH_RESOURCE,
},
},
{
path: '/subscribe/movie',
component: () => import('../pages/subscribe.vue'),
@@ -99,6 +109,18 @@ const router = createRouter({
subType: '电视剧',
},
},
{
path: '/subscribe/music',
component: () => import('../pages/subscribe.vue'),
meta: {
keepAlive: true,
keepAliveKey: 'subscribe-music',
requiresAuth: true,
permission: 'subscribe',
feature: PERMISSION_FEATURE.SUBSCRIBE_MUSIC,
subType: '音乐',
},
},
{
path: '/subscribe-share',
component: () => import('../pages/subscribe-share.vue'),
+7 -1
View File
@@ -34,7 +34,7 @@ describe('recommendSources', () => {
it('creates the complete built-in source contract', () => {
const sources = createBuiltInRecommendSources(translate)
expect(sources).toHaveLength(15)
expect(sources).toHaveLength(16)
expect(sources[0]).toEqual({
apipath: 'recommend/tmdb_trending',
linkurl: '/browse/recommend/tmdb_trending?title=translated:recommend.trendingNow',
@@ -60,6 +60,12 @@ describe('recommendSources', () => {
title: 'translated:recommend.anilistPopularThisSeason',
type: 'translated:recommend.categoryAnime',
})
expect(sources).toContainEqual({
apipath: 'recommend/music_weekly',
linkurl: '/browse/recommend/music_weekly?title=translated:recommend.listenBrainzWeekly',
title: 'translated:recommend.listenBrainzWeekly',
type: 'translated:recommend.categoryMusic',
})
expect(
sources.filter(source => source.type === 'translated:recommend.categoryAnime').map(source => source.apipath),
).toEqual([
+16 -2
View File
@@ -31,6 +31,7 @@ export const PERMISSION_FEATURE = {
SEARCH_RESOURCE: 'search.resource',
SUBSCRIBE_MOVIE: 'subscribe.movie',
SUBSCRIBE_TV: 'subscribe.tv',
SUBSCRIBE_MUSIC: 'subscribe.music',
SUBSCRIBE_CALENDAR: 'subscribe.calendar',
SUBSCRIBE_SHARE: 'subscribe.share',
MANAGE_WORKFLOW: 'manage.workflow',
@@ -81,6 +82,14 @@ export const USER_PERMISSION_FEATURES: UserPermissionFeatureOption[] = [
icon: 'mdi-television',
path: '/subscribe/tv',
},
{
key: PERMISSION_FEATURE.SUBSCRIBE_MUSIC,
permission: 'subscribe',
titleKey: 'dialog.userAddEdit.permissions.features.musicSubscribe',
descriptionKey: 'dialog.userAddEdit.permissions.featureDescriptions.musicSubscribe',
icon: 'mdi-music-note',
path: '/subscribe/music',
},
{
key: PERMISSION_FEATURE.SUBSCRIBE_CALENDAR,
permission: 'subscribe',
@@ -155,7 +164,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
/** 规整用户权限数据,兼容没有 features 字段的历史用户。 */
export function normalizeUserPermissions(permissions: Partial<UserPermissions> | null | undefined = {}): UserPermissions {
export function normalizeUserPermissions(
permissions: Partial<UserPermissions> | null | undefined = {},
): UserPermissions {
const permissionData = permissions ?? {}
const rawFeatures = isRecord(permissionData.features) ? permissionData.features : {}
const features = Object.fromEntries(
@@ -173,7 +184,10 @@ export function normalizeUserPermissions(permissions: Partial<UserPermissions> |
}
/** 构造权限检查上下文,统一超级管理员标记、分类权限与功能权限字段。 */
export function buildUserPermissionContext(isSuperuser: boolean, permissions: Partial<UserPermissions> = {}): UserPermissionContext {
export function buildUserPermissionContext(
isSuperuser: boolean,
permissions: Partial<UserPermissions> = {},
): UserPermissionContext {
return {
is_superuser: isSuperuser,
...normalizeUserPermissions(permissions),
+6
View File
@@ -42,6 +42,12 @@ export function createBuiltInRecommendSources(t: Translate): RecommendViewSource
title: t('recommend.anilistPopularThisSeason'),
type: t('recommend.categoryAnime'),
},
{
apipath: 'recommend/music_weekly',
linkurl: '/browse/recommend/music_weekly?title=' + t('recommend.listenBrainzWeekly'),
title: t('recommend.listenBrainzWeekly'),
type: t('recommend.categoryMusic'),
},
{
apipath: 'recommend/tmdb_movies',
linkurl: '/browse/recommend/tmdb_movies?title=' + t('recommend.tmdbHotMovies'),
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
</script>
<template>
<MediaCardListView apipath="music/explore" :params="{ count: 30 }" />
</template>
@@ -63,6 +63,7 @@ const SystemSettings = ref<any>({
SCRAP_SOURCE: 'themoviedb',
MOVIE_RENAME_FORMAT: null,
TV_RENAME_FORMAT: null,
MUSIC_RENAME_FORMAT: null,
},
})
@@ -107,6 +108,13 @@ const tvRenameFormat = computed({
},
})
const musicRenameFormat = computed({
get: () => SystemSettings.value.Basic.MUSIC_RENAME_FORMAT ?? '',
set: value => {
SystemSettings.value.Basic.MUSIC_RENAME_FORMAT = value || null
},
})
//
async function loadSystemSettings() {
try {
@@ -457,6 +465,29 @@ useSilentSettingRefresh(loadPageData, {
</div>
</div>
</VCol>
<VCol cols="12">
<div class="rename-format-editor">
<div class="rename-format-editor__label">
<VIcon icon="mdi-music-note" size="20" class="me-2" />
<span>{{ t('setting.directory.musicRenameFormat') }}</span>
</div>
<VAceEditor
v-model:value="musicRenameFormat"
lang="jinja2"
:theme="editorTheme"
:options="renameEditorOptions"
:print-margin="false"
:min-lines="4"
:max-lines="12"
wrap
class="rename-format-editor__ace"
@init="configureAceEditorPadding"
/>
<div class="rename-format-editor__hint">
{{ t('setting.directory.musicRenameFormatHint') }}
</div>
</div>
</VCol>
<VCol cols="12">
<div class="rename-format-editor">
<div class="rename-format-editor__label">
+9 -6
View File
@@ -99,10 +99,10 @@ const isAllSubscribesSelected = computed(
() => displayList.value.length > 0 && displayList.value.every(item => selectedSubscribesSet.value.has(item.id)),
)
// 使
// 使
const normalizedSortBy = computed<SubscribeSortBy | ''>(() => {
const sortBy = props.sortBy as SubscribeSortBy | ''
if (props.type === '电' && sortBy === 'lack_episode') {
if (props.type !== '电视剧' && sortBy === 'lack_episode') {
return 'date'
}
@@ -151,8 +151,8 @@ function getSubscribeStatus(subscribe: Subscribe) {
return 'paused' //
}
//
if (subscribe.type === '电影') {
//
if (subscribe.type === '电影' || subscribe.type === '音乐') {
return 'all'
}
@@ -175,7 +175,11 @@ function getSubscribeStatus(subscribe: Subscribe) {
}
// API
const orderRequestKey = computed(() => (props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'))
const orderRequestKey = computed(() => {
if (props.type === '电影') return 'SubscribeMovieOrder'
if (props.type === '音乐') return 'SubscribeMusicOrder'
return 'SubscribeTvOrder'
})
//
function getSubscribeTimeValue(value?: string) {
@@ -558,7 +562,6 @@ onMounted(async () => {
sub.page_open = true
}
}
})
useKeepAliveRefresh(fetchData, {