|
6 | 6 | "fmt" |
7 | 7 | "math/rand" |
8 | 8 | "os" |
| 9 | + "os/exec" |
9 | 10 | "path/filepath" |
10 | 11 | "strings" |
11 | 12 | "sync" |
@@ -41,7 +42,9 @@ type Streamer struct { |
41 | 42 | InjectMetadata bool |
42 | 43 | Visible bool |
43 | 44 | MPDPassword string |
44 | | - LastPlaylist string |
| 45 | + LastPlaylist string |
| 46 | + SongCommand string |
| 47 | + SongCommandTimeout int |
45 | 48 |
|
46 | 49 | relay *Relay |
47 | 50 | cancel context.CancelFunc |
@@ -486,6 +489,51 @@ func (s *Streamer) signalStateChange() { |
486 | 489 | } |
487 | 490 | } |
488 | 491 |
|
| 492 | +func (s *Streamer) execSongCommand() (string, error) { |
| 493 | + if s.SongCommand == "" { |
| 494 | + return "", fmt.Errorf("no song command configured") |
| 495 | + } |
| 496 | + |
| 497 | + timeout := s.SongCommandTimeout |
| 498 | + if timeout <= 0 { |
| 499 | + timeout = 5 |
| 500 | + } |
| 501 | + |
| 502 | + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) |
| 503 | + defer cancel() |
| 504 | + |
| 505 | + cmd := exec.CommandContext(ctx, "sh", "-c", s.SongCommand) |
| 506 | + cmd.Dir = s.MusicDir |
| 507 | + |
| 508 | + output, err := cmd.Output() |
| 509 | + if err != nil { |
| 510 | + return "", fmt.Errorf("song command failed: %w", err) |
| 511 | + } |
| 512 | + |
| 513 | + filePath := strings.TrimSpace(string(output)) |
| 514 | + if filePath == "" { |
| 515 | + return "", fmt.Errorf("song command returned empty output") |
| 516 | + } |
| 517 | + |
| 518 | + // Take only the first line |
| 519 | + if idx := strings.IndexByte(filePath, '\n'); idx >= 0 { |
| 520 | + filePath = filePath[:idx] |
| 521 | + } |
| 522 | + filePath = strings.TrimSpace(filePath) |
| 523 | + |
| 524 | + // Resolve relative paths against music dir |
| 525 | + if !filepath.IsAbs(filePath) { |
| 526 | + filePath = filepath.Join(s.MusicDir, filePath) |
| 527 | + } |
| 528 | + |
| 529 | + // Validate the file exists and is a supported audio format |
| 530 | + if err := validateAudioFile(filePath); err != nil { |
| 531 | + return "", fmt.Errorf("song command returned invalid file %q: %w", filePath, err) |
| 532 | + } |
| 533 | + |
| 534 | + return filePath, nil |
| 535 | +} |
| 536 | + |
489 | 537 | type StreamerStats struct { |
490 | 538 | Name string |
491 | 539 | Mount string |
@@ -536,7 +584,7 @@ func (s *Streamer) GetStats() StreamerStats { |
536 | 584 | } |
537 | 585 | } |
538 | 586 |
|
539 | | -func (sm *StreamerManager) StartStreamer(name, mount, musicDir string, loop bool, format string, bitrate int, injectMetadata bool, initialPlaylistPaths []string, mpdEnabled bool, mpdPort, mpdPassword string, visible bool, lastPlaylist string) (*Streamer, error) { |
| 587 | +func (sm *StreamerManager) StartStreamer(name, mount, musicDir string, loop bool, format string, bitrate int, injectMetadata bool, initialPlaylistPaths []string, mpdEnabled bool, mpdPort, mpdPassword string, visible bool, lastPlaylist string, songCommand string, songCommandTimeout int) (*Streamer, error) { |
540 | 588 | sm.mu.Lock() |
541 | 589 | defer sm.mu.Unlock() |
542 | 590 |
|
@@ -566,8 +614,10 @@ func (sm *StreamerManager) StartStreamer(name, mount, musicDir string, loop bool |
566 | 614 | InjectMetadata: injectMetadata, |
567 | 615 | Visible: visible, |
568 | 616 | MPDPassword: mpdPassword, |
569 | | - LastPlaylist: lastPlaylist, |
570 | | - relay: sm.relay, |
| 617 | + LastPlaylist: lastPlaylist, |
| 618 | + SongCommand: songCommand, |
| 619 | + SongCommandTimeout: songCommandTimeout, |
| 620 | + relay: sm.relay, |
571 | 621 | cancel: cancel, |
572 | 622 | titleCache: make(map[string]string), |
573 | 623 | NextID: nextID, // Start NextID after initial playlist |
@@ -687,8 +737,41 @@ func (sm *StreamerManager) runStreamerLoop(ctx context.Context, s *Streamer) { |
687 | 737 | s.Queue = s.Queue[1:] |
688 | 738 | fileID = -1 // Queue items don't have an ID from the playlist |
689 | 739 | filePos = -1 |
| 740 | + } else if s.SongCommand != "" { |
| 741 | + // 2. External song command (unlock during exec to avoid blocking) |
| 742 | + s.mu.Unlock() |
| 743 | + if path, err := s.execSongCommand(); err == nil { |
| 744 | + filePath = path |
| 745 | + fileID = -1 |
| 746 | + filePos = -1 |
| 747 | + } else { |
| 748 | + logger.L.Warnf("Streamer %s: Song command error, falling back to playlist: %v", s.Name, err) |
| 749 | + } |
| 750 | + s.mu.Lock() |
| 751 | + // If command failed, try playlist as fallback |
| 752 | + if filePath == "" && len(s.Playlist) > 0 { |
| 753 | + if s.Shuffle { |
| 754 | + s.CurrentPos = rand.Intn(len(s.Playlist)) |
| 755 | + } else { |
| 756 | + if s.CurrentPos >= len(s.Playlist) { |
| 757 | + if s.Loop { |
| 758 | + s.CurrentPos = 0 |
| 759 | + } else { |
| 760 | + s.State = StateStopped |
| 761 | + s.mu.Unlock() |
| 762 | + continue |
| 763 | + } |
| 764 | + } |
| 765 | + } |
| 766 | + filePath = s.Playlist[s.CurrentPos].Path |
| 767 | + fileID = s.Playlist[s.CurrentPos].ID |
| 768 | + filePos = s.CurrentPos |
| 769 | + if !s.Shuffle { |
| 770 | + s.CurrentPos++ |
| 771 | + } |
| 772 | + } |
690 | 773 | } else if len(s.Playlist) > 0 { |
691 | | - // 2. Handle Shuffle or Sequential Playlist |
| 774 | + // 3. Normal playlist selection |
692 | 775 | if s.Shuffle { |
693 | 776 | s.CurrentPos = rand.Intn(len(s.Playlist)) |
694 | 777 | } else { |
|
0 commit comments