Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,19 @@ metadata:
secret: <discogs_token> # You can get it here -> https://www.discogs.com/settings/developers
musicbrainz:
enabled: true
lyrics:
providers:
lrclib:
lyrics:
providers:
lrclib:
enabled: true
players:
emby:
enabled: true
url: http://emby-server:8096
api_key: <api_key>
plex:
enabled: true
url: http://plex-server:32400
token: <plex_token>
sync:
enabled: true
devices:
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ require (
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bogem/id3v2 v1.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
Expand All @@ -42,6 +43,8 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mewkiz/flac v1.0.5 // indirect
github.com/mjibson/moggio v0.2.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
Expand Down
87 changes: 87 additions & 0 deletions go.sum

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions server.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
2025/12/31 21:49:33 INFO Required directories created/verified library=./music_library downloads=./downloads
9:49PM INFO <logging/logger.go:47> Soulsolid: Logger initialized
9:49PM INFO <downloading/plugins.go:34> Soulsolid: Loading downloader plugins
9:49PM ERRO <downloading/plugins.go:38> Soulsolid: Failed to load plugin name=deezer path=../soulsolid-deemix-plugin/plugin.so error="failed to open plugin ../soulsolid-deemix-plugin/plugin.so: plugin.Open(\"../soulsolid-deemix-plugin/plugin\"): plugin was built with a different version of package crypto/x509"
9:49PM INFO <downloading/plugins.go:46> Soulsolid: Plugin loading completed total_downloaders=0
9:49PM INFO <src/main.go:171> Soulsolid: Starting server port=3535
9:49PM ERRO <src/main.go:173> Soulsolid: server stopped: %v error="failed to listen: listen tcp4 :3535: bind: address already in use"
12 changes: 12 additions & 0 deletions src/features/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Config struct {
Import Import `yaml:"import"`
Metadata Metadata `yaml:"metadata"`
Lyrics Lyrics `yaml:"lyrics"`
Players Players `yaml:"players"`
Sync Sync `yaml:"sync"`
Jobs Jobs `yaml:"jobs"`
}
Expand Down Expand Up @@ -86,6 +87,17 @@ type Lyrics struct {
Providers map[string]Provider `yaml:"providers"`
}

// Players holds the configuration for external media player providers
type Players map[string]PlayerProvider

// PlayerProvider holds configuration for individual player providers
type PlayerProvider struct {
Enabled bool `yaml:"enabled"`
URL *string `yaml:"url,omitempty"`
APIKey *string `yaml:"api_key,omitempty"`
Token *string `yaml:"token,omitempty"`
}

// Provider holds configuration for individual tagging providers
type Provider struct {
Enabled bool `yaml:"enabled"`
Expand Down
7 changes: 6 additions & 1 deletion src/features/hosting/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/contre95/soulsolid/src/features/lyrics"
"github.com/contre95/soulsolid/src/features/metadata"
"github.com/contre95/soulsolid/src/features/metrics"
"github.com/contre95/soulsolid/src/features/playback"
"github.com/contre95/soulsolid/src/features/playlists"
"github.com/contre95/soulsolid/src/features/syncdap"
"github.com/contre95/soulsolid/src/features/ui"
Expand All @@ -30,7 +31,7 @@ type Server struct {
}

// NewServer creates a new HTTP server.
func NewServer(cfg *config.Manager, importingService *importing.Service, libraryService *library.Service, playlistsService *playlists.Service, syncService *syncdap.Service, downloadingService *downloading.Service, jobService *jobs.Service, tagService *metadata.Service, lyricsService *lyrics.Service, metricsService *metrics.Service, analyzeService *analyze.Service) *Server {
func NewServer(cfg *config.Manager, importingService *importing.Service, libraryService *library.Service, playlistsService *playlists.Service, syncService *syncdap.Service, downloadingService *downloading.Service, jobService *jobs.Service, tagService *metadata.Service, lyricsService *lyrics.Service, metricsService *metrics.Service, analyzeService *analyze.Service, playbackService *playback.Service) *Server {
engine := html.New("./views", ".html")
engine.Debug(cfg.Get().Logger.Level == "debug")
// Add custom template functions
Expand Down Expand Up @@ -140,6 +141,10 @@ func NewServer(cfg *config.Manager, importingService *importing.Service, library
lyrics.RegisterRoutes(app, lyricsHandler)
analyze.RegisterRoutes(app, analyzeHandler)

// Register playback routes
playbackHandler := playback.NewHandler(playbackService)
playback.RegisterRoutes(app, playbackHandler)

return &Server{app: app, port: cfg.Get().Server.Port}
}

Expand Down
55 changes: 55 additions & 0 deletions src/features/playback/handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package playback

import (
"log/slog"

"github.com/gofiber/fiber/v2"
)

// Handler handles playback requests
type Handler struct {
service *Service
}

// NewHandler creates a new playback handler
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}

// GetTrackPreview serves a 30-second audio preview of a track
func (h *Handler) GetTrackPreview(c *fiber.Ctx) error {
slog.Debug("GetTrackPreview handler called", "trackID", c.Params("id"))

trackID := c.Params("id")
if trackID == "" {
return c.Status(fiber.StatusBadRequest).SendString("Track ID is required")
}

reader, contentType, err := h.service.GetTrackPreview(c.Context(), trackID)
if err != nil {
slog.Error("Failed to get track preview", "trackID", trackID, "error", err)
return c.Status(fiber.StatusInternalServerError).SendString("Failed to get track preview")
}

// Set content type based on track format
if contentType != "" {
c.Set("Content-Type", "audio/"+contentType)
} else {
c.Set("Content-Type", "audio/mpeg") // default
}

// Set additional headers for proper streaming
c.Set("Accept-Ranges", "bytes")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")

slog.Debug("Streaming audio", "contentType", contentType, "trackID", trackID)

// Stream the audio
err = c.SendStream(reader)
if err != nil {
slog.Error("Failed to stream audio", "trackID", trackID, "error", err)
return c.Status(fiber.StatusInternalServerError).SendString("Failed to stream audio")
}
return nil
}
9 changes: 9 additions & 0 deletions src/features/playback/routes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package playback

import "github.com/gofiber/fiber/v2"

// RegisterRoutes registers the playback routes
func RegisterRoutes(app *fiber.App, handler *Handler) {
playback := app.Group("/playback")
playback.Get("/tracks/:id/preview", handler.GetTrackPreview)
}
152 changes: 152 additions & 0 deletions src/features/playback/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package playback

import (
"context"
"fmt"
"io"
"log/slog"
"math/rand"
"os"

"github.com/contre95/soulsolid/src/music"
)

// Service provides playback functionality for audio previews
type Service struct {
library music.Library
}

// NewService creates a new playback service
func NewService(library music.Library) *Service {
return &Service{
library: library,
}
}

// PreviewTrackReader wraps a file reader to limit reading to 30 seconds
type PreviewTrackReader struct {
file *os.File
remaining int64
}

func (r *PreviewTrackReader) Read(p []byte) (n int, err error) {
if r.remaining <= 0 {
slog.Debug("Preview reader EOF reached", "remaining", r.remaining)
// Auto-close when done
r.file.Close()
return 0, io.EOF
}
if int64(len(p)) > r.remaining {
p = p[:r.remaining]
}
n, err = r.file.Read(p)
r.remaining -= int64(n)

// Auto-close on EOF
if err == io.EOF {
r.file.Close()
}

// Log first few reads for debugging
if r.remaining == int64(r.remaining) && n > 0 { // This is approximate for first read check
slog.Debug("Preview reader reading", "bytesRequested", len(p), "bytesRead", n, "remainingAfter", r.remaining)
}

if err != nil && err != io.EOF {
slog.Error("Error reading from preview reader", "error", err, "remaining", r.remaining)
r.file.Close() // Close on error too
}

return n, err
}

func (r *PreviewTrackReader) Close() error {
return r.file.Close()
}

// GetTrackPreview returns a reader for a 30-second random preview of the track
func (s *Service) GetTrackPreview(ctx context.Context, trackID string) (io.ReadCloser, string, error) {
slog.Debug("GetTrackPreview service called", "trackID", trackID)

// Get track info
track, err := s.library.GetTrack(ctx, trackID)
if err != nil {
slog.Error("Failed to get track", "trackID", trackID, "error", err)
return nil, "", fmt.Errorf("failed to get track: %w", err)
}
if track == nil {
slog.Error("Track not found", "trackID", trackID)
return nil, "", fmt.Errorf("track not found")
}

// Open the audio file
slog.Debug("Attempting to open audio file", "path", track.Path)
file, err := os.Open(track.Path)
if err != nil {
slog.Error("Failed to open track file", "path", track.Path, "error", err)
return nil, "", fmt.Errorf("failed to open track file: %w", err)
}
slog.Debug("Successfully opened audio file", "path", track.Path)

// Get file info to determine file size
fileInfo, err := file.Stat()
if err != nil {
file.Close()
return nil, "", fmt.Errorf("failed to get file info: %w", err)
}
fileSize := fileInfo.Size()

// If we have duration information, calculate a random starting point
// For now, we'll use a simple approach: start at a random position up to 1/3 of the file
// and read for approximately 30 seconds worth of data
var seekOffset int64 = 0
var previewSize int64 = fileSize // fallback to full file

if track.Metadata.Duration > 0 {
// Calculate bytes per second
bytesPerSecond := fileSize / int64(track.Metadata.Duration)

// Random start time: up to max(30 seconds, or 1/3 of track duration)
maxStartSeconds := track.Metadata.Duration / 3
if maxStartSeconds < 30 {
maxStartSeconds = track.Metadata.Duration
}
if maxStartSeconds > 0 {
maxStartSeconds-- // ensure we don't start at the very end
}

startSeconds := rand.Intn(int(maxStartSeconds) + 1)
seekOffset = int64(startSeconds) * bytesPerSecond

// Calculate 30 seconds worth of data
previewSize = 30 * bytesPerSecond

// Make sure we don't go beyond file size
if seekOffset+previewSize > fileSize {
if fileSize > seekOffset {
previewSize = fileSize - seekOffset
} else {
seekOffset = 0 // start from beginning if we can't seek to our desired position
previewSize = fileSize
}
}

slog.Debug("Track preview calculated", "duration", track.Metadata.Duration, "startSeconds", startSeconds, "previewSize", previewSize, "fileSize", fileSize)
}

// Seek to the calculated offset
if seekOffset > 0 {
_, err = file.Seek(seekOffset, io.SeekStart)
if err != nil {
slog.Warn("Failed to seek to random position, starting from beginning", "offset", seekOffset, "error", err)
file.Seek(0, io.SeekStart) // reset to beginning
previewSize = fileSize
}
}

// Return the preview reader
return &PreviewTrackReader{
file: file,
remaining: previewSize,
}, track.Format, nil
}
32 changes: 32 additions & 0 deletions src/features/playlists/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,35 @@ func (h *Handler) ExportM3U(c *fiber.Ctx) error {

return c.SendString(m3uContent)
}

// SyncPlaylistToPlayers handles syncing a playlist to external players.
func (h *Handler) SyncPlaylistToPlayers(c *fiber.Ctx) error {
playlistID := c.Params("id")

slog.Debug("SyncPlaylistToPlayers handler called", "playlistID", playlistID)

err := h.service.SyncPlaylistToPlayers(c.Context(), playlistID)
if err != nil {
slog.Error("Error syncing playlist to players", "error", err, "playlistID", playlistID)
return c.Status(fiber.StatusInternalServerError).SendString("Failed to sync playlist to players")
}

// Return success toast
return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist synced to external players"})
}

// DeletePlaylistFromPlayers handles deleting a playlist from external players.
func (h *Handler) DeletePlaylistFromPlayers(c *fiber.Ctx) error {
playlistID := c.Params("id")

slog.Debug("DeletePlaylistFromPlayers handler called", "playlistID", playlistID)

err := h.service.DeletePlaylistFromPlayers(c.Context(), playlistID)
if err != nil {
slog.Error("Error deleting playlist from players", "error", err, "playlistID", playlistID)
return c.Status(fiber.StatusInternalServerError).SendString("Failed to delete playlist from players")
}

// Return success toast
return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist deleted from external players"})
}
25 changes: 25 additions & 0 deletions src/features/playlists/players.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package playlists

import (
"context"

"github.com/contre95/soulsolid/src/music"
)

// PlayerProvider defines the interface for external media player integrations
type PlayerProvider interface {
// Name returns the name of the player provider
Name() string

// IsEnabled returns whether this provider is enabled
IsEnabled() bool

// SyncPlaylist syncs a playlist to the external player
SyncPlaylist(ctx context.Context, playlist *music.Playlist) error

// GetPlaylists retrieves playlists from the external player
GetPlaylists(ctx context.Context) ([]*music.Playlist, error)

// DeletePlaylist removes a playlist from the external player
DeletePlaylist(ctx context.Context, playlistID string) error
}
2 changes: 2 additions & 0 deletions src/features/playlists/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ func RegisterRoutes(app *fiber.App, service *Service) {
playlists.Get("/:type/:id/playlists", handler.GetPlaylistsForItem)

playlists.Get("/:id/export", handler.ExportM3U)
playlists.Post("/:id/sync", handler.SyncPlaylistToPlayers)
playlists.Delete("/:id/sync", handler.DeletePlaylistFromPlayers)
}
Loading