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
+837 -351

No files matched your search

+14
View File
@@ -2687,6 +2687,8 @@ components:
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]
properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
id:
type: string
format: uuid
@@ -2747,6 +2749,8 @@ components:
type: object
required: [name, type]
properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name:
type: string
maxLength: 100
@@ -2774,6 +2778,8 @@ components:
SourcePatch:
type: object
properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name:
type: string
maxLength: 100
@@ -2802,6 +2808,14 @@ components:
description: |
Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid`
с `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:
type: object
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
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 { 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>}
</div>
<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 && (
<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];
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 session = useSession();
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 [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
const [excludedDirs, setExcludedDirs] = useState((source?.excluded_dirs ?? []).join('\n'));
const excludedDirsId = useId();
const save = useMutation({
mutationFn: async () => {
const common = {
name: name.trim(),
tag_priority: priority,
excluded_dirs: excludedDirs.split(/\r?\n/).map((dir) => dir.trim()).filter(Boolean),
...(type === 'local'
? { local_path: localPath }
: { 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 () => {
await queryClient.invalidateQueries({ queryKey: ['sources'] });
for (const key of ['library-status', 'library-errors', 'analysis-errors']) {
void queryClient.invalidateQueries({ queryKey: [key] });
}
onClose();
},
});
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 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}>
<span className={l.label}>{t('sources.tag_priority')}</span>
<Segmented
@@ -238,6 +238,20 @@
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+14
View File
@@ -139,6 +139,20 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
Ожидаемое время первого скана 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} */
TagPriority: "tags_first" | "path_first";
Source: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
/** Format: uuid */
id: string;
name: string;
@@ -1988,6 +1989,7 @@ export interface components {
items: components["schemas"]["Source"][];
};
SourceInput: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name: string;
type: components["schemas"]["SourceType"];
nfs_server?: string;
@@ -2000,6 +2002,7 @@ export interface components {
write_template?: string;
};
SourcePatch: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name?: string;
nfs_server?: string;
nfs_export?: string;
@@ -2018,6 +2021,8 @@ export interface components {
*/
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: {
ok: boolean;
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.",
"scanning": "Scanning: {{processed}} of {{total}}",
"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_current_dir": "Current folder: {{path}}",
"scan_preparing": "Matching files with the library…",
+1
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Pattern error: {{reason}}",
"nfs_invalid": "Invalid value",
"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",
"scan_interval_invalid": "The interval must be at least {{min}} minutes",
"source_type_invalid": "Unknown storage type",
+4
View File
@@ -98,6 +98,10 @@
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
"scanning": "Сканирование: {{processed}} из {{total}}",
"scan_walking": "Обход папок…",
"excluded_dirs": "Не сканировать папки",
"excluded_hint": "По одной папке в строке, путь от корня хранилища. Например: Downloads_PumpEars. Все подпапки тоже исключаются. Учитывается регистр; шаблоны со звёздочкой не поддерживаются. Анализ и его ошибки отключаются сразу после сохранения. Уже добавленные записи сохраняются. Пустой список возвращает папки в сканирование и анализ.",
"excluded_running": "Дождитесь завершения сканирования, чтобы изменить исключения.",
"excluded_count": "Исключено папок из сканирования: {{count}}",
"scan_walk_progress": "Обход папок… Проверено папок: {{dirs}} · найдено аудиофайлов: {{found}}",
"scan_current_dir": "Текущая папка: {{path}}",
"scan_preparing": "Сопоставление файлов с библиотекой…",
+1
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Ошибка в шаблоне: {{reason}}",
"nfs_invalid": "Недопустимое значение",
"local_path_invalid": "Папка не найдена",
"excluded_dirs_invalid": "Укажите до 100 папок: относительные пути без .., по одной папке в строке (до 1000 символов).",
"name_invalid": "Название — от 1 до {{max}} символов",
"scan_interval_invalid": "Интервал — не меньше {{min}} минут",
"source_type_invalid": "Неизвестный тип хранилища",
+332 -317
View File
@@ -511,6 +511,9 @@ type EventBatchResult struct {
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.
type FieldError struct {
Code string `json:"code"`
@@ -972,10 +975,13 @@ type SessionList struct {
// Source defines model for Source.
type Source struct {
// AcceptsUploads Принимает импорт из очереди на добавление (NFS монтируется агентом ещё и на запись)
AcceptsUploads bool `json:"accepts_uploads"`
CreatedAt time.Time `json:"created_at"`
Id openapi_types.UUID `json:"id"`
LastScanAt *time.Time `json:"last_scan_at"`
AcceptsUploads bool `json:"accepts_uploads"`
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"`
LastScanAt *time.Time `json:"last_scan_at"`
// LocalPath Подпапка относительно LIBRARY_HOST, пусто — весь каталог
LocalPath *string `json:"local_path"`
@@ -998,16 +1004,19 @@ type Source struct {
// SourceInput defines model for SourceInput.
type SourceInput struct {
AcceptsUploads *bool `json:"accepts_uploads,omitempty"`
LocalPath *string `json:"local_path,omitempty"`
Name string `json:"name"`
NfsExport *string `json:"nfs_export,omitempty"`
NfsOptions *string `json:"nfs_options,omitempty"`
NfsServer *string `json:"nfs_server,omitempty"`
ScanIntervalMinutes *int `json:"scan_interval_minutes"`
TagPriority *TagPriority `json:"tag_priority,omitempty"`
Type SourceType `json:"type"`
WriteTemplate *string `json:"write_template,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"`
Name string `json:"name"`
NfsExport *string `json:"nfs_export,omitempty"`
NfsOptions *string `json:"nfs_options,omitempty"`
NfsServer *string `json:"nfs_server,omitempty"`
ScanIntervalMinutes *int `json:"scan_interval_minutes"`
TagPriority *TagPriority `json:"tag_priority,omitempty"`
Type SourceType `json:"type"`
WriteTemplate *string `json:"write_template,omitempty"`
}
// SourceList defines model for SourceList.
@@ -1021,12 +1030,15 @@ type SourcePatch struct {
AcceptsUploads *bool `json:"accepts_uploads,omitempty"`
// Enabled false — отключить хранилище (треки становятся недоступны), true — подключить снова
Enabled *bool `json:"enabled,omitempty"`
LocalPath *string `json:"local_path,omitempty"`
Name *string `json:"name,omitempty"`
NfsExport *string `json:"nfs_export,omitempty"`
NfsOptions *string `json:"nfs_options,omitempty"`
NfsServer *string `json:"nfs_server,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"`
Name *string `json:"name,omitempty"`
NfsExport *string `json:"nfs_export,omitempty"`
NfsOptions *string `json:"nfs_options,omitempty"`
NfsServer *string `json:"nfs_server,omitempty"`
// ScanIntervalMinutes 0 — отключить автоматический скан
ScanIntervalMinutes *int `json:"scan_interval_minutes,omitempty"`
@@ -11377,303 +11389,306 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"cf/O/f8/AAD//+0IjFXOgAEA",
"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",
}
// 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, 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 {
if s.ExcludedDirs == nil {
s.ExcludedDirs = []string{}
}
out := gen.Source{
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,
Status: gen.SourceStatus(s.Status), StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt,
CreatedAt: s.CreatedAt, AcceptsUploads: s.AcceptsUploads, WriteTemplate: s.WriteTemplate,
ExcludedDirs: &s.ExcludedDirs,
}
if !s.TagsFirst {
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{
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,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
})
if err != nil {
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{
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,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
})
if err != nil {
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 {
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) {
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): после импорта из очереди трек сразу появляется в каталоге.
// Возвращает id записи media_files. Хуки AfterScan (обложки, поиск, отпечатки) вызывает сам.
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)
if err != nil {
return uuid.Nil, err
@@ -470,7 +479,7 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
// walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются;
// 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 {
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
}
rel = filepath.ToSlash(rel)
if pathExcluded(rel, excludedDirs) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
dirs++
if report != nil {
@@ -55,7 +55,7 @@ func TestWalkAndHash(t *testing.T) {
write("_incoming/new.mp3", 10)
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)
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
for _, w := range found {
@@ -69,7 +69,7 @@ func TestWalkAndHash(t *testing.T) {
require.NoError(t, err)
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)
}
@@ -83,7 +83,7 @@ func TestWalkReportsIncrementalProgress(t *testing.T) {
}
var counts, folders []int
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)
})
require.NoError(t, err)
+23
View File
@@ -105,9 +105,17 @@ type SourceInput struct {
Enabled *bool
AcceptsUploads *bool
WriteTemplate *string // "" — убрать
ExcludedDirs *[]string
}
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 {
name := strings.TrimSpace(*in.Name)
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 {
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 {
return Source{}, err
}
@@ -387,6 +400,16 @@ func (s *SourceService) testRead(ctx context.Context, src Source) (TestResult, e
if err != nil {
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()) {
n++
if n >= 1000 {
+32 -18
View File
@@ -45,6 +45,7 @@ type Source struct {
CreatedAt time.Time
AcceptsUploads bool
WriteTemplate *string
ExcludedDirs []string
}
// 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',
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) {
var (
@@ -95,7 +96,7 @@ func scanSource(row pgx.Row) (Source, error) {
interval *int64
)
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) {
return Source{}, ErrNotFound
}
@@ -150,17 +151,20 @@ func tagPriority(tagsFirst bool) string {
// SaveSource создаёт или полностью обновляет хранилище (кроме статуса и времени скана).
func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) {
if s.ExcludedDirs == nil {
s.ExcludedDirs = []string{}
}
return scanSource(st.pool.QueryRow(ctx, `
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, now())
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, $14, now())
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,
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,
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 обновляет состояние подключения.
@@ -263,7 +267,9 @@ func (st *Store) FileByPath(ctx context.Context, sourceID uuid.UUID, rel string)
// SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов).
func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) {
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)
if err != nil {
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.
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)
if err != nil {
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, `
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 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)
if err != nil {
return Counts{}, fmt.Errorf("library: counts: %w", err)
@@ -530,8 +539,9 @@ type FileError struct {
// FileErrors — страница файлов с ошибками чтения (по пути, после after).
func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
rows, err := st.pool.Query(ctx, `
SELECT id, source_id, rel_path, scan_error FROM media_files
WHERE scan_error IS NOT NULL AND missing_since IS NULL AND rel_path > $1 ORDER BY rel_path LIMIT $2`, after, limit)
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 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 {
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
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'
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
LIMIT 1) f
WHERE al.cover_checked_at IS NULL
@@ -612,12 +622,13 @@ type AnalysisCounts struct {
func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
var c AnalysisCounts
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'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(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)
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 {
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, '')
FROM analysis_jobs a JOIN tracks t ON t.id = a.track_id
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
LIMIT 1
) f ON true
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 {
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) {
tag, err := st.pool.Exec(ctx, `
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 {
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
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
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
ORDER BY mf.id
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 albums al ON al.id = t.album_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)
if err != nil {
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
}
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 {
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 (
SELECT a.track_id FROM analysis_jobs a
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'
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
LIMIT %s
FOR UPDATE OF a SKIP LOCKED)
@@ -108,10 +112,17 @@ class Worker:
return []
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
JOIN media_files f ON f.id = t.primary_file_id
JOIN library_sources s ON s.id = f.source_id
CROSS JOIN LATERAL (
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[])
""",
([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)
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:
with psycopg.connect(DSN) as conn:
assert len(load_interactions(conn)) >= 60