feat: store and use m3u files on filesystem for playlists
closes #306 closes #307 closes #66
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
39
README.md
39
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 `<userid>/<name>.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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
20
db/migrations_old_models.go
Normal file
20
db/migrations_old_models.go
Normal file
@@ -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"
|
||||
}
|
||||
22
db/model.go
22
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
|
||||
|
||||
243
playlist/playlist.go
Normal file
243
playlist/playlist.go
Normal file
@@ -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 <base path>/<user id>/**/<playlist name>.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
|
||||
}
|
||||
57
playlist/playlist_test.go
Normal file
57
playlist/playlist_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -198,29 +198,5 @@
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ component "block" (props .
|
||||
"Icon" "list"
|
||||
"Name" "playlists"
|
||||
"Desc" "choose a local <span class='italic text-gray-800'>.m3u8</span> file containing paths to music files that gonic has scanned. paths should be absolute, prefixed by the <span class='italic text-gray-800'>music-path</span> option that you started gonic with. a playlist will be created from the file and available to subsonic clients"
|
||||
) }}
|
||||
{{ if eq (len .Playlists) 0 }}
|
||||
<span class="text-gray-500">no playlists yet</span>
|
||||
{{ end }}
|
||||
<div class="grid grid-cols-[auto_1fr_auto] md:grid-cols-[auto_repeat(3,min-content)] gap-x-3 gap-y-2 items-center justify-items-end">
|
||||
{{ range $i, $playlist := .Playlists }}
|
||||
<div class="text-right ellipsis">{{ $playlist.Name }}</div>
|
||||
<div class="text-gray-500 whitespace-nowrap">({{ $playlist.TrackCount }} tracks)</div>
|
||||
<div class="text-right text-gray-500 whitespace-nowrap hidden md:block" title="{{ $playlist.CreatedAt }}">{{ $playlist.CreatedAt | dateHuman }}</div>
|
||||
<form class="contents" action="{{ printf "/admin/delete_playlist_do?id=%d" $playlist.ID | path }}" method="post">
|
||||
<input type="submit" value="delete">
|
||||
</form>
|
||||
{{ end }}
|
||||
<form class="col-span-full relative pointer-events-auto" enctype="multipart/form-data" action="{{ path "/admin/upload_playlist_do" }}" method="post">
|
||||
<input class="auto-submit absolute opacity-0" name="playlist-files" type="file" multiple />
|
||||
<input type="button" value="choose m3u8">
|
||||
</form>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -115,7 +115,6 @@ type templateData struct {
|
||||
AllUsers []*db.User
|
||||
LastScanTime time.Time
|
||||
IsScanning bool
|
||||
Playlists []*db.Playlist
|
||||
TranscodePreferences []*db.TranscodePreference
|
||||
TranscodeProfiles []string
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user