When multiple people share the same instance, they might want to share their playlists between them. This allows people to mark playlists as public, and to listen to public playlists from other people. Listeners will also know who owns the playlist, to help avoid confusion and make this feature a bit nicer. Subsonic restrict updating playlists only to owners, this honors that behavior, but adding flexibility could be achieved easily.
403 lines
11 KiB
Go
403 lines
11 KiB
Go
// Package db provides database helpers and models
|
|
//nolint:lll // struct tags get very long and can't be split
|
|
package db
|
|
|
|
// see this db fiddle to mess around with the schema
|
|
// https://www.db-fiddle.com/f/wJ7z8L7mu6ZKaYmWk1xr1p/5
|
|
|
|
import (
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
// TODO: remove this dep
|
|
|
|
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
|
"go.senan.xyz/gonic/server/mime"
|
|
)
|
|
|
|
func splitInt(in, sep string) []int {
|
|
if in == "" {
|
|
return []int{}
|
|
}
|
|
parts := strings.Split(in, sep)
|
|
ret := make([]int, 0, len(parts))
|
|
for _, p := range parts {
|
|
i, _ := strconv.Atoi(p)
|
|
ret = append(ret, i)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func joinInt(in []int, sep string) string {
|
|
if in == nil {
|
|
return ""
|
|
}
|
|
strs := make([]string, 0, len(in))
|
|
for _, i := range in {
|
|
strs = append(strs, strconv.Itoa(i))
|
|
}
|
|
return strings.Join(strs, sep)
|
|
}
|
|
|
|
type Artist struct {
|
|
ID int `gorm:"primary_key"`
|
|
Name string `gorm:"not null; unique_index"`
|
|
NameUDec string `sql:"default: null"`
|
|
Albums []*Album `gorm:"foreignkey:TagArtistID"`
|
|
AlbumCount int `sql:"-"`
|
|
Cover string `sql:"default: null"`
|
|
}
|
|
|
|
func (a *Artist) SID() *specid.ID {
|
|
return &specid.ID{Type: specid.Artist, Value: a.ID}
|
|
}
|
|
|
|
func (a *Artist) IndexName() string {
|
|
if len(a.NameUDec) > 0 {
|
|
return a.NameUDec
|
|
}
|
|
return a.Name
|
|
}
|
|
|
|
type Genre struct {
|
|
ID int `gorm:"primary_key"`
|
|
Name string `gorm:"not null; unique_index"`
|
|
AlbumCount int `sql:"-"`
|
|
TrackCount int `sql:"-"`
|
|
}
|
|
|
|
// AudioFile is used to avoid some duplication in handlers_raw.go
|
|
// between Track and Podcast
|
|
type AudioFile interface {
|
|
AudioFilename() string
|
|
Ext() string
|
|
MIME() string
|
|
AudioBitrate() int
|
|
}
|
|
|
|
type Track struct {
|
|
ID int `gorm:"primary_key"`
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
Filename string `gorm:"not null; unique_index:idx_folder_filename" sql:"default: null"`
|
|
FilenameUDec string `sql:"default: null"`
|
|
Album *Album
|
|
AlbumID int `gorm:"not null; unique_index:idx_folder_filename" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
|
|
Artist *Artist
|
|
ArtistID int `gorm:"not null" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"`
|
|
Genres []*Genre `gorm:"many2many:track_genres"`
|
|
Size int `sql:"default: null"`
|
|
Length int `sql:"default: null"`
|
|
Bitrate int `sql:"default: null"`
|
|
TagTitle string `sql:"default: null"`
|
|
TagTitleUDec string `sql:"default: null"`
|
|
TagTrackArtist string `sql:"default: null"`
|
|
TagTrackNumber int `sql:"default: null"`
|
|
TagDiscNumber int `sql:"default: null"`
|
|
TagBrainzID string `sql:"default: null"`
|
|
}
|
|
|
|
func (t *Track) SID() *specid.ID {
|
|
return &specid.ID{Type: specid.Track, Value: t.ID}
|
|
}
|
|
|
|
func (t *Track) AlbumSID() *specid.ID {
|
|
return &specid.ID{Type: specid.Album, Value: t.AlbumID}
|
|
}
|
|
|
|
func (t *Track) ArtistSID() *specid.ID {
|
|
return &specid.ID{Type: specid.Artist, Value: t.ArtistID}
|
|
}
|
|
|
|
func (t *Track) Ext() string {
|
|
longExt := path.Ext(t.Filename)
|
|
if len(longExt) < 1 {
|
|
return ""
|
|
}
|
|
return longExt[1:]
|
|
}
|
|
|
|
func (t *Track) AudioFilename() string {
|
|
return t.Filename
|
|
}
|
|
|
|
func (t *Track) AudioBitrate() int {
|
|
return t.Bitrate
|
|
}
|
|
|
|
func (t *Track) MIME() string {
|
|
v, _ := mime.FromExtension(t.Ext())
|
|
return v
|
|
}
|
|
|
|
func (t *Track) AbsPath() string {
|
|
if t.Album == nil {
|
|
return ""
|
|
}
|
|
return path.Join(
|
|
t.Album.RootDir,
|
|
t.Album.LeftPath,
|
|
t.Album.RightPath,
|
|
t.Filename,
|
|
)
|
|
}
|
|
|
|
func (t *Track) RelPath() string {
|
|
if t.Album == nil {
|
|
return ""
|
|
}
|
|
return path.Join(
|
|
t.Album.LeftPath,
|
|
t.Album.RightPath,
|
|
t.Filename,
|
|
)
|
|
}
|
|
|
|
func (t *Track) GenreStrings() []string {
|
|
strs := make([]string, 0, len(t.Genres))
|
|
for _, genre := range t.Genres {
|
|
strs = append(strs, genre.Name)
|
|
}
|
|
return strs
|
|
}
|
|
|
|
type User struct {
|
|
ID int `gorm:"primary_key"`
|
|
CreatedAt time.Time
|
|
Name string `gorm:"not null; unique_index" sql:"default: null"`
|
|
Password string `gorm:"not null" sql:"default: null"`
|
|
LastFMSession string `sql:"default: null"`
|
|
ListenBrainzURL string `sql:"default: null"`
|
|
ListenBrainzToken string `sql:"default: null"`
|
|
IsAdmin bool `sql:"default: null"`
|
|
}
|
|
|
|
type Setting struct {
|
|
Key string `gorm:"not null; primary_key; auto_increment:false" sql:"default: null"`
|
|
Value string `sql:"default: null"`
|
|
}
|
|
|
|
type Play struct {
|
|
ID int `gorm:"primary_key"`
|
|
User *User
|
|
UserID int `gorm:"not null; index" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
|
|
Album *Album
|
|
AlbumID int `gorm:"not null; index" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
|
|
Time time.Time `sql:"default: null"`
|
|
Count int
|
|
}
|
|
|
|
type Album struct {
|
|
ID int `gorm:"primary_key"`
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
ModifiedAt time.Time
|
|
LeftPath string `gorm:"unique_index:idx_album_abs_path"`
|
|
RightPath string `gorm:"not null; unique_index:idx_album_abs_path" sql:"default: null"`
|
|
RightPathUDec string `sql:"default: null"`
|
|
Parent *Album
|
|
ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
|
|
RootDir string `gorm:"unique_index:idx_album_abs_path" sql:"default: null"`
|
|
Genres []*Genre `gorm:"many2many:album_genres"`
|
|
Cover string `sql:"default: null"`
|
|
TagArtist *Artist
|
|
TagArtistID int `gorm:"index" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"`
|
|
TagTitle string `sql:"default: null"`
|
|
TagTitleUDec string `sql:"default: null"`
|
|
TagBrainzID string `sql:"default: null"`
|
|
TagYear int `sql:"default: null"`
|
|
Tracks []*Track
|
|
ChildCount int `sql:"-"`
|
|
Duration int `sql:"-"`
|
|
}
|
|
|
|
func (a *Album) SID() *specid.ID {
|
|
return &specid.ID{Type: specid.Album, Value: a.ID}
|
|
}
|
|
|
|
func (a *Album) ParentSID() *specid.ID {
|
|
return &specid.ID{Type: specid.Album, Value: a.ParentID}
|
|
}
|
|
|
|
func (a *Album) IndexRightPath() string {
|
|
if len(a.RightPathUDec) > 0 {
|
|
return a.RightPathUDec
|
|
}
|
|
return a.RightPath
|
|
}
|
|
|
|
func (a *Album) GenreStrings() []string {
|
|
strs := make([]string, 0, len(a.Genres))
|
|
for _, genre := range a.Genres {
|
|
strs = append(strs, genre.Name)
|
|
}
|
|
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() []int {
|
|
return splitInt(p.Items, ",")
|
|
}
|
|
|
|
func (p *Playlist) SetItems(items []int) {
|
|
p.Items = joinInt(items, ",")
|
|
p.TrackCount = len(items)
|
|
}
|
|
|
|
type PlayQueue 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"`
|
|
Current int
|
|
Position int
|
|
ChangedBy string
|
|
Items string
|
|
}
|
|
|
|
func (p *PlayQueue) CurrentSID() *specid.ID {
|
|
return &specid.ID{Type: specid.Track, Value: p.Current}
|
|
}
|
|
|
|
func (p *PlayQueue) GetItems() []int {
|
|
return splitInt(p.Items, ",")
|
|
}
|
|
|
|
func (p *PlayQueue) SetItems(items []int) {
|
|
p.Items = joinInt(items, ",")
|
|
}
|
|
|
|
type TranscodePreference struct {
|
|
User *User
|
|
UserID int `gorm:"not null; unique_index:idx_user_id_client" sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
|
|
Client string `gorm:"not null; unique_index:idx_user_id_client" sql:"default: null"`
|
|
Profile string `gorm:"not null" sql:"default: null"`
|
|
}
|
|
|
|
type TrackGenre struct {
|
|
Track *Track
|
|
TrackID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES tracks(id) ON DELETE CASCADE"`
|
|
Genre *Genre
|
|
GenreID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"`
|
|
}
|
|
|
|
type AlbumGenre struct {
|
|
Album *Album
|
|
AlbumID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
|
|
Genre *Genre
|
|
GenreID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"`
|
|
}
|
|
|
|
type PodcastAutoDownload string
|
|
|
|
const (
|
|
PodcastAutoDownloadLatest PodcastAutoDownload = "latest"
|
|
PodcastAutoDownloadNone PodcastAutoDownload = "none"
|
|
)
|
|
|
|
type Podcast struct {
|
|
ID int `gorm:"primary_key"`
|
|
UpdatedAt time.Time
|
|
ModifiedAt time.Time
|
|
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
|
|
URL string
|
|
Title string
|
|
Description string
|
|
ImageURL string
|
|
ImagePath string
|
|
Error string
|
|
Episodes []*PodcastEpisode
|
|
AutoDownload PodcastAutoDownload
|
|
}
|
|
|
|
func (p *Podcast) Fullpath(podcastPath string) string {
|
|
sanitizedTitle := strings.ReplaceAll(p.Title, "/", "_")
|
|
return filepath.Join(podcastPath, filepath.Clean(sanitizedTitle))
|
|
}
|
|
|
|
func (p *Podcast) SID() *specid.ID {
|
|
return &specid.ID{Type: specid.Podcast, Value: p.ID}
|
|
}
|
|
|
|
type PodcastEpisodeStatus string
|
|
|
|
const (
|
|
PodcastEpisodeStatusDownloading PodcastEpisodeStatus = "downloading"
|
|
PodcastEpisodeStatusSkipped PodcastEpisodeStatus = "skipped"
|
|
PodcastEpisodeStatusDeleted PodcastEpisodeStatus = "deleted"
|
|
PodcastEpisodeStatusCompleted PodcastEpisodeStatus = "completed"
|
|
PodcastEpisodeStatusError PodcastEpisodeStatus = "error"
|
|
)
|
|
|
|
type PodcastEpisode struct {
|
|
ID int `gorm:"primary_key"`
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
ModifiedAt time.Time
|
|
PodcastID int `gorm:"not null" sql:"default: null; type:int REFERENCES podcasts(id) ON DELETE CASCADE"`
|
|
Title string
|
|
Description string
|
|
PublishDate *time.Time
|
|
AudioURL string
|
|
Bitrate int
|
|
Length int
|
|
Size int
|
|
Path string
|
|
Filename string
|
|
Status PodcastEpisodeStatus
|
|
Error string
|
|
}
|
|
|
|
func (pe *PodcastEpisode) SID() *specid.ID {
|
|
return &specid.ID{Type: specid.PodcastEpisode, Value: pe.ID}
|
|
}
|
|
|
|
func (pe *PodcastEpisode) AudioFilename() string {
|
|
return pe.Filename
|
|
}
|
|
|
|
func (pe *PodcastEpisode) Ext() string {
|
|
longExt := path.Ext(pe.Filename)
|
|
if len(longExt) < 1 {
|
|
return ""
|
|
}
|
|
return longExt[1:]
|
|
}
|
|
|
|
func (pe *PodcastEpisode) MIME() string {
|
|
v, _ := mime.FromExtension(pe.Ext())
|
|
return v
|
|
}
|
|
|
|
func (pe *PodcastEpisode) AudioBitrate() int {
|
|
return pe.Bitrate
|
|
}
|
|
|
|
type Bookmark struct {
|
|
ID int `gorm:"primary_key"`
|
|
User *User
|
|
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
|
|
Position int
|
|
Comment string
|
|
EntryIDType string
|
|
EntryID int
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|