Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
12 changes: 9 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ RUN GOOS=linux GOARCH=$TARGETARCH go build -o explo ./src/main/

FROM python:3.12-alpine

# Install runtime deps: libc compat, ffmpeg, yt-dlp, tzdata
# Install runtime deps: libc compat, ffmpeg, yt-dlp, tzdata, shadow for user management, su-exec for user switching
RUN apk add --no-cache \
libc6-compat \
ffmpeg \
yt-dlp \
tzdata
tzdata \
shadow \
su-exec

# Install ytmusicapi in the container
RUN pip install --no-cache-dir ytmusicapi
Expand All @@ -33,7 +35,11 @@ COPY src/downloader/youtube_music/search_ytmusic.py .

RUN chmod +x /start.sh ./explo

# Can be defined from compose as well
# Can be defined from compose as well
ENV WEEKLY_EXPLORATION_SCHEDULE="15 0 * * 2"

# Default PUID and PGID
ENV PUID=1000
ENV PGID=1000

CMD ["/start.sh"]
2 changes: 2 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ services:
# - $PLAYLIST_DIR:$PLAYLIST_DIR # for MPD. Both paths should be as defined in .env (e.g /my/playlists/:/my/playlists/)
environment:
- TZ=UTC # Change this to the timezone set in ListenBrainz (default is UTC)
- PUID=1000 # User ID for file permissions (optional, defaults to 1000)
- PGID=1000 # Group ID for file permissions (optional, defaults to 1000)

- WEEKLY_EXPLORATION_SCHEDULE=15 00 * * 2 # Runs weekly, every Tuesday 15 minutes past midnight
- WEEKLY_EXPLORATION_FLAGS= # Run weekly exploration with default settings
Expand Down
44 changes: 40 additions & 4 deletions docker/start.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,45 @@
#!/bin/sh

# Handle PUID/PGID
if [ "$PUID" != "0" ] && [ "$PGID" != "0" ]; then
echo "[setup] Setting up user with PUID=$PUID and PGID=$PGID"

# Create group if it doesn't exist
if ! getent group explo > /dev/null 2>&1; then
groupadd -g "$PGID" explo
fi

# Create user if it doesn't exist
if ! getent passwd explo > /dev/null 2>&1; then
useradd -u "$PUID" -g "$PGID" -d /opt/explo -s /bin/sh explo
fi

# Ensure explo user owns the working directory and data directory
chown -R explo:explo /opt/explo
[ -d /data ] && chown -R explo:explo /data

# If running as non-root, exec as the explo user
if [ "$(id -u)" = "0" ]; then
exec su-exec explo "$0" "$@"
fi
fi

echo "[setup] Initializing cron jobs..."

# Determine which user to run cron jobs as
CRON_USER="root"
if [ "$PUID" != "0" ] && [ "$PGID" != "0" ]; then
CRON_USER="explo"
# Create crontab directory for explo user if it doesn't exist
mkdir -p /var/spool/cron/crontabs
touch "/var/spool/cron/crontabs/$CRON_USER"
chown "$CRON_USER:$CRON_USER" "/var/spool/cron/crontabs/$CRON_USER"
fi

if [ -n "$CRON_SCHEDULE" ]; then
echo "$CRON_SCHEDULE apk add --upgrade yt-dlp && cd /opt/explo && ./explo >> /proc/1/fd/1 2>&1" > /etc/crontabs/root
chmod 600 /etc/crontabs/root
cmd="apk add --upgrade yt-dlp && cd /opt/explo && ./explo >> /proc/1/fd/1 2>&1"
echo "$CRON_SCHEDULE $cmd" > "/var/spool/cron/crontabs/$CRON_USER"
chmod 600 "/var/spool/cron/crontabs/$CRON_USER"
echo "[setup] Registered single CRON_SCHEDULE job: $CRON_SCHEDULE"
crond -f -l 2
fi
Expand All @@ -23,13 +59,13 @@ for var in $(env | grep "_SCHEDULE=" | cut -d= -f1); do
# Default: just run explo if flags are empty
cmd="apk add --upgrade yt-dlp && cd /opt/explo && ./explo $flags >> /proc/1/fd/1 2>&1"

echo "$schedule $cmd" >> /etc/crontabs/root
echo "$schedule $cmd" >> "/var/spool/cron/crontabs/$CRON_USER"
echo "[setup] Registered job: $job"
echo " Schedule: $schedule"
echo " Command : ./explo $flags"
done

chmod 600 /etc/crontabs/root
chmod 600 "/var/spool/cron/crontabs/$CRON_USER"

echo "[setup] Starting cron..."
crond -f -l 2
11 changes: 11 additions & 0 deletions sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ YOUTUBE_API_KEY=
# Minimal Bitrate (default: 256)
# MIN_BITRATE=256

