Skip to content

Commit

Permalink
refactor podcast schema and generate unique episode paths (#373)
Browse files Browse the repository at this point in the history
closes #350
  • Loading branch information
sentriz authored Sep 20, 2023
1 parent c13d172 commit 33f1f2e
Show file tree
Hide file tree
Showing 15 changed files with 400 additions and 192 deletions.
103 changes: 100 additions & 3 deletions db/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"

"github.com/jinzhu/gorm"
"go.senan.xyz/gonic/fileutil"
"go.senan.xyz/gonic/playlist"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"gopkg.in/gormigrate.v1"
Expand Down Expand Up @@ -59,6 +61,7 @@ func (db *DB) Migrate(ctx MigrationContext) error {
construct(ctx, "202307281628", migrateAlbumArtistsMany2Many),
construct(ctx, "202309070009", migrateDeleteArtistCoverField),
construct(ctx, "202309131743", migrateArtistInfo),
construct(ctx, "202309161411", migratePlaylistsPaths),
}

return gormigrate.
Expand Down Expand Up @@ -478,11 +481,11 @@ func migratePlaylistsToM3U(tx *gorm.DB, ctx MigrationContext) error {
return track.AbsPath()
case specid.PodcastEpisode:
var pe PodcastEpisode
tx.Where("id=?", id.Value).Find(&pe)
if pe.Path == "" {
tx.Where("id=?", id.Value).Preload("Podcast").Find(&pe)
if pe.Filename == "" {
return ""
}
return filepath.Join(ctx.PodcastsPath, pe.Path)
return filepath.Join(ctx.PodcastsPath, fileutil.Safe(pe.Podcast.Title), pe.Filename)
}
return ""
}
Expand Down Expand Up @@ -613,3 +616,97 @@ func migrateArtistInfo(tx *gorm.DB, _ MigrationContext) error {
).
Error
}

func migratePlaylistsPaths(tx *gorm.DB, ctx MigrationContext) error {
if !tx.Dialect().HasColumn("podcast_episodes", "path") {
return nil
}
if !tx.Dialect().HasColumn("podcasts", "image_path") {
return nil
}

step := tx.Exec(`
ALTER TABLE podcasts RENAME COLUMN image_path TO image
`)
if err := step.Error; err != nil {
return fmt.Errorf("step drop podcast_episodes path: %w", err)
}

step = tx.AutoMigrate(
Podcast{},
PodcastEpisode{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}

var podcasts []*Podcast
if err := tx.Find(&podcasts).Error; err != nil {
return fmt.Errorf("step load: %w", err)
}

for _, p := range podcasts {
p.Image = filepath.Base(p.Image)
if err := tx.Save(p).Error; err != nil {
return fmt.Errorf("saving podcast for cover %d: %w", p.ID, err)
}

oldPath, err := fileutil.First(
filepath.Join(ctx.PodcastsPath, fileutil.Safe(p.Title)),
filepath.Join(ctx.PodcastsPath, strings.ReplaceAll(p.Title, string(filepath.Separator), "_")), // old safe func
filepath.Join(ctx.PodcastsPath, p.Title),
)
if err != nil {
return fmt.Errorf("find old podcast path: %w", err)
}
newPath := filepath.Join(ctx.PodcastsPath, fileutil.Safe(p.Title))
p.RootDir = newPath
if err := tx.Save(p).Error; err != nil {
return fmt.Errorf("saving podcast %d: %w", p.ID, err)
}
if oldPath == newPath {
continue
}
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("rename podcast path: %w", err)
}
}

var podcastEpisodes []*PodcastEpisode
if err := tx.Preload("Podcast").Find(&podcastEpisodes, "status=? OR status=?", PodcastEpisodeStatusCompleted, PodcastEpisodeStatusDownloading).Error; err != nil {
return fmt.Errorf("step load: %w", err)
}
for _, pe := range podcastEpisodes {
if pe.Filename == "" {
continue
}
oldPath, err := fileutil.First(
filepath.Join(pe.Podcast.RootDir, fileutil.Safe(pe.Filename)),
filepath.Join(pe.Podcast.RootDir, strings.ReplaceAll(pe.Filename, string(filepath.Separator), "_")), // old safe func
filepath.Join(pe.Podcast.RootDir, pe.Filename),
)
if err != nil {
return fmt.Errorf("find old podcast episode path: %w", err)
}
newName := fileutil.Safe(filepath.Base(oldPath))
pe.Filename = newName
if err := tx.Save(pe).Error; err != nil {
return fmt.Errorf("saving podcast episode %d: %w", pe.ID, err)
}
newPath := filepath.Join(pe.Podcast.RootDir, newName)
if oldPath == newPath {
continue
}
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("rename podcast episode path: %w", err)
}
}

