From 77f3dba242ddd86901ff7255e37350f0839187af Mon Sep 17 00:00:00 2001 From: Avery Dorgan Date: Fri, 9 May 2025 11:16:26 -0400 Subject: [PATCH] Upgrade runner go version and linter Check errors; run lint on PRs --- .github/workflows/release.yml | 10 +++-- Dockerfile | 2 +- src/client/client.go | 79 ++++++++++++++++++++--------------- src/downloader/youtube.go | 61 +++++++++++++++------------ src/{main => }/main.go | 13 +++--- src/util/http.go | 24 +++++++---- 6 files changed, 112 insertions(+), 77 deletions(-) rename src/{main => }/main.go (84%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da82aea..d011b90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,10 @@ on: # Sequence of patterns matched against refs/tags tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + branches: + - dev + pull_request: + branches: ['*'] name: Latest Release @@ -18,9 +22,9 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: - go-version: '1.21.6' + 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: @@ -41,7 +45,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v4 with: - go-version: '1.21.6' + go-version: '1.23' - name: Get OS and arch info run: | GOOSARCH=${{matrix.goosarch}} diff --git a/Dockerfile b/Dockerfile index 9357103..55bbf71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine AS builder +FROM golang:1.23-alpine AS builder # Set the working directory WORKDIR /app diff --git a/src/client/client.go b/src/client/client.go index 96f4fa3..1abee62 100644 --- a/src/client/client.go +++ b/src/client/client.go @@ -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 { @@ -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 { @@ -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()) @@ -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 -} \ No newline at end of file +} diff --git a/src/downloader/youtube.go b/src/downloader/youtube.go index 460d571..22903c5 100644 --- a/src/downloader/youtube.go +++ b/src/downloader/youtube.go @@ -18,7 +18,7 @@ import ( ) type Videos struct { - Items []Item `json:"items"` + Items []Item `json:"items"` } type ID struct { @@ -26,8 +26,8 @@ type ID struct { } type Snippet struct { - Title string `json:"title"` - ChannelTitle string `json:"channelTitle"` + Title string `json:"title"` + ChannelTitle string `json:"channelTitle"` } type Item struct { @@ -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) @@ -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 @@ -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() @@ -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 { @@ -171,11 +180,11 @@ 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 } @@ -183,7 +192,7 @@ func fetchAndSaveVideo(ctx context.Context, cfg Youtube, track models.Track) boo 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 } } @@ -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), - ) -} \ No newline at end of file + strings.ToLower(str), + strings.ToLower(substr), + ) +} diff --git a/src/main/main.go b/src/main.go similarity index 84% rename from src/main/main.go rename to src/main.go index 4a55070..8d0c4eb 100644 --- a/src/main/main.go +++ b/src/main.go @@ -12,9 +12,9 @@ import ( ) type Song struct { - Title string + Title string Artist string - Album string + Album string } func initHttpClient() *util.HttpClient { @@ -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) @@ -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) } -} \ No newline at end of file +} diff --git a/src/util/http.go b/src/util/http.go index 7f4cf07..f89d8b9 100644 --- a/src/util/http.go +++ b/src/util/http.go @@ -1,21 +1,22 @@ package util import ( - "net/http" "encoding/json" "fmt" "io" + "log" + "net/http" "time" "explo/src/debug" ) type HttpClientConfig struct { - Timeout time.Duration + Timeout time.Duration } type HttpClient struct { - Client *http.Client + Client *http.Client } func NewHttp(cfg HttpClientConfig) *HttpClient { @@ -31,24 +32,29 @@ func (c *HttpClient) MakeRequest(method, url string, payload io.Reader, headers if err != nil { return nil, fmt.Errorf("failed to initialize request: %s", err.Error()) } - req.Header.Add("Content-Type","application/json") + req.Header.Add("Content-Type", "application/json") req.Header.Add("Accept", "application/json") for key, value := range headers { - req.Header.Add(key,value) + req.Header.Add(key, value) } resp, err := c.Client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %s", err.Error()) } - defer resp.Body.Close() + + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("warning: response body close failed: %v", err) + } + }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %s", err.Error()) } - + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("got %d from %s", resp.StatusCode, url) } @@ -57,10 +63,10 @@ func (c *HttpClient) MakeRequest(method, url string, payload io.Reader, headers } func ParseResp[T any](body []byte, target *T) error { - + if err := json.Unmarshal(body, target); err != nil { debug.Debug(fmt.Sprintf("full response: %s", string(body))) return fmt.Errorf("error unmarshaling response body: %s", err.Error()) } return nil -} \ No newline at end of file +}