# === Lidarr Configuration ===

# LIDARR_API_KEY=
# LIDARR_RETRY=
# LIDARR_DL_ATTEMPTS=
# LIDARR_DIR=
# MIGRATE_DOWNLOADS=
# LIDARR_TIMEOUT=
# LIDARR_SCHEME=
# LIDARR_URL=

# === Metadata / Formatting ===

# Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true)
Expand Down
163 changes: 92 additions & 71 deletions src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,94 +14,113 @@ import (
)

type Config struct {
DownloadCfg DownloadConfig
DownloadCfg DownloadConfig
DiscoveryCfg DiscoveryConfig
ClientCfg ClientConfig
Flags Flags
Persist bool `env:"PERSIST" env-default:"true"`
System string `env:"EXPLO_SYSTEM"`
Debug bool `env:"DEBUG" env-default:"false"`
ClientCfg ClientConfig
Flags Flags
Persist bool `env:"PERSIST" env-default:"true"`
System string `env:"EXPLO_SYSTEM"`
Debug bool `env:"DEBUG" env-default:"false"`
}

type Flags struct {
CfgPath string
Playlist string
CfgPath string
Playlist string
DownloadMode string
ExcludeLocal bool
Timeout int `env:"TIMEOUT" env-default:"10"`
}

type ClientConfig struct {
ClientID string `env:"CLIENT_ID" env-default:"explo"`
LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"`
URL string `env:"SYSTEM_URL"`
DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
PlaylistDir string `env:"PLAYLIST_DIR"`
PlaylistName string
ClientID string `env:"CLIENT_ID" env-default:"explo"`
LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"`
URL string `env:"SYSTEM_URL"`
DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
PlaylistDir string `env:"PLAYLIST_DIR"`
PlaylistName string
PlaylistDescr string
PlaylistID string
Sleep int `env:"SLEEP" env-default:"2"`
Creds Credentials
Subsonic SubsonicConfig
PlaylistID string
Sleep int `env:"SLEEP" env-default:"2"`
Creds Credentials
Subsonic SubsonicConfig
}

type Credentials struct {
APIKey string `env:"API_KEY"`
User string `env:"SYSTEM_USERNAME"`
APIKey string `env:"API_KEY"`
User string `env:"SYSTEM_USERNAME"`
Password string `env:"SYSTEM_PASSWORD"`
Headers map[string]string
Token string
Salt string
Headers map[string]string
Token string
Salt string
}

type Lidarr struct {
APIKey string `env:"LIDARR_API_KEY"`
Retry int `env:"LIDARR_RETRY" env-default:"5"` // Number of times to check search status before skipping the track
DownloadAttempts int `env:"LIDARR_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track
LidarrDir string `env:"LIDARR_DIR" env-default:"/lidarr/"`
MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir
Timeout time.Duration `env:"LIDARR_TIMEOUT" env-default:"20s"`
Scheme string `env:"LIDARR_SCHEME" env-default:"http"`
URL string `env:"LIDARR_URL"`
Filters Filters
MonitorConfig LidarrMon
}

type LidarrMon struct {
Interval time.Duration `env:"SLSKD_MONITOR_INTERVAL" env-default:"1m"`
Duration time.Duration `env:"SLSKD_MONITOR_DURATION" env-default:"15m"`
}

type SubsonicConfig struct {
Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"`
ID string `env:"CLIENT" env-default:"explo"`
URL string `env:"SUBSONIC_URL" env-default:"http://127.0.0.1:4533"`
User string `env:"SUBSONIC_USER"`
Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"`
ID string `env:"CLIENT" env-default:"explo"`
URL string `env:"SUBSONIC_URL" env-default:"http://127.0.0.1:4533"`
User string `env:"SUBSONIC_USER"`
Password string `env:"SUBSONIC_PASSWORD"`
}

type DownloadConfig struct {
DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
Youtube Youtube
DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
Youtube Youtube
YoutubeMusic YoutubeMusic
Slskd Slskd
Slskd Slskd
Lidarr Lidarr
ExcludeLocal bool
Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"`
Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"`
}

type Filters struct {
Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"`
MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"`
MinBitRate int `env:"MIN_BITRATE" env-default:"256"`
FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended"`
Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"`
MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"`
MinBitRate int `env:"MIN_BITRATE" env-default:"256"`
FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended"`
}

type Youtube struct {
APIKey string `env:"YOUTUBE_API_KEY"`
APIKey string `env:"YOUTUBE_API_KEY"`
FfmpegPath string `env:"FFMPEG_PATH"`
YtdlpPath string `env:"YTDLP_PATH"`
Filters Filters
YtdlpPath string `env:"YTDLP_PATH"`
Filters Filters
}

