feat(subsonic): add support for podcast episodes in both playlists and play queues
This commit is contained in:
@@ -45,6 +45,7 @@ func (db *DB) Migrate(ctx MigrationContext) error {
|
|||||||
construct(ctx, "202206011628", migrateInternetRadioStations),
|
construct(ctx, "202206011628", migrateInternetRadioStations),
|
||||||
construct(ctx, "202206101425", migrateUser),
|
construct(ctx, "202206101425", migrateUser),
|
||||||
construct(ctx, "202207251148", migrateStarRating),
|
construct(ctx, "202207251148", migrateStarRating),
|
||||||
|
construct(ctx, "202211111057", migratePlaylistsQueuesToFullID),
|
||||||
}
|
}
|
||||||
|
|
||||||
return gormigrate.
|
return gormigrate.
|
||||||
@@ -386,3 +387,57 @@ func migrateStarRating(tx *gorm.DB, _ MigrationContext) error {
|
|||||||
).
|
).
|
||||||
Error
|
Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migratePlaylistsQueuesToFullID(tx *gorm.DB, _ MigrationContext) error {
|
||||||
|
step := tx.Exec(`
|
||||||
|
UPDATE playlists SET items=('tr-' || items) WHERE items IS NOT NULL;
|
||||||
|
`)
|
||||||
|
if err := step.Error; err != nil {
|
||||||
|
return fmt.Errorf("step migrate playlists to full id: %w", err)
|
||||||
|
}
|
||||||
|
step = tx.Exec(`
|
||||||
|
UPDATE playlists SET items=REPLACE(items,',',',tr-') WHERE items IS NOT NULL;
|
||||||
|
`)
|
||||||
|
if err := step.Error; err != nil {
|
||||||
|
return fmt.Errorf("step migrate playlists to full id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
step = tx.Exec(`
|
||||||
|
UPDATE play_queues SET items=('tr-' || items) WHERE items IS NOT NULL;
|
||||||
|
`)
|
||||||
|
if err := step.Error; err != nil {
|
||||||
|
return fmt.Errorf("step migrate play_queues to full id: %w", err)
|
||||||
|
}
|
||||||
|
step = tx.Exec(`
|
||||||
|
UPDATE play_queues SET items=REPLACE(items,',',',tr-') WHERE items IS NOT NULL;
|
||||||
|
`)
|
||||||
|
if err := step.Error; err != nil {
|
||||||
|
return fmt.Errorf("step migrate play_queues to full id: %w", err)
|
||||||
|
}
|
||||||
|
step = tx.Exec(`
|
||||||
|
ALTER TABLE play_queues ADD COLUMN newcurrent varchar[255];
|
||||||
|
`)
|
||||||
|
if err := step.Error; err != nil {
|
||||||
|
return fmt.Errorf("step migrate play_queues to full id: %w", err)
|
||||||
|
}
|
||||||
|
step = tx.Exec(`
|
||||||
|
UPDATE play_queues SET newcurrent=('tr-' || CAST(current AS varchar(10)));
|
||||||
|
`)
|
||||||
|
if err := step.Error; err != nil {
|
||||||
|
return fmt.Errorf("step migrate play_queues to full id: %w", err)
|
||||||
|
}
|
||||||
|
step = tx.Exec(`
|
||||||
|
ALTER TABLE play_queues DROP COLUMN current;
|
||||||
|
`)
|
||||||
|
if err := step.Error; err != nil {
|
||||||
|
return fmt.Errorf("step migrate play_queues to full id: %w", err)
|
||||||
|
}
|
||||||
|
step = tx.Exec(`
|
||||||
|
ALTER TABLE play_queues RENAME COLUMN newcurrent TO "current";
|
||||||
|
`)
|
||||||
|
if err := step.Error; err != nil {
|
||||||
|
return fmt.Errorf("step migrate play_queues to full id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
38
db/model.go
38
db/model.go
@@ -9,7 +9,6 @@ package db
|
|||||||
import (
|
import (
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,26 +18,26 @@ import (
|
|||||||
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func splitInt(in, sep string) []int {
|
func splitIDs(in, sep string) []specid.ID {
|
||||||
if in == "" {
|
if in == "" {
|
||||||
return []int{}
|
return []specid.ID{}
|
||||||
}
|
}
|
||||||
parts := strings.Split(in, sep)
|
parts := strings.Split(in, sep)
|
||||||
ret := make([]int, 0, len(parts))
|
ret := make([]specid.ID, 0, len(parts))
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
i, _ := strconv.Atoi(p)
|
id, _ := specid.New(p)
|
||||||
ret = append(ret, i)
|
ret = append(ret, id)
|
||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func joinInt(in []int, sep string) string {
|
func joinIds(in []specid.ID, sep string) string {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
strs := make([]string, 0, len(in))
|
strs := make([]string, 0, len(in))
|
||||||
for _, i := range in {
|
for _, id := range in {
|
||||||
strs = append(strs, strconv.Itoa(i))
|
strs = append(strs, id.String())
|
||||||
}
|
}
|
||||||
return strings.Join(strs, sep)
|
return strings.Join(strs, sep)
|
||||||
}
|
}
|
||||||
@@ -256,12 +255,12 @@ type Playlist struct {
|
|||||||
IsPublic bool `sql:"default: null"`
|
IsPublic bool `sql:"default: null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Playlist) GetItems() []int {
|
func (p *Playlist) GetItems() []specid.ID {
|
||||||
return splitInt(p.Items, ",")
|
return splitIDs(p.Items, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Playlist) SetItems(items []int) {
|
func (p *Playlist) SetItems(items []specid.ID) {
|
||||||
p.Items = joinInt(items, ",")
|
p.Items = joinIds(items, ",")
|
||||||
p.TrackCount = len(items)
|
p.TrackCount = len(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,22 +270,23 @@ type PlayQueue struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
User *User
|
User *User
|
||||||
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
|
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
|
||||||
Current int
|
Current string
|
||||||
Position int
|
Position int
|
||||||
ChangedBy string
|
ChangedBy string
|
||||||
Items string
|
Items string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlayQueue) CurrentSID() *specid.ID {
|
func (p *PlayQueue) CurrentSID() *specid.ID {
|
||||||
return &specid.ID{Type: specid.Track, Value: p.Current}
|
id, _ := specid.New(p.Current)
|
||||||
|
return &id
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlayQueue) GetItems() []int {
|
func (p *PlayQueue) GetItems() []specid.ID {
|
||||||
return splitInt(p.Items, ",")
|
return splitIDs(p.Items, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlayQueue) SetItems(items []int) {
|
func (p *PlayQueue) SetItems(items []specid.ID) {
|
||||||
p.Items = joinInt(items, ",")
|
p.Items = joinIds(items, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
type TranscodePreference struct {
|
type TranscodePreference struct {
|
||||||
|
|||||||
@@ -13,15 +13,16 @@ import (
|
|||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
|
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
|
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errPlaylistNoMatch = errors.New("couldn't match track")
|
errPlaylistNoMatch = errors.New("couldn't match track")
|
||||||
)
|
)
|
||||||
|
|
||||||
func playlistParseLine(c *Controller, absPath string) (int, error) {
|
func playlistParseLine(c *Controller, absPath string) (*specid.ID, error) {
|
||||||
if strings.HasPrefix(absPath, "#") || strings.TrimSpace(absPath) == "" {
|
if strings.HasPrefix(absPath, "#") || strings.TrimSpace(absPath) == "" {
|
||||||
return 0, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var track db.Track
|
var track db.Track
|
||||||
query := c.DB.Raw(`
|
query := c.DB.Raw(`
|
||||||
@@ -30,14 +31,23 @@ func playlistParseLine(c *Controller, absPath string) (int, error) {
|
|||||||
WHERE (albums.root_dir || ? || albums.left_path || albums.right_path || ? || tracks.filename)=?`,
|
WHERE (albums.root_dir || ? || albums.left_path || albums.right_path || ? || tracks.filename)=?`,
|
||||||
string(os.PathSeparator), string(os.PathSeparator), absPath)
|
string(os.PathSeparator), string(os.PathSeparator), absPath)
|
||||||
err := query.First(&track).Error
|
err := query.First(&track).Error
|
||||||
switch {
|
if err == nil {
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
return &specid.ID{Type: specid.Track, Value: track.ID}, nil
|
||||||
return 0, fmt.Errorf("%v: %w", err, errPlaylistNoMatch)
|
|
||||||
case err != nil:
|
|
||||||
return 0, fmt.Errorf("while matching: %w", err)
|
|
||||||
default:
|
|
||||||
return 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 {
|
func playlistCheckContentType(contentType string) bool {
|
||||||
@@ -65,7 +75,7 @@ func playlistParseUpload(c *Controller, userID int, header *multipart.FileHeader
|
|||||||
if !playlistCheckContentType(contentType) {
|
if !playlistCheckContentType(contentType) {
|
||||||
return []string{fmt.Sprintf("invalid content-type %q", contentType)}, false
|
return []string{fmt.Sprintf("invalid content-type %q", contentType)}, false
|
||||||
}
|
}
|
||||||
var trackIDs []int
|
var trackIDs []specid.ID
|
||||||
var errors []string
|
var errors []string
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
@@ -74,8 +84,8 @@ func playlistParseUpload(c *Controller, userID int, header *multipart.FileHeader
|
|||||||
// trim length of error to not overflow cookie flash
|
// trim length of error to not overflow cookie flash
|
||||||
errors = append(errors, fmt.Sprintf("%.100s", err.Error()))
|
errors = append(errors, fmt.Sprintf("%.100s", err.Error()))
|
||||||
}
|
}
|
||||||
if trackID != 0 {
|
if trackID.Value != 0 {
|
||||||
trackIDs = append(trackIDs, trackID)
|
trackIDs = append(trackIDs, *trackID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
|
|||||||
@@ -167,16 +167,31 @@ func (c *Controller) ServeGetPlayQueue(r *http.Request) *spec.Response {
|
|||||||
transcodeMIME, transcodeSuffix := streamGetTransPrefProfile(c.DB, user.ID, params.GetOr("c", ""))
|
transcodeMIME, transcodeSuffix := streamGetTransPrefProfile(c.DB, user.ID, params.GetOr("c", ""))
|
||||||
|
|
||||||
for i, id := range trackIDs {
|
for i, id := range trackIDs {
|
||||||
track := db.Track{}
|
switch id.Type {
|
||||||
c.DB.
|
case specid.Track:
|
||||||
Where("id=?", id).
|
track := db.Track{}
|
||||||
Preload("Album").
|
c.DB.
|
||||||
Preload("TrackStar", "user_id=?", user.ID).
|
Where("id=?", id.Value).
|
||||||
Preload("TrackRating", "user_id=?", user.ID).
|
Preload("Album").
|
||||||
Find(&track)
|
Preload("TrackStar", "user_id=?", user.ID).
|
||||||
sub.PlayQueue.List[i] = spec.NewTCTrackByFolder(&track, track.Album)
|
Preload("TrackRating", "user_id=?", user.ID).
|
||||||
sub.PlayQueue.List[i].TranscodedContentType = transcodeMIME
|
Find(&track)
|
||||||
sub.PlayQueue.List[i].TranscodedSuffix = transcodeSuffix
|
sub.PlayQueue.List[i] = spec.NewTCTrackByFolder(&track, track.Album)
|
||||||
|
sub.PlayQueue.List[i].TranscodedContentType = transcodeMIME
|
||||||
|
sub.PlayQueue.List[i].TranscodedSuffix = transcodeSuffix
|
||||||
|
case specid.PodcastEpisode:
|
||||||
|
pe := db.PodcastEpisode{}
|
||||||
|
c.DB.
|
||||||
|
Where("id=?", id.Value).
|
||||||
|
Find(&pe)
|
||||||
|
p := db.Podcast{}
|
||||||
|
c.DB.
|
||||||
|
Where("id=?", pe.PodcastID).
|
||||||
|
Find(&p)
|
||||||
|
sub.PlayQueue.List[i] = spec.NewTCPodcastEpisode(&pe, &p)
|
||||||
|
sub.PlayQueue.List[i].TranscodedContentType = transcodeMIME
|
||||||
|
sub.PlayQueue.List[i].TranscodedSuffix = transcodeSuffix
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
@@ -187,11 +202,10 @@ func (c *Controller) ServeSavePlayQueue(r *http.Request) *spec.Response {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return spec.NewError(10, "please provide some `id` parameters")
|
return spec.NewError(10, "please provide some `id` parameters")
|
||||||
}
|
}
|
||||||
// TODO: support other play queue entries other than tracks
|
trackIDs := make([]specid.ID, 0, len(tracks))
|
||||||
trackIDs := make([]int, 0, len(tracks))
|
|
||||||
for _, id := range tracks {
|
for _, id := range tracks {
|
||||||
if id.Type == specid.Track {
|
if (id.Type == specid.Track) || (id.Type == specid.PodcastEpisode) {
|
||||||
trackIDs = append(trackIDs, id.Value)
|
trackIDs = append(trackIDs, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(trackIDs) == 0 {
|
if len(trackIDs) == 0 {
|
||||||
@@ -201,7 +215,7 @@ func (c *Controller) ServeSavePlayQueue(r *http.Request) *spec.Response {
|
|||||||
var queue db.PlayQueue
|
var queue db.PlayQueue
|
||||||
c.DB.Where("user_id=?", user.ID).First(&queue)
|
c.DB.Where("user_id=?", user.ID).First(&queue)
|
||||||
queue.UserID = user.ID
|
queue.UserID = user.ID
|
||||||
queue.Current = params.GetOrID("current", specid.ID{}).Value
|
queue.Current = params.GetOrID("current", specid.ID{}).String()
|
||||||
queue.Position = params.GetOrInt("position", 0)
|
queue.Position = params.GetOrInt("position", 0)
|
||||||
queue.ChangedBy = params.GetOr("c", "") // must exist, middleware checks
|
queue.ChangedBy = params.GetOr("c", "") // must exist, middleware checks
|
||||||
queue.SetItems(trackIDs)
|
queue.SetItems(trackIDs)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func playlistRender(c *Controller, playlist *db.Playlist, params params.Params) *spec.Playlist {
|
func playlistRender(c *Controller, playlist *db.Playlist, params params.Params) *spec.Playlist {
|
||||||
@@ -33,23 +34,47 @@ func playlistRender(c *Controller, playlist *db.Playlist, params params.Params)
|
|||||||
transcodeMIME, transcodeSuffix := streamGetTransPrefProfile(c.DB, user.ID, params.GetOr("c", ""))
|
transcodeMIME, transcodeSuffix := streamGetTransPrefProfile(c.DB, user.ID, params.GetOr("c", ""))
|
||||||
|
|
||||||
for i, id := range trackIDs {
|
for i, id := range trackIDs {
|
||||||
track := db.Track{}
|
switch id.Type {
|
||||||
err := c.DB.
|
case specid.Track:
|
||||||
Where("id=?", id).
|
track := db.Track{}
|
||||||
Preload("Album").
|
err := c.DB.
|
||||||
Preload("Album.TagArtist").
|
Where("id=?", id.Value).
|
||||||
Preload("TrackStar", "user_id=?", user.ID).
|
Preload("Album").
|
||||||
Preload("TrackRating", "user_id=?", user.ID).
|
Preload("Album.TagArtist").
|
||||||
Find(&track).
|
Preload("TrackStar", "user_id=?", user.ID).
|
||||||
Error
|
Preload("TrackRating", "user_id=?", user.ID).
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
Find(&track).
|
||||||
log.Printf("wasn't able to find track with id %d", id)
|
Error
|
||||||
continue
|
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] = spec.NewTCTrackByFolder(&track, track.Album)
|
|
||||||
resp.List[i].TranscodedContentType = transcodeMIME
|
resp.List[i].TranscodedContentType = transcodeMIME
|
||||||
resp.List[i].TranscodedSuffix = transcodeSuffix
|
resp.List[i].TranscodedSuffix = transcodeSuffix
|
||||||
resp.Duration += track.Length
|
|
||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
@@ -109,12 +134,7 @@ func (c *Controller) ServeCreatePlaylist(r *http.Request) *spec.Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// replace song IDs
|
// replace song IDs
|
||||||
var trackIDs []int
|
trackIDs, _ := params.GetIDList("songId")
|
||||||
if p, err := params.GetIDList("songId"); err == nil {
|
|
||||||
for _, i := range p {
|
|
||||||
trackIDs = append(trackIDs, i.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Set the items of the playlist
|
// Set the items of the playlist
|
||||||
playlist.SetItems(trackIDs)
|
playlist.SetItems(trackIDs)
|
||||||
c.DB.Save(playlist)
|
c.DB.Save(playlist)
|
||||||
@@ -161,9 +181,7 @@ func (c *Controller) ServeUpdatePlaylist(r *http.Request) *spec.Response {
|
|||||||
|
|
||||||
// add items
|
// add items
|
||||||
if p, err := params.GetIDList("songIdToAdd"); err == nil {
|
if p, err := params.GetIDList("songIdToAdd"); err == nil {
|
||||||
for _, i := range p {
|
trackIDs = append(trackIDs, p...)
|
||||||
trackIDs = append(trackIDs, i.Value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playlist.SetItems(trackIDs)
|
playlist.SetItems(trackIDs)
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ func NewTCTrackByFolder(t *db.Track, parent *db.Album) *TrackChild {
|
|||||||
return trCh
|
return trCh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewTCPodcastEpisode(pe *db.PodcastEpisode, parent *db.Podcast) *TrackChild {
|
||||||
|
trCh := &TrackChild{
|
||||||
|
ID: pe.SID(),
|
||||||
|
ContentType: pe.MIME(),
|
||||||
|
Suffix: pe.Ext(),
|
||||||
|
Size: pe.Size,
|
||||||
|
Title: pe.Title,
|
||||||
|
Path: pe.Path,
|
||||||
|
ParentID: parent.SID(),
|
||||||
|
Duration: pe.Length,
|
||||||
|
Bitrate: pe.Bitrate,
|
||||||
|
IsDir: false,
|
||||||
|
Type: "podcastepisode",
|
||||||
|
CreatedAt: pe.CreatedAt,
|
||||||
|
}
|
||||||
|
return trCh
|
||||||
|
}
|
||||||
|
|
||||||
func NewArtistByFolder(f *db.Album) *Artist {
|
func NewArtistByFolder(f *db.Album) *Artist {
|
||||||
// the db is structued around "browse by tags", and where
|
// the db is structued around "browse by tags", and where
|
||||||
// an album is also a folder. so we're constructing an artist
|
// an album is also a folder. so we're constructing an artist
|
||||||
|
|||||||
Reference in New Issue
Block a user