Skip to content

Commit

Permalink
Changed backened to musicbrainz
Browse files Browse the repository at this point in the history
  • Loading branch information
7eventy7 committed Nov 13, 2024
1 parent 28c1fdc commit b09d920
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 60 deletions.
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@

> Track your favorite artists' new releases with Discord notifications
Trackly is a Docker container that monitors your music library and notifies you about new releases from your favorite artists via Discord webhooks. The application scans a specified music directory for artist folders and tracks new releases using the Discogs API.
Trackly is a Docker container that monitors your music library and notifies you about new releases from your favorite artists via Discord webhooks. The application scans a specified music directory for artist folders and tracks new releases using the MusicBrainz API.

## ✨ Features

- 📁 **Smart Directory Monitoring** - Watches your local music directory for artist folders
- 🎵 **Release Tracking** - Tracks new releases (albums, EPs, and singles) from your artists
- 🎵 **Release Tracking** - Tracks new album releases from your artists using MusicBrainz API
- 🔔 **Discord Integration** - Sends beautiful Discord notifications for new releases
- 🤖 **Automated Updates** - Automatic periodic checks based on your schedule
- 🔄 **Real-time Updates** - Instant updates when new artists are added to the music folder
- 🚦 **Smart Rate Limiting** - Respects MusicBrainz API rate limits with exponential backoff
- 🎯 **Year-Specific Tracking** - Only tracks releases from the current year

## 📋 Prerequisites

Expand Down Expand Up @@ -115,11 +117,24 @@ docker-compose up -d --build

The bot sends beautiful Discord notifications with:

- 📀 Release type (Album, EP, or Single)
- 👤 Artist name
- 🎵 Release name
- 🎵 Album name
- 📅 Release date
- 🖼️ Album artwork thumbnail

## 🔄 How It Works

1. **Artist Discovery**: Scans your music directory for artist folders
2. **MusicBrainz Integration**:
- Searches for artists on MusicBrainz
- Stores artist IDs for efficient lookups
- Checks for new album releases from the current year
3. **Smart Rate Limiting**:
- Implements adaptive delays between requests
- Uses exponential backoff for failed requests
- Adds random jitter to prevent request clustering
4. **Local Library Check**:
- Compares new releases with your local music folders
- Only notifies for albums you don't have

## 📊 Monitoring and Logs

Expand Down Expand Up @@ -173,4 +188,4 @@ MIT License - feel free to use and modify as needed.

<div align="center">
Made with ❤️ by 7eventy7
</div>
</div>
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
python-dotenv==1.0.0
python3-discogs-client>=2.0.0
requests==2.31.0
schedule==1.2.1
watchdog==3.0.0
185 changes: 132 additions & 53 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import time
from datetime import datetime
import schedule
import discogs_client
import requests
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from dotenv import load_dotenv
import logging
import random

