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

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