Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ on:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
branches:
- dev
pull_request:
branches: ['*']

name: Latest Release

Expand All @@ -22,7 +24,7 @@ jobs:
with:
go-version: '1.23'
- name: golangci-lint
uses: golangci/golangci-lint-action@v3.7.0
uses: golangci/golangci-lint-action@v8.0.0
with:
version: latest
release:
Expand Down
79 changes: 46 additions & 33 deletions src/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import (
// Client manages interactions with the selected music system
type Client struct {
System string
Cfg *config.ClientConfig
API APIClient
Cfg *config.ClientConfig
API APIClient
}

type APIClient interface {
Expand All @@ -31,85 +31,96 @@ type APIClient interface {
}

// NewClient initializes a client and sets up authentication
func NewClient(cfg *config.Config, httpClient *util.HttpClient) *Client {
func NewClient(cfg *config.Config, httpClient *util.HttpClient) (*Client, error) {
c := &Client{
System: cfg.System,
Cfg: &cfg.ClientCfg,
Cfg: &cfg.ClientCfg,
}
switch c.System {

case "emby":
c.API = NewEmby(cfg.ClientCfg, httpClient)

case "jellyfin":
c.API = NewJellyfin(cfg.ClientCfg, httpClient)

case "mpd":
c.API = NewMPD(cfg.ClientCfg)

case "plex":
c.API = NewPlex(cfg.ClientCfg, httpClient)

case "subsonic":
case "subsonic":
c.API = NewSubsonic(cfg.ClientCfg, httpClient)

default:
log.Fatalf("unknown system: %s. Use a supported system (emby, jellyfin, mpd, plex, or subsonic).", c.System)
}

c.systemSetup() // Run setup automatically
return c
if err := c.systemSetup(); err != nil { // Run setup automatically
return nil, fmt.Errorf("setup failed: %w", err)
}

return c, nil
}

// systemSetup checks needed credentials and initializes the selected system
func (c *Client) systemSetup() {
func (c *Client) systemSetup() error {
switch c.System {
case "subsonic":
if c.Cfg.Creds.User == "" || c.Cfg.Creds.Password == "" {
log.Fatal("Subsonic USER and PASSWORD are required")
return fmt.Errorf("Subsonic USER and PASSWORD are required")
}
c.API.GetAuth()
return c.API.GetAuth()

case "jellyfin":
if c.Cfg.Creds.APIKey == "" {
log.Fatal("Jellyfin API_KEY is required")
return fmt.Errorf("Jellyfin API_KEY is required")
}
if err := c.API.AddHeader(); err != nil {
return err
}
c.API.AddHeader()
c.API.GetLibrary()
return c.API.GetLibrary()

case "mpd":
if c.Cfg.PlaylistDir == "" {
log.Fatal("MPD PLAYLIST_DIR is required")
return fmt.Errorf("MPD PLAYLIST_DIR is required")
}
return nil

case "plex":
if (c.Cfg.Creds.User == "" || c.Cfg.Creds.Password == "") && c.Cfg.Creds.APIKey == "" {
log.Fatal("Plex USER/PASSWORD or API_KEY is required")
return fmt.Errorf("Plex USER/PASSWORD or API_KEY is required")
}
c.API.AddHeader()
if c.Cfg.Creds.APIKey == "" {
if err := c.API.GetAuth(); err != nil {
log.Fatal(err)
return err
}

}
c.API.AddHeader()
c.API.GetLibrary()
if err := c.API.AddHeader(); err != nil {
return err
}
return c.API.GetLibrary()

case "emby":
if c.Cfg.Creds.APIKey == "" {
log.Fatal("Emby API_KEY is required")
return fmt.Errorf("Emby API_KEY is required")
}
if err := c.API.AddHeader(); err != nil {
return err
}
c.API.AddHeader()
c.API.GetLibrary()
return c.API.GetLibrary()

default:
log.Fatalf("Unknown system: %s. Use a supported system (emby, jellyfin, mpd, plex, or subsonic).", c.System)
return fmt.Errorf("unknown system: %s. Use a supported system (emby, jellyfin, mpd, plex, or subsonic)", c.System)
}
}

func (c *Client) CheckTracks(tracks []*models.Track) {
c.API.SearchSongs(tracks)
if err := c.API.SearchSongs(tracks); err != nil {
log.Printf("warning: SearchSongs failed: %v", err)
}
}

func (c *Client) CreatePlaylist(tracks []*models.Track) error {
Expand All @@ -123,12 +134,13 @@ func (c *Client) CreatePlaylist(tracks []*models.Track) error {

log.Printf("[%s] Refreshing library...", c.System)
time.Sleep(time.Duration(c.Cfg.Sleep) * time.Minute)
c.API.SearchSongs(tracks) // search newly added songs
if err := c.API.SearchSongs(tracks); err != nil { // search newly added songs
log.Printf("warning: SearchSongs failed: %v", err)
}
if err := c.API.CreatePlaylist(tracks); err != nil {
return fmt.Errorf("[%s] failed to create playlist: %s", c.System, err.Error())
}


description := "Created by Explo using recommendations from ListenBrainz"
if err := c.API.UpdatePlaylist(description); err != nil {
return fmt.Errorf("[%s] failed to update playlist: %s", c.System, err.Error())
Expand All @@ -137,10 +149,11 @@ func (c *Client) CreatePlaylist(tracks []*models.Track) error {
}

func (c *Client) DeletePlaylist() error {
c.API.SearchPlaylist()
err := c.API.DeletePlaylist()
if err != nil {
if err := c.API.SearchPlaylist(); err != nil {
return fmt.Errorf("warning: SearchSongs failed: %v", err)
}
if err := c.API.DeletePlaylist(); err != nil {
return fmt.Errorf("[%s] failed to delete playlist: %s", c.System, err.Error())
}
return nil
}
}
61 changes: 35 additions & 26 deletions src/downloader/youtube.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ import (
)

type Videos struct {
Items []Item `json:"items"`
Items []Item `json:"items"`
}

type ID struct {
VideoID string `json:"videoId"`
}

type Snippet struct {
Title string `json:"title"`
ChannelTitle string `json:"channelTitle"`
Title string `json:"title"`
ChannelTitle string `json:"channelTitle"`
}

type Item struct {
Expand All @@ -37,19 +37,19 @@ type Item struct {

type Youtube struct {
DownloadDir string
HttpClient *util.HttpClient
Cfg cfg.Youtube
HttpClient *util.HttpClient
Cfg cfg.Youtube
}

func NewYoutube(cfg cfg.Youtube, discovery, downloadDir string, httpClient *util.HttpClient) *Youtube { // init downloader cfg for youtube
return &Youtube{
DownloadDir: downloadDir,
Cfg: cfg,
HttpClient: httpClient}
Cfg: cfg,
HttpClient: httpClient}
}

func (c *Youtube) QueryTrack(track *models.Track) error { // Queries youtube for the song

escQuery := url.PathEscape(fmt.Sprintf("%s - %s", track.Title, track.Artist))
queryURL := fmt.Sprintf("https://youtube.googleapis.com/youtube/v3/search?part=snippet&q=%s&type=video&videoCategoryId=10&key=%s", escQuery, c.Cfg.APIKey)

Expand Down Expand Up @@ -83,7 +83,7 @@ func (c *Youtube) GetTrack(track *models.Track) error {
}

func getTopic(cfg cfg.Youtube, videos Videos, track models.Track) string { // gets song under artist topic or personal channel

for _, v := range videos.Items {
if (strings.Contains(v.Snippet.ChannelTitle, "- Topic") || v.Snippet.ChannelTitle == track.MainArtist) && filter(track, v.Snippet.Title, cfg.FilterList) {
return v.ID.VideoID
Expand All @@ -109,29 +109,38 @@ func getVideo(ctx context.Context, c Youtube, videoID string) (*goutubedl.Downlo
}

return downloadResult, nil

}

func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult) bool {

defer stream.Close()
defer func() {
if err := stream.Close(); err != nil {
log.Printf("warning: stream close failed: %v", err)
}
}()

input := fmt.Sprintf("%s%s_TEMP.mp3", c.DownloadDir, track.File)
file, err := os.Create(input)
if err != nil {
log.Fatalf("Failed to create song file: %s", err.Error())
}
defer file.Close()

defer func() {
if err := file.Close(); err != nil {
log.Printf("warning: file close failed: %v", err)
}
}()

if _, err = io.Copy(file, stream); err != nil {
log.Printf("Failed to copy stream to file: %s", err.Error())
os.Remove(input)
_ = os.Remove(input) //nolint:errcheck // best-effort cleanup
return false
}

cmd := ffmpeg.Input(input).Output(fmt.Sprintf("%s%s.mp3", c.DownloadDir, track.File), ffmpeg.KwArgs{
"map": "0:a",
"metadata": []string{"artist="+track.Artist,"title="+track.Title,"album="+track.Album},
"map": "0:a",
"metadata": []string{"artist=" + track.Artist, "title=" + track.Title, "album=" + track.Album},
"loglevel": "error",
}).OverWriteOutput().ErrorToStdOut()

Expand All @@ -141,19 +150,19 @@ func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult)

if err = cmd.Run(); err != nil {
log.Printf("Failed to convert audio: %s", err.Error())
os.Remove(input)
_ = os.Remove(input) //nolint:errcheck // best-effort cleanup
return false
}
os.Remove(input)
_ = os.Remove(input) //nolint:errcheck // best-effort cleanup
return true
}

func gatherVideo(cfg cfg.Youtube, videos Videos, track models.Track) string { // filter out video ID

// Try to get the video from the official or topic channel
if id := getTopic(cfg, videos, track); id != "" {
return id

}
// If official video isn't found, try the first suitable channel
for _, video := range videos.Items {
Expand All @@ -171,19 +180,19 @@ func fetchAndSaveVideo(ctx context.Context, cfg Youtube, track models.Track) boo
log.Printf("failed getting stream for video ID %s: %s", track.ID, err.Error())
return false
}

if stream != nil {
return saveVideo(cfg, track, stream)
}

log.Printf("stream was nil for video ID %s", track.ID)
return false
}

func filter(track models.Track, videoTitle string, filterList []string) bool { // ignore titles that have a specific keyword (defined in .env)

for _, keyword := range filterList {
if (!containsLower(track.Title,keyword) && !containsLower(track.Artist, keyword) && containsLower(videoTitle, keyword)) {
if !containsLower(track.Title, keyword) && !containsLower(track.Artist, keyword) && containsLower(videoTitle, keyword) {
return false
}
}
Expand All @@ -193,7 +202,7 @@ func filter(track models.Track, videoTitle string, filterList []string) bool { /
func containsLower(str string, substr string) bool {

return strings.Contains(
strings.ToLower(str),
strings.ToLower(substr),
)
}
strings.ToLower(str),
strings.ToLower(substr),
)
}
13 changes: 8 additions & 5 deletions src/main/main.go → src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import (
)

type Song struct {
Title string
Title string
Artist string
Album string
Album string
}

func initHttpClient() *util.HttpClient {
Expand All @@ -33,7 +33,10 @@ func main() {
cfg := config.ReadEnv()
setup(&cfg)
httpClient := initHttpClient()
client := client.NewClient(&cfg, httpClient)
client, err := client.NewClient(&cfg, httpClient)
if err != nil {
log.Fatal(err)
}
discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient)
downloader := downloader.NewDownloader(&cfg.DownloadCfg, httpClient)

Expand All @@ -57,6 +60,6 @@ func main() {
if err := client.CreatePlaylist(tracks); err != nil {
log.Println(err)
} else {
log.Printf("[%s] %s playlist created successfully",cfg.System, cfg.ClientCfg.PlaylistName)
log.Printf("[%s] %s playlist created successfully", cfg.System, cfg.ClientCfg.PlaylistName)
}
}
}
Loading