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
18 changes: 16 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import (
"fmt"
"log"
"os"
"time"
"strings"
"time"

"github.com/ilyakaznacheev/cleanenv"
)

Expand Down Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down
2 changes: 2 additions & 0 deletions src/downloader/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
66 changes: 66 additions & 0 deletions src/downloader/youtube_music.go
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 24 additions & 0 deletions src/downloader/youtube_music/search_ytmusic.py
Original file line number Diff line number Diff line change
@@ -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 <query> [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()