# Set up logging
logging.basicConfig(
Expand All @@ -19,12 +19,44 @@
logger = logging.getLogger(__name__)

CONFIG_PATH = "/config/artists.json"
MUSICBRAINZ_BASE_URL = "https://musicbrainz.org/ws/2"
USER_AGENT = "Trackly/1.0.0 (https://github.com/7eventy7/trackly)"

class RateLimiter:
def __init__(self, min_delay=1.0, max_delay=3.0):
self.min_delay = min_delay
self.max_delay = max_delay
self.last_request_time = 0
self.consecutive_failures = 0

def wait(self):
"""Wait appropriate time between requests"""
now = time.time()
# Calculate delay with jitter
delay = self.min_delay + random.random() * (self.max_delay - self.min_delay)
# Add exponential backoff if there were failures
if self.consecutive_failures > 0:
delay *= (2 ** self.consecutive_failures)
delay = min(delay, 30) # Cap maximum delay at 30 seconds

time_since_last = now - self.last_request_time
if time_since_last < delay:
time.sleep(delay - time_since_last)

self.last_request_time = time.time()

def success(self):
"""Reset failure counter on successful request"""
self.consecutive_failures = 0

def failure(self):
"""Increment failure counter"""
self.consecutive_failures += 1

class MusicFolderHandler(FileSystemEventHandler):
def __init__(self):
self.artists_file = CONFIG_PATH
self.last_update = time.time()
# Prevent multiple updates within 5 seconds
self.update_cooldown = 5

def on_any_event(self, event):
Expand All @@ -40,39 +72,91 @@ def load_config():
"""Load environment variables"""
update_interval = os.getenv('UPDATE_INTERVAL')
discord_webhook = os.getenv('DISCORD_WEBHOOK')
discogs_token = os.getenv('DISCOGS_TOKEN')

logger.info("Loading configuration...")
if not all([update_interval, discord_webhook, discogs_token]):
if not all([update_interval, discord_webhook]):
missing_vars = [var for var, val in {
'UPDATE_INTERVAL': update_interval,
'DISCORD_WEBHOOK': discord_webhook,
'DISCOGS_TOKEN': discogs_token
'DISCORD_WEBHOOK': discord_webhook
}.items() if not val]
logger.error(f"Missing required environment variables: {', '.join(missing_vars)}")
raise ValueError("Missing required environment variables")

logger.info(f"Configuration loaded successfully. Update interval set to: {update_interval}")
# Use the mounted path directly
music_path = "/music"

return music_path, update_interval, discord_webhook

def make_musicbrainz_request(url, params, rate_limiter):
"""Make a rate-limited request to MusicBrainz API"""
headers = {'User-Agent': USER_AGENT}
max_retries = 3

for attempt in range(max_retries):
rate_limiter.wait()
try:
response = requests.get(url, params=params, headers=headers)
response.raise_for_status()
rate_limiter.success()
return response.json()
except requests.exceptions.RequestException as e:
rate_limiter.failure()
logger.warning(f"Request failed (attempt {attempt + 1}/{max_retries}): {str(e)}")
if attempt == max_retries - 1:
raise
return None

def get_artist_id(artist_name, rate_limiter):
"""Get MusicBrainz ID for an artist"""
url = f"{MUSICBRAINZ_BASE_URL}/artist"
params = {
'query': artist_name,
'limit': 1,
'fmt': 'json'
}

try:
data = make_musicbrainz_request(url, params, rate_limiter)
if data and data.get('artists'):
return data['artists'][0]['id']
return None
except Exception as e:
logger.error(f"Error getting MusicBrainz ID for {artist_name}: {str(e)}")
return None

def update_artist_list():
"""Update the JSON list of artists from the music directory"""
logger.info("Updating artist list...")
music_path = "/music"
rate_limiter = RateLimiter()

try:
artists = [d for d in os.listdir(music_path)
if os.path.isdir(os.path.join(music_path, d))]
artists = []
for artist_name in [d for d in os.listdir(music_path)
if os.path.isdir(os.path.join(music_path, d))]:
artist_id = get_artist_id(artist_name, rate_limiter)
if artist_id:
artists.append({
'name': artist_name,
'id': artist_id
})
else:
logger.warning(f"Could not find MusicBrainz ID for {artist_name}")
artists.append({
'name': artist_name,
'id': None
})

logger.info(f"Found {len(artists)} artists in music directory")

with open(CONFIG_PATH, 'w') as f:
json.dump({'artists': artists, 'last_updated': datetime.now().isoformat()}, f, indent=2)
json.dump({
'artists': artists,
'last_updated': datetime.now().isoformat()
}, f, indent=2)

logger.info("Artist list updated successfully")
logger.debug(f"Artists found: {', '.join(artists)}")
logger.debug(f"Artists found: {', '.join([a['name'] for a in artists])}")
except Exception as e:
logger.error(f"Error updating artist list: {str(e)}")
raise
Expand All @@ -84,12 +168,11 @@ def send_discord_notification(release_info):
logger.info(f"Sending Discord notification for {release_info['artist']} - {release_info['title']}")

embed = {
"title": f"New {release_info['type']} Release!",
"title": f"New Album Release!",
"description": f"Artist: {release_info['artist']}\n"
f"{release_info['type'].title()}: {release_info['title']}\n"
f"Album: {release_info['title']}\n"
f"Release Date: {release_info['release_date']}",
"color": 3447003, # Blue color
"thumbnail": {"url": release_info.get('thumb_url', '')}
"color": 3447003 # Blue color
}

payload = {"embeds": [embed]}
Expand All @@ -103,6 +186,8 @@ def send_discord_notification(release_info):
def check_new_releases():
"""Check for new releases from tracked artists"""
logger.info("Starting new release check...")
rate_limiter = RateLimiter()

try:
with open(CONFIG_PATH, 'r') as f:
data = json.load(f)
Expand All @@ -120,57 +205,51 @@ def check_new_releases():
logger.error(f"Unexpected error reading config: {str(e)}")
return

# Initialize Discogs client
logger.info("Initializing Discogs client...")
discogs = discogs_client.Client('TracklyBot/1.0', user_token=os.getenv('DISCOGS_TOKEN'))
current_year = datetime.now().year

for artist in artists:
logger.info(f"Checking releases for artist: {artist}")
if not artist['id']:
logger.warning(f"Skipping {artist['name']} - no MusicBrainz ID")
continue

logger.info(f"Checking releases for artist: {artist['name']}")
try:
# Search for the artist on Discogs
results = discogs.search(artist, type='artist')
if not results:
logger.warning(f"No Discogs results found for artist: {artist}")
continue
url = f"{MUSICBRAINZ_BASE_URL}/release-group"
params = {
'artist': artist['id'],
'type': 'album',
'limit': 25,
'offset': 0,
'fmt': 'json'
}

artist_id = results[0].id
logger.info(f"Found Discogs ID for {artist}: {artist_id}")
artist_releases = discogs.artist(artist_id).releases
release_data = make_musicbrainz_request(url, params, rate_limiter)

# Check recent releases
releases_checked = 0
for release in artist_releases:
for release_group in release_data.get('release-groups', []):
try:
release_date = datetime.strptime(release.release_date, '%Y-%m-%d')
releases_checked += 1

if release_date > last_check:
logger.info(f"Found new release for {artist}: {release.title}")
# Determine release type
release_type = 'album'
if release.formats[0].get('quantity', '1') == '1':
if release.formats[0].get('descriptions', [''])[0] == 'Single':
release_type = 'track'
elif release.formats[0].get('descriptions', [''])[0] == 'EP':
release_type = 'ep'
release_date = release_group.get('first-release-date', '')
if not release_date.startswith(str(current_year)):
continue

album_title = release_group['title']
artist_folder = os.path.join('/music', artist['name'])
album_folder = os.path.join(artist_folder, album_title)

if not os.path.exists(album_folder):
logger.info(f"Found new album for {artist['name']}: {album_title}")
release_info = {
'type': release_type,
'artist': artist,
'title': release.title,
'release_date': release.release_date,
'thumb_url': release.thumb
'artist': artist['name'],
'title': album_title,
'release_date': release_date
}

send_discord_notification(release_info)
except (AttributeError, ValueError) as e:
logger.error(f"Error processing release for {artist}: {e}")

except Exception as e:
logger.error(f"Error processing release for {artist['name']}: {e}")
continue

logger.info(f"Checked {releases_checked} releases for {artist}")

except Exception as e:
logger.error(f"Error checking releases for {artist}: {e}")
logger.error(f"Error checking releases for {artist['name']}: {e}")
continue

logger.info("Completed release check for all artists")
Expand Down

0 comments on commit b09d920

Please sign in to comment.