feat: store and use m3u files on filesystem for playlists
closes #306 closes #307 closes #66
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -115,7 +115,6 @@ type templateData struct {
|
||||
AllUsers []*db.User
|
||||
LastScanTime time.Time
|
||||
IsScanning bool
|
||||
Playlists []*db.Playlist
|
||||
TranscodePreferences []*db.TranscodePreference
|
||||
TranscodeProfiles []string
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
"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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user