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
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:
1 parent
0dc0384c78
commit
75ae2092ad
23 files changed
+837
-351
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user