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 + + + + +
+
+

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 %} + Cover Art {{ release.title }} + {% 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 %} +
  • +
    + {{ feed.name }} + ({{ feed.artists|length }}) +
    +
    + RSS +
    + +
    +
    +
  • + {% 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