diff --git a/api/openapi.yaml b/api/openapi.yaml
index d6cba98..88b60e4 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -2928,6 +2928,10 @@ components:
type: string
nullable: true
description: Название целевого хранилища; null, если хранилище не выбрано или удалено
+ target_storage_path:
+ type: string
+ nullable: true
+ description: Полный путь назначения для админской очереди — локальный путь API или server:/export/path для NFS. Для ожидающих файлов рассчитывается по текущему шаблону.
status_reason:
type: string
nullable: true
diff --git a/apps/web/e2e/upload-review.spec.ts b/apps/web/e2e/upload-review.spec.ts
index aa3fefa..0fb7379 100644
--- a/apps/web/e2e/upload-review.spec.ts
+++ b/apps/web/e2e/upload-review.spec.ts
@@ -13,7 +13,7 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
const file = structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]);
const files = [
- { ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac' },
+ { ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac' },
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
{ ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
@@ -34,6 +34,10 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
return json(missing);
}
if (scenario === 'template') {
+ if (url.pathname === '/api/v1/admin/uploads') {
+ const path = document.documentElement.dataset.savedTemplate ? 'Artist/Album/01. Song.flac' : 'Artist/Album/Song.flac';
+ return json({ items: [{ ...files[0], target_rel_path: path, target_storage_path: `nas:/music/${path}` }], next_cursor: null });
+ }
if (url.pathname.endsWith('/write-template/suggest')) return json({ template: '{albumartist}/{album}/{track:02}. {title}.{ext}', matched: 180, total: 200, examples: ['Кино/Группа крови/01. Группа крови.flac'] });
if (url.pathname.endsWith('/write-template/check')) return json({ ok: false, total: 50, round_trip: 0, same_path: 45, mismatches: [] });
if (url.pathname === `/api/v1/admin/sources/${source.id}` && req.method === 'PATCH') {
@@ -71,8 +75,19 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
await setup(page, 'queue');
+ await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
+ await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
+ await expect(page.getByText('nas:/music/Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
+ await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
+ await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
+ await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
+ await expect(page.getByRole('button', { name: 'Развернуть пачку', exact: true })).toHaveAttribute('aria-expanded', 'false');
+ await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).toBeChecked();
+ await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).press('Enter');
+ await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
+ await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).uncheck();
await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist');
@@ -131,3 +146,25 @@ test('keeps withdrawn proposals in their own filter', async ({ page }) => {
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
});
+
+test('queue shows the current full destination after saving a changed source template', async ({ page }) => {
+ await setup(page, 'template');
+ const sourcePath = new URL(page.url()).pathname;
+ const navigate = async (path: string) => page.evaluate((path) => {
+ history.pushState({}, '', path);
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ }, path);
+ await navigate('/admin/uploads');
+ await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
+ await expect(page.getByText('nas:/music/Artist/Album/Song.flac', { exact: true })).toBeVisible();
+ await navigate(sourcePath);
+ await page.getByRole('textbox', { name: 'Шаблон пути', exact: true }).fill('{albumartist}/{album}/{track:02}. {title}.{ext}');
+ const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
+ await section.getByRole('checkbox').check();
+ await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
+ await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/{album}/{track:02}. {title}.{ext}');
+ await navigate('/admin/uploads');
+ await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
+ await expect(page.getByText('nas:/music/Artist/Album/01. Song.flac', { exact: true })).toBeVisible();
+ await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveCount(0);
+});
diff --git a/apps/web/src/features/server-management/management.module.css b/apps/web/src/features/server-management/management.module.css
index dd13fc8..b497c74 100644
--- a/apps/web/src/features/server-management/management.module.css
+++ b/apps/web/src/features/server-management/management.module.css
@@ -1,4 +1,4 @@
-.page { max-width: 1050px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
+.page { width: 100%; min-width: 0; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
diff --git a/apps/web/src/features/uploads/QueueRows.tsx b/apps/web/src/features/uploads/QueueRows.tsx
index 6741102..bc239eb 100644
--- a/apps/web/src/features/uploads/QueueRows.tsx
+++ b/apps/web/src/features/uploads/QueueRows.tsx
@@ -1,5 +1,6 @@
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
-import { editIcon, refreshIcon } from '../../shared/ui/icons';
+import { chevronRightIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
+import { useId, useState } from 'react';
import { needsFormatAttention } from './files';
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
import { useTranslation } from 'react-i18next';
@@ -24,6 +25,8 @@ export function BatchCard({
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
+ const [collapsed, setCollapsed] = useState(true);
+ const contentId = useId();
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
@@ -31,31 +34,36 @@ export function BatchCard({
return (
{f.target_rel_path}
{t('uploads.target_path_hint')}
+ {f.target_storage_path && <>
+ {t('uploads.target_full_path')}
+ {f.target_storage_path}
+ >}
>
) : (
<>
diff --git a/apps/web/src/pages/admin/PatternsPage.tsx b/apps/web/src/pages/admin/PatternsPage.tsx
index 35a1ad9..5efeca5 100644
--- a/apps/web/src/pages/admin/PatternsPage.tsx
+++ b/apps/web/src/pages/admin/PatternsPage.tsx
@@ -83,7 +83,7 @@ export function PatternsPage({ id }: { id: string }) {
});
return (
-