9 Commits

Author SHA1 Message Date
droideparanoico 01e641bf41 Delete directory '.github/workflows'
Build & Deploy / build-and-deploy (push) Successful in 6s
2026-07-01 13:07:42 +02:00
droideparanoico ac1e42d614 Delete directory '.gitea/wokrflows'
Build & Deploy / build-and-deploy (push) Successful in 4s
2026-07-01 12:46:05 +02:00
droideparanoico 7d012c2e44 Add .gitea/workflows/build.yaml
Build & Deploy / build-and-deploy (push) Successful in 18s
2026-07-01 12:45:52 +02:00
droideparanoico 86c3b4d4d4 Add .gitea/wokrflows/build.yaml
Docker Publish / build-and-push-image (push) Failing after 52s
Docker Publish / tag-release (push) Has been skipped
2026-07-01 12:43:20 +02:00
Volker Kopetzky 783581ffe4 Merge pull request #9 from voko/feature/add-related-links
Feature/add related links
2025-12-27 18:25:16 +01:00
Volker a11ec95e58 add service rss url (can be copied to miniflux directly) 2025-12-27 18:15:10 +01:00
Volker 93eef59aa8 add related links
artist links to feed page (/feed/:ID)
release links to feed xml (release description)
2025-12-27 18:15:10 +01:00
Volker 6efa6617d3 fix typo: mbz is the correct string 2025-12-27 17:09:43 +01:00
Volker c97d22e0f0 version bump 1.1.2 2025-12-27 17:06:31 +01:00
17 changed files with 130 additions and 103 deletions
+31
View File
@@ -0,0 +1,31 @@
name: Build & Deploy
run-name: Build ${{ gitea.sha.substring(0, 7) }}
on:
push:
branches: [ main ]
env:
IMAGE_NAME: mbz-rss-feeder
CONTAINER_NAME: mbz-rss-feeder
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest
- name: Stop existing container (if any)
run: |
docker stop ${{ env.CONTAINER_NAME }} 2>/dev/null || true
docker rm ${{ env.CONTAINER_NAME }} 2>/dev/null || true
- name: Clean up old images
run: docker image prune -f --filter "until=24h"
-82
View File
@@ -1,82 +0,0 @@
name: Docker Publish
on:
push:
branches:
- "main"
- "fix/*"
- "feature/*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
tag-release:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
needs: build-and-push-image
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Read version from file
id: version
run: echo "TAG=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create and push Git tag
run: |
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.github.com"
git tag -a "${{ steps.version.outputs.TAG }}" -m "Release ${{ steps.version.outputs.TAG }}"
git push origin "${{ steps.version.outputs.TAG }}"
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: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ steps.version.outputs.TAG }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=branch
flavor: |
latest=false
prefix=
suffix=
- 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
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3 -3
View File
@@ -26,7 +26,7 @@ RUN mkdir -p /var/mbz-rss-feeder/cache /var/mbz-rss-feeder/log /var/mbz-rss-feed
# Test stage # Test stage
FROM builder AS test FROM builder AS test
COPY mzb_rss_service/tests/requirements.txt . COPY mbz_rss_service/tests/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
CMD ["python -m pytest"] CMD ["python -m pytest"]
@@ -35,8 +35,8 @@ CMD ["python -m pytest"]
FROM base AS final 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/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin COPY --from=builder /usr/local/bin /usr/local/bin
COPY mzb_rss_service mzb_rss_service COPY mbz_rss_service mbz_rss_service
COPY VERSION . COPY VERSION .
EXPOSE 8080 EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "mzb_rss_service.main:app"] CMD ["gunicorn", "--bind", "0.0.0.0:8080", "mbz_rss_service.main:app"]
+1 -1
View File
@@ -45,7 +45,7 @@ This is a local container running with data stored in ./local/testdata
-p 8080:8080 -e LOG_LEVEL=debug --rm --name mbz-rss\ -p 8080:8080 -e LOG_LEVEL=debug --rm --name mbz-rss\
-v ./local/testdata/:/var/mbz-rss-feeder \ -v ./local/testdata/:/var/mbz-rss-feeder \
--env-file ./local/testdata/.env \ --env-file ./local/testdata/.env \
docker.io/library/mzb-rss-feeder:latest` docker.io/library/mbz-rss-feeder:latest`
After startup the server can be accessed through http://docker-host:8080. After startup the server can be accessed through http://docker-host:8080.
+1 -1
View File
@@ -1 +1 @@
1.1.1 1.2.0
+1
View File
@@ -0,0 +1 @@
# mbz_rss_service package
@@ -11,9 +11,10 @@ logger = logging.getLogger(__name__)
FEEDS_FILE_PATH = os.path.expandvars(os.environ.get('FEEDS_FILE_PATH', '/var/mbz-rss-feeder/feeds.yml')) 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', '/var/mbz-rss-feeder/etc/mbz-rss-feeder.yml')) CONFIG_FILE_PATH = os.path.expandvars(os.environ.get('CONFIG_FILE_PATH', '/var/mbz-rss-feeder/etc/mbz-rss-feeder.yml'))
CACHE_DIR = os.path.expandvars(os.environ.get('CACHE_DIR', '/var/mbz-rss-feeder/cache')) 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_APP_NAME = os.path.expandvars(os.environ.get('MB_APP_NAME', 'mbz-rss-service'))
MB_VERSION = os.path.expandvars(os.environ.get('MB_VERSION', '0.1.0')) MB_VERSION = os.path.expandvars(os.environ.get('MB_VERSION', '1'))
MB_CONTACT = os.path.expandvars(os.environ.get('MB_CONTACT', 'someone@somewhere.com')) MB_CONTACT = os.path.expandvars(os.environ.get('MB_CONTACT', 'someone@somewhere.com'))
MBZ_SERVICE_BASE_URL = os.path.expandvars(os.environ.get('MBZ_SERVICE_BASE_URL', 'http://mbz-rss-feeder:8080'))
# file system dir check # file system dir check
if not os.path.exists(CACHE_DIR): if not os.path.exists(CACHE_DIR):
@@ -41,6 +42,8 @@ class Config:
self.MB_APP_NAME = MB_APP_NAME self.MB_APP_NAME = MB_APP_NAME
self.MB_VERSION = MB_VERSION self.MB_VERSION = MB_VERSION
self.MB_CONTACT = MB_CONTACT self.MB_CONTACT = MB_CONTACT
self.MBZ_SERVICE_BASE_URL = MBZ_SERVICE_BASE_URL
try: try:
with open(os.path.join(os.path.dirname(__file__), '..', 'VERSION')) as f: with open(os.path.join(os.path.dirname(__file__), '..', 'VERSION')) as f:
self.VERSION = f.read().strip() self.VERSION = f.read().strip()
@@ -68,10 +71,13 @@ class Config:
backup_path = f"{file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}.bak" backup_path = f"{file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}.bak"
shutil.copy(file_path, backup_path) shutil.copy(file_path, backup_path)
logger.debug(f"Saving yaml to {file_path}")
os.makedirs(os.path.dirname(file_path), exist_ok=True) os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w') as f: with open(file_path, 'w') as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False) yaml.dump(data, f, default_flow_style=False, sort_keys=False)
logger.debug(f"Saved yaml to {file_path}")
@property @property
def feeds(self): def feeds(self):
return self._feeds_data.get('feeds', []) return self._feeds_data.get('feeds', [])
@@ -105,13 +111,16 @@ class Config:
self._feeds_data['feeds'] = [feed for feed in self.feeds if feed['id'] != feed_id] self._feeds_data['feeds'] = [feed for feed in self.feeds if feed['id'] != feed_id]
self.save_feeds() self.save_feeds()
def add_artist_to_feed(self, feed_id, artist_id, artist_name): def add_artist_to_feed(self, feed_id, artist_id, artist_name, links = None):
for feed in self.feeds: for feed in self.feeds:
if feed['id'] == feed_id: if feed['id'] == feed_id:
if 'artists' not in feed: if 'artists' not in feed:
feed['artists'] = [] feed['artists'] = []
if not any(a['id'] == artist_id for a in feed['artists']): if not any(a['id'] == artist_id for a in feed['artists']):
feed['artists'].append({'id': artist_id, 'name': artist_name}) artist_data = {'id': artist_id, 'name': artist_name}
if links:
artist_data['links'] = links
feed['artists'].append(artist_data)
feed['updated_at'] = datetime.now(timezone.utc).isoformat() feed['updated_at'] = datetime.now(timezone.utc).isoformat()
self.save_feeds() self.save_feeds()
break break
@@ -9,6 +9,8 @@ import os
import sys import sys
from . import musicbrainz from . import musicbrainz
XML_CONTENT_TYPE = "application/xml"
log_level_str = os.environ.get('LOG_LEVEL', 'INFO').upper() log_level_str = os.environ.get('LOG_LEVEL', 'INFO').upper()
log_level = getattr(logging, log_level_str, logging.INFO) log_level = getattr(logging, log_level_str, logging.INFO)
log_file = os.path.expandvars(os.environ.get('LOG_FILE', '/var/mbz-rss-feeder/log/mbz-rss-feeder.log')) log_file = os.path.expandvars(os.environ.get('LOG_FILE', '/var/mbz-rss-feeder/log/mbz-rss-feeder.log'))
@@ -55,7 +57,7 @@ for handler in werkzeug_logger.handlers:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"Starting mzb-rss-service v{config.VERSION} with the following configuration:") logger.info(f"Starting mbz-rss-service v{config.VERSION} with the following configuration:")
logger.info(f" - Log Level: {log_level_str}") logger.info(f" - Log Level: {log_level_str}")
logger.info(f" - Log File: {log_file}") logger.info(f" - Log File: {log_file}")
logger.info(f" - Feeds File: {config.FEEDS_FILE_PATH}") logger.info(f" - Feeds File: {config.FEEDS_FILE_PATH}")
@@ -70,6 +72,10 @@ logger.info(f" - MusicBrainz Log Level: {mbz_log_level_str}")
musicbrainz.init_musicbrainz() musicbrainz.init_musicbrainz()
app = Flask(__name__) app = Flask(__name__)
@app.context_processor
def inject_service_name():
return {'service_base_url': config.MBZ_SERVICE_BASE_URL}
class ReverseProxied(object): class ReverseProxied(object):
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
@@ -154,7 +160,7 @@ def _check_cache(feed_id):
try: try:
with open(cache_file, 'r') as f: with open(cache_file, 'r') as f:
logger.debug(f"Serving cached feed for {feed_id}") logger.debug(f"Serving cached feed for {feed_id}")
return f.read(), {"Content-Type": "application/xml"} return f.read(), {"Content-Type": XML_CONTENT_TYPE}
except IOError as e: except IOError as e:
logger.warning(f"Could not read cache file {cache_file}: {e}") logger.warning(f"Could not read cache file {cache_file}: {e}")
@@ -195,7 +201,8 @@ def add_artist(feed_id):
artist_name = request.form.get('artist_name') artist_name = request.form.get('artist_name')
logger.debug(f"Request to add artist '{artist_name}' ({artist_id}) to feed {feed_id}") logger.debug(f"Request to add artist '{artist_name}' ({artist_id}) to feed {feed_id}")
if artist_id and artist_name: if artist_id and artist_name:
config.add_artist_to_feed(feed_id, artist_id, artist_name) meta = musicbrainz.get_artist_meta_by_id(artist_id)
config.add_artist_to_feed(feed_id, artist_id, artist_name, meta['links'])
return redirect(url_for('edit_feed', feed_id=feed_id)) return redirect(url_for('edit_feed', feed_id=feed_id))
@app.route("/feed/<feed_id>/artist/<artist_id>/remove", methods=["POST"]) @app.route("/feed/<feed_id>/artist/<artist_id>/remove", methods=["POST"])
@@ -228,7 +235,7 @@ def opml():
logger.debug("Request for OPML file") logger.debug("Request for OPML file")
feeds = config.feeds feeds = config.feeds
opml_content = render_template('opml.xml', feeds=feeds) opml_content = render_template('opml.xml', feeds=feeds)
return opml_content, {"Content-Type": "application/xml"} return opml_content, {"Content-Type": XML_CONTENT_TYPE}
@app.route('/feed/<feed_id>') @app.route('/feed/<feed_id>')
def get_feed_rss(feed_id): def get_feed_rss(feed_id):
@@ -277,7 +284,7 @@ def get_feed_rss(feed_id):
except IOError as e: except IOError as e:
logger.warning(f"Could not write feed '{feed_data['name']}' to cache file {cache_file}: {e}") logger.warning(f"Could not write feed '{feed_data['name']}' to cache file {cache_file}: {e}")
return rss_content, {"Content-Type": "application/xml"} return rss_content, {"Content-Type": XML_CONTENT_TYPE}
@app.route("/health") @app.route("/health")
def health_check(): def health_check():
@@ -1,6 +1,6 @@
import re
import musicbrainzngs import musicbrainzngs
import logging import logging
import os
from .config import config from .config import config
from datetime import datetime from datetime import datetime
from email.utils import formatdate from email.utils import formatdate
@@ -62,6 +62,10 @@ def _process_release(release, artist_id):
release_date = release.get('date', 'Unknown') release_date = release.get('date', 'Unknown')
_, pub_date = _parse_release_date(release_date, release['title']) _, pub_date = _parse_release_date(release_date, release['title'])
links = {}
if 'url-relation-list' in release:
links = get_artist_relation_links(release['url-relation-list'])
return { return {
'artist': { 'artist': {
'name': config.get_artist_name(artist_id), 'name': config.get_artist_name(artist_id),
@@ -72,8 +76,54 @@ def _process_release(release, artist_id):
'title': release['title'], 'title': release['title'],
'date': release_date, 'date': release_date,
'pub_date': pub_date, 'pub_date': pub_date,
'release-group': release.get('release-group', {}) 'release-group': release.get('release-group', {}),
'links': links,
} }
def get_artist_meta_by_id(artist_id):
"""get additional meta data about an artist"""
meta = {
'links': {}
}
try:
logger.debug(f"Fetching artist {artist_id} meta data")
# Fetch artist data including URL relations
result = musicbrainzngs.get_artist_by_id(artist_id, includes=["url-rels"])
artist_data = result.get('artist', {})
if 'url-relation-list' in artist_data:
meta['links'] = get_artist_relation_links(artist_data['url-relation-list'])
else:
meta['links'] = None
return meta
except musicbrainzngs.WebServiceError as exc:
logger.error(f"Error fetching artist {artist_id} meta data from MusicBrainz: {exc}")
return meta
def get_artist_relation_links(relationlist):
"""Parse and extract named links from a MusicBrainz URL relation list."""
links = {}
for rel in relationlist:
target = rel.get('target')
if not target:
continue
if re.match(r'^https://([^/]+\.)?imdb\.com', target):
links['IMDb'] = target
elif re.match(r'^https://music\.apple\.com', target):
links['apple'] = target
elif re.match(r'^https://music\.amazon\.com', target):
links['amazon'] = target
elif re.match(r'^https://open\.spotify\.com', target):
links['spotify'] = target
elif re.match(r'^https://([^/]+\.)?qobuz\.com', target):
links['qobuz'] = target
elif re.match(r'^https://([^/]+\.)?deezer\.com', target):
links['deezer'] = target
elif re.match(r'^https://([^/]+\.)?beatport\.com', target):
links['beatport'] = target
return links
def get_artist_releases(artist_id): def get_artist_releases(artist_id):
"""Fetch releases for a given artist ID from MusicBrainz.""" """Fetch releases for a given artist ID from MusicBrainz."""
@@ -83,7 +133,7 @@ def get_artist_releases(artist_id):
result = musicbrainzngs.browse_releases( result = musicbrainzngs.browse_releases(
artist=artist_id, artist=artist_id,
release_type=['album'], release_type=['album'],
includes=['release-groups'] includes=['release-groups', 'url-rels']
) )
releases = [] releases = []
@@ -9,6 +9,11 @@
{% for artist in feed.artists %} {% for artist in feed.artists %}
<li> <li>
{{ artist.name }} {{ artist.name }}
{% if artist.links %}
{% for type, link in artist.links.items() %}
<a href="{{ link }}" target="_blank">{{ type }}</a>{% if not loop.last %} | {% endif %}
{% endfor %}
{% endif %}
<div class="item-actions"> <div class="item-actions">
<form class="form-inline" action="{{ url_for('remove_artist', feed_id=feed.id, artist_id=artist.id) }}" method="post"> <form class="form-inline" action="{{ url_for('remove_artist', feed_id=feed.id, artist_id=artist.id) }}" method="post">
<button type="submit">Remove</button> <button type="submit">Remove</button>
@@ -14,10 +14,17 @@
<link>https://musicbrainz.org/release/{{ release.id }}</link> <link>https://musicbrainz.org/release/{{ release.id }}</link>
<guid isPermaLink="false">{{ release.id }}</guid> <guid isPermaLink="false">{{ release.id }}</guid>
<description><![CDATA[ <description><![CDATA[
{% if release.hasCoverArt %}
<img src="https://coverartarchive.org/release/{{ release.id }}/front" alt="Cover Art {{ release.title }}">
{% endif %}
<p><b>{{ release.artist.name }}</b> <i>{{ release.title }}</i><br> <p><b>{{ release.artist.name }}</b> <i>{{ release.title }}</i><br>
Release Date: {{ release.date }}</p> Release Date: {{ release.date }}</p>
{% if release.hasCoverArt %} {% if release.links %}
<img src="http://coverartarchive.org/release/{{ release.id }}/front-500" alt="Cover Art {{ release.title }}"> <p><b>Links: </b>
{% for type, url in release.links.items() %}
<a href="{{ url }}" target="_blank">{{ type | capitalize }}</a>{% if not loop.last %} | {% endif %}
{% endfor %}
</p>
{% endif %} {% endif %}
]]></description> ]]></description>
{% if release.pub_date %} {% if release.pub_date %}
@@ -11,7 +11,7 @@
({{ feed.artists|length }}) ({{ feed.artists|length }})
</div> </div>
<div class="item-actions"> <div class="item-actions">
<a href="{{ url_for('get_feed_rss', feed_id=feed.id) }}">RSS</a> <a href="{{ url_for('get_feed_rss', feed_id=feed.id) }}">RSS</a> | <a href="{{ service_base_url }}/feed/{{ feed.id }}">service rss</a>
<form class="form-inline" action="{{ url_for('delete_feed', feed_id=feed.id) }}" method="post"> <form class="form-inline" action="{{ url_for('delete_feed', feed_id=feed.id) }}" method="post">
<button type="submit">Delete</button> <button type="submit">Delete</button>
</form> </form>
-1
View File
@@ -1 +0,0 @@
# mzb_rss_service package