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
84 changes: 84 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Contributing to Explo

Thanks for your interest in contributing to Explo.

Explo is maintained by a small number of active contributors with limited time, so every change is considered not only for its usefulness, but also for its maintenance cost.

## Design Philosophy

Explo started as a simple personal Go script in 2023: fetch recommendations from ListenBrainz, find the tracks on YouTube, and create playlists in Navidrome (Subsonic). It was originally intended to run as a single binary on a server.

While Explo has grown significantly since then, its core goal has remained the same: **automated music discovery for self-hosted music systems.**

Explo is designed to be a **set-and-forget** solution. Once configured, it should run with minimal user intervention, continuously requesting music and creating playlists. Optional interaction is acceptable, but automation remains the default. Features that cannot reasonably fit this automation-first approach may be rejected.

This philosophy also applies to Explo's development. The codebase should be as close to set-and-forget as the application itself. Changes should avoid introducing unnecessary maintenance, manual intervention, or complexity for maintainers.

The project prioritizes:

- Reliability over feature count
- Automation over manual workflows
- Compatibility across supported platforms
- Long-term maintainability

Not every useful feature belongs in Explo. Features may be declined when they do not align with the project's goals, even if they solve a legitimate use case.

## Backward Compatibility

Compatibility is a priority.

Changes should avoid breaking existing configurations, Docker deployments or supported integrations whenever possible. If a breaking change is necessary, discuss it first and provide a clear migration path.

## Pull Requests

Please discuss changes before beginning work on them.

- Check existing issues first
- If an issue already exists, comment there before starting work
- If the idea involves a specific music system, please try to check if it can be implemented on other supported systems as well

Opening a pull request without prior discussion may result in it being rejected.

Wait for maintainer feedback before investing significant work in a new feature.

### Keep changes focused

Pull requests are easier to review when they solve one problem at a time.

Good examples include:

- Bug fixes
- Small, well-scoped features
- Refactors that solve a specific maintenance issue

If a pull request contains multiple related fixes or features, use a title that reflects the overall change rather than a single problem.

## Coding Expectations

You do not need to know every part of the codebase, but you should understand the code you work with and systems you modify.

Before submitting:

- Test the implementation on every supported system affected by your changes
- Make sure existing functionality continues to work unless the change intentionally modifies it

## AI Usage

AI-driven development is discouraged.

Examples include:

- Most or all code generated by LLM agents
- Submitting generated code without understanding how it works or accounting for platform limitations

AI-assisted development is allowed when used as a supporting tool.

Examples include:

- Rubber ducking
- Explaining unfamiliar parts of the codebase
- Generating small helper functions

Contributors are responsible for understanding and testing all submitted code.

Large AI-generated implementations or refactors that the author cannot explain, verify, or maintain will be **rejected**.
3 changes: 1 addition & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,12 @@ FROM python:3.12-alpine
RUN apk add --no-cache \
libc6-compat \
ffmpeg \
yt-dlp \
tzdata \
shadow \
su-exec

# Install ytmusicapi in the container
RUN pip install --no-cache-dir ytmusicapi
RUN pip install --no-cache-dir ytmusicapi yt-dlp

