mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 09:46:44 +08:00
feat(history): group music tracks by album (#753)
This commit is contained in:
@@ -746,9 +746,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/views/reorganize/TransferHistoryView.vue": {
|
"src/views/reorganize/TransferHistoryView.vue": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 5
|
|
||||||
},
|
|
||||||
"vue/no-v-text-v-html-on-component": {
|
"vue/no-v-text-v-html-on-component": {
|
||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const aiRedoIds = ref<number[]>([])
|
|||||||
// AI整理进度
|
// AI整理进度
|
||||||
const aiRedoProgressActive = ref(false)
|
const aiRedoProgressActive = ref(false)
|
||||||
const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))
|
const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))
|
||||||
const aiRedoProgressSSE = ref<any>(null)
|
const aiRedoProgressSSE = ref<ReturnType<typeof useProgressSSE> | null>(null)
|
||||||
const aiRedoProgressHistoryIds = ref<number[]>([])
|
const aiRedoProgressHistoryIds = ref<number[]>([])
|
||||||
let aiRedoProgressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let aiRedoProgressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
@@ -88,6 +88,13 @@ const redoTargetStorage = ref<string>()
|
|||||||
// 已选中的数据
|
// 已选中的数据
|
||||||
const selected = ref<TransferHistory[]>([])
|
const selected = ref<TransferHistory[]>([])
|
||||||
|
|
||||||
|
interface TransferHistoryDisplayItem extends TransferHistory {
|
||||||
|
history_group_is_music_album: boolean
|
||||||
|
history_group_key: string
|
||||||
|
history_group_label: string
|
||||||
|
history_group_track_key: string
|
||||||
|
}
|
||||||
|
|
||||||
// 当前删除尝试已完成的文件步骤,页面刷新后由后端“已不存在”状态重新确认。
|
// 当前删除尝试已完成的文件步骤,页面刷新后由后端“已不存在”状态重新确认。
|
||||||
const completedDeleteSteps = new Map<number, { source: boolean; destination: boolean }>()
|
const completedDeleteSteps = new Map<number, { source: boolean; destination: boolean }>()
|
||||||
|
|
||||||
@@ -256,7 +263,7 @@ const pageRange = [
|
|||||||
const pageRangeValues = pageRange.map(item => item.value)
|
const pageRangeValues = pageRange.map(item => item.value)
|
||||||
|
|
||||||
// 数据列表
|
// 数据列表
|
||||||
const dataList = ref<TransferHistory[]>([])
|
const dataList = ref<TransferHistoryDisplayItem[]>([])
|
||||||
|
|
||||||
// 移动端历史记录列表,独立于桌面分页表格。
|
// 移动端历史记录列表,独立于桌面分页表格。
|
||||||
const mobileDataList = ref<TransferHistory[]>([])
|
const mobileDataList = ref<TransferHistory[]>([])
|
||||||
@@ -298,11 +305,13 @@ const totalItems = ref(0)
|
|||||||
|
|
||||||
// 是否要分组
|
// 是否要分组
|
||||||
const group = ref<boolean>(route.query.grouped === 'true')
|
const group = ref<boolean>(route.query.grouped === 'true')
|
||||||
|
// 区分默认平铺与用户显式选择平铺,避免搜索/翻页提前关闭后续专辑自动分组。
|
||||||
|
const groupPreferenceExplicit = ref(route.query.grouped !== undefined)
|
||||||
|
|
||||||
// 分组条件
|
// 分组条件
|
||||||
const groupBy = ref<any>([
|
const groupBy = ref<Array<{ key: string }>>([
|
||||||
{
|
{
|
||||||
key: 'title',
|
key: 'history_group_key',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -516,6 +525,72 @@ watch(isDesktop, desktop => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 统一 Windows 与 POSIX 路径后返回父目录;音乐整理后的父目录就是播放器使用的专辑目录。
|
||||||
|
function normalizeHistoryPath(path?: string) {
|
||||||
|
let normalized = (path || '').replaceAll('\\', '/')
|
||||||
|
while (normalized.endsWith('/')) normalized = normalized.slice(0, -1)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoryParentPath(path?: string) {
|
||||||
|
const normalized = normalizeHistoryPath(path)
|
||||||
|
const separator = normalized.lastIndexOf('/')
|
||||||
|
return separator > 0 ? normalized.slice(0, separator) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从完整路径中提取分组标题,保留整理规则生成的专辑名与年份。
|
||||||
|
function getHistoryPathName(path: string) {
|
||||||
|
return path.split('/').filter(Boolean).at(-1) || path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音乐按目标专辑目录分组,旧记录或失败记录则回退到源目录;其它媒体保持原有的标题分组。
|
||||||
|
function toHistoryDisplayItem(item: TransferHistory): TransferHistoryDisplayItem {
|
||||||
|
if (item.type === '音乐') {
|
||||||
|
const candidates = item.status
|
||||||
|
? ([
|
||||||
|
[item.dest, item.dest_storage],
|
||||||
|
[item.src, item.src_storage],
|
||||||
|
] as const)
|
||||||
|
: item.src
|
||||||
|
? ([[item.src, item.src_storage]] as const)
|
||||||
|
: ([[item.dest, item.dest_storage]] as const)
|
||||||
|
for (const [path, storage] of candidates) {
|
||||||
|
const normalizedPath = normalizeHistoryPath(path)
|
||||||
|
const albumPath = getHistoryParentPath(normalizedPath)
|
||||||
|
if (!albumPath) continue
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
history_group_is_music_album: true,
|
||||||
|
history_group_key: `music:${JSON.stringify([storage || '', albumPath])}`,
|
||||||
|
history_group_label: getHistoryPathName(albumPath),
|
||||||
|
history_group_track_key: JSON.stringify([storage || '', normalizedPath]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = item.title || t('common.unknown')
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
history_group_is_music_album: false,
|
||||||
|
history_group_key: `title:${JSON.stringify(item.title ?? null)}`,
|
||||||
|
history_group_label: title,
|
||||||
|
history_group_track_key: `history:${item.id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前页出现同一专辑的多首音乐时,首次访问自动切换到可展开的分组视图。
|
||||||
|
function hasMusicAlbumGroup(items: TransferHistoryDisplayItem[]) {
|
||||||
|
const tracksByAlbum = new Map<string, Set<string>>()
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.history_group_is_music_album) continue
|
||||||
|
const tracks = tracksByAlbum.get(item.history_group_key) || new Set<string>()
|
||||||
|
tracks.add(item.history_group_track_key)
|
||||||
|
if (tracks.size > 1) return true
|
||||||
|
tracksByAlbum.set(item.history_group_key, tracks)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 获取历史记录数据,keep-alive 重新进入时可静默刷新,避免表格出现重新加载感。
|
// 获取历史记录数据,keep-alive 重新进入时可静默刷新,避免表格出现重新加载感。
|
||||||
async function fetchData(page = currentPage.value, count = itemsPerPage.value, options: { silent?: boolean } = {}) {
|
async function fetchData(page = currentPage.value, count = itemsPerPage.value, options: { silent?: boolean } = {}) {
|
||||||
const requestSeed = ++fetchDataRequestSeed
|
const requestSeed = ++fetchDataRequestSeed
|
||||||
@@ -538,9 +613,10 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value, o
|
|||||||
const list = Array.isArray(result.list) ? result.list : []
|
const list = Array.isArray(result.list) ? result.list : []
|
||||||
|
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
dataList.value = list
|
const displayList = list.map(toHistoryDisplayItem)
|
||||||
|
dataList.value = displayList
|
||||||
if (isDesktop.value && selected.value.length > 0) {
|
if (isDesktop.value && selected.value.length > 0) {
|
||||||
const refreshedItems = new Map(list.map(item => [item.id, item]))
|
const refreshedItems = new Map(displayList.map(item => [item.id, item]))
|
||||||
selected.value = selected.value.flatMap(item => {
|
selected.value = selected.value.flatMap(item => {
|
||||||
const refreshed = refreshedItems.get(item.id)
|
const refreshed = refreshedItems.get(item.id)
|
||||||
return refreshed ? [refreshed] : []
|
return refreshed ? [refreshed] : []
|
||||||
@@ -549,6 +625,10 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value, o
|
|||||||
totalItems.value = ensureNumber(result.total, 0)
|
totalItems.value = ensureNumber(result.total, 0)
|
||||||
updateSearchHintList(list)
|
updateSearchHintList(list)
|
||||||
|
|
||||||
|
if (isDesktop.value && route.query.grouped === undefined && hasMusicAlbumGroup(displayList)) {
|
||||||
|
group.value = true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
total: totalItems.value,
|
total: totalItems.value,
|
||||||
@@ -681,12 +761,14 @@ function resetMobileHistory(options: MobileHistoryResetOptions = {}) {
|
|||||||
mobileInfiniteKey.value++
|
mobileInfiniteKey.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端只从路由同步搜索词,不接收桌面分页和分组状态。
|
// 移动端不启用桌面分组视图,但保留其显式偏好,避免搜索时从地址栏丢失。
|
||||||
function syncMobileSearchFromRouteQuery() {
|
function syncMobileSearchFromRouteQuery() {
|
||||||
syncingRouteQuery = true
|
syncingRouteQuery = true
|
||||||
try {
|
try {
|
||||||
search.value = getRouteQueryString(route.query.search)
|
search.value = getRouteQueryString(route.query.search)
|
||||||
statusFilter.value = getRouteStatusFilter(route.query.status)
|
statusFilter.value = getRouteStatusFilter(route.query.status)
|
||||||
|
group.value = route.query.grouped === 'true'
|
||||||
|
groupPreferenceExplicit.value = route.query.grouped !== undefined
|
||||||
} finally {
|
} finally {
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
syncingRouteQuery = false
|
syncingRouteQuery = false
|
||||||
@@ -786,6 +868,7 @@ async function syncStateFromRouteQuery() {
|
|||||||
itemsPerPage.value = ensurePageSize(route.query.itemsPerPage, 50)
|
itemsPerPage.value = ensurePageSize(route.query.itemsPerPage, 50)
|
||||||
currentPage.value = Math.max(1, ensureNumber(route.query.currentPage, 1))
|
currentPage.value = Math.max(1, ensureNumber(route.query.currentPage, 1))
|
||||||
group.value = route.query.grouped === 'true'
|
group.value = route.query.grouped === 'true'
|
||||||
|
groupPreferenceExplicit.value = route.query.grouped !== undefined
|
||||||
} finally {
|
} finally {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
syncingRouteQuery = false
|
syncingRouteQuery = false
|
||||||
@@ -1227,8 +1310,8 @@ function createHistoryUrl(resetPage = false, page = resetPage ? 1 : currentPage.
|
|||||||
if (page) {
|
if (page) {
|
||||||
query.currentPage = String(page)
|
query.currentPage = String(page)
|
||||||
}
|
}
|
||||||
if (group.value) {
|
if (group.value || groupPreferenceExplicit.value) {
|
||||||
query.grouped = 'true'
|
query.grouped = String(group.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1247,18 +1330,21 @@ async function reloadMobileSearchPage() {
|
|||||||
await router.push(createHistoryUrl(true))
|
await router.push(createHistoryUrl(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只有工具栏按钮切换才属于显式偏好;数据驱动的自动分组不把默认平铺误记为用户选择。
|
||||||
|
function toggleHistoryGrouping() {
|
||||||
|
groupPreferenceExplicit.value = true
|
||||||
|
group.value = !group.value
|
||||||
|
}
|
||||||
|
|
||||||
// 确保值为number类型
|
// 确保值为number类型
|
||||||
function ensureNumber(value: any, defaultValue: number = 0) {
|
function ensureNumber(value: unknown, defaultValue: number = 0) {
|
||||||
value = Number(value)
|
const numberValue = Number(value)
|
||||||
// 如果不是数字
|
// 如果不是数字
|
||||||
if (Number.isNaN(value)) {
|
return Number.isNaN(numberValue) ? defaultValue : numberValue
|
||||||
value = defaultValue
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验分页条数,避免地址栏参数超出可选范围。
|
// 校验分页条数,避免地址栏参数超出可选范围。
|
||||||
function ensurePageSize(value: any, defaultValue: number = 50) {
|
function ensurePageSize(value: unknown, defaultValue: number = 50) {
|
||||||
const pageSize = ensureNumber(value, defaultValue)
|
const pageSize = ensureNumber(value, defaultValue)
|
||||||
return pageRangeValues.includes(pageSize) ? pageSize : defaultValue
|
return pageRangeValues.includes(pageSize) ? pageSize : defaultValue
|
||||||
}
|
}
|
||||||
@@ -1437,20 +1523,34 @@ function deselectAllMobileHistory() {
|
|||||||
updateHistorySelection(mobileDataList.value, false)
|
updateHistorySelection(mobileDataList.value, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数
|
// 按展示分组统计选中数量,音乐专辑使用目录身份,其它媒体沿用标题身份。
|
||||||
const selectedCountsGroupedByTitle = computed(() => {
|
const selectedCountsGroupedByKey = computed(() => {
|
||||||
return selected.value.reduce(
|
return selected.value.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
const title = item.title || ''
|
const key = (item as Partial<TransferHistoryDisplayItem>).history_group_key || item.title || ''
|
||||||
acc[title] = (acc[title] || 0) + 1
|
acc[key] = (acc[key] || 0) + 1
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{} as Record<string, number>,
|
{} as Record<string, number>,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface TransferHistoryGroupItem {
|
||||||
|
value: TransferHistoryDisplayItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vuetify 分组项包装了原始记录,标题取第一条记录的展示标签。
|
||||||
|
function getHistoryGroupLabel(items: readonly TransferHistoryGroupItem[]) {
|
||||||
|
return items[0]?.value?.history_group_label || t('common.unknown')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 曲目数量只属于音乐专辑组,非音乐标题组保持原有展示。
|
||||||
|
function isMusicAlbumGroup(items: readonly TransferHistoryGroupItem[]) {
|
||||||
|
return Boolean(items[0]?.value?.history_group_is_music_album)
|
||||||
|
}
|
||||||
|
|
||||||
// 控制分组内所有子项的选中状态
|
// 控制分组内所有子项的选中状态
|
||||||
const toggleGroupSelection = (checked: boolean | null, items: readonly any[]) => {
|
const toggleGroupSelection = (checked: boolean | null, items: readonly TransferHistoryGroupItem[]) => {
|
||||||
const values = items.map(item => item.value)
|
const values = items.map(item => item.value)
|
||||||
updateHistorySelection(values, checked)
|
updateHistorySelection(values, checked)
|
||||||
}
|
}
|
||||||
@@ -1598,6 +1698,7 @@ onMounted(() => {
|
|||||||
if (isDesktop.value) {
|
if (isDesktop.value) {
|
||||||
void refreshDataFromRouteQuery()
|
void refreshDataFromRouteQuery()
|
||||||
} else {
|
} else {
|
||||||
|
syncMobileSearchFromRouteQuery()
|
||||||
resetMobileHistory()
|
resetMobileHistory()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1670,7 +1771,10 @@ onUnmounted(() => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="4" md="4" class="text-end">
|
<VCol cols="4" md="4" class="text-end">
|
||||||
<VBtnGroup variant="outlined" divided rounded>
|
<VBtnGroup variant="outlined" divided rounded>
|
||||||
<VBtn :icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'" @click="group = !group" />
|
<VBtn
|
||||||
|
:icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'"
|
||||||
|
@click="toggleHistoryGrouping"
|
||||||
|
/>
|
||||||
</VBtnGroup>
|
</VBtnGroup>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
@@ -1706,11 +1810,17 @@ onUnmounted(() => {
|
|||||||
@click="toggleGroup(item)"
|
@click="toggleGroup(item)"
|
||||||
/>
|
/>
|
||||||
<VCheckbox
|
<VCheckbox
|
||||||
:model-value="selectedCountsGroupedByTitle[item.value] == item.items.length"
|
:model-value="selectedCountsGroupedByKey[item.value] == item.items.length"
|
||||||
:indeterminate="selectedCountsGroupedByTitle[item.value] < item.items.length"
|
:indeterminate="
|
||||||
|
selectedCountsGroupedByKey[item.value] > 0 &&
|
||||||
|
selectedCountsGroupedByKey[item.value] < item.items.length
|
||||||
|
"
|
||||||
@update:modelValue="checked => toggleGroupSelection(checked, item.items)"
|
@update:modelValue="checked => toggleGroupSelection(checked, item.items)"
|
||||||
/>
|
/>
|
||||||
{{ item.value }}
|
<span>{{ getHistoryGroupLabel(item.items) }}</span>
|
||||||
|
<VChip v-if="item.items.length > 1 && isMusicAlbumGroup(item.items)" size="x-small" variant="tonal">
|
||||||
|
{{ t('music.trackCount', { count: item.items.length }) }}
|
||||||
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -117,10 +117,17 @@ const HistoryTableStub = defineComponent({
|
|||||||
return h('section', { 'aria-label': '整理历史桌面列表' }, [
|
return h('section', { 'aria-label': '整理历史桌面列表' }, [
|
||||||
h('output', { 'aria-label': '整理历史排序结果' }, JSON.stringify(sortResults)),
|
h('output', { 'aria-label': '整理历史排序结果' }, JSON.stringify(sortResults)),
|
||||||
...props.items.map(item =>
|
...props.items.map(item =>
|
||||||
h('article', { 'data-history-id': item.id }, [
|
h(
|
||||||
|
'article',
|
||||||
|
{
|
||||||
|
'data-history-group-key': (item as TransferHistory & { history_group_key?: string }).history_group_key,
|
||||||
|
'data-history-id': item.id,
|
||||||
|
},
|
||||||
|
[
|
||||||
item.image ? (slots['item.title']?.({ item }) ?? h('span', item.title)) : h('span', item.title),
|
item.image ? (slots['item.title']?.({ item }) ?? h('span', item.title)) : h('span', item.title),
|
||||||
slots['item.actions']?.({ item }),
|
slots['item.actions']?.({ item }),
|
||||||
]),
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'button',
|
'button',
|
||||||
@@ -446,6 +453,217 @@ describe('TransferHistoryView', () => {
|
|||||||
await waitFor(() => expect(requests).toEqual([{ count: 50, page: 1, status: false, title: '失败' }]))
|
await waitFor(() => expect(requests).toEqual([{ count: 50, page: 1, status: false, title: '失败' }]))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('automatically groups multiple music tracks by their organized album directory', async () => {
|
||||||
|
const tracks = [
|
||||||
|
createHistory(1, '女骑士', {
|
||||||
|
dest: '/media/徐良/情话 (2013)/01 - 女骑士.flac',
|
||||||
|
src: '/downloads/徐良 情话/01 - 女骑士.flac',
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
createHistory(2, '悲伤的李白', {
|
||||||
|
dest: '/media/徐良/情话 (2013)/02 - 悲伤的李白.flac',
|
||||||
|
src: '/downloads/徐良 情话/02 - 悲伤的李白.flac',
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
||||||
|
return Promise.resolve(historyResponse(tracks))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container, router } = await renderHistory('/history')
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.currentRoute.value.query.grouped).toBe('true'))
|
||||||
|
const rows = [...container.querySelectorAll<HTMLElement>('[data-history-group-key]')]
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows[0]?.dataset.historyGroupKey).toBe(rows[1]?.dataset.historyGroupKey)
|
||||||
|
expect(rows[0]?.dataset.historyGroupKey).toContain('/media/徐良/情话 (2013)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups failed music records by their source album directory and source storage', async () => {
|
||||||
|
const tracks = [
|
||||||
|
createHistory(1, 'Track 1', {
|
||||||
|
dest: '/media/Artist/Predicted Album A/01.flac',
|
||||||
|
src: '/downloads/Album Bundle/01.flac',
|
||||||
|
status: false,
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
createHistory(2, 'Track 2', {
|
||||||
|
dest: '/media/Artist/Predicted Album B/02.flac',
|
||||||
|
src: '/downloads/Album Bundle/02.flac',
|
||||||
|
status: false,
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
||||||
|
return Promise.resolve(historyResponse(tracks))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = await renderHistory('/history')
|
||||||
|
|
||||||
|
const rows = await waitFor(() => {
|
||||||
|
const items = [...container.querySelectorAll<HTMLElement>('[data-history-group-key]')]
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
expect(rows[0]?.dataset.historyGroupKey).toBe('music:["downloads","/downloads/Album Bundle"]')
|
||||||
|
expect(rows[1]?.dataset.historyGroupKey).toBe(rows[0]?.dataset.historyGroupKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the original exact-title grouping identity for non-music records', async () => {
|
||||||
|
const histories = [
|
||||||
|
createHistory(1, 'Shared Title', { type: '电影' }),
|
||||||
|
createHistory(2, 'Shared Title', { type: '电视剧' }),
|
||||||
|
createHistory(3, 'shared title', { type: '电影' }),
|
||||||
|
createHistory(4, '', { type: '电影' }),
|
||||||
|
createHistory(5, '未知', { type: '电影' }),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
||||||
|
return Promise.resolve(historyResponse(histories))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = await renderHistory('/history?grouped=true')
|
||||||
|
|
||||||
|
const rows = await waitFor(() => {
|
||||||
|
const items = [...container.querySelectorAll<HTMLElement>('[data-history-group-key]')]
|
||||||
|
expect(items).toHaveLength(5)
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
expect(rows.map(row => row.dataset.historyGroupKey)).toEqual([
|
||||||
|
'title:"Shared Title"',
|
||||||
|
'title:"Shared Title"',
|
||||||
|
'title:"shared title"',
|
||||||
|
'title:""',
|
||||||
|
'title:"未知"',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps music album and non-music title group namespaces separate', async () => {
|
||||||
|
const musicKeyAsTitle = 'music:["library","/media/Artist/Album"]'
|
||||||
|
const histories = [
|
||||||
|
createHistory(1, 'Track', {
|
||||||
|
dest: '/media/Artist/Album/01.flac',
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
createHistory(2, musicKeyAsTitle),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
||||||
|
return Promise.resolve(historyResponse(histories))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = await renderHistory('/history?grouped=true')
|
||||||
|
|
||||||
|
const rows = await waitFor(() => {
|
||||||
|
const items = [...container.querySelectorAll<HTMLElement>('[data-history-group-key]')]
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
expect(rows[0]?.dataset.historyGroupKey).toBe(musicKeyAsTitle)
|
||||||
|
expect(rows[1]?.dataset.historyGroupKey).toBe(`title:${JSON.stringify(musicKeyAsTitle)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not auto-group non-music titles that resemble music group keys', async () => {
|
||||||
|
const histories = [createHistory(1, 'music:archive'), createHistory(2, 'music:archive')]
|
||||||
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
||||||
|
return Promise.resolve(historyResponse(histories))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { router } = await renderHistory('/history')
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByText('music:archive')).toHaveLength(2))
|
||||||
|
expect(router.currentRoute.value.query.grouped).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not auto-group music records without a shared album directory', async () => {
|
||||||
|
const histories = [
|
||||||
|
createHistory(1, 'Unknown Track', { dest: '', src: '01.flac', type: '音乐' }),
|
||||||
|
createHistory(2, 'Unknown Track', { dest: '', src: '02.flac', type: '音乐' }),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
||||||
|
return Promise.resolve(historyResponse(histories))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { router } = await renderHistory('/history')
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByText('Unknown Track')).toHaveLength(2))
|
||||||
|
expect(router.currentRoute.value.query.grouped).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not use a planned destination when a failed source path has no directory', async () => {
|
||||||
|
const histories = [
|
||||||
|
createHistory(1, 'Track 1', {
|
||||||
|
dest: '/media/Artist/Predicted Album/01.flac',
|
||||||
|
src: '01.flac',
|
||||||
|
status: false,
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
createHistory(2, 'Track 2', {
|
||||||
|
dest: '/media/Artist/Predicted Album/02.flac',
|
||||||
|
src: '02.flac',
|
||||||
|
status: false,
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
||||||
|
return Promise.resolve(historyResponse(histories))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { router } = await renderHistory('/history')
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Track 1')).toBeInTheDocument())
|
||||||
|
expect(router.currentRoute.value.query.grouped).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not count repeated history rows for one file as multiple album tracks', async () => {
|
||||||
|
const histories = [
|
||||||
|
createHistory(1, 'Track', {
|
||||||
|
dest: '/media/Artist/Album/01.flac',
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
createHistory(2, 'Track retry', {
|
||||||
|
dest: '/media/Artist/Album/01.flac',
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
||||||
|
return Promise.resolve(historyResponse(histories))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { router } = await renderHistory('/history')
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Track retry')).toBeInTheDocument())
|
||||||
|
expect(router.currentRoute.value.query.grouped).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects an explicit flat-view choice for a multi-track music album', async () => {
|
||||||
|
const tracks = [
|
||||||
|
createHistory(1, 'Track 1', {
|
||||||
|
dest: 'D:\\Music\\Artist\\Album\\01.flac',
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
createHistory(2, 'Track 2', {
|
||||||
|
dest: 'D:\\Music\\Artist\\Album\\02.flac',
|
||||||
|
type: '音乐',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
mocks.apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
||||||
|
return Promise.resolve(historyResponse(tracks))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container, router } = await renderHistory('/history?grouped=false')
|
||||||
|
|
||||||
|
await waitFor(() => expect(container.querySelectorAll('[data-history-group-key]')).toHaveLength(2))
|
||||||
|
expect(router.currentRoute.value.query.grouped).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
it('joins the desktop status filter to search and moves the mobile filter into the titlebar menu', () => {
|
it('joins the desktop status filter to search and moves the mobile filter into the titlebar menu', () => {
|
||||||
const mobileTitlebarSource = transferHistorySource.slice(
|
const mobileTitlebarSource = transferHistorySource.slice(
|
||||||
transferHistorySource.indexOf('<div class="transfer-history-mobile-titlebar__actions">'),
|
transferHistorySource.indexOf('<div class="transfer-history-mobile-titlebar__actions">'),
|
||||||
@@ -627,6 +845,7 @@ describe('TransferHistoryView', () => {
|
|||||||
itemsPerPage: '50',
|
itemsPerPage: '50',
|
||||||
search: 'desktop-query',
|
search: 'desktop-query',
|
||||||
})
|
})
|
||||||
|
expect(router.currentRoute.value.query.grouped).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads mobile pages with deduplication and reports empty when the last page is exhausted', async () => {
|
it('loads mobile pages with deduplication and reports empty when the last page is exhausted', async () => {
|
||||||
@@ -770,7 +989,7 @@ describe('TransferHistoryView', () => {
|
|||||||
it('persists mobile search in the URL before resetting the infinite list', async () => {
|
it('persists mobile search in the URL before resetting the infinite list', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
mocks.desktop = false
|
mocks.desktop = false
|
||||||
const { router } = await renderHistory('/history?search=old')
|
const { router } = await renderHistory('/history?search=old&grouped=false')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await fireEvent.update(screen.getByLabelText('搜索(支持 * ? 通配符)'), 'new')
|
await fireEvent.update(screen.getByLabelText('搜索(支持 * ? 通配符)'), 'new')
|
||||||
@@ -778,7 +997,7 @@ describe('TransferHistoryView', () => {
|
|||||||
|
|
||||||
expect(router.currentRoute.value).toMatchObject({
|
expect(router.currentRoute.value).toMatchObject({
|
||||||
path: '/history',
|
path: '/history',
|
||||||
query: { currentPage: '1', itemsPerPage: '50', search: 'new' },
|
query: { currentPage: '1', grouped: 'false', itemsPerPage: '50', search: 'new' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user