commit f2cb420182db4b27051351570932ace5bcc551b4
Author: Volker
Date: Thu Dec 25 22:32:24 2025 +0100
initial v1
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..99f975d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.venv
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.idea
+.git
\ No newline at end of file
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..ec53ea1
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,45 @@
+
+name: Docker Publish
+
+on:
+ push:
+ branches: [ "main" ]
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-and-push-image:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Read version from file
+ id: version
+ run: echo "TAG=$(cat VERSION)" >> $GITHUB_OUTPUT
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build, Test and Push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ target: final
+ push: true
+ tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.TAG }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6e25118
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+*.iml
+.idea
+.venv
+local
+.DS_Store
+**/__pycache__
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1a36955
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,40 @@
+
+# Base stage for installing dependencies
+FROM python:3.10-slim AS base
+WORKDIR /app
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+# Builder stage for production dependencies
+FROM base AS builder
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Default config and data paths
+ENV FEEDS_FILE_PATH=/var/mbz-rss-feeder/feeds.yml
+ENV CONFIG_FILE_PATH=/etc/mbz-rss-feeder.yml
+ENV CACHE_DIR=/var/mbz-rss-feeder/cache
+ENV LOG_FILE=/var/log/mbz-rss-feeder.log
+ENV LOG_LEVEL=INFO
+
+RUN mkdir -p /var/mbz-rss-feeder/cache \
+ && touch /var/log/mbz-rss-feeder.log \
+ && touch /var/mbz-rss-feeder/feeds.yml \
+ && touch /etc/mbz-rss-feeder.yml
+
+# Test stage
+FROM builder AS test
+COPY mzb_rss_service/tests/requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+COPY . .
+CMD ["python -m pytest"]
+
+# Final production stage
+FROM base AS final
+COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
+COPY --from=builder /usr/local/bin /usr/local/bin
+COPY mzb_rss_service mzb_rss_service
+COPY VERSION .
+
+EXPOSE 8080
+CMD ["gunicorn", "--bind", "0.0.0.0:8080", "mzb_rss_service.main:app"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..41cd9e2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,62 @@
+# MusicBrainz RSS Feeder
+`mbz-rss-feeder`
+
+## Overview
+A small webserver that provides OPML, RSS and a minimal management UI to generate and manage feeds for artist release on
+[MusicBrainz](musicbrainz.org).
+
+The OPML can be used to discover the current feeds.
+A feed lists all new releases for a group of artists.
+
+## Web UI
+### Feeds and artists
+You can manage feeds and artists in the web UI:
+- create a new feed or delete a feed
+- edit a feed: give it a name and manage the artists in the feed
+ - add an artist
+ - remove an artist
+
+**Artist Search*
+When adding an artist, the service will query the musicbrainz API for the given string and return a list of matching artists.
+The user can then click on the artist to add to the feed.
+
+### Settings
+In the settings page, you can
+* set the number of days to look back (default: 90 days)
+* the caching time (default: 8 hours)
+
+## Technical Features
+### Persistence
+These files should be mounted to persist the configuration.
+* the feed and artist information is stored in `/var/mbz-rss-feeder/feeds.yml`
+* the service configuration is stored in `/etc/mbz-rss-feeder.yml`
+
+Cached data is stored under `/var/mbz-rss-feeder/cache` but is not required to persist.
+
+Updates to the configuration will also create a timestamped backup of the previous configuration.
+
+### Persistence updates
+
+### deployment
+#### docker
+`docker run \
+ -p 8080:8080 -e LOG_LEVEL=debug --rm --name mbz-rss\
+ -v ./local/testdata/feeds.yml:/var/mbz-rss-feeder/feeds.yml \
+ -v ./local/testdata/mbz-rss-feeder.yml:/etc/mbz-rss-feeder.yml \
+ -v ./local/testdata/cache:/var/mbz-rss-feeder/cache \
+ -v ./local/testdata/mbz-rss-feeder.log:/var/mbz-rss-feeder.log \
+ --env-file ./local/testdata/.env \
+ docker.io/library/mzb-rss-feeder:1.0.0`
+
+After startup the server can be accessed through http://docker-host:8080.
+
+**Environment**
+```bash
+# User Agent for MusicBrainz API
+MB_APP_NAME=MinifluxRSSGenerator
+MB_VERSION=2.0
+MB_CONTACT=yourname@example.com
+```
+
+### Dependencies/Requirements
+The pypi package musicbrainzngs is used to communicate with MusicBrainz.
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..afaf360
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.0.0
\ No newline at end of file
diff --git a/mzb_rss_service/__init__.py b/mzb_rss_service/__init__.py
new file mode 100644
index 0000000..89cb37b
--- /dev/null
+++ b/mzb_rss_service/__init__.py
@@ -0,0 +1 @@
+# mzb_rss_service package
diff --git a/mzb_rss_service/config.py b/mzb_rss_service/config.py
new file mode 100644
index 0000000..30f2006
--- /dev/null
+++ b/mzb_rss_service/config.py
@@ -0,0 +1,132 @@
+
+import shutil
+import os
+import yaml
+from datetime import datetime, timezone
+import logging
+import uuid
+
+logger = logging.getLogger(__name__)
+
+FEEDS_FILE_PATH = os.path.expandvars(os.environ.get('FEEDS_FILE_PATH', '/var/mbz-rss-feeder/feeds.yml'))
+CONFIG_FILE_PATH = os.path.expandvars(os.environ.get('CONFIG_FILE_PATH', '/etc/mbz-rss-feeder.yml'))
+CACHE_DIR = os.path.expandvars(os.environ.get('CACHE_DIR', '/var/mbz-rss-feeder/cache'))
+MB_APP_NAME = os.path.expandvars(os.environ.get('MB_APP_NAME', 'mzb-rss-service'))
+MB_VERSION = os.path.expandvars(os.environ.get('MB_VERSION', '0.1.0'))
+MB_CONTACT = os.path.expandvars(os.environ.get('MB_CONTACT', 'someone@somewhere.com'))
+
+
+class Config:
+ def __init__(self):
+ self._feeds_data = self._load_yaml(FEEDS_FILE_PATH)
+ self._settings = self._load_yaml(CONFIG_FILE_PATH)
+ self.FEEDS_FILE_PATH = FEEDS_FILE_PATH
+ self.CONFIG_FILE_PATH = CONFIG_FILE_PATH
+ self.CACHE_DIR = CACHE_DIR
+ self.MB_APP_NAME = MB_APP_NAME
+ self.MB_VERSION = MB_VERSION
+ self.MB_CONTACT = MB_CONTACT
+ try:
+ with open(os.path.join(os.path.dirname(__file__), '..', 'VERSION')) as f:
+ self.VERSION = f.read().strip()
+ except FileNotFoundError:
+ self.VERSION = '0.0.0'
+
+ def __getattr__(self, name):
+ if name == 'feeds':
+ return self._feeds_data.get('feeds', [])
+ if name in self._settings['service']:
+ return self._settings['service'][name]
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
+
+ def get_settings(self):
+ return self._settings
+
+ def _load_yaml(self, file_path):
+ if not os.path.exists(file_path):
+ return {}
+ with open(file_path, 'r') as f:
+ return yaml.safe_load(f) or {}
+
+ def _save_yaml(self, data, file_path):
+ if os.path.exists(file_path):
+ backup_path = f"{file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}.bak"
+ shutil.copy(file_path, backup_path)
+
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
+ with open(file_path, 'w') as f:
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
+
+ @property
+ def feeds(self):
+ return self._feeds_data.get('feeds', [])
+
+ def save_feeds(self):
+ self._save_yaml(self._feeds_data, FEEDS_FILE_PATH)
+
+ def get_feed(self, feed_id):
+ for feed in self.feeds:
+ if feed['id'] == feed_id:
+ return feed
+ return None
+
+ def add_feed(self, name):
+ now = datetime.now(timezone.utc).isoformat()
+ new_feed = {
+ 'id': str(uuid.uuid4()),
+ 'name': name,
+ 'artists': [],
+ 'created_at': now,
+ 'updated_at': now
+ }
+ if 'feeds' not in self._feeds_data:
+ self._feeds_data['feeds'] = []
+ self._feeds_data['feeds'].append(new_feed)
+ self.save_feeds()
+ return new_feed
+
+ def delete_feed(self, feed_id):
+ if 'feeds' in self._feeds_data:
+ self._feeds_data['feeds'] = [feed for feed in self.feeds if feed['id'] != feed_id]
+ self.save_feeds()
+
+ def add_artist_to_feed(self, feed_id, artist_id, artist_name):
+ for feed in self.feeds:
+ if feed['id'] == feed_id:
+ if 'artists' not in feed:
+ feed['artists'] = []
+ if not any(a['id'] == artist_id for a in feed['artists']):
+ feed['artists'].append({'id': artist_id, 'name': artist_name})
+ feed['updated_at'] = datetime.now(timezone.utc).isoformat()
+ self.save_feeds()
+ break
+
+ def remove_artist_from_feed(self, feed_id, artist_id):
+ for feed in self.feeds:
+ if feed['id'] == feed_id and 'artists' in feed:
+ original_artist_count = len(feed['artists'])
+ feed['artists'] = [a for a in feed['artists'] if a['id'] != artist_id]
+ if len(feed['artists']) < original_artist_count:
+ feed['updated_at'] = datetime.now(timezone.utc).isoformat()
+ self.save_feeds()
+ break
+
+ def get_artist_name(self, artist_id):
+ for feed in self.feeds:
+ for artist in feed.get('artists', []):
+ if artist['id'] == artist_id:
+ return artist['name']
+ return 'unknown artist'
+
+ def save_settings(self, days_back, cache_time_hours):
+ logger.debug(f"Saving settings: days_back={days_back}, cache_time_hours={cache_time_hours}")
+ if 'service' not in self._settings:
+ self._settings['service'] = {}
+ if days_back is not None:
+ self._settings['service']['days_back'] = int(days_back)
+ if cache_time_hours is not None:
+ self._settings['service']['cache_time_hours'] = int(cache_time_hours)
+ self._save_yaml(self._settings, CONFIG_FILE_PATH)
+
+# Global instance
+config = Config()
diff --git a/mzb_rss_service/main.py b/mzb_rss_service/main.py
new file mode 100644
index 0000000..d9895f3
--- /dev/null
+++ b/mzb_rss_service/main.py
@@ -0,0 +1,261 @@
+
+from .config import config
+import logging
+from flask import Flask, jsonify, render_template, request, redirect, url_for
+from datetime import datetime, timedelta, timezone
+from email.utils import formatdate, parsedate_to_datetime
+import xml.etree.ElementTree as ET
+import os
+import sys
+from . import musicbrainz
+
+log_level_str = os.environ.get('LOG_LEVEL', 'INFO').upper()
+log_level = getattr(logging, log_level_str, logging.INFO)
+log_file = os.path.expandvars(os.environ.get('LOG_FILE', '/var/log/mbz-rss-feeder.log'))
+log_format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+mbz_log_level_str = os.environ.get('LOG_LEVEL', 'WARNING').upper()
+mbz_log_level = os.environ.get('MBZ_LOG_LEVEL', logging.WARNING)
+
+# Configure root logger
+root_logger = logging.getLogger()
+root_logger.setLevel(log_level)
+formatter = logging.Formatter(log_format)
+
+# configure musicbrainz logger
+logging.getLogger('musicbrainzngs').setLevel(mbz_log_level)
+
+# Clear existing handlers
+if root_logger.hasHandlers():
+ root_logger.handlers.clear()
+
+# Add file handler
+file_handler = logging.FileHandler(log_file)
+file_handler.setFormatter(formatter)
+root_logger.addHandler(file_handler)
+
+# Add stdout handler if running with gunicorn (in container)
+if 'gunicorn' in sys.argv[0]:
+ stdout_handler = logging.StreamHandler(sys.stdout)
+ stdout_handler.setFormatter(formatter)
+ root_logger.addHandler(stdout_handler)
+
+
+# Get the werkzeug logger and remove its default handlers
+werkzeug_logger = logging.getLogger('werkzeug')
+for handler in werkzeug_logger.handlers:
+ werkzeug_logger.removeHandler(handler)
+
+logger = logging.getLogger(__name__)
+
+
+logger.info("Starting mzb-rss-service with the following configuration:")
+logger.info(f" - Log Level: {log_level_str}")
+logger.info(f" - Log File: {log_file}")
+logger.info(f" - Feeds File: {config.FEEDS_FILE_PATH}")
+logger.info(f" - Config File: {config.CONFIG_FILE_PATH}")
+logger.info(f" - MusicBrainz App Name: {config.MB_APP_NAME}")
+logger.info(f" - MusicBrainz App Version: {config.MB_VERSION}")
+logger.info(f" - MusicBrainz Contact: {config.MB_CONTACT}")
+logger.info(f" - MusicBrainz Log Level: {mbz_log_level_str}")
+
+
+# init
+musicbrainz.init_musicbrainz()
+app = Flask(__name__)
+app.template_folder = 'templates'
+
+
+def _get_cache_file_path(feed_id):
+ """Constructs the full path for a feed's cache file."""
+ return os.path.join(config.CACHE_DIR, f"{feed_id}.xml")
+
+
+def _get_cache_last_build_date(cache_file):
+ """Parses the cache file and returns its last build date as a timezone-aware datetime object."""
+ if not os.path.exists(cache_file):
+ return None
+
+ try:
+ tree = ET.parse(cache_file)
+ root = tree.getroot()
+ last_build_date_str = root.findtext('channel/lastBuildDate')
+ if not last_build_date_str:
+ return None
+
+ last_build_date = parsedate_to_datetime(last_build_date_str)
+ # Ensure last_build_date is timezone-aware (in UTC)
+ if last_build_date.tzinfo is None:
+ return last_build_date.replace(tzinfo=timezone.utc)
+ return last_build_date
+ except (ET.ParseError, FileNotFoundError) as e:
+ logger.warning(f"Could not read or parse cache file {cache_file}: {e}")
+ return None
+
+
+def _is_cache_stale(last_build_date, feed_data, cache_time_hours):
+ """Determines if the cache is stale based on update times and age."""
+ if not last_build_date:
+ return True # No build date means it's stale
+
+ # Invalidate if feed config was updated since last cache build
+ if 'updated_at' in feed_data and feed_data['updated_at']:
+ feed_updated_at = datetime.fromisoformat(feed_data['updated_at'])
+ if feed_updated_at > last_build_date:
+ logger.debug(f"Feed {feed_data['id']} has been updated. Invalidating cache.")
+ return True
+
+ # Invalidate if cache is older than the configured time
+ if datetime.now(timezone.utc) - last_build_date > timedelta(hours=cache_time_hours):
+ logger.debug(f"Cache for feed {feed_data['id']} is older than {cache_time_hours} hours. Invalidating.")
+ return True
+
+ return False
+
+
+def _check_cache(feed_id):
+ """Checks for a valid cached feed and returns it if found."""
+ feed_data = config.get_feed(feed_id)
+ if not feed_data:
+ return None # Feed doesn't exist, so no cache.
+
+ cache_file = _get_cache_file_path(feed_id)
+ last_build_date = _get_cache_last_build_date(cache_file)
+
+ cache_time_hours = int(config.get_settings().get('service', {}).get('cache_time_hours', 24))
+
+ if not _is_cache_stale(last_build_date, feed_data, cache_time_hours):
+ try:
+ with open(cache_file, 'r') as f:
+ logger.debug(f"Serving cached feed for {feed_id}")
+ return f.read(), {"Content-Type": "application/xml"}
+ except IOError as e:
+ logger.warning(f"Could not read cache file {cache_file}: {e}")
+
+ return None
+
+
+@app.route("/")
+def index():
+ logger.debug("Request for index page")
+ feeds = config.feeds
+ return render_template('index.html', feeds=feeds)
+
+@app.route("/feed/create", methods=["POST"])
+def create_feed():
+ feed_name = request.form.get('name')
+ logger.debug(f"Request to create feed with name: {feed_name}")
+ if feed_name and feed_name.strip():
+ config.add_feed(feed_name.strip())
+ return redirect(url_for('index'))
+
+@app.route("/feed//delete", methods=["POST"])
+def delete_feed(feed_id):
+ logger.debug(f"Request to delete feed with id: {feed_id}")
+ config.delete_feed(feed_id)
+ return redirect(url_for('index'))
+
+@app.route("/feed//edit")
+def edit_feed(feed_id):
+ logger.debug(f"Request to edit feed with id: {feed_id}")
+ feed = config.get_feed(feed_id)
+ if not feed:
+ return "Feed not found", 404
+ return render_template('feed.html', feed=feed)
+
+@app.route("/feed//artist/add", methods=["POST"])
+def add_artist(feed_id):
+ artist_id = request.form.get('artist_id')
+ artist_name = request.form.get('artist_name')
+ logger.debug(f"Request to add artist '{artist_name}' ({artist_id}) to feed {feed_id}")
+ if artist_id and artist_name:
+ config.add_artist_to_feed(feed_id, artist_id, artist_name)
+ return redirect(url_for('edit_feed', feed_id=feed_id))
+
+@app.route("/feed//artist//remove", methods=["POST"])
+def remove_artist(feed_id, artist_id):
+ logger.debug(f"Request to remove artist {artist_id} from feed {feed_id}")
+ config.remove_artist_from_feed(feed_id, artist_id)
+ return redirect(url_for('edit_feed', feed_id=feed_id))
+
+@app.route("/artist/search")
+def search_artist():
+ query = request.args.get('q', '')
+ logger.debug(f"Request to search for artist with query: '{query}'")
+ artists = musicbrainz.search_artists(query)
+ return jsonify(artists)
+
+@app.route("/settings", methods=["GET", "POST"])
+def settings():
+ if request.method == "POST":
+ logger.debug("Request to update settings")
+ days_back = request.form.get('days_back')
+ cache_time_hours = request.form.get('cache_time_hours')
+ config.save_settings(days_back, cache_time_hours)
+ return redirect(url_for('settings'))
+
+ logger.debug("Request for settings page")
+ return render_template('settings.html', settings=config)
+
+@app.route("/opml")
+def opml():
+ logger.debug("Request for OPML file")
+ feeds = config.feeds
+ opml_content = render_template('opml.xml', feeds=feeds)
+ return opml_content, {"Content-Type": "application/xml"}
+
+@app.route('/feed/')
+def get_feed_rss(feed_id):
+ logger.debug(f"Request for RSS feed with id: {feed_id}")
+
+ # Check cache for a valid feed before generating it
+ cached_response = _check_cache(feed_id)
+ if cached_response:
+ return cached_response
+
+ # cache outdated or not found, generate a new feed
+ logger.debug(f"Generating new feed for {feed_id}")
+ feed_data = config.get_feed(feed_id)
+ if not feed_data:
+ return "Feed not found", 404
+
+ # Add rfc822 formatted updated_at for template
+ if 'updated_at' in feed_data and feed_data['updated_at']:
+ updated_at_dt = datetime.fromisoformat(feed_data['updated_at'])
+ feed_data['updated_at_rfc822'] = formatdate(updated_at_dt.timestamp())
+ else:
+ # Fallback for older feeds without updated_at
+ now_utc = datetime.now(timezone.utc)
+ feed_data['updated_at_rfc822'] = formatdate(now_utc.timestamp())
+
+ all_releases = []
+ if 'artists' in feed_data:
+ for artist in feed_data['artists']:
+ releases = musicbrainz.get_artist_releases(artist['id'])
+ all_releases.extend(releases)
+
+ # Sort releases by date, newest first
+ all_releases.sort(key=lambda r: r.get('date', '0000-00-00'), reverse=True)
+
+ last_build_date = formatdate(datetime.now(timezone.utc).timestamp())
+
+ rss_content = render_template('feed.xml', feed=feed_data, releases=all_releases, last_build_date=last_build_date)
+
+ cache_dir = config.CACHE_DIR
+ os.makedirs(cache_dir, exist_ok=True)
+ cache_file = os.path.join(cache_dir, f"{feed_id}.xml")
+ try:
+ with open(cache_file, 'w') as f:
+ f.write(rss_content)
+ logger.debug(f"Cached feed '{feed_data['name']}' at {cache_file}")
+ except IOError as e:
+ logger.warning(f"Could not write feed '{feed_data['name']}' to cache file {cache_file}: {e}")
+
+ return rss_content, {"Content-Type": "application/xml"}
+
+@app.route("/health")
+def health_check():
+ logger.debug("Health check requested")
+ return jsonify({"status": "ok"})
+
+if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=8080, debug=True)
diff --git a/mzb_rss_service/musicbrainz.py b/mzb_rss_service/musicbrainz.py
new file mode 100644
index 0000000..03e920c
--- /dev/null
+++ b/mzb_rss_service/musicbrainz.py
@@ -0,0 +1,97 @@
+import musicbrainzngs
+import logging
+import os
+from .config import config
+from datetime import datetime
+from email.utils import formatdate
+
+logger = logging.getLogger(__name__)
+
+def init_musicbrainz():
+ app_name = config.MB_APP_NAME
+ version = config.MB_VERSION
+ contact = config.MB_CONTACT
+
+ logger.debug(f"Initializing MusicBrainz API with user agent: {app_name}/{version} ( {contact} )")
+ musicbrainzngs.set_useragent(app_name, version, contact)
+
+def search_artists(query):
+ logger.debug(f"Searching for artists with query: '{query}'")
+ try:
+ result = musicbrainzngs.search_artists(query=query, limit=10)
+ artists = []
+ if 'artist-list' in result:
+ for artist in result['artist-list']:
+ artists.append({
+ 'id': artist['id'],
+ 'name': artist['name'],
+ 'disambiguation': artist.get('disambiguation', '')
+ })
+ logger.debug(f"Found {len(artists)} artists for query: '{query}'")
+ return artists
+ except musicbrainzngs.MusicBrainzError as e:
+ logger.error(f"MusicBrainz API error while searching for '{query}': {e}")
+ return []
+
+def _parse_release_date(release_date_str, release_title):
+ """Parse a release date string which can be YYYY, YYYY-MM, or YYYY-MM-DD."""
+ if not release_date_str:
+ return None, None
+
+ date_formats = ['%Y-%m-%d', '%Y-%m', '%Y']
+ dt = None
+ for fmt in date_formats:
+ try:
+ dt = datetime.strptime(release_date_str, fmt)
+ if fmt == '%Y-%m':
+ dt = dt.replace(day=1)
+ elif fmt == '%Y':
+ dt = dt.replace(month=1, day=1)
+ break # Found a valid format
+ except (ValueError, TypeError):
+ continue
+
+ if dt:
+ return dt, formatdate(dt.timestamp())
+
+ logger.warning(f"Could not parse date format '{release_date_str}' for release '{release_title}'")
+ return None, None
+
+def _process_release(release, artist_id):
+ """Process a single release from the MusicBrainz API response."""
+ release_date = release.get('date', 'Unknown')
+ _, pub_date = _parse_release_date(release_date, release['title'])
+
+ return {
+ 'artist': {
+ 'name': config.get_artist_name(artist_id),
+ 'id': artist_id,
+ },
+ 'hasCoverArt': release.get('cover-art-archive', {}).get('artwork') == 'true',
+ 'id': release['id'],
+ 'title': release['title'],
+ 'date': release_date,
+ 'pub_date': pub_date,
+ 'release-group': release.get('release-group', {})
+ }
+
+def get_artist_releases(artist_id):
+ """Fetch releases for a given artist ID from MusicBrainz."""
+ artist_name = config.get_artist_name(artist_id)
+ logger.debug(f"Fetching releases for artist {artist_name} ({artist_id})")
+ try:
+ result = musicbrainzngs.browse_releases(
+ artist=artist_id,
+ release_type=['album'],
+ includes=['release-groups']
+ )
+
+ releases = []
+ if 'release-list' in result:
+ releases = [_process_release(r, artist_id) for r in result['release-list']]
+
+ logger.debug(f"Found {len(releases)} releases for artist {artist_name} ({artist_id})")
+ return releases
+ except musicbrainzngs.MusicBrainzError as e:
+ logger.error(f"MusicBrainz API error while fetching releases for artist '{artist_id}': {e}")
+ return []
diff --git a/mzb_rss_service/templates/base.html b/mzb_rss_service/templates/base.html
new file mode 100644
index 0000000..2b69aa1
--- /dev/null
+++ b/mzb_rss_service/templates/base.html
@@ -0,0 +1,104 @@
+
+
+
+
+ MusicBrainz RSS Feeder
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
\ No newline at end of file
diff --git a/mzb_rss_service/templates/feed.html b/mzb_rss_service/templates/feed.html
new file mode 100644
index 0000000..3ec97f5
--- /dev/null
+++ b/mzb_rss_service/templates/feed.html
@@ -0,0 +1,72 @@
+
+{% extends "base.html" %}
+
+{% block content %}
+ Edit Feed: {{ feed.name }}
+
+ Artists in this feed
+
+ {% for artist in feed.artists %}
+ -
+ {{ artist.name }}
+
+
+
+
+ {% else %}
+ - No artists in this feed.
+ {% endfor %}
+
+
+ Add Artist
+
+
+
+
+{% endblock %}
diff --git a/mzb_rss_service/templates/feed.xml b/mzb_rss_service/templates/feed.xml
new file mode 100644
index 0000000..37a6816
--- /dev/null
+++ b/mzb_rss_service/templates/feed.xml
@@ -0,0 +1,29 @@
+
+
+
+ {{ feed.name }}
+ {{ request.url_root }}
+ Latest album releases for artists in the '{{ feed.name }}' feed.
+ en
+ {{ last_build_date }}
+
+
+ {% for release in releases %}
+ -
+ {{ release.artist.name }} - {{ release.title }}
+ https://musicbrainz.org/release/{{ release.id }}
+ {{ release.id }}
+ {{ release.artist.name }} {{ release.title }}
+ Release Date: {{ release.date }}
+ {% if release.hasCoverArt %}
+
+ {% endif %}
+ ]]>
+ {% if release.pub_date %}
+ {{ release.pub_date }}
+ {% endif %}
+
+ {% endfor %}
+
+
\ No newline at end of file
diff --git a/mzb_rss_service/templates/index.html b/mzb_rss_service/templates/index.html
new file mode 100644
index 0000000..839c9bc
--- /dev/null
+++ b/mzb_rss_service/templates/index.html
@@ -0,0 +1,31 @@
+
+{% extends "base.html" %}
+
+{% block content %}
+ Feeds
+
+ {% for feed in feeds %}
+ -
+
+
+
+ {% else %}
+ - No feeds configured.
+ {% endfor %}
+
+
+ Create New Feed
+
+{% endblock %}
diff --git a/mzb_rss_service/templates/opml.xml b/mzb_rss_service/templates/opml.xml
new file mode 100644
index 0000000..aa24f1d
--- /dev/null
+++ b/mzb_rss_service/templates/opml.xml
@@ -0,0 +1,13 @@
+
+
+
+ MusicBrainz Artist Feeds
+
+
+
+ {% for feed in feeds %}
+
+ {% endfor %}
+
+
+
\ No newline at end of file
diff --git a/mzb_rss_service/templates/settings.html b/mzb_rss_service/templates/settings.html
new file mode 100644
index 0000000..00f69f1
--- /dev/null
+++ b/mzb_rss_service/templates/settings.html
@@ -0,0 +1,19 @@
+
+{% extends "base.html" %}
+
+{% block content %}
+ Settings
+
+{% endblock %}
diff --git a/mzb_rss_service/tests/requirements.txt b/mzb_rss_service/tests/requirements.txt
new file mode 100644
index 0000000..0595d18
--- /dev/null
+++ b/mzb_rss_service/tests/requirements.txt
@@ -0,0 +1,7 @@
+Flask
+PyYAML
+musicbrainzngs
+feedgen
+gunicorn
+pytest
+pytz
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..1f7fd96
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+Flask
+PyYAML
+musicbrainzngs
+feedgen
+gunicorn
+pytz
\ No newline at end of file