Merge branch 'develop' into genre-tags-spec

environments/review-docs-genre-pd4r81/deployments/19375
Ciarán Ainsworth 2024-04-14 14:58:33 +02:00
commit 675fa0da68
Nie znaleziono w bazie danych klucza dla tego podpisu
311 zmienionych plików z 71426 dodań i 42108 usunięć

Wyświetl plik

@ -7,7 +7,9 @@ nd
readby
serie
upto
afterall
# Names
nin
noe
manuel

12
.gitignore vendored
Wyświetl plik

@ -1,3 +1,5 @@
/dist
### OSX ###
.DS_Store
.AppleDouble
@ -83,8 +85,12 @@ front/yarn-debug.log*
front/yarn-error.log*
front/tests/unit/coverage
front/tests/e2e/reports
front/test_results.xml
front/coverage/
front/selenium-debug.log
docs/_build
#Tauri
front/tauri/gen
/data/
.env
@ -104,3 +110,9 @@ tsconfig.tsbuildinfo
# Vscode
.vscode/
# Nix
.direnv/
.envrc
flake.nix
flake.lock

Wyświetl plik

@ -118,21 +118,16 @@ review_docs:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes: [docs/**/*]
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:3.11
variables:
BUILD_PATH: "../docs-review"
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-docs:3.11
environment:
name: review/docs/$CI_COMMIT_REF_NAME
url: http://$CI_PROJECT_NAMESPACE.pages.funkwhale.audio/-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/docs-review/index.html
cache: *docs_cache
before_script:
- mkdir docs-review
- cd docs
- apt-get update
- apt-get install -y graphviz
- poetry install
- make install
script:
- poetry run python3 -m sphinx . $BUILD_PATH
- make build BUILD_DIR=../docs-review
artifacts:
expire_in: 2 weeks
paths:
@ -149,7 +144,6 @@ find_broken_links:
--cache
--no-progress
--exclude-all-private
--exclude-mail
--exclude 'demo\.funkwhale\.audio'
--exclude 'nginx\.com'
--exclude-path 'docs/_templates/'
@ -236,7 +230,7 @@ test_api:
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:$PYTHON_VERSION
parallel:
matrix:
- PYTHON_VERSION: ["3.8", "3.9", "3.10", "3.11"]
- PYTHON_VERSION: ["3.8", "3.9", "3.10", "3.11", "3.12"]
services:
- name: postgres:15-alpine
command:
@ -253,7 +247,7 @@ test_api:
CACHE_URL: "redis://redis:6379/0"
before_script:
- cd api
- poetry install --all-extras
- make install
script:
- >
poetry run pytest
@ -293,6 +287,7 @@ test_front:
coverage_report:
coverage_format: cobertura
path: front/coverage/cobertura-coverage.xml
coverage: '/All files\s+(?:\|\s+((?:\d+\.)?\d+)\s+){4}.*/'
build_metadata:
stage: build
@ -317,7 +312,9 @@ test_integration:
- if: $RUN_CYPRESS
interruptible: true
image: cypress/base:18.12.1
image:
name: cypress/included:13.6.4
entrypoint: [""]
cache:
- *front_cache
- key:
@ -354,7 +351,7 @@ build_api_schema:
API_TYPE: "v1"
before_script:
- cd api
- poetry install --all-extras
- make install
- poetry run funkwhale-manage migrate
script:
- poetry run funkwhale-manage spectacular --file ../docs/schema.yml
@ -372,19 +369,13 @@ build_docs:
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
- changes: [docs/**/*]
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:3.11
variables:
BUILD_PATH: "../public"
GIT_STRATEGY: clone
GIT_DEPTH: 0
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-docs:3.11
cache: *docs_cache
before_script:
- cd docs
- apt-get update
- apt-get install -y graphviz
- poetry install
- make install
script:
- ./build_docs.sh
- make build-all BUILD_DIR=../public
artifacts:
expire_in: 2 weeks
paths:
@ -439,6 +430,25 @@ build_api:
paths:
- api
build_tauri:
stage: build
rules:
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
- changes: [front/**/*]
image: $CI_REGISTRY/funkwhale/ci/node-tauri:18
variables:
<<: *keep_git_files_permissions
before_script:
- source /root/.cargo/env
- yarn install
script:
- yarn tauri build --verbose
artifacts:
name: desktop_${CI_COMMIT_REF_NAME}
paths:
- front/tauri/target/release/bundle/appimage/*.AppImage
deploy_docs:
interruptible: false
extends: .ssh-agent
@ -471,22 +481,23 @@ docker:
variables:
BUILD_ARGS: >
--set *.platform=linux/amd64,linux/arm64,linux/arm/v7
--set *.no-cache
--no-cache
--push
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
variables:
BUILD_ARGS: >
--set *.platform=linux/amd64,linux/arm64,linux/arm/v7
--set *.cache-from=type=registry,ref=$DOCKER_CACHE_IMAGE:$CI_COMMIT_BRANCH
--set *.cache-to=type=registry,ref=$DOCKER_CACHE_IMAGE:$CI_COMMIT_BRANCH,mode=max
--set *.cache-from=type=registry,ref=$DOCKER_CACHE_IMAGE:$CI_COMMIT_BRANCH,oci-mediatypes=false
--set *.cache-to=type=registry,ref=$DOCKER_CACHE_IMAGE:$CI_COMMIT_BRANCH,mode=max,oci-mediatypes=false
--push
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_PROJECT_NAMESPACE == "funkwhale"
# We don't provide priviledged runners to everyone, so we can only build docker images in the funkwhale group
variables:
BUILD_ARGS: >
--set *.platform=linux/amd64
--set *.cache-from=type=registry,ref=$DOCKER_CACHE_IMAGE:$CI_MERGE_REQUEST_TARGET_BRANCH_NAME
--set *.cache-from=type=registry,ref=$DOCKER_CACHE_IMAGE:$CI_MERGE_REQUEST_TARGET_BRANCH_NAME,oci-mediatypes=false
image: $CI_REGISTRY/funkwhale/ci/docker:20
services:
@ -517,3 +528,24 @@ docker:
name: docker_metadata_${CI_COMMIT_REF_NAME}
paths:
- metadata.json
package:
stage: publish
needs:
- job: build_metadata
artifacts: true
- job: build_api
artifacts: true
- job: build_front
artifacts: true
- job: build_tauri
artifacts: true
rules:
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
image: $CI_REGISTRY/funkwhale/ci/python:3.11
variables:
<<: *keep_git_files_permissions
script:
- make package
- scripts/ci-upload-packages.sh

Wyświetl plik

@ -25,6 +25,16 @@
"branchConcurrentLimit": 0,
"prConcurrentLimit": 0
},
{
"matchBaseBranches": ["develop"],
"matchUpdateTypes": ["major"],
"prPriority": 2
},
{
"matchBaseBranches": ["develop"],
"matchUpdateTypes": ["minor"],
"prPriority": 1
},
{
"matchUpdateTypes": ["major", "minor"],
"matchBaseBranches": ["stable"],
@ -35,12 +45,6 @@
"matchBaseBranches": ["stable"],
"enabled": false
},
{
"matchUpdateTypes": ["patch", "pin", "digest"],
"matchBaseBranches": ["develop"],
"automerge": true,
"automergeType": "branch"
},
{
"matchManagers": ["npm"],
"addLabels": ["Area::Frontend"]
@ -70,6 +74,10 @@
],
"fileFilters": ["changes/changelog.d/postgres.update"]
}
},
{
"matchPackageNames": ["python"],
"rangeStrategy": "widen"
}
]
}

Wyświetl plik

@ -14,11 +14,12 @@ tasks:
docker-compose up -d
poetry env use python
poetry install
make install
gp ports await 5432
poetry run funkwhale-manage migrate
poetry run funkwhale-manage fw users create --superuser --username gitpod --password funkwhale --email test@example.org
poetry run funkwhale-manage gitpod init
command: |
echo "MEDIA_URL=`gp url 8000`/media/" >> ../.gitpod/.env
@ -47,49 +48,66 @@ tasks:
yarn install
command: yarn dev --host 0.0.0.0 --base ./
- name: Documentation
before: cd docs
init: make install
command: make dev
- name: Welcome to Funkwhale development!
env:
COMPOSE_FILE: /workspace/funkwhale/.gitpod/docker-compose.yml
ENV_FILE: /workspace/funkwhale/.gitpod/.env
VUE_EDITOR: code
DJANGO_SETTINGS_MODULE: config.settings.local
init: pre-commit install
init: |
pre-commit install
pre-commit run --all
command: |
pre-commit run --all && clear
echo ""
echo -e " ⠀⠀⠸⣿⣷⣦⣄⣠⣶⣾⣿⠇⠀⠀ You can now start developing Funkwhale with gitpod!"
echo -e " ⠀⠀⠀⠈⠉⠻⣿⣿⠟⠉⠁⠀⠀⠀"
echo -e " \u1b[34m⣀⢀⡀⢀⣀\u1b[0m⠹⠇\u1b[34m⣀⡀⢀⡀⣀ \u1b[0mTo sign in to the superuser account,"
echo -e " \u1b[34m⢻⣇⠘⣧⡈⠻⠶⠶⠟⢁⣾⠃⣸⡟ \u1b[0mplease use these credentials:"
echo -e " \u1b[34m⠻⣦⡈⠻⠶⣶⣶⠶⠟⢁⣴⠟"
echo -e " \u1b[34m⠈⠻⠷⣦⣤⣤⣴⠾⠟⠁ gitpod\u1b[0m:\u1b[34mgitpod"
echo -e " \u1b[34m⠈⠻⠷⣦⣤⣤⣴⠾⠟⠁ gitpod\u1b[0m:\u1b[34mfunkwhale"
echo ""
ports:
- port: 8000
- name: Funkwhale
port: 8000
visibility: public
onOpen: notify
- port: 5000
- name: Funkwhale API
port: 5000
visibility: private
onOpen: ignore
- port: 5432
- name: PostgreSQL
port: 5432
visibility: private
onOpen: ignore
- port: 5678
- name: Debugpy
port: 5678
visibility: private
onOpen: ignore
- port: 6379
- name: Redis
port: 6379
visibility: private
onOpen: ignore
- port: 8080
- name: Frontend
port: 8080
visibility: private
onOpen: ignore
- name: Documentation
port: 8001
visibility: public
onOpen: notify
vscode:
extensions:
- Vue.volar

Wyświetl plik

@ -1,9 +1,13 @@
FROM gitpod/workspace-full:2022-11-15-17-00-18
FROM gitpod/workspace-full:2023-10-25-20-43-33
USER gitpod
RUN sudo apt update -y \
&& sudo apt install libsasl2-dev libldap2-dev libssl-dev ffmpeg gettext -y
RUN pip install poetry pre-commit \
RUN pyenv install 3.11 && pyenv global 3.11
RUN brew install neovim
RUN pip install poetry pre-commit jinja2 towncrier \
&& poetry config virtualenvs.create true \
&& poetry config virtualenvs.in-project true

Wyświetl plik

@ -18,7 +18,6 @@ services:
- 6379:6379
nginx:
command: /entrypoint.sh
env_file:
- ./.env
image: nginx
@ -29,15 +28,16 @@ services:
environment:
- "NGINX_MAX_BODY_SIZE=100M"
- "FUNKWHALE_API_IP=host.docker.internal"
- "FUNKWHALE_API_HOST=host.docker.internal"
- "FUNKWHALE_API_PORT=5000"
- "FUNKWHALE_FRONT_IP=host.docker.internal"
- "FUNKWHALE_FRONT_PORT=8080"
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-host.docker.internal}"
- "FUNKWHALE_PROTOCOL=https"
volumes:
- ../data/media:/protected/media:ro
- ../data/media:/workspace/funkwhale/data/media:ro
- ../data/music:/music:ro
- ../data/staticfiles:/staticfiles:ro
- ../data/staticfiles:/usr/share/nginx/html/staticfiles/:ro
- ../deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro
- ../docker/nginx/conf.dev:/etc/nginx/nginx.conf.template:ro
- ../docker/nginx/entrypoint.sh:/entrypoint.sh:ro
- ../docker/nginx/conf.dev:/etc/nginx/templates/default.conf.template:ro
- ../front:/frontend:ro

Wyświetl plik

@ -53,18 +53,18 @@ repos:
- id: isort
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
rev: 6.1.0
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.0.2
rev: v3.0.3
hooks:
- id: prettier
files: \.(md|yml|yaml|json)$
- repo: https://github.com/codespell-project/codespell
rev: v2.2.5
rev: v2.2.6
hooks:
- id: codespell
additional_dependencies: [tomli]

Plik diff jest za duży Load Diff

Wyświetl plik

@ -17,3 +17,41 @@ docker-build: docker-metadata
build-metadata:
./scripts/build_metadata.py --format env | tee build_metadata.env
BUILD_DIR = dist
package:
rm -Rf $(BUILD_DIR)
mkdir -p $(BUILD_DIR)
tar --create --gunzip --file='$(BUILD_DIR)/funkwhale-api.tar.gz' \
--owner='root' \
--group='root' \
--exclude-vcs \
api/config \
api/funkwhale_api \
api/install_os_dependencies.sh \
api/manage.py \
api/poetry.lock \
api/pyproject.toml \
api/Readme.md
cd '$(BUILD_DIR)' && \
tar --extract --gunzip --file='funkwhale-api.tar.gz' && \
zip -q 'funkwhale-api.zip' -r api && \
rm -Rf api
tar --create --gunzip --file='$(BUILD_DIR)/funkwhale-front.tar.gz' \
--owner='root' \
--group='root' \
--exclude-vcs \
--transform='s/^front\/dist/front/' \
front/dist
cd '$(BUILD_DIR)' && \
tar --extract --gunzip --file='funkwhale-front.tar.gz' && \
zip -q 'funkwhale-front.zip' -r front && \
rm -Rf front
cd '$(BUILD_DIR)' && \
cp ../front/tauri/target/release/bundle/appimage/funkwhale_*.AppImage FunkwhaleDesktop.AppImage
cd '$(BUILD_DIR)' && sha256sum * > SHA256SUMS

Wyświetl plik

@ -23,4 +23,4 @@ If you find a security issue or vulnerability, please report it on our [GitLab i
## Code of conduct
The Funkwhale collective adheres to a [code of conduct](https://funkwhale.audio/en_US/code-of-conduct) in all our community spaces. Please familiarize yourself with this code and follow it when participating in discussions in our spaces.
The Funkwhale collective adheres to a [code of conduct](https://funkwhale.audio/code-of-conduct) in all our community spaces. Please familiarize yourself with this code and follow it when participating in discussions in our spaces.

Wyświetl plik

@ -1,8 +1,4 @@
FROM alpine:3.17 as requirements
# We need this additional step to avoid having poetrys deps interacting with our
# dependencies. This is only required until alpine 3.16 is released, since this
# allows us to install poetry as package.
FROM alpine:3.19 as requirements
RUN set -eux; \
apk add --no-cache \
@ -16,7 +12,7 @@ RUN set -eux; \
poetry export --without-hashes --extras typesense > requirements.txt; \
poetry export --without-hashes --with dev > dev-requirements.txt;
FROM alpine:3.17 as builder
FROM alpine:3.19 as builder
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
@ -41,11 +37,11 @@ RUN set -eux; \
openssl-dev \
postgresql-dev \
zlib-dev \
py3-cryptography=38.0.3-r1 \
py3-cryptography=41.0.7-r0 \
py3-lxml=4.9.3-r1 \
py3-pillow=9.3.0-r0 \
py3-psycopg2=2.9.5-r0 \
py3-watchfiles=0.18.1-r0 \
py3-pillow=10.2.0-r0 \
py3-psycopg2=2.9.9-r0 \
py3-watchfiles=0.19.0-r1 \
python3-dev
# Create virtual env
@ -65,11 +61,11 @@ RUN --mount=type=cache,target=~/.cache/pip; \
# to install the deps using pip.
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles' /requirements.txt \
| pip3 install -r /dev/stdin \
cryptography==38.0.3 \
cryptography==41.0.7 \
lxml==4.9.3 \
pillow==9.3.0 \
psycopg2==2.9.5 \
watchfiles==0.18.1
pillow==10.2.0 \
psycopg2==2.9.9 \
watchfiles==0.19.0
ARG install_dev_deps=0
RUN --mount=type=cache,target=~/.cache/pip; \
@ -77,14 +73,14 @@ RUN --mount=type=cache,target=~/.cache/pip; \
if [ "$install_dev_deps" = "1" ] ; then \
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles' /dev-requirements.txt \
| pip3 install -r /dev/stdin \
cryptography==38.0.3 \
cryptography==41.0.7 \
lxml==4.9.3 \
pillow==9.3.0 \
psycopg2==2.9.5 \
watchfiles==0.18.1; \
pillow==10.2.0 \
psycopg2==2.9.9 \
watchfiles==0.19.0; \
fi
FROM alpine:3.17 as production
FROM alpine:3.19 as production
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
@ -101,11 +97,11 @@ RUN set -eux; \
libpq \
libxml2 \
libxslt \
py3-cryptography=38.0.3-r1 \
py3-cryptography=41.0.7-r0 \
py3-lxml=4.9.3-r1 \
py3-pillow=9.3.0-r0 \
py3-psycopg2=2.9.5-r0 \
py3-watchfiles=0.18.1-r0 \
py3-pillow=10.2.0-r0 \
py3-psycopg2=2.9.9-r0 \
py3-watchfiles=0.19.0-r1 \
python3 \
tzdata

Wyświetl plik

@ -4,7 +4,7 @@ CPU_CORES := $(shell N=$$(nproc); echo $$(( $$N > 4 ? 4 : $$N )))
.PHONY: install lint
install:
poetry install
poetry install --all-extras
lint:
poetry run pylint \

Wyświetl plik

@ -1,97 +0,0 @@
from django.conf.urls import include, url
from rest_framework import routers
from rest_framework.urlpatterns import format_suffix_patterns
from funkwhale_api.activity import views as activity_views
from funkwhale_api.audio import views as audio_views
from funkwhale_api.common import routers as common_routers
from funkwhale_api.common import views as common_views
from funkwhale_api.music import views
from funkwhale_api.playlists import views as playlists_views
from funkwhale_api.subsonic.views import SubsonicViewSet
from funkwhale_api.tags import views as tags_views
router = common_routers.OptionalSlashRouter()
router.register(r"activity", activity_views.ActivityViewSet, "activity")
router.register(r"tags", tags_views.TagViewSet, "tags")
router.register(r"plugins", common_views.PluginViewSet, "plugins")
router.register(r"tracks", views.TrackViewSet, "tracks")
router.register(r"uploads", views.UploadViewSet, "uploads")
router.register(r"libraries", views.LibraryViewSet, "libraries")
router.register(r"listen", views.ListenViewSet, "listen")
router.register(r"stream", views.StreamViewSet, "stream")
router.register(r"artists", views.ArtistViewSet, "artists")
router.register(r"channels", audio_views.ChannelViewSet, "channels")
router.register(r"subscriptions", audio_views.SubscriptionsViewSet, "subscriptions")
router.register(r"albums", views.AlbumViewSet, "albums")
router.register(r"licenses", views.LicenseViewSet, "licenses")
router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
router.register(r"mutations", common_views.MutationViewSet, "mutations")
router.register(r"attachments", common_views.AttachmentViewSet, "attachments")
v1_patterns = router.urls
subsonic_router = routers.SimpleRouter(trailing_slash=False)
subsonic_router.register(r"subsonic/rest", SubsonicViewSet, basename="subsonic")
v1_patterns += [
url(r"^oembed/$", views.OembedView.as_view(), name="oembed"),
url(
r"^instance/",
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
),
url(
r"^manage/",
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
),
url(
r"^moderation/",
include(
("funkwhale_api.moderation.urls", "moderation"), namespace="moderation"
),
),
url(
r"^federation/",
include(
("funkwhale_api.federation.api_urls", "federation"), namespace="federation"
),
),
url(
r"^providers/",
include(("funkwhale_api.providers.urls", "providers"), namespace="providers"),
),
url(
r"^favorites/",
include(("funkwhale_api.favorites.urls", "favorites"), namespace="favorites"),
),
url(r"^search$", views.Search.as_view(), name="search"),
url(
r"^radios/",
include(("funkwhale_api.radios.urls", "radios"), namespace="radios"),
),
url(
r"^history/",
include(("funkwhale_api.history.urls", "history"), namespace="history"),
),
url(
r"^",
include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
),
# XXX: remove if Funkwhale 1.1
url(
r"^users/",
include(("funkwhale_api.users.api_urls", "users"), namespace="users-nested"),
),
url(
r"^oauth/",
include(("funkwhale_api.users.oauth.urls", "oauth"), namespace="oauth"),
),
url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
url(
r"^text-preview/?$", common_views.TextPreviewView.as_view(), name="text-preview"
),
]
urlpatterns = [
url(r"^v1/", include((v1_patterns, "v1"), namespace="v1"))
] + format_suffix_patterns(subsonic_router.urls, allowed=["view"])

Wyświetl plik

@ -1,7 +1,7 @@
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.conf.urls import url
from django.core.asgi import get_asgi_application
from django.urls import re_path
from funkwhale_api.instance import consumers
@ -10,7 +10,12 @@ application = ProtocolTypeRouter(
# Empty for now (http->django views is added by default)
"websocket": AuthMiddlewareStack(
URLRouter(
[url("^api/v1/activity$", consumers.InstanceActivityConsumer.as_asgi())]
[
re_path(
"^api/v1/activity$",
consumers.InstanceActivityConsumer.as_asgi(),
)
]
)
),
"http": get_asgi_application(),

Wyświetl plik

@ -2,7 +2,7 @@ import logging.config
import sys
import warnings
from collections import OrderedDict
from urllib.parse import urlsplit
from urllib.parse import urlparse, urlsplit
import environ
from celery.schedules import crontab
@ -13,7 +13,29 @@ APPS_DIR = ROOT_DIR.path("funkwhale_api")
env = environ.Env()
ENV = env
LOGLEVEL = env("LOGLEVEL", default="info").upper()
# If DEBUG is `true`, we automatically set the loglevel to "DEBUG"
# If DEBUG is `false`, we try to read the level from LOGLEVEL environment and default to "INFO"
LOGLEVEL = (
"DEBUG" if env.bool("DEBUG", False) else env("LOGLEVEL", default="info").upper()
)
"""
Default logging level for the Funkwhale processes.
.. note::
The `DEBUG` variable overrides the `LOGLEVEL` if it is set to `TRUE`.
The `LOGLEVEL` value only applies if `DEBUG` is `false` or not present.
Available levels:
- ``debug``
- ``info``
- ``warning``
- ``error``
- ``critical``
"""
IS_DOCKER_SETUP = env.bool("IS_DOCKER_SETUP", False)
@ -35,19 +57,6 @@ if env("FUNKWHALE_SENTRY_DSN", default=None) is not None:
)
sentry_sdk.set_tag("instance", env("FUNKWHALE_HOSTNAME"))
"""
Default logging level for the Funkwhale processes
Available levels:
- ``debug``
- ``info``
- ``warning``
- ``error``
- ``critical``
""" # pylint: disable=W0105
LOGGING_CONFIG = None
logging.config.dictConfig(
{
@ -187,9 +196,7 @@ request errors related to this.
FUNKWHALE_SPA_HTML_CACHE_DURATION = env.int(
"FUNKWHALE_SPA_HTML_CACHE_DURATION", default=60 * 15
)
FUNKWHALE_EMBED_URL = env(
"FUNKWHALE_EMBED_URL", default=FUNKWHALE_URL + "/front/embed.html"
)
FUNKWHALE_EMBED_URL = env("FUNKWHALE_EMBED_URL", default=FUNKWHALE_URL + "/embed.html")
FUNKWHALE_SPA_REWRITE_MANIFEST = env.bool(
"FUNKWHALE_SPA_REWRITE_MANIFEST", default=True
)
@ -217,6 +224,13 @@ ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=[]) + [FUNKWHALE_HOSTNA
List of allowed hostnames for which the Funkwhale server will answer.
"""
CSRF_TRUSTED_ORIGINS = [urlparse(o, FUNKWHALE_PROTOCOL).geturl() for o in ALLOWED_HOSTS]
"""
List of origins that are trusted for unsafe requests
We simply consider all allowed hosts to be trusted origins
See https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins
"""
# APP CONFIGURATION
# ------------------------------------------------------------------------------
DJANGO_APPS = (
@ -823,7 +837,7 @@ If you're using password auth (the extra slash is important)
.. note::
If you want to use Redis over unix sockets, you also need to update
:attr:`CELERY_BROKER_URL`, because the scheme differ from the one used by
:attr:`CELERY_BROKER_URL`, because the scheme differs from the one used by
:attr:`CACHE_URL`.
"""
@ -874,7 +888,7 @@ to use a different server or use Redis sockets to connect.
Example:
- ``redis://127.0.0.1:6379/0``
- ``unix://127.0.0.1:6379/0``
- ``redis+socket:///run/redis/redis.sock?virtual_host=0``
"""
@ -935,12 +949,14 @@ CELERY_BEAT_SCHEDULE = {
),
"options": {"expires": 60 * 60},
},
"typesense.build_canonical_index": {
}
if env.str("TYPESENSE_API_KEY", default=None):
CELERY_BEAT_SCHEDULE["typesense.build_canonical_index"] = {
"task": "typesense.build_canonical_index",
"schedule": crontab(day_of_week="*/2", minute="0", hour="3"),
"options": {"expires": 60 * 60 * 24},
},
}
}
if env.bool("ADD_ALBUM_TAGS_FROM_TRACKS", default=True):
CELERY_BEAT_SCHEDULE["music.albums_set_tags_from_tracks"] = {
@ -1186,7 +1202,7 @@ if BROWSABLE_API_ENABLED:
"rest_framework.renderers.BrowsableAPIRenderer",
)
REST_AUTH_SERIALIZERS = {
REST_AUTH = {
"PASSWORD_RESET_SERIALIZER": "funkwhale_api.users.serializers.PasswordResetSerializer", # noqa
"PASSWORD_RESET_CONFIRM_SERIALIZER": "funkwhale_api.users.serializers.PasswordResetConfirmSerializer", # noqa
}

Wyświetl plik

@ -96,8 +96,6 @@ CELERY_TASK_ALWAYS_EAGER = False
# Your local stuff: Below this line define 3rd party library settings
CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS]
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "funkwhale_api.schema.CustomAutoSchema"
SPECTACULAR_SETTINGS = {
"TITLE": "Funkwhale API",

Wyświetl plik

@ -41,14 +41,6 @@ SECRET_KEY = env("DJANGO_SECRET_KEY")
# SESSION_COOKIE_HTTPONLY = True
# SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True)
# SITE CONFIGURATION
# ------------------------------------------------------------------------------
# Hosts/domain names that are valid for this site
# See https://docs.djangoproject.com/en/1.6/ref/settings/#allowed-hosts
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# END SITE CONFIGURATION
# Static Assets
# ------------------------
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"

Wyświetl plik

@ -1,7 +1,6 @@
from django.conf import settings
from django.conf.urls import url
from django.conf.urls.static import static
from django.urls import include, path
from django.urls import include, path, re_path
from django.views import defaults as default_views
from config import plugins
@ -10,34 +9,34 @@ from funkwhale_api.common import admin
plugins_patterns = plugins.trigger_filter(plugins.URLS, [], enabled=True)
api_patterns = [
url("v1/", include("config.urls.api")),
url("v2/", include("config.urls.api_v2")),
url("subsonic/", include("config.urls.subsonic")),
re_path("v1/", include("config.urls.api")),
re_path("v2/", include("config.urls.api_v2")),
re_path("subsonic/", include("config.urls.subsonic")),
]
urlpatterns = [
# Django Admin, use {% url 'admin:index' %}
url(settings.ADMIN_URL, admin.site.urls),
url(r"^api/", include((api_patterns, "api"), namespace="api")),
url(
re_path(settings.ADMIN_URL, admin.site.urls),
re_path(r"^api/", include((api_patterns, "api"), namespace="api")),
re_path(
r"^",
include(
("funkwhale_api.federation.urls", "federation"), namespace="federation"
),
),
url(r"^api/v1/auth/", include("funkwhale_api.users.rest_auth_urls")),
url(r"^accounts/", include("allauth.urls")),
re_path(r"^api/v1/auth/", include("funkwhale_api.users.rest_auth_urls")),
re_path(r"^accounts/", include("allauth.urls")),
] + plugins_patterns
if settings.DEBUG:
# This allows the error pages to be debugged during development, just visit
# these url in browser to see how these error pages look like.
urlpatterns += [
url(r"^400/$", default_views.bad_request),
url(r"^403/$", default_views.permission_denied),
url(r"^404/$", default_views.page_not_found),
url(r"^500/$", default_views.server_error),
re_path(r"^400/$", default_views.bad_request),
re_path(r"^403/$", default_views.permission_denied),
re_path(r"^404/$", default_views.page_not_found),
re_path(r"^500/$", default_views.server_error),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if "debug_toolbar" in settings.INSTALLED_APPS:
@ -49,5 +48,5 @@ if settings.DEBUG:
if "silk" in settings.INSTALLED_APPS:
urlpatterns = [
url(r"^api/silk/", include("silk.urls", namespace="silk"))
re_path(r"^api/silk/", include("silk.urls", namespace="silk"))
] + urlpatterns

Wyświetl plik

@ -1,4 +1,5 @@
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
from funkwhale_api.activity import views as activity_views
from funkwhale_api.audio import views as audio_views
@ -28,61 +29,61 @@ router.register(r"attachments", common_views.AttachmentViewSet, "attachments")
v1_patterns = router.urls
v1_patterns += [
url(r"^oembed/$", views.OembedView.as_view(), name="oembed"),
url(
re_path(r"^oembed/$", views.OembedView.as_view(), name="oembed"),
re_path(
r"^instance/",
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
),
url(
re_path(
r"^manage/",
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
),
url(
re_path(
r"^moderation/",
include(
("funkwhale_api.moderation.urls", "moderation"), namespace="moderation"
),
),
url(
re_path(
r"^federation/",
include(
("funkwhale_api.federation.api_urls", "federation"), namespace="federation"
),
),
url(
re_path(
r"^providers/",
include(("funkwhale_api.providers.urls", "providers"), namespace="providers"),
),
url(
re_path(
r"^favorites/",
include(("funkwhale_api.favorites.urls", "favorites"), namespace="favorites"),
),
url(r"^search$", views.Search.as_view(), name="search"),
url(
re_path(r"^search$", views.Search.as_view(), name="search"),
re_path(
r"^radios/",
include(("funkwhale_api.radios.urls", "radios"), namespace="radios"),
),
url(
re_path(
r"^history/",
include(("funkwhale_api.history.urls", "history"), namespace="history"),
),
url(
re_path(
r"^",
include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
),
# XXX: remove if Funkwhale 1.1
url(
re_path(
r"^users/",
include(("funkwhale_api.users.api_urls", "users"), namespace="users-nested"),
),
url(
re_path(
r"^oauth/",
include(("funkwhale_api.users.oauth.urls", "oauth"), namespace="oauth"),
),
url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
url(
re_path(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
re_path(
r"^text-preview/?$", common_views.TextPreviewView.as_view(), name="text-preview"
),
]
urlpatterns = [url("", include((v1_patterns, "v1"), namespace="v1"))]
urlpatterns = [re_path("", include((v1_patterns, "v1"), namespace="v1"))]

Wyświetl plik

@ -1,4 +1,5 @@
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
from funkwhale_api.common import routers as common_routers
@ -6,14 +7,14 @@ router = common_routers.OptionalSlashRouter()
v2_patterns = router.urls
v2_patterns += [
url(
re_path(
r"^instance/",
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
include(("funkwhale_api.instance.urls_v2", "instance"), namespace="instance"),
),
url(
re_path(
r"^radios/",
include(("funkwhale_api.radios.urls_v2", "radios"), namespace="radios"),
),
]
urlpatterns = [url("", include((v2_patterns, "v2"), namespace="v2"))]
urlpatterns = [re_path("", include((v2_patterns, "v2"), namespace="v2"))]

Wyświetl plik

@ -1,4 +1,5 @@
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
from rest_framework import routers
from rest_framework.urlpatterns import format_suffix_patterns
@ -8,7 +9,9 @@ subsonic_router = routers.SimpleRouter(trailing_slash=False)
subsonic_router.register(r"rest", SubsonicViewSet, basename="subsonic")
subsonic_patterns = format_suffix_patterns(subsonic_router.urls, allowed=["view"])
urlpatterns = [url("", include((subsonic_patterns, "subsonic"), namespace="subsonic"))]
urlpatterns = [
re_path("", include((subsonic_patterns, "subsonic"), namespace="subsonic"))
]
# urlpatterns = [
# url(

Wyświetl plik

@ -1,6 +1,6 @@
from allauth.account.utils import send_email_confirmation
from allauth.account.models import EmailAddress
from django.core.cache import cache
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from oauth2_provider.contrib.rest_framework.authentication import (
OAuth2Authentication as BaseOAuth2Authentication,
)
@ -20,9 +20,13 @@ def resend_confirmation_email(request, user):
if cache.get(cache_key):
return False
done = send_email_confirmation(request, user)
# We do the sending of the conformation by hand because we don't want to pass the request down
# to the email rendering, which would cause another UnverifiedEmail Exception and restarts the sending
# again and again
email = EmailAddress.objects.get_for_user(user, user.email)
email.send_confirmation()
cache.set(cache_key, True, THROTTLE_DELAY)
return done
return True
class OAuth2Authentication(BaseOAuth2Authentication):

Wyświetl plik

@ -1,5 +1,6 @@
from django import forms
from django.db.models import Q
from django.db.models.functions import Lower
from django_filters import rest_framework as filters
from django_filters import widgets
from drf_spectacular.utils import extend_schema_field
@ -239,3 +240,19 @@ class ActorScopeFilter(filters.CharFilter):
raise EmptyQuerySet()
return Q(**{self.actor_field: actor})
class CaseInsensitiveNameOrderingFilter(filters.OrderingFilter):
def filter(self, qs, value):
order_by = []
if value is None:
return qs
for param in value:
if param == "name":
order_by.append(Lower("name"))
else:
order_by.append(self.get_ordering_value(param))
return qs.order_by(*order_by)

Wyświetl plik

@ -36,22 +36,7 @@ class Command(BaseCommand):
self.stdout.write("")
def init(self):
try:
user = User.objects.get(username="gitpod")
except Exception:
call_command(
"createsuperuser",
username="gitpod",
email="gitpod@example.com",
no_input=False,
)
user = User.objects.get(username="gitpod")
user.set_password("gitpod")
if not user.actor:
user.create_actor()
user.save()
user = User.objects.get(username="gitpod")
# Allow anonymous access
preferences.set("common__api_authentication_required", False)

Wyświetl plik

@ -10,7 +10,7 @@ class Command(BaseCommand):
self.help = "Helper to generate randomized testdata"
self.type_choices = {"notifications": self.handle_notifications}
self.missing_args_message = f"Please specify one of the following sub-commands: { *self.type_choices.keys(), }"
self.missing_args_message = f"Please specify one of the following sub-commands: {*self.type_choices.keys(), }"
def add_arguments(self, parser):
subparsers = parser.add_subparsers(dest="subcommand")

Wyświetl plik

@ -150,7 +150,9 @@ def get_default_head_tags(path):
{
"tag": "meta",
"property": "og:image",
"content": utils.join_url(settings.FUNKWHALE_URL, "/front/favicon.png"),
"content": utils.join_url(
settings.FUNKWHALE_URL, "/android-chrome-512x512.png"
),
},
{
"tag": "meta",

Wyświetl plik

@ -60,12 +60,12 @@ class NullsLastSQLCompiler(SQLCompiler):
class NullsLastQuery(models.sql.query.Query):
"""Use a custom compiler to inject 'NULLS LAST' (for PostgreSQL)."""
def get_compiler(self, using=None, connection=None):
def get_compiler(self, using=None, connection=None, elide_empty=True):
if using is None and connection is None:
raise ValueError("Need either using or connection")
if using:
connection = connections[using]
return NullsLastSQLCompiler(self, connection, using)
return NullsLastSQLCompiler(self, connection, using, elide_empty)
class NullsLastQuerySet(models.QuerySet):

Wyświetl plik

@ -2,7 +2,7 @@ import json
from django import forms
from django.conf import settings
from django.contrib.postgres.forms import JSONField
from django.forms import JSONField
from dynamic_preferences import serializers, types
from dynamic_preferences.registries import global_preferences_registry
@ -93,7 +93,6 @@ class SerializedPreference(types.BasePreferenceType):
serializer
"""
serializer = JSONSerializer
data_serializer_class = None
field_class = JSONField
widget = forms.Textarea

Wyświetl plik

@ -5,8 +5,8 @@ import os
import PIL
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from django.utils.encoding import smart_str
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@ -52,7 +52,7 @@ class RelatedField(serializers.RelatedField):
self.fail(
"does_not_exist",
related_field_name=self.related_field_name,
value=smart_text(data),
value=smart_str(data),
)
except (TypeError, ValueError):
self.fail("invalid")
@ -349,7 +349,7 @@ class ScopesSerializer(serializers.Serializer):
class IdentSerializer(serializers.Serializer):
type = serializers.CharField()
id = serializers.IntegerField()
id = serializers.CharField()
class RateLimitSerializer(serializers.Serializer):

Wyświetl plik

@ -1,6 +1,6 @@
import django.dispatch
mutation_created = django.dispatch.Signal(providing_args=["mutation"])
mutation_updated = django.dispatch.Signal(
providing_args=["mutation", "old_is_approved", "new_is_approved"]
)
""" Required args: mutation """
mutation_created = django.dispatch.Signal()
""" Required args: mutation, old_is_approved, new_is_approved """
mutation_updated = django.dispatch.Signal()

Wyświetl plik

@ -7,7 +7,7 @@ from rest_framework import throttling as rest_throttling
def get_ident(user, request):
if user and user.is_authenticated:
return {"type": "authenticated", "id": user.pk}
return {"type": "authenticated", "id": f"{user.pk}"}
ident = rest_throttling.BaseThrottle().get_ident(request)
return {"type": "anonymous", "id": ident}

Wyświetl plik

@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError
from django.core.files.images import get_image_dimensions
from django.template.defaultfilters import filesizeformat
from django.utils.deconstruct import deconstructible
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
@deconstructible

Wyświetl plik

@ -1,168 +0,0 @@
# Copyright (c) 2018 Philipp Wolfer <ph.wolfer@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import json
import logging
import ssl
import time
from http.client import HTTPSConnection
HOST_NAME = "api.listenbrainz.org"
PATH_SUBMIT = "/1/submit-listens"
SSL_CONTEXT = ssl.create_default_context()
class Track:
"""
Represents a single track to submit.
See https://listenbrainz.readthedocs.io/en/latest/dev/json.html
"""
def __init__(self, artist_name, track_name, release_name=None, additional_info={}):
"""
Create a new Track instance
@param artist_name as str
@param track_name as str
@param release_name as str
@param additional_info as dict
"""
self.artist_name = artist_name
self.track_name = track_name
self.release_name = release_name
self.additional_info = additional_info
@staticmethod
def from_dict(data):
return Track(
data["artist_name"],
data["track_name"],
data.get("release_name", None),
data.get("additional_info", {}),
)
def to_dict(self):
return {
"artist_name": self.artist_name,
"track_name": self.track_name,
"release_name": self.release_name,
"additional_info": self.additional_info,
}
def __repr__(self):
return f"Track({self.artist_name}, {self.track_name})"
class ListenBrainzClient:
"""
Submit listens to ListenBrainz.org.
See https://listenbrainz.readthedocs.io/en/latest/dev/api.html
"""
def __init__(self, user_token, logger=logging.getLogger(__name__)):
self.__next_request_time = 0
self.user_token = user_token
self.logger = logger
def listen(self, listened_at, track):
"""
Submit a listen for a track
@param listened_at as int
@param entry as Track
"""
payload = _get_payload(track, listened_at)
return self._submit("single", [payload])
def playing_now(self, track):
"""
Submit a playing now notification for a track
@param track as Track
"""
payload = _get_payload(track)
return self._submit("playing_now", [payload])
def import_tracks(self, tracks):
"""
Import a list of tracks as (listened_at, Track) pairs
@param track as [(int, Track)]
"""
payload = _get_payload_many(tracks)
return self._submit("import", payload)
def _submit(self, listen_type, payload, retry=0):
self._wait_for_ratelimit()
self.logger.debug("ListenBrainz %s: %r", listen_type, payload)
data = {"listen_type": listen_type, "payload": payload}
headers = {
"Authorization": "Token %s" % self.user_token,
"Content-Type": "application/json",
}
body = json.dumps(data)
conn = HTTPSConnection(HOST_NAME, context=SSL_CONTEXT)
conn.request("POST", PATH_SUBMIT, body, headers)
response = conn.getresponse()
response_text = response.read()
try:
response_data = json.loads(response_text)
except json.decoder.JSONDecodeError:
response_data = response_text
self._handle_ratelimit(response)
log_msg = f"Response {response.status}: {response_data!r}"
if response.status == 429 and retry < 5: # Too Many Requests
self.logger.warning(log_msg)
return self._submit(listen_type, payload, retry + 1)
elif response.status == 200:
self.logger.debug(log_msg)
else:
self.logger.error(log_msg)
return response
def _wait_for_ratelimit(self):
now = time.time()
if self.__next_request_time > now:
delay = self.__next_request_time - now
self.logger.debug("Rate limit applies, delay %d", delay)
time.sleep(delay)
def _handle_ratelimit(self, response):
remaining = int(response.getheader("X-RateLimit-Remaining", 0))
reset_in = int(response.getheader("X-RateLimit-Reset-In", 0))
self.logger.debug("X-RateLimit-Remaining: %i", remaining)
self.logger.debug("X-RateLimit-Reset-In: %i", reset_in)
if remaining == 0:
self.__next_request_time = time.time() + reset_in
def _get_payload_many(tracks):
payload = []
for listened_at, track in tracks:
data = _get_payload(track, listened_at)
payload.append(data)
return payload
def _get_payload(track, listened_at=None):
data = {"track_metadata": track.to_dict()}
if listened_at is not None:
data["listened_at"] = listened_at
return data

Wyświetl plik

@ -1,7 +1,9 @@
import liblistenbrainz
from django.utils import timezone
import funkwhale_api
from config import plugins
from .client import ListenBrainzClient, Track
from .funkwhale_startup import PLUGIN
@ -13,15 +15,14 @@ def submit_listen(listening, conf, **kwargs):
logger = PLUGIN["logger"]
logger.info("Submitting listen to ListenBrainz")
client = ListenBrainzClient(user_token=user_token, logger=logger)
track = get_track(listening.track)
client.listen(int(listening.creation_date.timestamp()), track)
client = liblistenbrainz.ListenBrainz()
client.set_auth_token(user_token)
listen = get_listen(listening.track)
client.submit_single_listen(listen)
def get_track(track):
artist = track.artist.name
title = track.title
album = None
def get_listen(track):
additional_info = {
"media_player": "Funkwhale",
"media_player_version": funkwhale_api.__version__,
@ -36,7 +37,7 @@ def get_track(track):
if track.album:
if track.album.title:
album = track.album.title
release_name = track.album.title
if track.album.mbid:
additional_info["release_mbid"] = str(track.album.mbid)
@ -47,4 +48,10 @@ def get_track(track):
if upload:
additional_info["duration"] = upload.duration
return Track(artist, title, album, additional_info)
return liblistenbrainz.Listen(
track_name=track.title,
artist_name=track.artist.name,
listened_at=int(timezone.now()),
release_name=release_name,
additional_info=additional_info,
)

Wyświetl plik

@ -1,4 +1,5 @@
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
from rest_framework import routers
from . import views
@ -23,6 +24,8 @@ music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
index_router.register(r"index", views.IndexViewSet, "index")
urlpatterns = router.urls + [
url("federation/music/", include((music_router.urls, "music"), namespace="music")),
url("federation/", include((index_router.urls, "index"), namespace="index")),
re_path(
"federation/music/", include((music_router.urls, "music"), namespace="music")
),
re_path("federation/", include((index_router.urls, "index"), namespace="index")),
]

Wyświetl plik

@ -1,3 +1,4 @@
import pycountry
from django.core.validators import FileExtensionValidator
from django.forms import widgets
from dynamic_preferences import types
@ -170,3 +171,18 @@ class Banner(ImagePreference):
default = None
help_text = "This banner will be displayed on your pod's landing and about page. At least 600x100px recommended."
field_kwargs = {"required": False}
@global_preferences_registry.register
class Location(types.ChoicePreference):
show_in_api = True
section = instance
name = "location"
verbose_name = "Server Location"
default = ""
choices = [(country.alpha_2, country.name) for country in pycountry.countries]
help_text = (
"The country or territory in which your server is located. This is displayed in the server's Nodeinfo "
"endpoint."
)
field_kwargs = {"choices": choices, "required": False}

Wyświetl plik

@ -12,6 +12,17 @@ class SoftwareSerializer(serializers.Serializer):
return "funkwhale"
class SoftwareSerializer_v2(SoftwareSerializer):
repository = serializers.SerializerMethodField()
homepage = serializers.SerializerMethodField()
def get_repository(self, obj):
return "https://dev.funkwhale.audio/funkwhale/funkwhale"
def get_homepage(self, obj):
return "https://funkwhale.audio"
class ServicesSerializer(serializers.Serializer):
inbound = serializers.ListField(child=serializers.CharField(), default=[])
outbound = serializers.ListField(child=serializers.CharField(), default=[])
@ -31,6 +42,8 @@ class UsersUsageSerializer(serializers.Serializer):
class UsageSerializer(serializers.Serializer):
users = UsersUsageSerializer()
localPosts = serializers.IntegerField(required=False)
localComments = serializers.IntegerField(required=False)
class TotalCountSerializer(serializers.Serializer):
@ -92,19 +105,14 @@ class MetadataSerializer(serializers.Serializer):
private = serializers.SerializerMethodField()
shortDescription = serializers.SerializerMethodField()
longDescription = serializers.SerializerMethodField()
rules = serializers.SerializerMethodField()
contactEmail = serializers.SerializerMethodField()
terms = serializers.SerializerMethodField()
nodeName = serializers.SerializerMethodField()
banner = serializers.SerializerMethodField()
defaultUploadQuota = serializers.SerializerMethodField()
library = serializers.SerializerMethodField()
supportedUploadExtensions = serializers.ListField(child=serializers.CharField())
allowList = serializers.SerializerMethodField()
reportTypes = ReportTypeSerializer(source="report_types", many=True)
funkwhaleSupportMessageEnabled = serializers.SerializerMethodField()
instanceSupportMessage = serializers.SerializerMethodField()
endpoints = EndpointsSerializer()
usage = MetadataUsageSerializer(source="stats", required=False)
def get_private(self, obj) -> bool:
@ -116,15 +124,9 @@ class MetadataSerializer(serializers.Serializer):
def get_longDescription(self, obj) -> str:
return obj["preferences"].get("instance__long_description")
def get_rules(self, obj) -> str:
return obj["preferences"].get("instance__rules")
def get_contactEmail(self, obj) -> str:
return obj["preferences"].get("instance__contact_email")
def get_terms(self, obj) -> str:
return obj["preferences"].get("instance__terms")
def get_nodeName(self, obj) -> str:
return obj["preferences"].get("instance__name")
@ -137,15 +139,6 @@ class MetadataSerializer(serializers.Serializer):
def get_defaultUploadQuota(self, obj) -> int:
return obj["preferences"].get("users__upload_quota")
@extend_schema_field(NodeInfoLibrarySerializer)
def get_library(self, obj):
data = obj["stats"] or {}
data["federationEnabled"] = obj["preferences"].get("federation__enabled")
data["anonymousCanListen"] = not obj["preferences"].get(
"common__api_authentication_required"
)
return NodeInfoLibrarySerializer(data).data
@extend_schema_field(AllowListStatSerializer)
def get_allowList(self, obj):
return AllowListStatSerializer(
@ -166,6 +159,62 @@ class MetadataSerializer(serializers.Serializer):
return MetadataUsageSerializer(obj["stats"]).data
class Metadata20Serializer(MetadataSerializer):
library = serializers.SerializerMethodField()
reportTypes = ReportTypeSerializer(source="report_types", many=True)
endpoints = EndpointsSerializer()
rules = serializers.SerializerMethodField()
terms = serializers.SerializerMethodField()
def get_rules(self, obj) -> str:
return obj["preferences"].get("instance__rules")
def get_terms(self, obj) -> str:
return obj["preferences"].get("instance__terms")
@extend_schema_field(NodeInfoLibrarySerializer)
def get_library(self, obj):
data = obj["stats"] or {}
data["federationEnabled"] = obj["preferences"].get("federation__enabled")
data["anonymousCanListen"] = not obj["preferences"].get(
"common__api_authentication_required"
)
return NodeInfoLibrarySerializer(data).data
class MetadataContentLocalSerializer(serializers.Serializer):
artists = serializers.IntegerField()
releases = serializers.IntegerField()
recordings = serializers.IntegerField()
hoursOfContent = serializers.IntegerField()
class MetadataContentCategorySerializer(serializers.Serializer):
name = serializers.CharField()
count = serializers.IntegerField()
class MetadataContentSerializer(serializers.Serializer):
local = MetadataContentLocalSerializer()
topMusicCategories = MetadataContentCategorySerializer(many=True)
topPodcastCategories = MetadataContentCategorySerializer(many=True)
class Metadata21Serializer(MetadataSerializer):
languages = serializers.ListField(child=serializers.CharField())
location = serializers.CharField()
content = MetadataContentSerializer()
features = serializers.ListField(child=serializers.CharField())
codeOfConduct = serializers.SerializerMethodField()
def get_codeOfConduct(self, obj) -> str:
return (
full_url("/about/pod#rules")
if obj["preferences"].get("instance__rules")
else ""
)
class NodeInfo20Serializer(serializers.Serializer):
version = serializers.SerializerMethodField()
software = SoftwareSerializer()
@ -196,9 +245,36 @@ class NodeInfo20Serializer(serializers.Serializer):
usage = {"users": {"total": 0, "activeMonth": 0, "activeHalfyear": 0}}
return UsageSerializer(usage).data
@extend_schema_field(MetadataSerializer)
@extend_schema_field(Metadata20Serializer)
def get_metadata(self, obj):
return MetadataSerializer(obj).data
return Metadata20Serializer(obj).data
class NodeInfo21Serializer(NodeInfo20Serializer):
version = serializers.SerializerMethodField()
software = SoftwareSerializer_v2()
def get_version(self, obj) -> str:
return "2.1"
@extend_schema_field(UsageSerializer)
def get_usage(self, obj):
usage = None
if obj["preferences"]["instance__nodeinfo_stats_enabled"]:
usage = obj["stats"]
usage["localPosts"] = 0
usage["localComments"] = 0
else:
usage = {
"users": {"total": 0, "activeMonth": 0, "activeHalfyear": 0},
"localPosts": 0,
"localComments": 0,
}
return UsageSerializer(usage).data
@extend_schema_field(Metadata21Serializer)
def get_metadata(self, obj):
return Metadata21Serializer(obj).data
class SpaManifestIconSerializer(serializers.Serializer):

Wyświetl plik

@ -1,6 +1,6 @@
import datetime
from django.db.models import Sum
from django.db.models import Count, F, Sum
from django.utils import timezone
from funkwhale_api.favorites.models import TrackFavorite
@ -22,6 +22,39 @@ def get():
}
def get_content():
return {
"local": {
"artists": get_artists(),
"releases": get_albums(),
"recordings": get_tracks(),
"hoursOfContent": get_music_duration(),
},
"topMusicCategories": get_top_music_categories(),
"topPodcastCategories": get_top_podcast_categories(),
}
def get_top_music_categories():
return (
models.Track.objects.filter(artist__content_category="music")
.exclude(tagged_items__tag_id=None)
.values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name"))
.order_by("-count")[:3]
)
def get_top_podcast_categories():
return (
models.Track.objects.filter(artist__content_category="podcast")
.exclude(tagged_items__tag_id=None)
.values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name"))
.order_by("-count")[:3]
)
def get_users():
qs = User.objects.filter(is_active=True)
now = timezone.now()

Wyświetl plik

@ -1,4 +1,4 @@
from django.conf.urls import url
from django.urls import re_path
from funkwhale_api.common import routers
@ -8,7 +8,7 @@ admin_router = routers.OptionalSlashRouter()
admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
urlpatterns = [
url(r"^nodeinfo/2.0/?$", views.NodeInfo.as_view(), name="nodeinfo-2.0"),
url(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
url(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"),
re_path(r"^nodeinfo/2.0/?$", views.NodeInfo20.as_view(), name="nodeinfo-2.0"),
re_path(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
re_path(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"),
] + admin_router.urls

Wyświetl plik

@ -0,0 +1,7 @@
from django.urls import re_path
from . import views
urlpatterns = [
re_path(r"^nodeinfo/2.1/?$", views.NodeInfo21.as_view(), name="nodeinfo-2.1"),
]

Wyświetl plik

@ -11,6 +11,7 @@ from dynamic_preferences.api import viewsets as preferences_viewsets
from dynamic_preferences.api.serializers import GlobalPreferenceSerializer
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import generics, views
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from funkwhale_api import __version__ as funkwhale_version
@ -58,9 +59,11 @@ class InstanceSettings(generics.GenericAPIView):
@method_decorator(ensure_csrf_cookie, name="dispatch")
class NodeInfo(views.APIView):
class NodeInfo20(views.APIView):
permission_classes = []
authentication_classes = []
serializer_class = serializers.NodeInfo20Serializer
renderer_classes = (JSONRenderer,)
@extend_schema(
responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20"
@ -81,6 +84,7 @@ class NodeInfo(views.APIView):
data = {
"software": {"version": funkwhale_version},
"services": {"inbound": ["atom1.0"], "outbound": ["atom1.0"]},
"preferences": pref,
"stats": cache_memoize(600, prefix="memoize:instance:stats")(stats.get)()
if pref["instance__nodeinfo_stats_enabled"]
@ -112,7 +116,65 @@ class NodeInfo(views.APIView):
data["endpoints"]["channels"] = reverse(
"federation:index:index-channels"
)
serializer = serializers.NodeInfo20Serializer(data)
serializer = self.serializer_class(data)
return Response(
serializer.data, status=200, content_type=NODEINFO_2_CONTENT_TYPE
)
class NodeInfo21(NodeInfo20):
serializer_class = serializers.NodeInfo21Serializer
@extend_schema(
responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20"
)
def get(self, request):
pref = preferences.all()
if (
pref["moderation__allow_list_public"]
and pref["moderation__allow_list_enabled"]
):
allowed_domains = list(
Domain.objects.filter(allowed=True)
.order_by("name")
.values_list("name", flat=True)
)
else:
allowed_domains = None
data = {
"software": {"version": funkwhale_version},
"services": {"inbound": ["atom1.0"], "outbound": ["atom1.0"]},
"preferences": pref,
"stats": cache_memoize(600, prefix="memoize:instance:stats")(stats.get)()
if pref["instance__nodeinfo_stats_enabled"]
else None,
"actorId": get_service_actor().fid,
"supportedUploadExtensions": SUPPORTED_EXTENSIONS,
"allowed_domains": allowed_domains,
"languages": pref.get("moderation__languages"),
"location": pref.get("instance__location"),
"content": cache_memoize(600, prefix="memoize:instance:content")(
stats.get_content
)()
if pref["instance__nodeinfo_stats_enabled"]
else None,
"features": [
"channels",
"podcasts",
],
}
if not pref.get("common__api_authentication_required"):
data["features"].append("anonymousCanListen")
if pref.get("federation__enabled"):
data["features"].append("federation")
if pref.get("music__only_allow_musicbrainz_tagged_files"):
data["features"].append("onlyMbidTaggedContent")
serializer = self.serializer_class(data)
return Response(
serializer.data, status=200, content_type=NODEINFO_2_CONTENT_TYPE
)

Wyświetl plik

@ -1,4 +1,5 @@
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
from funkwhale_api.common import routers
@ -32,14 +33,16 @@ other_router.register(r"channels", views.ManageChannelViewSet, "channels")
other_router.register(r"tags", views.ManageTagViewSet, "tags")
urlpatterns = [
url(
re_path(
r"^federation/",
include((federation_router.urls, "federation"), namespace="federation"),
),
url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
url(
re_path(
r"^library/", include((library_router.urls, "instance"), namespace="library")
),
re_path(
r"^moderation/",
include((moderation_router.urls, "moderation"), namespace="moderation"),
),
url(r"^users/", include((users_router.urls, "instance"), namespace="users")),
re_path(r"^users/", include((users_router.urls, "instance"), namespace="users")),
] + other_router.urls

Wyświetl plik

@ -1,3 +1,4 @@
import pycountry
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import serializers
@ -92,3 +93,18 @@ class SignupFormCustomization(common_preferences.SerializedPreference):
required = False
default = {}
data_serializer_class = CustomFormSerializer
@global_preferences_registry.register
class Languages(common_preferences.StringListPreference):
show_in_api = True
section = moderation
name = "languages"
default = ["en"]
verbose_name = "Moderation languages"
help_text = (
"The language(s) spoken by the server moderator(s). Set this to inform users "
"what languages they should write reports and requests in."
)
choices = [(lang.alpha_3, lang.name) for lang in pycountry.languages]
field_kwargs = {"choices": choices, "required": False}

Wyświetl plik

@ -1,3 +1,4 @@
import django.dispatch
report_created = django.dispatch.Signal(providing_args=["report"])
""" Required argument: report """
report_created = django.dispatch.Signal()

Wyświetl plik

@ -32,3 +32,18 @@ class MusicCacheDuration(types.IntPreference):
"will be erased and retranscoded on the next listening."
)
field_kwargs = {"required": False}
@global_preferences_registry.register
class MbidTaggedContent(types.BooleanPreference):
show_in_api = True
section = music
name = "only_allow_musicbrainz_tagged_files"
verbose_name = "Only allow Musicbrainz tagged files"
help_text = (
"Requires uploaded files to be tagged with a MusicBrainz ID. "
"Enabling this setting has no impact on previously uploaded files. "
"You can use the CLI to clear files that don't contain an MBID or "
"or enable quality filtering to hide untagged content from API calls. "
)
default = False

Wyświetl plik

@ -104,7 +104,7 @@ class ArtistFilter(
distinct=True,
library_field="tracks__uploads__library",
)
ordering = django_filters.OrderingFilter(
ordering = common_filters.CaseInsensitiveNameOrderingFilter(
fields=(
("id", "id"),
("name", "name"),

Wyświetl plik

@ -0,0 +1,61 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from funkwhale_api.music import models
class Command(BaseCommand):
help = """Deletes any tracks not tagged with a MusicBrainz ID from the database. By default, any tracks that
have been favorited by a user or added to a playlist are preserved."""
def add_arguments(self, parser):
parser.add_argument(
"--no-dry-run",
action="store_true",
dest="no_dry_run",
default=True,
help="Disable dry run mode and apply pruning for real on the database",
)
parser.add_argument(
"--include-playlist-content",
action="store_true",
dest="include_playlist_content",
default=False,
help="Allow tracks included in playlists to be pruned",
)
parser.add_argument(
"--include-favorites-content",
action="store_true",
dest="include_favorited_content",
default=False,
help="Allow favorited tracks to be pruned",
)
parser.add_argument(
"--include-listened-content",
action="store_true",
dest="include_listened_content",
default=False,
help="Allow tracks with listening history to be pruned",
)
@transaction.atomic
def handle(self, *args, **options):
tracks = models.Track.objects.filter(mbid__isnull=True)
if not options["include_favorited_content"]:
tracks = tracks.filter(track_favorites__isnull=True)
if not options["include_playlist_content"]:
tracks = tracks.filter(playlist_tracks__isnull=True)
if not options["include_listened_content"]:
tracks = tracks.filter(listenings__isnull=True)
pruned_total = tracks.count()
total = models.Track.objects.count()
if options["no_dry_run"]:
self.stdout.write(f"Deleting {pruned_total}/{total} tracks…")
tracks.delete()
else:
self.stdout.write(f"Would prune {pruned_total}/{total} tracks")

Wyświetl plik

@ -226,17 +226,18 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
)
def serialize_upload(upload) -> object:
return {
"uuid": str(upload.uuid),
"listen_url": upload.listen_url,
"size": upload.size,
"duration": upload.duration,
"bitrate": upload.bitrate,
"mimetype": upload.mimetype,
"extension": upload.extension,
"is_local": federation_utils.is_local(upload.fid),
}
class TrackUploadSerializer(serializers.Serializer):
uuid = serializers.UUIDField()
listen_url = serializers.URLField()
size = serializers.IntegerField()
duration = serializers.IntegerField()
bitrate = serializers.IntegerField()
mimetype = serializers.CharField()
extension = serializers.CharField()
is_local = serializers.SerializerMethodField()
def get_is_local(self, upload) -> bool:
return federation_utils.is_local(upload.fid)
def sort_uploads_for_listen(uploads):
@ -281,11 +282,14 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
def get_listen_url(self, obj):
return obj.listen_url
@extend_schema_field({"type": "array", "items": {"type": "object"}})
# @extend_schema_field({"type": "array", "items": {"type": "object"}})
@extend_schema_field(TrackUploadSerializer(many=True))
def get_uploads(self, obj):
uploads = getattr(obj, "playable_uploads", [])
# we put local uploads first
uploads = [serialize_upload(u) for u in sort_uploads_for_listen(uploads)]
uploads = [
TrackUploadSerializer(u).data for u in sort_uploads_for_listen(uploads)
]
uploads = sorted(uploads, key=lambda u: u["is_local"], reverse=True)
return list(uploads)

Wyświetl plik

@ -1,5 +1,4 @@
import django.dispatch
upload_import_status_updated = django.dispatch.Signal(
providing_args=["old_status", "new_status", "upload"]
)
""" Required args: old_status, new_status, upload """
upload_import_status_updated = django.dispatch.Signal()

Wyświetl plik

@ -247,6 +247,13 @@ def process_upload(upload, update_denormalization=True):
return fail_import(
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
)
check_mbid = preferences.get("music__only_allow_musicbrainz_tagged_files")
if check_mbid and not serializer.validated_data.get("mbid"):
return fail_import(
upload,
"Only content tagged with a MusicBrainz ID is permitted on this pod.",
detail="You can tag your files with MusicBrainz Picard",
)
final_metadata = collections.ChainMap(
additional_data, serializer.validated_data, internal_config

Wyświetl plik

@ -297,8 +297,6 @@ class LibraryViewSet(
)
instance.delete()
follows = action
@extend_schema(
responses=federation_api_serializers.LibraryFollowSerializer(many=True)
)

Wyświetl plik

@ -1,4 +1,4 @@
from django.conf.urls import url
from django.urls import re_path
from funkwhale_api.common import routers
@ -7,22 +7,22 @@ from . import views
router = routers.OptionalSlashRouter()
router.register(r"search", views.SearchViewSet, "search")
urlpatterns = [
url(
re_path(
"releases/(?P<uuid>[0-9a-z-]+)/$",
views.ReleaseDetail.as_view(),
name="release-detail",
),
url(
re_path(
"artists/(?P<uuid>[0-9a-z-]+)/$",
views.ArtistDetail.as_view(),
name="artist-detail",
),
url(
re_path(
"release-groups/browse/(?P<artist_uuid>[0-9a-z-]+)/$",
views.ReleaseGroupBrowse.as_view(),
name="release-group-browse",
),
url(
re_path(
"releases/browse/(?P<release_group_uuid>[0-9a-z-]+)/$",
views.ReleaseBrowse.as_view(),
name="release-browse",

Wyświetl plik

@ -1,7 +1,8 @@
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
urlpatterns = [
url(
re_path(
r"^musicbrainz/",
include(
("funkwhale_api.musicbrainz.urls", "musicbrainz"), namespace="musicbrainz"

Wyświetl plik

@ -38,14 +38,12 @@ def validate(config):
return True
def build_radio_queryset(patch, config, radio_qs):
"""Take a troi patch and its arg, match the missing mbid and then build a radio queryset"""
logger.info("Config used for troi radio generation is " + str(config))
def build_radio_queryset(patch, radio_qs):
"""Take a troi patch, match the missing mbid and then build a radio queryset"""
start_time = time.time()
try:
recommendations = troi.core.generate_playlist(patch, config)
recommendations = patch.generate_playlist()
except ConnectTimeout:
raise ValueError(
"Timed out while connecting to ListenBrainz. No candidates could be retrieved for the radio."
@ -56,33 +54,37 @@ def build_radio_queryset(patch, config, radio_qs):
if not recommendations:
raise ValueError("No candidates found by troi")
recommended_recording_mbids = [
recommended_mbids = [
recommended_recording.mbid
for recommended_recording in recommendations.playlists[0].recordings
]
logger.info("Searching for MusicBrainz ID in Funkwhale database")
qs_mbid = music_models.Track.objects.all().filter(
mbid__in=recommended_recording_mbids
qs_recommended = (
music_models.Track.objects.all()
.filter(mbid__in=recommended_mbids)
.order_by("mbid", "pk")
.distinct("mbid")
)
mbids_found = [str(i.mbid) for i in qs_mbid]
qs_recommended_mbid = [str(i.mbid) for i in qs_recommended]
recommended_recording_mbids_not_found = [
mbid for mbid in recommended_recording_mbids if mbid not in mbids_found
recommended_mbids_not_qs = [
mbid for mbid in recommended_mbids if mbid not in qs_recommended_mbid
]
cached_mbid_match = cache.get_many(recommended_recording_mbids_not_found)
cached_match = cache.get_many(recommended_mbids_not_qs)
cached_match_mbid = [str(i) for i in cached_match.keys()]
if qs_mbid and cached_mbid_match:
if qs_recommended and cached_match_mbid:
logger.info("MusicBrainz IDs found in Funkwhale database and redis")
mbids_found = [str(i.mbid) for i in qs_mbid]
mbids_found.extend([i for i in cached_mbid_match.keys()])
elif qs_mbid and not cached_mbid_match:
qs_recommended_mbid.extend(cached_match_mbid)
mbids_found = qs_recommended_mbid
elif qs_recommended and not cached_match_mbid:
logger.info("MusicBrainz IDs found in Funkwhale database")
mbids_found = mbids_found
elif not qs_mbid and cached_mbid_match:
mbids_found = qs_recommended_mbid
elif not qs_recommended and cached_match_mbid:
logger.info("MusicBrainz IDs found in redis cache")
mbids_found = [i for i in cached_mbid_match.keys()]
mbids_found = cached_match_mbid
else:
logger.info(
"Couldn't find any matches in Funkwhale database. Trying to match all"
@ -106,23 +108,32 @@ def build_radio_queryset(patch, config, radio_qs):
+ str(end_time_resolv - start_time_resolv)
)
cached_mbid_match = cache.get_many(recommended_recording_mbids_not_found)
cached_match = cache.get_many(recommended_mbids)
if not qs_mbid and not cached_mbid_match:
if not mbids_found and not cached_match:
raise ValueError("No candidates found for troi radio")
logger.info("Radio generation with troi took " + str(end_time_resolv - start_time))
logger.info("qs_mbid is " + str(mbids_found))
mbids_found_pks = list(
music_models.Track.objects.all()
.filter(mbid__in=mbids_found)
.order_by("mbid", "pk")
.distinct("mbid")
.values_list("pk", flat=True)
)
if qs_mbid and cached_mbid_match:
mbids_found_pks_unique = [
i for i in mbids_found_pks if i not in cached_match.keys()
]
if mbids_found and cached_match:
return radio_qs.filter(
Q(mbid__in=mbids_found) | Q(pk__in=cached_mbid_match.values())
Q(pk__in=mbids_found_pks_unique) | Q(pk__in=cached_match.values())
)
if qs_mbid and not cached_mbid_match:
return radio_qs.filter(mbid__in=mbids_found)
if mbids_found and not cached_match:
return radio_qs.filter(pk__in=mbids_found_pks_unique)
if not qs_mbid and cached_mbid_match:
return radio_qs.filter(pk__in=cached_mbid_match.values())
if not mbids_found and cached_match:
return radio_qs.filter(pk__in=cached_match.values())
class TroiPatch:
@ -132,4 +143,4 @@ class TroiPatch:
def get_queryset(self, config, qs):
patch_string = config.pop("patch")
patch = patches[patch_string]
return build_radio_queryset(patch(), config, qs)
return build_radio_queryset(patch(config), qs)

Wyświetl plik

@ -6,6 +6,10 @@ from rest_framework import renderers
import funkwhale_api
class TagValue(str):
"""Use this for string values that must be rendered as tags instead of attributes in XML."""
# from https://stackoverflow.com/a/8915039
# because I want to avoid a lxml dependency just for outputting cdata properly
# in a RSS feed
@ -31,10 +35,14 @@ ET._serialize_xml = ET._serialize["xml"] = _serialize_xml
def structure_payload(data):
payload = {
# funkwhaleVersion is deprecated and will be removed in a future
# release. Use serverVersion instead.
"funkwhaleVersion": funkwhale_api.__version__,
"serverVersion": funkwhale_api.__version__,
"status": "ok",
"type": "funkwhale",
"version": "1.16.0",
"openSubsonic": "true",
}
payload.update(data)
if "detail" in payload:
@ -81,6 +89,10 @@ def dict_to_xml_tree(root_tag, d, parent=None):
el = ET.Element(key)
el.text = str(obj)
root.append(el)
elif isinstance(value, TagValue):
el = ET.Element(key)
el.text = str(value)
root.append(el)
else:
if key == "value":
root.text = str(value)

Wyświetl plik

@ -7,6 +7,8 @@ from funkwhale_api.history import models as history_models
from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils as music_utils
from .renderers import TagValue
def to_subsonic_date(date):
"""
@ -50,6 +52,7 @@ def get_artist_data(artist_values):
"name": artist_values["name"],
"albumCount": artist_values["_albums_count"],
"coverArt": "ar-{}".format(artist_values["id"]),
"musicBrainzId": str(artist_values.get("mbid", "")),
}
@ -58,7 +61,7 @@ class GetArtistsSerializer(serializers.Serializer):
payload = {"ignoredArticles": "", "index": []}
queryset = queryset.with_albums_count()
queryset = queryset.order_by(functions.Lower("name"))
values = queryset.values("id", "_albums_count", "name")
values = queryset.values("id", "_albums_count", "name", "mbid")
first_letter_mapping = collections.defaultdict(list)
for artist in values:
@ -102,6 +105,23 @@ class GetArtistSerializer(serializers.Serializer):
return payload
class GetArtistInfo2Serializer(serializers.Serializer):
def to_representation(self, artist):
payload = {}
if artist.mbid:
payload["musicBrainzId"] = TagValue(artist.mbid)
if artist.attachment_cover:
payload["mediumImageUrl"] = TagValue(
artist.attachment_cover.download_url_medium_square_crop
)
payload["largeImageUrl"] = TagValue(
artist.attachment_cover.download_url_large_square_crop
)
if artist.description:
payload["biography"] = TagValue(artist.description.rendered)
return payload
def get_track_data(album, track, upload):
data = {
"id": track.pk,
@ -126,11 +146,13 @@ def get_track_data(album, track, upload):
"albumId": album.pk if album else "",
"artistId": album.artist.pk if album else track.artist.pk,
"type": "music",
"mediaType": "song",
"musicBrainzId": str(track.mbid or ""),
}
if album and album.attachment_cover_id:
data["coverArt"] = f"al-{album.id}"
if upload.bitrate:
data["bitrate"] = int(upload.bitrate / 1000)
data["bitRate"] = int(upload.bitrate / 1000)
if upload.size:
data["size"] = upload.size
if album and album.release_date:
@ -149,13 +171,17 @@ def get_album2_data(album):
"created": to_subsonic_date(album.creation_date),
"duration": album.duration,
"playCount": album.tracks.aggregate(l=Count("listenings"))["l"] or 0,
"mediaType": "album",
"musicBrainzId": str(album.mbid or ""),
}
if album.attachment_cover_id:
payload["coverArt"] = f"al-{album.id}"
if album.tagged_items:
genres = [{"name": i.tag.name} for i in album.tagged_items.all()]
# exposes only first genre since the specification uses singular noun
first_genre = album.tagged_items.first()
payload["genre"] = first_genre.tag.name if first_genre else ""
payload["genre"] = genres[0]["name"] if len(genres) > 0 else ""
# OpenSubsonic full genre list
payload["genres"] = genres
if album.release_date:
payload["year"] = album.release_date.year
try:
@ -343,7 +369,7 @@ def get_channel_episode_data(upload, channel_id):
"genre": "Podcast",
"size": upload.size if upload.size else "",
"duration": upload.duration if upload.duration else "",
"bitrate": upload.bitrate / 1000 if upload.bitrate else "",
"bitRate": upload.bitrate / 1000 if upload.bitrate else "",
"contentType": upload.mimetype or "audio/mpeg",
"suffix": upload.extension or "mp3",
"status": "completed",

Wyświetl plik

@ -180,6 +180,19 @@ class SubsonicViewSet(viewsets.GenericViewSet):
}
return response.Response(data, status=200)
@action(
detail=False,
methods=["get", "post"],
url_name="get_open_subsonic_extensions",
permission_classes=[],
url_path="getOpenSubsonicExtensions",
)
def get_open_subsonic_extensions(self, request, *args, **kwargs):
data = {
"openSubsonicExtensions": [{"name": "formPost", "versions": [1]}],
}
return response.Response(data, status=200)
@action(
detail=False,
methods=["get", "post"],
@ -255,7 +268,9 @@ class SubsonicViewSet(viewsets.GenericViewSet):
)
@find_object(music_models.Artist.objects.all(), filter_playable=True)
def get_artist_info2(self, request, *args, **kwargs):
payload = {"artist-info2": {}}
artist = kwargs.pop("obj")
data = serializers.GetArtistInfo2Serializer(artist).data
payload = {"artistInfo2": data}
return response.Response(payload, status=200)
@ -523,7 +538,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
"search_fields": ["name"],
"queryset": (
music_models.Artist.objects.with_albums_count().values(
"id", "_albums_count", "name"
"id", "_albums_count", "name", "mbid"
)
),
"serializer": lambda qs: [serializers.get_artist_data(a) for a in qs],

Wyświetl plik

@ -24,7 +24,7 @@ class TagFilter(filters.FilterSet):
def get_by_similar_tags(qs, tags):
"""
Return a queryset of obects with at least one matching tag.
Return a queryset of objects with at least one matching tag.
Annotate the queryset so you can order later by number of matches.
"""
qs = qs.filter(tagged_items__tag__name__in=tags).annotate(

Wyświetl plik

@ -1,7 +1,7 @@
from django import forms
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from funkwhale_api.common import admin

Wyświetl plik

@ -1,4 +1,4 @@
from django.conf.urls import url
from django.urls import re_path
from funkwhale_api.common import routers
@ -8,6 +8,6 @@ router = routers.OptionalSlashRouter()
router.register(r"users", views.UserViewSet, "users")
urlpatterns = [
url(r"^users/login/?$", views.login, name="login"),
url(r"^users/logout/?$", views.logout, name="logout"),
re_path(r"^users/login/?$", views.login, name="login"),
re_path(r"^users/logout/?$", views.logout, name="logout"),
] + router.urls

Wyświetl plik

@ -12,7 +12,7 @@ from django.db.models import JSONField
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from django_auth_ldap.backend import populate_user as ldap_populate_user
from oauth2_provider import models as oauth2_models
from oauth2_provider import validators as oauth2_validators

Wyświetl plik

@ -1,4 +1,4 @@
from django.conf.urls import url
from django.urls import re_path
from django.views.decorators.csrf import csrf_exempt
from funkwhale_api.common import routers
@ -10,7 +10,9 @@ router.register(r"apps", views.ApplicationViewSet, "apps")
router.register(r"grants", views.GrantViewSet, "grants")
urlpatterns = router.urls + [
url("^authorize/$", csrf_exempt(views.AuthorizeView.as_view()), name="authorize"),
url("^token/$", views.TokenView.as_view(), name="token"),
url("^revoke/$", views.RevokeTokenView.as_view(), name="revoke"),
re_path(
"^authorize/$", csrf_exempt(views.AuthorizeView.as_view()), name="authorize"
),
re_path("^token/$", views.TokenView.as_view(), name="token"),
re_path("^revoke/$", views.RevokeTokenView.as_view(), name="revoke"),
]

Wyświetl plik

@ -200,7 +200,7 @@ class AuthorizeView(views.APIView, oauth_views.AuthorizationView):
return self.json_payload({"non_field_errors": ["Invalid application"]}, 400)
def redirect(self, redirect_to, application):
if self.request.is_ajax():
if self.request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
# Web client need this to be able to redirect the user
query = urllib.parse.urlparse(redirect_to).query
code = urllib.parse.parse_qs(query)["code"][0]

Wyświetl plik

@ -1,38 +1,38 @@
from dj_rest_auth import views as rest_auth_views
from django.conf.urls import url
from django.urls import re_path
from django.views.generic import TemplateView
from . import views
urlpatterns = [
# URLs that do not require a session or valid token
url(
re_path(
r"^password/reset/$",
views.PasswordResetView.as_view(),
name="rest_password_reset",
),
url(
re_path(
r"^password/reset/confirm/$",
views.PasswordResetConfirmView.as_view(),
name="rest_password_reset_confirm",
),
# URLs that require a user to be logged in with a valid session / token.
url(
re_path(
r"^user/$", rest_auth_views.UserDetailsView.as_view(), name="rest_user_details"
),
url(
re_path(
r"^password/change/$",
views.PasswordChangeView.as_view(),
name="rest_password_change",
),
# Registration URLs
url(r"^registration/$", views.RegisterView.as_view(), name="rest_register"),
url(
re_path(r"^registration/$", views.RegisterView.as_view(), name="rest_register"),
re_path(
r"^registration/verify-email/?$",
views.VerifyEmailView.as_view(),
name="rest_verify_email",
),
url(
re_path(
r"^registration/change-password/?$",
views.PasswordChangeView.as_view(),
name="change_password",
@ -47,7 +47,7 @@ urlpatterns = [
# If you don't want to use API on that step, then just use ConfirmEmailView
# view from:
# https://github.com/pennersr/django-allauth/blob/a62a370681/allauth/account/views.py#L291
url(
re_path(
r"^registration/account-confirm-email/(?P<key>\w+)/?$",
TemplateView.as_view(),
name="account_confirm_email",

Wyświetl plik

@ -340,4 +340,8 @@ class UserChangeEmailSerializer(serializers.Serializer):
email=request.user.email,
defaults={"verified": False, "primary": True},
)
current.change(request, self.validated_data["email"], confirm=True)
if request.user.email != self.validated_data["email"]:
current.email = self.validated_data["email"]
current.verified = False
current.save()
current.send_confirmation()

Wyświetl plik

@ -1,6 +1,7 @@
import json
from allauth.account.adapter import get_adapter
from allauth.account.utils import send_email_confirmation
from dj_rest_auth import views as rest_auth_views
from dj_rest_auth.registration import views as registration_views
from django import http
@ -11,7 +12,7 @@ from rest_framework import mixins, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from funkwhale_api.common import authentication, preferences, throttling
from funkwhale_api.common import preferences, throttling
from . import models, serializers, tasks
@ -37,7 +38,7 @@ class RegisterView(registration_views.RegisterView):
user = super().perform_create(serializer)
if not user.is_active:
# manual approval, we need to send the confirmation e-mail by hand
authentication.send_email_confirmation(self.request, user)
send_email_confirmation(self.request, user)
if user.invitation:
user.invitation.set_invited_user(user)

4026
api/poetry.lock wygenerowano

Plik diff jest za duży Load Diff

Wyświetl plik

@ -1,6 +1,6 @@
[tool.poetry]
name = "funkwhale-api"
version = "1.3.3"
version = "1.4.0"
description = "Funkwhale API"
authors = ["Funkwhale Collective"]
@ -25,102 +25,104 @@ exclude = ["tests"]
funkwhale-manage = 'funkwhale_api.main:main'
[tool.poetry.dependencies]
python = "^3.8"
python = "^3.8,<3.13"
# Django
dj-rest-auth = { extras = ["with_social"], version = "2.2.8" }
django = "==3.2.20"
django-allauth = "==0.42.0"
dj-rest-auth = "5.0.2"
django = "4.2.9"
django-allauth = "0.55.2"
django-cache-memoize = "0.1.10"
django-cacheops = "==6.1"
django-cleanup = "==6.0.0"
django-cors-headers = "==3.13.0"
django-cacheops = "==7.0.2"
django-cleanup = "==8.1.0"
django-cors-headers = "==4.3.1"
django-dynamic-preferences = "==1.14.0"
django-environ = "==0.10.0"
django-filter = "==22.1"
django-filter = "==23.5"
django-oauth-toolkit = "2.2.0"
django-redis = "==5.2.0"
django-storages = "==1.13.2"
django-versatileimagefield = "==2.2"
django-versatileimagefield = "==3.1"
djangorestframework = "==3.14.0"
drf-spectacular = "==0.26.1"
drf-spectacular = "==0.26.5"
markdown = "==3.4.4"
persisting-theory = "==1.0"
psycopg2 = "==2.9.7"
redis = "==4.5.5"
psycopg2 = "==2.9.9"
redis = "==5.0.1"
# Django LDAP
django-auth-ldap = "==4.1.0"
python-ldap = "==3.4.3"
python-ldap = "==3.4.4"
# Channels
channels = { extras = ["daphne"], version = "==4.0.0" }
channels-redis = "==4.1.0"
# Celery
kombu = "==5.2.4"
celery = "==5.2.7"
kombu = "5.3.4"
celery = "5.3.6"
# Deployment
gunicorn = "==20.1.0"
gunicorn = "==21.2.0"
uvicorn = { version = "==0.20.0", extras = ["standard"] }
# Libs
aiohttp = "==3.8.5"
aiohttp = "3.9.1"
arrow = "==1.2.3"
backports-zoneinfo = { version = "==0.2.1", python = "<3.9" }
bleach = "==5.0.1"
bleach = "==6.1.0"
boto3 = "==1.26.161"
click = "==8.1.7"
cryptography = "==38.0.4"
cryptography = "==41.0.7"
feedparser = "==6.0.10"
liblistenbrainz = "==0.5.5"
musicbrainzngs = "==0.7.1"
mutagen = "==1.46.0"
pillow = "==9.3.0"
pillow = "==10.2.0"
pydub = "==0.25.1"
pyld = "==2.0.3"
python-magic = "==0.4.27"
requests = "==2.28.2"
requests = "==2.31.0"
requests-http-message-signatures = "==0.3.1"
sentry-sdk = "==1.19.1"
watchdog = "==2.2.1"
troi = { git = "https://github.com/metabrainz/troi-recommendation-playground.git", branch = "main"}
lb-matching-tools = { git = "https://github.com/metabrainz/listenbrainz-matching-tools.git", branch = "main"}
unidecode = "==1.3.6"
watchdog = "==4.0.0"
troi = "==2024.1.26.0"
lb-matching-tools = "==2024.1.25.0rc1"
unidecode = "==1.3.7"
pycountry = "23.12.11"
# Typesense
typesense = { version = "==0.15.1", optional = true }
# Dependencies pinning
ipython = "==7.34.0"
ipython = "==8.12.3"
pluralizer = "==1.2.0"
service-identity = "==21.1.0"
service-identity = "==24.1.0"
unicode-slugify = "==0.1.5"
[tool.poetry.group.dev.dependencies]
aioresponses = "==0.7.4"
aioresponses = "==0.7.6"
asynctest = "==0.13.0"
black = "==23.3.0"
coverage = { version = "==6.5.0", extras = ["toml"] }
black = "==24.1.1"
coverage = { version = "==7.4.1", extras = ["toml"] }
debugpy = "==1.6.7.post1"
django-coverage-plugin = "==3.0.0"
django-debug-toolbar = "==3.8.1"
django-debug-toolbar = "==4.2.0"
factory-boy = "==3.2.1"
faker = "==15.3.4"
faker = "==23.2.1"
flake8 = "==3.9.2"
ipdb = "==0.13.13"
prompt-toolkit = "==3.0.39"
pytest = "==7.2.2"
pytest = "==8.0.0"
pytest-asyncio = "==0.21.0"
prompt-toolkit = "==3.0.41"
pytest-cov = "==4.0.0"
pytest-django = "==4.5.2"
pytest-env = "==0.8.1"
pytest-env = "==1.1.3"
pytest-mock = "==3.10.0"
pytest-randomly = "==3.12.0"
pytest-sugar = "==0.9.7"
pytest-sugar = "==1.0.0"
requests-mock = "==1.10.0"
pylint = "==2.17.2"
pylint-django = "==2.5.3"
pylint = "==3.0.3"
pylint-django = "==2.5.5"
django-extensions = "==3.2.3"
[tool.poetry.extras]

Wyświetl plik

@ -108,7 +108,7 @@ def test_get_default_head_tags(preferences, settings):
{
"tag": "meta",
"property": "og:image",
"content": settings.FUNKWHALE_URL + "/front/favicon.png",
"content": settings.FUNKWHALE_URL + "/android-chrome-512x512.png",
},
{"tag": "meta", "property": "og:url", "content": settings.FUNKWHALE_URL + "/"},
]

Wyświetl plik

@ -17,7 +17,7 @@ def test_get_ident_anonymous(api_request):
def test_get_ident_authenticated(api_request, factories):
user = factories["users.User"]()
request = api_request.get("/")
expected = {"id": user.pk, "type": "authenticated"}
expected = {"id": f"{user.pk}", "type": "authenticated"}
assert throttling.get_ident(user, request) == expected
@ -26,7 +26,7 @@ def test_get_ident_authenticated(api_request, factories):
[
(
"create",
{"id": 42, "type": "authenticated"},
{"id": "42", "type": "authenticated"},
"throttling:create:authenticated:42",
),
(
@ -269,6 +269,7 @@ def test_throttle_calls_attach_info(method, mocker):
def test_allow_request(api_request, settings, mocker):
settings.THROTTLING_ENABLED = True
settings.THROTTLING_RATES = {"test": {"rate": "2/s"}}
ip = "92.92.92.92"
request = api_request.get("/", HTTP_X_FORWARDED_FOR=ip)

Wyświetl plik

@ -160,7 +160,7 @@ def test_cannot_approve_reject_without_perm(
def test_rate_limit(logged_in_api_client, now_time, settings, mocker):
expected_ident = {"type": "authenticated", "id": logged_in_api_client.user.pk}
expected_ident = {"type": "authenticated", "id": f"{logged_in_api_client.user.pk}"}
expected = {
"ident": expected_ident,

Wyświetl plik

@ -6,7 +6,7 @@ from funkwhale_api import __version__ as api_version
from funkwhale_api.music.utils import SUPPORTED_EXTENSIONS
def test_nodeinfo_default(api_client):
def test_nodeinfo_20(api_client):
url = reverse("api:v1:instance:nodeinfo-2.0")
response = api_client.get(url)
@ -14,7 +14,7 @@ def test_nodeinfo_default(api_client):
"version": "2.0",
"software": OrderedDict([("name", "funkwhale"), ("version", api_version)]),
"protocols": ["activitypub"],
"services": OrderedDict([("inbound", []), ("outbound", [])]),
"services": OrderedDict([("inbound", ["atom1.0"]), ("outbound", ["atom1.0"])]),
"openRegistrations": False,
"usage": {
"users": OrderedDict(
@ -89,3 +89,74 @@ def test_nodeinfo_default(api_client):
}
assert response.data == expected
def test_nodeinfo_21(api_client):
url = reverse("api:v2:instance:nodeinfo-2.1")
response = api_client.get(url)
expected = {
"version": "2.1",
"software": OrderedDict(
[
("name", "funkwhale"),
("version", api_version),
("repository", "https://dev.funkwhale.audio/funkwhale/funkwhale"),
("homepage", "https://funkwhale.audio"),
]
),
"protocols": ["activitypub"],
"services": OrderedDict([("inbound", ["atom1.0"]), ("outbound", ["atom1.0"])]),
"openRegistrations": False,
"usage": {
"users": OrderedDict(
[("total", 0), ("activeHalfyear", 0), ("activeMonth", 0)]
),
"localPosts": 0,
"localComments": 0,
},
"metadata": {
"actorId": "https://test.federation/federation/actors/service",
"private": False,
"shortDescription": "",
"longDescription": "",
"contactEmail": "",
"nodeName": "",
"banner": None,
"defaultUploadQuota": 1000,
"supportedUploadExtensions": SUPPORTED_EXTENSIONS,
"allowList": {"enabled": False, "domains": None},
"funkwhaleSupportMessageEnabled": True,
"instanceSupportMessage": "",
"usage": OrderedDict(
[
("favorites", OrderedDict([("tracks", {"total": 0})])),
("listenings", OrderedDict([("total", 0)])),
("downloads", OrderedDict([("total", 0)])),
]
),
"location": "",
"languages": ["en"],
"features": ["channels", "podcasts", "federation"],
"content": OrderedDict(
[
(
"local",
OrderedDict(
[
("artists", 0),
("releases", 0),
("recordings", 0),
("hoursOfContent", 0),
]
),
),
("topMusicCategories", []),
("topPodcastCategories", []),
]
),
"codeOfConduct": "",
},
}
assert response.data == expected

Wyświetl plik

@ -7,6 +7,7 @@ from funkwhale_api.music.management.commands import (
check_inplace_files,
fix_uploads,
prune_library,
prune_non_mbid_content,
)
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
@ -204,3 +205,45 @@ def test_check_inplace_files_no_dry_run(factories, tmpfile):
for u in not_prunable:
u.refresh_from_db()
def test_prune_non_mbid_content(factories):
prunable = factories["music.Track"](mbid=None)
track = factories["music.Track"](mbid=None)
factories["playlists.PlaylistTrack"](track=track)
not_prunable = [factories["music.Track"](), track]
c = prune_non_mbid_content.Command()
options = {
"include_playlist_content": False,
"include_listened_content": False,
"include_favorited_content": True,
"no_dry_run": True,
}
c.handle(**options)
with pytest.raises(prunable.DoesNotExist):
prunable.refresh_from_db()
for t in not_prunable:
t.refresh_from_db()
track = factories["music.Track"](mbid=None)
factories["playlists.PlaylistTrack"](track=track)
prunable = [factories["music.Track"](mbid=None), track]
not_prunable = [factories["music.Track"]()]
options = {
"include_playlist_content": True,
"include_listened_content": False,
"include_favorited_content": False,
"no_dry_run": True,
}
c.handle(**options)
for t in prunable:
with pytest.raises(t.DoesNotExist):
t.refresh_from_db()
for t in not_prunable:
t.refresh_from_db()

Wyświetl plik

@ -3,9 +3,32 @@ import pytest
from funkwhale_api.music import filters, models
def test_artist_filter_ordering(factories, mocker):
# Lista de prueba
artist1 = factories["music.Artist"](name="Anita Muller")
artist2 = factories["music.Artist"](name="Jane Smith")
artist3 = factories["music.Artist"](name="Adam Johnson")
artist4 = factories["music.Artist"](name="anita iux")
qs = models.Artist.objects.all()
cf = factories["moderation.UserFilter"](for_artist=True)
# Request con ordenamiento
filterset = filters.ArtistFilter(
{"ordering": "name"}, request=mocker.Mock(user=cf.user), queryset=qs
)
expected_order = [artist3.name, artist4.name, artist1.name, artist2.name]
actual_order = list(filterset.qs.values_list("name", flat=True))
assert actual_order == expected_order
def test_album_filter_hidden(factories, mocker, queryset_equal_list):
factories["music.Album"]()
cf = factories["moderation.UserFilter"](for_artist=True)
hidden_album = factories["music.Album"](artist=cf.target_artist)
qs = models.Album.objects.all()

Wyświetl plik

@ -198,8 +198,8 @@ def test_can_get_pictures(name):
cover_data = data.get_picture("cover_front", "other")
assert cover_data["mimetype"].startswith("image/")
assert len(cover_data["content"]) > 0
assert type(cover_data["content"]) == bytes
assert type(cover_data["description"]) == str
assert type(cover_data["content"]) is bytes
assert type(cover_data["description"]) is str
@pytest.mark.parametrize(

Wyświetl plik

@ -245,7 +245,7 @@ def test_track_serializer(factories, to_api_date):
"title": track.title,
"position": track.position,
"disc_number": track.disc_number,
"uploads": [serializers.serialize_upload(upload)],
"uploads": [serializers.TrackUploadSerializer(upload).data],
"creation_date": to_api_date(track.creation_date),
"listen_url": track.listen_url,
"license": upload.track.license.code,
@ -373,7 +373,7 @@ def test_manage_upload_action_publish(factories, mocker):
m.assert_any_call(tasks.process_upload.delay, upload_id=draft.pk)
def test_serialize_upload(factories):
def test_track_upload_serializer(factories):
upload = factories["music.Upload"]()
expected = {
@ -387,7 +387,7 @@ def test_serialize_upload(factories):
"is_local": False,
}
data = serializers.serialize_upload(upload)
data = serializers.TrackUploadSerializer(upload).data
assert data == expected

Wyświetl plik

@ -1400,3 +1400,53 @@ def test_fs_import(factories, cache, mocker, settings):
}
assert cache.get("fs-import:status") == "finished"
assert "Pruning dangling tracks" in cache.get("fs-import:logs")[-1]
def test_upload_checks_mbid_tag(temp_signal, factories, mocker, preferences):
preferences["music__only_allow_musicbrainz_tagged_files"] = True
mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
mocker.patch("funkwhale_api.music.tasks.populate_album_cover")
mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture")
track = factories["music.Track"](album__attachment_cover=None, mbid=None)
path = os.path.join(DATA_DIR, "with_cover.opus")
upload = factories["music.Upload"](
track=None,
audio_file__from_path=path,
import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}},
)
mocker.patch("funkwhale_api.music.models.TrackActor.create_entries")
with temp_signal(signals.upload_import_status_updated):
tasks.process_upload(upload_id=upload.pk)
upload.refresh_from_db()
assert upload.import_status == "errored"
assert upload.import_details == {
"error_code": "Only content tagged with a MusicBrainz ID is permitted on this pod.",
"detail": "You can tag your files with MusicBrainz Picard",
}
def test_upload_checks_mbid_tag_pass(temp_signal, factories, mocker, preferences):
preferences["music__only_allow_musicbrainz_tagged_files"] = True
mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
mocker.patch("funkwhale_api.music.tasks.populate_album_cover")
mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture")
track = factories["music.Track"](album__attachment_cover=None, mbid=None)
path = os.path.join(DATA_DIR, "test.mp3")
upload = factories["music.Upload"](
track=None,
audio_file__from_path=path,
import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}},
)
mocker.patch("funkwhale_api.music.models.TrackActor.create_entries")
with temp_signal(signals.upload_import_status_updated):
tasks.process_upload(upload_id=upload.pk)
upload.refresh_from_db()
assert upload.import_status == "finished"

Wyświetl plik

@ -131,3 +131,12 @@ def test_transcode_file(name, expected):
result = {k: round(v) for k, v in utils.get_audio_file_data(f).items()}
assert result == expected
def test_custom_s3_domain(factories, settings):
"""See #2220"""
settings.AWS_S3_CUSTOM_DOMAIN = "my.custom.domain.tld"
settings.DEFAULT_FILE_STORAGE = "funkwhale_api.common.storage.ASCIIS3Boto3Storage"
f = factories["music.Upload"].build(audio_file__filename="test.mp3")
assert f.audio_file.url.startswith("https://")

Wyświetl plik

@ -24,7 +24,7 @@ def test_can_build_radio_queryset_with_fw_db(factories, mocker):
mocker.patch("funkwhale_api.typesense.utils.resolve_recordings_to_fw_track")
radio_qs = lb_recommendations.build_radio_queryset(
custom_factories.DummyPatch(), {"min_recordings": 1}, qs
custom_factories.DummyPatch({"min_recordings": 1}), qs
)
recommended_recording_mbids = [
"87dfa566-21c3-45ed-bc42-1d345b8563fa",
@ -46,7 +46,7 @@ def test_build_radio_queryset_without_fw_db(mocker):
with pytest.raises(ValueError):
lb_recommendations.build_radio_queryset(
custom_factories.DummyPatch(), {"min_recordings": 1}, qs
custom_factories.DummyPatch({"min_recordings": 1}), qs
)
assert resolve_recordings_to_fw_track.called_once_with(
@ -67,7 +67,7 @@ def test_build_radio_queryset_with_redis_and_fw_db(factories, mocker):
assert list(
lb_recommendations.build_radio_queryset(
custom_factories.DummyPatch(), {"min_recordings": 1}, qs
custom_factories.DummyPatch({"min_recordings": 1}), qs
)
) == list(Track.objects.all().filter(pk__in=[1, 2]))
@ -84,14 +84,14 @@ def test_build_radio_queryset_with_redis_and_without_fw_db(factories, mocker):
assert list(
lb_recommendations.build_radio_queryset(
custom_factories.DummyPatch(), {"min_recordings": 1}, qs
custom_factories.DummyPatch({"min_recordings": 1}), qs
)
) == list(Track.objects.all().filter(pk=1))
def test_build_radio_queryset_catch_troi_ConnectTimeout(mocker):
mocker.patch.object(
troi.core,
troi.core.Patch,
"generate_playlist",
side_effect=ConnectTimeout,
)
@ -99,18 +99,18 @@ def test_build_radio_queryset_catch_troi_ConnectTimeout(mocker):
with pytest.raises(ValueError):
lb_recommendations.build_radio_queryset(
custom_factories.DummyPatch(), {"min_recordings": 1}, qs
custom_factories.DummyPatch({"min_recordings": 1}), qs
)
def test_build_radio_queryset_catch_troi_no_candidates(mocker):
mocker.patch.object(
troi.core,
troi.core.Patch,
"generate_playlist",
)
qs = Track.objects.all()
with pytest.raises(ValueError):
lb_recommendations.build_radio_queryset(
custom_factories.DummyPatch(), {"min_recordings": 1}, qs
custom_factories.DummyPatch({"min_recordings": 1}), qs
)

Wyświetl plik

@ -17,6 +17,8 @@ from funkwhale_api.subsonic import renderers
"version": "1.16.0",
"type": "funkwhale",
"funkwhaleVersion": funkwhale_api.__version__,
"serverVersion": funkwhale_api.__version__,
"openSubsonic": "true",
"hello": "world",
},
),
@ -30,6 +32,8 @@ from funkwhale_api.subsonic import renderers
"version": "1.16.0",
"type": "funkwhale",
"funkwhaleVersion": funkwhale_api.__version__,
"serverVersion": funkwhale_api.__version__,
"openSubsonic": "true",
"hello": "world",
"error": {"code": 10, "message": "something went wrong"},
},
@ -41,6 +45,8 @@ from funkwhale_api.subsonic import renderers
"version": "1.16.0",
"type": "funkwhale",
"funkwhaleVersion": funkwhale_api.__version__,
"serverVersion": funkwhale_api.__version__,
"openSubsonic": "true",
"hello": "world",
"error": {"code": 0, "message": "something went wrong"},
},
@ -59,6 +65,8 @@ def test_json_renderer():
"version": "1.16.0",
"type": "funkwhale",
"funkwhaleVersion": funkwhale_api.__version__,
"serverVersion": funkwhale_api.__version__,
"openSubsonic": "true",
"hello": "world",
}
}
@ -71,9 +79,10 @@ def test_xml_renderer_dict_to_xml():
"hello": "world",
"item": [{"this": 1, "value": "text"}, {"some": "node"}],
"list": [1, 2],
"some-tag": renderers.TagValue("foo"),
}
expected = """<?xml version="1.0" encoding="UTF-8"?>
<key hello="world"><item this="1">text</item><item some="node" /><list>1</list><list>2</list></key>"""
<key hello="world"><item this="1">text</item><item some="node" /><list>1</list><list>2</list><some-tag>foo</some-tag></key>""" # noqa
result = renderers.dict_to_xml_tree("key", payload)
exp = ET.fromstring(expected)
assert ET.tostring(result) == ET.tostring(exp)
@ -81,8 +90,9 @@ def test_xml_renderer_dict_to_xml():
def test_xml_renderer():
payload = {"hello": "world"}
expected = '<?xml version="1.0" encoding="UTF-8"?>\n<subsonic-response funkwhaleVersion="{}" hello="world" status="ok" type="funkwhale" version="1.16.0" xmlns="http://subsonic.org/restapi" />' # noqa
expected = expected.format(funkwhale_api.__version__).encode()
expected = '<?xml version="1.0" encoding="UTF-8"?>\n<subsonic-response funkwhaleVersion="{}" hello="world" openSubsonic="true" serverVersion="{}" status="ok" type="funkwhale" version="1.16.0" xmlns="http://subsonic.org/restapi" />' # noqa
version = funkwhale_api.__version__
expected = expected.format(version, version).encode()
renderer = renderers.SubsonicXMLRenderer()
rendered = renderer.render(payload)

Wyświetl plik

@ -4,7 +4,7 @@ import pytest
from django.db.models.aggregates import Count
from funkwhale_api.music import models as music_models
from funkwhale_api.subsonic import serializers
from funkwhale_api.subsonic import renderers, serializers
@pytest.mark.parametrize(
@ -90,12 +90,14 @@ def test_get_artists_serializer(factories):
"name": artist1.name,
"albumCount": 3,
"coverArt": f"ar-{artist1.id}",
"musicBrainzId": artist1.mbid,
},
{
"id": artist2.pk,
"name": artist2.name,
"albumCount": 2,
"coverArt": f"ar-{artist2.id}",
"musicBrainzId": artist2.mbid,
},
],
},
@ -107,6 +109,7 @@ def test_get_artists_serializer(factories):
"name": artist3.name,
"albumCount": 0,
"coverArt": f"ar-{artist3.id}",
"musicBrainzId": artist3.mbid,
}
],
},
@ -147,6 +150,24 @@ def test_get_artist_serializer(factories):
assert serializers.GetArtistSerializer(artist).data == expected
def test_get_artist_info_2_serializer(factories):
content = factories["common.Content"]()
artist = factories["music.Artist"](with_cover=True, description=content)
expected = {
"musicBrainzId": artist.mbid,
"mediumImageUrl": renderers.TagValue(
artist.attachment_cover.download_url_medium_square_crop
),
"largeImageUrl": renderers.TagValue(
artist.attachment_cover.download_url_large_square_crop
),
"biography": renderers.TagValue(artist.description.rendered),
}
assert serializers.GetArtistInfo2Serializer(artist).data == expected
@pytest.mark.parametrize(
"mimetype, extension, expected",
[
@ -184,6 +205,9 @@ def test_get_album_serializer(factories):
"year": album.release_date.year,
"coverArt": f"al-{album.id}",
"genre": tagged_item.tag.name,
"genres": [{"name": tagged_item.tag.name}],
"mediaType": "album",
"musicBrainzId": album.mbid,
"duration": 43,
"playCount": album.tracks.aggregate(l=Count("listenings"))["l"] or 0,
"song": [
@ -200,13 +224,15 @@ def test_get_album_serializer(factories):
"contentType": upload.mimetype,
"suffix": upload.extension or "",
"path": serializers.get_track_path(track, upload.extension),
"bitrate": 42,
"bitRate": 42,
"duration": 43,
"size": 44,
"created": serializers.to_subsonic_date(track.creation_date),
"albumId": album.pk,
"artistId": artist.pk,
"type": "music",
"mediaType": "song",
"musicBrainzId": track.mbid,
}
],
}
@ -341,7 +367,7 @@ def test_channel_episode_serializer(factories):
"genre": "Podcast",
"size": upload.size,
"duration": upload.duration,
"bitrate": upload.bitrate / 1000,
"bitRate": upload.bitrate / 1000,
"contentType": upload.mimetype,
"suffix": upload.extension,
"status": "completed",

Wyświetl plik

@ -97,6 +97,23 @@ def test_ping(f, db, api_client):
assert response.data == expected
@pytest.mark.parametrize("f", ["xml", "json"])
def test_get_open_subsonic_extensions(f, db, api_client):
url = reverse("api:subsonic:subsonic-get_open_subsonic_extensions")
response = api_client.get(url, {"f": f})
expected = {
"openSubsonicExtensions": [
{
"name": "formPost",
"versions": [1],
}
],
}
assert response.status_code == 200
assert response.data == expected
@pytest.mark.parametrize("f", ["json"])
def test_get_artists(
f, db, logged_in_api_client, factories, mocker, queryset_equal_queries
@ -166,7 +183,11 @@ def test_get_artist_info2(
artist = factories["music.Artist"](playable=True)
playable_by = mocker.spy(music_models.ArtistQuerySet, "playable_by")
expected = {"artist-info2": {}}
expected = {
"artistInfo2": {
"musicBrainzId": artist.mbid,
}
}
response = logged_in_api_client.get(url, {"id": artist.pk})
assert response.status_code == 200
@ -592,7 +613,7 @@ def test_search3(f, db, logged_in_api_client, factories):
artist_qs = (
music_models.Artist.objects.with_albums_count()
.filter(pk=artist.pk)
.values("_albums_count", "id", "name")
.values("_albums_count", "id", "name", "mbid")
)
assert response.status_code == 200
assert response.data == {

Wyświetl plik

@ -12,5 +12,5 @@ def test_can_resolve_subsonic():
def test_can_resolve_v2():
path = reverse("api:v2:instance:nodeinfo-2.0")
assert path == "/api/v2/instance/nodeinfo/2.0"
path = reverse("api:v2:instance:nodeinfo-2.1")
assert path == "/api/v2/instance/nodeinfo/2.1"

Wyświetl plik

@ -8,7 +8,27 @@ from funkwhale_api.moderation import tasks as moderation_tasks
from funkwhale_api.users.models import User
def test_can_create_user_via_api(preferences, api_client, db):
def test_can_create_user_via_api(settings, preferences, api_client, db):
url = reverse("rest_register")
data = {
"username": "test1",
"email": "test1@test.com",
"password1": "thisismypassword",
"password2": "thisismypassword",
}
preferences["users__registration_enabled"] = True
settings.ACCOUNT_EMAIL_VERIFICATION = "mandatory"
response = api_client.post(url, data)
assert response.status_code == 201
assert response.data["detail"] == "Verification e-mail sent."
u = User.objects.get(email="test1@test.com")
assert u.username == "test1"
def test_can_create_user_via_api_mail_verification_mandatory(
settings, preferences, api_client, db
):
url = reverse("rest_register")
data = {
"username": "test1",
@ -18,7 +38,7 @@ def test_can_create_user_via_api(preferences, api_client, db):
}
preferences["users__registration_enabled"] = True
response = api_client.post(url, data)
assert response.status_code == 201
assert response.status_code == 204
u = User.objects.get(email="test1@test.com")
assert u.username == "test1"
@ -82,7 +102,7 @@ def test_can_signup_with_invitation(preferences, factories, api_client):
}
preferences["users__registration_enabled"] = False
response = api_client.post(url, data)
assert response.status_code == 201
assert response.status_code == 204
u = User.objects.get(email="test1@test.com")
assert u.username == "test1"
assert u.invitation == invitation
@ -302,7 +322,7 @@ def test_creating_user_creates_actor_as_well(
mocker.patch("funkwhale_api.users.models.create_actor", return_value=actor)
response = api_client.post(url, data)
assert response.status_code == 201
assert response.status_code == 204
user = User.objects.get(username="test1")
@ -323,7 +343,7 @@ def test_creating_user_sends_confirmation_email(
preferences["instance__name"] = "Hello world"
response = api_client.post(url, data)
assert response.status_code == 201
assert response.status_code == 204
confirmation_message = mailoutbox[-1]
assert "Hello world" in confirmation_message.body
@ -405,7 +425,7 @@ def test_signup_with_approval_enabled(
}
on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
response = api_client.post(url, data, format="json")
assert response.status_code == 201
assert response.status_code == 204
u = User.objects.get(email="test1@test.com")
assert u.username == "test1"
assert u.is_active is False

Wyświetl plik

@ -1,3 +0,0 @@
Prohibit the creation of new users using django's `createsuperuser` command in favor of our own CLI
entry point. Run `funkwhale-manage fw users create --superuser` instead. (#1288)

Wyświetl plik

@ -1 +0,0 @@
Create a testing environment in production for ListenBrainz recommendation engine (troi-recommendation-playground) (#1861)

Wyświetl plik

@ -1 +0,0 @@
Merge nginx configs for docker production and development setups (#1939)

Wyświetl plik

@ -1 +0,0 @@
Rename CHANGELOG to CHANGELOG.md

Wyświetl plik

@ -0,0 +1 @@
Add cli command to prune non mbid content from db (#2083)

Wyświetl plik

@ -1 +0,0 @@
Fixed development docker setup (2102)

Wyświetl plik

@ -1 +0,0 @@
Adding typesense container and api client (2104)

Wyświetl plik

@ -1 +0,0 @@
Add a management command to generate dummy notifications for testing

Wyświetl plik

@ -1 +0,0 @@
Cache radio queryset into redis. New radio track endpoint for api v2 is /api/v2/radios/sessions/{radiosessionid}/tracks (#2135)

Wyświetl plik

@ -1,2 +0,0 @@
New management command to update Uploads which have been imported using --in-place and are now
stored in s3 (#2156)

Wyświetl plik

@ -1 +0,0 @@
Make sure embed codes generated before 1.3.0 are still working

Wyświetl plik

@ -1 +0,0 @@
Fixed embedded player crash when API returns relative listen URL. (#2163)

Wyświetl plik

@ -1 +0,0 @@
Cache pip package in api docker builds (#2193)

Wyświetl plik

@ -1 +0,0 @@
Fixed development docker setup (2196)

Wyświetl plik

@ -1 +0,0 @@
Add custom logging functionality (#2155)

Some files were not shown because too many files have changed in this diff Show More