diff --git a/docs/uploads.md b/docs/uploads.md index 3b1ffbb..cdd4155 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -178,6 +178,9 @@ предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт. Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь. Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути. + Отсутствующий год отмечается предупреждением и не блокирует одобрение или импорт: + `{year}` опускается вместе со скобками или разделителем, необязательный фрагмент с годом опускается. + В теги неизвестный год не записывается. - Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`) недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно. Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам. diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index da053bb..75e875a 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -104,6 +104,7 @@ "no_target": "No storage accepts uploads (or several do and none is the default)", "no_template": "The storage has no path template", "missing_field": "Not enough metadata to build the storage path", + "missing_year": "Year is missing — check the metadata. The year is omitted from the path", "offset_mismatch": "The upload got out of sync; try again", "size_mismatch": "The file size does not match the declared one", "hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)", diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index b765d2c..f8843ab 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -104,6 +104,7 @@ "no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)", "no_template": "У хранилища не задан шаблон пути", "missing_field": "Не хватает метаданных для пути в хранилище", + "missing_year": "Не указан год — проверьте метаданные. Год пропущен в пути", "offset_mismatch": "Загрузка рассинхронизировалась — повторите", "size_mismatch": "Размер файла не совпал с заявленным", "hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)", diff --git a/services/api/internal/library/meta/template.go b/services/api/internal/library/meta/template.go index 37a1fdf..0222f13 100644 --- a/services/api/internal/library/meta/template.go +++ b/services/api/internal/library/meta/template.go @@ -254,6 +254,65 @@ func (t Template) Render(in TemplateInput) (string, error) { return finishPath(b.String()) } +// RenderWithoutMissingYear omits an unknown year and its punctuation for uploads. +// Other required metadata still uses strict rendering. Optional fragments retain +// their usual all-or-nothing semantics. +func (t Template) RenderWithoutMissingYear(in TemplateInput) (string, error) { + if fieldValue(in, tnode{field: "year"}) != "" { + return t.Render(in) + } + var src strings.Builder + for _, n := range t.nodes { + if n.isOpt { + if _, ok := renderOptional(in, n.opt); ok { + src.WriteString("[") + for _, o := range n.opt { + src.WriteString(templateNodeSource(o)) + } + src.WriteString("]") + } + } else { + src.WriteString(templateNodeSource(n)) + } + } + s := src.String() + for _, re := range missingYearParts { + s = re.ReplaceAllString(s, "") + } + // A year-only directory disappears as a whole. + var segments []string + for _, seg := range strings.Split(s, "/") { + if strings.TrimSpace(seg) != "" { + segments = append(segments, seg) + } + } + tpl, err := ParseTemplate(strings.Join(segments, "/")) + if err != nil { + return "", err + } + return tpl.Render(in) +} + +var missingYearParts = []*regexp.Regexp{ + regexp.MustCompile(`\(\s*\{year\}\s*\)`), + regexp.MustCompile(`\{year\}[ \t]*[-–—_][ \t]*`), + regexp.MustCompile(`[ \t]*[-–—_][ \t]*\{year\}`), + regexp.MustCompile(`\{year\}`), +} + +func templateNodeSource(n tnode) string { + if n.field == "" { + return n.lit + } + if n.multi { + return "{" + n.field + ":1" + n.suffix + "}" + } + if n.pad > 0 { + return fmt.Sprintf("{%s:0%d}", n.field, n.pad) + } + return "{" + n.field + "}" +} + func renderOptional(in TemplateInput, nodes []tnode) (string, bool) { var b strings.Builder for _, n := range nodes { diff --git a/services/api/internal/library/meta/template_test.go b/services/api/internal/library/meta/template_test.go index 36aac86..c43c5a3 100644 --- a/services/api/internal/library/meta/template_test.go +++ b/services/api/internal/library/meta/template_test.go @@ -7,6 +7,41 @@ import ( const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}" +func TestRenderWithoutMissingYear(t *testing.T) { + for _, pattern := range []string{ + "{artist}/{album} ({year})/{track:02} - {title}.{ext}", + "{artist}/{year} - {album}/{track:02} - {title}.{ext}", + "{artist}/{album} - {year}/{track:02} - {title}.{ext}", + "{artist}/{year}/{album}/{track:02} - {title}.{ext}", + "{artist}/[{year} - ]{album}/{track:02} - {title}.{ext}", + } { + t.Run(pattern, func(t *testing.T) { + tpl, err := ParseTemplate(pattern) + if err != nil { + t.Fatal(err) + } + input := in(Fields{Artist: "A", Album: "B", Title: "C", Track: "1"}, 1, "mp3") + got, err := tpl.RenderWithoutMissingYear(input) + if err != nil || got != "A/B/01 - C.mp3" { + t.Fatalf("got %q, %v", got, err) + } + input.Fields.Title = "" + if _, err := tpl.RenderWithoutMissingYear(input); err == nil { + t.Fatal("missing title must still fail") + } + input.Fields.Title, input.Fields.Year = "C", "2026" + want, err := tpl.Render(input) + if err != nil { + t.Fatal(err) + } + got, err = tpl.RenderWithoutMissingYear(input) + if err != nil || got != want { + t.Fatalf("known year: got %q, %v; want %q", got, err, want) + } + }) + } +} + func in(f Fields, discs int, ext string) TemplateInput { return TemplateInput{Fields: f, DiscCount: discs, Ext: ext} } diff --git a/services/api/internal/uploads/model.go b/services/api/internal/uploads/model.go index 7aa72dd..a8f9292 100644 --- a/services/api/internal/uploads/model.go +++ b/services/api/internal/uploads/model.go @@ -57,6 +57,7 @@ const ( CodeSizeMismatch = "uploads.size_mismatch" CodeHashMismatch = "uploads.hash_mismatch" CodeMissingField = "uploads.missing_field" + CodeMissingYear = "uploads.missing_year" CodeImportFailed = "uploads.import_failed" CodeTemplate = "uploads.no_template" ) diff --git a/services/api/internal/uploads/placement.go b/services/api/internal/uploads/placement.go index 0face37..9a595e0 100644 --- a/services/api/internal/uploads/placement.go +++ b/services/api/internal/uploads/placement.go @@ -38,7 +38,7 @@ type PlacementPlanner struct { // --- Раскладка в хранилище --- -// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil. +// placement — куда ляжет файл; Reason при ненулевом пути — предупреждение. type placement struct { SourceID *uuid.UUID RelPath *string @@ -147,7 +147,7 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui if m.Disc != nil { discs = max(discs, *m.Disc) } - rel, err := tpl.Render(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)}) + rel, err := tpl.RenderWithoutMissingYear(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)}) if err != nil { var mf *meta.MissingFieldError if errors.As(err, &mf) { @@ -164,5 +164,8 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui } rel = reuseExisting(root, rel) pl.RelPath, pl.root = &rel, root + if m.Year == nil || *m.Year <= 0 { + pl.Reason = CodeMissingYear + } return pl } diff --git a/services/api/internal/uploads/placement_test.go b/services/api/internal/uploads/placement_test.go index 385de10..eb1857b 100644 --- a/services/api/internal/uploads/placement_test.go +++ b/services/api/internal/uploads/placement_test.go @@ -33,6 +33,28 @@ func (previewStore) BatchMaxDisc(context.Context, uuid.UUID, string) (int, error type previewRoot string +func TestPreviewMissingYearStillHasImportPath(t *testing.T) { + root := t.TempDir() + catalog := &previewCatalog{source: library.Source{ID: uuid.New(), Name: "Base", Type: library.SourceLocal, + AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{albumartist} - {album} ({year})/{title}.{ext}")}} + planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)} + f := File{Status: StatusPendingReview, OriginalName: "song.mp3", Meta: &Meta{ + Artist: "Artist feat. Guest", AlbumArtist: "Artist", Album: "Album", Title: "Song"}} + planner.previewPlacement(context.Background(), &f) + require.NotNil(t, f.TargetRelPath) + require.Equal(t, "Artist/Artist - Album/Song.mp3", *f.TargetRelPath) + require.NotNil(t, f.TargetStoragePath) + require.Equal(t, CodeMissingYear, *f.StatusReason) + pl := planner.plan(context.Background(), f, *f.Meta, f.TargetSourceID) + require.Equal(t, f.TargetRelPath, pl.RelPath) + require.Nil(t, f.Meta.Year, "path rendering must not invent metadata") + year := 2026 + f.Meta.Year = &year + planner.previewPlacement(context.Background(), &f) + require.Equal(t, "Artist/Artist - Album (2026)/Song.mp3", *f.TargetRelPath) + require.Nil(t, f.StatusReason) +} + func (r previewRoot) Root(library.Source) (string, error) { return string(r), nil } func TestPreviewUsesCurrentImportTemplate(t *testing.T) { @@ -42,8 +64,9 @@ func TestPreviewUsesCurrentImportTemplate(t *testing.T) { AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{title}.{ext}")}} planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)} track := 3 + year := 2026 f := File{Status: StatusPendingReview, OriginalName: "song.flac", Meta: &Meta{ - Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "Song", Track: &track}, + Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "Song", Track: &track, Year: &year}, TargetSourceID: &catalog.source.ID, TargetRelPath: nullable("stale/path.flac"), StatusReason: nullable(CodeNoTarget)} planner.previewPlacement(ctx, &f) require.Equal(t, "Artist/Album/Song.flac", *f.TargetRelPath) diff --git a/services/api/internal/uploads/service.go b/services/api/internal/uploads/service.go index efb7546..cd46759 100644 --- a/services/api/internal/uploads/service.go +++ b/services/api/internal/uploads/service.go @@ -452,7 +452,7 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve return apperr.Conflict(CodeMissingField, "file has no metadata") } pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID) - if pl.Reason != "" { + if pl.Reason != "" && pl.RelPath == nil { return apperr.Conflict(pl.Reason, "cannot place the file into a storage") } reason = "" diff --git a/services/api/internal/uploads/worker.go b/services/api/internal/uploads/worker.go index b06f93d..e3387a4 100644 --- a/services/api/internal/uploads/worker.go +++ b/services/api/internal/uploads/worker.go @@ -298,7 +298,7 @@ func (s *Service) importFile(ctx context.Context, f File) error { return errors.New("no metadata") } pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID) - if pl.Reason != "" { + if pl.Reason != "" && pl.RelPath == nil { return errors.New(pl.Reason) } rel := *pl.RelPath