type YoutubeMusic struct {
FfmpegPath string `env:"FFMPEG_PATH"`
YtdlpPath string `env:"YTDLP_PATH"`
Filters Filters
YtdlpPath string `env:"YTDLP_PATH"`
Filters Filters
}

type Slskd struct {
APIKey string `env:"SLSKD_API_KEY"`
URL string `env:"SLSKD_URL"`
Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track
DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track
SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"`
MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir
Timeout time.Duration `env:"SLSKD_TIMEOUT" env-default:"20s"`
Filters Filters
MonitorConfig SlskdMon
APIKey string `env:"SLSKD_API_KEY"`
URL string `env:"SLSKD_URL"`
Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track
DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track
SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"`
MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir
Timeout time.Duration `env:"SLSKD_TIMEOUT" env-default:"20s"`
Filters Filters
MonitorConfig SlskdMon
}

type SlskdMon struct {
Expand All @@ -110,14 +129,15 @@ type SlskdMon struct {
}

type DiscoveryConfig struct {
Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"`
Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"`
Listenbrainz Listenbrainz
}

type Listenbrainz struct {
Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
User string `env:"LISTENBRAINZ_USER"`
Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
User string `env:"LISTENBRAINZ_USER"`
ImportPlaylist string
SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"`
SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"`
}

func (cfg *Config) ReadEnv() {
Expand Down Expand Up @@ -153,28 +173,29 @@ func fixDir(dir string) string {
return dir
}

/* func (cfg *Config) HandleDeprecation() { // no deprecations at the moment (keeping this for reference)
switch cfg.System {
case "subsonic":
if cfg.Subsonic.User != "" && cfg.Creds.User == "" {
log.Println("Warning: 'SUBSONIC_USER' is deprecated. Please use 'SYSTEM_USERNAME'.")
cfg.Creds.User = cfg.Subsonic.User
}
if cfg.Subsonic.Password != "" && cfg.Creds.Password == "" {
log.Println("Warning: 'SUBSONIC_PASSWORD' is deprecated. Please use 'SYSTEM_PASSWORD'.")
cfg.Creds.Password = cfg.Subsonic.Password
}
if cfg.Subsonic.URL != "" && cfg.URL == "" {
log.Println("Warning: 'SUBSONIC_URL' is deprecated. Please use 'SYSTEM_URL'.")
cfg.URL = cfg.Subsonic.URL
/*
func (cfg *Config) HandleDeprecation() { // no deprecations at the moment (keeping this for reference)
switch cfg.System {
case "subsonic":
if cfg.Subsonic.User != "" && cfg.Creds.User == "" {
log.Println("Warning: 'SUBSONIC_USER' is deprecated. Please use 'SYSTEM_USERNAME'.")
cfg.Creds.User = cfg.Subsonic.User
}
if cfg.Subsonic.Password != "" && cfg.Creds.Password == "" {
log.Println("Warning: 'SUBSONIC_PASSWORD' is deprecated. Please use 'SYSTEM_PASSWORD'.")
cfg.Creds.Password = cfg.Subsonic.Password
}
if cfg.Subsonic.URL != "" && cfg.URL == "" {
log.Println("Warning: 'SUBSONIC_URL' is deprecated. Please use 'SYSTEM_URL'.")
cfg.URL = cfg.Subsonic.URL
}
}
}
}
*/
*/
func (cfg *Config) GetPlaylistName() { // Generate playlist name and description

toTitle := cases.Title(language.Und)

playlistName := toTitle.String(cfg.Flags.Playlist)
if cfg.Persist {
year, week := time.Now().ISOWeek()
Expand Down
9 changes: 6 additions & 3 deletions src/downloader/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL
slskdClient := NewSlskd(cfg.Slskd, cfg.DownloadDir)
slskdClient.AddHeader()
downloader = append(downloader, slskdClient)
case "lidarr":
lidarrClient := NewLidarr(cfg.Lidarr, cfg.DownloadDir)
lidarrClient.AddHeader()
downloader = append(downloader, lidarrClient)
default:
log.Fatalf("downloader '%s' not supported", service)
}
}

return &DownloadClient{
Cfg: cfg,
Downloaders: downloader}
Expand All @@ -55,7 +58,7 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) {
if err := os.MkdirAll(c.Cfg.DownloadDir, 0755); err != nil {
log.Fatalln(err)
}

for _, d := range c.Downloaders {
var g errgroup.Group
g.SetLimit(5)
Expand Down Expand Up @@ -177,7 +180,7 @@ func moveDownload(srcDir, destDir, trackPath, file string) error { // Move downl

if err = os.MkdirAll(destDir, os.ModePerm); err != nil {
return fmt.Errorf("couldn't make download directory: %s", err.Error())
}
}

dstFile := filepath.Join(destDir, file)
out, err := os.Create(dstFile)
Expand Down
Loading