feat(library): exclude configured folders from scanning and analysis
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 1m8s
ci / recommender / recommender (push) Failing after 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / ts / ts (packages + web) (push) Successful in 7m20s

This commit is contained in:
mrleo1nid committed 2026-10-22 15:51:15 +03:00
1 parent 0dc0384c78
commit 75ae2092ad
23 files changed
+817 -331

No files matched your search

+14
View File
@@ -2687,6 +2687,8 @@ components:
type: object type: object
required: [id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, scan_interval_minutes, accepts_uploads, write_template, status, status_message, last_scan_at, created_at, scan] required: [id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, scan_interval_minutes, accepts_uploads, write_template, status, status_message, last_scan_at, created_at, scan]
properties: properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
id: id:
type: string type: string
format: uuid format: uuid
@@ -2747,6 +2749,8 @@ components:
type: object type: object
required: [name, type] required: [name, type]
properties: properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name: name:
type: string type: string
maxLength: 100 maxLength: 100
@@ -2774,6 +2778,8 @@ components:
SourcePatch: SourcePatch:
type: object type: object
properties: properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name: name:
type: string type: string
maxLength: 100 maxLength: 100
@@ -2802,6 +2808,14 @@ components:
description: | description: |
Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid` Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid`
с `params.reason`. Проверка обратимости не обязательна для сохранения — её показывает `write-template/check`. с `params.reason`. Проверка обратимости не обязательна для сохранения — её показывает `write-template/check`.
ExcludedDirs:
type: array
maxItems: 100
description: Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
items:
type: string
minLength: 1
maxLength: 1000
SourceTestResult: SourceTestResult:
type: object type: object
required: [ok, message, audio_files] required: [ok, message, audio_files]
@@ -0,0 +1,63 @@
import type { components } from '@pipu/api-client';
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { mobileFixtures } from '../../test/mobileFixtures';
import { SourceDialog } from './SourcesPage';
afterEach(cleanup);
type Source = components['schemas']['Source'];
const base = (mobileFixtures['/api/v1/admin/sources'] as { items: Source[] }).items[0]!;
async function fixture(source: Source | null = { ...base, excluded_dirs: ['Downloads_PumpEars'] }, reject = false) {
const bodies: Record<string, unknown>[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
const req = new Request(input, init);
if (req.method === 'PATCH' || req.method === 'POST') {
const body = await req.json() as Record<string, unknown>;
bodies.push(body);
return new Response(JSON.stringify(reject
? { code: 'validation.excluded_dirs_invalid', errors: [{ field: 'excluded_dirs', code: 'validation.excluded_dirs_invalid' }] }
: { ...base, ...body }), { status: reject ? 422 : 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ path: '', dirs: [] }), { headers: { 'Content-Type': 'application/json' } });
} });
const i18n = await createI18n('ru');
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const close = vi.fn();
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
<SourceDialog source={source} onClose={close} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
return { bodies, close, user: userEvent.setup() };
}
it('loads and saves one excluded path per line', async () => {
const { bodies, close, user } = await fixture();
const input = screen.getByLabelText('Не сканировать папки');
expect(input).toHaveValue('Downloads_PumpEars');
await user.type(input, '\nArtist/Samples\n');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(bodies[0]?.excluded_dirs).toEqual(['Downloads_PumpEars', 'Artist/Samples']);
});
it('clears the list explicitly so excluded folders can be scanned again', async () => {
const { bodies, close, user } = await fixture();
await user.clear(screen.getByLabelText('Не сканировать папки'));
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(bodies[0]?.excluded_dirs).toEqual([]);
});
it('keeps invalid paths in the editor and displays the server validation', async () => {
const { user, close } = await fixture({ ...base, excluded_dirs: ['../outside'] }, true);
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.getByLabelText('Не сканировать папки')).toHaveAttribute('aria-invalid', 'true'));
expect(screen.getByLabelText('Не сканировать папки')).toHaveValue('../outside');
expect(close).not.toHaveBeenCalled();
});
+22 -3
View File
@@ -2,7 +2,7 @@ import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react'; import { useId, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session'; import { useSession, useUser } from '../../app/session';
@@ -120,6 +120,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
{source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>} {source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>}
</div> </div>
<span className={l.location}>{location(source)}</span> <span className={l.location}>{location(source)}</span>
{!!source.excluded_dirs?.length && <span className={s.note}>{t('sources.excluded_count', { count: source.excluded_dirs.length })}</span>}
{source.status === 'error' && source.status_message && ( {source.status === 'error' && source.status_message && (
<span className={l.message}>{source.status_message}</span> <span className={l.message}>{source.status_message}</span>
)} )}
@@ -185,7 +186,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
const INTERVALS = [0, 60, 360, 1440]; const INTERVALS = [0, 60, 360, 1440];
function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) { export function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']); const { t } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -199,12 +200,15 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3'); const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3');
const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first'); const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0); const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
const [excludedDirs, setExcludedDirs] = useState((source?.excluded_dirs ?? []).join('\n'));
const excludedDirsId = useId();
const save = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: async () => {
const common = { const common = {
name: name.trim(), name: name.trim(),
tag_priority: priority, tag_priority: priority,
excluded_dirs: excludedDirs.split(/\r?\n/).map((dir) => dir.trim()).filter(Boolean),
...(type === 'local' ...(type === 'local'
? { local_path: localPath } ? { local_path: localPath }
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }), : { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }),
@@ -224,11 +228,14 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
}, },
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['sources'] }); await queryClient.invalidateQueries({ queryKey: ['sources'] });
for (const key of ['library-status', 'library-errors', 'analysis-errors']) {
void queryClient.invalidateQueries({ queryKey: [key] });
}
onClose(); onClose();
}, },
}); });
const err = save.error; const err = save.error;
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options']; const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options', 'excluded_dirs'];
const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null; const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
const submit = (e: FormEvent) => { const submit = (e: FormEvent) => {
@@ -297,6 +304,18 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
/> />
</> </>
)} )}
<div className={l.field}>
<label htmlFor={excludedDirsId} className={l.label}>{t('sources.excluded_dirs')}</label>
<textarea id={excludedDirsId} className={l.excludedDirs} rows={5}
value={excludedDirs} onChange={(event) => setExcludedDirs(event.target.value)}
disabled={source?.scan?.running ?? false}
placeholder="Downloads_PumpEars"
aria-invalid={!!fieldError(err, 'excluded_dirs')}
aria-describedby={`${excludedDirsId}-hint`} />
<span id={`${excludedDirsId}-hint`} className={s.note}>
{fieldError(err, 'excluded_dirs') ?? t(source?.scan?.running ? 'sources.excluded_running' : 'sources.excluded_hint')}
</span>
</div>
<div className={l.field}> <div className={l.field}>
<span className={l.label}>{t('sources.tag_priority')}</span> <span className={l.label}>{t('sources.tag_priority')}</span>
<Segmented <Segmented
@@ -238,6 +238,20 @@
gap: 6px; gap: 6px;
} }
.excludedDirs {
width: 100%;
box-sizing: border-box;
resize: vertical;
border: 1px solid var(--line2);
border-radius: var(--radius-md);
padding: 10px 12px;
background: var(--surface2);
color: var(--text);
font: 13px var(--font-mono);
}
.excludedDirs:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.excludedDirs:disabled { opacity: 0.6; }
.tiles { .tiles {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+14
View File
@@ -139,6 +139,20 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов). Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов).
В редакторе хранилища поле **«Не сканировать папки»** задаёт до 100 исключений:
по одному пути относительно корня хранилища в строке, например `Downloads_PumpEars`
или `Artist/Samples`. Исключается вся ветка; соседняя `Downloads_PumpEars-copy`
не затрагивается. Пути чувствительны к регистру, шаблоны и абсолютные пути не используются.
Настройка хранится в `library_sources.excluded_dirs`; пустой список снимает исключения.
Во время активного скана менять список нельзя.
Исключённые ветки не обходятся; точечный скан такого пути отклоняется. Уже записанные
файлы остаются в каталоге и не помечаются пропавшими. Сразу после сохранения исключений
ошибки чтения и задачи аудиоанализа этих файлов перестают попадать в диагностику и повтор.
Анализатор не забирает их в работу; если у трека есть другая разрешённая копия, использует её.
После снятия исключений сохранённые задачи снова учитываются; неудавшиеся можно повторить.
Файлы на диске не изменяются.
Во время обхода админка показывает число посещённых папок (включая корень), найденных Во время обхода админка показывает число посещённых папок (включая корень), найденных
аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее
число файлов неизвестно, поэтому индикатор показывает активность без процента. число файлов неизвестно, поэтому индикатор показывает активность без процента.
@@ -1960,6 +1960,7 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
TagPriority: "tags_first" | "path_first"; TagPriority: "tags_first" | "path_first";
Source: { Source: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
name: string; name: string;
@@ -1988,6 +1989,7 @@ export interface components {
items: components["schemas"]["Source"][]; items: components["schemas"]["Source"][];
}; };
SourceInput: { SourceInput: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name: string; name: string;
type: components["schemas"]["SourceType"]; type: components["schemas"]["SourceType"];
nfs_server?: string; nfs_server?: string;
@@ -2000,6 +2002,7 @@ export interface components {
write_template?: string; write_template?: string;
}; };
SourcePatch: { SourcePatch: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name?: string; name?: string;
nfs_server?: string; nfs_server?: string;
nfs_export?: string; nfs_export?: string;
@@ -2018,6 +2021,8 @@ export interface components {
*/ */
write_template?: string; write_template?: string;
}; };
/** @description Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan. */
ExcludedDirs: string[];
SourceTestResult: { SourceTestResult: {
ok: boolean; ok: boolean;
message: string; message: string;
+4
View File
@@ -98,6 +98,10 @@
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.", "delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
"scanning": "Scanning: {{processed}} of {{total}}", "scanning": "Scanning: {{processed}} of {{total}}",
"scan_walking": "Walking folders…", "scan_walking": "Walking folders…",
"excluded_dirs": "Folders to exclude from scanning",
"excluded_hint": "One folder per line, relative to the storage root. Example: Downloads_PumpEars. All subfolders are excluded too. Paths are case sensitive; wildcards are not supported. Analysis and its errors are excluded as soon as you save. Existing catalog records are retained. An empty list enables scanning and analysis again.",
"excluded_running": "Wait for the current scan to finish before editing exclusions.",
"excluded_count": "Folders excluded from scanning: {{count}}",
"scan_walk_progress": "Walking folders… Directories checked: {{dirs}} · audio files found: {{found}}", "scan_walk_progress": "Walking folders… Directories checked: {{dirs}} · audio files found: {{found}}",
"scan_current_dir": "Current folder: {{path}}", "scan_current_dir": "Current folder: {{path}}",
"scan_preparing": "Matching files with the library…", "scan_preparing": "Matching files with the library…",
+1
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Pattern error: {{reason}}", "pattern_invalid": "Pattern error: {{reason}}",
"nfs_invalid": "Invalid value", "nfs_invalid": "Invalid value",
"local_path_invalid": "Folder not found", "local_path_invalid": "Folder not found",
"excluded_dirs_invalid": "Enter up to 100 folders: relative paths without .., one per line (up to 1000 characters).",
"name_invalid": "The name must be 1 to {{max}} characters", "name_invalid": "The name must be 1 to {{max}} characters",
"scan_interval_invalid": "The interval must be at least {{min}} minutes", "scan_interval_invalid": "The interval must be at least {{min}} minutes",
"source_type_invalid": "Unknown storage type", "source_type_invalid": "Unknown storage type",
+4
View File
@@ -98,6 +98,10 @@
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.", "delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
"scanning": "Сканирование: {{processed}} из {{total}}", "scanning": "Сканирование: {{processed}} из {{total}}",
"scan_walking": "Обход папок…", "scan_walking": "Обход папок…",
"excluded_dirs": "Не сканировать папки",
"excluded_hint": "По одной папке в строке, путь от корня хранилища. Например: Downloads_PumpEars. Все подпапки тоже исключаются. Учитывается регистр; шаблоны со звёздочкой не поддерживаются. Анализ и его ошибки отключаются сразу после сохранения. Уже добавленные записи сохраняются. Пустой список возвращает папки в сканирование и анализ.",
"excluded_running": "Дождитесь завершения сканирования, чтобы изменить исключения.",
"excluded_count": "Исключено папок из сканирования: {{count}}",
"scan_walk_progress": "Обход папок… Проверено папок: {{dirs}} · найдено аудиофайлов: {{found}}", "scan_walk_progress": "Обход папок… Проверено папок: {{dirs}} · найдено аудиофайлов: {{found}}",
"scan_current_dir": "Текущая папка: {{path}}", "scan_current_dir": "Текущая папка: {{path}}",
"scan_preparing": "Сопоставление файлов с библиотекой…", "scan_preparing": "Сопоставление файлов с библиотекой…",
+1
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Ошибка в шаблоне: {{reason}}", "pattern_invalid": "Ошибка в шаблоне: {{reason}}",
"nfs_invalid": "Недопустимое значение", "nfs_invalid": "Недопустимое значение",
"local_path_invalid": "Папка не найдена", "local_path_invalid": "Папка не найдена",
"excluded_dirs_invalid": "Укажите до 100 папок: относительные пути без .., по одной папке в строке (до 1000 символов).",
"name_invalid": "Название — от 1 до {{max}} символов", "name_invalid": "Название — от 1 до {{max}} символов",
"scan_interval_invalid": "Интервал — не меньше {{min}} минут", "scan_interval_invalid": "Интервал — не меньше {{min}} минут",
"source_type_invalid": "Неизвестный тип хранилища", "source_type_invalid": "Неизвестный тип хранилища",
+312 -297
View File
@@ -511,6 +511,9 @@ type EventBatchResult struct {
Rejected int `json:"rejected"` Rejected int `json:"rejected"`
} }
// ExcludedDirs Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
type ExcludedDirs = []string
// FieldError defines model for FieldError. // FieldError defines model for FieldError.
type FieldError struct { type FieldError struct {
Code string `json:"code"` Code string `json:"code"`
@@ -974,6 +977,9 @@ type Source struct {
// AcceptsUploads Принимает импорт из очереди на добавление (NFS монтируется агентом ещё и на запись) // AcceptsUploads Принимает импорт из очереди на добавление (NFS монтируется агентом ещё и на запись)
AcceptsUploads bool `json:"accepts_uploads"` AcceptsUploads bool `json:"accepts_uploads"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// ExcludedDirs Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
ExcludedDirs *ExcludedDirs `json:"excluded_dirs,omitempty"`
Id openapi_types.UUID `json:"id"` Id openapi_types.UUID `json:"id"`
LastScanAt *time.Time `json:"last_scan_at"` LastScanAt *time.Time `json:"last_scan_at"`
@@ -999,6 +1005,9 @@ type Source struct {
// SourceInput defines model for SourceInput. // SourceInput defines model for SourceInput.
type SourceInput struct { type SourceInput struct {
AcceptsUploads *bool `json:"accepts_uploads,omitempty"` AcceptsUploads *bool `json:"accepts_uploads,omitempty"`
// ExcludedDirs Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
ExcludedDirs *ExcludedDirs `json:"excluded_dirs,omitempty"`
LocalPath *string `json:"local_path,omitempty"` LocalPath *string `json:"local_path,omitempty"`
Name string `json:"name"` Name string `json:"name"`
NfsExport *string `json:"nfs_export,omitempty"` NfsExport *string `json:"nfs_export,omitempty"`
@@ -1022,6 +1031,9 @@ type SourcePatch struct {
// Enabled false — отключить хранилище (треки становятся недоступны), true — подключить снова // Enabled false — отключить хранилище (треки становятся недоступны), true — подключить снова
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
// ExcludedDirs Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
ExcludedDirs *ExcludedDirs `json:"excluded_dirs,omitempty"`
LocalPath *string `json:"local_path,omitempty"` LocalPath *string `json:"local_path,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
NfsExport *string `json:"nfs_export,omitempty"` NfsExport *string `json:"nfs_export,omitempty"`
@@ -11377,303 +11389,306 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"cf/O/f8/AAD//+0IjFXOgAEA", "loca/xniiDLR6ER9gZ55qOa0BNSyN0qpcROfmCzOj71RQgsOEfiUpxRsphlV3IevdjVjXHl57qVzm9xX",
"mNEuS2zyWcvGiQ+QgQf56Zft8T+CWXmM3d3FTh/HjzHnRoSSlRRtnha6Q6Zqkm/ayCj6nD5qLFEroMG1",
"XrTKpsgEIibloSDtBW7jSmMWBCV/10cyrwDfea8pPwEIcOXvDtW+hSa8ygeusxQwaigfieCU8hHPG1Q+",
"kZF09Ydpgo/yKQgLdQrdbuPeB/f+bwAAAP//4XfHrPyCAQA=",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file
@@ -88,3 +88,73 @@ func TestLibraryDiagnostics(t *testing.T) {
require.Equal(t, 1, paths) require.Equal(t, 1, paths)
require.Equal(t, 1, missing) require.Equal(t, 1, missing)
} }
func TestScanExcludedFolders(t *testing.T) {
pool := connect(t)
ctx := context.Background()
require.NoError(t, db.Migrate(ctx, pool))
store := library.NewStore(pool)
src, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "exclusions", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, store.DeleteSource(ctx, src.ID))
require.NoError(t, store.CleanupOrphans(ctx))
})
root := t.TempDir()
for _, rel := range []string{"Downloads_PumpEars/Artist - Loop.wav", "Downloads_PumpEars/nested/Artist - Hit.wav", "Downloads_PumpEars-copy/Artist - Song.flac"} {
p := filepath.Join(root, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
require.NoError(t, os.WriteFile(p, []byte(rel), 0o644))
}
board := library.NewProgressBoard()
scanner := library.NewScanner(store, fakeProber{}, board, slog.New(slog.DiscardHandler))
scan := func() library.Progress {
require.NoError(t, scanner.Start(src, root, true))
require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 10*time.Millisecond)
p, _ := board.Get(src.ID)
require.Empty(t, p.Error)
return p
}
require.Equal(t, 3, scan().Found)
_, err = pool.Exec(ctx, `UPDATE analysis_jobs SET status = 'failed', error = 'too short' WHERE track_id IN (SELECT track_id FROM media_files WHERE source_id = $1)`, src.ID)
require.NoError(t, err)
before, err := store.AnalysisCounts(ctx)
require.NoError(t, err)
src.ExcludedDirs = []string{"Downloads_PumpEars"}
src, err = store.SaveSource(ctx, src)
require.NoError(t, err)
saved, err := store.GetSource(ctx, src.ID)
require.NoError(t, err)
require.Equal(t, src.ExcludedDirs, saved.ExcludedDirs)
counts, err := store.AnalysisCounts(ctx)
require.NoError(t, err)
require.Equal(t, before.Failed-2, counts.Failed, "old failed jobs from excluded folders disappear immediately")
_, err = store.RetryFailedAnalysis(ctx)
require.NoError(t, err)
var failed int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM analysis_jobs WHERE status = 'failed' AND track_id IN (SELECT track_id FROM media_files WHERE source_id = $1)`, src.ID).Scan(&failed))
require.Equal(t, 2, failed, "retry must not requeue excluded jobs")
p := scan()
require.Equal(t, 1, p.Found)
require.Equal(t, 1, p.ToProcess)
require.Zero(t, p.Missing)
files, err := store.Files(ctx, src.ID)
require.NoError(t, err)
require.Len(t, files, 3, "excluded records must be preserved")
for _, f := range files {
require.False(t, f.Missing, "excluded files must not be treated as absent")
}
// Literal matching must not treat % and _ as SQL wildcards.
for _, test := range []struct {
path string
excluded bool
}{{"100%_loops/a.wav", true}, {"100xxloops/a.wav", false}, {"100%_loops-copy/a.wav", false}} {
var excluded bool
require.NoError(t, pool.QueryRow(ctx, `SELECT library_path_excluded($1, ARRAY['100%_loops'])`, test.path).Scan(&excluded))
require.Equal(t, test.excluded, excluded)
}
src.ExcludedDirs = []string{}
src, err = store.SaveSource(ctx, src)
require.NoError(t, err)
require.Equal(t, 3, scan().Found, "clearing exclusions makes the subtree eligible again")
}
@@ -0,0 +1,50 @@
package library
import (
"path"
"strings"
"unicode/utf8"
"pipu/api/internal/platform/apperr"
)
// normalizeExcludedDirs accepts literal paths relative to the storage root, not globs.
func normalizeExcludedDirs(dirs []string) ([]string, error) {
invalid := func() ([]string, error) {
return nil, apperr.Validation("excluded_dirs", "validation.excluded_dirs_invalid", "use up to 100 relative folder paths without .., one per entry", nil)
}
if len(dirs) > 100 {
return invalid()
}
out := []string{}
seen := map[string]bool{}
for _, dir := range dirs {
dir = strings.ReplaceAll(strings.TrimSpace(dir), "\\", "/")
if dir == "" || strings.HasPrefix(dir, "/") || strings.ContainsAny(dir, "\x00\r\n") || utf8.RuneCountInString(dir) > 1000 || (len(dir) > 1 && dir[1] == ':') {
return invalid()
}
for _, part := range strings.Split(dir, "/") {
if part == ".." {
return invalid()
}
}
dir = path.Clean(dir)
if dir == "." {
return invalid()
}
if !seen[dir] {
out = append(out, dir)
seen[dir] = true
}
}
return out, nil
}
func pathExcluded(rel string, dirs []string) bool {
for _, dir := range dirs {
if rel == dir || strings.HasPrefix(rel, dir+"/") {
return true
}
}
return false
}
@@ -0,0 +1,85 @@
package library
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
)
func TestNormalizeExcludedDirs(t *testing.T) {
dirs, err := normalizeExcludedDirs([]string{" Downloads_PumpEars/ ", `Artist\Samples`, "Downloads_PumpEars", "Artist/./Samples", "100%_loops"})
require.NoError(t, err)
require.Equal(t, []string{"Downloads_PumpEars", "Artist/Samples", "100%_loops"}, dirs)
for _, invalid := range []string{"", ".", "./", "/etc", "../outside", "album/../samples", `C:\Downloads`, `\\server\share`, "folder\nother", strings.Repeat("a", 1001)} {
t.Run(invalid, func(t *testing.T) {
_, err := normalizeExcludedDirs([]string{invalid})
require.Error(t, err)
})
}
_, err = normalizeExcludedDirs(make([]string, 101))
require.Error(t, err)
dirs, err = normalizeExcludedDirs(nil)
require.NoError(t, err)
require.Empty(t, dirs)
}
func TestWalkPrunesExcludedSubtrees(t *testing.T) {
root := t.TempDir()
for _, rel := range []string{"Downloads_PumpEars/sample.wav", "Downloads_PumpEars/nested/sample.wav", "Downloads_PumpEars-copy/song.flac", "Artist/Downloads_PumpEars/song.flac", "Artist/Samples/loop.wav", "Artist/Album/song.flac", "100%_loops/loop.wav", "100xxloops/song.flac"} {
p := filepath.Join(root, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
require.NoError(t, os.WriteFile(p, []byte("audio"), 0o644))
}
dirs := []string{"Downloads_PumpEars", "Artist/Samples", "100%_loops"}
var visited []string
found, err := walk(root, dirs, func(string) bool { return false }, func(_, _ int, current string) { visited = append(visited, current) })
require.NoError(t, err)
require.ElementsMatch(t, []string{"Downloads_PumpEars-copy/song.flac", "Artist/Downloads_PumpEars/song.flac", "Artist/Album/song.flac", "100xxloops/song.flac"}, rels(found))
for _, dir := range visited {
require.False(t, pathExcluded(dir, dirs), "excluded directories must not be visited")
}
// A point scan must reject an excluded path before touching storage or the filesystem.
_, err = (&Scanner{}).ScanFile(context.Background(), Source{ExcludedDirs: dirs}, root, "Downloads_PumpEars/sample.wav")
require.ErrorContains(t, err, "excluded scan folder")
}
type exclusionsSourceStore struct {
SourceStore
source Source
}
func (s *exclusionsSourceStore) GetSource(context.Context, uuid.UUID) (Source, error) {
return s.source, nil
}
func (s *exclusionsSourceStore) SaveSource(_ context.Context, src Source) (Source, error) {
s.source = src
return src, nil
}
func TestExcludedDirsPatchPreservesOmittedAndAllowsClearing(t *testing.T) {
id := uuid.New()
store := &exclusionsSourceStore{source: Source{ID: id, Name: "Base", Type: SourceLocal, Status: "mounted", ExcludedDirs: []string{"Downloads"}}}
board := NewProgressBoard()
svc := &SourceService{store: store, board: board}
ctx := auth.WithPrincipal(context.Background(), auth.Principal{Role: auth.RoleAdmin})
_, err := svc.UpdateSource(ctx, id, SourceInput{})
require.NoError(t, err)
require.Equal(t, []string{"Downloads"}, store.source.ExcludedDirs)
empty := []string{}
_, err = svc.UpdateSource(ctx, id, SourceInput{ExcludedDirs: &empty})
require.NoError(t, err)
require.Empty(t, store.source.ExcludedDirs)
board.start(id, false, time.Now())
dirs := []string{"Downloads_PumpEars"}
_, err = svc.UpdateSource(ctx, id, SourceInput{ExcludedDirs: &dirs})
require.Error(t, err)
require.Empty(t, store.source.ExcludedDirs, "a running scan must not race with exclusions changes")
}
+6 -2
View File
@@ -61,11 +61,15 @@ func (d DTO) Tracks(ctx context.Context, rows []TrackRow) ([]gen.Track, error) {
} }
func (h *Handler) sourceDTO(s Source) gen.Source { func (h *Handler) sourceDTO(s Source) gen.Source {
if s.ExcludedDirs == nil {
s.ExcludedDirs = []string{}
}
out := gen.Source{ out := gen.Source{
Id: s.ID, Name: s.Name, Type: gen.SourceType(s.Type), NfsServer: s.NFSServer, NfsExport: s.NFSExport, Id: s.ID, Name: s.Name, Type: gen.SourceType(s.Type), NfsServer: s.NFSServer, NfsExport: s.NFSExport,
NfsOptions: s.NFSOptions, LocalPath: s.LocalPath, TagPriority: gen.TagsFirst, NfsOptions: s.NFSOptions, LocalPath: s.LocalPath, TagPriority: gen.TagsFirst,
Status: gen.SourceStatus(s.Status), StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt, Status: gen.SourceStatus(s.Status), StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt,
CreatedAt: s.CreatedAt, AcceptsUploads: s.AcceptsUploads, WriteTemplate: s.WriteTemplate, CreatedAt: s.CreatedAt, AcceptsUploads: s.AcceptsUploads, WriteTemplate: s.WriteTemplate,
ExcludedDirs: &s.ExcludedDirs,
} }
if !s.TagsFirst { if !s.TagsFirst {
out.TagPriority = gen.PathFirst out.TagPriority = gen.PathFirst
@@ -122,7 +126,7 @@ func (h *Handler) CreateSource(ctx context.Context, req gen.CreateSourceRequestO
src, err := h.svc.CreateSource(ctx, SourceInput{ src, err := h.svc.CreateSource(ctx, SourceInput{
Name: &b.Name, Type: SourceType(b.Type), NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions, Name: &b.Name, Type: SourceType(b.Type), NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions,
LocalPath: b.LocalPath, TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes, LocalPath: b.LocalPath, TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -145,7 +149,7 @@ func (h *Handler) UpdateSource(ctx context.Context, req gen.UpdateSourceRequestO
src, err := h.svc.UpdateSource(ctx, req.Id, SourceInput{ src, err := h.svc.UpdateSource(ctx, req.Id, SourceInput{
Name: b.Name, NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions, LocalPath: b.LocalPath, Name: b.Name, NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions, LocalPath: b.LocalPath,
TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes, Enabled: b.Enabled, TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes, Enabled: b.Enabled,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
+17 -2
View File
@@ -205,7 +205,13 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
if err != nil { if err != nil {
return err return err
} }
found, err := walk(root, func(rel string) bool { return meta.MatchPath(patterns, rel).Skip }, func(found, dirs int, currentDir string) { // Excluded files keep their catalog rows and are not treated as missing or rename candidates.
for rel := range existing {
if pathExcluded(rel, src.ExcludedDirs) {
delete(existing, rel)
}
}
found, err := walk(root, src.ExcludedDirs, func(rel string) bool { return meta.MatchPath(patterns, rel).Skip }, func(found, dirs int, currentDir string) {
sc.board.update(src.ID, func(p *Progress) { sc.board.update(src.ID, func(p *Progress) {
p.Found, p.WalkedDirs, p.CurrentDir = found, dirs, currentDir p.Found, p.WalkedDirs, p.CurrentDir = found, dirs, currentDir
}) })
@@ -299,6 +305,9 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
// ScanFile — точечный скан одного файла (scan_path): после импорта из очереди трек сразу появляется в каталоге. // ScanFile — точечный скан одного файла (scan_path): после импорта из очереди трек сразу появляется в каталоге.
// Возвращает id записи media_files. Хуки AfterScan (обложки, поиск, отпечатки) вызывает сам. // Возвращает id записи media_files. Хуки AfterScan (обложки, поиск, отпечатки) вызывает сам.
func (sc *Scanner) ScanFile(ctx context.Context, src Source, root, rel string) (uuid.UUID, error) { func (sc *Scanner) ScanFile(ctx context.Context, src Source, root, rel string) (uuid.UUID, error) {
if pathExcluded(filepath.ToSlash(rel), src.ExcludedDirs) {
return uuid.Nil, fmt.Errorf("library: file is in an excluded scan folder: %s", rel)
}
rows, err := sc.store.Patterns(ctx, src.ID) rows, err := sc.store.Patterns(ctx, src.ID)
if err != nil { if err != nil {
return uuid.Nil, err return uuid.Nil, err
@@ -470,7 +479,7 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
// walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются; // walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются;
// skip — пути, которые шаблоны велят игнорировать. Ошибки отдельных каталогов не прерывают обход. // skip — пути, которые шаблоны велят игнорировать. Ошибки отдельных каталогов не прерывают обход.
func walk(root string, skip func(rel string) bool, report func(found, dirs int, currentDir string)) ([]walked, error) { func walk(root string, excludedDirs []string, skip func(rel string) bool, report func(found, dirs int, currentDir string)) ([]walked, error) {
if _, err := os.Stat(root); err != nil { if _, err := os.Stat(root); err != nil {
return nil, fmt.Errorf("library: source root: %w", err) return nil, fmt.Errorf("library: source root: %w", err)
} }
@@ -495,6 +504,12 @@ func walk(root string, skip func(rel string) bool, report func(found, dirs int,
return nil return nil
} }
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
if pathExcluded(rel, excludedDirs) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() { if d.IsDir() {
dirs++ dirs++
if report != nil { if report != nil {
@@ -55,7 +55,7 @@ func TestWalkAndHash(t *testing.T) {
write("_incoming/new.mp3", 10) write("_incoming/new.mp3", 10)
write("big.mp3", 3<<20) write("big.mp3", 3<<20)
found, err := walk(root, func(rel string) bool { return rel == "_incoming/new.mp3" }, nil) found, err := walk(root, nil, func(rel string) bool { return rel == "_incoming/new.mp3" }, nil)
require.NoError(t, err) require.NoError(t, err)
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found)) assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
for _, w := range found { for _, w := range found {
@@ -69,7 +69,7 @@ func TestWalkAndHash(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, h1, h2, "hash survives a rename") assert.Equal(t, h1, h2, "hash survives a rename")
_, err = walk(filepath.Join(root, "nope"), func(string) bool { return false }, nil) _, err = walk(filepath.Join(root, "nope"), nil, func(string) bool { return false }, nil)
assert.Error(t, err) assert.Error(t, err)
} }
@@ -83,7 +83,7 @@ func TestWalkReportsIncrementalProgress(t *testing.T) {
} }
var counts, folders []int var counts, folders []int
var paths []string var paths []string
found, err := walk(root, func(rel string) bool { return rel == "b-album/skip.mp3" }, func(n, dirs int, path string) { found, err := walk(root, nil, func(rel string) bool { return rel == "b-album/skip.mp3" }, func(n, dirs int, path string) {
counts, folders, paths = append(counts, n), append(folders, dirs), append(paths, path) counts, folders, paths = append(counts, n), append(folders, dirs), append(paths, path)
}) })
require.NoError(t, err) require.NoError(t, err)
+23
View File
@@ -105,9 +105,17 @@ type SourceInput struct {
Enabled *bool Enabled *bool
AcceptsUploads *bool AcceptsUploads *bool
WriteTemplate *string // "" — убрать WriteTemplate *string // "" — убрать
ExcludedDirs *[]string
} }
func (s *SourceService) apply(src *Source, in SourceInput) error { func (s *SourceService) apply(src *Source, in SourceInput) error {
if in.ExcludedDirs != nil {
dirs, err := normalizeExcludedDirs(*in.ExcludedDirs)
if err != nil {
return err
}
src.ExcludedDirs = dirs
}
if in.Name != nil { if in.Name != nil {
name := strings.TrimSpace(*in.Name) name := strings.TrimSpace(*in.Name)
if name == "" || utf8.RuneCountInString(name) > 100 { if name == "" || utf8.RuneCountInString(name) > 100 {
@@ -228,6 +236,11 @@ func (s *SourceService) UpdateSource(ctx context.Context, id uuid.UUID, in Sourc
if err := s.apply(&src, in); err != nil { if err := s.apply(&src, in); err != nil {
return Source{}, err return Source{}, err
} }
if !slices.Equal(before.ExcludedDirs, src.ExcludedDirs) && s.board != nil {
if progress, ok := s.board.Get(id); ok && progress.Running {
return Source{}, apperr.Conflict(CodeScanRunning, "wait for the running scan before changing excluded folders")
}
}
if src, err = s.store.SaveSource(ctx, src); err != nil { if src, err = s.store.SaveSource(ctx, src); err != nil {
return Source{}, err return Source{}, err
} }
@@ -387,6 +400,16 @@ func (s *SourceService) testRead(ctx context.Context, src Source) (TestResult, e
if err != nil { if err != nil {
return err return err
} }
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
if pathExcluded(filepath.ToSlash(rel), src.ExcludedDirs) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if !d.IsDir() && IsAudio(d.Name()) { if !d.IsDir() && IsAudio(d.Name()) {
n++ n++
if n >= 1000 { if n >= 1000 {
+32 -18
View File
@@ -45,6 +45,7 @@ type Source struct {
CreatedAt time.Time CreatedAt time.Time
AcceptsUploads bool AcceptsUploads bool
WriteTemplate *string WriteTemplate *string
ExcludedDirs []string
} }
// PatternRow — шаблон пути источника. // PatternRow — шаблон пути источника.
@@ -86,7 +87,7 @@ func newID() uuid.UUID { return uuid.Must(uuid.NewV7()) }
const sourceColumns = `id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority = 'tags_first', const sourceColumns = `id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority = 'tags_first',
extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads, extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads,
write_template` write_template, excluded_dirs`
func scanSource(row pgx.Row) (Source, error) { func scanSource(row pgx.Row) (Source, error) {
var ( var (
@@ -95,7 +96,7 @@ func scanSource(row pgx.Row) (Source, error) {
interval *int64 interval *int64
) )
err := row.Scan(&s.ID, &s.Name, &typ, &s.NFSServer, &s.NFSExport, &s.NFSOptions, &s.LocalPath, &s.TagsFirst, err := row.Scan(&s.ID, &s.Name, &typ, &s.NFSServer, &s.NFSExport, &s.NFSOptions, &s.LocalPath, &s.TagsFirst,
&interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads, &s.WriteTemplate) &interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads, &s.WriteTemplate, &s.ExcludedDirs)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return Source{}, ErrNotFound return Source{}, ErrNotFound
} }
@@ -150,17 +151,20 @@ func tagPriority(tagsFirst bool) string {
// SaveSource создаёт или полностью обновляет хранилище (кроме статуса и времени скана). // SaveSource создаёт или полностью обновляет хранилище (кроме статуса и времени скана).
func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) { func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) {
if s.ExcludedDirs == nil {
s.ExcludedDirs = []string{}
}
return scanSource(st.pool.QueryRow(ctx, ` return scanSource(st.pool.QueryRow(ctx, `
INSERT INTO library_sources (id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, INSERT INTO library_sources (id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority,
scan_interval, status, status_message, accepts_uploads, write_template, created_at) scan_interval, status, status_message, accepts_uploads, write_template, excluded_dirs, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, now()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, $14, now())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, nfs_server = EXCLUDED.nfs_server, ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, nfs_server = EXCLUDED.nfs_server,
nfs_export = EXCLUDED.nfs_export, nfs_options = EXCLUDED.nfs_options, local_path = EXCLUDED.local_path, nfs_export = EXCLUDED.nfs_export, nfs_options = EXCLUDED.nfs_options, local_path = EXCLUDED.local_path,
tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval, tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval,
accepts_uploads = EXCLUDED.accepts_uploads, write_template = EXCLUDED.write_template accepts_uploads = EXCLUDED.accepts_uploads, write_template = EXCLUDED.write_template, excluded_dirs = EXCLUDED.excluded_dirs
RETURNING `+sourceColumns, RETURNING `+sourceColumns,
s.ID, s.Name, string(s.Type), s.NFSServer, s.NFSExport, s.NFSOptions, s.LocalPath, tagPriority(s.TagsFirst), s.ID, s.Name, string(s.Type), s.NFSServer, s.NFSExport, s.NFSOptions, s.LocalPath, tagPriority(s.TagsFirst),
intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage, s.AcceptsUploads, s.WriteTemplate)) intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage, s.AcceptsUploads, s.WriteTemplate, s.ExcludedDirs))
} }
// SetSourceStatus обновляет состояние подключения. // SetSourceStatus обновляет состояние подключения.
@@ -263,7 +267,9 @@ func (st *Store) FileByPath(ctx context.Context, sourceID uuid.UUID, rel string)
// SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов). // SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов).
func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) { func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) {
rows, err := st.pool.Query(ctx, ` rows, err := st.pool.Query(ctx, `
SELECT rel_path FROM media_files WHERE source_id = $1 AND missing_since IS NULL ORDER BY random() LIMIT $2`, SELECT f.rel_path FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.source_id = $1 AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
ORDER BY random() LIMIT $2`,
sourceID, n) sourceID, n)
if err != nil { if err != nil {
return nil, fmt.Errorf("library: sample files: %w", err) return nil, fmt.Errorf("library: sample files: %w", err)
@@ -342,7 +348,9 @@ func (st *Store) MarkMissing(ctx context.Context, ids []uuid.UUID, at time.Time)
// PurgeMissing удаляет файлы, пропавшие раньше before. // PurgeMissing удаляет файлы, пропавшие раньше before.
func (st *Store) PurgeMissing(ctx context.Context, sourceID uuid.UUID, before time.Time) (int64, error) { func (st *Store) PurgeMissing(ctx context.Context, sourceID uuid.UUID, before time.Time) (int64, error) {
tag, err := st.pool.Exec(ctx, `DELETE FROM media_files WHERE source_id = $1 AND missing_since < $2`, tag, err := st.pool.Exec(ctx, `DELETE FROM media_files f USING library_sources s
WHERE f.source_id = s.id AND f.source_id = $1 AND f.missing_since < $2
AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)`,
sourceID, before) sourceID, before)
if err != nil { if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err) return 0, fmt.Errorf("library: purge missing: %w", err)
@@ -511,7 +519,8 @@ func (st *Store) Counts(ctx context.Context) (Counts, error) {
err := st.pool.QueryRow(ctx, ` err := st.pool.QueryRow(ctx, `
SELECT (SELECT count(*) FROM artists), (SELECT count(*) FROM albums), (SELECT count(*) FROM tracks), SELECT (SELECT count(*) FROM artists), (SELECT count(*) FROM albums), (SELECT count(*) FROM tracks),
(SELECT count(*) FROM media_files), (SELECT count(*) FROM media_files WHERE missing_since IS NOT NULL), (SELECT count(*) FROM media_files), (SELECT count(*) FROM media_files WHERE missing_since IS NOT NULL),
(SELECT count(*) FROM media_files WHERE scan_error IS NOT NULL AND missing_since IS NULL)`). (SELECT count(*) FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.scan_error IS NOT NULL AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs))`).
Scan(&c.Artists, &c.Albums, &c.Tracks, &c.Files, &c.Missing, &c.Errors) Scan(&c.Artists, &c.Albums, &c.Tracks, &c.Files, &c.Missing, &c.Errors)
if err != nil { if err != nil {
return Counts{}, fmt.Errorf("library: counts: %w", err) return Counts{}, fmt.Errorf("library: counts: %w", err)
@@ -530,8 +539,9 @@ type FileError struct {
// FileErrors — страница файлов с ошибками чтения (по пути, после after). // FileErrors — страница файлов с ошибками чтения (по пути, после after).
func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) { func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
rows, err := st.pool.Query(ctx, ` rows, err := st.pool.Query(ctx, `
SELECT id, source_id, rel_path, scan_error FROM media_files SELECT f.id, f.source_id, f.rel_path, f.scan_error FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE scan_error IS NOT NULL AND missing_since IS NULL AND rel_path > $1 ORDER BY rel_path LIMIT $2`, after, limit) WHERE f.scan_error IS NOT NULL AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
AND f.rel_path > $1 ORDER BY f.rel_path LIMIT $2`, after, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("library: file errors: %w", err) return nil, fmt.Errorf("library: file errors: %w", err)
} }
@@ -551,7 +561,7 @@ func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int) ([]CoverCand
SELECT mf.source_id, mf.rel_path FROM tracks t SELECT mf.source_id, mf.rel_path FROM tracks t
JOIN media_files mf ON mf.id = t.primary_file_id JOIN media_files mf ON mf.id = t.primary_file_id
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted' JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE t.album_id = al.id WHERE t.album_id = al.id AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST
LIMIT 1) f LIMIT 1) f
WHERE al.cover_checked_at IS NULL WHERE al.cover_checked_at IS NULL
@@ -612,12 +622,13 @@ type AnalysisCounts struct {
func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) { func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
var c AnalysisCounts var c AnalysisCounts
err := st.pool.QueryRow(ctx, ` err := st.pool.QueryRow(ctx, `
WITH jobs AS (SELECT * FROM analysis_jobs WHERE NOT library_track_scan_excluded(track_id))
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'), SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'), count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1), (SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1),
(SELECT error FROM analysis_jobs WHERE status = 'failed' AND error IS NOT NULL (SELECT error FROM jobs WHERE status = 'failed' AND error IS NOT NULL
GROUP BY error ORDER BY count(*) DESC LIMIT 1) GROUP BY error ORDER BY count(*) DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError) FROM jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
if err != nil { if err != nil {
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err) return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
} }
@@ -640,12 +651,13 @@ func (st *Store) AnalysisErrors(ctx context.Context, after uuid.UUID, limit int)
SELECT t.id, t.title, f.source_id, s.name, f.rel_path, COALESCE(a.error, '') SELECT t.id, t.title, f.source_id, s.name, f.rel_path, COALESCE(a.error, '')
FROM analysis_jobs a JOIN tracks t ON t.id = a.track_id FROM analysis_jobs a JOIN tracks t ON t.id = a.track_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT mf.source_id, mf.rel_path FROM media_files mf WHERE mf.track_id = t.id SELECT mf.source_id, mf.rel_path FROM media_files mf JOIN library_sources src ON src.id = mf.source_id
WHERE mf.track_id = t.id AND NOT library_path_excluded(mf.rel_path, src.excluded_dirs)
ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, (mf.missing_since IS NULL) DESC, mf.id ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, (mf.missing_since IS NULL) DESC, mf.id
LIMIT 1 LIMIT 1
) f ON true ) f ON true
LEFT JOIN library_sources s ON s.id = f.source_id LEFT JOIN library_sources s ON s.id = f.source_id
WHERE a.status = 'failed' AND t.id > $1 ORDER BY t.id LIMIT $2`, after, limit) WHERE a.status = 'failed' AND NOT library_track_scan_excluded(t.id) AND t.id > $1 ORDER BY t.id LIMIT $2`, after, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("library: analysis errors: %w", err) return nil, fmt.Errorf("library: analysis errors: %w", err)
} }
@@ -659,7 +671,7 @@ func (st *Store) AnalysisErrors(ctx context.Context, after uuid.UUID, limit int)
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) { func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
tag, err := st.pool.Exec(ctx, ` tag, err := st.pool.Exec(ctx, `
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now() UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
WHERE status = 'failed'`) WHERE status = 'failed' AND NOT library_track_scan_excluded(track_id)`)
if err != nil { if err != nil {
return 0, fmt.Errorf("library: retry analysis: %w", err) return 0, fmt.Errorf("library: retry analysis: %w", err)
} }
@@ -748,6 +760,7 @@ func (st *Store) FilesToFingerprint(ctx context.Context, withFP bool, after uuid
FROM media_files mf FROM media_files mf
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted' JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE mf.missing_since IS NULL AND mf.scan_error IS NULL WHERE mf.missing_since IS NULL AND mf.scan_error IS NULL
AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
AND (mf.sha256 IS NULL OR ($1 AND mf.fingerprinted_at IS NULL)) AND mf.id > $2 AND (mf.sha256 IS NULL OR ($1 AND mf.fingerprinted_at IS NULL)) AND mf.id > $2
ORDER BY mf.id ORDER BY mf.id
LIMIT $3`, withFP, after, limit) LIMIT $3`, withFP, after, limit)
@@ -801,7 +814,8 @@ func (st *Store) TemplateSamples(ctx context.Context, sourceID uuid.UUID, n int)
JOIN tracks t ON t.id = mf.track_id JOIN tracks t ON t.id = mf.track_id
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
JOIN artists aa ON aa.id = al.album_artist_id JOIN artists aa ON aa.id = al.album_artist_id
WHERE mf.source_id = $1 AND mf.missing_since IS NULL JOIN library_sources src ON src.id = mf.source_id
WHERE mf.source_id = $1 AND mf.missing_since IS NULL AND NOT library_path_excluded(mf.rel_path, src.excluded_dirs)
ORDER BY random() LIMIT $2`, sourceID, n) ORDER BY random() LIMIT $2`, sourceID, n)
if err != nil { if err != nil {
return nil, fmt.Errorf("library: template samples: %w", err) return nil, fmt.Errorf("library: template samples: %w", err)
+1 -1
View File
@@ -116,7 +116,7 @@ func (s *TemplateService) Preview(ctx context.Context, id uuid.UUID, patterns []
return nil, 0, err return nil, 0, err
} }
if len(paths) == 0 { if len(paths) == 0 {
found, err := walk(root, func(string) bool { return false }, nil) found, err := walk(root, src.ExcludedDirs, func(string) bool { return false }, nil)
if err != nil { if err != nil {
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error()) return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
} }
@@ -0,0 +1,29 @@
-- +goose Up
ALTER TABLE library_sources ADD COLUMN excluded_dirs text[] NOT NULL DEFAULT '{}';
-- +goose StatementBegin
CREATE FUNCTION library_path_excluded(rel_path text, excluded_dirs text[])
RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT EXISTS (
SELECT 1 FROM unnest(excluded_dirs) AS d(dir)
WHERE rel_path = dir OR starts_with(rel_path, dir || '/')
);
$$;
-- +goose StatementEnd
-- Keep historical jobs and files; only tracks whose every known file is excluded are suppressed.
-- +goose StatementBegin
CREATE FUNCTION library_track_scan_excluded(track uuid)
RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT EXISTS (SELECT 1 FROM media_files WHERE track_id = track)
AND NOT EXISTS (
SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.track_id = track AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
);
$$;
-- +goose StatementEnd
-- +goose Down
DROP FUNCTION library_track_scan_excluded(uuid);
DROP FUNCTION library_path_excluded(text, text[]);
ALTER TABLE library_sources DROP COLUMN excluded_dirs;
@@ -93,9 +93,13 @@ class Worker:
WHERE j.track_id IN ( WHERE j.track_id IN (
SELECT a.track_id FROM analysis_jobs a SELECT a.track_id FROM analysis_jobs a
JOIN tracks t ON t.id = a.track_id JOIN tracks t ON t.id = a.track_id
JOIN media_files f ON f.id = t.primary_file_id
JOIN library_sources s ON s.id = f.source_id AND s.status = 'mounted'
WHERE a.status = 'queued' WHERE a.status = 'queued'
AND EXISTS (
SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.track_id = t.id AND s.status = 'mounted'
AND f.missing_since IS NULL AND f.scan_error IS NULL
AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
)
ORDER BY a.updated_at ORDER BY a.updated_at
LIMIT %s LIMIT %s
FOR UPDATE OF a SKIP LOCKED) FOR UPDATE OF a SKIP LOCKED)
@@ -108,10 +112,17 @@ class Worker:
return [] return []
files = conn.execute( files = conn.execute(
""" """
SELECT t.id::text, s.id::text, s.type::text, s.local_path, f.rel_path SELECT t.id::text, f.source_id::text, f.source_type, f.local_path, f.rel_path
FROM tracks t FROM tracks t
JOIN media_files f ON f.id = t.primary_file_id CROSS JOIN LATERAL (
JOIN library_sources s ON s.id = f.source_id SELECT s.id AS source_id, s.type::text AS source_type, s.local_path, mf.rel_path
FROM media_files mf JOIN library_sources s ON s.id = mf.source_id
WHERE mf.track_id = t.id AND s.status = 'mounted'
AND mf.missing_since IS NULL AND mf.scan_error IS NULL
AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, mf.id
LIMIT 1
) f
WHERE t.id = ANY(%s::uuid[]) WHERE t.id = ANY(%s::uuid[])
""", """,
([r[0] for r in rows],), ([r[0] for r in rows],),
@@ -122,6 +122,37 @@ def test_analysis_worker_saves_features(world: dict[str, Any]) -> None:
assert row is not None and row[0] == pytest.approx(120.0) assert row is not None and row[0] == pytest.approx(120.0)
def test_analysis_worker_skips_excluded_folders_and_uses_allowed_copy(world: dict[str, Any]) -> None:
worker = Worker(DSN, "/library", "/library-host", 1)
blocked, alternative, allowed = world["tracks"][:3]
with psycopg.connect(DSN, autocommit=True) as conn:
source = conn.execute(
"SELECT source_id FROM media_files WHERE track_id = %s", (blocked,)
).fetchone()
assert source is not None
conn.execute(
"UPDATE library_sources SET excluded_dirs = ARRAY['Downloads_PumpEars'] WHERE id = %s",
(source[0],),
)
for track, rel in [(blocked, "Downloads_PumpEars/nested/loop.wav"), (alternative, "Downloads_PumpEars/loop.wav"), (allowed, "Downloads_PumpEars-copy/song.flac")]:
conn.execute("UPDATE media_files SET rel_path = %s WHERE track_id = %s", (rel, track))
conn.execute(
"INSERT INTO media_files (id, source_id, rel_path, size_bytes, mtime, format, duration_ms, track_id) "
"VALUES (%s, %s, 'Music/allowed-copy.flac', 1, now(), 'flac', 200000, %s)",
(uid(), source[0], alternative),
)
conn.execute(
"UPDATE analysis_jobs SET status = 'queued' WHERE track_id = ANY(%s::uuid[])",
([blocked, alternative, allowed],),
)
jobs = {job.track_id: job.path for job in worker.claim(conn, 10000)}
assert blocked not in jobs
assert jobs[alternative].replace("\\", "/").endswith("/Music/allowed-copy.flac")
assert jobs[allowed].replace("\\", "/").endswith("/Downloads_PumpEars-copy/song.flac")
row = conn.execute("SELECT attempts, status FROM analysis_jobs WHERE track_id = %s", (blocked,)).fetchone()
assert row == (0, 'queued'), "excluded files must not consume retry attempts"
def test_training_and_wave_queries(world: dict[str, Any]) -> None: def test_training_and_wave_queries(world: dict[str, Any]) -> None:
with psycopg.connect(DSN) as conn: with psycopg.connect(DSN) as conn:
assert len(load_interactions(conn)) >= 60 assert len(load_interactions(conn)) >= 60