# Create user to set PUID/PGID values
RUN useradd -ms /bin/sh explo
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Explo uses the [ListenBrainz](https://listenbrainz.org/) recommendation engine t
- Apple Music
- ListenBrainz
- Spotify
- Request tracks from YouTube, Soulseek, or both
- Automatically request playlist tracks from YouTube and Soulseek, or albums through Lidarr
- Add metadata to downloaded tracks
- Create playlists in your music system
- Optionally keep previous playlists for later listening
Expand Down
19 changes: 15 additions & 4 deletions docker/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
PUID="${PUID:-0}"
PGID="${PGID:-0}"


# Update yt-dlp on startup
pip install --disable-pip-version-check --root-user-action=ignore --no-cache-dir --upgrade yt-dlp
# Clear crontabs
crontab -r

# Update yt-dlp periodically
YTDLP_UPDATE="${YTDLP_UPDATE:-59 23 * * *}"
echo "$YTDLP_UPDATE echo 'Updating yt-dlp...' && pip install --disable-pip-version-check --root-user-action=ignore --no-cache-dir --upgrade yt-dlp >> /proc/1/fd/1 2>&1" >> /etc/crontabs/root
echo "[setup] Registered yt-dlp update job"
echo " Schedule: $YTDLP_UPDATE"

if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then
groupmod -o -g "$PGID" explo
usermod -o -u "$PUID" explo
Expand All @@ -12,7 +24,6 @@ else
echo "[setup] WARN: running as root. Consider defining PUID & PGID in docker-compose to run as a non-root user"
RUN_USER="root"
RUNNER=""

fi

mkdir -p /opt/explo
Expand Down Expand Up @@ -52,7 +63,7 @@ fi

# $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility
if [ -n "$CRON_SCHEDULE" ]; then
echo "$CRON_SCHEDULE apk add --no-cache --upgrade yt-dlp && cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" >> /proc/1/fd/1 2>&1" > /etc/crontabs/root
echo "$CRON_SCHEDULE cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" >> /proc/1/fd/1 2>&1" >> /etc/crontabs/root
chmod 600 /etc/crontabs/root
echo "[setup] Registered single CRON_SCHEDULE job: $CRON_SCHEDULE"
crond -f -l 2
Expand All @@ -71,7 +82,7 @@ for var in $(env | grep "_SCHEDULE=" | cut -d= -f1); do
fi

# Default: just run explo if flags are empty
cmd="apk add --no-cache --upgrade yt-dlp && cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" $flags >> /proc/1/fd/1 2>&1"
cmd="cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" $flags >> /proc/1/fd/1 2>&1"

echo "$schedule $cmd" >> /etc/crontabs/root
echo "[setup] Registered job: $job"
Expand All @@ -85,7 +96,7 @@ echo "[setup] Starting cron..."

if [ "$EXECUTE_ON_START" = "true" ]; then
echo "[setup] Executing startup task..."
apk add --no-cache --upgrade yt-dlp && cd /opt/explo && $RUNNER ./explo --config "$_cfg" $START_FLAGS
cd /opt/explo && $RUNNER ./explo --config "$_cfg" $START_FLAGS

fi
crond -f -l 2
141 changes: 120 additions & 21 deletions src/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,59 @@ import (
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"strings"

"explo/src/config"
"explo/src/models"
"explo/src/util"
)

type SearchResult struct {
ID string
Title string
Album string
Artist string
Artists []string
Path string
Duration int // seconds
MBID string
Score int
}

// normalized track data for matching
type NormalisedTrack struct {
CleanTitle string
MainArtist string
Album string
File string
Duration int
MBTrackID string
MBReleaseTrackID string
}

// Client manages interactions with the selected music system
type Client struct {
System string
Cfg *config.ClientConfig
API APIClient
}

type APIClient interface {
GetLibrary() error
GetAuth() error
AddHeader() error
AddLibrary() error
SearchSongs([]*models.Track) error
RefreshLibrary() error
CheckRefreshState() bool
CreatePlaylist([]*models.Track) error
SearchPlaylist() error
UpdatePlaylist() error
DeletePlaylist() error
}

// uploadPlaylistArtwork POSTs raw image bytes to a music app's artwork endpoint.
// Plex, Jellyfin, and Emby all accept the same format — POST + Content-Type: image/jpeg + raw body.
// The only per-client difference is the URL path, which each caller builds before invoking.
Expand Down Expand Up @@ -47,27 +93,6 @@ func uploadPlaylistArtwork(hc *util.HttpClient, endpoint, localPath string, head
return nil
}

// Client manages interactions with the selected music system
type Client struct {
System string
Cfg *config.ClientConfig
API APIClient
}

type APIClient interface {
GetLibrary() error
GetAuth() error
AddHeader() error
AddLibrary() error
SearchSongs([]*models.Track) error
RefreshLibrary() error
CheckRefreshState() bool
CreatePlaylist([]*models.Track) error
SearchPlaylist() error
UpdatePlaylist() error
DeletePlaylist() error
}

// ArtworkUploader is an optional capability for clients that support setting
// playlist artwork. Use a type assertion: if u, ok := c.API.(client.ArtworkUploader); ok {...}.
type ArtworkUploader interface {
Expand Down Expand Up @@ -227,3 +252,77 @@ func (c *Client) DeletePlaylist() error {
}
return nil
}

func BestMatch(track *models.Track, results []SearchResult, minScore int) (SearchResult, bool) {
bestScore := -1
var best SearchResult
nmTrack := NormalisedTrack{
CleanTitle: util.NormalizeTitle(track.CleanTitle),
MainArtist: track.MainArtist,
Album: track.Album,
File: filepath.Base(track.File),
Duration: track.Duration,
MBTrackID: track.MusicBrainzTrackID,
MBReleaseTrackID: track.MusicBrainzReleaseTrackID}

for _, r := range results {
score := rankResult(nmTrack, r)
if score > bestScore {
bestScore = score
best = r
}
if bestScore == definitiveMatchScore {
break
}
}
if bestScore < minScore {
return SearchResult{}, false
}
best.Score = bestScore
return best, true
}

const definitiveMatchScore = 1000
func rankResult(track NormalisedTrack, r SearchResult) int {
score := 0

resultTitle := util.NormalizeTitle(r.Title)

if (track.MBTrackID != "" && r.MBID == track.MBTrackID) || (track.MBReleaseTrackID != "" && r.MBID == track.MBReleaseTrackID) {
return definitiveMatchScore // definitive match
}
if track.CleanTitle == resultTitle {
score += 45
} else if len(track.CleanTitle) > 3 && (strings.Contains(resultTitle, track.CleanTitle) || strings.Contains(track.CleanTitle, resultTitle)) {
score += 25
}
if strings.EqualFold(track.Album, r.Album) {
score += 20
} else if util.ContainsFold(track.Album, r.Album) || util.ContainsFold(r.Album, track.Album) {
score += 15
}
if strings.EqualFold(track.MainArtist, r.Artist) || (len(r.Artists) > 0 && strings.EqualFold(r.Artists[0], track.MainArtist)) {
score += 30
} else if (util.ContainsFold(track.MainArtist, r.Artist) || util.ContainsFold(r.Artist, track.MainArtist)) || (len(r.Artists) > 0 && util.ContainsFold(r.Artists[0], track.MainArtist)) {
score += 15
}

resultFile := filepath.Base(r.Path)

if track.File != "" && strings.EqualFold(track.File, resultFile) {
score += 50
}
durationSet := track.Duration != 0 && r.Duration != 0
if durationSet {
durationDiff := util.Abs(r.Duration-track.Duration/1000)
switch {
case durationDiff < 3:
score += 10
case durationDiff < 10:
score += 5
case durationDiff > 30:
score -= 10
}
}
return score
}
Loading
Loading