Skip to content

Commit

Permalink
feat: store and use m3u files on filesystem for playlists
Browse files Browse the repository at this point in the history
closes #306
closes #307
closes #66
  • Loading branch information
sentriz committed May 6, 2023
1 parent 1d38776 commit 7dc9575
Show file tree
Hide file tree
Showing 18 changed files with 623 additions and 357 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Expand Up @@ -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"]
1 change: 1 addition & 0 deletions Dockerfile.dev
Expand Up @@ -33,5 +33,6 @@ ENV GONIC_DB_PATH /data/gonic.db
ENV GONIC_LISTEN_ADDR :80
ENV GONIC_MUSIC_PATH /music
ENV GONIC_PODCAST_PATH /podcasts
ENV GONIC_PLAYLISTS_PATH /playlists
ENV GONIC_CACHE_PATH /cache
CMD ["gonic"]
39 changes: 20 additions & 19 deletions README.md
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions cmd/gonic/gonic.go
Expand Up @@ -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)")
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -135,6 +142,7 @@ func main() {
CacheAudioPath: cacheDirAudio,
CoverCachePath: cacheDirCovers,
PodcastPath: *confPodcastPath,
PlaylistsPath: *confPlaylistsPath,
ProxyPrefix: *confProxyPrefix,
GenreSplit: *confGenreSplit,
HTTPLog: *confHTTPLog,
Expand Down
79 changes: 77 additions & 2 deletions db/migrations.go
@@ -1,16 +1,23 @@
//nolint:goerr113
package db

import (
"errors"
"fmt"
"log"
"path/filepath"
"time"

"github.com/jinzhu/gorm"
"go.senan.xyz/gonic/playlist"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"gopkg.in/gormigrate.v1"
)

type MigrationContext struct {
OriginalMusicPath string
PlaylistsPath string
PodcastsPath string
}

func (db *DB) Migrate(ctx MigrationContext) error {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -82,7 +90,6 @@ func migrateInitSchema(tx *gorm.DB, _ MigrationContext) error {
Setting{},
Play{},
Album{},
Playlist{},
PlayQueue{},
).
Error
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
`)
Expand Down Expand Up @@ -441,3 +458,61 @@ func migratePlaylistsQueuesToFullID(tx *gorm.DB, _ MigrationContext) error {

return nil
}

func migratePlaylistsToM3U(tx *gorm.DB, ctx MigrationContext) error {
if ctx.PlaylistsPath == "" || !tx.HasTable("playlists") {
return nil
}

// local copy of specidpaths.Locate to avoid circular dep
locate := func(id specid.ID) string {
switch id.Type {
case specid.Track:
var track Track
tx.Preload("Album").Where("id=?", id.Value).Find(&track)
return track.AbsPath()
case specid.PodcastEpisode:
var pe PodcastEpisode
tx.Where("id=?", id.Value).Find(&pe)
if pe.Path == "" {
return ""
}
return filepath.Join(ctx.PodcastsPath, pe.Path)
}
return ""
}

store, err := playlist.NewStore(ctx.PlaylistsPath)
if err != nil {
return fmt.Errorf("create playlists store: %w", err)
}

var prevs []*_OldPlaylist
if err := tx.Find(&prevs).Error; err != nil {
return fmt.Errorf("fetch old playlists: %w", err)
}

for _, prev := range prevs {
var pl playlist.Playlist
pl.UpdatedAt = time.Now()
pl.UserID = prev.UserID
pl.Name = prev.Name
pl.Comment = prev.Comment
pl.IsPublic = prev.IsPublic

for _, id := range splitIDs(prev.Items, ",") {
path := locate(id)
if path == "" {
log.Printf("migrating: can't find item %s from playlist %q on filesystem", id, prev.Name)
continue
}
pl.Items = append(pl.Items, path)
}

if err := store.Write(playlist.NewPath(prev.UserID, prev.Name), &pl); err != nil {
return fmt.Errorf("write playlist: %w", err)
}
}

return nil
}
20 changes: 20 additions & 0 deletions db/migrations_old_models.go
@@ -0,0 +1,20 @@
package db

import "time"

type _OldPlaylist struct {
ID int `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
User *User
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
Name string
Comment string
TrackCount int
Items string
IsPublic bool `sql:"default: null"`
}

func (_OldPlaylist) TableName() string {
return "playlists"
}
22 changes: 0 additions & 22 deletions db/model.go
Expand Up @@ -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
Expand Down

0 comments on commit 7dc9575

Please sign in to comment.