From 5adf658d2842c3bb6a23c7ffe68795118c9f2b48 Mon Sep 17 00:00:00 2001 From: Grygon Date: Sat, 6 Sep 2025 00:34:31 -0500 Subject: [PATCH 1/2] Implement code changes to use YTMusic --- src/config/config.go | 10 ++- src/downloader/downloader.go | 2 + src/downloader/youtube_music.go | 66 +++++++++++++++++++ .../youtube_music/search_ytmusic.py | 24 +++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/downloader/youtube_music.go create mode 100644 src/downloader/youtube_music/search_ytmusic.py diff --git a/src/config/config.go b/src/config/config.go index c5cdbe7..c273afc 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -5,8 +5,9 @@ import ( "fmt" "log" "os" - "time" "strings" + "time" + "github.com/ilyakaznacheev/cleanenv" ) @@ -54,6 +55,7 @@ type SubsonicConfig struct { type DownloadConfig struct { DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` Youtube Youtube + YoutubeMusic YoutubeMusic Slskd Slskd Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"` @@ -73,6 +75,12 @@ type Youtube struct { Filters Filters } +type YoutubeMusic struct { + FfmpegPath string `env:"FFMPEG_PATH"` + YtdlpPath string `env:"YTDLP_PATH"` + Filters Filters +} + type Slskd struct { APIKey string `env:"SLSKD_API_KEY"` URL string `env:"SLSKD_URL"` diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 637b8d9..768c178 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -32,6 +32,8 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient) *Downlo var downloader []Downloader for _, service := range cfg.Services { switch service { + case "youtubeMusic": + downloader = append(downloader, NewYoutubeMusic(cfg.YoutubeMusic, cfg.Discovery, cfg.DownloadDir, httpClient)) case "youtube": downloader = append(downloader, NewYoutube(cfg.Youtube, cfg.Discovery, cfg.DownloadDir, httpClient)) case "slskd": diff --git a/src/downloader/youtube_music.go b/src/downloader/youtube_music.go new file mode 100644 index 0000000..0feca36 --- /dev/null +++ b/src/downloader/youtube_music.go @@ -0,0 +1,66 @@ +package downloader + +import ( + "encoding/json" + "fmt" + "log" + "os/exec" + + cfg "explo/src/config" + "explo/src/models" + "explo/src/util" +) + +type YoutubeMusic struct { + *Youtube + Cfg cfg.YoutubeMusic +} + +type PythonSearchResult struct { + VideoID string `json:"videoId"` + Title string `json:"title"` +} + +func NewYoutubeMusic(ytmusicCfg cfg.YoutubeMusic, discovery, downloadDir string, httpClient *util.HttpClient) *YoutubeMusic { + yCfg := cfg.Youtube{ + APIKey: "", // YT Music doesn't need the API key in this scenario + FfmpegPath: ytmusicCfg.FfmpegPath, + YtdlpPath: ytmusicCfg.YtdlpPath, + Filters: ytmusicCfg.Filters, + } + + // Reuse NewYoutube to reuse implementation that provides GetTrack etc. + underlying := NewYoutube(yCfg, discovery, downloadDir, httpClient) + + return &YoutubeMusic{ + Youtube: underlying, + Cfg: ytmusicCfg, + } +} + +func (c *YoutubeMusic) QueryTrack(track *models.Track) error { + query := fmt.Sprintf("%s - %s", track.Title, track.Artist) + + log.Printf("Querying YTMusic for track %s - %s", track.Title, track.Artist) + + cmd := exec.Command("python3", "search_ytmusic.py", query, "1") + + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("ytmusicapi subprocess failed: %w", err) + } + + var results []PythonSearchResult + if err := json.Unmarshal(out, &results); err != nil { + return fmt.Errorf("failed to parse ytmusicapi JSON: %w", err) + } + + if len(results) == 0 { + return fmt.Errorf("no YouTube Music track found for: %s - %s", track.Title, track.Artist) + } + + track.ID = results[0].VideoID + log.Printf("Matched track %s - %s => videoId %s", track.Title, track.Artist, track.ID) + + return nil +} diff --git a/src/downloader/youtube_music/search_ytmusic.py b/src/downloader/youtube_music/search_ytmusic.py new file mode 100644 index 0000000..e9ef80f --- /dev/null +++ b/src/downloader/youtube_music/search_ytmusic.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +import sys +import json +from ytmusicapi import YTMusic + + +# Simple wrapper to search YouTube Music and return results as JSON. +def main(): + if len(sys.argv) < 2: + print("Usage: search_cli.py [limit]", file=sys.stderr) + sys.exit(1) + + query = sys.argv[1] + limit = int(sys.argv[2]) if len(sys.argv) > 2 else 1 + + yt = YTMusic() + results = yt.search(query, limit=limit) + + # Print JSON to stdout for Go to parse + print(json.dumps(results)) + + +if __name__ == "__main__": + main() From c4c3f278fbb4a5c42ce13320dac0d03efc6f3c4c Mon Sep 17 00:00:00 2001 From: Grygon <647846+Grygon@users.noreply.github.com> Date: Sat, 6 Sep 2025 00:34:39 -0500 Subject: [PATCH 2/2] Modify Dockerfile to allow Python --- Dockerfile | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index dd034b0..d8c2933 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,13 +10,27 @@ COPY ./ . ARG TARGETARCH RUN GOOS=linux GOARCH=$TARGETARCH go build -o explo ./src/main/ -FROM alpine +FROM python:3.12-alpine -RUN apk add --no-cache libc6-compat ffmpeg yt-dlp tzdata +# Install runtime deps: libc compat, ffmpeg, yt-dlp, tzdata +RUN apk add --no-cache \ + libc6-compat \ + ffmpeg \ + yt-dlp \ + tzdata +# Install ytmusicapi in the container +RUN pip install --no-cache-dir ytmusicapi + +# Set working directory WORKDIR /opt/explo/ + +# Copy entrypoint, binary, python helper COPY ./docker/start.sh /start.sh COPY --from=builder /app/explo . +COPY src/downloader/youtube_music/search_ytmusic.py . + + RUN chmod +x /start.sh ./explo # Can be defined from compose as well