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_MUSIC_PATH /music
|
||||||
ENV GONIC_PODCAST_PATH /podcasts
|
ENV GONIC_PODCAST_PATH /podcasts
|
||||||
ENV GONIC_CACHE_PATH /cache
|
ENV GONIC_CACHE_PATH /cache
|
||||||
|
ENV GONIC_PLAYLISTS_PATH /playlists
|
||||||
ENTRYPOINT ["/sbin/tini", "--"]
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
CMD ["gonic"]
|
CMD ["gonic"]
|
||||||
|
|||||||
@@ -33,5 +33,6 @@ ENV GONIC_DB_PATH /data/gonic.db
|
|||||||
ENV GONIC_LISTEN_ADDR :80
|
ENV GONIC_LISTEN_ADDR :80
|
||||||
ENV GONIC_MUSIC_PATH /music
|
ENV GONIC_MUSIC_PATH /music
|
||||||
ENV GONIC_PODCAST_PATH /podcasts
|
ENV GONIC_PODCAST_PATH /podcasts
|
||||||
|
ENV GONIC_PLAYLISTS_PATH /playlists
|
||||||
ENV GONIC_CACHE_PATH /cache
|
ENV GONIC_CACHE_PATH /cache
|
||||||
CMD ["gonic"]
|
CMD ["gonic"]
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -55,25 +55,26 @@ password can then be changed from the web interface
|
|||||||
|
|
||||||
## configuration options
|
## configuration options
|
||||||
|
|
||||||
| env var | command line arg | description |
|
| env var | command line arg | description |
|
||||||
| ------------------------------ | ------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
| ------------------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `GONIC_MUSIC_PATH` | `-music-path` | path to your music collection (see also multi-folder support below) |
|
| `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_PODCAST_PATH` | `-podcast-path` | path to a podcasts directory |
|
||||||
| `GONIC_CACHE_PATH` | `-cache-path` | path to store audio transcodes, covers, etc |
|
| `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_DB_PATH` | `-db-path` | **optional** path to database file |
|
| `GONIC_CACHE_PATH` | `-cache-path` | path to store audio transcodes, covers, etc |
|
||||||
| `GONIC_HTTP_LOG` | `-http-log` | **optional** http request logging, enabled by default |
|
| `GONIC_DB_PATH` | `-db-path` | **optional** path to database file |
|
||||||
| `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_HTTP_LOG` | `-http-log` | **optional** http request logging, enabled by default |
|
||||||
| `GONIC_TLS_CERT` | `-tls-cert` | **optional** path to a TLS cert (enables HTTPS listening) |
|
| `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_KEY` | `-tls-key` | **optional** path to a TLS key (enables HTTPS listening) |
|
| `GONIC_TLS_CERT` | `-tls-cert` | **optional** path to a TLS cert (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_TLS_KEY` | `-tls-key` | **optional** path to a TLS key (enables HTTPS listening) |
|
||||||
| `GONIC_SCAN_INTERVAL` | `-scan-interval` | **optional** interval (in minutes) to check for new music (automatic scanning disabled if omitted) |
|
| `GONIC_PROXY_PREFIX` | `-proxy-prefix` | **optional** url path prefix to use if behind reverse proxy. eg `/gonic` (see example configs below) |
|
||||||
| `GONIC_SCAN_AT_START_ENABLED` | `-scan-at-start-enabled` | **optional** whether to perform an initial scan at startup |
|
| `GONIC_SCAN_INTERVAL` | `-scan-interval` | **optional** interval (in minutes) to check for new music (automatic scanning disabled if omitted) |
|
||||||
| `GONIC_SCAN_WATCHER_ENABLED` | `-scan-watcher-enabled` | **optional** whether to watch file system for new music and rescan |
|
| `GONIC_SCAN_AT_START_ENABLED` | `-scan-at-start-enabled` | **optional** whether to perform an initial scan at startup |
|
||||||
| `GONIC_JUKEBOX_ENABLED` | `-jukebox-enabled` | **optional** whether the subsonic [jukebox api](https://airsonic.github.io/docs/jukebox/) should be enabled |
|
| `GONIC_SCAN_WATCHER_ENABLED` | `-scan-watcher-enabled` | **optional** whether to watch file system for new music and rescan |
|
||||||
| `GONIC_JUKEBOX_MPV_EXTRA_ARGS` | `-jukebox-mpv-extra-args` | **optional** extra command line arguments to pass to the jukebox mpv daemon |
|
| `GONIC_JUKEBOX_ENABLED` | `-jukebox-enabled` | **optional** whether the subsonic [jukebox api](https://airsonic.github.io/docs/jukebox/) should be enabled |
|
||||||
| `GONIC_PODCAST_PURGE_AGE` | `-podcast-purge-age` | **optional** age (in days) to purge podcast episodes if not accessed |
|
| `GONIC_JUKEBOX_MPV_EXTRA_ARGS` | `-jukebox-mpv-extra-args` | **optional** extra command line arguments to pass to the jukebox mpv daemon |
|
||||||
| `GONIC_GENRE_SPLIT` | `-genre-split` | **optional** a string or character to split genre tags on for multi-genre support (eg. `;`) |
|
| `GONIC_PODCAST_PURGE_AGE` | `-podcast-purge-age` | **optional** age (in days) to purge podcast episodes if not accessed |
|
||||||
| `GONIC_EXCLUDE_PATTERN` | `-exclude-pattern` | **optional** files matching this regex pattern will not be imported |
|
| `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
|
## screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ func main() {
|
|||||||
var confMusicPaths pathAliases
|
var confMusicPaths pathAliases
|
||||||
set.Var(&confMusicPaths, "music-path", "path to music")
|
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)")
|
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)")
|
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 {
|
if *confCachePath, err = validatePath(*confCachePath); err != nil {
|
||||||
log.Fatalf("checking cache directory: %v", err)
|
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")
|
cacheDirAudio := path.Join(*confCachePath, "audio")
|
||||||
cacheDirCovers := path.Join(*confCachePath, "covers")
|
cacheDirCovers := path.Join(*confCachePath, "covers")
|
||||||
@@ -116,6 +121,8 @@ func main() {
|
|||||||
|
|
||||||
err = dbc.Migrate(db.MigrationContext{
|
err = dbc.Migrate(db.MigrationContext{
|
||||||
OriginalMusicPath: confMusicPaths[0].path,
|
OriginalMusicPath: confMusicPaths[0].path,
|
||||||
|
PlaylistsPath: *confPlaylistsPath,
|
||||||
|
PodcastsPath: *confPodcastPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Panicf("error migrating database: %v\n", err)
|
log.Panicf("error migrating database: %v\n", err)
|
||||||
@@ -135,6 +142,7 @@ func main() {
|
|||||||
CacheAudioPath: cacheDirAudio,
|
CacheAudioPath: cacheDirAudio,
|
||||||
CoverCachePath: cacheDirCovers,
|
CoverCachePath: cacheDirCovers,
|
||||||
PodcastPath: *confPodcastPath,
|
PodcastPath: *confPodcastPath,
|
||||||
|
PlaylistsPath: *confPlaylistsPath,
|
||||||
ProxyPrefix: *confProxyPrefix,
|
ProxyPrefix: *confProxyPrefix,
|
||||||
GenreSplit: *confGenreSplit,
|
GenreSplit: *confGenreSplit,
|
||||||
HTTPLog: *confHTTPLog,
|
HTTPLog: *confHTTPLog,
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
|
//nolint:goerr113
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
|
"go.senan.xyz/gonic/playlist"
|
||||||
|
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
||||||
"gopkg.in/gormigrate.v1"
|
"gopkg.in/gormigrate.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MigrationContext struct {
|
type MigrationContext struct {
|
||||||
OriginalMusicPath string
|
OriginalMusicPath string
|
||||||
|
PlaylistsPath string
|
||||||
|
PodcastsPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) Migrate(ctx MigrationContext) error {
|
func (db *DB) Migrate(ctx MigrationContext) error {
|
||||||
@@ -46,6 +53,7 @@ func (db *DB) Migrate(ctx MigrationContext) error {
|
|||||||
construct(ctx, "202206101425", migrateUser),
|
construct(ctx, "202206101425", migrateUser),
|
||||||
construct(ctx, "202207251148", migrateStarRating),
|
construct(ctx, "202207251148", migrateStarRating),
|
||||||
construct(ctx, "202211111057", migratePlaylistsQueuesToFullID),
|
construct(ctx, "202211111057", migratePlaylistsQueuesToFullID),
|
||||||
|
construct(ctx, "202304221528", migratePlaylistsToM3U),
|
||||||
}
|
}
|
||||||
|
|
||||||
return gormigrate.
|
return gormigrate.
|
||||||
@@ -82,7 +90,6 @@ func migrateInitSchema(tx *gorm.DB, _ MigrationContext) error {
|
|||||||
Setting{},
|
Setting{},
|
||||||
Play{},
|
Play{},
|
||||||
Album{},
|
Album{},
|
||||||
Playlist{},
|
|
||||||
PlayQueue{},
|
PlayQueue{},
|
||||||
).
|
).
|
||||||
Error
|
Error
|
||||||
@@ -110,6 +117,9 @@ func migrateCreateInitUser(tx *gorm.DB, _ MigrationContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateMergePlaylist(tx *gorm.DB, _ MigrationContext) error {
|
func migrateMergePlaylist(tx *gorm.DB, _ MigrationContext) error {
|
||||||
|
if !tx.HasTable("playlists") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if !tx.HasTable("playlist_items") {
|
if !tx.HasTable("playlist_items") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -335,7 +345,10 @@ func migrateAlbumRootDirAgain(tx *gorm.DB, ctx MigrationContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migratePublicPlaylist(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 {
|
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 {
|
func migratePlaylistsQueuesToFullID(tx *gorm.DB, _ MigrationContext) error {
|
||||||
|
if !tx.HasTable("playlists") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
step := tx.Exec(`
|
step := tx.Exec(`
|
||||||
UPDATE playlists SET items=('tr-' || items) WHERE items IS NOT NULL;
|
UPDATE playlists SET items=('tr-' || items) WHERE items IS NOT NULL;
|
||||||
`)
|
`)
|
||||||
@@ -441,3 +458,61 @@ func migratePlaylistsQueuesToFullID(tx *gorm.DB, _ MigrationContext) error {
|
|||||||
|
|
||||||
return nil
|
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
|
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 {
|
type PlayQueue struct {
|
||||||
ID int `gorm:"primary_key"`
|
ID int `gorm:"primary_key"`
|
||||||
CreatedAt time.Time
|
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"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -385,7 +386,7 @@ func (p *Podcasts) DownloadEpisode(episodeID int) error {
|
|||||||
return fmt.Errorf("create audio file: %w", err)
|
return fmt.Errorf("create audio file: %w", err)
|
||||||
}
|
}
|
||||||
podcastEpisode.Filename = filename
|
podcastEpisode.Filename = filename
|
||||||
podcastEpisode.Path = path.Join(pathSafe(podcast.Title), filename)
|
podcastEpisode.Path = path.Join(safeFilename(podcast.Title), filename)
|
||||||
p.db.Save(&podcastEpisode)
|
p.db.Save(&podcastEpisode)
|
||||||
go func() {
|
go func() {
|
||||||
if err := p.doPodcastDownload(&podcastEpisode, audioFile, resp.Body); err != nil {
|
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) {
|
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
|
||||||
return filename
|
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)
|
podcastPath = path.Join(absPath(p.baseDir, podcast), titlePath)
|
||||||
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
|
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
|
||||||
return titlePath
|
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 {
|
if _, err := io.Copy(coverFile, resp.Body); err != nil {
|
||||||
return fmt.Errorf("writing podcast cover: %w", err)
|
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 {
|
if err := p.db.Save(podcast).Error; err != nil {
|
||||||
return fmt.Errorf("save podcast: %w", err)
|
return fmt.Errorf("save podcast: %w", err)
|
||||||
}
|
}
|
||||||
@@ -545,10 +546,13 @@ func (p *Podcasts) PurgeOldPodcasts(maxAge time.Duration) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathSafe(in string) string {
|
var nonAlphaNum = regexp.MustCompile("[^a-zA-Z0-9_.]+")
|
||||||
return filepath.Clean(strings.ReplaceAll(in, string(filepath.Separator), "_"))
|
|
||||||
|
func safeFilename(filename string) string {
|
||||||
|
filename = nonAlphaNum.ReplaceAllString(filename, "")
|
||||||
|
return filename
|
||||||
}
|
}
|
||||||
|
|
||||||
func absPath(base string, p *db.Podcast) string {
|
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 }}
|
||||||
{{ 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 }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ type templateData struct {
|
|||||||
AllUsers []*db.User
|
AllUsers []*db.User
|
||||||
LastScanTime time.Time
|
LastScanTime time.Time
|
||||||
IsScanning bool
|
IsScanning bool
|
||||||
Playlists []*db.Playlist
|
|
||||||
TranscodePreferences []*db.TranscodePreference
|
TranscodePreferences []*db.TranscodePreference
|
||||||
TranscodeProfiles []string
|
TranscodeProfiles []string
|
||||||
|
|
||||||
|
|||||||
@@ -73,11 +73,6 @@ func (c *Controller) ServeHome(r *http.Request) *Response {
|
|||||||
data.LastScanTime = time.Unix(i, 0)
|
data.LastScanTime = time.Unix(i, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// playlists box
|
|
||||||
c.DB.
|
|
||||||
Where("user_id=?", user.ID).
|
|
||||||
Limit(20).
|
|
||||||
Find(&data.Playlists)
|
|
||||||
// transcoding box
|
// transcoding box
|
||||||
c.DB.
|
c.DB.
|
||||||
Where("user_id=?", user.ID).
|
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"
|
"path"
|
||||||
|
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
|
"go.senan.xyz/gonic/playlist"
|
||||||
"go.senan.xyz/gonic/scanner"
|
"go.senan.xyz/gonic/scanner"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,9 +46,10 @@ func statusToBlock(code int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Controller struct {
|
type Controller struct {
|
||||||
DB *db.DB
|
DB *db.DB
|
||||||
Scanner *scanner.Scanner
|
PlaylistStore *playlist.Store
|
||||||
ProxyPrefix string
|
Scanner *scanner.Scanner
|
||||||
|
ProxyPrefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path returns a URL path with the proxy prefix included
|
// Path returns a URL path with the proxy prefix included
|
||||||
|
|||||||
@@ -1,165 +1,137 @@
|
|||||||
package ctrlsubsonic
|
package ctrlsubsonic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
|
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
|
playlistp "go.senan.xyz/gonic/playlist"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
|
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
"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 {
|
func (c *Controller) ServeGetPlaylists(r *http.Request) *spec.Response {
|
||||||
params := r.Context().Value(CtxParams).(params.Params)
|
params := r.Context().Value(CtxParams).(params.Params)
|
||||||
user := r.Context().Value(CtxUser).(*db.User)
|
user := r.Context().Value(CtxUser).(*db.User)
|
||||||
var playlists []*db.Playlist
|
paths, err := c.PlaylistStore.List()
|
||||||
c.DB.Where("user_id=?", user.ID).Or("is_public=?", true).Find(&playlists)
|
if err != nil {
|
||||||
|
return spec.NewError(0, "error listing playlists: %v", err)
|
||||||
|
}
|
||||||
sub := spec.NewResponse()
|
sub := spec.NewResponse()
|
||||||
sub.Playlists = &spec.Playlists{
|
sub.Playlists = &spec.Playlists{
|
||||||
List: make([]*spec.Playlist, len(playlists)),
|
List: []*spec.Playlist{},
|
||||||
}
|
}
|
||||||
for i, playlist := range playlists {
|
for _, path := range paths {
|
||||||
sub.Playlists.List[i] = playlistRender(c, playlist, params)
|
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
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) ServeGetPlaylist(r *http.Request) *spec.Response {
|
func (c *Controller) ServeGetPlaylist(r *http.Request) *spec.Response {
|
||||||
params := r.Context().Value(CtxParams).(params.Params)
|
params := r.Context().Value(CtxParams).(params.Params)
|
||||||
playlistID, err := params.GetFirstInt("id", "playlistId")
|
playlistID, err := params.GetFirst("id", "playlistId")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec.NewError(10, "please provide an `id` parameter")
|
return spec.NewError(10, "please provide an `id` parameter")
|
||||||
}
|
}
|
||||||
playlist := db.Playlist{}
|
playlist, err := c.PlaylistStore.Read(playlistIDDecode(playlistID))
|
||||||
err = c.DB.
|
if err != nil {
|
||||||
Where("id=?", playlistID).
|
return spec.NewError(70, "playlist with id %s not found", playlistID)
|
||||||
Find(&playlist).
|
|
||||||
Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return spec.NewError(70, "playlist with id `%d` not found", playlistID)
|
|
||||||
}
|
}
|
||||||
sub := spec.NewResponse()
|
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
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) ServeCreatePlaylist(r *http.Request) *spec.Response {
|
func (c *Controller) ServeCreatePlaylist(r *http.Request) *spec.Response {
|
||||||
user := r.Context().Value(CtxUser).(*db.User)
|
user := r.Context().Value(CtxUser).(*db.User)
|
||||||
params := r.Context().Value(CtxParams).(params.Params)
|
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 {
|
if playlist.UserID != 0 && playlist.UserID != user.ID {
|
||||||
return spec.NewResponse()
|
return spec.NewResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
playlist.UserID = user.ID
|
playlist.UserID = user.ID
|
||||||
|
playlist.UpdatedAt = time.Now()
|
||||||
|
|
||||||
if val, err := params.Get("name"); err == nil {
|
if val, err := params.Get("name"); err == nil {
|
||||||
playlist.Name = val
|
playlist.Name = val
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace song IDs
|
playlist.Items = nil
|
||||||
trackIDs, _ := params.GetIDList("songId")
|
ids := params.GetOrIDList("songId", nil)
|
||||||
// Set the items of the playlist
|
for _, id := range ids {
|
||||||
playlist.SetItems(trackIDs)
|
r, err := specidpaths.Locate(c.DB, c.PodcastsPath, id)
|
||||||
c.DB.Save(playlist)
|
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 := 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
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) ServeUpdatePlaylist(r *http.Request) *spec.Response {
|
func (c *Controller) ServeUpdatePlaylist(r *http.Request) *spec.Response {
|
||||||
user := r.Context().Value(CtxUser).(*db.User)
|
user := r.Context().Value(CtxUser).(*db.User)
|
||||||
params := r.Context().Value(CtxParams).(params.Params)
|
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 {
|
if playlist.UserID != 0 && playlist.UserID != user.ID {
|
||||||
return spec.NewResponse()
|
return spec.NewResponse()
|
||||||
}
|
}
|
||||||
playlist.UserID = user.ID
|
|
||||||
if val, err := params.Get("name"); err == nil {
|
if val, err := params.Get("name"); err == nil {
|
||||||
playlist.Name = val
|
playlist.Name = val
|
||||||
}
|
}
|
||||||
@@ -169,30 +141,101 @@ func (c *Controller) ServeUpdatePlaylist(r *http.Request) *spec.Response {
|
|||||||
if val, err := params.GetBool("public"); err == nil {
|
if val, err := params.GetBool("public"); err == nil {
|
||||||
playlist.IsPublic = val
|
playlist.IsPublic = val
|
||||||
}
|
}
|
||||||
trackIDs := playlist.GetItems()
|
|
||||||
|
|
||||||
// delete items
|
// delete items
|
||||||
if p, err := params.GetIntList("songIndexToRemove"); err == nil {
|
if indexes, err := params.GetIntList("songIndexToRemove"); err == nil {
|
||||||
sort.Sort(sort.Reverse(sort.IntSlice(p)))
|
sort.Sort(sort.Reverse(sort.IntSlice(indexes)))
|
||||||
for _, i := range p {
|
for _, i := range indexes {
|
||||||
trackIDs = append(trackIDs[:i], trackIDs[i+1:]...)
|
playlist.Items = append(playlist.Items[:i], playlist.Items[i+1:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// add items
|
// add items
|
||||||
if p, err := params.GetIDList("songIdToAdd"); err == nil {
|
if ids, err := params.GetIDList("songIdToAdd"); err == nil {
|
||||||
trackIDs = append(trackIDs, p...)
|
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)
|
if err := c.PlaylistStore.Write(playlistPath, playlist); err != nil {
|
||||||
c.DB.Save(playlist)
|
return spec.NewError(0, "save playlist: %v", err)
|
||||||
|
}
|
||||||
return spec.NewResponse()
|
return spec.NewResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) ServeDeletePlaylist(r *http.Request) *spec.Response {
|
func (c *Controller) ServeDeletePlaylist(r *http.Request) *spec.Response {
|
||||||
params := r.Context().Value(CtxParams).(params.Params)
|
params := r.Context().Value(CtxParams).(params.Params)
|
||||||
c.DB.
|
playlistID := params.GetFirstOr( /* default */ "", "id", "playlistId")
|
||||||
Where("id=?", params.GetOrInt("id", 0)).
|
if err := c.PlaylistStore.Delete(playlistIDDecode(playlistID)); err != nil {
|
||||||
Delete(&db.Playlist{})
|
return spec.NewError(0, "delete playlist: %v", err)
|
||||||
|
}
|
||||||
return spec.NewResponse()
|
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 {
|
type Playlist struct {
|
||||||
ID int `xml:"id,attr" json:"id"`
|
ID string `xml:"id,attr" json:"id"`
|
||||||
Name string `xml:"name,attr" json:"name"`
|
Name string `xml:"name,attr" json:"name"`
|
||||||
Comment string `xml:"comment,attr" json:"comment"`
|
Comment string `xml:"comment,attr" json:"comment"`
|
||||||
Owner string `xml:"owner,attr" json:"owner"`
|
Owner string `xml:"owner,attr" json:"owner"`
|
||||||
SongCount int `xml:"songCount,attr" json:"songCount"`
|
SongCount int `xml:"songCount,attr" json:"songCount"`
|
||||||
Created time.Time `xml:"created,attr" json:"created"`
|
Created time.Time `xml:"created,attr" json:"created"`
|
||||||
Duration int `xml:"duration,attr" json:"duration,omitempty"`
|
Duration int `xml:"duration,attr" json:"duration,omitempty"`
|
||||||
Public bool `xml:"public,attr" json:"public,omitempty"`
|
Public bool `xml:"public,attr" json:"public,omitempty"`
|
||||||
List []*TrackChild `xml:"entry" json:"entry"`
|
List []*TrackChild `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SimilarArtist struct {
|
type SimilarArtist struct {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
"go.senan.xyz/gonic/jukebox"
|
"go.senan.xyz/gonic/jukebox"
|
||||||
|
"go.senan.xyz/gonic/playlist"
|
||||||
"go.senan.xyz/gonic/podcasts"
|
"go.senan.xyz/gonic/podcasts"
|
||||||
"go.senan.xyz/gonic/scanner"
|
"go.senan.xyz/gonic/scanner"
|
||||||
"go.senan.xyz/gonic/scanner/tags"
|
"go.senan.xyz/gonic/scanner/tags"
|
||||||
@@ -35,6 +36,7 @@ type Options struct {
|
|||||||
PodcastPath string
|
PodcastPath string
|
||||||
CacheAudioPath string
|
CacheAudioPath string
|
||||||
CoverCachePath string
|
CoverCachePath string
|
||||||
|
PlaylistsPath string
|
||||||
ProxyPrefix string
|
ProxyPrefix string
|
||||||
GenreSplit string
|
GenreSplit string
|
||||||
HTTPLog bool
|
HTTPLog bool
|
||||||
@@ -53,10 +55,17 @@ func New(opts Options) (*Server, error) {
|
|||||||
tagger := &tags.TagReader{}
|
tagger := &tags.TagReader{}
|
||||||
|
|
||||||
scanner := scanner.New(ctrlsubsonic.PathsOf(opts.MusicPaths), opts.DB, opts.GenreSplit, tagger, opts.ExcludePattern)
|
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{
|
base := &ctrlbase.Controller{
|
||||||
DB: opts.DB,
|
DB: opts.DB,
|
||||||
ProxyPrefix: opts.ProxyPrefix,
|
PlaylistStore: playlistStore,
|
||||||
Scanner: scanner,
|
ProxyPrefix: opts.ProxyPrefix,
|
||||||
|
Scanner: scanner,
|
||||||
}
|
}
|
||||||
|
|
||||||
// router with common wares for admin / subsonic
|
// 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("/unlink_lastfm_do", ctrl.H(ctrl.ServeUnlinkLastFMDo))
|
||||||
routUser.Handle("/link_listenbrainz_do", ctrl.H(ctrl.ServeLinkListenBrainzDo))
|
routUser.Handle("/link_listenbrainz_do", ctrl.H(ctrl.ServeLinkListenBrainzDo))
|
||||||
routUser.Handle("/unlink_listenbrainz_do", ctrl.H(ctrl.ServeUnlinkListenBrainzDo))
|
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("/create_transcode_pref_do", ctrl.H(ctrl.ServeCreateTranscodePrefDo))
|
||||||
routUser.Handle("/delete_transcode_pref_do", ctrl.H(ctrl.ServeDeleteTranscodePrefDo))
|
routUser.Handle("/delete_transcode_pref_do", ctrl.H(ctrl.ServeDeleteTranscodePrefDo))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user