feat: store and use m3u files on filesystem for playlists

closes #306
closes #307
closes #66
This commit is contained in:
sentriz
2023-04-22 18:25:19 +01:00
committed by Senan Kelly
parent 1d3877668f
commit 7dc9575e52
18 changed files with 621 additions and 355 deletions

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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

View File

@@ -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,

View File

@@ -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
}

View 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"
}

View File

@@ -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
View 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
View 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)
}

View File

@@ -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))
}

View File

@@ -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 }}

View File

@@ -115,7 +115,6 @@ type templateData struct {
AllUsers []*db.User
LastScanTime time.Time
IsScanning bool
Playlists []*db.Playlist
TranscodePreferences []*db.TranscodePreference
TranscodeProfiles []string

View File

@@ -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).

View File

@@ -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",
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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))