kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Porównaj commity
394 Commity
af8404c30e
...
ec368e0cd3
Autor | SHA1 | Data |
---|---|---|
Ciarán Ainsworth | ec368e0cd3 | |
Ciarán Ainsworth | a2579bdc60 | |
Ciarán Ainsworth | e1e0045a23 | |
Ciarán Ainsworth | 85c2be6a5b | |
Ciarán Ainsworth | 35de9bd48e | |
Petitminion | ba5b657b61 | |
Petitminion | 4fc73c1430 | |
Ciarán Ainsworth | 97e24bcaa6 | |
Ciarán Ainsworth | 1b15fea1ab | |
Ciarán Ainsworth | b624fea2fa | |
Ciarán Ainsworth | e028e8788b | |
Ciarán Ainsworth | 67f74d40a6 | |
Petitminion | 547bd6f371 | |
Petitminion | 05ec6f6d0f | |
Petitminion | a03cc1db24 | |
Petitminion | 2a364d5785 | |
Petitminion | 5bc0171694 | |
Petitminion | 37acfa475d | |
Petitminion | f45fd1e465 | |
Petitminion | 17c4a92f77 | |
Petitminion | 6414302899 | |
Ciarán Ainsworth | 94a5b9e696 | |
Bruno-Van-den-Bosch | d673e77dff | |
Kasper Seweryn | 02400ceea3 | |
Kasper Seweryn | 31f35a43f1 | |
Renovate Bot | 932de8c242 | |
Renovate Bot | a947a16b0f | |
Renovate Bot | a01079850d | |
Ciarán Ainsworth | 8d22eb925e | |
Georg Krause | 6fe153c8da | |
Georg Krause | cb7284ef95 | |
Georg Krause | 5ca8691feb | |
Georg Krause | b4920af0b8 | |
Georg Krause | 803b077f00 | |
Georg Krause | f1f6ef43ad | |
Georg Krause | 0fd0192b37 | |
Georg Krause | ac6d136105 | |
Georg Krause | 4e825527a5 | |
Georg Krause | 46ee53c967 | |
Georg Krause | 765c801142 | |
Tron | e0e8a54d45 | |
Tron | c67884a245 | |
Kasper Seweryn | d2ca28ca47 | |
Kasper Seweryn | 30540ec186 | |
Kasper Seweryn | 673fe8b828 | |
Kasper Seweryn | fe4af475af | |
Kasper Seweryn | ad1bb6a220 | |
Georg Krause | 298ace1b72 | |
Georg Krause | 37a1b008b3 | |
Kasper Seweryn | e42646d8a1 | |
Kasper Seweryn | 0095fc566e | |
Georg Krause | 419da80e37 | |
Kasper Seweryn | 0b99740d64 | |
Georg Krause | 51f56bc808 | |
Georg Krause | b00d782006 | |
Kasper Seweryn | f3a7394461 | |
Georg Krause | cb8725a838 | |
Georg Krause | cddf6b9d93 | |
Georg Krause | 521c4d927c | |
Kasper Seweryn | 78329ca821 | |
Georg Krause | 1ca5ea2b73 | |
Kasper Seweryn | 62f84a311b | |
Kasper Seweryn | 5bf6e23815 | |
Kasper Seweryn | 318aa196fa | |
Kasper Seweryn | b313d0e48c | |
Kasper Seweryn | cea9d9cf47 | |
Kasper Seweryn | 97aa045b0b | |
Kasper Seweryn | ccef0197c6 | |
Kasper Seweryn | 14d099b872 | |
Kasper Seweryn | 5647a1072d | |
Kasper Seweryn | de232cb749 | |
Georg Krause | b1eba58dcc | |
Georg Krause | 06cfe8da95 | |
wvffle | 6aa609970f | |
wvffle | 2b1228e620 | |
wvffle | 83120cced2 | |
wvffle | 367ba84f13 | |
wvffle | 7957661573 | |
wvffle | 9e2d47f698 | |
wvffle | 243f2a57e3 | |
wvffle | 670b522675 | |
Renovate Bot | ff6fc46c58 | |
Ciarán Ainsworth | 84bb893f3a | |
petitminion | 6c38bae189 | |
petitminion | 4364d82b0b | |
Renovate Bot | ac74380986 | |
Renovate Bot | ee0abed0b7 | |
Renovate Bot | fc456e6985 | |
petitminion | b0423d412f | |
Renovate Bot | 9853b89911 | |
Renovate Bot | e6e1b5cdc4 | |
Ciarán Ainsworth | 3b45fde10a | |
Georg Krause | 1eaad85c7d | |
Renovate Bot | f76a797638 | |
Georg Krause | d7d6976229 | |
Renovate Bot | 765bc62a2b | |
Renovate Bot | 446b49fd46 | |
Renovate Bot | 0210304338 | |
Renovate Bot | 6d7a52c5ec | |
Renovate Bot | 825baecf8f | |
Renovate Bot | 62f7fda42c | |
Georg Krause | d82eceecae | |
Renovate Bot | f58a33ec02 | |
Renovate Bot | abf0edfcdc | |
Philipp Wolfer | b658089e70 | |
Philipp Wolfer | 82fdc82f93 | |
Philipp Wolfer | 2371f2a4cb | |
Philipp Wolfer | 136f24a917 | |
Philipp Wolfer | a5ee48818e | |
Philipp Wolfer | d227490f5b | |
Philipp Wolfer | bf8f1e41b9 | |
Philipp Wolfer | e169e8edb1 | |
Philipp Wolfer | 0fab0470c2 | |
Philipp Wolfer | 81401075aa | |
Renovate Bot | c1d91ce4d6 | |
Renovate Bot | 1f8c03e248 | |
Renovate Bot | 42bf16034b | |
Renovate Bot | 787acab3ab | |
Renovate Bot | f43ef89c28 | |
Renovate Bot | c4bec419ab | |
Renovate Bot | 55a4221b69 | |
Renovate Bot | 60f66eea6d | |
Renovate Bot | 4148cdd186 | |
Renovate Bot | 004d535eb7 | |
Renovate Bot | 132e291708 | |
Renovate Bot | 40d2dcaeaf | |
Renovate Bot | fa36c97d72 | |
Renovate Bot | 9b8828ca42 | |
Georg Krause | e0791b570f | |
Georg Krause | 90c9230a60 | |
Renovate Bot | 1e0f3abb54 | |
Petitminion | bfff1f85f9 | |
petitminion | ae9fea0cf1 | |
Renovate Bot | da370f5915 | |
Renovate Bot | d6a078643b | |
Renovate Bot | 7fcaa1fed2 | |
Georg Krause | c3ae40cabe | |
Georg Krause | daf9e80ca5 | |
Georg Krause | b4f18edaff | |
Georg Krause | fa6d48f1b7 | |
Georg Krause | 8f3ab416ae | |
jo | cd9d6d696e | |
Baudouin Feildel | 2c90b32bb3 | |
Baudouin Feildel | e96748c029 | |
Georg Krause | d12ca2bad8 | |
Philipp Wolfer | 332ae20f05 | |
Georg Krause | 736625e235 | |
Georg Krause | 33cd0f05a7 | |
Georg Krause | 06d135875b | |
Bruno-Van-den-Bosch | de41545ab3 | |
Maksim Kliazovich | 5ce00a9230 | |
Thomas | d112d82768 | |
Thomas | 03e9be77f9 | |
Thomas | b6bcc88287 | |
Thomas | 4677b9117d | |
Thomas | bc573e47bc | |
Thomas | 9a5a749171 | |
mittwerk | de60ca7309 | |
josé m | 5693d0f86d | |
Thomas | 22084cbca7 | |
Georg Krause | 731ee7c21e | |
Georg Krause | afea533aed | |
Georg Krause | 8a6b19fb6f | |
Georg Krause | 0eec47e493 | |
Georg Krause | 4f9280bd2c | |
Renovate Bot | 2ac4e25fce | |
Georg Krause | 295b0dcc3a | |
Ciarán Ainsworth | ab0efa3edf | |
Ciarán Ainsworth | 587bbc1118 | |
Ciarán Ainsworth | b8978021c0 | |
Georg Krause | 349610bbeb | |
Ciarán Ainsworth | 65f13a379f | |
Ciarán Ainsworth | ba53d03ac5 | |
Ciarán Ainsworth | cb65ee69e1 | |
Ciarán Ainsworth | 65728c81c4 | |
Matteo Piovanelli | 5b022d94d1 | |
Georg Krause | 21ff5f65da | |
Georg Krause | d8c734d3cd | |
Georg Krause | b1f3a62fae | |
Georg Krause | 20cfaa8dc9 | |
Georg Krause | 038b696e75 | |
Georg Krause | 59687b2f32 | |
Thomas | da71fb640d | |
Thomas | 09facc553d | |
Georg Krause | da01070455 | |
Georg Krause | b00daa189d | |
drakonicguy | aa0ce033aa | |
Georg Krause | cc2272bb80 | |
Matteo Piovanelli | f0e79b4a0a | |
Aznörth Niryn | 9da91df798 | |
Aitor | 807a6fd02c | |
Ciarán Ainsworth | 517d99f9bf | |
Georg Krause | 6ab1dc0536 | |
Georg Krause | 803eb85b67 | |
Georg Krause | 6fcae233df | |
Georg Krause | bf43b95208 | |
Georg Krause | d721a3808b | |
Georg Krause | d22a911619 | |
Georg Krause | 7c52227d43 | |
Georg Krause | 58e2c896b2 | |
Georg Krause | 91b85cab46 | |
Georg Krause | bc15de7556 | |
Georg Krause | f99de1ef97 | |
Georg Krause | 5cc0219196 | |
josé m | 369b80bb1c | |
Thomas | 60db27dfba | |
Aznörth Niryn | efffeac280 | |
Thomas | d112ea4bc6 | |
Aznörth Niryn | b8ed2ccd5c | |
Quentin PAGÈS | ab15803be0 | |
Quentin PAGÈS | e282422592 | |
omarmaciasmolina | 96d25ff25d | |
rinenweb | 8645180620 | |
Jérémie Lorente | 142a517b93 | |
dignny | 233d17d287 | |
Aznörth Niryn | 630ba7262a | |
dignny | 0b78affdcd | |
Transcriber allium | 41dbf62356 | |
Matyáš Caras | 6b6ba94291 | |
josé m | 9eda066a39 | |
Aznörth Niryn | 4cf2d68a4f | |
Renovate Bot | a19b459533 | |
Renovate Bot | e3206e2122 | |
Renovate Bot | ba3300a682 | |
Renovate Bot | c6aec56e71 | |
Renovate Bot | 02fd31d321 | |
Renovate Bot | 07f665cb8b | |
Renovate Bot | 0b03bd6c89 | |
Renovate Bot | 2aa301387c | |
Renovate Bot | 46531884b3 | |
Renovate Bot | 6234dfd2a7 | |
Renovate Bot | 1c93460ffb | |
Renovate Bot | b6c906bf7c | |
Renovate Bot | 793fc31e13 | |
Georg Krause | 80b4906438 | |
Renovate Bot | e11a6cea02 | |
Renovate Bot | b46aa638bc | |
Ciarán Ainsworth | 17e08fd332 | |
Georg Krause | 86ce4cfd7c | |
Georg Krause | b21e241f37 | |
Renovate Bot | 08bfc93243 | |
Ciarán Ainsworth | 4cbce95bcb | |
Georg Krause | 3ee6ba6658 | |
Thomas | 259fb1b61d | |
Thomas | 516c281a57 | |
Thomas | d842243b3c | |
Thomas | a4ea1a06b9 | |
Thomas | d44c29bedb | |
Thomas | 6e46660d70 | |
Thomas | 32db5e92a3 | |
Thomas | ba365d6722 | |
Thomas | fd44d0bf12 | |
Thomas | 70c0a038fc | |
Thomas | 06e49598a3 | |
Thomas | 779a3ee717 | |
Thomas | 92f73b1755 | |
Thomas | f34eb14c9a | |
Thomas | 358ce509a5 | |
Thomas | 65ebb8d90e | |
Thomas | 499e1a8354 | |
Thomas | 8de3c1489d | |
Thomas | 11f7fa25ae | |
Thomas | 1ccf18412f | |
Thomas | 1061275487 | |
Thomas | af592d99c2 | |
Thomas | d1dd0bebcf | |
Renovate Bot | 9da463e69d | |
Renovate Bot | 1ee1c88ed1 | |
Renovate Bot | e38808e2ce | |
Renovate Bot | 2edbc6c98f | |
Georg Krause | bfa50a0c35 | |
Georg Krause | 74b2593cb2 | |
Georg Krause | cc2ff8ae88 | |
Georg Krause | 9dbbe9e768 | |
Georg Krause | 0840aeb943 | |
Georg Krause | 362aa9db3e | |
Georg Krause | 150a9f68a4 | |
Georg Krause | 0c2f9c8dbb | |
Georg Krause | 69876867d5 | |
Ciarán Ainsworth | 76362b020e | |
Ciarán Ainsworth | b74a873b4a | |
Renovate Bot | dfb893e63b | |
Ciarán Ainsworth | 4740df9d3c | |
Georg Krause | 43c2861252 | |
Georg Krause | 3db367f4bc | |
Ciarán Ainsworth | b6190540ee | |
Georg Krause | 6157df5552 | |
Ciarán Ainsworth | eb0c644b93 | |
Ciarán Ainsworth | 08c142cfff | |
Georg Krause | a0ae9bbb70 | |
Georg Krause | 71140d5a9b | |
Georg Krause | 1a0596b102 | |
Georg Krause | 523245d035 | |
Georg Krause | a05b44f27b | |
Georg Krause | e3a28aaeb3 | |
Renovate Bot | dd4d191767 | |
Ciarán Ainsworth | 4cfa3a4f71 | |
Georg Krause | 88d7bdb8ab | |
Georg Krause | abf1306e2f | |
Georg Krause | 346d4e9639 | |
Ciarán Ainsworth | f769c8ce68 | |
Ciarán Ainsworth | a7c76279f6 | |
Ciarán Ainsworth | e2a0697529 | |
Petitminion | 7bf1d95d8e | |
Petitminion | 363a4b5d35 | |
Petitminion | ccb9987a95 | |
Petitminion | b6b0b22f6c | |
Petitminion | 179c53695e | |
Petitminion | d3b27b4ba9 | |
Petitminion | 6dea3f3cf8 | |
Petitminion | 6e3185f653 | |
Petitminion | df6f2d919d | |
Petitminion | 2e3205a19d | |
Ciarán Ainsworth | 169cd69a46 | |
Ciarán Ainsworth | 94c96e3045 | |
Ciarán Ainsworth | b345d4d429 | |
Ciarán Ainsworth | b11b0dfd52 | |
Alexander Dunkel | 048b20130f | |
Alexander Dunkel | 96b74d2984 | |
Alexander Dunkel | ce4b576b86 | |
Alexander Dunkel | 58fe1c4e57 | |
Renovate Bot | 739e5fa3b7 | |
Ciarán Ainsworth | defc5931c6 | |
Ciarán Ainsworth | 82a0a040d2 | |
Ciarán Ainsworth | 0a12fedaff | |
Ciarán Ainsworth | e5bd8a0560 | |
Ciarán Ainsworth | 95c8e798ab | |
Ciarán Ainsworth | 473cc1be25 | |
Ciarán Ainsworth | 3d5381760f | |
Georg Krause | 7ac6447308 | |
Georg Krause | 64b3fdf273 | |
Renovate Bot | 376e1fb019 | |
Renovate Bot | 65d36e59fa | |
Renovate Bot | 3b287b1d37 | |
Renovate Bot | d0dc7d2232 | |
Georg Krause | 8f354135b5 | |
Renovate Bot | 28989d8ed6 | |
Renovate Bot | d6e5ba8acd | |
Petitminion | 4e79362aef | |
Ciarán Ainsworth | 18136c7ae4 | |
Ciarán Ainsworth | 7f12f5f9c3 | |
Ciarán Ainsworth | 8f4251bb6e | |
Georg Krause | 66bd79b613 | |
Georg Krause | 1933a06cc0 | |
Georg Krause | b05bce3b37 | |
Georg Krause | debd334b38 | |
Ciarán Ainsworth | 935aa257b8 | |
Ciarán Ainsworth | 10ba5d02e7 | |
Ciarán Ainsworth | e120fc6815 | |
Georg Krause | a54522eac2 | |
Renovate Bot | 225d55924f | |
Renovate Bot | abb78a47e6 | |
Georg Krause | 145ca4a1e7 | |
Georg Krause | ab73f355c0 | |
Georg Krause | 8485e4b162 | |
Ciarán Ainsworth | 57ae3fae3c | |
Ciarán Ainsworth | 0b91d0d7dc | |
Ciarán Ainsworth | 384a4d1974 | |
Ciarán Ainsworth | 3a5090a85c | |
Renovate Bot | cfb5850f7f | |
Georg Krause | c69a48c457 | |
petitminion | 7ccb2d88f8 | |
Georg Krause | 7ecd3e6767 | |
jooola | bb1c6d935a | |
Georg Krause | 623d1571ee | |
Georg Krause | a752a83ac0 | |
Georg Krause | 163e9310fc | |
Georg Krause | 58a5733987 | |
Georg Krause | 47efcb4b5a | |
Georg Krause | e0f6641bba | |
Georg Krause | 73364145c3 | |
jo | fe47420ba1 | |
jo | c5dd88a2e2 | |
Georg Krause | accf261683 | |
Georg Krause | 9cd2f30129 | |
Mathieu Jourdan | a756a5f920 | |
Ciarán Ainsworth | b70cabccdf | |
Ciarán Ainsworth | 1a04a84ec3 | |
Ciarán Ainsworth | c0d6c7ee74 | |
Georg Krause | 5eda0def09 | |
Georg Krause | 40cc9afb65 | |
Georg Krause | 9d23d10e23 | |
Renovate Bot | ab7fe55b51 | |
Renovate Bot | ef827f22e5 | |
Renovate Bot | 973ba97980 | |
alextprog | ccec8288ef | |
Georg Krause | eae91ab016 | |
Renovate Bot | 2f1f7bcf95 | |
Renovate Bot | 433c9c78e8 | |
Renovate Bot | d9161a5088 | |
Renovate Bot | 3e9c0f80c6 | |
Renovate Bot | cef09e877b | |
Renovate Bot | 4ea74750ff | |
Renovate Bot | 10b85fd638 |
|
@ -7,7 +7,9 @@ nd
|
||||||
readby
|
readby
|
||||||
serie
|
serie
|
||||||
upto
|
upto
|
||||||
|
afterall
|
||||||
|
|
||||||
# Names
|
# Names
|
||||||
nin
|
nin
|
||||||
noe
|
noe
|
||||||
|
manuel
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
/dist
|
||||||
|
|
||||||
### OSX ###
|
### OSX ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.AppleDouble
|
.AppleDouble
|
||||||
|
@ -83,8 +85,12 @@ front/yarn-debug.log*
|
||||||
front/yarn-error.log*
|
front/yarn-error.log*
|
||||||
front/tests/unit/coverage
|
front/tests/unit/coverage
|
||||||
front/tests/e2e/reports
|
front/tests/e2e/reports
|
||||||
|
front/test_results.xml
|
||||||
|
front/coverage/
|
||||||
front/selenium-debug.log
|
front/selenium-debug.log
|
||||||
docs/_build
|
docs/_build
|
||||||
|
#Tauri
|
||||||
|
front/tauri/gen
|
||||||
|
|
||||||
/data/
|
/data/
|
||||||
.env
|
.env
|
||||||
|
@ -104,3 +110,9 @@ tsconfig.tsbuildinfo
|
||||||
|
|
||||||
# Vscode
|
# Vscode
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
.direnv/
|
||||||
|
.envrc
|
||||||
|
flake.nix
|
||||||
|
flake.lock
|
||||||
|
|
|
@ -118,21 +118,16 @@ review_docs:
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
changes: [docs/**/*]
|
changes: [docs/**/*]
|
||||||
|
|
||||||
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:3.11
|
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-docs:3.11
|
||||||
variables:
|
|
||||||
BUILD_PATH: "../docs-review"
|
|
||||||
environment:
|
environment:
|
||||||
name: review/docs/$CI_COMMIT_REF_NAME
|
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
|
url: http://$CI_PROJECT_NAMESPACE.pages.funkwhale.audio/-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/docs-review/index.html
|
||||||
cache: *docs_cache
|
cache: *docs_cache
|
||||||
before_script:
|
before_script:
|
||||||
- mkdir docs-review
|
|
||||||
- cd docs
|
- cd docs
|
||||||
- apt-get update
|
- make install
|
||||||
- apt-get install -y graphviz
|
|
||||||
- poetry install
|
|
||||||
script:
|
script:
|
||||||
- poetry run python3 -m sphinx . $BUILD_PATH
|
- make build BUILD_DIR=../docs-review
|
||||||
artifacts:
|
artifacts:
|
||||||
expire_in: 2 weeks
|
expire_in: 2 weeks
|
||||||
paths:
|
paths:
|
||||||
|
@ -149,7 +144,6 @@ find_broken_links:
|
||||||
--cache
|
--cache
|
||||||
--no-progress
|
--no-progress
|
||||||
--exclude-all-private
|
--exclude-all-private
|
||||||
--exclude-mail
|
|
||||||
--exclude 'demo\.funkwhale\.audio'
|
--exclude 'demo\.funkwhale\.audio'
|
||||||
--exclude 'nginx\.com'
|
--exclude 'nginx\.com'
|
||||||
--exclude-path 'docs/_templates/'
|
--exclude-path 'docs/_templates/'
|
||||||
|
@ -236,7 +230,7 @@ test_api:
|
||||||
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:$PYTHON_VERSION
|
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:$PYTHON_VERSION
|
||||||
parallel:
|
parallel:
|
||||||
matrix:
|
matrix:
|
||||||
- PYTHON_VERSION: ["3.8", "3.9", "3.10", "3.11"]
|
- PYTHON_VERSION: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
services:
|
services:
|
||||||
- name: postgres:15-alpine
|
- name: postgres:15-alpine
|
||||||
command:
|
command:
|
||||||
|
@ -253,7 +247,7 @@ test_api:
|
||||||
CACHE_URL: "redis://redis:6379/0"
|
CACHE_URL: "redis://redis:6379/0"
|
||||||
before_script:
|
before_script:
|
||||||
- cd api
|
- cd api
|
||||||
- poetry install --all-extras
|
- make install
|
||||||
script:
|
script:
|
||||||
- >
|
- >
|
||||||
poetry run pytest
|
poetry run pytest
|
||||||
|
@ -293,6 +287,7 @@ test_front:
|
||||||
coverage_report:
|
coverage_report:
|
||||||
coverage_format: cobertura
|
coverage_format: cobertura
|
||||||
path: front/coverage/cobertura-coverage.xml
|
path: front/coverage/cobertura-coverage.xml
|
||||||
|
coverage: '/All files\s+(?:\|\s+((?:\d+\.)?\d+)\s+){4}.*/'
|
||||||
|
|
||||||
build_metadata:
|
build_metadata:
|
||||||
stage: build
|
stage: build
|
||||||
|
@ -317,7 +312,9 @@ test_integration:
|
||||||
- if: $RUN_CYPRESS
|
- if: $RUN_CYPRESS
|
||||||
interruptible: true
|
interruptible: true
|
||||||
|
|
||||||
image: cypress/base:18.12.1
|
image:
|
||||||
|
name: cypress/included:13.6.4
|
||||||
|
entrypoint: [""]
|
||||||
cache:
|
cache:
|
||||||
- *front_cache
|
- *front_cache
|
||||||
- key:
|
- key:
|
||||||
|
@ -354,7 +351,7 @@ build_api_schema:
|
||||||
API_TYPE: "v1"
|
API_TYPE: "v1"
|
||||||
before_script:
|
before_script:
|
||||||
- cd api
|
- cd api
|
||||||
- poetry install --all-extras
|
- make install
|
||||||
- poetry run funkwhale-manage migrate
|
- poetry run funkwhale-manage migrate
|
||||||
script:
|
script:
|
||||||
- poetry run funkwhale-manage spectacular --file ../docs/schema.yml
|
- poetry run funkwhale-manage spectacular --file ../docs/schema.yml
|
||||||
|
@ -372,19 +369,13 @@ build_docs:
|
||||||
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
|
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
|
||||||
- changes: [docs/**/*]
|
- changes: [docs/**/*]
|
||||||
|
|
||||||
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:3.11
|
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-docs:3.11
|
||||||
variables:
|
|
||||||
BUILD_PATH: "../public"
|
|
||||||
GIT_STRATEGY: clone
|
|
||||||
GIT_DEPTH: 0
|
|
||||||
cache: *docs_cache
|
cache: *docs_cache
|
||||||
before_script:
|
before_script:
|
||||||
- cd docs
|
- cd docs
|
||||||
- apt-get update
|
- make install
|
||||||
- apt-get install -y graphviz
|
|
||||||
- poetry install
|
|
||||||
script:
|
script:
|
||||||
- ./build_docs.sh
|
- make build-all BUILD_DIR=../public
|
||||||
artifacts:
|
artifacts:
|
||||||
expire_in: 2 weeks
|
expire_in: 2 weeks
|
||||||
paths:
|
paths:
|
||||||
|
@ -439,6 +430,25 @@ build_api:
|
||||||
paths:
|
paths:
|
||||||
- api
|
- 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:
|
deploy_docs:
|
||||||
interruptible: false
|
interruptible: false
|
||||||
extends: .ssh-agent
|
extends: .ssh-agent
|
||||||
|
@ -471,22 +481,23 @@ docker:
|
||||||
variables:
|
variables:
|
||||||
BUILD_ARGS: >
|
BUILD_ARGS: >
|
||||||
--set *.platform=linux/amd64,linux/arm64,linux/arm/v7
|
--set *.platform=linux/amd64,linux/arm64,linux/arm/v7
|
||||||
--set *.no-cache
|
--no-cache
|
||||||
--push
|
--push
|
||||||
|
|
||||||
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
|
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
|
||||||
variables:
|
variables:
|
||||||
BUILD_ARGS: >
|
BUILD_ARGS: >
|
||||||
--set *.platform=linux/amd64,linux/arm64,linux/arm/v7
|
--set *.platform=linux/amd64,linux/arm64,linux/arm/v7
|
||||||
--set *.cache-from=type=registry,ref=$DOCKER_CACHE_IMAGE:$CI_COMMIT_BRANCH
|
--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
|
--set *.cache-to=type=registry,ref=$DOCKER_CACHE_IMAGE:$CI_COMMIT_BRANCH,mode=max,oci-mediatypes=false
|
||||||
--push
|
--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:
|
variables:
|
||||||
BUILD_ARGS: >
|
BUILD_ARGS: >
|
||||||
--set *.platform=linux/amd64
|
--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
|
image: $CI_REGISTRY/funkwhale/ci/docker:20
|
||||||
services:
|
services:
|
||||||
|
@ -517,3 +528,24 @@ docker:
|
||||||
name: docker_metadata_${CI_COMMIT_REF_NAME}
|
name: docker_metadata_${CI_COMMIT_REF_NAME}
|
||||||
paths:
|
paths:
|
||||||
- metadata.json
|
- 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
|
||||||
|
|
|
@ -25,6 +25,16 @@
|
||||||
"branchConcurrentLimit": 0,
|
"branchConcurrentLimit": 0,
|
||||||
"prConcurrentLimit": 0
|
"prConcurrentLimit": 0
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"matchBaseBranches": ["develop"],
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"prPriority": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchBaseBranches": ["develop"],
|
||||||
|
"matchUpdateTypes": ["minor"],
|
||||||
|
"prPriority": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": ["major", "minor"],
|
"matchUpdateTypes": ["major", "minor"],
|
||||||
"matchBaseBranches": ["stable"],
|
"matchBaseBranches": ["stable"],
|
||||||
|
@ -35,12 +45,6 @@
|
||||||
"matchBaseBranches": ["stable"],
|
"matchBaseBranches": ["stable"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"matchUpdateTypes": ["patch", "pin", "digest"],
|
|
||||||
"matchBaseBranches": ["develop"],
|
|
||||||
"automerge": true,
|
|
||||||
"automergeType": "branch"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
"addLabels": ["Area::Frontend"]
|
"addLabels": ["Area::Frontend"]
|
||||||
|
@ -70,6 +74,10 @@
|
||||||
],
|
],
|
||||||
"fileFilters": ["changes/changelog.d/postgres.update"]
|
"fileFilters": ["changes/changelog.d/postgres.update"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["python"],
|
||||||
|
"rangeStrategy": "widen"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
38
.gitpod.yml
38
.gitpod.yml
|
@ -14,11 +14,12 @@ tasks:
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
poetry env use python
|
poetry env use python
|
||||||
poetry install
|
make install
|
||||||
|
|
||||||
gp ports await 5432
|
gp ports await 5432
|
||||||
|
|
||||||
poetry run funkwhale-manage migrate
|
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
|
poetry run funkwhale-manage gitpod init
|
||||||
command: |
|
command: |
|
||||||
echo "MEDIA_URL=`gp url 8000`/media/" >> ../.gitpod/.env
|
echo "MEDIA_URL=`gp url 8000`/media/" >> ../.gitpod/.env
|
||||||
|
@ -47,49 +48,66 @@ tasks:
|
||||||
yarn install
|
yarn install
|
||||||
command: yarn dev --host 0.0.0.0 --base ./
|
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!
|
- name: Welcome to Funkwhale development!
|
||||||
env:
|
env:
|
||||||
COMPOSE_FILE: /workspace/funkwhale/.gitpod/docker-compose.yml
|
COMPOSE_FILE: /workspace/funkwhale/.gitpod/docker-compose.yml
|
||||||
ENV_FILE: /workspace/funkwhale/.gitpod/.env
|
ENV_FILE: /workspace/funkwhale/.gitpod/.env
|
||||||
VUE_EDITOR: code
|
VUE_EDITOR: code
|
||||||
DJANGO_SETTINGS_MODULE: config.settings.local
|
DJANGO_SETTINGS_MODULE: config.settings.local
|
||||||
init: pre-commit install
|
init: |
|
||||||
|
pre-commit install
|
||||||
|
pre-commit run --all
|
||||||
command: |
|
command: |
|
||||||
pre-commit run --all && clear
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ⠀⠀⠸⣿⣷⣦⣄⣠⣶⣾⣿⠇⠀⠀ You can now start developing Funkwhale with gitpod!"
|
echo -e " ⠀⠀⠸⣿⣷⣦⣄⣠⣶⣾⣿⠇⠀⠀ You can now start developing Funkwhale with gitpod!"
|
||||||
echo -e " ⠀⠀⠀⠈⠉⠻⣿⣿⠟⠉⠁⠀⠀⠀"
|
echo -e " ⠀⠀⠀⠈⠉⠻⣿⣿⠟⠉⠁⠀⠀⠀"
|
||||||
echo -e " \u1b[34m⣀⠀⢀⡀⢀⣀\u1b[0m⠹⠇\u1b[34m⣀⡀⢀⡀⠀⣀ \u1b[0mTo sign in to the superuser account,"
|
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⢻⣇⠘⣧⡈⠻⠶⠶⠟⢁⣾⠃⣸⡟ \u1b[0mplease use these credentials:"
|
||||||
echo -e " \u1b[34m⠀⠻⣦⡈⠻⠶⣶⣶⠶⠟⢁⣴⠟⠀"
|
echo -e " \u1b[34m⠀⠻⣦⡈⠻⠶⣶⣶⠶⠟⢁⣴⠟⠀"
|
||||||
echo -e " \u1b[34m⠀⠀⠈⠻⠷⣦⣤⣤⣴⠾⠟⠁⠀⠀ gitpod\u1b[0m:\u1b[34mgitpod"
|
echo -e " \u1b[34m⠀⠀⠈⠻⠷⣦⣤⣤⣴⠾⠟⠁⠀⠀ gitpod\u1b[0m:\u1b[34mfunkwhale"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- port: 8000
|
- name: Funkwhale
|
||||||
|
port: 8000
|
||||||
visibility: public
|
visibility: public
|
||||||
onOpen: notify
|
onOpen: notify
|
||||||
|
|
||||||
- port: 5000
|
- name: Funkwhale API
|
||||||
|
port: 5000
|
||||||
visibility: private
|
visibility: private
|
||||||
onOpen: ignore
|
onOpen: ignore
|
||||||
|
|
||||||
- port: 5432
|
- name: PostgreSQL
|
||||||
|
port: 5432
|
||||||
visibility: private
|
visibility: private
|
||||||
onOpen: ignore
|
onOpen: ignore
|
||||||
|
|
||||||
- port: 5678
|
- name: Debugpy
|
||||||
|
port: 5678
|
||||||
visibility: private
|
visibility: private
|
||||||
onOpen: ignore
|
onOpen: ignore
|
||||||
|
|
||||||
- port: 6379
|
- name: Redis
|
||||||
|
port: 6379
|
||||||
visibility: private
|
visibility: private
|
||||||
onOpen: ignore
|
onOpen: ignore
|
||||||
|
|
||||||
- port: 8080
|
- name: Frontend
|
||||||
|
port: 8080
|
||||||
visibility: private
|
visibility: private
|
||||||
onOpen: ignore
|
onOpen: ignore
|
||||||
|
|
||||||
|
- name: Documentation
|
||||||
|
port: 8001
|
||||||
|
visibility: public
|
||||||
|
onOpen: notify
|
||||||
|
|
||||||
vscode:
|
vscode:
|
||||||
extensions:
|
extensions:
|
||||||
- Vue.volar
|
- Vue.volar
|
||||||
|
|
|
@ -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
|
USER gitpod
|
||||||
|
|
||||||
RUN sudo apt update -y \
|
RUN sudo apt update -y \
|
||||||
&& sudo apt install libsasl2-dev libldap2-dev libssl-dev ffmpeg gettext -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.create true \
|
||||||
&& poetry config virtualenvs.in-project true
|
&& poetry config virtualenvs.in-project true
|
||||||
|
|
|
@ -18,7 +18,6 @@ services:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
command: /entrypoint.sh
|
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
image: nginx
|
image: nginx
|
||||||
|
@ -29,15 +28,16 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- "NGINX_MAX_BODY_SIZE=100M"
|
- "NGINX_MAX_BODY_SIZE=100M"
|
||||||
- "FUNKWHALE_API_IP=host.docker.internal"
|
- "FUNKWHALE_API_IP=host.docker.internal"
|
||||||
|
- "FUNKWHALE_API_HOST=host.docker.internal"
|
||||||
- "FUNKWHALE_API_PORT=5000"
|
- "FUNKWHALE_API_PORT=5000"
|
||||||
- "FUNKWHALE_FRONT_IP=host.docker.internal"
|
- "FUNKWHALE_FRONT_IP=host.docker.internal"
|
||||||
- "FUNKWHALE_FRONT_PORT=8080"
|
- "FUNKWHALE_FRONT_PORT=8080"
|
||||||
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-host.docker.internal}"
|
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-host.docker.internal}"
|
||||||
|
- "FUNKWHALE_PROTOCOL=https"
|
||||||
volumes:
|
volumes:
|
||||||
- ../data/media:/protected/media:ro
|
- ../data/media:/workspace/funkwhale/data/media:ro
|
||||||
- ../data/music:/music: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
|
- ../deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro
|
||||||
- ../docker/nginx/conf.dev:/etc/nginx/nginx.conf.template:ro
|
- ../docker/nginx/conf.dev:/etc/nginx/templates/default.conf.template:ro
|
||||||
- ../docker/nginx/entrypoint.sh:/entrypoint.sh:ro
|
|
||||||
- ../front:/frontend:ro
|
- ../front:/frontend:ro
|
||||||
|
|
|
@ -53,18 +53,18 @@ repos:
|
||||||
- id: isort
|
- id: isort
|
||||||
|
|
||||||
- repo: https://github.com/pycqa/flake8
|
- repo: https://github.com/pycqa/flake8
|
||||||
rev: 6.0.0
|
rev: 6.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v3.0.2
|
rev: v3.0.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
files: \.(md|yml|yaml|json)$
|
files: \.(md|yml|yaml|json)$
|
||||||
|
|
||||||
- repo: https://github.com/codespell-project/codespell
|
- repo: https://github.com/codespell-project/codespell
|
||||||
rev: v2.2.5
|
rev: v2.2.6
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
|
|
3529
CHANGELOG.md
3529
CHANGELOG.md
Plik diff jest za duży
Load Diff
38
Makefile
38
Makefile
|
@ -17,3 +17,41 @@ docker-build: docker-metadata
|
||||||
|
|
||||||
build-metadata:
|
build-metadata:
|
||||||
./scripts/build_metadata.py --format env | tee build_metadata.env
|
./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
|
||||||
|
|
|
@ -23,4 +23,4 @@ If you find a security issue or vulnerability, please report it on our [GitLab i
|
||||||
|
|
||||||
## Code of conduct
|
## 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.
|
||||||
|
|
|
@ -1,8 +1,4 @@
|
||||||
FROM alpine:3.17 as requirements
|
FROM alpine:3.19 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.
|
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
apk add --no-cache \
|
apk add --no-cache \
|
||||||
|
@ -16,7 +12,7 @@ RUN set -eux; \
|
||||||
poetry export --without-hashes --extras typesense > requirements.txt; \
|
poetry export --without-hashes --extras typesense > requirements.txt; \
|
||||||
poetry export --without-hashes --with dev > dev-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 PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
@ -41,11 +37,11 @@ RUN set -eux; \
|
||||||
openssl-dev \
|
openssl-dev \
|
||||||
postgresql-dev \
|
postgresql-dev \
|
||||||
zlib-dev \
|
zlib-dev \
|
||||||
py3-cryptography=38.0.3-r1 \
|
py3-cryptography=41.0.7-r0 \
|
||||||
py3-lxml=4.9.3-r1 \
|
py3-lxml=4.9.3-r1 \
|
||||||
py3-pillow=9.3.0-r0 \
|
py3-pillow=10.3.0-r0 \
|
||||||
py3-psycopg2=2.9.5-r0 \
|
py3-psycopg2=2.9.9-r0 \
|
||||||
py3-watchfiles=0.18.1-r0 \
|
py3-watchfiles=0.19.0-r1 \
|
||||||
python3-dev
|
python3-dev
|
||||||
|
|
||||||
# Create virtual env
|
# Create virtual env
|
||||||
|
@ -65,11 +61,11 @@ RUN --mount=type=cache,target=~/.cache/pip; \
|
||||||
# to install the deps using pip.
|
# to install the deps using pip.
|
||||||
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles' /requirements.txt \
|
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles' /requirements.txt \
|
||||||
| pip3 install -r /dev/stdin \
|
| pip3 install -r /dev/stdin \
|
||||||
cryptography==38.0.3 \
|
cryptography==41.0.7 \
|
||||||
lxml==4.9.3 \
|
lxml==4.9.3 \
|
||||||
pillow==9.3.0 \
|
pillow==10.2.0 \
|
||||||
psycopg2==2.9.5 \
|
psycopg2==2.9.9 \
|
||||||
watchfiles==0.18.1
|
watchfiles==0.19.0
|
||||||
|
|
||||||
ARG install_dev_deps=0
|
ARG install_dev_deps=0
|
||||||
RUN --mount=type=cache,target=~/.cache/pip; \
|
RUN --mount=type=cache,target=~/.cache/pip; \
|
||||||
|
@ -77,14 +73,14 @@ RUN --mount=type=cache,target=~/.cache/pip; \
|
||||||
if [ "$install_dev_deps" = "1" ] ; then \
|
if [ "$install_dev_deps" = "1" ] ; then \
|
||||||
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles' /dev-requirements.txt \
|
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles' /dev-requirements.txt \
|
||||||
| pip3 install -r /dev/stdin \
|
| pip3 install -r /dev/stdin \
|
||||||
cryptography==38.0.3 \
|
cryptography==41.0.7 \
|
||||||
lxml==4.9.3 \
|
lxml==4.9.3 \
|
||||||
pillow==9.3.0 \
|
pillow==10.2.0 \
|
||||||
psycopg2==2.9.5 \
|
psycopg2==2.9.9 \
|
||||||
watchfiles==0.18.1; \
|
watchfiles==0.19.0; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM alpine:3.17 as production
|
FROM alpine:3.19 as production
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
@ -101,11 +97,11 @@ RUN set -eux; \
|
||||||
libpq \
|
libpq \
|
||||||
libxml2 \
|
libxml2 \
|
||||||
libxslt \
|
libxslt \
|
||||||
py3-cryptography=38.0.3-r1 \
|
py3-cryptography=41.0.7-r0 \
|
||||||
py3-lxml=4.9.3-r1 \
|
py3-lxml=4.9.3-r1 \
|
||||||
py3-pillow=9.3.0-r0 \
|
py3-pillow=10.3.0-r0 \
|
||||||
py3-psycopg2=2.9.5-r0 \
|
py3-psycopg2=2.9.9-r0 \
|
||||||
py3-watchfiles=0.18.1-r0 \
|
py3-watchfiles=0.19.0-r1 \
|
||||||
python3 \
|
python3 \
|
||||||
tzdata
|
tzdata
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ CPU_CORES := $(shell N=$$(nproc); echo $$(( $$N > 4 ? 4 : $$N )))
|
||||||
.PHONY: install lint
|
.PHONY: install lint
|
||||||
|
|
||||||
install:
|
install:
|
||||||
poetry install
|
poetry install --all-extras
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
poetry run pylint \
|
poetry run pylint \
|
||||||
|
|
|
@ -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"])
|
|
|
@ -303,6 +303,23 @@ LISTENING_CREATED = "listening_created"
|
||||||
"""
|
"""
|
||||||
Called when a track is being listened
|
Called when a track is being listened
|
||||||
"""
|
"""
|
||||||
|
LISTENING_SYNC = "listening_sync"
|
||||||
|
"""
|
||||||
|
Called by the task manager to trigger listening sync
|
||||||
|
"""
|
||||||
|
FAVORITE_CREATED = "favorite_created"
|
||||||
|
"""
|
||||||
|
Called when a track is being favorited
|
||||||
|
"""
|
||||||
|
FAVORITE_DELETED = "favorite_deleted"
|
||||||
|
"""
|
||||||
|
Called when a favorited track is being unfavorited
|
||||||
|
"""
|
||||||
|
FAVORITE_SYNC = "favorite_sync"
|
||||||
|
"""
|
||||||
|
Called by the task manager to trigger favorite sync
|
||||||
|
"""
|
||||||
|
|
||||||
SCAN = "scan"
|
SCAN = "scan"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from channels.auth import AuthMiddlewareStack
|
from channels.auth import AuthMiddlewareStack
|
||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
from django.conf.urls import url
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
from funkwhale_api.instance import consumers
|
from funkwhale_api.instance import consumers
|
||||||
|
|
||||||
|
@ -10,7 +10,12 @@ application = ProtocolTypeRouter(
|
||||||
# Empty for now (http->django views is added by default)
|
# Empty for now (http->django views is added by default)
|
||||||
"websocket": AuthMiddlewareStack(
|
"websocket": AuthMiddlewareStack(
|
||||||
URLRouter(
|
URLRouter(
|
||||||
[url("^api/v1/activity$", consumers.InstanceActivityConsumer.as_asgi())]
|
[
|
||||||
|
re_path(
|
||||||
|
"^api/v1/activity$",
|
||||||
|
consumers.InstanceActivityConsumer.as_asgi(),
|
||||||
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"http": get_asgi_application(),
|
"http": get_asgi_application(),
|
||||||
|
|
|
@ -2,7 +2,7 @@ import logging.config
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlparse, urlsplit
|
||||||
|
|
||||||
import environ
|
import environ
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
@ -13,7 +13,29 @@ APPS_DIR = ROOT_DIR.path("funkwhale_api")
|
||||||
|
|
||||||
env = environ.Env()
|
env = environ.Env()
|
||||||
ENV = 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)
|
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"))
|
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 = None
|
||||||
logging.config.dictConfig(
|
logging.config.dictConfig(
|
||||||
{
|
{
|
||||||
|
@ -187,9 +196,7 @@ request errors related to this.
|
||||||
FUNKWHALE_SPA_HTML_CACHE_DURATION = env.int(
|
FUNKWHALE_SPA_HTML_CACHE_DURATION = env.int(
|
||||||
"FUNKWHALE_SPA_HTML_CACHE_DURATION", default=60 * 15
|
"FUNKWHALE_SPA_HTML_CACHE_DURATION", default=60 * 15
|
||||||
)
|
)
|
||||||
FUNKWHALE_EMBED_URL = env(
|
FUNKWHALE_EMBED_URL = env("FUNKWHALE_EMBED_URL", default=FUNKWHALE_URL + "/embed.html")
|
||||||
"FUNKWHALE_EMBED_URL", default=FUNKWHALE_URL + "/front/embed.html"
|
|
||||||
)
|
|
||||||
FUNKWHALE_SPA_REWRITE_MANIFEST = env.bool(
|
FUNKWHALE_SPA_REWRITE_MANIFEST = env.bool(
|
||||||
"FUNKWHALE_SPA_REWRITE_MANIFEST", default=True
|
"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.
|
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
|
# APP CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
DJANGO_APPS = (
|
DJANGO_APPS = (
|
||||||
|
@ -262,6 +276,7 @@ LOCAL_APPS = (
|
||||||
# Your stuff: custom apps go here
|
# Your stuff: custom apps go here
|
||||||
"funkwhale_api.instance",
|
"funkwhale_api.instance",
|
||||||
"funkwhale_api.audio",
|
"funkwhale_api.audio",
|
||||||
|
"funkwhale_api.contrib.listenbrainz",
|
||||||
"funkwhale_api.music",
|
"funkwhale_api.music",
|
||||||
"funkwhale_api.requests",
|
"funkwhale_api.requests",
|
||||||
"funkwhale_api.favorites",
|
"funkwhale_api.favorites",
|
||||||
|
@ -823,7 +838,7 @@ If you're using password auth (the extra slash is important)
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
If you want to use Redis over unix sockets, you also need to update
|
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`.
|
:attr:`CACHE_URL`.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@ -874,7 +889,7 @@ to use a different server or use Redis sockets to connect.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
- ``redis://127.0.0.1:6379/0``
|
- ``unix://127.0.0.1:6379/0``
|
||||||
- ``redis+socket:///run/redis/redis.sock?virtual_host=0``
|
- ``redis+socket:///run/redis/redis.sock?virtual_host=0``
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@ -935,13 +950,25 @@ CELERY_BEAT_SCHEDULE = {
|
||||||
),
|
),
|
||||||
"options": {"expires": 60 * 60},
|
"options": {"expires": 60 * 60},
|
||||||
},
|
},
|
||||||
"typesense.build_canonical_index": {
|
"listenbrainz.trigger_listening_sync_with_listenbrainz": {
|
||||||
"task": "typesense.build_canonical_index",
|
"task": "listenbrainz.trigger_listening_sync_with_listenbrainz",
|
||||||
"schedule": crontab(day_of_week="*/2", minute="0", hour="3"),
|
"schedule": crontab(day_of_week="*", minute="0", hour="3"),
|
||||||
|
"options": {"expires": 60 * 60 * 24},
|
||||||
|
},
|
||||||
|
"listenbrainz.trigger_favorite_sync_with_listenbrainz": {
|
||||||
|
"task": "listenbrainz.trigger_favorite_sync_with_listenbrainz",
|
||||||
|
"schedule": crontab(day_of_week="*", minute="0", hour="3"),
|
||||||
"options": {"expires": 60 * 60 * 24},
|
"options": {"expires": 60 * 60 * 24},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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):
|
if env.bool("ADD_ALBUM_TAGS_FROM_TRACKS", default=True):
|
||||||
CELERY_BEAT_SCHEDULE["music.albums_set_tags_from_tracks"] = {
|
CELERY_BEAT_SCHEDULE["music.albums_set_tags_from_tracks"] = {
|
||||||
"task": "music.albums_set_tags_from_tracks",
|
"task": "music.albums_set_tags_from_tracks",
|
||||||
|
@ -1186,7 +1213,7 @@ if BROWSABLE_API_ENABLED:
|
||||||
"rest_framework.renderers.BrowsableAPIRenderer",
|
"rest_framework.renderers.BrowsableAPIRenderer",
|
||||||
)
|
)
|
||||||
|
|
||||||
REST_AUTH_SERIALIZERS = {
|
REST_AUTH = {
|
||||||
"PASSWORD_RESET_SERIALIZER": "funkwhale_api.users.serializers.PasswordResetSerializer", # noqa
|
"PASSWORD_RESET_SERIALIZER": "funkwhale_api.users.serializers.PasswordResetSerializer", # noqa
|
||||||
"PASSWORD_RESET_CONFIRM_SERIALIZER": "funkwhale_api.users.serializers.PasswordResetConfirmSerializer", # noqa
|
"PASSWORD_RESET_CONFIRM_SERIALIZER": "funkwhale_api.users.serializers.PasswordResetConfirmSerializer", # noqa
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,8 +96,6 @@ CELERY_TASK_ALWAYS_EAGER = False
|
||||||
|
|
||||||
# Your local stuff: Below this line define 3rd party library settings
|
# 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"
|
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "funkwhale_api.schema.CustomAutoSchema"
|
||||||
SPECTACULAR_SETTINGS = {
|
SPECTACULAR_SETTINGS = {
|
||||||
"TITLE": "Funkwhale API",
|
"TITLE": "Funkwhale API",
|
||||||
|
|
|
@ -41,14 +41,6 @@ SECRET_KEY = env("DJANGO_SECRET_KEY")
|
||||||
# SESSION_COOKIE_HTTPONLY = True
|
# SESSION_COOKIE_HTTPONLY = True
|
||||||
# SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=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
|
# Static Assets
|
||||||
# ------------------------
|
# ------------------------
|
||||||
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
|
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls import url
|
|
||||||
from django.conf.urls.static import static
|
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 django.views import defaults as default_views
|
||||||
|
|
||||||
from config import plugins
|
from config import plugins
|
||||||
|
@ -10,34 +9,34 @@ from funkwhale_api.common import admin
|
||||||
plugins_patterns = plugins.trigger_filter(plugins.URLS, [], enabled=True)
|
plugins_patterns = plugins.trigger_filter(plugins.URLS, [], enabled=True)
|
||||||
|
|
||||||
api_patterns = [
|
api_patterns = [
|
||||||
url("v1/", include("config.urls.api")),
|
re_path("v1/", include("config.urls.api")),
|
||||||
url("v2/", include("config.urls.api_v2")),
|
re_path("v2/", include("config.urls.api_v2")),
|
||||||
url("subsonic/", include("config.urls.subsonic")),
|
re_path("subsonic/", include("config.urls.subsonic")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Django Admin, use {% url 'admin:index' %}
|
# Django Admin, use {% url 'admin:index' %}
|
||||||
url(settings.ADMIN_URL, admin.site.urls),
|
re_path(settings.ADMIN_URL, admin.site.urls),
|
||||||
url(r"^api/", include((api_patterns, "api"), namespace="api")),
|
re_path(r"^api/", include((api_patterns, "api"), namespace="api")),
|
||||||
url(
|
re_path(
|
||||||
r"^",
|
r"^",
|
||||||
include(
|
include(
|
||||||
("funkwhale_api.federation.urls", "federation"), namespace="federation"
|
("funkwhale_api.federation.urls", "federation"), namespace="federation"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
url(r"^api/v1/auth/", include("funkwhale_api.users.rest_auth_urls")),
|
re_path(r"^api/v1/auth/", include("funkwhale_api.users.rest_auth_urls")),
|
||||||
url(r"^accounts/", include("allauth.urls")),
|
re_path(r"^accounts/", include("allauth.urls")),
|
||||||
] + plugins_patterns
|
] + plugins_patterns
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
# This allows the error pages to be debugged during development, just visit
|
# This allows the error pages to be debugged during development, just visit
|
||||||
# these url in browser to see how these error pages look like.
|
# these url in browser to see how these error pages look like.
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
url(r"^400/$", default_views.bad_request),
|
re_path(r"^400/$", default_views.bad_request),
|
||||||
url(r"^403/$", default_views.permission_denied),
|
re_path(r"^403/$", default_views.permission_denied),
|
||||||
url(r"^404/$", default_views.page_not_found),
|
re_path(r"^404/$", default_views.page_not_found),
|
||||||
url(r"^500/$", default_views.server_error),
|
re_path(r"^500/$", default_views.server_error),
|
||||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
if "debug_toolbar" in settings.INSTALLED_APPS:
|
if "debug_toolbar" in settings.INSTALLED_APPS:
|
||||||
|
@ -49,5 +48,5 @@ if settings.DEBUG:
|
||||||
|
|
||||||
if "silk" in settings.INSTALLED_APPS:
|
if "silk" in settings.INSTALLED_APPS:
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r"^api/silk/", include("silk.urls", namespace="silk"))
|
re_path(r"^api/silk/", include("silk.urls", namespace="silk"))
|
||||||
] + urlpatterns
|
] + urlpatterns
|
||||||
|
|
|
@ -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.activity import views as activity_views
|
||||||
from funkwhale_api.audio import views as audio_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 = router.urls
|
||||||
|
|
||||||
v1_patterns += [
|
v1_patterns += [
|
||||||
url(r"^oembed/$", views.OembedView.as_view(), name="oembed"),
|
re_path(r"^oembed/$", views.OembedView.as_view(), name="oembed"),
|
||||||
url(
|
re_path(
|
||||||
r"^instance/",
|
r"^instance/",
|
||||||
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
|
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^manage/",
|
r"^manage/",
|
||||||
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
|
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^moderation/",
|
r"^moderation/",
|
||||||
include(
|
include(
|
||||||
("funkwhale_api.moderation.urls", "moderation"), namespace="moderation"
|
("funkwhale_api.moderation.urls", "moderation"), namespace="moderation"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^federation/",
|
r"^federation/",
|
||||||
include(
|
include(
|
||||||
("funkwhale_api.federation.api_urls", "federation"), namespace="federation"
|
("funkwhale_api.federation.api_urls", "federation"), namespace="federation"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^providers/",
|
r"^providers/",
|
||||||
include(("funkwhale_api.providers.urls", "providers"), namespace="providers"),
|
include(("funkwhale_api.providers.urls", "providers"), namespace="providers"),
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^favorites/",
|
r"^favorites/",
|
||||||
include(("funkwhale_api.favorites.urls", "favorites"), namespace="favorites"),
|
include(("funkwhale_api.favorites.urls", "favorites"), namespace="favorites"),
|
||||||
),
|
),
|
||||||
url(r"^search$", views.Search.as_view(), name="search"),
|
re_path(r"^search$", views.Search.as_view(), name="search"),
|
||||||
url(
|
re_path(
|
||||||
r"^radios/",
|
r"^radios/",
|
||||||
include(("funkwhale_api.radios.urls", "radios"), namespace="radios"),
|
include(("funkwhale_api.radios.urls", "radios"), namespace="radios"),
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^history/",
|
r"^history/",
|
||||||
include(("funkwhale_api.history.urls", "history"), namespace="history"),
|
include(("funkwhale_api.history.urls", "history"), namespace="history"),
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^",
|
r"^",
|
||||||
include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
|
include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
|
||||||
),
|
),
|
||||||
# XXX: remove if Funkwhale 1.1
|
# XXX: remove if Funkwhale 1.1
|
||||||
url(
|
re_path(
|
||||||
r"^users/",
|
r"^users/",
|
||||||
include(("funkwhale_api.users.api_urls", "users"), namespace="users-nested"),
|
include(("funkwhale_api.users.api_urls", "users"), namespace="users-nested"),
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^oauth/",
|
r"^oauth/",
|
||||||
include(("funkwhale_api.users.oauth.urls", "oauth"), namespace="oauth"),
|
include(("funkwhale_api.users.oauth.urls", "oauth"), namespace="oauth"),
|
||||||
),
|
),
|
||||||
url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
|
re_path(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
|
||||||
url(
|
re_path(
|
||||||
r"^text-preview/?$", common_views.TextPreviewView.as_view(), name="text-preview"
|
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"))]
|
||||||
|
|
|
@ -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
|
from funkwhale_api.common import routers as common_routers
|
||||||
|
|
||||||
|
@ -6,14 +7,14 @@ router = common_routers.OptionalSlashRouter()
|
||||||
v2_patterns = router.urls
|
v2_patterns = router.urls
|
||||||
|
|
||||||
v2_patterns += [
|
v2_patterns += [
|
||||||
url(
|
re_path(
|
||||||
r"^instance/",
|
r"^instance/",
|
||||||
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
|
include(("funkwhale_api.instance.urls_v2", "instance"), namespace="instance"),
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^radios/",
|
r"^radios/",
|
||||||
include(("funkwhale_api.radios.urls_v2", "radios"), namespace="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"))]
|
||||||
|
|
|
@ -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 import routers
|
||||||
from rest_framework.urlpatterns import format_suffix_patterns
|
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_router.register(r"rest", SubsonicViewSet, basename="subsonic")
|
||||||
|
|
||||||
subsonic_patterns = format_suffix_patterns(subsonic_router.urls, allowed=["view"])
|
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 = [
|
# urlpatterns = [
|
||||||
# url(
|
# url(
|
||||||
|
|
|
@ -48,4 +48,5 @@ def get_activity(user, limit=20):
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
records = combined_recent(limit=limit, querysets=querysets)
|
records = combined_recent(limit=limit, querysets=querysets)
|
||||||
|
|
||||||
return [r["object"] for r in records]
|
return [r["object"] for r in records]
|
||||||
|
|
|
@ -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.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 (
|
from oauth2_provider.contrib.rest_framework.authentication import (
|
||||||
OAuth2Authentication as BaseOAuth2Authentication,
|
OAuth2Authentication as BaseOAuth2Authentication,
|
||||||
)
|
)
|
||||||
|
@ -20,9 +20,13 @@ def resend_confirmation_email(request, user):
|
||||||
if cache.get(cache_key):
|
if cache.get(cache_key):
|
||||||
return False
|
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)
|
cache.set(cache_key, True, THROTTLE_DELAY)
|
||||||
return done
|
return True
|
||||||
|
|
||||||
|
|
||||||
class OAuth2Authentication(BaseOAuth2Authentication):
|
class OAuth2Authentication(BaseOAuth2Authentication):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Q
|
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 rest_framework as filters
|
||||||
from django_filters import widgets
|
from django_filters import widgets
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
|
@ -239,3 +240,19 @@ class ActorScopeFilter(filters.CharFilter):
|
||||||
raise EmptyQuerySet()
|
raise EmptyQuerySet()
|
||||||
|
|
||||||
return Q(**{self.actor_field: actor})
|
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)
|
||||||
|
|
|
@ -36,22 +36,7 @@ class Command(BaseCommand):
|
||||||
self.stdout.write("")
|
self.stdout.write("")
|
||||||
|
|
||||||
def init(self):
|
def init(self):
|
||||||
try:
|
user = User.objects.get(username="gitpod")
|
||||||
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()
|
|
||||||
|
|
||||||
# Allow anonymous access
|
# Allow anonymous access
|
||||||
preferences.set("common__api_authentication_required", False)
|
preferences.set("common__api_authentication_required", False)
|
||||||
|
|
|
@ -10,7 +10,7 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
self.help = "Helper to generate randomized testdata"
|
self.help = "Helper to generate randomized testdata"
|
||||||
self.type_choices = {"notifications": self.handle_notifications}
|
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):
|
def add_arguments(self, parser):
|
||||||
subparsers = parser.add_subparsers(dest="subcommand")
|
subparsers = parser.add_subparsers(dest="subcommand")
|
||||||
|
|
|
@ -150,7 +150,9 @@ def get_default_head_tags(path):
|
||||||
{
|
{
|
||||||
"tag": "meta",
|
"tag": "meta",
|
||||||
"property": "og:image",
|
"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",
|
"tag": "meta",
|
||||||
|
|
|
@ -60,12 +60,12 @@ class NullsLastSQLCompiler(SQLCompiler):
|
||||||
class NullsLastQuery(models.sql.query.Query):
|
class NullsLastQuery(models.sql.query.Query):
|
||||||
"""Use a custom compiler to inject 'NULLS LAST' (for PostgreSQL)."""
|
"""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:
|
if using is None and connection is None:
|
||||||
raise ValueError("Need either using or connection")
|
raise ValueError("Need either using or connection")
|
||||||
if using:
|
if using:
|
||||||
connection = connections[using]
|
connection = connections[using]
|
||||||
return NullsLastSQLCompiler(self, connection, using)
|
return NullsLastSQLCompiler(self, connection, using, elide_empty)
|
||||||
|
|
||||||
|
|
||||||
class NullsLastQuerySet(models.QuerySet):
|
class NullsLastQuerySet(models.QuerySet):
|
||||||
|
|
|
@ -2,7 +2,7 @@ import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
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 import serializers, types
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
|
@ -93,7 +93,6 @@ class SerializedPreference(types.BasePreferenceType):
|
||||||
serializer
|
serializer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
serializer = JSONSerializer
|
|
||||||
data_serializer_class = None
|
data_serializer_class = None
|
||||||
field_class = JSONField
|
field_class = JSONField
|
||||||
widget = forms.Textarea
|
widget = forms.Textarea
|
||||||
|
|
|
@ -5,8 +5,8 @@ import os
|
||||||
import PIL
|
import PIL
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.utils.encoding import smart_text
|
from django.utils.encoding import smart_str
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
@ -52,7 +52,7 @@ class RelatedField(serializers.RelatedField):
|
||||||
self.fail(
|
self.fail(
|
||||||
"does_not_exist",
|
"does_not_exist",
|
||||||
related_field_name=self.related_field_name,
|
related_field_name=self.related_field_name,
|
||||||
value=smart_text(data),
|
value=smart_str(data),
|
||||||
)
|
)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
self.fail("invalid")
|
self.fail("invalid")
|
||||||
|
@ -349,7 +349,7 @@ class ScopesSerializer(serializers.Serializer):
|
||||||
|
|
||||||
class IdentSerializer(serializers.Serializer):
|
class IdentSerializer(serializers.Serializer):
|
||||||
type = serializers.CharField()
|
type = serializers.CharField()
|
||||||
id = serializers.IntegerField()
|
id = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class RateLimitSerializer(serializers.Serializer):
|
class RateLimitSerializer(serializers.Serializer):
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import django.dispatch
|
import django.dispatch
|
||||||
|
|
||||||
mutation_created = django.dispatch.Signal(providing_args=["mutation"])
|
""" Required args: mutation """
|
||||||
mutation_updated = django.dispatch.Signal(
|
mutation_created = django.dispatch.Signal()
|
||||||
providing_args=["mutation", "old_is_approved", "new_is_approved"]
|
""" Required args: mutation, old_is_approved, new_is_approved """
|
||||||
)
|
mutation_updated = django.dispatch.Signal()
|
||||||
|
|
|
@ -7,7 +7,7 @@ from rest_framework import throttling as rest_throttling
|
||||||
|
|
||||||
def get_ident(user, request):
|
def get_ident(user, request):
|
||||||
if user and user.is_authenticated:
|
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)
|
ident = rest_throttling.BaseThrottle().get_ident(request)
|
||||||
|
|
||||||
return {"type": "anonymous", "id": ident}
|
return {"type": "anonymous", "id": ident}
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError
|
||||||
from django.core.files.images import get_image_dimensions
|
from django.core.files.images import get_image_dimensions
|
||||||
from django.template.defaultfilters import filesizeformat
|
from django.template.defaultfilters import filesizeformat
|
||||||
from django.utils.deconstruct import deconstructible
|
from django.utils.deconstruct import deconstructible
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
@deconstructible
|
@deconstructible
|
||||||
|
|
|
@ -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
|
|
|
@ -1,27 +1,31 @@
|
||||||
|
import liblistenbrainz
|
||||||
|
|
||||||
import funkwhale_api
|
import funkwhale_api
|
||||||
from config import plugins
|
from config import plugins
|
||||||
|
from funkwhale_api.favorites import models as favorites_models
|
||||||
|
from funkwhale_api.history import models as history_models
|
||||||
|
|
||||||
from .client import ListenBrainzClient, Track
|
from . import tasks
|
||||||
from .funkwhale_startup import PLUGIN
|
from .funkwhale_startup import PLUGIN
|
||||||
|
|
||||||
|
|
||||||
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
|
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
|
||||||
def submit_listen(listening, conf, **kwargs):
|
def submit_listen(listening, conf, **kwargs):
|
||||||
user_token = conf["user_token"]
|
user_token = conf["user_token"]
|
||||||
if not user_token:
|
if not user_token and not conf["submit_listenings"]:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger = PLUGIN["logger"]
|
logger = PLUGIN["logger"]
|
||||||
logger.info("Submitting listen to ListenBrainz")
|
logger.info("Submitting listen to ListenBrainz")
|
||||||
client = ListenBrainzClient(user_token=user_token, logger=logger)
|
client = liblistenbrainz.ListenBrainz()
|
||||||
track = get_track(listening.track)
|
client.set_auth_token(user_token)
|
||||||
client.listen(int(listening.creation_date.timestamp()), track)
|
listen = get_lb_listen(listening)
|
||||||
|
|
||||||
|
client.submit_single_listen(listen)
|
||||||
|
|
||||||
|
|
||||||
def get_track(track):
|
def get_lb_listen(listening):
|
||||||
artist = track.artist.name
|
track = listening.track
|
||||||
title = track.title
|
|
||||||
album = None
|
|
||||||
additional_info = {
|
additional_info = {
|
||||||
"media_player": "Funkwhale",
|
"media_player": "Funkwhale",
|
||||||
"media_player_version": funkwhale_api.__version__,
|
"media_player_version": funkwhale_api.__version__,
|
||||||
|
@ -36,7 +40,7 @@ def get_track(track):
|
||||||
|
|
||||||
if track.album:
|
if track.album:
|
||||||
if track.album.title:
|
if track.album.title:
|
||||||
album = track.album.title
|
release_name = track.album.title
|
||||||
if track.album.mbid:
|
if track.album.mbid:
|
||||||
additional_info["release_mbid"] = str(track.album.mbid)
|
additional_info["release_mbid"] = str(track.album.mbid)
|
||||||
|
|
||||||
|
@ -47,4 +51,86 @@ def get_track(track):
|
||||||
if upload:
|
if upload:
|
||||||
additional_info["duration"] = upload.duration
|
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=listening.creation_date.timestamp(),
|
||||||
|
release_name=release_name,
|
||||||
|
additional_info=additional_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@plugins.register_hook(plugins.FAVORITE_CREATED, PLUGIN)
|
||||||
|
def submit_favorite_creation(track_favorite, conf, **kwargs):
|
||||||
|
user_token = conf["user_token"]
|
||||||
|
if not user_token or not conf["submit_favorites"]:
|
||||||
|
return
|
||||||
|
logger = PLUGIN["logger"]
|
||||||
|
logger.info("Submitting favorite to ListenBrainz")
|
||||||
|
client = liblistenbrainz.ListenBrainz()
|
||||||
|
track = track_favorite.track
|
||||||
|
if not track.mbid:
|
||||||
|
logger.warning(
|
||||||
|
"This tracks doesn't have a mbid. Feedback will not be submitted to Listenbrainz"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
client.submit_user_feedback(1, track.mbid)
|
||||||
|
|
||||||
|
|
||||||
|
@plugins.register_hook(plugins.FAVORITE_DELETED, PLUGIN)
|
||||||
|
def submit_favorite_deletion(track_favorite, conf, **kwargs):
|
||||||
|
user_token = conf["user_token"]
|
||||||
|
if not user_token or not conf["submit_favorites"]:
|
||||||
|
return
|
||||||
|
logger = PLUGIN["logger"]
|
||||||
|
logger.info("Submitting favorite deletion to ListenBrainz")
|
||||||
|
client = liblistenbrainz.ListenBrainz()
|
||||||
|
track = track_favorite.track
|
||||||
|
if not track.mbid:
|
||||||
|
logger.warning(
|
||||||
|
"This tracks doesn't have a mbid. Feedback will not be submitted to Listenbrainz"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
client.submit_user_feedback(0, track.mbid)
|
||||||
|
|
||||||
|
|
||||||
|
@plugins.register_hook(plugins.LISTENING_SYNC, PLUGIN)
|
||||||
|
def sync_listenings_from_listenbrainz(user, conf):
|
||||||
|
user_name = conf["user_name"]
|
||||||
|
|
||||||
|
if not user_name or not conf["sync_listenings"]:
|
||||||
|
return
|
||||||
|
logger = PLUGIN["logger"]
|
||||||
|
logger.info("Getting listenings from ListenBrainz")
|
||||||
|
try:
|
||||||
|
last_ts = (
|
||||||
|
history_models.Listening.objects.filter(user=user)
|
||||||
|
.filter(source="Listenbrainz")
|
||||||
|
.latest("creation_date")
|
||||||
|
.values_list("creation_date", flat=True)
|
||||||
|
).timestamp()
|
||||||
|
except funkwhale_api.history.models.Listening.DoesNotExist:
|
||||||
|
tasks.import_listenbrainz_listenings(user, user_name, 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
tasks.import_listenbrainz_listenings(user, user_name, last_ts)
|
||||||
|
|
||||||
|
|
||||||
|
@plugins.register_hook(plugins.FAVORITE_SYNC, PLUGIN)
|
||||||
|
def sync_favorites_from_listenbrainz(user, conf):
|
||||||
|
user_name = conf["user_name"]
|
||||||
|
|
||||||
|
if not user_name or not conf["sync_favorites"]:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
last_ts = (
|
||||||
|
favorites_models.TrackFavorite.objects.filter(user=user)
|
||||||
|
.filter(source="Listenbrainz")
|
||||||
|
.latest("creation_date")
|
||||||
|
.creation_date.timestamp()
|
||||||
|
)
|
||||||
|
except favorites_models.TrackFavorite.DoesNotExist:
|
||||||
|
tasks.import_listenbrainz_favorites(user, user_name, 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
tasks.import_listenbrainz_favorites(user, user_name, last_ts)
|
||||||
|
|
|
@ -3,7 +3,7 @@ from config import plugins
|
||||||
PLUGIN = plugins.get_plugin_config(
|
PLUGIN = plugins.get_plugin_config(
|
||||||
name="listenbrainz",
|
name="listenbrainz",
|
||||||
label="ListenBrainz",
|
label="ListenBrainz",
|
||||||
description="A plugin that allows you to submit your listens to ListenBrainz.",
|
description="A plugin that allows you to submit or sync your listens and favorites to ListenBrainz.",
|
||||||
homepage="https://docs.funkwhale.audio/users/builtinplugins.html#listenbrainz-plugin", # noqa
|
homepage="https://docs.funkwhale.audio/users/builtinplugins.html#listenbrainz-plugin", # noqa
|
||||||
version="0.3",
|
version="0.3",
|
||||||
user=True,
|
user=True,
|
||||||
|
@ -13,6 +13,45 @@ PLUGIN = plugins.get_plugin_config(
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"label": "Your ListenBrainz user token",
|
"label": "Your ListenBrainz user token",
|
||||||
"help": "You can find your user token in your ListenBrainz profile at https://listenbrainz.org/profile/",
|
"help": "You can find your user token in your ListenBrainz profile at https://listenbrainz.org/profile/",
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"name": "user_name",
|
||||||
|
"type": "text",
|
||||||
|
"required": False,
|
||||||
|
"label": "Your ListenBrainz user name.",
|
||||||
|
"help": "Required for importing listenings and favorites with ListenBrainz \
|
||||||
|
but not to send activities",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "submit_listenings",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
"label": "Enable listening submission to ListenBrainz",
|
||||||
|
"help": "If enabled, your listenings from Funkwhale will be imported into ListenBrainz.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sync_listenings",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"label": "Enable listenings sync",
|
||||||
|
"help": "If enabled, your listening from ListenBrainz will be imported into Funkwhale. This means they \
|
||||||
|
will be used along with Funkwhale listenings to filter out recently listened content or \
|
||||||
|
generate recommendations",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sync_favorites",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"label": "Enable favorite sync",
|
||||||
|
"help": "If enabled, your favorites from ListenBrainz will be imported into Funkwhale. This means they \
|
||||||
|
will be used along with Funkwhale favorites (UI display, federation activity)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "submit_favorites",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"label": "Enable favorite submission to ListenBrainz services",
|
||||||
|
"help": "If enabled, your favorites from Funkwhale will be submitted to ListenBrainz",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,165 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import liblistenbrainz
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from config import plugins
|
||||||
|
from funkwhale_api.favorites import models as favorites_models
|
||||||
|
from funkwhale_api.history import models as history_models
|
||||||
|
from funkwhale_api.music import models as music_models
|
||||||
|
from funkwhale_api.taskapp import celery
|
||||||
|
from funkwhale_api.users import models
|
||||||
|
|
||||||
|
from .funkwhale_startup import PLUGIN
|
||||||
|
|
||||||
|
|
||||||
|
@celery.app.task(name="listenbrainz.trigger_listening_sync_with_listenbrainz")
|
||||||
|
def trigger_listening_sync_with_listenbrainz():
|
||||||
|
now = timezone.now()
|
||||||
|
active_month = now - datetime.timedelta(days=30)
|
||||||
|
users = (
|
||||||
|
models.User.objects.filter(plugins__code="listenbrainz")
|
||||||
|
.filter(plugins__conf__sync_listenings=True)
|
||||||
|
.filter(last_activity__gte=active_month)
|
||||||
|
)
|
||||||
|
for user in users:
|
||||||
|
plugins.trigger_hook(
|
||||||
|
plugins.LISTENING_SYNC,
|
||||||
|
user=user,
|
||||||
|
confs=plugins.get_confs(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.app.task(name="listenbrainz.trigger_favorite_sync_with_listenbrainz")
|
||||||
|
def trigger_favorite_sync_with_listenbrainz():
|
||||||
|
now = timezone.now()
|
||||||
|
active_month = now - datetime.timedelta(days=30)
|
||||||
|
users = (
|
||||||
|
models.User.objects.filter(plugins__code="listenbrainz")
|
||||||
|
.filter(plugins__conf__sync_listenings=True)
|
||||||
|
.filter(last_activity__gte=active_month)
|
||||||
|
)
|
||||||
|
for user in users:
|
||||||
|
plugins.trigger_hook(
|
||||||
|
plugins.FAVORITE_SYNC,
|
||||||
|
user=user,
|
||||||
|
confs=plugins.get_confs(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.app.task(name="listenbrainz.import_listenbrainz_listenings")
|
||||||
|
def import_listenbrainz_listenings(user, user_name, since):
|
||||||
|
client = liblistenbrainz.ListenBrainz()
|
||||||
|
response = client.get_listens(username=user_name, min_ts=since, count=100)
|
||||||
|
listens = response["payload"]["listens"]
|
||||||
|
while listens:
|
||||||
|
add_lb_listenings_to_db(listens, user)
|
||||||
|
new_ts = max(
|
||||||
|
listens,
|
||||||
|
key=lambda obj: datetime.datetime.fromtimestamp(
|
||||||
|
obj.listened_at, timezone.utc
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = client.get_listens(username=user_name, min_ts=new_ts, count=100)
|
||||||
|
listens = response["payload"]["listens"]
|
||||||
|
|
||||||
|
|
||||||
|
def add_lb_listenings_to_db(listens, user):
|
||||||
|
logger = PLUGIN["logger"]
|
||||||
|
fw_listens = []
|
||||||
|
for listen in listens:
|
||||||
|
if (
|
||||||
|
listen.additional_info.get("submission_client")
|
||||||
|
and listen.additional_info.get("submission_client")
|
||||||
|
== "Funkwhale ListenBrainz plugin"
|
||||||
|
and history_models.Listening.objects.filter(
|
||||||
|
creation_date=datetime.datetime.fromtimestamp(
|
||||||
|
listen.listened_at, timezone.utc
|
||||||
|
)
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
f"Listen with ts {listen.listened_at} skipped because already in db"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
mbid = (
|
||||||
|
listen.mbid_mapping
|
||||||
|
if hasattr(listen, "mbid_mapping")
|
||||||
|
else listen.recording_mbid
|
||||||
|
)
|
||||||
|
|
||||||
|
if not mbid:
|
||||||
|
logger.info("Received listening that doesn't have a mbid. Skipping...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
track = music_models.Track.objects.get(mbid=mbid)
|
||||||
|
except music_models.Track.DoesNotExist:
|
||||||
|
logger.info(
|
||||||
|
"Received listening that doesn't exist in fw database. Skipping..."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
user = user
|
||||||
|
fw_listen = history_models.Listening(
|
||||||
|
creation_date=datetime.datetime.fromtimestamp(
|
||||||
|
listen.listened_at, timezone.utc
|
||||||
|
),
|
||||||
|
track=track,
|
||||||
|
user=user,
|
||||||
|
source="Listenbrainz",
|
||||||
|
)
|
||||||
|
fw_listens.append(fw_listen)
|
||||||
|
|
||||||
|
history_models.Listening.objects.bulk_create(fw_listens)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.app.task(name="listenbrainz.import_listenbrainz_favorites")
|
||||||
|
def import_listenbrainz_favorites(user, user_name, since):
|
||||||
|
client = liblistenbrainz.ListenBrainz()
|
||||||
|
response = client.get_user_feedback(username=user_name)
|
||||||
|
offset = 0
|
||||||
|
while response["feedback"]:
|
||||||
|
count = response["count"]
|
||||||
|
offset = offset + count
|
||||||
|
last_sync = min(
|
||||||
|
response["feedback"],
|
||||||
|
key=lambda obj: datetime.datetime.fromtimestamp(
|
||||||
|
obj["created"], timezone.utc
|
||||||
|
),
|
||||||
|
)["created"]
|
||||||
|
add_lb_feedback_to_db(response["feedback"], user)
|
||||||
|
if last_sync <= since or count == 0:
|
||||||
|
return
|
||||||
|
response = client.get_user_feedback(username=user_name, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
def add_lb_feedback_to_db(feedbacks, user):
|
||||||
|
logger = PLUGIN["logger"]
|
||||||
|
for feedback in feedbacks:
|
||||||
|
try:
|
||||||
|
track = music_models.Track.objects.get(mbid=feedback["recording_mbid"])
|
||||||
|
except music_models.Track.DoesNotExist:
|
||||||
|
logger.info(
|
||||||
|
"Received feedback track that doesn't exist in fw database. Skipping..."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if feedback["score"] == 1:
|
||||||
|
favorites_models.TrackFavorite.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
creation_date=datetime.datetime.fromtimestamp(
|
||||||
|
feedback["created"], timezone.utc
|
||||||
|
),
|
||||||
|
track=track,
|
||||||
|
source="Listenbrainz",
|
||||||
|
)
|
||||||
|
elif feedback["score"] == 0:
|
||||||
|
try:
|
||||||
|
favorites_models.TrackFavorite.objects.get(
|
||||||
|
user=user, track=track
|
||||||
|
).delete()
|
||||||
|
except favorites_models.TrackFavorite.DoesNotExist:
|
||||||
|
continue
|
||||||
|
elif feedback["score"] == -1:
|
||||||
|
logger.info("Funkwhale doesn't support disliked tracks")
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-12-09 14:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('favorites', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='trackfavorite',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -12,6 +12,7 @@ class TrackFavorite(models.Model):
|
||||||
track = models.ForeignKey(
|
track = models.ForeignKey(
|
||||||
Track, related_name="track_favorites", on_delete=models.CASCADE
|
Track, related_name="track_favorites", on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
|
source = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("track", "user")
|
unique_together = ("track", "user")
|
||||||
|
|
|
@ -4,6 +4,7 @@ from rest_framework import mixins, status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from config import plugins
|
||||||
from funkwhale_api.activity import record
|
from funkwhale_api.activity import record
|
||||||
from funkwhale_api.common import fields, permissions
|
from funkwhale_api.common import fields, permissions
|
||||||
from funkwhale_api.music import utils as music_utils
|
from funkwhale_api.music import utils as music_utils
|
||||||
|
@ -44,6 +45,11 @@ class TrackFavoriteViewSet(
|
||||||
instance = self.perform_create(serializer)
|
instance = self.perform_create(serializer)
|
||||||
serializer = self.get_serializer(instance=instance)
|
serializer = self.get_serializer(instance=instance)
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
plugins.trigger_hook(
|
||||||
|
plugins.FAVORITE_CREATED,
|
||||||
|
track_favorite=serializer.instance,
|
||||||
|
confs=plugins.get_confs(self.request.user),
|
||||||
|
)
|
||||||
record.send(instance)
|
record.send(instance)
|
||||||
return Response(
|
return Response(
|
||||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||||
|
@ -76,6 +82,11 @@ class TrackFavoriteViewSet(
|
||||||
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
|
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
|
||||||
return Response({}, status=400)
|
return Response({}, status=400)
|
||||||
favorite.delete()
|
favorite.delete()
|
||||||
|
plugins.trigger_hook(
|
||||||
|
plugins.FAVORITE_DELETED,
|
||||||
|
track_favorite=favorite,
|
||||||
|
confs=plugins.get_confs(self.request.user),
|
||||||
|
)
|
||||||
return Response([], status=status.HTTP_204_NO_CONTENT)
|
return Response([], status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
|
|
|
@ -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 import routers
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
@ -23,6 +24,8 @@ music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
|
||||||
index_router.register(r"index", views.IndexViewSet, "index")
|
index_router.register(r"index", views.IndexViewSet, "index")
|
||||||
|
|
||||||
urlpatterns = router.urls + [
|
urlpatterns = router.urls + [
|
||||||
url("federation/music/", include((music_router.urls, "music"), namespace="music")),
|
re_path(
|
||||||
url("federation/", include((index_router.urls, "index"), namespace="index")),
|
"federation/music/", include((music_router.urls, "music"), namespace="music")
|
||||||
|
),
|
||||||
|
re_path("federation/", include((index_router.urls, "index"), namespace="index")),
|
||||||
]
|
]
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-12-09 14:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('history', '0002_auto_20180325_1433'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='listening',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -17,6 +17,7 @@ class Listening(models.Model):
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
session_key = models.CharField(max_length=100, null=True, blank=True)
|
session_key = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
source = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("-creation_date",)
|
ordering = ("-creation_date",)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import pycountry
|
||||||
from django.core.validators import FileExtensionValidator
|
from django.core.validators import FileExtensionValidator
|
||||||
from django.forms import widgets
|
from django.forms import widgets
|
||||||
from dynamic_preferences import types
|
from dynamic_preferences import types
|
||||||
|
@ -170,3 +171,18 @@ class Banner(ImagePreference):
|
||||||
default = None
|
default = None
|
||||||
help_text = "This banner will be displayed on your pod's landing and about page. At least 600x100px recommended."
|
help_text = "This banner will be displayed on your pod's landing and about page. At least 600x100px recommended."
|
||||||
field_kwargs = {"required": False}
|
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}
|
||||||
|
|
|
@ -12,6 +12,17 @@ class SoftwareSerializer(serializers.Serializer):
|
||||||
return "funkwhale"
|
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):
|
class ServicesSerializer(serializers.Serializer):
|
||||||
inbound = serializers.ListField(child=serializers.CharField(), default=[])
|
inbound = serializers.ListField(child=serializers.CharField(), default=[])
|
||||||
outbound = 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):
|
class UsageSerializer(serializers.Serializer):
|
||||||
users = UsersUsageSerializer()
|
users = UsersUsageSerializer()
|
||||||
|
localPosts = serializers.IntegerField(required=False)
|
||||||
|
localComments = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
class TotalCountSerializer(serializers.Serializer):
|
class TotalCountSerializer(serializers.Serializer):
|
||||||
|
@ -92,19 +105,14 @@ class MetadataSerializer(serializers.Serializer):
|
||||||
private = serializers.SerializerMethodField()
|
private = serializers.SerializerMethodField()
|
||||||
shortDescription = serializers.SerializerMethodField()
|
shortDescription = serializers.SerializerMethodField()
|
||||||
longDescription = serializers.SerializerMethodField()
|
longDescription = serializers.SerializerMethodField()
|
||||||
rules = serializers.SerializerMethodField()
|
|
||||||
contactEmail = serializers.SerializerMethodField()
|
contactEmail = serializers.SerializerMethodField()
|
||||||
terms = serializers.SerializerMethodField()
|
|
||||||
nodeName = serializers.SerializerMethodField()
|
nodeName = serializers.SerializerMethodField()
|
||||||
banner = serializers.SerializerMethodField()
|
banner = serializers.SerializerMethodField()
|
||||||
defaultUploadQuota = serializers.SerializerMethodField()
|
defaultUploadQuota = serializers.SerializerMethodField()
|
||||||
library = serializers.SerializerMethodField()
|
|
||||||
supportedUploadExtensions = serializers.ListField(child=serializers.CharField())
|
supportedUploadExtensions = serializers.ListField(child=serializers.CharField())
|
||||||
allowList = serializers.SerializerMethodField()
|
allowList = serializers.SerializerMethodField()
|
||||||
reportTypes = ReportTypeSerializer(source="report_types", many=True)
|
|
||||||
funkwhaleSupportMessageEnabled = serializers.SerializerMethodField()
|
funkwhaleSupportMessageEnabled = serializers.SerializerMethodField()
|
||||||
instanceSupportMessage = serializers.SerializerMethodField()
|
instanceSupportMessage = serializers.SerializerMethodField()
|
||||||
endpoints = EndpointsSerializer()
|
|
||||||
usage = MetadataUsageSerializer(source="stats", required=False)
|
usage = MetadataUsageSerializer(source="stats", required=False)
|
||||||
|
|
||||||
def get_private(self, obj) -> bool:
|
def get_private(self, obj) -> bool:
|
||||||
|
@ -116,15 +124,9 @@ class MetadataSerializer(serializers.Serializer):
|
||||||
def get_longDescription(self, obj) -> str:
|
def get_longDescription(self, obj) -> str:
|
||||||
return obj["preferences"].get("instance__long_description")
|
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:
|
def get_contactEmail(self, obj) -> str:
|
||||||
return obj["preferences"].get("instance__contact_email")
|
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:
|
def get_nodeName(self, obj) -> str:
|
||||||
return obj["preferences"].get("instance__name")
|
return obj["preferences"].get("instance__name")
|
||||||
|
|
||||||
|
@ -137,15 +139,6 @@ class MetadataSerializer(serializers.Serializer):
|
||||||
def get_defaultUploadQuota(self, obj) -> int:
|
def get_defaultUploadQuota(self, obj) -> int:
|
||||||
return obj["preferences"].get("users__upload_quota")
|
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)
|
@extend_schema_field(AllowListStatSerializer)
|
||||||
def get_allowList(self, obj):
|
def get_allowList(self, obj):
|
||||||
return AllowListStatSerializer(
|
return AllowListStatSerializer(
|
||||||
|
@ -166,6 +159,62 @@ class MetadataSerializer(serializers.Serializer):
|
||||||
return MetadataUsageSerializer(obj["stats"]).data
|
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):
|
class NodeInfo20Serializer(serializers.Serializer):
|
||||||
version = serializers.SerializerMethodField()
|
version = serializers.SerializerMethodField()
|
||||||
software = SoftwareSerializer()
|
software = SoftwareSerializer()
|
||||||
|
@ -196,9 +245,36 @@ class NodeInfo20Serializer(serializers.Serializer):
|
||||||
usage = {"users": {"total": 0, "activeMonth": 0, "activeHalfyear": 0}}
|
usage = {"users": {"total": 0, "activeMonth": 0, "activeHalfyear": 0}}
|
||||||
return UsageSerializer(usage).data
|
return UsageSerializer(usage).data
|
||||||
|
|
||||||
@extend_schema_field(MetadataSerializer)
|
@extend_schema_field(Metadata20Serializer)
|
||||||
def get_metadata(self, obj):
|
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):
|
class SpaManifestIconSerializer(serializers.Serializer):
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django.db.models import Sum
|
from django.db.models import Count, F, Sum
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from funkwhale_api.favorites.models import TrackFavorite
|
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():
|
def get_users():
|
||||||
qs = User.objects.filter(is_active=True)
|
qs = User.objects.filter(is_active=True)
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from django.conf.urls import url
|
from django.urls import re_path
|
||||||
|
|
||||||
from funkwhale_api.common import routers
|
from funkwhale_api.common import routers
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ admin_router = routers.OptionalSlashRouter()
|
||||||
admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
|
admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r"^nodeinfo/2.0/?$", views.NodeInfo.as_view(), name="nodeinfo-2.0"),
|
re_path(r"^nodeinfo/2.0/?$", views.NodeInfo20.as_view(), name="nodeinfo-2.0"),
|
||||||
url(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
|
re_path(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
|
||||||
url(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"),
|
re_path(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"),
|
||||||
] + admin_router.urls
|
] + admin_router.urls
|
||||||
|
|
|
@ -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"),
|
||||||
|
]
|
|
@ -11,6 +11,7 @@ from dynamic_preferences.api import viewsets as preferences_viewsets
|
||||||
from dynamic_preferences.api.serializers import GlobalPreferenceSerializer
|
from dynamic_preferences.api.serializers import GlobalPreferenceSerializer
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
from rest_framework import generics, views
|
from rest_framework import generics, views
|
||||||
|
from rest_framework.renderers import JSONRenderer
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from funkwhale_api import __version__ as funkwhale_version
|
from funkwhale_api import __version__ as funkwhale_version
|
||||||
|
@ -58,9 +59,11 @@ class InstanceSettings(generics.GenericAPIView):
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(ensure_csrf_cookie, name="dispatch")
|
@method_decorator(ensure_csrf_cookie, name="dispatch")
|
||||||
class NodeInfo(views.APIView):
|
class NodeInfo20(views.APIView):
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
authentication_classes = []
|
authentication_classes = []
|
||||||
|
serializer_class = serializers.NodeInfo20Serializer
|
||||||
|
renderer_classes = (JSONRenderer,)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20"
|
responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20"
|
||||||
|
@ -81,6 +84,7 @@ class NodeInfo(views.APIView):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"software": {"version": funkwhale_version},
|
"software": {"version": funkwhale_version},
|
||||||
|
"services": {"inbound": ["atom1.0"], "outbound": ["atom1.0"]},
|
||||||
"preferences": pref,
|
"preferences": pref,
|
||||||
"stats": cache_memoize(600, prefix="memoize:instance:stats")(stats.get)()
|
"stats": cache_memoize(600, prefix="memoize:instance:stats")(stats.get)()
|
||||||
if pref["instance__nodeinfo_stats_enabled"]
|
if pref["instance__nodeinfo_stats_enabled"]
|
||||||
|
@ -112,7 +116,65 @@ class NodeInfo(views.APIView):
|
||||||
data["endpoints"]["channels"] = reverse(
|
data["endpoints"]["channels"] = reverse(
|
||||||
"federation:index:index-channels"
|
"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(
|
return Response(
|
||||||
serializer.data, status=200, content_type=NODEINFO_2_CONTENT_TYPE
|
serializer.data, status=200, content_type=NODEINFO_2_CONTENT_TYPE
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
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")
|
other_router.register(r"tags", views.ManageTagViewSet, "tags")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(
|
re_path(
|
||||||
r"^federation/",
|
r"^federation/",
|
||||||
include((federation_router.urls, "federation"), namespace="federation"),
|
include((federation_router.urls, "federation"), namespace="federation"),
|
||||||
),
|
),
|
||||||
url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
|
re_path(
|
||||||
url(
|
r"^library/", include((library_router.urls, "instance"), namespace="library")
|
||||||
|
),
|
||||||
|
re_path(
|
||||||
r"^moderation/",
|
r"^moderation/",
|
||||||
include((moderation_router.urls, "moderation"), namespace="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
|
] + other_router.urls
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import pycountry
|
||||||
from dynamic_preferences import types
|
from dynamic_preferences import types
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
@ -92,3 +93,18 @@ class SignupFormCustomization(common_preferences.SerializedPreference):
|
||||||
required = False
|
required = False
|
||||||
default = {}
|
default = {}
|
||||||
data_serializer_class = CustomFormSerializer
|
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}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
import django.dispatch
|
import django.dispatch
|
||||||
|
|
||||||
report_created = django.dispatch.Signal(providing_args=["report"])
|
""" Required argument: report """
|
||||||
|
report_created = django.dispatch.Signal()
|
||||||
|
|
|
@ -32,3 +32,18 @@ class MusicCacheDuration(types.IntPreference):
|
||||||
"will be erased and retranscoded on the next listening."
|
"will be erased and retranscoded on the next listening."
|
||||||
)
|
)
|
||||||
field_kwargs = {"required": False}
|
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
|
||||||
|
|
|
@ -104,7 +104,7 @@ class ArtistFilter(
|
||||||
distinct=True,
|
distinct=True,
|
||||||
library_field="tracks__uploads__library",
|
library_field="tracks__uploads__library",
|
||||||
)
|
)
|
||||||
ordering = django_filters.OrderingFilter(
|
ordering = common_filters.CaseInsensitiveNameOrderingFilter(
|
||||||
fields=(
|
fields=(
|
||||||
("id", "id"),
|
("id", "id"),
|
||||||
("name", "name"),
|
("name", "name"),
|
||||||
|
|
|
@ -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")
|
|
@ -226,17 +226,18 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def serialize_upload(upload) -> object:
|
class TrackUploadSerializer(serializers.Serializer):
|
||||||
return {
|
uuid = serializers.UUIDField()
|
||||||
"uuid": str(upload.uuid),
|
listen_url = serializers.URLField()
|
||||||
"listen_url": upload.listen_url,
|
size = serializers.IntegerField()
|
||||||
"size": upload.size,
|
duration = serializers.IntegerField()
|
||||||
"duration": upload.duration,
|
bitrate = serializers.IntegerField()
|
||||||
"bitrate": upload.bitrate,
|
mimetype = serializers.CharField()
|
||||||
"mimetype": upload.mimetype,
|
extension = serializers.CharField()
|
||||||
"extension": upload.extension,
|
is_local = serializers.SerializerMethodField()
|
||||||
"is_local": federation_utils.is_local(upload.fid),
|
|
||||||
}
|
def get_is_local(self, upload) -> bool:
|
||||||
|
return federation_utils.is_local(upload.fid)
|
||||||
|
|
||||||
|
|
||||||
def sort_uploads_for_listen(uploads):
|
def sort_uploads_for_listen(uploads):
|
||||||
|
@ -281,11 +282,14 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
|
||||||
def get_listen_url(self, obj):
|
def get_listen_url(self, obj):
|
||||||
return obj.listen_url
|
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):
|
def get_uploads(self, obj):
|
||||||
uploads = getattr(obj, "playable_uploads", [])
|
uploads = getattr(obj, "playable_uploads", [])
|
||||||
# we put local uploads first
|
# 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)
|
uploads = sorted(uploads, key=lambda u: u["is_local"], reverse=True)
|
||||||
return list(uploads)
|
return list(uploads)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import django.dispatch
|
import django.dispatch
|
||||||
|
|
||||||
upload_import_status_updated = django.dispatch.Signal(
|
""" Required args: old_status, new_status, upload """
|
||||||
providing_args=["old_status", "new_status", "upload"]
|
upload_import_status_updated = django.dispatch.Signal()
|
||||||
)
|
|
||||||
|
|
|
@ -247,6 +247,13 @@ def process_upload(upload, update_denormalization=True):
|
||||||
return fail_import(
|
return fail_import(
|
||||||
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
|
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(
|
final_metadata = collections.ChainMap(
|
||||||
additional_data, serializer.validated_data, internal_config
|
additional_data, serializer.validated_data, internal_config
|
||||||
|
|
|
@ -297,8 +297,6 @@ class LibraryViewSet(
|
||||||
)
|
)
|
||||||
instance.delete()
|
instance.delete()
|
||||||
|
|
||||||
follows = action
|
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
responses=federation_api_serializers.LibraryFollowSerializer(many=True)
|
responses=federation_api_serializers.LibraryFollowSerializer(many=True)
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from django.conf.urls import url
|
from django.urls import re_path
|
||||||
|
|
||||||
from funkwhale_api.common import routers
|
from funkwhale_api.common import routers
|
||||||
|
|
||||||
|
@ -7,22 +7,22 @@ from . import views
|
||||||
router = routers.OptionalSlashRouter()
|
router = routers.OptionalSlashRouter()
|
||||||
router.register(r"search", views.SearchViewSet, "search")
|
router.register(r"search", views.SearchViewSet, "search")
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(
|
re_path(
|
||||||
"releases/(?P<uuid>[0-9a-z-]+)/$",
|
"releases/(?P<uuid>[0-9a-z-]+)/$",
|
||||||
views.ReleaseDetail.as_view(),
|
views.ReleaseDetail.as_view(),
|
||||||
name="release-detail",
|
name="release-detail",
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
"artists/(?P<uuid>[0-9a-z-]+)/$",
|
"artists/(?P<uuid>[0-9a-z-]+)/$",
|
||||||
views.ArtistDetail.as_view(),
|
views.ArtistDetail.as_view(),
|
||||||
name="artist-detail",
|
name="artist-detail",
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
"release-groups/browse/(?P<artist_uuid>[0-9a-z-]+)/$",
|
"release-groups/browse/(?P<artist_uuid>[0-9a-z-]+)/$",
|
||||||
views.ReleaseGroupBrowse.as_view(),
|
views.ReleaseGroupBrowse.as_view(),
|
||||||
name="release-group-browse",
|
name="release-group-browse",
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
"releases/browse/(?P<release_group_uuid>[0-9a-z-]+)/$",
|
"releases/browse/(?P<release_group_uuid>[0-9a-z-]+)/$",
|
||||||
views.ReleaseBrowse.as_view(),
|
views.ReleaseBrowse.as_view(),
|
||||||
name="release-browse",
|
name="release-browse",
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include
|
||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(
|
re_path(
|
||||||
r"^musicbrainz/",
|
r"^musicbrainz/",
|
||||||
include(
|
include(
|
||||||
("funkwhale_api.musicbrainz.urls", "musicbrainz"), namespace="musicbrainz"
|
("funkwhale_api.musicbrainz.urls", "musicbrainz"), namespace="musicbrainz"
|
||||||
|
|
|
@ -38,14 +38,12 @@ def validate(config):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_radio_queryset(patch, config, radio_qs):
|
def build_radio_queryset(patch, radio_qs):
|
||||||
"""Take a troi patch and its arg, match the missing mbid and then build a radio queryset"""
|
"""Take a troi patch, match the missing mbid and then build a radio queryset"""
|
||||||
|
|
||||||
logger.info("Config used for troi radio generation is " + str(config))
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
recommendations = troi.core.generate_playlist(patch, config)
|
recommendations = patch.generate_playlist()
|
||||||
except ConnectTimeout:
|
except ConnectTimeout:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Timed out while connecting to ListenBrainz. No candidates could be retrieved for the radio."
|
"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:
|
if not recommendations:
|
||||||
raise ValueError("No candidates found by troi")
|
raise ValueError("No candidates found by troi")
|
||||||
|
|
||||||
recommended_recording_mbids = [
|
recommended_mbids = [
|
||||||
recommended_recording.mbid
|
recommended_recording.mbid
|
||||||
for recommended_recording in recommendations.playlists[0].recordings
|
for recommended_recording in recommendations.playlists[0].recordings
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Searching for MusicBrainz ID in Funkwhale database")
|
logger.info("Searching for MusicBrainz ID in Funkwhale database")
|
||||||
|
|
||||||
qs_mbid = music_models.Track.objects.all().filter(
|
qs_recommended = (
|
||||||
mbid__in=recommended_recording_mbids
|
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 = [
|
recommended_mbids_not_qs = [
|
||||||
mbid for mbid in recommended_recording_mbids if mbid not in mbids_found
|
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")
|
logger.info("MusicBrainz IDs found in Funkwhale database and redis")
|
||||||
mbids_found = [str(i.mbid) for i in qs_mbid]
|
qs_recommended_mbid.extend(cached_match_mbid)
|
||||||
mbids_found.extend([i for i in cached_mbid_match.keys()])
|
mbids_found = qs_recommended_mbid
|
||||||
elif qs_mbid and not cached_mbid_match:
|
elif qs_recommended and not cached_match_mbid:
|
||||||
logger.info("MusicBrainz IDs found in Funkwhale database")
|
logger.info("MusicBrainz IDs found in Funkwhale database")
|
||||||
mbids_found = mbids_found
|
mbids_found = qs_recommended_mbid
|
||||||
elif not qs_mbid and cached_mbid_match:
|
elif not qs_recommended and cached_match_mbid:
|
||||||
logger.info("MusicBrainz IDs found in redis cache")
|
logger.info("MusicBrainz IDs found in redis cache")
|
||||||
mbids_found = [i for i in cached_mbid_match.keys()]
|
mbids_found = cached_match_mbid
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Couldn't find any matches in Funkwhale database. Trying to match all"
|
"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)
|
+ 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")
|
raise ValueError("No candidates found for troi radio")
|
||||||
|
|
||||||
logger.info("Radio generation with troi took " + str(end_time_resolv - start_time))
|
mbids_found_pks = list(
|
||||||
logger.info("qs_mbid is " + str(mbids_found))
|
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(
|
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:
|
if mbids_found and not cached_match:
|
||||||
return radio_qs.filter(mbid__in=mbids_found)
|
return radio_qs.filter(pk__in=mbids_found_pks_unique)
|
||||||
|
|
||||||
if not qs_mbid and cached_mbid_match:
|
if not mbids_found and cached_match:
|
||||||
return radio_qs.filter(pk__in=cached_mbid_match.values())
|
return radio_qs.filter(pk__in=cached_match.values())
|
||||||
|
|
||||||
|
|
||||||
class TroiPatch:
|
class TroiPatch:
|
||||||
|
@ -132,4 +143,4 @@ class TroiPatch:
|
||||||
def get_queryset(self, config, qs):
|
def get_queryset(self, config, qs):
|
||||||
patch_string = config.pop("patch")
|
patch_string = config.pop("patch")
|
||||||
patch = patches[patch_string]
|
patch = patches[patch_string]
|
||||||
return build_radio_queryset(patch(), config, qs)
|
return build_radio_queryset(patch(config), qs)
|
||||||
|
|
|
@ -6,6 +6,10 @@ from rest_framework import renderers
|
||||||
import funkwhale_api
|
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
|
# from https://stackoverflow.com/a/8915039
|
||||||
# because I want to avoid a lxml dependency just for outputting cdata properly
|
# because I want to avoid a lxml dependency just for outputting cdata properly
|
||||||
# in a RSS feed
|
# in a RSS feed
|
||||||
|
@ -31,10 +35,14 @@ ET._serialize_xml = ET._serialize["xml"] = _serialize_xml
|
||||||
|
|
||||||
def structure_payload(data):
|
def structure_payload(data):
|
||||||
payload = {
|
payload = {
|
||||||
|
# funkwhaleVersion is deprecated and will be removed in a future
|
||||||
|
# release. Use serverVersion instead.
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
|
"serverVersion": funkwhale_api.__version__,
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
|
"openSubsonic": "true",
|
||||||
}
|
}
|
||||||
payload.update(data)
|
payload.update(data)
|
||||||
if "detail" in payload:
|
if "detail" in payload:
|
||||||
|
@ -81,6 +89,10 @@ def dict_to_xml_tree(root_tag, d, parent=None):
|
||||||
el = ET.Element(key)
|
el = ET.Element(key)
|
||||||
el.text = str(obj)
|
el.text = str(obj)
|
||||||
root.append(el)
|
root.append(el)
|
||||||
|
elif isinstance(value, TagValue):
|
||||||
|
el = ET.Element(key)
|
||||||
|
el.text = str(value)
|
||||||
|
root.append(el)
|
||||||
else:
|
else:
|
||||||
if key == "value":
|
if key == "value":
|
||||||
root.text = str(value)
|
root.text = str(value)
|
||||||
|
|
|
@ -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 models as music_models
|
||||||
from funkwhale_api.music import utils as music_utils
|
from funkwhale_api.music import utils as music_utils
|
||||||
|
|
||||||
|
from .renderers import TagValue
|
||||||
|
|
||||||
|
|
||||||
def to_subsonic_date(date):
|
def to_subsonic_date(date):
|
||||||
"""
|
"""
|
||||||
|
@ -50,6 +52,7 @@ def get_artist_data(artist_values):
|
||||||
"name": artist_values["name"],
|
"name": artist_values["name"],
|
||||||
"albumCount": artist_values["_albums_count"],
|
"albumCount": artist_values["_albums_count"],
|
||||||
"coverArt": "ar-{}".format(artist_values["id"]),
|
"coverArt": "ar-{}".format(artist_values["id"]),
|
||||||
|
"musicBrainzId": str(artist_values.get("mbid", "")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -58,7 +61,7 @@ class GetArtistsSerializer(serializers.Serializer):
|
||||||
payload = {"ignoredArticles": "", "index": []}
|
payload = {"ignoredArticles": "", "index": []}
|
||||||
queryset = queryset.with_albums_count()
|
queryset = queryset.with_albums_count()
|
||||||
queryset = queryset.order_by(functions.Lower("name"))
|
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)
|
first_letter_mapping = collections.defaultdict(list)
|
||||||
for artist in values:
|
for artist in values:
|
||||||
|
@ -102,6 +105,23 @@ class GetArtistSerializer(serializers.Serializer):
|
||||||
return payload
|
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):
|
def get_track_data(album, track, upload):
|
||||||
data = {
|
data = {
|
||||||
"id": track.pk,
|
"id": track.pk,
|
||||||
|
@ -126,11 +146,13 @@ def get_track_data(album, track, upload):
|
||||||
"albumId": album.pk if album else "",
|
"albumId": album.pk if album else "",
|
||||||
"artistId": album.artist.pk if album else track.artist.pk,
|
"artistId": album.artist.pk if album else track.artist.pk,
|
||||||
"type": "music",
|
"type": "music",
|
||||||
|
"mediaType": "song",
|
||||||
|
"musicBrainzId": str(track.mbid or ""),
|
||||||
}
|
}
|
||||||
if album and album.attachment_cover_id:
|
if album and album.attachment_cover_id:
|
||||||
data["coverArt"] = f"al-{album.id}"
|
data["coverArt"] = f"al-{album.id}"
|
||||||
if upload.bitrate:
|
if upload.bitrate:
|
||||||
data["bitrate"] = int(upload.bitrate / 1000)
|
data["bitRate"] = int(upload.bitrate / 1000)
|
||||||
if upload.size:
|
if upload.size:
|
||||||
data["size"] = upload.size
|
data["size"] = upload.size
|
||||||
if album and album.release_date:
|
if album and album.release_date:
|
||||||
|
@ -149,13 +171,17 @@ def get_album2_data(album):
|
||||||
"created": to_subsonic_date(album.creation_date),
|
"created": to_subsonic_date(album.creation_date),
|
||||||
"duration": album.duration,
|
"duration": album.duration,
|
||||||
"playCount": album.tracks.aggregate(l=Count("listenings"))["l"] or 0,
|
"playCount": album.tracks.aggregate(l=Count("listenings"))["l"] or 0,
|
||||||
|
"mediaType": "album",
|
||||||
|
"musicBrainzId": str(album.mbid or ""),
|
||||||
}
|
}
|
||||||
if album.attachment_cover_id:
|
if album.attachment_cover_id:
|
||||||
payload["coverArt"] = f"al-{album.id}"
|
payload["coverArt"] = f"al-{album.id}"
|
||||||
if album.tagged_items:
|
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
|
# exposes only first genre since the specification uses singular noun
|
||||||
first_genre = album.tagged_items.first()
|
payload["genre"] = genres[0]["name"] if len(genres) > 0 else ""
|
||||||
payload["genre"] = first_genre.tag.name if first_genre else ""
|
# OpenSubsonic full genre list
|
||||||
|
payload["genres"] = genres
|
||||||
if album.release_date:
|
if album.release_date:
|
||||||
payload["year"] = album.release_date.year
|
payload["year"] = album.release_date.year
|
||||||
try:
|
try:
|
||||||
|
@ -343,7 +369,7 @@ def get_channel_episode_data(upload, channel_id):
|
||||||
"genre": "Podcast",
|
"genre": "Podcast",
|
||||||
"size": upload.size if upload.size else "",
|
"size": upload.size if upload.size else "",
|
||||||
"duration": upload.duration if upload.duration 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",
|
"contentType": upload.mimetype or "audio/mpeg",
|
||||||
"suffix": upload.extension or "mp3",
|
"suffix": upload.extension or "mp3",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
|
|
|
@ -180,6 +180,19 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
||||||
}
|
}
|
||||||
return response.Response(data, status=200)
|
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(
|
@action(
|
||||||
detail=False,
|
detail=False,
|
||||||
methods=["get", "post"],
|
methods=["get", "post"],
|
||||||
|
@ -255,7 +268,9 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
||||||
)
|
)
|
||||||
@find_object(music_models.Artist.objects.all(), filter_playable=True)
|
@find_object(music_models.Artist.objects.all(), filter_playable=True)
|
||||||
def get_artist_info2(self, request, *args, **kwargs):
|
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)
|
return response.Response(payload, status=200)
|
||||||
|
|
||||||
|
@ -523,7 +538,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
||||||
"search_fields": ["name"],
|
"search_fields": ["name"],
|
||||||
"queryset": (
|
"queryset": (
|
||||||
music_models.Artist.objects.with_albums_count().values(
|
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],
|
"serializer": lambda qs: [serializers.get_artist_data(a) for a in qs],
|
||||||
|
|
|
@ -24,7 +24,7 @@ class TagFilter(filters.FilterSet):
|
||||||
|
|
||||||
def get_by_similar_tags(qs, tags):
|
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.
|
Annotate the queryset so you can order later by number of matches.
|
||||||
"""
|
"""
|
||||||
qs = qs.filter(tagged_items__tag__name__in=tags).annotate(
|
qs = qs.filter(tagged_items__tag__name__in=tags).annotate(
|
||||||
|
|
|
@ -36,7 +36,6 @@ def delete_non_alnum_characters(text):
|
||||||
def resolve_recordings_to_fw_track(recordings):
|
def resolve_recordings_to_fw_track(recordings):
|
||||||
"""
|
"""
|
||||||
Tries to match a troi recording entity to a fw track using the typesense index.
|
Tries to match a troi recording entity to a fw track using the typesense index.
|
||||||
It will save the results in the match_mbid attribute of the Track table.
|
|
||||||
For test purposes : if multiple fw tracks are returned, we log the information
|
For test purposes : if multiple fw tracks are returned, we log the information
|
||||||
but only keep the best result in db to avoid duplicates.
|
but only keep the best result in db to avoid duplicates.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
|
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
|
||||||
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
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
|
from funkwhale_api.common import admin
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from django.conf.urls import url
|
from django.urls import re_path
|
||||||
|
|
||||||
from funkwhale_api.common import routers
|
from funkwhale_api.common import routers
|
||||||
|
|
||||||
|
@ -8,6 +8,6 @@ router = routers.OptionalSlashRouter()
|
||||||
router.register(r"users", views.UserViewSet, "users")
|
router.register(r"users", views.UserViewSet, "users")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r"^users/login/?$", views.login, name="login"),
|
re_path(r"^users/login/?$", views.login, name="login"),
|
||||||
url(r"^users/logout/?$", views.logout, name="logout"),
|
re_path(r"^users/logout/?$", views.logout, name="logout"),
|
||||||
] + router.urls
|
] + router.urls
|
||||||
|
|
|
@ -12,7 +12,7 @@ from django.db.models import JSONField
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
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 django_auth_ldap.backend import populate_user as ldap_populate_user
|
||||||
from oauth2_provider import models as oauth2_models
|
from oauth2_provider import models as oauth2_models
|
||||||
from oauth2_provider import validators as oauth2_validators
|
from oauth2_provider import validators as oauth2_validators
|
||||||
|
|
|
@ -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 django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
from funkwhale_api.common import routers
|
from funkwhale_api.common import routers
|
||||||
|
@ -10,7 +10,9 @@ router.register(r"apps", views.ApplicationViewSet, "apps")
|
||||||
router.register(r"grants", views.GrantViewSet, "grants")
|
router.register(r"grants", views.GrantViewSet, "grants")
|
||||||
|
|
||||||
urlpatterns = router.urls + [
|
urlpatterns = router.urls + [
|
||||||
url("^authorize/$", csrf_exempt(views.AuthorizeView.as_view()), name="authorize"),
|
re_path(
|
||||||
url("^token/$", views.TokenView.as_view(), name="token"),
|
"^authorize/$", csrf_exempt(views.AuthorizeView.as_view()), name="authorize"
|
||||||
url("^revoke/$", views.RevokeTokenView.as_view(), name="revoke"),
|
),
|
||||||
|
re_path("^token/$", views.TokenView.as_view(), name="token"),
|
||||||
|
re_path("^revoke/$", views.RevokeTokenView.as_view(), name="revoke"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -200,7 +200,7 @@ class AuthorizeView(views.APIView, oauth_views.AuthorizationView):
|
||||||
return self.json_payload({"non_field_errors": ["Invalid application"]}, 400)
|
return self.json_payload({"non_field_errors": ["Invalid application"]}, 400)
|
||||||
|
|
||||||
def redirect(self, redirect_to, application):
|
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
|
# Web client need this to be able to redirect the user
|
||||||
query = urllib.parse.urlparse(redirect_to).query
|
query = urllib.parse.urlparse(redirect_to).query
|
||||||
code = urllib.parse.parse_qs(query)["code"][0]
|
code = urllib.parse.parse_qs(query)["code"][0]
|
||||||
|
|
|
@ -1,38 +1,38 @@
|
||||||
from dj_rest_auth import views as rest_auth_views
|
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 django.views.generic import TemplateView
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# URLs that do not require a session or valid token
|
# URLs that do not require a session or valid token
|
||||||
url(
|
re_path(
|
||||||
r"^password/reset/$",
|
r"^password/reset/$",
|
||||||
views.PasswordResetView.as_view(),
|
views.PasswordResetView.as_view(),
|
||||||
name="rest_password_reset",
|
name="rest_password_reset",
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^password/reset/confirm/$",
|
r"^password/reset/confirm/$",
|
||||||
views.PasswordResetConfirmView.as_view(),
|
views.PasswordResetConfirmView.as_view(),
|
||||||
name="rest_password_reset_confirm",
|
name="rest_password_reset_confirm",
|
||||||
),
|
),
|
||||||
# URLs that require a user to be logged in with a valid session / token.
|
# 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"
|
r"^user/$", rest_auth_views.UserDetailsView.as_view(), name="rest_user_details"
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^password/change/$",
|
r"^password/change/$",
|
||||||
views.PasswordChangeView.as_view(),
|
views.PasswordChangeView.as_view(),
|
||||||
name="rest_password_change",
|
name="rest_password_change",
|
||||||
),
|
),
|
||||||
# Registration URLs
|
# Registration URLs
|
||||||
url(r"^registration/$", views.RegisterView.as_view(), name="rest_register"),
|
re_path(r"^registration/$", views.RegisterView.as_view(), name="rest_register"),
|
||||||
url(
|
re_path(
|
||||||
r"^registration/verify-email/?$",
|
r"^registration/verify-email/?$",
|
||||||
views.VerifyEmailView.as_view(),
|
views.VerifyEmailView.as_view(),
|
||||||
name="rest_verify_email",
|
name="rest_verify_email",
|
||||||
),
|
),
|
||||||
url(
|
re_path(
|
||||||
r"^registration/change-password/?$",
|
r"^registration/change-password/?$",
|
||||||
views.PasswordChangeView.as_view(),
|
views.PasswordChangeView.as_view(),
|
||||||
name="change_password",
|
name="change_password",
|
||||||
|
@ -47,7 +47,7 @@ urlpatterns = [
|
||||||
# If you don't want to use API on that step, then just use ConfirmEmailView
|
# If you don't want to use API on that step, then just use ConfirmEmailView
|
||||||
# view from:
|
# view from:
|
||||||
# https://github.com/pennersr/django-allauth/blob/a62a370681/allauth/account/views.py#L291
|
# https://github.com/pennersr/django-allauth/blob/a62a370681/allauth/account/views.py#L291
|
||||||
url(
|
re_path(
|
||||||
r"^registration/account-confirm-email/(?P<key>\w+)/?$",
|
r"^registration/account-confirm-email/(?P<key>\w+)/?$",
|
||||||
TemplateView.as_view(),
|
TemplateView.as_view(),
|
||||||
name="account_confirm_email",
|
name="account_confirm_email",
|
||||||
|
|
|
@ -340,4 +340,8 @@ class UserChangeEmailSerializer(serializers.Serializer):
|
||||||
email=request.user.email,
|
email=request.user.email,
|
||||||
defaults={"verified": False, "primary": True},
|
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()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from allauth.account.adapter import get_adapter
|
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 import views as rest_auth_views
|
||||||
from dj_rest_auth.registration import views as registration_views
|
from dj_rest_auth.registration import views as registration_views
|
||||||
from django import http
|
from django import http
|
||||||
|
@ -11,7 +12,7 @@ from rest_framework import mixins, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
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
|
from . import models, serializers, tasks
|
||||||
|
|
||||||
|
@ -37,7 +38,7 @@ class RegisterView(registration_views.RegisterView):
|
||||||
user = super().perform_create(serializer)
|
user = super().perform_create(serializer)
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
# manual approval, we need to send the confirmation e-mail by hand
|
# 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:
|
if user.invitation:
|
||||||
user.invitation.set_invited_user(user)
|
user.invitation.set_invited_user(user)
|
||||||
|
|
||||||
|
|
Plik diff jest za duży
Load Diff
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "funkwhale-api"
|
name = "funkwhale-api"
|
||||||
version = "1.3.3"
|
version = "1.4.0"
|
||||||
description = "Funkwhale API"
|
description = "Funkwhale API"
|
||||||
|
|
||||||
authors = ["Funkwhale Collective"]
|
authors = ["Funkwhale Collective"]
|
||||||
|
@ -25,102 +25,104 @@ exclude = ["tests"]
|
||||||
funkwhale-manage = 'funkwhale_api.main:main'
|
funkwhale-manage = 'funkwhale_api.main:main'
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.8"
|
python = "^3.8,<3.13"
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
dj-rest-auth = { extras = ["with_social"], version = "2.2.8" }
|
dj-rest-auth = "5.0.2"
|
||||||
django = "==3.2.20"
|
django = "4.2.9"
|
||||||
django-allauth = "==0.42.0"
|
django-allauth = "0.55.2"
|
||||||
django-cache-memoize = "0.1.10"
|
django-cache-memoize = "0.1.10"
|
||||||
django-cacheops = "==6.1"
|
django-cacheops = "==7.0.2"
|
||||||
django-cleanup = "==6.0.0"
|
django-cleanup = "==8.1.0"
|
||||||
django-cors-headers = "==3.13.0"
|
django-cors-headers = "==4.3.1"
|
||||||
django-dynamic-preferences = "==1.14.0"
|
django-dynamic-preferences = "==1.14.0"
|
||||||
django-environ = "==0.10.0"
|
django-environ = "==0.10.0"
|
||||||
django-filter = "==22.1"
|
django-filter = "==23.5"
|
||||||
django-oauth-toolkit = "2.2.0"
|
django-oauth-toolkit = "2.2.0"
|
||||||
django-redis = "==5.2.0"
|
django-redis = "==5.2.0"
|
||||||
django-storages = "==1.13.2"
|
django-storages = "==1.13.2"
|
||||||
django-versatileimagefield = "==2.2"
|
django-versatileimagefield = "==3.1"
|
||||||
djangorestframework = "==3.14.0"
|
djangorestframework = "==3.14.0"
|
||||||
drf-spectacular = "==0.26.1"
|
drf-spectacular = "==0.26.5"
|
||||||
markdown = "==3.4.4"
|
markdown = "==3.4.4"
|
||||||
persisting-theory = "==1.0"
|
persisting-theory = "==1.0"
|
||||||
psycopg2 = "==2.9.7"
|
psycopg2 = "==2.9.9"
|
||||||
redis = "==4.5.5"
|
redis = "==5.0.1"
|
||||||
|
|
||||||
# Django LDAP
|
# Django LDAP
|
||||||
django-auth-ldap = "==4.1.0"
|
django-auth-ldap = "==4.1.0"
|
||||||
python-ldap = "==3.4.3"
|
python-ldap = "==3.4.4"
|
||||||
|
|
||||||
# Channels
|
# Channels
|
||||||
channels = { extras = ["daphne"], version = "==4.0.0" }
|
channels = { extras = ["daphne"], version = "==4.0.0" }
|
||||||
channels-redis = "==4.1.0"
|
channels-redis = "==4.1.0"
|
||||||
|
|
||||||
# Celery
|
# Celery
|
||||||
kombu = "==5.2.4"
|
kombu = "5.3.4"
|
||||||
celery = "==5.2.7"
|
celery = "5.3.6"
|
||||||
|
|
||||||
# Deployment
|
# Deployment
|
||||||
gunicorn = "==20.1.0"
|
gunicorn = "==21.2.0"
|
||||||
uvicorn = { version = "==0.20.0", extras = ["standard"] }
|
uvicorn = { version = "==0.20.0", extras = ["standard"] }
|
||||||
|
|
||||||
# Libs
|
# Libs
|
||||||
aiohttp = "==3.8.5"
|
aiohttp = "3.9.1"
|
||||||
arrow = "==1.2.3"
|
arrow = "==1.2.3"
|
||||||
backports-zoneinfo = { version = "==0.2.1", python = "<3.9" }
|
backports-zoneinfo = { version = "==0.2.1", python = "<3.9" }
|
||||||
bleach = "==5.0.1"
|
bleach = "==6.1.0"
|
||||||
boto3 = "==1.26.161"
|
boto3 = "==1.26.161"
|
||||||
click = "==8.1.7"
|
click = "==8.1.7"
|
||||||
cryptography = "==38.0.4"
|
cryptography = "==41.0.7"
|
||||||
feedparser = "==6.0.10"
|
feedparser = "==6.0.10"
|
||||||
|
liblistenbrainz = "==0.5.5"
|
||||||
musicbrainzngs = "==0.7.1"
|
musicbrainzngs = "==0.7.1"
|
||||||
mutagen = "==1.46.0"
|
mutagen = "==1.46.0"
|
||||||
pillow = "==9.3.0"
|
pillow = "==10.2.0"
|
||||||
pydub = "==0.25.1"
|
pydub = "==0.25.1"
|
||||||
pyld = "==2.0.3"
|
pyld = "==2.0.3"
|
||||||
python-magic = "==0.4.27"
|
python-magic = "==0.4.27"
|
||||||
requests = "==2.28.2"
|
requests = "==2.31.0"
|
||||||
requests-http-message-signatures = "==0.3.1"
|
requests-http-message-signatures = "==0.3.1"
|
||||||
sentry-sdk = "==1.19.1"
|
sentry-sdk = "==1.19.1"
|
||||||
watchdog = "==2.2.1"
|
watchdog = "==4.0.0"
|
||||||
troi = { git = "https://github.com/metabrainz/troi-recommendation-playground.git", branch = "main"}
|
troi = "==2024.1.26.0"
|
||||||
lb-matching-tools = { git = "https://github.com/metabrainz/listenbrainz-matching-tools.git", branch = "main"}
|
lb-matching-tools = "==2024.1.25.0rc1"
|
||||||
unidecode = "==1.3.6"
|
unidecode = "==1.3.7"
|
||||||
|
pycountry = "23.12.11"
|
||||||
|
|
||||||
# Typesense
|
# Typesense
|
||||||
typesense = { version = "==0.15.1", optional = true }
|
typesense = { version = "==0.15.1", optional = true }
|
||||||
|
|
||||||
# Dependencies pinning
|
# Dependencies pinning
|
||||||
ipython = "==7.34.0"
|
ipython = "==8.12.3"
|
||||||
pluralizer = "==1.2.0"
|
pluralizer = "==1.2.0"
|
||||||
service-identity = "==21.1.0"
|
service-identity = "==24.1.0"
|
||||||
unicode-slugify = "==0.1.5"
|
unicode-slugify = "==0.1.5"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
aioresponses = "==0.7.4"
|
aioresponses = "==0.7.6"
|
||||||
asynctest = "==0.13.0"
|
asynctest = "==0.13.0"
|
||||||
black = "==23.3.0"
|
black = "==24.1.1"
|
||||||
coverage = { version = "==6.5.0", extras = ["toml"] }
|
coverage = { version = "==7.4.1", extras = ["toml"] }
|
||||||
debugpy = "==1.6.7.post1"
|
debugpy = "==1.6.7.post1"
|
||||||
django-coverage-plugin = "==3.0.0"
|
django-coverage-plugin = "==3.0.0"
|
||||||
django-debug-toolbar = "==3.8.1"
|
django-debug-toolbar = "==4.2.0"
|
||||||
factory-boy = "==3.2.1"
|
factory-boy = "==3.2.1"
|
||||||
faker = "==15.3.4"
|
faker = "==23.2.1"
|
||||||
flake8 = "==3.9.2"
|
flake8 = "==3.9.2"
|
||||||
ipdb = "==0.13.13"
|
ipdb = "==0.13.13"
|
||||||
prompt-toolkit = "==3.0.39"
|
pytest = "==8.0.0"
|
||||||
pytest = "==7.2.2"
|
|
||||||
pytest-asyncio = "==0.21.0"
|
pytest-asyncio = "==0.21.0"
|
||||||
|
prompt-toolkit = "==3.0.41"
|
||||||
pytest-cov = "==4.0.0"
|
pytest-cov = "==4.0.0"
|
||||||
pytest-django = "==4.5.2"
|
pytest-django = "==4.5.2"
|
||||||
pytest-env = "==0.8.1"
|
pytest-env = "==1.1.3"
|
||||||
pytest-mock = "==3.10.0"
|
pytest-mock = "==3.10.0"
|
||||||
pytest-randomly = "==3.12.0"
|
pytest-randomly = "==3.12.0"
|
||||||
pytest-sugar = "==0.9.7"
|
pytest-sugar = "==1.0.0"
|
||||||
requests-mock = "==1.10.0"
|
requests-mock = "==1.10.0"
|
||||||
pylint = "==2.17.2"
|
pylint = "==3.0.3"
|
||||||
pylint-django = "==2.5.3"
|
pylint-django = "==2.5.5"
|
||||||
django-extensions = "==3.2.3"
|
django-extensions = "==3.2.3"
|
||||||
|
|
||||||
[tool.poetry.extras]
|
[tool.poetry.extras]
|
||||||
|
|
|
@ -108,7 +108,7 @@ def test_get_default_head_tags(preferences, settings):
|
||||||
{
|
{
|
||||||
"tag": "meta",
|
"tag": "meta",
|
||||||
"property": "og:image",
|
"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 + "/"},
|
{"tag": "meta", "property": "og:url", "content": settings.FUNKWHALE_URL + "/"},
|
||||||
]
|
]
|
||||||
|
|
|
@ -17,7 +17,7 @@ def test_get_ident_anonymous(api_request):
|
||||||
def test_get_ident_authenticated(api_request, factories):
|
def test_get_ident_authenticated(api_request, factories):
|
||||||
user = factories["users.User"]()
|
user = factories["users.User"]()
|
||||||
request = api_request.get("/")
|
request = api_request.get("/")
|
||||||
expected = {"id": user.pk, "type": "authenticated"}
|
expected = {"id": f"{user.pk}", "type": "authenticated"}
|
||||||
assert throttling.get_ident(user, request) == expected
|
assert throttling.get_ident(user, request) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ def test_get_ident_authenticated(api_request, factories):
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
"create",
|
"create",
|
||||||
{"id": 42, "type": "authenticated"},
|
{"id": "42", "type": "authenticated"},
|
||||||
"throttling:create:authenticated:42",
|
"throttling:create:authenticated:42",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -269,6 +269,7 @@ def test_throttle_calls_attach_info(method, mocker):
|
||||||
|
|
||||||
|
|
||||||
def test_allow_request(api_request, settings, mocker):
|
def test_allow_request(api_request, settings, mocker):
|
||||||
|
settings.THROTTLING_ENABLED = True
|
||||||
settings.THROTTLING_RATES = {"test": {"rate": "2/s"}}
|
settings.THROTTLING_RATES = {"test": {"rate": "2/s"}}
|
||||||
ip = "92.92.92.92"
|
ip = "92.92.92.92"
|
||||||
request = api_request.get("/", HTTP_X_FORWARDED_FOR=ip)
|
request = api_request.get("/", HTTP_X_FORWARDED_FOR=ip)
|
||||||
|
|
|
@ -160,7 +160,7 @@ def test_cannot_approve_reject_without_perm(
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit(logged_in_api_client, now_time, settings, mocker):
|
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 = {
|
expected = {
|
||||||
"ident": expected_ident,
|
"ident": expected_ident,
|
||||||
|
|
|
@ -0,0 +1,333 @@
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import liblistenbrainz
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from config import plugins
|
||||||
|
from funkwhale_api.contrib.listenbrainz import funkwhale_ready
|
||||||
|
from funkwhale_api.favorites import models as favorites_models
|
||||||
|
from funkwhale_api.history import models as history_models
|
||||||
|
|
||||||
|
|
||||||
|
def test_listenbrainz_submit_listen(logged_in_client, mocker, factories):
|
||||||
|
config = plugins.get_plugin_config(
|
||||||
|
name="listenbrainz",
|
||||||
|
description="A plugin that allows you to submit or sync your listens and favorites to ListenBrainz.",
|
||||||
|
conf=[],
|
||||||
|
source=False,
|
||||||
|
)
|
||||||
|
handler = mocker.Mock()
|
||||||
|
plugins.register_hook(plugins.LISTENING_CREATED, config)(handler)
|
||||||
|
plugins.set_conf(
|
||||||
|
"listenbrainz",
|
||||||
|
{
|
||||||
|
"sync_listenings": True,
|
||||||
|
"sync_favorites": True,
|
||||||
|
"submit_favorites": True,
|
||||||
|
"sync_favorites": True,
|
||||||
|
"user_token": "blablabla",
|
||||||
|
},
|
||||||
|
user=logged_in_client.user,
|
||||||
|
)
|
||||||
|
plugins.enable_conf("listenbrainz", True, logged_in_client.user)
|
||||||
|
|
||||||
|
track = factories["music.Track"]()
|
||||||
|
url = reverse("api:v1:history:listenings-list")
|
||||||
|
logged_in_client.post(url, {"track": track.pk})
|
||||||
|
logged_in_client.get(url)
|
||||||
|
listening = history_models.Listening.objects.get(user=logged_in_client.user)
|
||||||
|
handler.assert_called_once_with(listening=listening, conf=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_listenings_from_listenbrainz(factories, mocker, caplog):
|
||||||
|
logger = logging.getLogger("plugins")
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
logger.addHandler(caplog.handler)
|
||||||
|
user = factories["users.User"]()
|
||||||
|
|
||||||
|
factories["music.Track"](mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476")
|
||||||
|
track = factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
|
||||||
|
factories["history.Listening"](
|
||||||
|
creation_date=datetime.datetime.fromtimestamp(1871, timezone.utc), track=track
|
||||||
|
)
|
||||||
|
|
||||||
|
conf = {
|
||||||
|
"user_name": user.username,
|
||||||
|
"user_token": "user_tolkien",
|
||||||
|
"sync_listenings": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
listens = {
|
||||||
|
"payload": {
|
||||||
|
"count": 25,
|
||||||
|
"user_id": "-- the MusicBrainz ID of the user --",
|
||||||
|
"listens": [
|
||||||
|
liblistenbrainz.Listen(
|
||||||
|
track_name="test",
|
||||||
|
artist_name="artist_test",
|
||||||
|
recording_mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476",
|
||||||
|
additional_info={"submission_client": "not funkwhale"},
|
||||||
|
listened_at=-3124224000,
|
||||||
|
),
|
||||||
|
liblistenbrainz.Listen(
|
||||||
|
track_name="test2",
|
||||||
|
artist_name="artist_test2",
|
||||||
|
recording_mbid="54c60860-f43d-484e-b691-7ab7ec8de559",
|
||||||
|
additional_info={
|
||||||
|
"submission_client": "Funkwhale ListenBrainz plugin"
|
||||||
|
},
|
||||||
|
listened_at=1871,
|
||||||
|
),
|
||||||
|
liblistenbrainz.Listen(
|
||||||
|
track_name="test3",
|
||||||
|
artist_name="artist_test3",
|
||||||
|
listened_at=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
no_more_listen = {
|
||||||
|
"payload": {
|
||||||
|
"count": 25,
|
||||||
|
"user_id": "Bilbo",
|
||||||
|
"listens": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mocker.patch.object(
|
||||||
|
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||||
|
"get_listens",
|
||||||
|
side_effect=[listens, no_more_listen],
|
||||||
|
)
|
||||||
|
|
||||||
|
funkwhale_ready.sync_listenings_from_listenbrainz(user, conf)
|
||||||
|
|
||||||
|
assert history_models.Listening.objects.filter(
|
||||||
|
track__mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
assert "Listen with ts 1871 skipped because already in db" in caplog.text
|
||||||
|
assert "Received listening that doesn't have a mbid. Skipping..." in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_favorites_from_listenbrainz(factories, mocker, caplog):
|
||||||
|
logger = logging.getLogger("plugins")
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
logger.addHandler(caplog.handler)
|
||||||
|
user = factories["users.User"]()
|
||||||
|
# track lb fav
|
||||||
|
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
|
||||||
|
# random track
|
||||||
|
factories["music.Track"]()
|
||||||
|
# track lb neutral
|
||||||
|
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
|
||||||
|
favorite = factories["favorites.TrackFavorite"](track=track, user=user)
|
||||||
|
# last_sync
|
||||||
|
track_last_sync = factories["music.Track"](
|
||||||
|
mbid="c878ef2f-c08d-4a81-a047-f2a9f978cec7"
|
||||||
|
)
|
||||||
|
factories["favorites.TrackFavorite"](track=track_last_sync, source="Listenbrainz")
|
||||||
|
|
||||||
|
conf = {
|
||||||
|
"user_name": user.username,
|
||||||
|
"user_token": "user_tolkien",
|
||||||
|
"sync_favorites": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
feedbacks = {
|
||||||
|
"count": 5,
|
||||||
|
"feedback": [
|
||||||
|
{
|
||||||
|
"created": 1701116226,
|
||||||
|
"recording_mbid": "195565db-65f9-4d0d-b347-5f0c85509528",
|
||||||
|
"score": 1,
|
||||||
|
"user_id": user.username,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"created": 1701116214,
|
||||||
|
"recording_mbid": "c5af5351-dbbf-4481-b52e-a480b6c57986",
|
||||||
|
"score": 0,
|
||||||
|
"user_id": user.username,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# last sync
|
||||||
|
"created": 1690775094,
|
||||||
|
"recording_mbid": "c878ef2f-c08d-4a81-a047-f2a9f978cec7",
|
||||||
|
"score": -1,
|
||||||
|
"user_id": user.username,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"created": 1690775093,
|
||||||
|
"recording_mbid": "1fd02cf2-7247-4715-8862-c378ec1965d2",
|
||||||
|
"score": 1,
|
||||||
|
"user_id": user.username,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"offset": 0,
|
||||||
|
"total_count": 4,
|
||||||
|
}
|
||||||
|
empty_feedback = {"count": 0, "feedback": [], "offset": 0, "total_count": 0}
|
||||||
|
mocker.patch.object(
|
||||||
|
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||||
|
"get_user_feedback",
|
||||||
|
side_effect=[feedbacks, empty_feedback],
|
||||||
|
)
|
||||||
|
|
||||||
|
funkwhale_ready.sync_favorites_from_listenbrainz(user, conf)
|
||||||
|
|
||||||
|
assert favorites_models.TrackFavorite.objects.filter(
|
||||||
|
track__mbid="195565db-65f9-4d0d-b347-5f0c85509528"
|
||||||
|
).exists()
|
||||||
|
with pytest.raises(favorites_models.TrackFavorite.DoesNotExist):
|
||||||
|
favorite.refresh_from_db()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_favorites_from_listenbrainz_since(factories, mocker, caplog):
|
||||||
|
logger = logging.getLogger("plugins")
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
logger.addHandler(caplog.handler)
|
||||||
|
user = factories["users.User"]()
|
||||||
|
# track lb fav
|
||||||
|
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
|
||||||
|
# track lb neutral
|
||||||
|
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
|
||||||
|
favorite = factories["favorites.TrackFavorite"](track=track, user=user)
|
||||||
|
# track should be not synced
|
||||||
|
factories["music.Track"](mbid="1fd02cf2-7247-4715-8862-c378ec196000")
|
||||||
|
# last_sync
|
||||||
|
track_last_sync = factories["music.Track"](
|
||||||
|
mbid="c878ef2f-c08d-4a81-a047-f2a9f978cec7"
|
||||||
|
)
|
||||||
|
factories["favorites.TrackFavorite"](
|
||||||
|
track=track_last_sync,
|
||||||
|
user=user,
|
||||||
|
source="Listenbrainz",
|
||||||
|
creation_date=datetime.datetime.fromtimestamp(1690775094),
|
||||||
|
)
|
||||||
|
|
||||||
|
conf = {
|
||||||
|
"user_name": user.username,
|
||||||
|
"user_token": "user_tolkien",
|
||||||
|
"sync_favorites": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
feedbacks = {
|
||||||
|
"count": 5,
|
||||||
|
"feedback": [
|
||||||
|
{
|
||||||
|
"created": 1701116226,
|
||||||
|
"recording_mbid": "195565db-65f9-4d0d-b347-5f0c85509528",
|
||||||
|
"score": 1,
|
||||||
|
"user_id": user.username,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"created": 1701116214,
|
||||||
|
"recording_mbid": "c5af5351-dbbf-4481-b52e-a480b6c57986",
|
||||||
|
"score": 0,
|
||||||
|
"user_id": user.username,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# last sync
|
||||||
|
"created": 1690775094,
|
||||||
|
"recording_mbid": "c878ef2f-c08d-4a81-a047-f2a9f978cec7",
|
||||||
|
"score": -1,
|
||||||
|
"user_id": user.username,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"created": 1690775093,
|
||||||
|
"recording_mbid": "1fd02cf2-7247-4715-8862-c378ec1965d2",
|
||||||
|
"score": 1,
|
||||||
|
"user_id": user.username,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"offset": 0,
|
||||||
|
"total_count": 4,
|
||||||
|
}
|
||||||
|
second_feedback = {
|
||||||
|
"count": 0,
|
||||||
|
"feedback": [
|
||||||
|
{
|
||||||
|
"created": 0,
|
||||||
|
"recording_mbid": "1fd02cf2-7247-4715-8862-c378ec196000",
|
||||||
|
"score": 1,
|
||||||
|
"user_id": user.username,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"offset": 0,
|
||||||
|
"total_count": 0,
|
||||||
|
}
|
||||||
|
mocker.patch.object(
|
||||||
|
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||||
|
"get_user_feedback",
|
||||||
|
side_effect=[feedbacks, second_feedback],
|
||||||
|
)
|
||||||
|
|
||||||
|
funkwhale_ready.sync_favorites_from_listenbrainz(user, conf)
|
||||||
|
|
||||||
|
assert favorites_models.TrackFavorite.objects.filter(
|
||||||
|
track__mbid="195565db-65f9-4d0d-b347-5f0c85509528"
|
||||||
|
).exists()
|
||||||
|
assert not favorites_models.TrackFavorite.objects.filter(
|
||||||
|
track__mbid="1fd02cf2-7247-4715-8862-c378ec196000"
|
||||||
|
).exists()
|
||||||
|
with pytest.raises(favorites_models.TrackFavorite.DoesNotExist):
|
||||||
|
favorite.refresh_from_db()
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_favorites_to_listenbrainz(factories, mocker, caplog):
|
||||||
|
logger = logging.getLogger("plugins")
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
logger.addHandler(caplog.handler)
|
||||||
|
user = factories["users.User"]()
|
||||||
|
|
||||||
|
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
|
||||||
|
|
||||||
|
factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
|
||||||
|
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
|
||||||
|
|
||||||
|
favorite = factories["favorites.TrackFavorite"](track=track)
|
||||||
|
conf = {
|
||||||
|
"user_name": user.username,
|
||||||
|
"user_token": "user_tolkien",
|
||||||
|
"submit_favorites": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
patch = mocker.patch.object(
|
||||||
|
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||||
|
"submit_user_feedback",
|
||||||
|
return_value="Success",
|
||||||
|
)
|
||||||
|
funkwhale_ready.submit_favorite_creation(favorite, conf)
|
||||||
|
|
||||||
|
patch.assert_called_once_with(1, track.mbid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_favorites_deletion(factories, mocker, caplog):
|
||||||
|
logger = logging.getLogger("plugins")
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
logger.addHandler(caplog.handler)
|
||||||
|
user = factories["users.User"]()
|
||||||
|
|
||||||
|
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
|
||||||
|
|
||||||
|
factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
|
||||||
|
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
|
||||||
|
|
||||||
|
favorite = factories["favorites.TrackFavorite"](track=track)
|
||||||
|
conf = {
|
||||||
|
"user_name": user.username,
|
||||||
|
"user_token": "user_tolkien",
|
||||||
|
"submit_favorites": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
patch = mocker.patch.object(
|
||||||
|
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||||
|
"submit_user_feedback",
|
||||||
|
return_value="Success",
|
||||||
|
)
|
||||||
|
funkwhale_ready.submit_favorite_deletion(favorite, conf)
|
||||||
|
|
||||||
|
patch.assert_called_once_with(0, track.mbid)
|
|
@ -6,7 +6,7 @@ from funkwhale_api import __version__ as api_version
|
||||||
from funkwhale_api.music.utils import SUPPORTED_EXTENSIONS
|
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")
|
url = reverse("api:v1:instance:nodeinfo-2.0")
|
||||||
response = api_client.get(url)
|
response = api_client.get(url)
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ def test_nodeinfo_default(api_client):
|
||||||
"version": "2.0",
|
"version": "2.0",
|
||||||
"software": OrderedDict([("name", "funkwhale"), ("version", api_version)]),
|
"software": OrderedDict([("name", "funkwhale"), ("version", api_version)]),
|
||||||
"protocols": ["activitypub"],
|
"protocols": ["activitypub"],
|
||||||
"services": OrderedDict([("inbound", []), ("outbound", [])]),
|
"services": OrderedDict([("inbound", ["atom1.0"]), ("outbound", ["atom1.0"])]),
|
||||||
"openRegistrations": False,
|
"openRegistrations": False,
|
||||||
"usage": {
|
"usage": {
|
||||||
"users": OrderedDict(
|
"users": OrderedDict(
|
||||||
|
@ -89,3 +89,74 @@ def test_nodeinfo_default(api_client):
|
||||||
}
|
}
|
||||||
|
|
||||||
assert response.data == expected
|
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
|
||||||
|
|
|
@ -7,6 +7,7 @@ from funkwhale_api.music.management.commands import (
|
||||||
check_inplace_files,
|
check_inplace_files,
|
||||||
fix_uploads,
|
fix_uploads,
|
||||||
prune_library,
|
prune_library,
|
||||||
|
prune_non_mbid_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
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:
|
for u in not_prunable:
|
||||||
u.refresh_from_db()
|
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()
|
||||||
|
|
|
@ -3,9 +3,32 @@ import pytest
|
||||||
from funkwhale_api.music import filters, models
|
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):
|
def test_album_filter_hidden(factories, mocker, queryset_equal_list):
|
||||||
factories["music.Album"]()
|
factories["music.Album"]()
|
||||||
cf = factories["moderation.UserFilter"](for_artist=True)
|
cf = factories["moderation.UserFilter"](for_artist=True)
|
||||||
|
|
||||||
hidden_album = factories["music.Album"](artist=cf.target_artist)
|
hidden_album = factories["music.Album"](artist=cf.target_artist)
|
||||||
|
|
||||||
qs = models.Album.objects.all()
|
qs = models.Album.objects.all()
|
||||||
|
|
|
@ -198,8 +198,8 @@ def test_can_get_pictures(name):
|
||||||
cover_data = data.get_picture("cover_front", "other")
|
cover_data = data.get_picture("cover_front", "other")
|
||||||
assert cover_data["mimetype"].startswith("image/")
|
assert cover_data["mimetype"].startswith("image/")
|
||||||
assert len(cover_data["content"]) > 0
|
assert len(cover_data["content"]) > 0
|
||||||
assert type(cover_data["content"]) == bytes
|
assert type(cover_data["content"]) is bytes
|
||||||
assert type(cover_data["description"]) == str
|
assert type(cover_data["description"]) is str
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
|
@ -245,7 +245,7 @@ def test_track_serializer(factories, to_api_date):
|
||||||
"title": track.title,
|
"title": track.title,
|
||||||
"position": track.position,
|
"position": track.position,
|
||||||
"disc_number": track.disc_number,
|
"disc_number": track.disc_number,
|
||||||
"uploads": [serializers.serialize_upload(upload)],
|
"uploads": [serializers.TrackUploadSerializer(upload).data],
|
||||||
"creation_date": to_api_date(track.creation_date),
|
"creation_date": to_api_date(track.creation_date),
|
||||||
"listen_url": track.listen_url,
|
"listen_url": track.listen_url,
|
||||||
"license": upload.track.license.code,
|
"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)
|
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"]()
|
upload = factories["music.Upload"]()
|
||||||
|
|
||||||
expected = {
|
expected = {
|
||||||
|
@ -387,7 +387,7 @@ def test_serialize_upload(factories):
|
||||||
"is_local": False,
|
"is_local": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
data = serializers.serialize_upload(upload)
|
data = serializers.TrackUploadSerializer(upload).data
|
||||||
assert data == expected
|
assert data == expected
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1400,3 +1400,53 @@ def test_fs_import(factories, cache, mocker, settings):
|
||||||
}
|
}
|
||||||
assert cache.get("fs-import:status") == "finished"
|
assert cache.get("fs-import:status") == "finished"
|
||||||
assert "Pruning dangling tracks" in cache.get("fs-import:logs")[-1]
|
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"
|
||||||
|
|
|
@ -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()}
|
result = {k: round(v) for k, v in utils.get_audio_file_data(f).items()}
|
||||||
|
|
||||||
assert result == expected
|
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://")
|
||||||
|
|
|
@ -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")
|
mocker.patch("funkwhale_api.typesense.utils.resolve_recordings_to_fw_track")
|
||||||
|
|
||||||
radio_qs = lb_recommendations.build_radio_queryset(
|
radio_qs = lb_recommendations.build_radio_queryset(
|
||||||
custom_factories.DummyPatch(), {"min_recordings": 1}, qs
|
custom_factories.DummyPatch({"min_recordings": 1}), qs
|
||||||
)
|
)
|
||||||
recommended_recording_mbids = [
|
recommended_recording_mbids = [
|
||||||
"87dfa566-21c3-45ed-bc42-1d345b8563fa",
|
"87dfa566-21c3-45ed-bc42-1d345b8563fa",
|
||||||
|
@ -46,7 +46,7 @@ def test_build_radio_queryset_without_fw_db(mocker):
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
lb_recommendations.build_radio_queryset(
|
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(
|
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(
|
assert list(
|
||||||
lb_recommendations.build_radio_queryset(
|
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]))
|
) == 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(
|
assert list(
|
||||||
lb_recommendations.build_radio_queryset(
|
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))
|
) == list(Track.objects.all().filter(pk=1))
|
||||||
|
|
||||||
|
|
||||||
def test_build_radio_queryset_catch_troi_ConnectTimeout(mocker):
|
def test_build_radio_queryset_catch_troi_ConnectTimeout(mocker):
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
troi.core,
|
troi.core.Patch,
|
||||||
"generate_playlist",
|
"generate_playlist",
|
||||||
side_effect=ConnectTimeout,
|
side_effect=ConnectTimeout,
|
||||||
)
|
)
|
||||||
|
@ -99,18 +99,18 @@ def test_build_radio_queryset_catch_troi_ConnectTimeout(mocker):
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
lb_recommendations.build_radio_queryset(
|
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):
|
def test_build_radio_queryset_catch_troi_no_candidates(mocker):
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
troi.core,
|
troi.core.Patch,
|
||||||
"generate_playlist",
|
"generate_playlist",
|
||||||
)
|
)
|
||||||
qs = Track.objects.all()
|
qs = Track.objects.all()
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
lb_recommendations.build_radio_queryset(
|
lb_recommendations.build_radio_queryset(
|
||||||
custom_factories.DummyPatch(), {"min_recordings": 1}, qs
|
custom_factories.DummyPatch({"min_recordings": 1}), qs
|
||||||
)
|
)
|
||||||
|
|
|
@ -17,6 +17,8 @@ from funkwhale_api.subsonic import renderers
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
|
"serverVersion": funkwhale_api.__version__,
|
||||||
|
"openSubsonic": "true",
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -30,6 +32,8 @@ from funkwhale_api.subsonic import renderers
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
|
"serverVersion": funkwhale_api.__version__,
|
||||||
|
"openSubsonic": "true",
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
"error": {"code": 10, "message": "something went wrong"},
|
"error": {"code": 10, "message": "something went wrong"},
|
||||||
},
|
},
|
||||||
|
@ -41,6 +45,8 @@ from funkwhale_api.subsonic import renderers
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
|
"serverVersion": funkwhale_api.__version__,
|
||||||
|
"openSubsonic": "true",
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
"error": {"code": 0, "message": "something went wrong"},
|
"error": {"code": 0, "message": "something went wrong"},
|
||||||
},
|
},
|
||||||
|
@ -59,6 +65,8 @@ def test_json_renderer():
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
|
"serverVersion": funkwhale_api.__version__,
|
||||||
|
"openSubsonic": "true",
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -71,9 +79,10 @@ def test_xml_renderer_dict_to_xml():
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
"item": [{"this": 1, "value": "text"}, {"some": "node"}],
|
"item": [{"this": 1, "value": "text"}, {"some": "node"}],
|
||||||
"list": [1, 2],
|
"list": [1, 2],
|
||||||
|
"some-tag": renderers.TagValue("foo"),
|
||||||
}
|
}
|
||||||
expected = """<?xml version="1.0" encoding="UTF-8"?>
|
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)
|
result = renderers.dict_to_xml_tree("key", payload)
|
||||||
exp = ET.fromstring(expected)
|
exp = ET.fromstring(expected)
|
||||||
assert ET.tostring(result) == ET.tostring(exp)
|
assert ET.tostring(result) == ET.tostring(exp)
|
||||||
|
@ -81,8 +90,9 @@ def test_xml_renderer_dict_to_xml():
|
||||||
|
|
||||||
def test_xml_renderer():
|
def test_xml_renderer():
|
||||||
payload = {"hello": "world"}
|
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 = '<?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
|
||||||
expected = expected.format(funkwhale_api.__version__).encode()
|
version = funkwhale_api.__version__
|
||||||
|
expected = expected.format(version, version).encode()
|
||||||
|
|
||||||
renderer = renderers.SubsonicXMLRenderer()
|
renderer = renderers.SubsonicXMLRenderer()
|
||||||
rendered = renderer.render(payload)
|
rendered = renderer.render(payload)
|
||||||
|
|
|
@ -4,7 +4,7 @@ import pytest
|
||||||
from django.db.models.aggregates import Count
|
from django.db.models.aggregates import Count
|
||||||
|
|
||||||
from funkwhale_api.music import models as music_models
|
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(
|
@pytest.mark.parametrize(
|
||||||
|
@ -90,12 +90,14 @@ def test_get_artists_serializer(factories):
|
||||||
"name": artist1.name,
|
"name": artist1.name,
|
||||||
"albumCount": 3,
|
"albumCount": 3,
|
||||||
"coverArt": f"ar-{artist1.id}",
|
"coverArt": f"ar-{artist1.id}",
|
||||||
|
"musicBrainzId": artist1.mbid,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": artist2.pk,
|
"id": artist2.pk,
|
||||||
"name": artist2.name,
|
"name": artist2.name,
|
||||||
"albumCount": 2,
|
"albumCount": 2,
|
||||||
"coverArt": f"ar-{artist2.id}",
|
"coverArt": f"ar-{artist2.id}",
|
||||||
|
"musicBrainzId": artist2.mbid,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -107,6 +109,7 @@ def test_get_artists_serializer(factories):
|
||||||
"name": artist3.name,
|
"name": artist3.name,
|
||||||
"albumCount": 0,
|
"albumCount": 0,
|
||||||
"coverArt": f"ar-{artist3.id}",
|
"coverArt": f"ar-{artist3.id}",
|
||||||
|
"musicBrainzId": artist3.mbid,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -147,6 +150,24 @@ def test_get_artist_serializer(factories):
|
||||||
assert serializers.GetArtistSerializer(artist).data == expected
|
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(
|
@pytest.mark.parametrize(
|
||||||
"mimetype, extension, expected",
|
"mimetype, extension, expected",
|
||||||
[
|
[
|
||||||
|
@ -184,6 +205,9 @@ def test_get_album_serializer(factories):
|
||||||
"year": album.release_date.year,
|
"year": album.release_date.year,
|
||||||
"coverArt": f"al-{album.id}",
|
"coverArt": f"al-{album.id}",
|
||||||
"genre": tagged_item.tag.name,
|
"genre": tagged_item.tag.name,
|
||||||
|
"genres": [{"name": tagged_item.tag.name}],
|
||||||
|
"mediaType": "album",
|
||||||
|
"musicBrainzId": album.mbid,
|
||||||
"duration": 43,
|
"duration": 43,
|
||||||
"playCount": album.tracks.aggregate(l=Count("listenings"))["l"] or 0,
|
"playCount": album.tracks.aggregate(l=Count("listenings"))["l"] or 0,
|
||||||
"song": [
|
"song": [
|
||||||
|
@ -200,13 +224,15 @@ def test_get_album_serializer(factories):
|
||||||
"contentType": upload.mimetype,
|
"contentType": upload.mimetype,
|
||||||
"suffix": upload.extension or "",
|
"suffix": upload.extension or "",
|
||||||
"path": serializers.get_track_path(track, upload.extension),
|
"path": serializers.get_track_path(track, upload.extension),
|
||||||
"bitrate": 42,
|
"bitRate": 42,
|
||||||
"duration": 43,
|
"duration": 43,
|
||||||
"size": 44,
|
"size": 44,
|
||||||
"created": serializers.to_subsonic_date(track.creation_date),
|
"created": serializers.to_subsonic_date(track.creation_date),
|
||||||
"albumId": album.pk,
|
"albumId": album.pk,
|
||||||
"artistId": artist.pk,
|
"artistId": artist.pk,
|
||||||
"type": "music",
|
"type": "music",
|
||||||
|
"mediaType": "song",
|
||||||
|
"musicBrainzId": track.mbid,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
@ -341,7 +367,7 @@ def test_channel_episode_serializer(factories):
|
||||||
"genre": "Podcast",
|
"genre": "Podcast",
|
||||||
"size": upload.size,
|
"size": upload.size,
|
||||||
"duration": upload.duration,
|
"duration": upload.duration,
|
||||||
"bitrate": upload.bitrate / 1000,
|
"bitRate": upload.bitrate / 1000,
|
||||||
"contentType": upload.mimetype,
|
"contentType": upload.mimetype,
|
||||||
"suffix": upload.extension,
|
"suffix": upload.extension,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
|
|
|
@ -97,6 +97,23 @@ def test_ping(f, db, api_client):
|
||||||
assert response.data == expected
|
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"])
|
@pytest.mark.parametrize("f", ["json"])
|
||||||
def test_get_artists(
|
def test_get_artists(
|
||||||
f, db, logged_in_api_client, factories, mocker, queryset_equal_queries
|
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)
|
artist = factories["music.Artist"](playable=True)
|
||||||
playable_by = mocker.spy(music_models.ArtistQuerySet, "playable_by")
|
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})
|
response = logged_in_api_client.get(url, {"id": artist.pk})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
@ -592,7 +613,7 @@ def test_search3(f, db, logged_in_api_client, factories):
|
||||||
artist_qs = (
|
artist_qs = (
|
||||||
music_models.Artist.objects.with_albums_count()
|
music_models.Artist.objects.with_albums_count()
|
||||||
.filter(pk=artist.pk)
|
.filter(pk=artist.pk)
|
||||||
.values("_albums_count", "id", "name")
|
.values("_albums_count", "id", "name", "mbid")
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data == {
|
assert response.data == {
|
||||||
|
|
|
@ -12,5 +12,5 @@ def test_can_resolve_subsonic():
|
||||||
|
|
||||||
|
|
||||||
def test_can_resolve_v2():
|
def test_can_resolve_v2():
|
||||||
path = reverse("api:v2:instance:nodeinfo-2.0")
|
path = reverse("api:v2:instance:nodeinfo-2.1")
|
||||||
assert path == "/api/v2/instance/nodeinfo/2.0"
|
assert path == "/api/v2/instance/nodeinfo/2.1"
|
||||||
|
|
|
@ -8,7 +8,27 @@ from funkwhale_api.moderation import tasks as moderation_tasks
|
||||||
from funkwhale_api.users.models import User
|
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")
|
url = reverse("rest_register")
|
||||||
data = {
|
data = {
|
||||||
"username": "test1",
|
"username": "test1",
|
||||||
|
@ -18,7 +38,7 @@ def test_can_create_user_via_api(preferences, api_client, db):
|
||||||
}
|
}
|
||||||
preferences["users__registration_enabled"] = True
|
preferences["users__registration_enabled"] = True
|
||||||
response = api_client.post(url, data)
|
response = api_client.post(url, data)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 204
|
||||||
|
|
||||||
u = User.objects.get(email="test1@test.com")
|
u = User.objects.get(email="test1@test.com")
|
||||||
assert u.username == "test1"
|
assert u.username == "test1"
|
||||||
|
@ -82,7 +102,7 @@ def test_can_signup_with_invitation(preferences, factories, api_client):
|
||||||
}
|
}
|
||||||
preferences["users__registration_enabled"] = False
|
preferences["users__registration_enabled"] = False
|
||||||
response = api_client.post(url, data)
|
response = api_client.post(url, data)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 204
|
||||||
u = User.objects.get(email="test1@test.com")
|
u = User.objects.get(email="test1@test.com")
|
||||||
assert u.username == "test1"
|
assert u.username == "test1"
|
||||||
assert u.invitation == invitation
|
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)
|
mocker.patch("funkwhale_api.users.models.create_actor", return_value=actor)
|
||||||
response = api_client.post(url, data)
|
response = api_client.post(url, data)
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 204
|
||||||
|
|
||||||
user = User.objects.get(username="test1")
|
user = User.objects.get(username="test1")
|
||||||
|
|
||||||
|
@ -323,7 +343,7 @@ def test_creating_user_sends_confirmation_email(
|
||||||
preferences["instance__name"] = "Hello world"
|
preferences["instance__name"] = "Hello world"
|
||||||
response = api_client.post(url, data)
|
response = api_client.post(url, data)
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 204
|
||||||
|
|
||||||
confirmation_message = mailoutbox[-1]
|
confirmation_message = mailoutbox[-1]
|
||||||
assert "Hello world" in confirmation_message.body
|
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")
|
on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
|
||||||
response = api_client.post(url, data, format="json")
|
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")
|
u = User.objects.get(email="test1@test.com")
|
||||||
assert u.username == "test1"
|
assert u.username == "test1"
|
||||||
assert u.is_active is False
|
assert u.is_active is False
|
||||||
|
|
|
@ -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)
|
|
|
@ -1 +0,0 @@
|
||||||
Create a testing environment in production for ListenBrainz recommendation engine (troi-recommendation-playground) (#1861)
|
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue