mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
feat(v3): add music search and subscription UI
This commit is contained in:
+53
-28
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
// 季信息
|
||||
|
||||
@@ -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: '音乐' },
|
||||
])
|
||||
|
||||
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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: '音乐',
|
||||
},
|
||||
])
|
||||
|
||||
// 二级分类策略
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '工作流列表与分享入口',
|
||||
|
||||
@@ -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: '工作流列表與分享入口',
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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('音乐订阅添加成功')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user