fix(audiobooks): extract scan covers and harden dedupe (#103)
* fix(audiobooks): extract scan covers and harden dedupe * fix(audiobooks): address scan cover review --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
rxwatcher
Quick
parent
fe7eb70483
commit
bf54f040f9
@@ -32,6 +32,7 @@ var unabridgedTokenRE = regexp.MustCompile(`(?i)\s*\(unabridged\)\s*`)
|
||||
// Used after the strip passes since removing a mid-string token can
|
||||
// leave double spaces behind.
|
||||
var collapseSpacesRE = regexp.MustCompile(`\s+`)
|
||||
var audiobookDedupeTitleTokenRE = regexp.MustCompile(`[^A-Za-z0-9]+`)
|
||||
|
||||
// stripNarratorSuffix removes the narrator-suffix noise and "(unabridged)"
|
||||
// markers from a title. Returns the input unchanged when no match.
|
||||
@@ -44,6 +45,28 @@ func stripNarratorSuffix(title string) string {
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
func normalizeAudiobookDedupeTitle(title string) string {
|
||||
title = stripNarratorSuffix(title)
|
||||
title = audiobookDedupeTitleTokenRE.ReplaceAllString(title, " ")
|
||||
title = collapseSpacesRE.ReplaceAllString(title, " ")
|
||||
return strings.ToLower(strings.TrimSpace(title))
|
||||
}
|
||||
|
||||
func audiobookDedupeTitlesMatch(a, b string) bool {
|
||||
left := normalizeAudiobookDedupeTitle(a)
|
||||
right := normalizeAudiobookDedupeTitle(b)
|
||||
if left == "" || right == "" {
|
||||
return false
|
||||
}
|
||||
if left == right {
|
||||
return true
|
||||
}
|
||||
if len(left) < len(right) {
|
||||
return strings.HasPrefix(right, left+" ")
|
||||
}
|
||||
return strings.HasPrefix(left, right+" ")
|
||||
}
|
||||
|
||||
// parsedAudiobook is the structured output of parseAudiobookFolder.
|
||||
// The scanner write path (Task 8) converts this into media_items +
|
||||
// media_files + item_people rows.
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/titleutil"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type filesystemRootContentFinder interface {
|
||||
@@ -28,6 +29,44 @@ type filesystemMediaItemWriter interface {
|
||||
Upsert(ctx context.Context, item *models.MediaItem) error
|
||||
}
|
||||
|
||||
type audiobookPosterExec interface {
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
type audiobookPosterPathReader interface {
|
||||
GetPosterPath(ctx context.Context, contentID string) (string, error)
|
||||
}
|
||||
|
||||
const audiobookDuplicateCandidateSQL = `
|
||||
SELECT mi.content_id, mi.title
|
||||
FROM media_items mi
|
||||
JOIN item_people ipa ON ipa.content_id = mi.content_id AND ipa.kind = 7
|
||||
JOIN people pa ON pa.id = ipa.person_id AND LOWER(pa.name) = LOWER($1)
|
||||
JOIN item_people ipn ON ipn.content_id = mi.content_id AND ipn.kind = 8
|
||||
JOIN people pn ON pn.id = ipn.person_id AND LOWER(pn.name) = LOWER($2)
|
||||
JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(mf.duration), 0) AS dur
|
||||
FROM media_files mf WHERE mf.content_id = mi.content_id
|
||||
) f ON TRUE
|
||||
JOIN LATERAL (
|
||||
SELECT btrim(regexp_replace(
|
||||
regexp_replace(lower(mi.title), '[^a-z0-9]+', ' ', 'g'),
|
||||
'[[:space:]]+', ' ', 'g'
|
||||
)) AS normalized_title
|
||||
) t ON TRUE
|
||||
WHERE mi.type = 'audiobook'
|
||||
AND mi.year = $3
|
||||
AND f.dur > 0
|
||||
AND ABS(f.dur - $4) <= $5
|
||||
AND (
|
||||
t.normalized_title = $6
|
||||
OR t.normalized_title LIKE $6 || ' %'
|
||||
OR $6 LIKE t.normalized_title || ' %'
|
||||
)
|
||||
ORDER BY ABS(f.dur - $4), LENGTH(mi.title), mi.content_id
|
||||
LIMIT 25
|
||||
`
|
||||
|
||||
// audiobookDiskFile is the on-disk projection used by audiobookFolderUnchanged.
|
||||
// Path is the absolute file path; Size and ModTime come from os.Stat.
|
||||
type audiobookDiskFile struct {
|
||||
@@ -324,6 +363,16 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if len(parsed.Files) > 0 && s.fileRepo != nil {
|
||||
if _, err := applyAudiobookEmbeddedCover(ctx, s.itemRepo, s.fileRepo.Pool(), FFmpegPathFromFFprobe(s.ffprobePath), s.imageCacher, parsed.Files[0].Path, contentID); err != nil {
|
||||
slog.Warn("audiobook scan: embedded cover failed",
|
||||
"folder_id", folder.ID,
|
||||
"content_id", contentID,
|
||||
"path", parsed.Files[0].Path,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := s.upsertAudiobookPeople(ctx, contentID, parsed); err != nil {
|
||||
return fmt.Errorf("upsert audiobook people: %w", err)
|
||||
}
|
||||
@@ -360,6 +409,42 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyAudiobookEmbeddedCover(
|
||||
ctx context.Context,
|
||||
reader audiobookPosterPathReader,
|
||||
exec audiobookPosterExec,
|
||||
ffmpegPath string,
|
||||
cacher audiobookCoverCacher,
|
||||
audioFilePath string,
|
||||
contentID string,
|
||||
) (bool, error) {
|
||||
if exec == nil {
|
||||
return false, nil
|
||||
}
|
||||
if reader != nil {
|
||||
existingPosterPath, err := reader.GetPosterPath(ctx, contentID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get audiobook poster path for embedded cover: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(existingPosterPath) != "" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
poster, thumb := ExtractAndUploadAudiobookCover(ctx, ffmpegPath, cacher, audioFilePath, contentID)
|
||||
if poster == "" {
|
||||
return false, nil
|
||||
}
|
||||
_, err := exec.Exec(ctx, `
|
||||
UPDATE media_items
|
||||
SET poster_path = $1, poster_thumbhash = $2, updated_at = NOW()
|
||||
WHERE content_id = $3 AND (poster_path IS NULL OR poster_path = '')
|
||||
`, poster, thumb, contentID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("update audiobook embedded cover: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// upsertAudiobookMediaItem reuses an item already linked to the same filesystem
|
||||
// root, then falls back to the stricter ABS duplicate rule, or creates a new row.
|
||||
// It intentionally avoids title/year-only dedupe because audiobooks often share
|
||||
@@ -385,7 +470,7 @@ func (s *Scanner) upsertAudiobookMediaItem(ctx context.Context, folderID int, fo
|
||||
cleanTitle = book.Title
|
||||
}
|
||||
|
||||
if existing := s.findAudiobookByFilePath(ctx, book); existing != nil {
|
||||
if existing := s.findAudiobookByFilePath(ctx, folderID, book); existing != nil {
|
||||
applyBookToMediaItem(existing, book)
|
||||
if existing.SortTitle == "" {
|
||||
existing.SortTitle = titleutil.DeriveDefaultSortTitle(existing.Title)
|
||||
@@ -469,10 +554,10 @@ func createAudiobookMediaItem(ctx context.Context, itemWriter filesystemMediaIte
|
||||
|
||||
// findAudiobookDuplicate returns an existing audiobook media_items row that
|
||||
// matches the parsed book on (author, narrator, year, duration ±0.5%/±10s,
|
||||
// title-prefix). Used after the file-path lookup misses to detect the same
|
||||
// recording stored under a different folder name. Returns nil when no match
|
||||
// exists or any required attribute (author, narrator, year, duration) is
|
||||
// missing — under-tagged files don't qualify for automatic dedup.
|
||||
// punctuation-normalized title). Used after the file-path lookup misses to
|
||||
// detect the same recording stored under a different folder name. Returns nil
|
||||
// when no match exists or any required attribute (author, narrator, year,
|
||||
// duration) is missing — under-tagged files don't qualify for automatic dedup.
|
||||
func (s *Scanner) findAudiobookDuplicate(ctx context.Context, book *parsedAudiobook, cleanTitle string) *models.MediaItem {
|
||||
if s.fileRepo == nil {
|
||||
return nil
|
||||
@@ -491,32 +576,39 @@ func (s *Scanner) findAudiobookDuplicate(ctx context.Context, book *parsedAudiob
|
||||
if tolerance < 10 {
|
||||
tolerance = 10
|
||||
}
|
||||
var existingID string
|
||||
err := s.fileRepo.Pool().QueryRow(ctx, `
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
JOIN item_people ipa ON ipa.content_id = mi.content_id AND ipa.kind = 7
|
||||
JOIN people pa ON pa.id = ipa.person_id AND pa.name = $1
|
||||
JOIN item_people ipn ON ipn.content_id = mi.content_id AND ipn.kind = 8
|
||||
JOIN people pn ON pn.id = ipn.person_id AND pn.name = $2
|
||||
JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(mf.duration), 0) AS dur
|
||||
FROM media_files mf WHERE mf.content_id = mi.content_id
|
||||
) f ON TRUE
|
||||
WHERE mi.type = 'audiobook'
|
||||
AND mi.year = $3
|
||||
AND f.dur > 0
|
||||
AND ABS(f.dur - $4) <= $5
|
||||
AND (
|
||||
LOWER(mi.title) = LOWER($6)
|
||||
OR (LENGTH(mi.title) > LENGTH($6) AND LOWER(mi.title) LIKE LOWER($6) || ':%')
|
||||
OR (LENGTH($6) > LENGTH(mi.title) AND LOWER($6) LIKE LOWER(mi.title) || ':%')
|
||||
)
|
||||
LIMIT 1
|
||||
`, book.Author, book.Narrator, book.Year, totalDuration, tolerance, cleanTitle).Scan(&existingID)
|
||||
if err != nil || existingID == "" {
|
||||
titleKey := normalizeAudiobookDedupeTitle(cleanTitle)
|
||||
if titleKey == "" {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.fileRepo.Pool().Query(ctx,
|
||||
audiobookDuplicateCandidateSQL,
|
||||
book.Author,
|
||||
book.Narrator,
|
||||
book.Year,
|
||||
totalDuration,
|
||||
tolerance,
|
||||
titleKey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var existingID string
|
||||
for rows.Next() {
|
||||
var candidateID, candidateTitle string
|
||||
if err := rows.Scan(&candidateID, &candidateTitle); err != nil {
|
||||
return nil
|
||||
}
|
||||
if audiobookDedupeTitlesMatch(candidateTitle, cleanTitle) {
|
||||
existingID = candidateID
|
||||
break
|
||||
}
|
||||
}
|
||||
if rows.Err() != nil || existingID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
items, err := s.itemRepo.GetByIDs(ctx, []string{existingID})
|
||||
if err != nil || len(items) == 0 {
|
||||
return nil
|
||||
@@ -527,16 +619,11 @@ func (s *Scanner) findAudiobookDuplicate(ctx context.Context, book *parsedAudiob
|
||||
// findAudiobookByFilePath returns an existing audiobook media_items row
|
||||
// whose media_files reference any of this book's audio file paths.
|
||||
// Returns nil when no match exists (a fresh scan of a new folder).
|
||||
func (s *Scanner) findAudiobookByFilePath(ctx context.Context, book *parsedAudiobook) *models.MediaItem {
|
||||
func (s *Scanner) findAudiobookByFilePath(ctx context.Context, folderID int, book *parsedAudiobook) *models.MediaItem {
|
||||
if s.fileRepo == nil || len(book.Files) == 0 {
|
||||
return nil
|
||||
}
|
||||
paths := make([]string, 0, len(book.Files))
|
||||
for _, f := range book.Files {
|
||||
if f.Path != "" {
|
||||
paths = append(paths, f.Path)
|
||||
}
|
||||
}
|
||||
paths := audiobookLookupPaths(book.Files)
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -545,10 +632,11 @@ func (s *Scanner) findAudiobookByFilePath(ctx context.Context, book *parsedAudio
|
||||
SELECT mf.content_id
|
||||
FROM media_files mf
|
||||
JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.file_path = ANY($1)
|
||||
WHERE mf.media_folder_id = $2
|
||||
AND (mf.file_path = ANY($1) OR LOWER(mf.file_path) = ANY($1))
|
||||
AND mi.type = 'audiobook'
|
||||
LIMIT 1
|
||||
`, paths).Scan(&existingID)
|
||||
`, paths, folderID).Scan(&existingID)
|
||||
if err != nil || existingID == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -559,6 +647,28 @@ func (s *Scanner) findAudiobookByFilePath(ctx context.Context, book *parsedAudio
|
||||
return items[0]
|
||||
}
|
||||
|
||||
func audiobookLookupPaths(files []parsedAudiobookFile) []string {
|
||||
seen := make(map[string]struct{}, len(files)*2)
|
||||
paths := make([]string, 0, len(files)*2)
|
||||
for _, f := range files {
|
||||
path := strings.TrimSpace(f.Path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range []string{path, strings.ToLower(path)} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
paths = append(paths, candidate)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// applyBookToMediaItem copies parsed-audiobook tag fields onto the
|
||||
// MediaItem. Used for both fresh inserts and re-scans of existing rows
|
||||
// so manual edits to fields not driven by the file (e.g., poster_path
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
@@ -35,6 +37,139 @@ func (f *fakeFilesystemItemWriter) Upsert(_ context.Context, item *models.MediaI
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAudiobookPosterExec struct {
|
||||
calls int
|
||||
query string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (f *fakeAudiobookPosterExec) Exec(_ context.Context, query string, args ...any) (pgconn.CommandTag, error) {
|
||||
f.calls++
|
||||
f.query = query
|
||||
f.args = append([]any(nil), args...)
|
||||
return pgconn.CommandTag{}, nil
|
||||
}
|
||||
|
||||
type fakeAudiobookPosterReader struct {
|
||||
posterPath string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeAudiobookPosterReader) GetPosterPath(context.Context, string) (string, error) {
|
||||
f.calls++
|
||||
return f.posterPath, f.err
|
||||
}
|
||||
|
||||
type fakeScannerCoverCacher struct {
|
||||
calls int
|
||||
data []byte
|
||||
contentID string
|
||||
}
|
||||
|
||||
func (f *fakeScannerCoverCacher) CacheAudiobookCover(_ context.Context, data []byte, contentID string) (string, string, string, error) {
|
||||
f.calls++
|
||||
f.data = append([]byte(nil), data...)
|
||||
f.contentID = contentID
|
||||
return "local/audiobooks/" + contentID + "/poster", ".webp", "thumbhash", nil
|
||||
}
|
||||
|
||||
func TestApplyAudiobookEmbeddedCoverStoresPosterDuringScan(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ffmpegPath := filepath.Join(dir, "ffmpeg")
|
||||
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nprintf cover-bytes\n"), 0o755); err != nil {
|
||||
t.Fatalf("write fake ffmpeg: %v", err)
|
||||
}
|
||||
|
||||
exec := &fakeAudiobookPosterExec{}
|
||||
cacher := &fakeScannerCoverCacher{}
|
||||
|
||||
applied, err := applyAudiobookEmbeddedCover(
|
||||
context.Background(),
|
||||
&fakeAudiobookPosterReader{},
|
||||
exec,
|
||||
ffmpegPath,
|
||||
cacher,
|
||||
"/library/Author/Book/book.m4b",
|
||||
"content-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("applyAudiobookEmbeddedCover: %v", err)
|
||||
}
|
||||
if !applied {
|
||||
t.Fatal("applied = false, want true")
|
||||
}
|
||||
if string(cacher.data) != "cover-bytes" || cacher.contentID != "content-1" {
|
||||
t.Fatalf("cacher got data=%q contentID=%q", string(cacher.data), cacher.contentID)
|
||||
}
|
||||
if exec.calls != 1 {
|
||||
t.Fatalf("Exec calls = %d, want 1", exec.calls)
|
||||
}
|
||||
if len(exec.args) != 3 {
|
||||
t.Fatalf("Exec args = %#v, want 3 args", exec.args)
|
||||
}
|
||||
if exec.args[0] != "local/audiobooks/content-1/poster/original.webp" {
|
||||
t.Fatalf("poster arg = %#v", exec.args[0])
|
||||
}
|
||||
if exec.args[1] != "thumbhash" || exec.args[2] != "content-1" {
|
||||
t.Fatalf("thumb/content args = %#v", exec.args)
|
||||
}
|
||||
if !strings.Contains(exec.query, "poster_path = $1") {
|
||||
t.Fatalf("query did not update poster_path: %s", exec.query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAudiobookEmbeddedCoverPreservesExistingPoster(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ffmpegPath := filepath.Join(dir, "ffmpeg")
|
||||
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nprintf cover-bytes\n"), 0o755); err != nil {
|
||||
t.Fatalf("write fake ffmpeg: %v", err)
|
||||
}
|
||||
|
||||
reader := &fakeAudiobookPosterReader{posterPath: "local/audiobooks/content-1/poster/original.webp"}
|
||||
exec := &fakeAudiobookPosterExec{}
|
||||
cacher := &fakeScannerCoverCacher{}
|
||||
|
||||
applied, err := applyAudiobookEmbeddedCover(
|
||||
context.Background(),
|
||||
reader,
|
||||
exec,
|
||||
ffmpegPath,
|
||||
cacher,
|
||||
"/library/Author/Book/book.m4b",
|
||||
"content-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("applyAudiobookEmbeddedCover: %v", err)
|
||||
}
|
||||
if applied {
|
||||
t.Fatal("applied = true, want false")
|
||||
}
|
||||
if reader.calls != 1 {
|
||||
t.Fatalf("GetPosterPath calls = %d, want 1", reader.calls)
|
||||
}
|
||||
if cacher.calls != 0 {
|
||||
t.Fatalf("cache calls = %d, want 0", cacher.calls)
|
||||
}
|
||||
if exec.calls != 0 {
|
||||
t.Fatalf("Exec calls = %d, want 0", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookDuplicateCandidateSQLFiltersTitleBeforeLimit(t *testing.T) {
|
||||
titlePredicate := strings.Index(audiobookDuplicateCandidateSQL, "normalized_title = $6")
|
||||
limit := strings.Index(audiobookDuplicateCandidateSQL, "LIMIT")
|
||||
if titlePredicate == -1 {
|
||||
t.Fatalf("expected SQL to filter on normalized_title, got:\n%s", audiobookDuplicateCandidateSQL)
|
||||
}
|
||||
if limit == -1 {
|
||||
t.Fatalf("expected SQL to keep a bounded candidate query, got:\n%s", audiobookDuplicateCandidateSQL)
|
||||
}
|
||||
if titlePredicate > limit {
|
||||
t.Fatalf("title predicate appears after LIMIT, got:\n%s", audiobookDuplicateCandidateSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopulateFromTags_SeriesDoesNotFallBackToAlbum(t *testing.T) {
|
||||
// In audiobook tagging the `album` tag holds the book title, not the
|
||||
// series name. populateFromTags must NOT use it as a series fallback,
|
||||
@@ -371,6 +506,43 @@ func TestStripNarratorSuffix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookDedupeTitlesMatchPunctuationVariants(t *testing.T) {
|
||||
if !audiobookDedupeTitlesMatch(
|
||||
"Witch vs. Witch: F/F Paranormal Romance Novella",
|
||||
"Witch vs. Witch - F/F Paranormal Romance Novella",
|
||||
) {
|
||||
t.Fatal("expected punctuation-only title variants to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookDedupeTitlesKeepDifferentBooksSeparate(t *testing.T) {
|
||||
if audiobookDedupeTitlesMatch(
|
||||
"Adventure of Constance Verity 1 - The Last Adventure of Constance Verity",
|
||||
"Adventure of Constance Verity 2 - Constance Verity Saves the World",
|
||||
) {
|
||||
t.Fatal("expected different series entries to remain separate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookLookupPathsIncludeCaseFoldedVariants(t *testing.T) {
|
||||
got := audiobookLookupPaths([]parsedAudiobookFile{
|
||||
{Path: "/Library/A. A. Milne/Winnie-The-Pooh.m4b"},
|
||||
{Path: ""},
|
||||
})
|
||||
want := []string{
|
||||
"/Library/A. A. Milne/Winnie-The-Pooh.m4b",
|
||||
"/library/a. a. milne/winnie-the-pooh.m4b",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("paths = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("paths[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookFolderUnchangedAllMatch(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
files := []*models.MediaFile{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- +goose NO TRANSACTION
|
||||
|
||||
-- +goose Up
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_files_folder_lower_file_path
|
||||
ON media_files (media_folder_id, lower(file_path));
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_media_files_folder_lower_file_path;
|
||||
Reference in New Issue
Block a user