step = tx.Exec(`
ALTER TABLE podcast_episodes DROP COLUMN path
`)
if err := step.Error; err != nil {
return fmt.Errorf("step drop podcast_episodes path: %w", err)
}
return nil
}
16 changes: 9 additions & 7 deletions db/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ package db

import (
"fmt"
"path"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -134,7 +133,7 @@ func (t *Track) AbsPath() string {
if t.Album == nil {
return ""
}
return path.Join(
return filepath.Join(
t.Album.RootDir,
t.Album.LeftPath,
t.Album.RightPath,
Expand All @@ -146,7 +145,7 @@ func (t *Track) RelPath() string {
if t.Album == nil {
return ""
}
return path.Join(
return filepath.Join(
t.Album.LeftPath,
t.Album.RightPath,
t.Filename,
Expand Down Expand Up @@ -354,10 +353,11 @@ type Podcast struct {
Title string
Description string
ImageURL string
ImagePath string
Image string
Error string
Episodes []*PodcastEpisode
AutoDownload PodcastAutoDownload
RootDir string
}

func (p *Podcast) SID() *specid.ID {
Expand Down Expand Up @@ -387,11 +387,10 @@ type PodcastEpisode struct {
Bitrate int
Length int
Size int
Path string
Filename string
Status PodcastEpisodeStatus
Error string
AbsP string `gorm:"-"` // TODO: not this. instead we need some consistent way to get the AbsPath for both tracks and podcast episodes. or just files in general
Podcast *Podcast
}

func (pe *PodcastEpisode) AudioLength() int { return pe.Length }
Expand All @@ -418,7 +417,10 @@ func (pe *PodcastEpisode) MIME() string {
}

func (pe *PodcastEpisode) AbsPath() string {
return pe.AbsP
if pe.Podcast == nil {
return ""
}
return filepath.Join(pe.Podcast.RootDir, pe.Filename)
}

type Bookmark struct {
Expand Down
56 changes: 56 additions & 0 deletions fileutil/fileutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// TODO: this package shouldn't really exist. we can usually just attempt our normal filesystem operations
// and handle errors atomically. eg.
// - Safe could instead be try create file, handle error
// - Unique could be try create file, on err create file (1), etc
package fileutil

import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)

var nonAlphaNumExpr = regexp.MustCompile("[^a-zA-Z0-9_.]+")

func Safe(filename string) string {
filename = nonAlphaNumExpr.ReplaceAllString(filename, "")
return filename
}

// try to find a unqiue file (or dir) name. incrementing like "example (1)"
func Unique(base, filename string) (string, error) {
return unique(base, filename, 0)
}

func unique(base, filename string, count uint) (string, error) {
var suffix string
if count > 0 {
suffix = fmt.Sprintf(" (%d)", count)
}
path := base + suffix
if filename != "" {
noExt := strings.TrimSuffix(filename, filepath.Ext(filename))
path = filepath.Join(base, noExt+suffix+filepath.Ext(filename))
}
_, err := os.Stat(path)
if os.IsNotExist(err) {
return path, nil
}
if err != nil {
return "", err
}
return unique(base, filename, count+1)
}

func First(path ...string) (string, error) {
var err error
for _, p := range path {
_, err = os.Stat(p)
if err == nil {
return p, nil
}
}
return "", err
}
56 changes: 56 additions & 0 deletions fileutil/fileutil_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package fileutil

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func TestUniquePath(t *testing.T) {
unq := func(base, filename string, count uint) string {
r, err := unique(base, filename, count)
require.NoError(t, err)
return r
}

require.Equal(t, "test/wow.mp3", unq("test", "wow.mp3", 0))
require.Equal(t, "test/wow (1).mp3", unq("test", "wow.mp3", 1))
require.Equal(t, "test/wow (2).mp3", unq("test", "wow.mp3", 2))

require.Equal(t, "test", unq("test", "", 0))
require.Equal(t, "test (1)", unq("test", "", 1))

base := filepath.Join(t.TempDir(), "a")

require.NoError(t, os.MkdirAll(base, os.ModePerm))

next := base + " (1)"
require.Equal(t, next, unq(base, "", 0))

require.NoError(t, os.MkdirAll(next, os.ModePerm))

next = base + " (2)"
require.Equal(t, next, unq(base, "", 0))

_, err := os.Create(filepath.Join(base, "test.mp3"))
require.NoError(t, err)
require.Equal(t, filepath.Join(base, "test (1).mp3"), unq(base, "test.mp3", 0))
}

func TestFirst(t *testing.T) {
base := t.TempDir()
name := filepath.Join(base, "test")
_, err := os.Create(name)
require.NoError(t, err)

p := func(name string) string {
return filepath.Join(base, name)
}

r, err := First(p("one"), p("two"), p("test"), p("four"))
require.NoError(t, err)
require.Equal(t, p("test"), r)

}
Loading

0 comments on commit 33f1f2e

Please sign in to comment.