451 lines
12 KiB
Go
451 lines
12 KiB
Go
package model
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/bjarneo/cliamp/history"
|
|
"github.com/bjarneo/cliamp/internal/playback"
|
|
"github.com/bjarneo/cliamp/lyrics"
|
|
"github.com/bjarneo/cliamp/player"
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
"github.com/bjarneo/cliamp/resolve"
|
|
)
|
|
|
|
// — Message types used by tea.Cmd constructors —
|
|
|
|
// devicesListedMsg carries the result of listing audio output devices.
|
|
type devicesListedMsg struct {
|
|
devices []player.AudioDevice
|
|
err error
|
|
}
|
|
|
|
// deviceSwitchedMsg signals that an audio device switch attempt completed.
|
|
type deviceSwitchedMsg struct {
|
|
name string
|
|
err error
|
|
}
|
|
|
|
// SetEQPresetMsg is sent by Lua plugins to change the EQ preset by name.
|
|
// If Bands is non-nil, the bands are applied and the name becomes a custom label.
|
|
type SetEQPresetMsg struct {
|
|
Name string
|
|
Bands *[10]float64 // nil = use built-in preset bands or keep current
|
|
}
|
|
|
|
// ShowStatusMsg is sent by Lua plugins to display a message in the status bar.
|
|
// Duration <= 0 falls back to the default status TTL.
|
|
type ShowStatusMsg struct {
|
|
Text string
|
|
Duration time.Duration
|
|
}
|
|
|
|
type tracksLoadedMsg struct {
|
|
tracks []playlist.Track
|
|
playlistID string
|
|
providerName string
|
|
playlistExact bool
|
|
}
|
|
|
|
// feedsLoadedMsg carries tracks resolved from remote feed/M3U URLs,
|
|
// along with the original source URLs so downstream handlers can identify
|
|
// the source (e.g. YouTube Radio) without re-scanning external state.
|
|
type feedsLoadedMsg struct {
|
|
tracks []playlist.Track
|
|
urls []string // original source URLs that produced these tracks
|
|
autoPlay bool // whether to start playback automatically
|
|
}
|
|
|
|
// feedTrackResolvedMsg carries episodes resolved from a feed track in the playlist.
|
|
type feedTrackResolvedMsg struct {
|
|
tracks []playlist.Track
|
|
}
|
|
|
|
// lyricsLoadedMsg carries parsed LRC output.
|
|
type lyricsLoadedMsg struct {
|
|
lines []lyrics.Line
|
|
err error
|
|
}
|
|
|
|
// netSearchResultsMsg carries the result set of a yt-dlp/sc-dlp search query
|
|
// so the UI can present a picker rather than auto-queuing.
|
|
type netSearchResultsMsg struct {
|
|
tracks []playlist.Track
|
|
err error
|
|
}
|
|
|
|
// streamPlayedMsg signals that async stream Play() completed.
|
|
type streamPlayedMsg struct{ err error }
|
|
|
|
// streamPreloadedMsg signals that async stream Preload() completed.
|
|
type streamPreloadedMsg struct{}
|
|
|
|
type attachNotifierMsg struct{ notifier playback.Notifier }
|
|
|
|
// ytdlResolvedMsg carries a lazily resolved yt-dlp track (direct audio URL).
|
|
type ytdlResolvedMsg struct {
|
|
index int
|
|
track playlist.Track
|
|
err error
|
|
}
|
|
|
|
// ytdlBatchMsg carries an incrementally loaded batch of yt-dlp tracks.
|
|
// The gen field ties the response to a specific batch session so stale
|
|
// responses from a previous or reloaded playlist are discarded.
|
|
type ytdlBatchMsg struct {
|
|
gen uint64 // batch session generation
|
|
tracks []playlist.Track
|
|
err error
|
|
}
|
|
|
|
// ytdlSavedMsg signals that an async yt-dlp download-to-disk completed.
|
|
type ytdlSavedMsg struct {
|
|
path string
|
|
err error
|
|
}
|
|
|
|
// — Navidrome browser message types —
|
|
|
|
// navArtistsLoadedMsg carries the full artist list from a provider browser.
|
|
type navArtistsLoadedMsg []provider.ArtistInfo
|
|
|
|
// navAlbumsLoadedMsg carries one page of albums and the fetch offset.
|
|
type navAlbumsLoadedMsg struct {
|
|
albums []provider.AlbumInfo
|
|
offset int // the offset this page was requested at
|
|
isLast bool // true when the server returned fewer than the requested page size
|
|
}
|
|
|
|
// navTracksLoadedMsg carries the track list from a provider.AlbumTrackLoader.
|
|
type navTracksLoadedMsg []playlist.Track
|
|
|
|
// provAuthDoneMsg signals that interactive provider authentication completed.
|
|
type provAuthDoneMsg struct{ err error }
|
|
|
|
// ProvAuthURLMsg carries the OAuth URL produced by a provider's interactive
|
|
// auth flow so the TUI can display it. Used as a fallback when the launched
|
|
// browser doesn't reach the user (e.g. inside containers or headless envs).
|
|
type ProvAuthURLMsg struct{ URL string }
|
|
|
|
// — Command constructors —
|
|
|
|
func AttachNotifier(notifier playback.Notifier) tea.Msg {
|
|
return attachNotifierMsg{notifier: notifier}
|
|
}
|
|
|
|
func listDevicesCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
devices, err := player.ListAudioDevices()
|
|
return devicesListedMsg{devices: devices, err: err}
|
|
}
|
|
}
|
|
|
|
func switchDeviceCmd(name string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
err := player.SwitchAudioDevice(name)
|
|
return deviceSwitchedMsg{name: name, err: err}
|
|
}
|
|
}
|
|
|
|
// authenticateProviderCmd runs the interactive auth flow for a provider.
|
|
func authenticateProviderCmd(auth playlist.Authenticator) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return provAuthDoneMsg{err: auth.Authenticate()}
|
|
}
|
|
}
|
|
|
|
func fetchPlaylistsCmd(prov playlist.Provider) tea.Cmd {
|
|
return func() tea.Msg {
|
|
pls, err := prov.Playlists()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return pls
|
|
}
|
|
}
|
|
|
|
func fetchYTDLBatchCmd(gen uint64, pageURL string, start, count int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
tracks, err := resolve.ResolveYTDLBatch(pageURL, start, count)
|
|
return ytdlBatchMsg{gen: gen, tracks: tracks, err: err}
|
|
}
|
|
}
|
|
|
|
func resolveFeedTrackCmd(feedURL string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
tracks, err := resolve.Remote([]string{feedURL})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return feedTrackResolvedMsg{tracks: tracks}
|
|
}
|
|
}
|
|
|
|
func resolveRemoteCmd(urls []string, autoPlay bool) tea.Cmd {
|
|
return func() tea.Msg {
|
|
tracks, err := resolve.Remote(urls)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return feedsLoadedMsg{tracks: tracks, urls: urls, autoPlay: autoPlay}
|
|
}
|
|
}
|
|
|
|
func fetchLyricsCmd(artist, title string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
lines, err := lyrics.Fetch(artist, title)
|
|
return lyricsLoadedMsg{lines: lines, err: err}
|
|
}
|
|
}
|
|
|
|
func fetchTrackLyricsCmd(track playlist.Track, artist, title string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if lines := lyrics.ParseEmbedded(track.EmbeddedLyrics); len(lines) > 0 {
|
|
return lyricsLoadedMsg{lines: lines}
|
|
}
|
|
lines, err := lyrics.Fetch(artist, title)
|
|
return lyricsLoadedMsg{lines: lines, err: err}
|
|
}
|
|
}
|
|
|
|
func fetchNetSearchCmd(query string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
tracks, err := resolve.Remote([]string{query})
|
|
return netSearchResultsMsg{tracks: tracks, err: err}
|
|
}
|
|
}
|
|
|
|
func playStreamCmd(p player.Engine, path string, knownDuration time.Duration) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return streamPlayedMsg{err: p.Play(path, knownDuration)}
|
|
}
|
|
}
|
|
|
|
func preloadStreamCmd(p player.Engine, path string, knownDuration time.Duration) tea.Cmd {
|
|
return func() tea.Msg {
|
|
p.Preload(path, knownDuration) // errors silently ignored
|
|
return streamPreloadedMsg{}
|
|
}
|
|
}
|
|
|
|
func preloadLocalCmd(p player.Engine, path string, knownDuration time.Duration) tea.Cmd {
|
|
return func() tea.Msg {
|
|
p.Preload(path, knownDuration)
|
|
return streamPreloadedMsg{}
|
|
}
|
|
}
|
|
|
|
func playYTDLStreamCmd(p player.Engine, pageURL string, knownDuration time.Duration) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return streamPlayedMsg{err: p.PlayYTDL(pageURL, knownDuration)}
|
|
}
|
|
}
|
|
|
|
func preloadYTDLStreamCmd(p player.Engine, pageURL string, knownDuration time.Duration) tea.Cmd {
|
|
return func() tea.Msg {
|
|
p.PreloadYTDL(pageURL, knownDuration) // errors silently ignored
|
|
return streamPreloadedMsg{}
|
|
}
|
|
}
|
|
|
|
func saveYTDLCmd(pageURL string, saveDir string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
path, err := resolve.DownloadYTDL(pageURL, saveDir)
|
|
return ytdlSavedMsg{path: path, err: err}
|
|
}
|
|
}
|
|
|
|
func fetchTracksCmd(prov playlist.Provider, playlistID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
tracks, err := prov.Tracks(playlistID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Resolve PLS/M3U wrapper URLs to actual stream URLs so the
|
|
// player receives a direct audio stream instead of a playlist file.
|
|
tracks, expanded := resolveWrapperURLs(tracks)
|
|
return tracksLoadedMsg{tracks: tracks, playlistID: playlistID, providerName: prov.Name(), playlistExact: !expanded}
|
|
}
|
|
}
|
|
|
|
// resolveWrapperURLs expands any PLS/M3U track paths into the actual stream
|
|
// URLs they contain. Non-wrapper tracks are passed through unchanged.
|
|
func resolveWrapperURLs(tracks []playlist.Track) ([]playlist.Track, bool) {
|
|
var out []playlist.Track
|
|
expanded := false
|
|
for _, t := range tracks {
|
|
if playlist.IsURL(t.Path) && (playlist.IsPLS(t.Path) || playlist.IsM3U(t.Path)) {
|
|
resolved, err := resolve.Remote([]string{t.Path})
|
|
if err == nil && len(resolved) > 0 {
|
|
expanded = true
|
|
// Preserve the original title/artist on resolved tracks.
|
|
for i := range resolved {
|
|
if resolved[i].Title == "" || resolved[i].Title == resolved[i].Path {
|
|
resolved[i].Title = t.Title
|
|
}
|
|
if resolved[i].Artist == "" {
|
|
resolved[i].Artist = t.Artist
|
|
}
|
|
if t.Realtime {
|
|
resolved[i].Realtime = true
|
|
}
|
|
}
|
|
out = append(out, resolved...)
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, expanded
|
|
}
|
|
|
|
const navAlbumPageSize = 100
|
|
|
|
func fetchNavArtistsCmd(b provider.ArtistBrowser) tea.Cmd {
|
|
return func() tea.Msg {
|
|
artists, err := b.Artists()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return navArtistsLoadedMsg(artists)
|
|
}
|
|
}
|
|
|
|
func fetchNavArtistAlbumsCmd(b provider.ArtistBrowser, artistID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
albums, err := b.ArtistAlbums(artistID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Artist album lists are complete in one call — treat as last page.
|
|
return navAlbumsLoadedMsg{albums: albums, offset: 0, isLast: true}
|
|
}
|
|
}
|
|
|
|
func fetchNavAlbumListCmd(b provider.AlbumBrowser, sortType string, offset int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
albums, err := b.AlbumList(sortType, offset, navAlbumPageSize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return navAlbumsLoadedMsg{
|
|
albums: albums,
|
|
offset: offset,
|
|
isLast: len(albums) < navAlbumPageSize,
|
|
}
|
|
}
|
|
}
|
|
|
|
func fetchNavAlbumTracksCmd(l provider.AlbumTrackLoader, albumID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
tracks, err := l.AlbumTracks(albumID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return navTracksLoadedMsg(tracks)
|
|
}
|
|
}
|
|
|
|
// catalogSearchMsg carries the result of a provider.CatalogSearcher.SearchCatalog call.
|
|
type catalogSearchMsg struct {
|
|
count int
|
|
err error
|
|
}
|
|
|
|
func fetchCatalogSearchCmd(s provider.CatalogSearcher, query string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
count, err := s.SearchCatalog(query)
|
|
return catalogSearchMsg{count: count, err: err}
|
|
}
|
|
}
|
|
|
|
// — Catalog batch loading for providers with lazy catalogs —
|
|
|
|
// catalogBatchSize is the number of catalog entries to fetch per page.
|
|
const catalogBatchSize = 100
|
|
|
|
// catalogBatchMsg carries the result of a provider.CatalogLoader.LoadCatalogPage call.
|
|
type catalogBatchMsg struct {
|
|
added int
|
|
err error
|
|
}
|
|
|
|
func fetchCatalogBatchCmd(loader provider.CatalogLoader, offset, limit int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
added, err := loader.LoadCatalogPage(offset, limit)
|
|
return catalogBatchMsg{added: added, err: err}
|
|
}
|
|
}
|
|
|
|
// — Spotify search + add-to-playlist messages —
|
|
|
|
type spotSearchResultsMsg struct {
|
|
tracks []playlist.Track
|
|
err error
|
|
}
|
|
|
|
type spotPlaylistsMsg struct {
|
|
playlists []playlist.PlaylistInfo
|
|
err error
|
|
}
|
|
|
|
type spotAddedMsg struct {
|
|
name string
|
|
err error
|
|
}
|
|
|
|
type spotCreatedMsg struct {
|
|
name string
|
|
err error
|
|
}
|
|
|
|
func fetchSpotSearchCmd(s provider.Searcher, query string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
tracks, err := s.SearchTracks(ctx, query, 20)
|
|
return spotSearchResultsMsg{tracks: tracks, err: err}
|
|
}
|
|
}
|
|
|
|
func fetchSpotPlaylistsCmd(prov playlist.Provider) tea.Cmd {
|
|
return func() tea.Msg {
|
|
playlists, err := prov.Playlists()
|
|
if err == nil && prov.Name() == "Local" {
|
|
filtered := playlists[:0]
|
|
for _, pl := range playlists {
|
|
if pl.Name != history.PlaylistName {
|
|
filtered = append(filtered, pl)
|
|
}
|
|
}
|
|
playlists = filtered
|
|
}
|
|
return spotPlaylistsMsg{playlists: playlists, err: err}
|
|
}
|
|
}
|
|
|
|
func addToSpotPlaylistCmd(w provider.PlaylistWriter, playlistID string, track playlist.Track, name string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
err := w.AddTrackToPlaylist(ctx, playlistID, track)
|
|
return spotAddedMsg{name: name, err: err}
|
|
}
|
|
}
|
|
|
|
func createSpotPlaylistCmd(c provider.PlaylistCreator, w provider.PlaylistWriter, name string, track playlist.Track) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
id, err := c.CreatePlaylist(ctx, name)
|
|
if err != nil {
|
|
return spotCreatedMsg{name: name, err: err}
|
|
}
|
|
err = w.AddTrackToPlaylist(ctx, id, track)
|
|
return spotCreatedMsg{name: name, err: err}
|
|
}
|
|
}
|