diff --git a/Dockerfile b/Dockerfile index d939c77..feb9c9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,5 +39,6 @@ ENV GONIC_LISTEN_ADDR :80 ENV GONIC_MUSIC_PATH /music ENV GONIC_PODCAST_PATH /podcasts ENV GONIC_CACHE_PATH /cache +ENV GONIC_PLAYLISTS_PATH /playlists ENTRYPOINT ["/sbin/tini", "--"] CMD ["gonic"] diff --git a/Dockerfile.dev b/Dockerfile.dev index cd6e240..b75e0a3 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -33,5 +33,6 @@ ENV GONIC_DB_PATH /data/gonic.db ENV GONIC_LISTEN_ADDR :80 ENV GONIC_MUSIC_PATH /music ENV GONIC_PODCAST_PATH /podcasts +ENV GONIC_PLAYLISTS_PATH /playlists ENV GONIC_CACHE_PATH /cache CMD ["gonic"] diff --git a/README.md b/README.md index 228ad05..13dfcbb 100644 --- a/README.md +++ b/README.md @@ -55,25 +55,26 @@ password can then be changed from the web interface ## configuration options -| env var | command line arg | description | -| ------------------------------ | ------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `GONIC_MUSIC_PATH` | `-music-path` | path to your music collection (see also multi-folder support below) | -| `GONIC_PODCAST_PATH` | `-podcast-path` | path to a podcasts directory | -| `GONIC_CACHE_PATH` | `-cache-path` | path to store audio transcodes, covers, etc | -| `GONIC_DB_PATH` | `-db-path` | **optional** path to database file | -| `GONIC_HTTP_LOG` | `-http-log` | **optional** http request logging, enabled by default | -| `GONIC_LISTEN_ADDR` | `-listen-addr` | **optional** host and port to listen on (eg. `0.0.0.0:4747`, `127.0.0.1:4747`) (_default_ `0.0.0.0:4747`) | -| `GONIC_TLS_CERT` | `-tls-cert` | **optional** path to a TLS cert (enables HTTPS listening) | -| `GONIC_TLS_KEY` | `-tls-key` | **optional** path to a TLS key (enables HTTPS listening) | -| `GONIC_PROXY_PREFIX` | `-proxy-prefix` | **optional** url path prefix to use if behind reverse proxy. eg `/gonic` (see example configs below) | -| `GONIC_SCAN_INTERVAL` | `-scan-interval` | **optional** interval (in minutes) to check for new music (automatic scanning disabled if omitted) | -| `GONIC_SCAN_AT_START_ENABLED` | `-scan-at-start-enabled` | **optional** whether to perform an initial scan at startup | -| `GONIC_SCAN_WATCHER_ENABLED` | `-scan-watcher-enabled` | **optional** whether to watch file system for new music and rescan | -| `GONIC_JUKEBOX_ENABLED` | `-jukebox-enabled` | **optional** whether the subsonic [jukebox api](https://airsonic.github.io/docs/jukebox/) should be enabled | -| `GONIC_JUKEBOX_MPV_EXTRA_ARGS` | `-jukebox-mpv-extra-args` | **optional** extra command line arguments to pass to the jukebox mpv daemon | -| `GONIC_PODCAST_PURGE_AGE` | `-podcast-purge-age` | **optional** age (in days) to purge podcast episodes if not accessed | -| `GONIC_GENRE_SPLIT` | `-genre-split` | **optional** a string or character to split genre tags on for multi-genre support (eg. `;`) | -| `GONIC_EXCLUDE_PATTERN` | `-exclude-pattern` | **optional** files matching this regex pattern will not be imported | +| env var | command line arg | description | +| ------------------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GONIC_MUSIC_PATH` | `-music-path` | path to your music collection (see also multi-folder support below) | +| `GONIC_PODCAST_PATH` | `-podcast-path` | path to a podcasts directory | +| `GONIC_PLAYLISTS_PATH` | `-playlists-path` | path to new or existing directory with m3u files for subsonic playlists. items in the directory should be in the format `/.m3u`. for example the admin user could have `1/my-playlist.m3u`. gonic create and make changes to these playlists over the subsonic api. | +| `GONIC_CACHE_PATH` | `-cache-path` | path to store audio transcodes, covers, etc | +| `GONIC_DB_PATH` | `-db-path` | **optional** path to database file | +| `GONIC_HTTP_LOG` | `-http-log` | **optional** http request logging, enabled by default | +| `GONIC_LISTEN_ADDR` | `-listen-addr` | **optional** host and port to listen on (eg. `0.0.0.0:4747`, `127.0.0.1:4747`) (_default_ `0.0.0.0:4747`) | +| `GONIC_TLS_CERT` | `-tls-cert` | **optional** path to a TLS cert (enables HTTPS listening) | +| `GONIC_TLS_KEY` | `-tls-key` | **optional** path to a TLS key (enables HTTPS listening) | +| `GONIC_PROXY_PREFIX` | `-proxy-prefix` | **optional** url path prefix to use if behind reverse proxy. eg `/gonic` (see example configs below) | +| `GONIC_SCAN_INTERVAL` | `-scan-interval` | **optional** interval (in minutes) to check for new music (automatic scanning disabled if omitted) | +| `GONIC_SCAN_AT_START_ENABLED` | `-scan-at-start-enabled` | **optional** whether to perform an initial scan at startup | +| `GONIC_SCAN_WATCHER_ENABLED` | `-scan-watcher-enabled` | **optional** whether to watch file system for new music and rescan | +| `GONIC_JUKEBOX_ENABLED` | `-jukebox-enabled` | **optional** whether the subsonic [jukebox api](https://airsonic.github.io/docs/jukebox/) should be enabled | +| `GONIC_JUKEBOX_MPV_EXTRA_ARGS` | `-jukebox-mpv-extra-args` | **optional** extra command line arguments to pass to the jukebox mpv daemon | +| `GONIC_PODCAST_PURGE_AGE` | `-podcast-purge-age` | **optional** age (in days) to purge podcast episodes if not accessed | +| `GONIC_GENRE_SPLIT` | `-genre-split` | **optional** a string or character to split genre tags on for multi-genre support (eg. `;`) | +| `GONIC_EXCLUDE_PATTERN` | `-exclude-pattern` | **optional** files matching this regex pattern will not be imported | ## screenshots diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index db302ae..18dcb47 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -45,6 +45,8 @@ func main() { var confMusicPaths pathAliases set.Var(&confMusicPaths, "music-path", "path to music") + confPlaylistsPath := set.String("playlists-path", "", "path to your list of new or existing m3u playlists that gonic can manage") + confDBPath := set.String("db-path", "gonic.db", "path to database (optional)") confScanIntervalMins := set.Int("scan-interval", 0, "interval (in minutes) to automatically scan music (optional)") @@ -98,6 +100,9 @@ func main() { if *confCachePath, err = validatePath(*confCachePath); err != nil { log.Fatalf("checking cache directory: %v", err) } + if *confPlaylistsPath, err = validatePath(*confPlaylistsPath); err != nil { + log.Fatalf("checking playlist directory: %v", err) + } cacheDirAudio := path.Join(*confCachePath, "audio") cacheDirCovers := path.Join(*confCachePath, "covers") @@ -116,6 +121,8 @@ func main() { err = dbc.Migrate(db.MigrationContext{ OriginalMusicPath: confMusicPaths[0].path, + PlaylistsPath: *confPlaylistsPath, + PodcastsPath: *confPodcastPath, }) if err != nil { log.Panicf("error migrating database: %v\n", err) @@ -135,6 +142,7 @@ func main() { CacheAudioPath: cacheDirAudio, CoverCachePath: cacheDirCovers, PodcastPath: *confPodcastPath, + PlaylistsPath: *confPlaylistsPath, ProxyPrefix: *confProxyPrefix, GenreSplit: *confGenreSplit, HTTPLog: *confHTTPLog, diff --git a/db/migrations.go b/db/migrations.go index ae180aa..e14502d 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -1,16 +1,23 @@ +//nolint:goerr113 package db import ( "errors" "fmt" "log" + "path/filepath" + "time" "github.com/jinzhu/gorm" + "go.senan.xyz/gonic/playlist" + "go.senan.xyz/gonic/server/ctrlsubsonic/specid" "gopkg.in/gormigrate.v1" ) type MigrationContext struct { OriginalMusicPath string + PlaylistsPath string + PodcastsPath string } func (db *DB) Migrate(ctx MigrationContext) error { @@ -46,6 +53,7 @@ func (db *DB) Migrate(ctx MigrationContext) error { construct(ctx, "202206101425", migrateUser), construct(ctx, "202207251148", migrateStarRating), construct(ctx, "202211111057", migratePlaylistsQueuesToFullID), + construct(ctx, "202304221528", migratePlaylistsToM3U), } return gormigrate. @@ -82,7 +90,6 @@ func migrateInitSchema(tx *gorm.DB, _ MigrationContext) error { Setting{}, Play{}, Album{}, - Playlist{}, PlayQueue{}, ). Error @@ -110,6 +117,9 @@ func migrateCreateInitUser(tx *gorm.DB, _ MigrationContext) error { } func migrateMergePlaylist(tx *gorm.DB, _ MigrationContext) error { + if !tx.HasTable("playlists") { + return nil + } if !tx.HasTable("playlist_items") { return nil } @@ -335,7 +345,10 @@ func migrateAlbumRootDirAgain(tx *gorm.DB, ctx MigrationContext) error { } func migratePublicPlaylist(tx *gorm.DB, ctx MigrationContext) error { - return tx.AutoMigrate(Playlist{}).Error + if !tx.HasTable("playlists") { + return nil + } + return tx.AutoMigrate(_OldPlaylist{}).Error } func migratePodcastDropUserID(tx *gorm.DB, _ MigrationContext) error { @@ -389,6 +402,10 @@ func migrateStarRating(tx *gorm.DB, _ MigrationContext) error { } func migratePlaylistsQueuesToFullID(tx *gorm.DB, _ MigrationContext) error { + if !tx.HasTable("playlists") { + return nil + } + step := tx.Exec(` UPDATE playlists SET items=('tr-' || items) WHERE items IS NOT NULL; `) @@ -441,3 +458,61 @@ func migratePlaylistsQueuesToFullID(tx *gorm.DB, _ MigrationContext) error { return nil } + +func migratePlaylistsToM3U(tx *gorm.DB, ctx MigrationContext) error { + if ctx.PlaylistsPath == "" || !tx.HasTable("playlists") { + return nil + } + + // local copy of specidpaths.Locate to avoid circular dep + locate := func(id specid.ID) string { + switch id.Type { + case specid.Track: + var track Track + tx.Preload("Album").Where("id=?", id.Value).Find(&track) + return track.AbsPath() + case specid.PodcastEpisode: + var pe PodcastEpisode + tx.Where("id=?", id.Value).Find(&pe) + if pe.Path == "" { + return "" + } + return filepath.Join(ctx.PodcastsPath, pe.Path) + } + return "" + } + + store, err := playlist.NewStore(ctx.PlaylistsPath) + if err != nil { + return fmt.Errorf("create playlists store: %w", err) + } + + var prevs []*_OldPlaylist + if err := tx.Find(&prevs).Error; err != nil { + return fmt.Errorf("fetch old playlists: %w", err) + } + + for _, prev := range prevs { + var pl playlist.Playlist + pl.UpdatedAt = time.Now() + pl.UserID = prev.UserID + pl.Name = prev.Name + pl.Comment = prev.Comment + pl.IsPublic = prev.IsPublic + + for _, id := range splitIDs(prev.Items, ",") { + path := locate(id) + if path == "" { + log.Printf("migrating: can't find item %s from playlist %q on filesystem", id, prev.Name) + continue + } + pl.Items = append(pl.Items, path) + } + + if err := store.Write(playlist.NewPath(prev.UserID, prev.Name), &pl); err != nil { + return fmt.Errorf("write playlist: %w", err) + } + } + + return nil +} diff --git a/db/migrations_old_models.go b/db/migrations_old_models.go new file mode 100644 index 0000000..ea727aa --- /dev/null +++ b/db/migrations_old_models.go @@ -0,0 +1,20 @@ +package db + +import "time" + +type _OldPlaylist struct { + ID int `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + User *User + UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` + Name string + Comment string + TrackCount int + Items string + IsPublic bool `sql:"default: null"` +} + +func (_OldPlaylist) TableName() string { + return "playlists" +} diff --git a/db/model.go b/db/model.go index a6eca17..7d65891 100644 --- a/db/model.go +++ b/db/model.go @@ -242,28 +242,6 @@ func (a *Album) GenreStrings() []string { return strs } -type Playlist struct { - ID int `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - User *User - UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` - Name string - Comment string - TrackCount int - Items string - IsPublic bool `sql:"default: null"` -} - -func (p *Playlist) GetItems() []specid.ID { - return splitIDs(p.Items, ",") -} - -func (p *Playlist) SetItems(items []specid.ID) { - p.Items = joinIds(items, ",") - p.TrackCount = len(items) -} - type PlayQueue struct { ID int `gorm:"primary_key"` CreatedAt time.Time diff --git a/playlist/playlist.go b/playlist/playlist.go new file mode 100644 index 0000000..bb8dd91 --- /dev/null +++ b/playlist/playlist.go @@ -0,0 +1,243 @@ +package playlist + +import ( + "bufio" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +var ErrInvalidPathFormat = errors.New("invalid path format") +var ErrInvalidBasePath = errors.New("invalid base path") +var ErrNoUserPrefix = errors.New("no user prefix") + +const ( + extM3U = ".m3u" + extM3U8 = ".m3u8" +) + +type Store struct { + basePath string + mu sync.Mutex +} + +func NewStore(basePath string) (*Store, error) { + if basePath == "" { + return nil, ErrInvalidBasePath + + } + + // sanity check layout, just in case someone tries to use an existing folder + entries, err := os.ReadDir(basePath) + if err != nil { + return nil, fmt.Errorf("sanity checking: reading dir: %w", err) + } + var found string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if _, err := userIDFromPath(entry.Name()); err != nil { + found = entry.Name() + break + } + } + if found != "" { + return nil, fmt.Errorf("sanity checking: %w: item %q in playlists directory is not a user id. see wiki for details on layout of the playlists dir", ErrNoUserPrefix, found) + } + + return &Store{ + basePath: basePath, + }, nil +} + +type Playlist struct { + UpdatedAt time.Time + UserID int + Name string + Comment string + Items []string + IsPublic bool +} + +func NewPath(userID int, playlistName string) string { + playlistName = safeFilename(playlistName) + if playlistName == "" { + playlistName = "pl" + } + playlistName = fmt.Sprintf("%s-%d%s", playlistName, time.Now().UnixMilli(), extM3U) + return filepath.Join(fmt.Sprint(userID), playlistName) +} + +// List finds playlist items in s.basePath. +// the expected format is //**/.m3u +func (s *Store) List() ([]string, error) { + var relPaths []string + return relPaths, filepath.WalkDir(s.basePath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + switch filepath.Ext(path) { + case extM3U, extM3U8: + default: + return nil + } + relPath, _ := filepath.Rel(s.basePath, path) + relPaths = append(relPaths, relPath) + return nil + }) +} + +const ( + attrPrefix = "#GONIC-" + attrName = "NAME" + attrCommment = "COMMENT" + attrIsPublic = "IS-PUBLIC" +) + +func encodeAttr(name, value string) string { + return fmt.Sprintf("%s%s:%s", attrPrefix, name, strconv.Quote(value)) +} +func decodeAttr(line string) (name, value string) { + if !strings.HasPrefix(line, attrPrefix) { + return "", "" + } + prefixAndName, rawValue, _ := strings.Cut(line, ":") + name = strings.TrimPrefix(prefixAndName, attrPrefix) + value, _ = strconv.Unquote(rawValue) + return name, value +} + +func (s *Store) Read(relPath string) (*Playlist, error) { + defer lock(&s.mu)() + + absPath := filepath.Join(s.basePath, relPath) + stat, err := os.Stat(absPath) + if err != nil { + return nil, fmt.Errorf("stat m3u: %w", err) + } + + var playlist Playlist + playlist.UpdatedAt = stat.ModTime() + + playlist.UserID, err = userIDFromPath(relPath) + if err != nil { + return nil, fmt.Errorf("convert id to str: %w", err) + } + + playlist.Name = strings.TrimSuffix(filepath.Base(relPath), filepath.Ext(relPath)) + + file, err := os.Open(absPath) + if err != nil { + return nil, fmt.Errorf("open m3u: %w", err) + } + defer file.Close() + + for sc := bufio.NewScanner(file); sc.Scan(); { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + switch name, value := decodeAttr(line); name { + case attrName: + playlist.Name = value + case attrCommment: + playlist.Comment = value + case attrIsPublic: + playlist.IsPublic, _ = strconv.ParseBool(value) + } + if strings.HasPrefix(line, "#") { + continue + } + playlist.Items = append(playlist.Items, line) + } + + return &playlist, nil +} + +func (s *Store) Write(relPath string, playlist *Playlist) error { + defer lock(&s.mu)() + + absPath := filepath.Join(s.basePath, relPath) + if err := os.MkdirAll(filepath.Dir(absPath), 0777); err != nil { + return fmt.Errorf("make m3u base dir: %w", err) + } + file, err := os.OpenFile(absPath, os.O_RDWR|os.O_CREATE, 0666) + if err != nil { + return fmt.Errorf("create m3u: %w", err) + } + defer file.Close() + + if err := os.Chtimes(absPath, time.Time{}, playlist.UpdatedAt); err != nil { + return fmt.Errorf("touch m3u: %w", err) + } + + var existingComments []string + for sc := bufio.NewScanner(file); sc.Scan(); { + line := strings.TrimSpace(sc.Text()) + if strings.HasPrefix(line, attrPrefix) { + continue + } + if strings.HasPrefix(line, "#") { + existingComments = append(existingComments, sc.Text()) + } + } + + if _, err := file.Seek(0, 0); err != nil { + return fmt.Errorf("seek m3u: %w", err) + } + if err := file.Truncate(0); err != nil { + return fmt.Errorf("truncate m3u: %w", err) + } + + for _, line := range existingComments { + fmt.Fprintln(file, line) + } + fmt.Fprintln(file, encodeAttr(attrName, playlist.Name)) + fmt.Fprintln(file, encodeAttr(attrCommment, playlist.Comment)) + fmt.Fprintln(file, encodeAttr(attrIsPublic, fmt.Sprint(playlist.IsPublic))) + for _, line := range playlist.Items { + fmt.Fprintln(file, line) + } + + return nil +} + +func (s *Store) Delete(relPath string) error { + return os.Remove(filepath.Join(s.basePath, relPath)) +} + +var nonAlphaNum = regexp.MustCompile("[^a-zA-Z0-9_.]+") + +func safeFilename(filename string) string { + filename = nonAlphaNum.ReplaceAllString(filename, "") + return filename +} + +func firstPathEl(path string) string { + path = strings.TrimPrefix(path, string(filepath.Separator)) + parts := strings.Split(path, string(filepath.Separator)) + if len(parts) == 0 { + return "" + } + return parts[0] +} + +func userIDFromPath(relPath string) (int, error) { + return strconv.Atoi(firstPathEl(relPath)) +} + +func lock(mu *sync.Mutex) func() { + mu.Lock() + return mu.Unlock +} diff --git a/playlist/playlist_test.go b/playlist/playlist_test.go new file mode 100644 index 0000000..4783f3a --- /dev/null +++ b/playlist/playlist_test.go @@ -0,0 +1,57 @@ +package playlist_test + +import ( + "testing" + + "github.com/matryer/is" + "go.senan.xyz/gonic/playlist" +) + +func TestPlaylist(t *testing.T) { + is := is.New(t) + + tmp := t.TempDir() + store, err := playlist.NewStore(tmp) + is.NoErr(err) + + playlistIDs, err := store.List() + is.NoErr(err) + is.True(len(playlistIDs) == 0) + + for _, playlistID := range playlistIDs { + playlist, err := store.Read(playlistID) + is.NoErr(err) + is.True(!playlist.UpdatedAt.IsZero()) + } + + before := playlist.Playlist{ + UserID: 10, + Name: "Examlpe playlist name", + Comment: ` +Example comment +It has multiple lines 👍 +`, + Items: []string{ + "item 1.flac", + "item 2.flac", + "item 3.flac", + }, + IsPublic: true, + } + + newPath := playlist.NewPath(before.UserID, before.Name) + is.NoErr(store.Write(newPath, &before)) + + after, err := store.Read(newPath) + is.NoErr(err) + + is.Equal(before.UserID, after.UserID) + is.Equal(before.Name, after.Name) + is.Equal(before.Comment, after.Comment) + is.Equal(before.Items, after.Items) + is.Equal(before.IsPublic, after.IsPublic) + + playlistIDs, err = store.List() + is.NoErr(err) + is.True(len(playlistIDs) == 1) +} diff --git a/podcasts/podcasts.go b/podcasts/podcasts.go index e131438..dcaaa94 100644 --- a/podcasts/podcasts.go +++ b/podcasts/podcasts.go @@ -10,6 +10,7 @@ import ( "os" "path" "path/filepath" + "regexp" "strconv" "strings" "time" @@ -385,7 +386,7 @@ func (p *Podcasts) DownloadEpisode(episodeID int) error { return fmt.Errorf("create audio file: %w", err) } podcastEpisode.Filename = filename - podcastEpisode.Path = path.Join(pathSafe(podcast.Title), filename) + podcastEpisode.Path = path.Join(safeFilename(podcast.Title), filename) p.db.Save(&podcastEpisode) go func() { if err := p.doPodcastDownload(&podcastEpisode, audioFile, resp.Body); err != nil { @@ -400,7 +401,7 @@ func (p *Podcasts) findUniqueEpisodeName(podcast *db.Podcast, podcastEpisode *db if _, err := os.Stat(podcastPath); os.IsNotExist(err) { return filename } - titlePath := fmt.Sprintf("%s%s", pathSafe(podcastEpisode.Title), filepath.Ext(filename)) + titlePath := fmt.Sprintf("%s%s", safeFilename(podcastEpisode.Title), filepath.Ext(filename)) podcastPath = path.Join(absPath(p.baseDir, podcast), titlePath) if _, err := os.Stat(podcastPath); os.IsNotExist(err) { return titlePath @@ -456,7 +457,7 @@ func (p *Podcasts) downloadPodcastCover(podPath string, podcast *db.Podcast) err if _, err := io.Copy(coverFile, resp.Body); err != nil { return fmt.Errorf("writing podcast cover: %w", err) } - podcast.ImagePath = path.Join(pathSafe(podcast.Title), fmt.Sprintf("cover%s", ext)) + podcast.ImagePath = path.Join(safeFilename(podcast.Title), fmt.Sprintf("cover%s", ext)) if err := p.db.Save(podcast).Error; err != nil { return fmt.Errorf("save podcast: %w", err) } @@ -545,10 +546,13 @@ func (p *Podcasts) PurgeOldPodcasts(maxAge time.Duration) error { return nil } -func pathSafe(in string) string { - return filepath.Clean(strings.ReplaceAll(in, string(filepath.Separator), "_")) +var nonAlphaNum = regexp.MustCompile("[^a-zA-Z0-9_.]+") + +func safeFilename(filename string) string { + filename = nonAlphaNum.ReplaceAllString(filename, "") + return filename } func absPath(base string, p *db.Podcast) string { - return filepath.Join(base, pathSafe(p.Title)) + return filepath.Join(base, safeFilename(p.Title)) } diff --git a/server/ctrladmin/adminui/pages/home.tmpl b/server/ctrladmin/adminui/pages/home.tmpl index 4c4125d..27dfbc4 100644 --- a/server/ctrladmin/adminui/pages/home.tmpl +++ b/server/ctrladmin/adminui/pages/home.tmpl @@ -198,29 +198,5 @@ {{ end }} {{ end }} -{{ component "block" (props . - "Icon" "list" - "Name" "playlists" - "Desc" "choose a local .m3u8 file containing paths to music files that gonic has scanned. paths should be absolute, prefixed by the music-path option that you started gonic with. a playlist will be created from the file and available to subsonic clients" -) }} - {{ if eq (len .Playlists) 0 }} - no playlists yet - {{ end }} -
- {{ range $i, $playlist := .Playlists }} -
{{ $playlist.Name }}
-
({{ $playlist.TrackCount }} tracks)
- -
- -
- {{ end }} -
- - -
-
-{{ end }} - {{ end }} {{ end }} diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go index e1b7998..afb5b77 100644 --- a/server/ctrladmin/ctrl.go +++ b/server/ctrladmin/ctrl.go @@ -115,7 +115,6 @@ type templateData struct { AllUsers []*db.User LastScanTime time.Time IsScanning bool - Playlists []*db.Playlist TranscodePreferences []*db.TranscodePreference TranscodeProfiles []string diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index 06ac907..e046cf1 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -73,11 +73,6 @@ func (c *Controller) ServeHome(r *http.Request) *Response { data.LastScanTime = time.Unix(i, 0) } - // playlists box - c.DB. - Where("user_id=?", user.ID). - Limit(20). - Find(&data.Playlists) // transcoding box c.DB. Where("user_id=?", user.ID). diff --git a/server/ctrladmin/handlers_playlist.go b/server/ctrladmin/handlers_playlist.go deleted file mode 100644 index 17ca923..0000000 --- a/server/ctrladmin/handlers_playlist.go +++ /dev/null @@ -1,144 +0,0 @@ -package ctrladmin - -import ( - "bufio" - "errors" - "fmt" - "mime/multipart" - "net/http" - "os" - "strconv" - "strings" - - "github.com/jinzhu/gorm" - - "go.senan.xyz/gonic/db" - "go.senan.xyz/gonic/server/ctrlsubsonic/specid" -) - -var ( - errPlaylistNoMatch = errors.New("couldn't match track") -) - -func playlistParseLine(c *Controller, absPath string) (*specid.ID, error) { - if strings.HasPrefix(absPath, "#") || strings.TrimSpace(absPath) == "" { - return nil, nil - } - var track db.Track - query := c.DB.Raw(` - SELECT tracks.id FROM TRACKS - JOIN albums ON tracks.album_id=albums.id - WHERE (albums.root_dir || ? || albums.left_path || albums.right_path || ? || tracks.filename)=?`, - string(os.PathSeparator), string(os.PathSeparator), absPath) - err := query.First(&track).Error - if err == nil { - return &specid.ID{Type: specid.Track, Value: track.ID}, nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("while matching: %w", err) - } - - var pe db.PodcastEpisode - err = c.DB.Where("path=?", absPath).First(&pe).Error - if err == nil { - return &specid.ID{Type: specid.PodcastEpisode, Value: pe.ID}, nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("while matching: %w", err) - } - - return nil, fmt.Errorf("%v: %w", err, errPlaylistNoMatch) -} - -func playlistCheckContentType(contentType string) bool { - switch ct := strings.ToLower(contentType); ct { - case - "audio/x-mpegurl", - "audio/mpegurl", - "application/x-mpegurl", - "application/octet-stream": - return true - } - return false -} - -func playlistParseUpload(c *Controller, userID int, header *multipart.FileHeader) ([]string, bool) { - file, err := header.Open() - if err != nil { - return []string{fmt.Sprintf("couldn't open file %q", header.Filename)}, false - } - playlistName := strings.TrimSuffix(header.Filename, ".m3u8") - if playlistName == "" { - return []string{fmt.Sprintf("invalid filename %q", header.Filename)}, false - } - contentType := header.Header.Get("Content-Type") - if !playlistCheckContentType(contentType) { - return []string{fmt.Sprintf("invalid content-type %q", contentType)}, false - } - var trackIDs []specid.ID - var errors []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - trackID, err := playlistParseLine(c, scanner.Text()) - if err != nil { - // trim length of error to not overflow cookie flash - errors = append(errors, fmt.Sprintf("%.100s", err.Error())) - continue - } - if trackID.Value != 0 { - trackIDs = append(trackIDs, *trackID) - } - } - if err := scanner.Err(); err != nil { - return []string{fmt.Sprintf("iterating playlist file: %v", err)}, true - } - playlist := &db.Playlist{} - c.DB.FirstOrCreate(playlist, db.Playlist{ - Name: playlistName, - UserID: userID, - }) - playlist.SetItems(trackIDs) - c.DB.Save(playlist) - return errors, true -} - -func (c *Controller) ServeUploadPlaylist(r *http.Request) *Response { - return &Response{template: "upload_playlist.tmpl"} -} - -func (c *Controller) ServeUploadPlaylistDo(r *http.Request) *Response { - if err := r.ParseMultipartForm((1 << 10) * 24); err != nil { - return &Response{code: 500, err: "couldn't parse mutlipart"} - } - user := r.Context().Value(CtxUser).(*db.User) - var playlistCount int - var errors []string - for _, headers := range r.MultipartForm.File { - for _, header := range headers { - headerErrors, created := playlistParseUpload(c, user.ID, header) - if created { - playlistCount++ - } - errors = append(errors, headerErrors...) - } - } - return &Response{ - redirect: "/admin/home", - flashN: []string{fmt.Sprintf("%d playlist(s) created", playlistCount)}, - flashW: errors, - } -} - -func (c *Controller) ServeDeletePlaylistDo(r *http.Request) *Response { - user := r.Context().Value(CtxUser).(*db.User) - id, err := strconv.Atoi(r.URL.Query().Get("id")) - if err != nil { - return &Response{code: 400, err: "please provide a valid id"} - } - c.DB. - Where("user_id=? AND id=?", user.ID, id). - Delete(db.Playlist{}) - return &Response{ - redirect: "/admin/home", - } -} diff --git a/server/ctrlbase/ctrl.go b/server/ctrlbase/ctrl.go index 93f17b3..7d8e1b5 100644 --- a/server/ctrlbase/ctrl.go +++ b/server/ctrlbase/ctrl.go @@ -7,6 +7,7 @@ import ( "path" "go.senan.xyz/gonic/db" + "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/scanner" ) @@ -45,9 +46,10 @@ func statusToBlock(code int) string { } type Controller struct { - DB *db.DB - Scanner *scanner.Scanner - ProxyPrefix string + DB *db.DB + PlaylistStore *playlist.Store + Scanner *scanner.Scanner + ProxyPrefix string } // Path returns a URL path with the proxy prefix included diff --git a/server/ctrlsubsonic/handlers_playlist.go b/server/ctrlsubsonic/handlers_playlist.go index afabd12..88ca47a 100644 --- a/server/ctrlsubsonic/handlers_playlist.go +++ b/server/ctrlsubsonic/handlers_playlist.go @@ -1,165 +1,137 @@ package ctrlsubsonic import ( + "encoding/base64" "errors" - "log" + "fmt" "net/http" "sort" + "time" "github.com/jinzhu/gorm" "go.senan.xyz/gonic/db" + playlistp "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/server/ctrlsubsonic/params" "go.senan.xyz/gonic/server/ctrlsubsonic/spec" "go.senan.xyz/gonic/server/ctrlsubsonic/specid" + "go.senan.xyz/gonic/server/ctrlsubsonic/specidpaths" ) -func playlistRender(c *Controller, playlist *db.Playlist, params params.Params) *spec.Playlist { - user := &db.User{} - c.DB.Where("id=?", playlist.UserID).Find(user) - - resp := &spec.Playlist{ - ID: playlist.ID, - Name: playlist.Name, - Comment: playlist.Comment, - Created: playlist.CreatedAt, - SongCount: playlist.TrackCount, - Public: playlist.IsPublic, - Owner: user.Name, - } - - trackIDs := playlist.GetItems() - resp.List = make([]*spec.TrackChild, len(trackIDs)) - - transcodeMIME, transcodeSuffix := streamGetTransPrefProfile(c.DB, user.ID, params.GetOr("c", "")) - - for i, id := range trackIDs { - switch id.Type { - case specid.Track: - track := db.Track{} - err := c.DB. - Where("id=?", id.Value). - Preload("Album"). - Preload("Album.TagArtist"). - Preload("TrackStar", "user_id=?", user.ID). - Preload("TrackRating", "user_id=?", user.ID). - Find(&track). - Error - if errors.Is(err, gorm.ErrRecordNotFound) { - log.Printf("wasn't able to find track with id %d", id.Value) - continue - } - resp.List[i] = spec.NewTCTrackByFolder(&track, track.Album) - resp.Duration += track.Length - case specid.PodcastEpisode: - pe := db.PodcastEpisode{} - err := c.DB. - Where("id=?", id.Value). - Find(&pe). - Error - if errors.Is(err, gorm.ErrRecordNotFound) { - log.Printf("wasn't able to find podcast episode with id %d", id.Value) - continue - } - p := db.Podcast{} - err = c.DB. - Where("id=?", pe.PodcastID). - Find(&p). - Error - if errors.Is(err, gorm.ErrRecordNotFound) { - log.Printf("wasn't able to find podcast with id %d", pe.PodcastID) - continue - } - resp.List[i] = spec.NewTCPodcastEpisode(&pe, &p) - resp.Duration += pe.Length - } - resp.List[i].TranscodedContentType = transcodeMIME - resp.List[i].TranscodedSuffix = transcodeSuffix - } - return resp -} - func (c *Controller) ServeGetPlaylists(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) user := r.Context().Value(CtxUser).(*db.User) - var playlists []*db.Playlist - c.DB.Where("user_id=?", user.ID).Or("is_public=?", true).Find(&playlists) + paths, err := c.PlaylistStore.List() + if err != nil { + return spec.NewError(0, "error listing playlists: %v", err) + } sub := spec.NewResponse() sub.Playlists = &spec.Playlists{ - List: make([]*spec.Playlist, len(playlists)), + List: []*spec.Playlist{}, } - for i, playlist := range playlists { - sub.Playlists.List[i] = playlistRender(c, playlist, params) + for _, path := range paths { + playlist, err := c.PlaylistStore.Read(path) + if err != nil { + return spec.NewError(0, "error reading playlist %q: %v", path, err) + } + if playlist.UserID != user.ID && !playlist.IsPublic { + continue + } + playlistID := playlistIDEncode(path) + rendered, err := playlistRender(c, params, playlistID, playlist, false) + if err != nil { + return spec.NewError(0, "error rendering playlist %q: %v", path, err) + } + sub.Playlists.List = append(sub.Playlists.List, rendered) } return sub } func (c *Controller) ServeGetPlaylist(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) - playlistID, err := params.GetFirstInt("id", "playlistId") + playlistID, err := params.GetFirst("id", "playlistId") if err != nil { return spec.NewError(10, "please provide an `id` parameter") } - playlist := db.Playlist{} - err = c.DB. - Where("id=?", playlistID). - Find(&playlist). - Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return spec.NewError(70, "playlist with id `%d` not found", playlistID) + playlist, err := c.PlaylistStore.Read(playlistIDDecode(playlistID)) + if err != nil { + return spec.NewError(70, "playlist with id %s not found", playlistID) } sub := spec.NewResponse() - sub.Playlist = playlistRender(c, &playlist, params) + rendered, err := playlistRender(c, params, playlistID, playlist, true) + if err != nil { + return spec.NewError(0, "error rendering playlist: %v", err) + } + sub.Playlist = rendered return sub } func (c *Controller) ServeCreatePlaylist(r *http.Request) *spec.Response { user := r.Context().Value(CtxUser).(*db.User) params := r.Context().Value(CtxParams).(params.Params) - playlistID := params.GetFirstOrInt( /* default */ 0, "id", "playlistId") - // playlistID may be 0 from above. in that case we get a new playlist - // as intended - var playlist db.Playlist - c.DB. - Where("id=?", playlistID). - FirstOrCreate(&playlist) - // update meta info + playlistID := params.GetFirstOr( /* default */ "", "id", "playlistId") + playlistPath := playlistIDDecode(playlistID) + + var playlist playlistp.Playlist + if pl, _ := c.PlaylistStore.Read(playlistPath); pl != nil { + playlist = *pl + } + + // update meta info if playlist.UserID != 0 && playlist.UserID != user.ID { return spec.NewResponse() } + playlist.UserID = user.ID + playlist.UpdatedAt = time.Now() + if val, err := params.Get("name"); err == nil { playlist.Name = val } - // replace song IDs - trackIDs, _ := params.GetIDList("songId") - // Set the items of the playlist - playlist.SetItems(trackIDs) - c.DB.Save(playlist) + playlist.Items = nil + ids := params.GetOrIDList("songId", nil) + for _, id := range ids { + r, err := specidpaths.Locate(c.DB, c.PodcastsPath, id) + if err != nil { + return spec.NewError(0, "lookup id %v: %v", id, err) + } + playlist.Items = append(playlist.Items, r.AbsPath()) + } + + if playlistPath == "" { + playlistPath = playlistp.NewPath(user.ID, fmt.Sprint(time.Now().UnixMilli())) + } + if err := c.PlaylistStore.Write(playlistPath, &playlist); err != nil { + return spec.NewError(0, "save playlist: %v", err) + } sub := spec.NewResponse() - sub.Playlist = playlistRender(c, &playlist, params) + rendered, err := playlistRender(c, params, playlistID, &playlist, true) + if err != nil { + return spec.NewError(0, "error rendering playlist: %v", err) + } + sub.Playlist = rendered return sub } func (c *Controller) ServeUpdatePlaylist(r *http.Request) *spec.Response { user := r.Context().Value(CtxUser).(*db.User) params := r.Context().Value(CtxParams).(params.Params) - playlistID := params.GetFirstOrInt( /* default */ 0, "id", "playlistId") - // playlistID may be 0 from above. in that case we get a new playlist - // as intended - var playlist db.Playlist - c.DB. - Where("id=?", playlistID). - FirstOrCreate(&playlist) - // update meta info + playlistID := params.GetFirstOr( /* default */ "", "id", "playlistId") + playlistPath := playlistIDDecode(playlistID) + playlist, err := c.PlaylistStore.Read(playlistPath) + if err != nil { + return spec.NewError(0, "find playlist: %v", err) + } + + // update meta info if playlist.UserID != 0 && playlist.UserID != user.ID { return spec.NewResponse() } - playlist.UserID = user.ID + if val, err := params.Get("name"); err == nil { playlist.Name = val } @@ -169,30 +141,101 @@ func (c *Controller) ServeUpdatePlaylist(r *http.Request) *spec.Response { if val, err := params.GetBool("public"); err == nil { playlist.IsPublic = val } - trackIDs := playlist.GetItems() // delete items - if p, err := params.GetIntList("songIndexToRemove"); err == nil { - sort.Sort(sort.Reverse(sort.IntSlice(p))) - for _, i := range p { - trackIDs = append(trackIDs[:i], trackIDs[i+1:]...) + if indexes, err := params.GetIntList("songIndexToRemove"); err == nil { + sort.Sort(sort.Reverse(sort.IntSlice(indexes))) + for _, i := range indexes { + playlist.Items = append(playlist.Items[:i], playlist.Items[i+1:]...) } } // add items - if p, err := params.GetIDList("songIdToAdd"); err == nil { - trackIDs = append(trackIDs, p...) + if ids, err := params.GetIDList("songIdToAdd"); err == nil { + for _, id := range ids { + item, err := specidpaths.Locate(c.DB, c.PodcastsPath, id) + if err != nil { + return spec.NewError(0, "locate id %q: %v", id, err) + } + playlist.Items = append(playlist.Items, item.AbsPath()) + } } - playlist.SetItems(trackIDs) - c.DB.Save(playlist) + if err := c.PlaylistStore.Write(playlistPath, playlist); err != nil { + return spec.NewError(0, "save playlist: %v", err) + } return spec.NewResponse() } func (c *Controller) ServeDeletePlaylist(r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) - c.DB. - Where("id=?", params.GetOrInt("id", 0)). - Delete(&db.Playlist{}) + playlistID := params.GetFirstOr( /* default */ "", "id", "playlistId") + if err := c.PlaylistStore.Delete(playlistIDDecode(playlistID)); err != nil { + return spec.NewError(0, "delete playlist: %v", err) + } return spec.NewResponse() } + +func playlistIDEncode(path string) string { + return base64.URLEncoding.EncodeToString([]byte(path)) +} +func playlistIDDecode(id string) string { + path, _ := base64.URLEncoding.DecodeString(id) + return string(path) +} + +func playlistRender(c *Controller, params params.Params, playlistID string, playlist *playlistp.Playlist, withItems bool) (*spec.Playlist, error) { + user := &db.User{} + if err := c.DB.Where("id=?", playlist.UserID).Find(user).Error; err != nil { + return nil, fmt.Errorf("find user by id: %w", err) + } + + resp := &spec.Playlist{ + ID: playlistID, + Name: playlist.Name, + Comment: playlist.Comment, + Created: playlist.UpdatedAt, + SongCount: len(playlist.Items), + Public: playlist.IsPublic, + Owner: user.Name, + } + if !withItems { + return resp, nil + } + + transcodeMIME, transcodeSuffix := streamGetTransPrefProfile(c.DB, user.ID, params.GetOr("c", "")) + + for _, path := range playlist.Items { + file, err := specidpaths.Lookup(c.DB, PathsOf(c.MusicPaths), c.PodcastsPath, path) + if err != nil { + return nil, fmt.Errorf("lookup path %q: %w", path, err) + } + var trch *spec.TrackChild + switch id := file.SID(); id.Type { + case specid.Track: + var track db.Track + if err := c.DB.Where("id=?", id.Value).Preload("Album").Preload("Album.TagArtist").Preload("TrackStar", "user_id=?", user.ID).Preload("TrackRating", "user_id=?", user.ID).Find(&track).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("load track by id: %w", err) + } + trch = spec.NewTCTrackByFolder(&track, track.Album) + resp.Duration += track.Length + case specid.PodcastEpisode: + var pe db.PodcastEpisode + if err := c.DB.Where("id=?", id.Value).Find(&pe).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("load podcast episode by id: %w", err) + } + var p db.Podcast + if err := c.DB.Where("id=?", pe.PodcastID).Find(&p).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("load podcast by id: %w", err) + } + trch = spec.NewTCPodcastEpisode(&pe, &p) + resp.Duration += pe.Length + default: + continue + } + trch.TranscodedContentType = transcodeMIME + trch.TranscodedSuffix = transcodeSuffix + resp.List = append(resp.List, trch) + } + return resp, nil +} diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index 2fbf01a..323894d 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -266,15 +266,15 @@ type Playlists struct { } type Playlist struct { - ID int `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - Comment string `xml:"comment,attr" json:"comment"` - Owner string `xml:"owner,attr" json:"owner"` - SongCount int `xml:"songCount,attr" json:"songCount"` - Created time.Time `xml:"created,attr" json:"created"` - Duration int `xml:"duration,attr" json:"duration,omitempty"` - Public bool `xml:"public,attr" json:"public,omitempty"` - List []*TrackChild `xml:"entry" json:"entry"` + ID string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + Comment string `xml:"comment,attr" json:"comment"` + Owner string `xml:"owner,attr" json:"owner"` + SongCount int `xml:"songCount,attr" json:"songCount"` + Created time.Time `xml:"created,attr" json:"created"` + Duration int `xml:"duration,attr" json:"duration,omitempty"` + Public bool `xml:"public,attr" json:"public,omitempty"` + List []*TrackChild `xml:"entry,omitempty" json:"entry,omitempty"` } type SimilarArtist struct { diff --git a/server/server.go b/server/server.go index b688b98..825947a 100644 --- a/server/server.go +++ b/server/server.go @@ -15,6 +15,7 @@ import ( "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/jukebox" + "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/podcasts" "go.senan.xyz/gonic/scanner" "go.senan.xyz/gonic/scanner/tags" @@ -35,6 +36,7 @@ type Options struct { PodcastPath string CacheAudioPath string CoverCachePath string + PlaylistsPath string ProxyPrefix string GenreSplit string HTTPLog bool @@ -53,10 +55,17 @@ func New(opts Options) (*Server, error) { tagger := &tags.TagReader{} scanner := scanner.New(ctrlsubsonic.PathsOf(opts.MusicPaths), opts.DB, opts.GenreSplit, tagger, opts.ExcludePattern) + + playlistStore, err := playlist.NewStore(opts.PlaylistsPath) + if err != nil { + return nil, fmt.Errorf("create playlists store: %w", err) + } + base := &ctrlbase.Controller{ - DB: opts.DB, - ProxyPrefix: opts.ProxyPrefix, - Scanner: scanner, + DB: opts.DB, + PlaylistStore: playlistStore, + ProxyPrefix: opts.ProxyPrefix, + Scanner: scanner, } // router with common wares for admin / subsonic @@ -170,8 +179,6 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) { routUser.Handle("/unlink_lastfm_do", ctrl.H(ctrl.ServeUnlinkLastFMDo)) routUser.Handle("/link_listenbrainz_do", ctrl.H(ctrl.ServeLinkListenBrainzDo)) routUser.Handle("/unlink_listenbrainz_do", ctrl.H(ctrl.ServeUnlinkListenBrainzDo)) - routUser.Handle("/upload_playlist_do", ctrl.H(ctrl.ServeUploadPlaylistDo)) - routUser.Handle("/delete_playlist_do", ctrl.H(ctrl.ServeDeletePlaylistDo)) routUser.Handle("/create_transcode_pref_do", ctrl.H(ctrl.ServeCreateTranscodePrefDo)) routUser.Handle("/delete_transcode_pref_do", ctrl.H(ctrl.ServeDeleteTranscodePrefDo))