Omit missing years from upload destination paths
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 2m33s
ci / api / api (push) Successful in 1m10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m45s
ci / ts / ts (packages + web) (push) Successful in 6m29s
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 2m33s
ci / api / api (push) Successful in 1m10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m45s
ci / ts / ts (packages + web) (push) Successful in 6m29s
This commit is contained in:
1 parent
3309a79a98
commit
db4548b115
10 files changed
+131
-5
No files matched your search
@@ -178,6 +178,9 @@
|
|||||||
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
|
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
|
||||||
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
|
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
|
||||||
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
|
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
|
||||||
|
Отсутствующий год отмечается предупреждением и не блокирует одобрение или импорт:
|
||||||
|
`{year}` опускается вместе со скобками или разделителем, необязательный фрагмент с годом опускается.
|
||||||
|
В теги неизвестный год не записывается.
|
||||||
- Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`)
|
- Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`)
|
||||||
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
|
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
|
||||||
Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам.
|
Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам.
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
"no_target": "No storage accepts uploads (or several do and none is the default)",
|
"no_target": "No storage accepts uploads (or several do and none is the default)",
|
||||||
"no_template": "The storage has no path template",
|
"no_template": "The storage has no path template",
|
||||||
"missing_field": "Not enough metadata to build the storage path",
|
"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",
|
"offset_mismatch": "The upload got out of sync; try again",
|
||||||
"size_mismatch": "The file size does not match the declared one",
|
"size_mismatch": "The file size does not match the declared one",
|
||||||
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
"no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)",
|
"no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)",
|
||||||
"no_template": "У хранилища не задан шаблон пути",
|
"no_template": "У хранилища не задан шаблон пути",
|
||||||
"missing_field": "Не хватает метаданных для пути в хранилище",
|
"missing_field": "Не хватает метаданных для пути в хранилище",
|
||||||
|
"missing_year": "Не указан год — проверьте метаданные. Год пропущен в пути",
|
||||||
"offset_mismatch": "Загрузка рассинхронизировалась — повторите",
|
"offset_mismatch": "Загрузка рассинхронизировалась — повторите",
|
||||||
"size_mismatch": "Размер файла не совпал с заявленным",
|
"size_mismatch": "Размер файла не совпал с заявленным",
|
||||||
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
||||||
|
|||||||
@@ -254,6 +254,65 @@ func (t Template) Render(in TemplateInput) (string, error) {
|
|||||||
return finishPath(b.String())
|
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) {
|
func renderOptional(in TemplateInput, nodes []tnode) (string, bool) {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
|
|||||||
@@ -7,6 +7,41 @@ import (
|
|||||||
|
|
||||||
const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
|
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 {
|
func in(f Fields, discs int, ext string) TemplateInput {
|
||||||
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
|
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const (
|
|||||||
CodeSizeMismatch = "uploads.size_mismatch"
|
CodeSizeMismatch = "uploads.size_mismatch"
|
||||||
CodeHashMismatch = "uploads.hash_mismatch"
|
CodeHashMismatch = "uploads.hash_mismatch"
|
||||||
CodeMissingField = "uploads.missing_field"
|
CodeMissingField = "uploads.missing_field"
|
||||||
|
CodeMissingYear = "uploads.missing_year"
|
||||||
CodeImportFailed = "uploads.import_failed"
|
CodeImportFailed = "uploads.import_failed"
|
||||||
CodeTemplate = "uploads.no_template"
|
CodeTemplate = "uploads.no_template"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type PlacementPlanner struct {
|
|||||||
|
|
||||||
// --- Раскладка в хранилище ---
|
// --- Раскладка в хранилище ---
|
||||||
|
|
||||||
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil.
|
// placement — куда ляжет файл; Reason при ненулевом пути — предупреждение.
|
||||||
type placement struct {
|
type placement struct {
|
||||||
SourceID *uuid.UUID
|
SourceID *uuid.UUID
|
||||||
RelPath *string
|
RelPath *string
|
||||||
@@ -147,7 +147,7 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui
|
|||||||
if m.Disc != nil {
|
if m.Disc != nil {
|
||||||
discs = max(discs, *m.Disc)
|
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 {
|
if err != nil {
|
||||||
var mf *meta.MissingFieldError
|
var mf *meta.MissingFieldError
|
||||||
if errors.As(err, &mf) {
|
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)
|
rel = reuseExisting(root, rel)
|
||||||
pl.RelPath, pl.root = &rel, root
|
pl.RelPath, pl.root = &rel, root
|
||||||
|
if m.Year == nil || *m.Year <= 0 {
|
||||||
|
pl.Reason = CodeMissingYear
|
||||||
|
}
|
||||||
return pl
|
return pl
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,28 @@ func (previewStore) BatchMaxDisc(context.Context, uuid.UUID, string) (int, error
|
|||||||
|
|
||||||
type previewRoot string
|
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 (r previewRoot) Root(library.Source) (string, error) { return string(r), nil }
|
||||||
|
|
||||||
func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
|
func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
|
||||||
@@ -42,8 +64,9 @@ func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
|
|||||||
AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{title}.{ext}")}}
|
AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{title}.{ext}")}}
|
||||||
planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)}
|
planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)}
|
||||||
track := 3
|
track := 3
|
||||||
|
year := 2026
|
||||||
f := File{Status: StatusPendingReview, OriginalName: "song.flac", Meta: &Meta{
|
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)}
|
TargetSourceID: &catalog.source.ID, TargetRelPath: nullable("stale/path.flac"), StatusReason: nullable(CodeNoTarget)}
|
||||||
planner.previewPlacement(ctx, &f)
|
planner.previewPlacement(ctx, &f)
|
||||||
require.Equal(t, "Artist/Album/Song.flac", *f.TargetRelPath)
|
require.Equal(t, "Artist/Album/Song.flac", *f.TargetRelPath)
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
|
|||||||
return apperr.Conflict(CodeMissingField, "file has no metadata")
|
return apperr.Conflict(CodeMissingField, "file has no metadata")
|
||||||
}
|
}
|
||||||
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
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")
|
return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
|
||||||
}
|
}
|
||||||
reason = ""
|
reason = ""
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
|
|||||||
return errors.New("no metadata")
|
return errors.New("no metadata")
|
||||||
}
|
}
|
||||||
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
||||||
if pl.Reason != "" {
|
if pl.Reason != "" && pl.RelPath == nil {
|
||||||
return errors.New(pl.Reason)
|
return errors.New(pl.Reason)
|
||||||
}
|
}
|
||||||
rel := *pl.RelPath
|
rel := *pl.RelPath
|
||||||
|
|||||||
Reference in new issue
Block a user