Compare commits

...

396 Commits

Author SHA1 Message Date
josé m. c82d7bc73c Translated using Weblate (Galician)
Currently translated at 100.0% (2191 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/gl/
2025-04-16 03:10:46 +00:00
petitminion c7bd63d1c1 pipelines:check OpenApi schema generation match repo schema (#2388) 2025-03-25 17:21:23 +00:00
Raul Magdalena Català a9927df89c Translated using Weblate (Catalan)
Currently translated at 100.0% (2191 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ca/
2025-03-09 12:40:26 +00:00
José Daniel Angulo Plata 93ba70b0b7 Translated using Weblate (Spanish)
Currently translated at 92.3% (2023 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/es/
2025-02-27 07:34:53 +00:00
José Daniel Angulo Plata b78c829d42 Translated using Weblate (Spanish)
Currently translated at 88.8% (1946 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/es/
2025-02-27 06:47:44 +00:00
José Daniel Angulo Plata c3bd945efe Translated using Weblate (Spanish)
Currently translated at 88.7% (1945 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/es/
2025-02-27 06:46:58 +00:00
petitminion 6c6cb60a28 Fix schema generation to allow propre types in front (#2404) 2025-02-20 15:04:25 +00:00
petitminion c1b0b71479 Fix missing description field in api schemas NOCHANGELOG 2025-02-20 13:44:54 +00:00
Petitminion 0937990980 Migrate artist attachement_cover to cover (to be consistent with track and album objects) 2025-02-20 14:04:37 +01:00
Renovate Bot 6696b671dc chore(api): update dependency sentry-sdk to v2.22.0 2025-02-17 14:32:53 +00:00
Petitminion 31557fdced upgrade all develop deps 2025-02-17 15:12:09 +01:00
Simó Albert i Beltran d23b2e9ff4 Remove instructions to get music from Jamendo NOCHANGELOG 2025-02-17 13:33:56 +00:00
Renovate Bot de7ad0135c chore(docs): update dependency myst-parser to v4 2025-02-17 13:01:50 +00:00
petitminion 1a5dca8606 upgrade docs to python 3.10 NOCHANGELOG 2025-02-17 13:00:23 +00:00
Petitminion 37e22e8b35 ll 2025-02-17 13:41:01 +01:00
Petitminion 15a137f261 lo 2025-02-17 13:40:42 +01:00
Petitminion ba62af15d5 upgdate poetry lock docs 2025-02-17 13:15:53 +01:00
Karl Sickendick 01915f91cf Attempt to fix issue #2122 2025-02-17 11:23:52 +00:00
Renovate Bot 8d726c2c8d chore(api): update dependency sentry-sdk to v2.21.0 2025-02-17 01:25:50 +00:00
Renovate Bot 8736924b36 chore(api): update dependency ipython to v8.32.0 2025-02-16 23:47:53 +00:00
Renovate Bot 2b2ea1e54a chore(api): update dependency faker to v36 2025-02-16 22:25:57 +00:00
Renovate Bot a3479e8c95 chore(api): update dependency django-filter to v25
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2877>
2025-02-16 21:48:26 +00:00
Renovate Bot e0e66fc333 chore(api): update dependency black to v25 2025-02-16 21:31:00 +00:00
petitminion 4db233b0c8 feat(subsonic):Subsonic getAlbumInfo, getAlbumInfo2 and getTopSongs endpoints (#2392) 2025-02-13 11:32:06 +00:00
petitminion 994765d952 Fix arm docker build gfortran NOCHANGELOG 2025-02-12 10:42:54 +00:00
petitminion 801ffbce40 fix(db):fix the fix of migrations regression from library drop NOCHANGELOG 2025-02-11 15:17:13 +00:00
petitminion 3843996e75 fix(db):drop library migration bugs NOCHANGELOG 2025-02-11 14:35:08 +00:00
petitminion 5fc8102776 fix(api): Admin library foreign key error NOCHANGELOG 2025-02-11 13:47:59 +00:00
appzer0 b4b8a36516 Translated using Weblate (French)
Currently translated at 100.0% (2191 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2025-01-28 21:28:30 +00:00
petitminion 0507c193d3 fix artist_credit regression NOCHANGELOG 2025-01-26 10:39:01 +00:00
JS Moore 830b0a485f Add support for deprecated COVERART in ogg containers and update relevant documentation (#2376) 2025-01-25 15:12:38 +00:00
petitminion d65fb8e640 Fix build_docs release list is empty NOCHANGELOG 2025-01-25 14:37:15 +00:00
Petitminion 85ec0011d7 test
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2861>
2025-01-23 18:08:23 +00:00
Petitminion 187108d495 fix Cannot fetch local actor webfinger
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2861>
2025-01-23 18:08:23 +00:00
Petitminion 689c9feb79 delete testing.py since unused
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2860>
2025-01-22 11:53:31 +00:00
Vaclovas Intas f6982f8936 Added translation using Weblate (Lithuanian) 2025-01-21 14:50:12 +00:00
petitminion 82a1facdb5 Resolve "ActivityStreams compliance: duration" (#1566) 2025-01-20 21:26:47 +00:00
petitminion 2636a3dde7 Update .gitlab-ci.yml file : python 3.13 and require changelog 2025-01-20 18:26:12 +00:00
RenovateBot e5aa82e141 chore(api): update all dependencies (develop) (major) 2025-01-16 11:51:31 +00:00
RenovateBot 606066bf3b chore(api): update all dependencies (develop) 2025-01-15 14:23:09 +00:00
Renovate Bot 8cc555321e chore(api): update channels
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2846>
2025-01-15 11:32:47 +00:00
Renovate Bot 95d2520420 chore(api): update dependency coverage to v7.6.10 2025-01-15 10:21:27 +00:00
petitminion d0c67d2488 disable python 3.13 2025-01-15 10:10:36 +00:00
RenovateBot 7c33efa1cd chore(api): update dependency dj-rest-auth to v7 (develop) 2025-01-15 09:00:51 +00:00
Renovate Bot 769a3dc79e chore(api): update dependency django-filter to v24 2025-01-14 19:22:31 +00:00
Renovate Bot 227379b7ab chore(api): update dependency django-dynamic-preferences to v1.17.0 2025-01-14 17:14:52 +00:00
RenovateBot 980bba942f chore(api): update dependency django-allauth to v0.63.6 (develop) 2025-01-14 16:57:52 +00:00
petitminion 2db7dc41fe update renovate config and desable tauri in makefile NOCHANGELOG 2025-01-13 23:13:55 +00:00
petitminion 78856cc32a Drop python 3.8 and 3.9, support python 3.13 2025-01-13 21:47:18 +00:00
petitminion 75c4b3a7ff disable tauri builds 2025-01-13 11:05:19 +00:00
petitminion e0c051f04a disable tauri builds 2025-01-13 11:04:19 +00:00
petitminion b6d27a58d3 Allow plugin to download third party tracks 2025-01-12 17:30:16 +00:00
petitminion 6f2c001bc2 fix channel upload NOCHANGELOG 2025-01-11 12:40:59 +00:00
petitminion d59019b9a7 update faka data docs NOCHANGELOG 2025-01-10 20:59:09 +00:00
Georg Krause f173029f75 chore: update tauri to v2 stable NOCHANGELOG 2025-01-10 19:39:54 +00:00
petitminion 6a79f048cd fix: set FORCE to 1 in dev env NOCHANGELOG 2025-01-10 19:17:58 +00:00
petitminion 73bd66404b Update .gitlab-ci.yml file with allow_failure on changelog 2025-01-10 18:18:32 +00:00
petitminion c9d915fb33 Drop libraries in favor of playlist for user audio sharing (#2366) 2025-01-04 15:03:49 +00:00
anonymous 7102da8ed3 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 95.1% (2084 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/zh_Hans/
2025-01-03 19:00:36 +00:00
anonymous 8f692f8b91 Translated using Weblate (Swedish)
Currently translated at 60.1% (1317 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/sv/
2025-01-03 19:00:35 +00:00
anonymous 7376c85521 Translated using Weblate (Russian)
Currently translated at 99.3% (2177 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ru/
2025-01-03 19:00:35 +00:00
anonymous c4652fbf61 Translated using Weblate (Portuguese (Portugal))
Currently translated at 74.0% (1623 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/pt_PT/
2025-01-03 19:00:34 +00:00
anonymous 66810099fc Translated using Weblate (Portuguese (Brazil))
Currently translated at 90.3% (1979 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/pt_BR/
2025-01-03 19:00:33 +00:00
anonymous dd715452d0 Translated using Weblate (Polish)
Currently translated at 94.7% (2075 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/pl/
2025-01-03 19:00:32 +00:00
anonymous 4bc4e3fcac Translated using Weblate (Occitan)
Currently translated at 96.9% (2125 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/oc/
2025-01-03 19:00:30 +00:00
anonymous 6a9b05575f Translated using Weblate (Norwegian Bokmål)
Currently translated at 65.4% (1434 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/nb_NO/
2025-01-03 19:00:29 +00:00
anonymous 7e3c4d70dd Translated using Weblate (Kabyle (kab_DZ))
Currently translated at 41.5% (911 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/kab_DZ/
2025-01-03 19:00:29 +00:00
anonymous 910f950abc Translated using Weblate (Japanese)
Currently translated at 92.1% (2018 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ja/
2025-01-03 19:00:29 +00:00
anonymous f1393da745 Translated using Weblate (Italian)
Currently translated at 96.5% (2115 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/it/
2025-01-03 19:00:28 +00:00
anonymous ebf2f15c26 Translated using Weblate (Hungarian)
Currently translated at 11.9% (262 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/hu/
2025-01-03 19:00:28 +00:00
anonymous d1e7e289f1 Translated using Weblate (Basque)
Currently translated at 99.3% (2177 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/eu/
2025-01-03 19:00:27 +00:00
anonymous 3aeecd0aca Translated using Weblate (Esperanto)
Currently translated at 74.9% (1642 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/eo/
2025-01-03 19:00:25 +00:00
anonymous fd1224f7c7 Translated using Weblate (English (United Kingdom))
Currently translated at 99.5% (2181 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/en_GB/
2025-01-03 19:00:24 +00:00
anonymous 08330bbd60 Translated using Weblate (Greek)
Currently translated at 39.5% (866 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/el/
2025-01-03 19:00:23 +00:00
anonymous 6d3788dbfc Translated using Weblate (German)
Currently translated at 99.3% (2177 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/de/
2025-01-03 19:00:23 +00:00
anonymous 7f9375c3c1 Translated using Weblate (Czech)
Currently translated at 99.3% (2176 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/cs/
2025-01-03 19:00:23 +00:00
anonymous 888e6477f1 Translated using Weblate (Arabic)
Currently translated at 75.1% (1646 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ar/
2025-01-03 19:00:21 +00:00
petitminion fedd340ed5 Playlist federation (#1458) 2025-01-03 18:17:25 +00:00
petitminion 4bfa1feacf Resolve "regression:multiple albums with same name and artsit creating during import" 2024-12-28 19:09:43 +00:00
petitminion 788e84d70c fix:make swagger in the build dir so it's properly copied to the hypervisor NOCHANGELOG 2024-12-28 17:01:38 +00:00
Petitminion 20c5cebfae fix:delete schema.yml 2024-12-27 18:45:48 +00:00
petitminion d2cbc3689b update federation doc with the new artist credit object 2024-12-22 22:56:35 +00:00
Petitminion e814b2fd01 doc:add artist_credit federation doc 2024-12-22 22:32:26 +00:00
Petitminion 8176bc6088 fix:dev federation documentation 2024-12-22 21:02:21 +00:00
josé m daa7e60160 Translated using Weblate (Galician)
Currently translated at 100.0% (2191 of 2191 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/gl/
2024-12-07 08:14:16 +00:00
petitminion 9804de3650 User follow with trackfavorite and listening activity (#1810 and #2075) 2024-12-06 14:17:21 +00:00
josé m 33ec6783aa Translated using Weblate (Galician)
Currently translated at 100.0% (2187 of 2187 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/gl/
2024-12-06 06:19:26 +00:00
petitminion d1287a36a5 Import/export playlist in xspf (#836). 2024-12-05 11:31:41 +00:00
Eric Lemesre bf2670519c Translated using Weblate (French)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2024-11-23 08:23:11 +00:00
Weblate Admin 1e71b868f6 Translated using Weblate (Spanish)
Currently translated at 88.2% (1925 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/es/
2024-11-21 16:24:32 +00:00
Petitminion 31330fed3e Display v2 endpoints to swagger (#2352) 2024-11-18 20:26:04 +01:00
Petitminion d2ac7bf84a Display v2 endpoints to swagger (#2352) 2024-11-18 17:54:54 +00:00
petitminion fc6d8ed73c Resolve "regression : multi-artist break fake data import" 2024-10-24 12:31:49 +00:00
petitminion 2f0b342866 disable some linter rule to avoid noise on the api lint process (#2346) 2024-10-21 11:06:43 +00:00
jon r 1e6e6923d2 fix(DX): Docker mac compatibility, dynamic DNS + Debian image (#2337 #1691) 2024-10-21 08:57:15 +00:00
Ciarán Ainsworth 4a74c2f5d0 Add upload groups to upload spec 2024-09-02 11:04:54 +00:00
vincent carter 345607cca3 listening port was hardcoded, no matter what the FUNKWHALE_API_PORT env var was set to, API would only listen on 5000. added env var to command to fix issue. 2024-08-29 15:11:15 +00:00
petitminion 4a11f9b58d spec-playlist-federation 2024-08-29 14:34:07 +00:00
petitminion 3b5de1a32d Supporting multi-artist per tracks/albums (#1568) 2024-08-29 14:11:35 +00:00
petitminion 007fe3b192 Resolve "Forbidden tags added to tracks in import process" 2024-08-27 20:45:45 +00:00
petitminion 965fad5bba listenings and favorites sync with listenbrainz spec 2024-08-06 08:48:46 +00:00
Lilou fb5c863dda Clearer explanation AWS_CUSTOM_DOMAIN + protocol 2024-08-04 14:11:07 +00:00
petitminion 181d39ffbc clear healthcheck on redis container NOCHANGELOG 2024-08-04 13:23:30 +00:00
petitminion a972708334 migrate frontend to api V2 (#2324) 2024-08-04 13:18:21 +00:00
petitminion 7c0ac160c5 Add healthcheck to containers (#2329) NOCHANGELOG 2024-07-31 07:58:11 +00:00
josé m 76eb908d77 Translated using Weblate (Galician)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/gl/
2024-07-23 08:50:43 +00:00
petitminion b59f71ef0f Quality filter for content frontent (#1469) 2024-07-16 18:58:15 +00:00
JuniorJPDJ b50b5cb661 feat(api): add additional parameters to fs-import endpoint 2024-07-07 13:26:22 +02:00
petitminion 40935ec5ce Resolve Radio playing fails when unauthenticated (#2319) 2024-07-04 11:19:22 +00:00
petitminion 2c2afe0b8f backend of "III-5 Quality filter for content" 2024-07-02 16:01:49 +00:00
petitminion 615ebde282 2009 allow special char in tags 2024-07-02 15:09:22 +00:00
petitminion cf32e16547 add a command to create playlist from folder structure 2024-07-02 14:30:51 +00:00
Ciarán Ainsworth c24b6ee183 chore(deps): Bump axios 2024-06-05 19:18:47 +00:00
petitminion 0705467bf9 Add Musicbrainz genres to funkwhale tag table and allow Musicbrainz tag sync (#2143) 2024-06-05 19:17:33 +00:00
Petitminion 13f6571ad0 lit 2024-06-05 18:35:25 +00:00
Petitminion 4441f054c8 update genre tag spec 2024-06-05 18:35:25 +00:00
Ciarán Ainsworth dd1dc97be5
chore(deps): Bump vite version 2024-06-04 17:23:29 +02:00
Adrià d6a8ce03c7 Translated using Weblate (Catalan)
Currently translated at 99.8% (2178 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ca/
2024-06-03 09:50:40 +00:00
ooosssay 47c5c08572 Translated using Weblate (Spanish)
Currently translated at 88.1% (1922 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/es/
2024-05-26 01:50:47 +00:00
ooosssay 1ee3c33128 Translated using Weblate (English)
Currently translated at 100.0% (2181 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/en/
2024-05-26 01:50:41 +00:00
Umut Solmaz f0706ecc5f Translated using Weblate (Turkish)
Currently translated at 48.3% (1054 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/tr/
2024-05-11 16:50:33 +00:00
Umut Solmaz 95e463a7ef Translated using Weblate (Turkish)
Currently translated at 44.6% (974 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/tr/
2024-05-09 16:50:37 +00:00
Umut Solmaz 71688bdfbc Translated using Weblate (Turkish)
Currently translated at 38.6% (842 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/tr/
2024-05-08 08:50:37 +00:00
josé m 1bb7108df5 Translated using Weblate (Galician)
Currently translated at 100.0% (2181 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/gl/
2024-05-08 08:50:35 +00:00
Petitminion 4bef27552f upgrade docker postgres dev version to postgres15
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2771>
2024-04-16 13:04:32 +00:00
Ciarán Ainsworth ec368e0cd3 Update from attribute information
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth a2579bdc60 Add from attribute to genre tag spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth e1e0045a23 Add changelog fragment
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth 85c2be6a5b fix(docs): run pre-commit
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth 35de9bd48e feat(docs): add genre tags spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Petitminion ba5b657b61 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 4fc73c1430 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 97e24bcaa6 Apply 12 suggestion(s) to 4 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 1b15fea1ab Apply 1 suggestion(s) to 1 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth b624fea2fa Apply 1 suggestion(s) to 1 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth e028e8788b Apply 1 suggestion(s) to 1 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 67f74d40a6 Add ListenBrainz sync documentation
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 547bd6f371 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 05ec6f6d0f tests
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion a03cc1db24 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 2a364d5785 add favorite sync
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 5bc0171694 delete test
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 37acfa475d loads of things
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion f45fd1e465 various reviews
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 17c4a92f77 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 6414302899 implement listening and favorite sync with listenbrainz
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 94a5b9e696
chore(deps): bump py3-pillow in Dockerfile 2024-04-14 15:32:26 +02:00
Bruno-Van-den-Bosch d673e77dff Translated using Weblate (Dutch)
Currently translated at 99.8% (2177 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/nl/
2024-04-12 13:50:31 +00:00
Kasper Seweryn 02400ceea3 fix(types): resolve vuex and typescript >5 errors
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2769>
2024-02-29 11:03:38 +01:00
Kasper Seweryn 31f35a43f1 fix(eslint): update dependencies
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2769>
2024-02-29 09:39:48 +01:00
Renovate Bot 932de8c242 chore(front): update dependency @typescript-eslint/eslint-plugin to v7
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2769>
2024-02-27 09:33:42 +00:00
Renovate Bot a947a16b0f chore(api): update dependency watchdog to v4
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2768>
2024-02-26 14:03:48 +00:00
Renovate Bot a01079850d chore(api): update dependency faker to v23
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2767>
2024-02-26 12:06:16 +00:00
Ciarán Ainsworth 8d22eb925e Add API v2 overview
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2763>
2024-02-26 10:37:51 +01:00
Georg Krause 6fe153c8da refactor(api): Make sure CSRF_TRUSTED_ORIGIN always has a protocol prefix
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause cb7284ef95 chore: Add changelog snippet
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause 5ca8691feb test(api): Fix order of s3 backend initializartion
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause b4920af0b8 fix(api): Replace deprecated is_ajax with manual check
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause 803b077f00 chore: Update django api
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause f1f6ef43ad chore: Replace reprecated alias django.conf.urls.urls()
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause 0fd0192b37 chore: Replace deprecated smart_text with smart_str
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause ac6d136105 chore: Remove deprecated argument for signal
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause 4e825527a5 chore: Replace deprecated django.contrib.postgres.forms.JSONField with django.forms.JSONField
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause 46ee53c967 chore: Use django.utils.translations.gettext_lazy instead of deprecated ugettext_lazy
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Georg Krause 765c801142 chore(api): Update dependency django to v4
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2709>
2024-02-26 07:44:18 +00:00
Tron e0e8a54d45 Translated using Weblate (Bengali)
Currently translated at 1.5% (33 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/bn/
2024-02-25 09:50:31 +00:00
Tron c67884a245 Added translation using Weblate (Bengali) 2024-02-24 09:00:51 +00:00
Kasper Seweryn d2ca28ca47 fix(tests): fix localhost test
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 16:03:18 +01:00
Kasper Seweryn 30540ec186 fix: resolve rebase mistakes
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:47:30 +01:00
Kasper Seweryn 673fe8b828 feat: update dependencies
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:20:11 +01:00
Kasper Seweryn fe4af475af style(tauri): format the code with rustfmt
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:18:59 +01:00
Kasper Seweryn ad1bb6a220 feat(nix): remove flake
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:18:59 +01:00
Georg Krause 298ace1b72 feat(tauri): add metadata to Cargo.toml
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:18:59 +01:00
Georg Krause 37a1b008b3 ci: Upload Funkwhale Desktop AppImage into package registry
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:18:59 +01:00
Kasper Seweryn e42646d8a1 feat(instance-chooser): add dark mode support
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:18:59 +01:00
Kasper Seweryn 0095fc566e feat(tauri): offload OAuth login flow to a separate window
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:18:59 +01:00
Georg Krause 419da80e37 ci(tauri): Enable verbose logs
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:18:59 +01:00
Kasper Seweryn 0b99740d64 feat(appimage): bundle media framework
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:18:59 +01:00
Georg Krause 51f56bc808 chore: Add frontend artifacts to gitignore
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:18:59 +01:00
Georg Krause b00d782006 test(front): Install required dependencies for coverage calculation
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:07:12 +01:00
Kasper Seweryn f3a7394461 test: test if tauri env is recognized correctly
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 15:06:29 +01:00
Georg Krause cb8725a838 ci: Specify appimage path
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:48:45 +01:00
Georg Krause cddf6b9d93 ci: Add cargo bin to PATH
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:48:45 +01:00
Georg Krause 521c4d927c ci: Install dependencies before building tauri
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:48:45 +01:00
Kasper Seweryn 78329ca821 feat: update to tauri v2-beta.1
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:48:31 +01:00
Georg Krause 1ca5ea2b73 ci: Build tauri desktop app
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:46:10 +01:00
Kasper Seweryn 62f84a311b feat: enable switch instance button in tauri builds
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:46:10 +01:00
Kasper Seweryn 5bf6e23815 chore: add changelog snippet
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:46:10 +01:00
Kasper Seweryn 318aa196fa style: fix linting
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:46:10 +01:00
Kasper Seweryn b313d0e48c fix: remove unused locale key
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:46:10 +01:00
Kasper Seweryn cea9d9cf47 fix: fix some linting errors
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:46:10 +01:00
Kasper Seweryn 97aa045b0b feat: add pre-commit to shellHook
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:46:10 +01:00
Kasper Seweryn ccef0197c6 fix: fix locale path
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:46:09 +01:00
Kasper Seweryn 14d099b872 fix: fix tauri checks
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:46:06 +01:00
Kasper Seweryn 5647a1072d feat: add tauri
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2701>
2024-02-21 14:45:38 +01:00
Kasper Seweryn de232cb749 ci: Adjust coverage regex to also match int
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-21 10:13:32 +00:00
Georg Krause b1eba58dcc feat: add a type hint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-21 08:34:57 +00:00
Georg Krause 06cfe8da95 ci: Report frontend test coverage to gitlab
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-21 08:24:45 +01:00
wvffle 6aa609970f fix(tests): don't wait arbitrary time
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-20 20:52:29 +00:00
wvffle 2b1228e620 fix(ci): ignore `afterall` in codespell
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-20 18:42:27 +00:00
wvffle 83120cced2 fix(tests): fix coverage in node tests
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-20 18:28:13 +00:00
wvffle 367ba84f13 fix(tests): replace serialize_upload with UploadSerializer
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-20 17:33:57 +00:00
wvffle 7957661573 style: fix linting errors
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-20 16:39:18 +00:00
wvffle 9e2d47f698 chore: add changelog snippets
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-20 14:45:33 +00:00
wvffle 243f2a57e3 test: add track cache tests and mock test server
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-20 14:39:55 +00:00
wvffle 670b522675 refactor: adjust code for lru-cache v10
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-20 14:31:55 +00:00
Renovate Bot ff6fc46c58 chore(front): update dependency lru-cache to v10
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2757>
2024-02-19 14:33:16 +00:00
Ciarán Ainsworth 84bb893f3a Remove deprecated flag for lychee
The --exclude-mail flag is deprecated and no longer needed

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2759>
2024-02-18 18:27:36 +01:00
petitminion 6c38bae189 add MbidTaggedContent to nodeinfo (#2284) NOCHANGELOG 2024-02-16 09:57:31 +00:00
petitminion 4364d82b0b Add cli command to prune non mbid content from db (#2083) 2024-02-06 11:52:29 +00:00
Renovate Bot ac74380986 chore(front): update dependency jsdom to v24 2024-02-05 22:33:28 +00:00
Renovate Bot ee0abed0b7 chore(front): update dependency eslint-plugin-n to v16
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2755>
2024-02-05 22:03:38 +00:00
Renovate Bot fc456e6985 chore(front): update dependency dompurify to v3
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2753>
2024-02-05 20:33:30 +00:00
petitminion b0423d412f add prune mbid cli doc NOCHANGELOG 2024-02-05 20:25:17 +00:00
Renovate Bot 9853b89911 chore: update cypress/included docker tag to v13
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2752>
2024-02-05 15:59:42 +00:00
Renovate Bot e6e1b5cdc4 chore(front): update dependency cypress to v13
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2748>
2024-02-05 15:02:56 +00:00
Ciarán Ainsworth 3b45fde10a Re-add django dependencies
We use Django/django-environ to auto doc FW settings

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2750>
2024-02-05 15:46:37 +01:00
Georg Krause 1eaad85c7d chore(docs) Update all dependencies
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2750>
2024-02-05 15:10:13 +01:00
Renovate Bot f76a797638 chore(front): update dependency vue-i18n to v9.9.1
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2749>
2024-02-05 12:04:13 +00:00
Georg Krause d7d6976229 feat(dev): Make neovim available in gitpod
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2734>
2024-02-05 08:34:13 +00:00
Renovate Bot 765bc62a2b chore(front): update dependency @vue/eslint-config-typescript to v12
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2746>
2024-02-04 23:33:21 +00:00
Renovate Bot 446b49fd46 chore(front): update dependency @vitejs/plugin-vue to v5
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2745>
2024-02-04 23:05:11 +00:00
Renovate Bot 0210304338 chore(front): update dependency @typescript-eslint/eslint-plugin to v6
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2744>
2024-02-04 22:33:38 +00:00
Renovate Bot 6d7a52c5ec chore(front): update dependency @intlify/unplugin-vue-i18n to v2
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2743>
2024-02-04 21:34:14 +00:00
Renovate Bot 825baecf8f chore(docs): update dependency sphinx-rtd-theme to v2
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2741>
2024-02-04 00:03:52 +00:00
Renovate Bot 62f7fda42c chore(api): update dependency watchdog to v3
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2736>
2024-02-02 19:34:37 +00:00
Georg Krause d82eceecae chore: Align with flake8 6.1 rules
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2737>
2024-02-02 19:46:08 +01:00
Renovate Bot f58a33ec02 chore: update pre-commit hook pycqa/flake8 to v6.1.0
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2737>
2024-02-02 13:03:59 +00:00
Renovate Bot abf0edfcdc chore(api): update dependency service-identity to v24
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2735>
2024-02-02 09:06:53 +00:00
Philipp Wolfer b658089e70 Subsonic: Note removal of "funkwhaleVersion" in FW 1.7.0
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Philipp Wolfer 82fdc82f93 Subsonic: Fixed getArtistInfo2 view test
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Philipp Wolfer 2371f2a4cb Subsonic: Added deprecation notice for funkwhaleVersion field
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Philipp Wolfer 136f24a917 Move Subsonic getArtistInfo2 serialization to serializer
Also fixed JSON serialization by not using lists for the single value fields.

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Philipp Wolfer a5ee48818e Extend Subsonic XML renderer to allow explicit XML child tags
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Philipp Wolfer d227490f5b OpenSubsonic: report HTTP form POST extension as supported
Funkwhale already supports passing parameters as application/x-www-form-urlencoded

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Philipp Wolfer bf8f1e41b9 OpenSubsonic: MBID for artist results, added mediaType field
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Philipp Wolfer e169e8edb1 Subsonic: Fixed casing of "bitRate" attribute
This follows the Subsonic / OpenSubsonic API spec

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Philipp Wolfer 0fab0470c2 Subsonic: Actually implement getArtistInfo2 endpoint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Philipp Wolfer 81401075aa Add OpenSubsonic support
Fixes #2270

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2695>
2024-02-02 08:47:38 +00:00
Renovate Bot c1d91ce4d6 chore(api): update dependency redis to v5
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2733>
2024-02-02 01:37:00 +00:00
Renovate Bot 1f8c03e248 chore(api): update dependency pytest-sugar to v1
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2732>
2024-02-02 01:09:28 +00:00
Renovate Bot 42bf16034b chore(api): update dependency pytest-env to v1
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2731>
2024-02-02 00:04:24 +00:00
Renovate Bot 787acab3ab chore(api): update dependency pytest to v8
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2730>
2024-02-01 23:34:38 +00:00
Renovate Bot f43ef89c28 chore(api): update dependency pylint to v3
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2729>
2024-02-01 23:04:32 +00:00
Renovate Bot c4bec419ab chore(api): update dependency pycountry to v23
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2728>
2024-02-01 12:34:27 +00:00
Renovate Bot 55a4221b69 chore(api): update dependency gunicorn to v21
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2727>
2024-02-01 08:34:50 +00:00
Renovate Bot 60f66eea6d chore(api): update dependency faker to v22
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2725>
2024-02-01 03:07:54 +00:00
Renovate Bot 4148cdd186 chore(api): update dependency django-versatileimagefield to v3
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2724>
2024-02-01 02:06:23 +00:00
Renovate Bot 004d535eb7 chore(api): update dependency django-filter to v23
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2723>
2024-02-01 01:15:04 +00:00
Renovate Bot 132e291708 chore(api): update dependency django-debug-toolbar to v4
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2722>
2024-02-01 00:05:36 +00:00
Renovate Bot 40d2dcaeaf chore(api): update dependency django-cors-headers to v4
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2721>
2024-01-31 23:05:24 +00:00
Renovate Bot fa36c97d72 chore(api): update dependency django-cleanup to v8
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2720>
2024-01-31 22:04:12 +00:00
Renovate Bot 9b8828ca42 chore(api): update dependency django-cacheops to v7
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2719>
2024-01-31 21:04:25 +00:00
Georg Krause e0791b570f chore(api): Update dependency pillow to 10.2.0
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2689>
2024-01-31 14:15:22 +01:00
Georg Krause 90c9230a60 chore(api): Update dependencies to align with Alpine 3.19
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2689>
2024-01-30 16:45:44 +01:00
Renovate Bot 1e0f3abb54 chore(api): update alpine docker tag to v3.19
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2689>
2024-01-30 16:43:35 +01:00
Petitminion bfff1f85f9 make typesense task conditionnal
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2706>
2024-01-30 13:07:25 +00:00
petitminion ae9fea0cf1 implement pylistenbrainz NOCHANGELOG 2024-01-30 11:32:14 +00:00
Renovate Bot da370f5915 chore(api): update dependency bleach to v6
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2703>
2024-01-30 04:35:38 +00:00
Renovate Bot d6a078643b chore(api): update dependency coverage to v7
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2704>
2024-01-29 13:35:58 +00:00
Renovate Bot 7fcaa1fed2 chore(api): update dependency black to v24
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2702>
2024-01-29 09:07:47 +00:00
Georg Krause c3ae40cabe style: Cleanup script, remove unused functions
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2698>
2024-01-11 10:53:57 +01:00
Georg Krause daf9e80ca5 fix: Install zip in upstream image
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2698>
2024-01-11 10:53:57 +01:00
Georg Krause b4f18edaff fix: Remove variable overrides
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2698>
2024-01-11 10:53:57 +01:00
Georg Krause fa6d48f1b7 fix: Use correct auth header for package upload
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2698>
2024-01-11 10:53:57 +01:00
Georg Krause 8f3ab416ae ci: Remove creation of release, only publish packages
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2698>
2024-01-11 10:53:57 +01:00
jo cd9d6d696e ci: add release job
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2698>
2024-01-11 10:53:57 +01:00
Baudouin Feildel 2c90b32bb3 Add changelog entry.
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2699>
2024-01-10 11:09:31 +00:00
Baudouin Feildel e96748c029 Fix Apache configuration
Built assets are fetched using path like this: `/assets/foo-a1b2c3.js`. Apache failed to serve those, as it was missing disabling the proxy pass for the static assets folder. This commit adds the necessary configuration for properly serving the static assets.

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2699>
2024-01-10 11:09:31 +00:00
Georg Krause d12ca2bad8 fix: Use the correct pre-defined variable to determine project namespace 2024-01-10 12:08:50 +01:00
Philipp Wolfer 332ae20f05 Fix docker dev setup
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2696>
2024-01-08 13:42:38 +00:00
Georg Krause 736625e235 ci: Don't run docker builds on foreign MRs 2024-01-08 14:40:26 +01:00
Georg Krause 33cd0f05a7 test(throttling): Explicitly enable throttling to make test more stable
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2670>
2024-01-03 10:02:08 +00:00
Georg Krause 06d135875b chore(api): Update dj-rest-auth to 5.0.2 and django-allauth to 0.55.2
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2670>
2024-01-03 10:02:03 +00:00
Bruno-Van-den-Bosch de41545ab3 Translated using Weblate (Dutch)
Currently translated at 99.6% (2174 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/nl/
2023-12-30 18:50:29 +00:00
Maksim Kliazovich 5ce00a9230 Added translation using Weblate (Belarusian) 2023-12-20 10:02:15 +00:00
Thomas d112d82768 Translated using Weblate (French)
Currently translated at 100.0% (9 of 9 strings)

Translation: Documentation/user-libraries-index
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-libraries-index/fr/
2023-12-18 13:38:28 +00:00
Thomas 03e9be77f9 Translated using Weblate (French)
Currently translated at 100.0% (4 of 4 strings)

Translation: Documentation/user-subsonic-index
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-subsonic-index/fr/
2023-12-18 13:38:28 +00:00
Thomas b6bcc88287 Translated using Weblate (French)
Currently translated at 11.1% (1 of 9 strings)

Translation: Documentation/administrator-upgrade-index
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-upgrade-index/fr/
2023-12-18 13:38:27 +00:00
Thomas 4677b9117d Translated using Weblate (French)
Currently translated at 13.3% (8 of 60 strings)

Translation: Documentation/administrator-installation-docker
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-installation-docker/fr/
2023-12-18 13:38:27 +00:00
Thomas bc573e47bc Translated using Weblate (French)
Currently translated at 100.0% (7 of 7 strings)

Translation: Documentation/administrator-installation-index
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-installation-index/fr/
2023-12-18 13:38:27 +00:00
Thomas 9a5a749171 Translated using Weblate (French)
Currently translated at 3.1% (1 of 32 strings)

Translation: Documentation/administrator-migration
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-migration/fr/
2023-12-18 13:38:27 +00:00
mittwerk de60ca7309 Translated using Weblate (Russian)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ru/
2023-12-17 15:50:30 +00:00
josé m 5693d0f86d Translated using Weblate (Galician)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/gl/
2023-12-17 15:50:29 +00:00
Thomas 22084cbca7 Translated using Weblate (French)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-15 13:50:28 +00:00
Georg Krause 731ee7c21e chore(api): Update kombu to 5.3.4 and celery to 5.3.6
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2660>
2023-12-13 14:34:54 +00:00
Georg Krause afea533aed chore(api): Update aiohttp to 3.9.1
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2660>
2023-12-13 14:33:58 +00:00
Georg Krause 8a6b19fb6f chore(api): Update Pillow to version 10.1.0
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2660>
2023-12-13 14:32:45 +00:00
Georg Krause 0eec47e493 feat(api): Add support for Python 3.12
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2660>
2023-12-13 14:30:29 +00:00
Georg Krause 4f9280bd2c ci: Run tests against python 3.12
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2660>
2023-12-13 14:29:04 +00:00
Renovate Bot 2ac4e25fce chore(api): update dependency ipython to v8
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2693>
2023-12-13 13:57:08 +00:00
Georg Krause 295b0dcc3a chore(renovate): Prioritize major over minor over patch updates in develop
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2690>
2023-12-13 13:52:33 +00:00
Ciarán Ainsworth ab0efa3edf Update behavior spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2631>
2023-12-13 13:46:15 +00:00
Ciarán Ainsworth 587bbc1118 fix(docs): use callout
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2631>
2023-12-13 13:46:15 +00:00
Ciarán Ainsworth b8978021c0 feat(docs): Add new upload process spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2631>
2023-12-13 13:46:15 +00:00
Georg Krause 349610bbeb chore: Use make install everywhere instead of poetry install
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2646>
2023-12-13 13:35:00 +00:00
Ciarán Ainsworth 65f13a379f Use glossary and clarify deletion process
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2630>
2023-12-12 16:15:44 +00:00
Ciarán Ainsworth ba53d03ac5 Add changelog for user deletion spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2630>
2023-12-12 16:15:44 +00:00
Ciarán Ainsworth cb65ee69e1 fix(docs): update heading and lexer
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2630>
2023-12-12 16:15:44 +00:00
Ciarán Ainsworth 65728c81c4 feat(docs): Add initial user deletion spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2630>
2023-12-12 16:15:44 +00:00
Matteo Piovanelli 5b022d94d1 Translated using Weblate (Italian)
Currently translated at 97.1% (2120 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/it/
2023-12-12 14:50:28 +00:00
Georg Krause 21ff5f65da Translated using Weblate (French)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-12 14:50:28 +00:00
Georg Krause d8c734d3cd Translated using Weblate (Basque)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/eu/
2023-12-12 14:50:28 +00:00
Georg Krause b1f3a62fae Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/en_GB/
2023-12-12 14:50:27 +00:00
Georg Krause 20cfaa8dc9 Translated using Weblate (German)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/de/
2023-12-12 14:50:27 +00:00
Georg Krause 038b696e75 Translated using Weblate (English)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/en/
2023-12-12 14:50:26 +00:00
Georg Krause 59687b2f32 Version bump and changelog for 1.4.0 2023-12-12 13:26:16 +01:00
Thomas da71fb640d Translated using Weblate (French)
Currently translated at 22.2% (2 of 9 strings)

Translation: Documentation/user-radios-index
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-radios-index/fr/
2023-12-11 21:34:10 +00:00
Thomas 09facc553d Translated using Weblate (French)
Currently translated at 100.0% (4 of 4 strings)

Translation: Documentation/user-reports-index
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-reports-index/fr/
2023-12-11 21:34:09 +00:00
Georg Krause da01070455 fix(nginx): Do not cache all requests for a day in the reverse proxy
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2673>
2023-12-11 14:41:16 +00:00
Georg Krause b00daa189d Translated using Weblate (Chinese (Simplified))
Currently translated at 95.8% (2091 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/zh_Hans/
2023-12-11 14:34:16 +00:00
drakonicguy aa0ce033aa Translated using Weblate (Polish)
Currently translated at 95.4% (2082 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/pl/
2023-12-11 14:34:16 +00:00
Georg Krause cc2272bb80 Translated using Weblate (Occitan)
Currently translated at 97.7% (2132 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/oc/
2023-12-11 14:34:15 +00:00
Matteo Piovanelli f0e79b4a0a Translated using Weblate (Italian)
Currently translated at 97.0% (2117 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/it/
2023-12-11 14:34:15 +00:00
Aznörth Niryn 9da91df798 Translated using Weblate (French)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-11 14:34:15 +00:00
Aitor 807a6fd02c Translated using Weblate (Basque)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/eu/
2023-12-11 14:34:15 +00:00
Ciarán Ainsworth 517d99f9bf Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/en_GB/
2023-12-11 14:34:14 +00:00
Georg Krause 6ab1dc0536 Translated using Weblate (German)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/de/
2023-12-11 14:34:10 +00:00
Georg Krause 803eb85b67 Translated using Weblate (English)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/en/
2023-12-11 14:34:09 +00:00
Georg Krause 6fcae233df Translated using Weblate (Russian)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ru/
2023-12-10 11:45:17 +00:00
Georg Krause bf43b95208 Translated using Weblate (Galician)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/gl/
2023-12-10 11:45:16 +00:00
Georg Krause d721a3808b Translated using Weblate (French)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-10 11:45:15 +00:00
Georg Krause d22a911619 Translated using Weblate (German)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/de/
2023-12-10 11:45:14 +00:00
Georg Krause 7c52227d43 Translated using Weblate (Czech)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/cs/
2023-12-10 11:45:14 +00:00
Georg Krause 58e2c896b2 Translated using Weblate (Catalan)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ca/
2023-12-10 11:45:13 +00:00
Georg Krause 91b85cab46 Translated using Weblate (English)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/en/
2023-12-10 11:45:12 +00:00
Georg Krause bc15de7556 Translated using Weblate (German)
Currently translated at 98.9% (2158 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/de/
2023-12-10 10:42:05 +01:00
Georg Krause f99de1ef97 Translated using Weblate (English)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/en/
2023-12-10 10:42:05 +01:00
Georg Krause 5cc0219196 Added translation using Weblate (Bengali (Bangladesh)) 2023-12-10 10:42:05 +01:00
josé m 369b80bb1c Translated using Weblate (Galician)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/gl/
2023-12-10 10:42:05 +01:00
Thomas 60db27dfba Translated using Weblate (French)
Currently translated at 99.9% (2181 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-10 10:42:05 +01:00
Aznörth Niryn efffeac280 Translated using Weblate (French)
Currently translated at 99.9% (2181 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-10 10:42:05 +01:00
Thomas d112ea4bc6 Translated using Weblate (French)
Currently translated at 99.9% (2181 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-10 10:42:05 +01:00
Aznörth Niryn b8ed2ccd5c Translated using Weblate (French)
Currently translated at 99.9% (2181 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-10 10:42:05 +01:00
Quentin PAGÈS ab15803be0 Translated using Weblate (Occitan)
Currently translated at 97.8% (2135 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/oc/
2023-12-10 10:42:05 +01:00
Quentin PAGÈS e282422592 Translated using Weblate (Catalan)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ca/
2023-12-10 10:42:05 +01:00
omarmaciasmolina 96d25ff25d Translated using Weblate (Catalan)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ca/
2023-12-10 10:42:05 +01:00
rinenweb 8645180620 Translated using Weblate (Greek)
Currently translated at 39.9% (872 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/el/
2023-12-10 10:42:05 +01:00
Jérémie Lorente 142a517b93 Translated using Weblate (French)
Currently translated at 99.9% (2181 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-10 10:42:05 +01:00
dignny 233d17d287 Translated using Weblate (Japanese)
Currently translated at 92.9% (2029 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ja/
2023-12-10 10:42:05 +01:00
Aznörth Niryn 630ba7262a Translated using Weblate (French)
Currently translated at 99.9% (2181 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-10 10:42:05 +01:00
dignny 0b78affdcd Translated using Weblate (Japanese)
Currently translated at 91.8% (2004 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ja/
2023-12-10 10:42:05 +01:00
Transcriber allium 41dbf62356 Translated using Weblate (Greek)
Currently translated at 38.3% (836 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/el/
2023-12-10 10:42:05 +01:00
Matyáš Caras 6b6ba94291 Translated using Weblate (Czech)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/cs/
2023-12-10 10:42:05 +01:00
josé m 9eda066a39 Translated using Weblate (Galician)
Currently translated at 100.0% (2182 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/gl/
2023-12-10 10:42:05 +01:00
Aznörth Niryn 4cf2d68a4f Translated using Weblate (French)
Currently translated at 99.9% (2181 of 2182 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/fr/
2023-12-10 10:42:05 +01:00
Renovate Bot a19b459533 chore(front): update vue monorepo to v3.3.11
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2688>
2023-12-10 08:34:18 +00:00
Renovate Bot e3206e2122 chore(front): update dependency vue-router to v4.2.5
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2687>
2023-12-10 08:05:39 +00:00
Renovate Bot ba3300a682 chore(front): update dependency standardized-audio-context-mock to v9.6.32
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2686>
2023-12-09 21:05:29 +00:00
Renovate Bot c6aec56e71 chore(front): update dependency standardized-audio-context to v25.3.60
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2685>
2023-12-09 20:07:17 +00:00
Renovate Bot 02fd31d321 chore(front): update dependency @vue/eslint-config-typescript to v11.0.3
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2684>
2023-12-09 18:08:10 +00:00
Renovate Bot 07f665cb8b chore(front): update dependency @types/showdown to v2.0.6
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2683>
2023-12-09 17:35:36 +00:00
Renovate Bot 0b03bd6c89 chore(front): update dependency @types/semantic-ui to v2.2.9
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2682>
2023-12-09 17:09:31 +00:00
Renovate Bot 2aa301387c chore(front): update dependency @types/qs to v6.9.10
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2681>
2023-12-09 16:37:05 +00:00
Renovate Bot 46531884b3 chore(front): update dependency @types/moxios to v0.4.17
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2680>
2023-12-09 16:08:41 +00:00
Renovate Bot 6234dfd2a7 chore(front): update dependency @types/lodash-es to v4.17.12
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2679>
2023-12-09 15:36:10 +00:00
Renovate Bot 1c93460ffb chore(front): update dependency @types/jquery to v3.5.29
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2678>
2023-12-09 15:07:55 +00:00
Renovate Bot b6c906bf7c chore(front): update dependency @types/diff to v5.0.9
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2677>
2023-12-09 14:29:36 +00:00
Renovate Bot 793fc31e13 chore(docs): update dependency django to v3.2.23
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2676>
2023-12-09 14:09:43 +00:00
Georg Krause 80b4906438 chore(renovate): Disable automerge since it is prevented by our Gitlab settings 2023-12-09 13:55:51 +00:00
Renovate Bot e11a6cea02 chore(api): update dependency python-ldap to v3.4.4
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2674>
2023-12-09 11:17:48 +00:00
Renovate Bot b46aa638bc chore(api): update dependency unidecode to v1.3.7 2023-12-08 15:17:02 +00:00
Ciarán Ainsworth 17e08fd332 fix(docs): Update env file for Unix socket
Added note to the CACHE_URL variable to clarify Unix socket usage

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2668>
2023-12-08 14:45:54 +00:00
Georg Krause 86ce4cfd7c fix(gitpod): Make sure jinja2 and towncrier are available
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2667>
2023-12-08 14:21:23 +00:00
Georg Krause b21e241f37 fix(gitpod): Properly serve media files, statics and fix proxy to API
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2667>
2023-12-08 14:21:23 +00:00
Renovate Bot 08bfc93243 chore(api): update dependency pylint-django to v2.5.5
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2671>
2023-12-06 09:35:39 +00:00
Ciarán Ainsworth 4cbce95bcb fix(docs): Fix postgres upgrade instructions
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2669>
2023-12-06 09:22:39 +00:00
Georg Krause 3ee6ba6658 fix(deploy): Serve staticfiles in bare metal deployments
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2665>
2023-12-05 20:05:44 +00:00
Thomas 259fb1b61d Translated using Weblate (French)
Currently translated at 8.6% (5 of 58 strings)

Translation: Documentation/user-channels-podcast-upload
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-channels-podcast-upload/fr/
2023-12-05 19:10:21 +00:00
Thomas 516c281a57 Translated using Weblate (French)
Currently translated at 8.6% (5 of 58 strings)

Translation: Documentation/user-channels-artist-upload
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-channels-artist-upload/fr/
2023-12-05 19:10:20 +00:00
Thomas d842243b3c Translated using Weblate (French)
Currently translated at 19.2% (5 of 26 strings)

Translation: Documentation/user-channels-podcast-delete
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-channels-podcast-delete/fr/
2023-12-05 19:10:20 +00:00
Thomas a4ea1a06b9 Translated using Weblate (French)
Currently translated at 19.2% (5 of 26 strings)

Translation: Documentation/user-channels-artist-delete
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-channels-artist-delete/fr/
2023-12-05 19:10:20 +00:00
Thomas d44c29bedb Translated using Weblate (French)
Currently translated at 16.6% (5 of 30 strings)

Translation: Documentation/user-channels-create
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-channels-create/fr/
2023-12-05 19:10:20 +00:00
Thomas 6e46660d70 Translated using Weblate (French)
Currently translated at 42.8% (6 of 14 strings)

Translation: Documentation/user-channels-delete
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-channels-delete/fr/
2023-12-05 19:10:20 +00:00
Thomas 32db5e92a3 Translated using Weblate (French)
Currently translated at 100.0% (4 of 4 strings)

Translation: Documentation/user-plugins-index
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-plugins-index/fr/
2023-12-05 19:10:19 +00:00
Thomas ba365d6722 Translated using Weblate (French)
Currently translated at 50.0% (6 of 12 strings)

Translation: Documentation/user-accounts-quota
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-accounts-quota/fr/
2023-12-05 19:10:19 +00:00
Thomas fd44d0bf12 Translated using Weblate (French)
Currently translated at 37.5% (6 of 16 strings)

Translation: Documentation/user-libraries-content-upload
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-libraries-content-upload/fr/
2023-12-05 19:10:19 +00:00
Thomas 70c0a038fc Translated using Weblate (French)
Currently translated at 50.0% (9 of 18 strings)

Translation: Documentation/user-libraries-content-delete
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-libraries-content-delete/fr/
2023-12-05 19:10:19 +00:00
Thomas 06e49598a3 Translated using Weblate (French)
Currently translated at 100.0% (15 of 15 strings)

Translation: Documentation/user-libraries-delete
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-libraries-delete/fr/
2023-12-05 19:10:19 +00:00
Thomas 779a3ee717 Translated using Weblate (French)
Currently translated at 27.2% (6 of 22 strings)

Translation: Documentation/user-libraries-create
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-libraries-create/fr/
2023-12-05 19:10:18 +00:00
Thomas 92f73b1755 Translated using Weblate (French)
Currently translated at 100.0% (21 of 21 strings)

Translation: Documentation/user-libraries-follow
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-libraries-follow/fr/
2023-12-05 19:10:18 +00:00
Thomas f34eb14c9a Translated using Weblate (French)
Currently translated at 36.8% (7 of 19 strings)

Translation: Documentation/user-libraries-share
Translate-URL: https://translate.funkwhale.audio/projects/documentation/user-libraries-share/fr/
2023-12-05 19:10:18 +00:00
Thomas 358ce509a5 Translated using Weblate (French)
Currently translated at 100.0% (18 of 18 strings)

Translation: Documentation/contributor-translation
Translate-URL: https://translate.funkwhale.audio/projects/documentation/contributor-translation/fr/
2023-12-05 19:10:17 +00:00
Thomas 65ebb8d90e Translated using Weblate (French)
Currently translated at 11.4% (4 of 35 strings)

Translation: Documentation/moderator-content-delete
Translate-URL: https://translate.funkwhale.audio/projects/documentation/moderator-content-delete/fr/
2023-12-05 19:10:17 +00:00
Thomas 499e1a8354 Translated using Weblate (French)
Currently translated at 2.5% (1 of 40 strings)

Translation: Documentation/developer-setup-gitpod
Translate-URL: https://translate.funkwhale.audio/projects/documentation/developer-setup-gitpod/fr/
2023-12-05 19:10:17 +00:00
Thomas 8de3c1489d Translated using Weblate (French)
Currently translated at 100.0% (17 of 17 strings)

Translation: Documentation/administrator-upgrade-debian
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-upgrade-debian/fr/
2023-12-05 19:10:16 +00:00
Thomas 11f7fa25ae Translated using Weblate (French)
Currently translated at 7.6% (2 of 26 strings)

Translation: Documentation/administrator-upgrade-docker
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-upgrade-docker/fr/
2023-12-05 19:10:16 +00:00
Thomas 1ccf18412f Translated using Weblate (French)
Currently translated at 100.0% (6 of 6 strings)

Translation: Documentation/administrator-configuration-index
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-configuration-index/fr/
2023-12-05 19:10:16 +00:00
Thomas 1061275487 Translated using Weblate (French)
Currently translated at 100.0% (30 of 30 strings)

Translation: Documentation/administrator-configuration-ldap
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-configuration-ldap/fr/
2023-12-05 19:10:15 +00:00
Thomas af592d99c2 Translated using Weblate (French)
Currently translated at 3.3% (1 of 30 strings)

Translation: Documentation/administrator-uninstall-debian
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-uninstall-debian/fr/
2023-12-05 19:10:14 +00:00
Thomas d1dd0bebcf Translated using Weblate (French)
Currently translated at 5.2% (1 of 19 strings)

Translation: Documentation/administrator-uninstall-docker
Translate-URL: https://translate.funkwhale.audio/projects/documentation/administrator-uninstall-docker/fr/
2023-12-05 19:10:13 +00:00
Renovate Bot 9da463e69d chore(api): update dependency pytest-env to v0.8.2
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2663>
2023-12-04 16:08:14 +00:00
Renovate Bot 1ee1c88ed1 chore(api): update dependency pytest to v7.4.3
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2664>
2023-12-04 14:34:30 +00:00
Renovate Bot e38808e2ce chore(api): update dependency pylint to v2.17.7
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2661>
2023-12-02 14:06:45 +00:00
Renovate Bot 2edbc6c98f chore(api): update dependency drf-spectacular to v0.26.5
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2657>
2023-12-02 13:28:39 +00:00
Georg Krause bfa50a0c35 chore: Add changelog snippet for ended support of Debian 10
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2656>
2023-12-02 13:22:42 +00:00
629 changed files with 132793 additions and 61880 deletions

View File

@ -7,6 +7,7 @@ nd
readby readby
serie serie
upto upto
afterall
# Names # Names
nin nin

View File

@ -67,3 +67,6 @@ mailhog
*.sqlite3 *.sqlite3
api/music api/music
api/media api/media
# Docker state
.state

View File

@ -1,23 +0,0 @@
DJANGO_ALLOWED_HOSTS=.funkwhale.test,localhost,nginx,0.0.0.0,127.0.0.1,.gitpod.io
DJANGO_SETTINGS_MODULE=config.settings.local
DJANGO_SECRET_KEY=dev
C_FORCE_ROOT=true
FUNKWHALE_HOSTNAME=localhost
FUNKWHALE_PROTOCOL=http
PYTHONDONTWRITEBYTECODE=true
VUE_PORT=8080
MUSIC_DIRECTORY_PATH=/music
BROWSABLE_API_ENABLED=True
FORWARDED_PROTO=http
LDAP_ENABLED=False
FUNKWHALE_SPA_HTML_ROOT=http://nginx/
PYTHONTRACEMALLOC=0
MEDIA_ROOT=/data/media
# Uncomment this if you're using traefik/https
# FORCE_HTTPS_URLS=True
# Customize to your needs
POSTGRES_VERSION=11
DEBUG=true
TYPESENSE_API_KEY="apikey"

58
.env.example Normal file
View File

@ -0,0 +1,58 @@
# api + celeryworker
DEBUG=True
DEFAULT_FROM_EMAIL=hello@funkwhale.test
FUNKWHALE_DOMAIN=funkwhale.test
FUNKWHALE_PROTOCOL=https
DJANGO_SECRET_KEY=dev
DJANGO_ALLOWED_HOSTS=.funkwhale.test,nginx
DJANGO_SETTINGS_MODULE=config.settings.local
DATABASE_URL=postgresql://postgres@postgres/postgres
CACHE_URL=redis://redis:6379/0
EMAIL_CONFIG=smtp://mailpit.funkwhale.test:1025
FORCE_HTTPS_URLS=True
EXTERNAL_REQUESTS_VERIFY_SSL=false
C_FORCE_ROOT=true
PYTHONDONTWRITEBYTECODE=true
PYTHONTRACEMALLOC=0
# api
FUNKWHALE_SPA_HTML_ROOT=http://nginx/
LDAP_ENABLED=False
BROWSABLE_API_ENABLED=True
# celeryworker
CELERYD_CONCURRENCY=0
# api + nginx
STATIC_ROOT=/staticfiles
MEDIA_ROOT=/data/media
# api + Typesense
TYPESENSE_API_KEY=apikey
# front
HOST=0.0.0.0
VUE_PORT=8080
# nginx
NGINX_MAX_BODY_SIZE=10G
FUNKWHALE_API_HOST=api
FUNKWHALE_API_PORT=5000
FUNKWHALE_FRONT_IP=front
FUNKWHALE_FRONT_PORT=${VUE_PORT}
# postgres
POSTGRES_HOST_AUTH_METHOD=trust

17
.gitignore vendored
View File

@ -1,3 +1,5 @@
/dist
### OSX ### ### OSX ###
.DS_Store .DS_Store
.AppleDouble .AppleDouble
@ -83,10 +85,15 @@ 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/
.state
.env .env
po/*.po po/*.po
@ -97,10 +104,20 @@ _build
# Docker # Docker
docker-bake.*.json docker-bake.*.json
metadata.json metadata.json
compose/var/test.*
# Linting # Linting
.eslintcache .eslintcache
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
# Nix
.direnv/
.envrc
flake.nix
flake.lock
# Vscode # Vscode
.vscode/ .vscode/
# Zed
.zed/

View File

@ -144,13 +144,13 @@ 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/'
-- . || exit $? -- . || exit $?
require_changelog: require_changelog:
allow_failure: false
stage: lint stage: lint
rules: rules:
# Don't run on merge request that mention NOCHANGELOG or renovate bot commits # Don't run on merge request that mention NOCHANGELOG or renovate bot commits
@ -175,7 +175,8 @@ lint_api:
- if: $CI_COMMIT_BRANCH =~ /(stable|develop)/ - if: $CI_COMMIT_BRANCH =~ /(stable|develop)/
- changes: [api/**/*] - changes: [api/**/*]
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:3.11 image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:3.13
cache: *api_cache
before_script: before_script:
- cd api - cd api
- make install - make install
@ -231,7 +232,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.10", "3.11", "3.12", "3.13"]
services: services:
- name: postgres:15-alpine - name: postgres:15-alpine
command: command:
@ -248,7 +249,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
@ -288,6 +289,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
@ -313,7 +315,7 @@ test_integration:
interruptible: true interruptible: true
image: image:
name: cypress/included:12.14.0 name: cypress/included:13.6.4
entrypoint: [""] entrypoint: [""]
cache: cache:
- *front_cache - *front_cache
@ -337,7 +339,7 @@ build_api_schema:
# Add build_docs rules because it depends on the build_api_schema artifact # Add build_docs rules because it depends on the build_api_schema artifact
- changes: [docs/**/*] - changes: [docs/**/*]
image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:3.11 image: $CI_REGISTRY/funkwhale/ci/python-funkwhale-api:3.13
services: services:
- postgres:15-alpine - postgres:15-alpine
- redis:7-alpine - redis:7-alpine
@ -351,10 +353,15 @@ 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
- diff ../docs/schema.yml ./funkwhale_api/common/schema.yml || (
echo "Schema files do not match! run sudo docker compose run --rm
api funkwhale-manage spectacular > ./api/funkwhale_api/common/schema.yml" &&
exit 1
)
artifacts: artifacts:
expire_in: 2 weeks expire_in: 2 weeks
paths: paths:
@ -430,6 +437,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
@ -473,7 +499,8 @@ docker:
--set *.cache-to=type=registry,ref=$DOCKER_CACHE_IMAGE:$CI_COMMIT_BRANCH,mode=max,oci-mediatypes=false --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
@ -508,3 +535,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

View File

@ -16,7 +16,7 @@
"ignoreDeps": ["$CI_REGISTRY/funkwhale/backend-test-docker"], "ignoreDeps": ["$CI_REGISTRY/funkwhale/backend-test-docker"],
"packageRules": [ "packageRules": [
{ {
"matchPaths": ["api/*", "front/*", "docs/*"], "matchFileNames": ["api/*", "front/*", "docs/*"],
"additionalBranchPrefix": "{{parentDir}}-", "additionalBranchPrefix": "{{parentDir}}-",
"semanticCommitScope": "{{parentDir}}" "semanticCommitScope": "{{parentDir}}"
}, },
@ -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"]
@ -50,20 +54,20 @@
"addLabels": ["Area::Backend"] "addLabels": ["Area::Backend"]
}, },
{ {
"matchPackagePatterns": ["^@vueuse/.*"], "groupName": "vueuse",
"groupName": "vueuse" "matchDepNames": ["/^@vueuse/.*/"]
}, },
{ {
"matchPackageNames": ["channels", "channels-redis", "daphne"], "matchDepNames": ["channels", "channels-redis", "daphne"],
"groupName": "channels" "groupName": "channels"
}, },
{ {
"matchPackageNames": ["node"], "matchDepNames": ["node"],
"allowedVersions": "/\\d+[02468]$/" "allowedVersions": "/\\d+[02468]$/"
}, },
{ {
"matchFiles": ["deploy/docker-compose.yml"], "matchFileNames": ["deploy/docker-compose.yml"],
"matchPackageNames": ["postgres"], "matchDepNames": ["postgres"],
"postUpgradeTasks": { "postUpgradeTasks": {
"commands": [ "commands": [
"echo 'Upgrade Postgres to version {{ newVersion }}. [Make sure to migrate!](https://docs.funkwhale.audio/administrator_documentation/upgrade_docs/docker.html#upgrade-the-postgres-container)' > changes/changelog.d/postgres.update" "echo 'Upgrade Postgres to version {{ newVersion }}. [Make sure to migrate!](https://docs.funkwhale.audio/administrator_documentation/upgrade_docs/docker.html#upgrade-the-postgres-container)' > changes/changelog.d/postgres.update"
@ -72,7 +76,7 @@
} }
}, },
{ {
"matchPackageNames": ["python"], "matchDepNames": ["python"],
"rangeStrategy": "widen" "rangeStrategy": "widen"
} }
] ]

View File

@ -14,7 +14,7 @@ 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

View File

@ -6,6 +6,8 @@ RUN sudo apt update -y \
RUN pyenv install 3.11 && pyenv global 3.11 RUN pyenv install 3.11 && pyenv global 3.11
RUN pip install poetry pre-commit \ 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

View File

@ -28,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" - "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/templates/default.conf.template:ro - ../docker/nginx/conf.dev:/etc/nginx/templates/default.conf.template:ro
- ../front:/frontend:ro - ../front:/frontend:ro

View File

@ -6,6 +6,7 @@ repos:
rev: v4.4.0 rev: v4.4.0
hooks: hooks:
- id: check-added-large-files - id: check-added-large-files
exclude: "api/funkwhale_api/common/schema.yml"
- id: check-case-conflict - id: check-case-conflict
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable - id: check-shebang-scripts-are-executable
@ -53,7 +54,7 @@ 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
@ -62,6 +63,7 @@ repos:
hooks: hooks:
- id: prettier - id: prettier
files: \.(md|yml|yaml|json)$ files: \.(md|yml|yaml|json)$
exclude: "api/funkwhale_api/common/schema.yml"
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell
rev: v2.2.6 rev: v2.2.6

View File

@ -9,23 +9,13 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
<!-- towncrier --> <!-- towncrier -->
## 1.4.0-rc2 (2023-11-30) ## 1.4.0 (2023-12-12)
Upgrade instructions are available at https://docs.funkwhale.audio/administrator/upgrade/index.html
Changes since 1.4.0-rc1:
Bugfixes:
- Fix broken nginx templates for docker setup (#2252)
- Fix docker builds in CI by using correct flag to disable cache
## 1.4.0-rc1 (2023-11-28)
Upgrade instructions are available at https://docs.funkwhale.audio/administrator/upgrade/index.html Upgrade instructions are available at https://docs.funkwhale.audio/administrator/upgrade/index.html
Features: Features:
- Add a management command to generate dummy notifications for testing
- Add atom1.0 to node info services (#2085) - Add atom1.0 to node info services (#2085)
- Add basic cypress testing - Add basic cypress testing
- Add NodeInfo 2.1 (#2085) - Add NodeInfo 2.1 (#2085)
@ -36,14 +26,14 @@ Features:
- Cache radio queryset into redis. New radio track endpoint for api v2 is /api/v2/radios/sessions/{radiosessionid}/tracks (#2135) - Cache radio queryset into redis. New radio track endpoint for api v2 is /api/v2/radios/sessions/{radiosessionid}/tracks (#2135)
- Create a testing environment in production for ListenBrainz recommendation engine (troi-recommendation-playground) (#1861) - Create a testing environment in production for ListenBrainz recommendation engine (troi-recommendation-playground) (#1861)
- Generate all nginx configurations from one template - Generate all nginx configurations from one template
- New management command to update Uploads which have been imported using --in-place and are now stored in s3 (#2156) - New management command to update Uploads which have been imported using --in-place and are now
- Add option to only allow MusicBrainz tagged file on a pod (#2083) stored in s3 (#2156)
- Only allow MusicBrainz tagged file on a pod (#2083)
- Prohibit the creation of new users using django's `createsuperuser` command in favor of our own CLI - 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) entry point. Run `funkwhale-manage fw users create --superuser` instead. (#1288)
Enhancements: Enhancements:
- Add a management command to generate dummy notifications for testing
- Add custom logging functionality (#2155) - Add custom logging functionality (#2155)
- Adding typesense container and api client (2104) - Adding typesense container and api client (2104)
- Cache pip package in api docker builds (#2193) - Cache pip package in api docker builds (#2193)
@ -61,9 +51,12 @@ Bugfixes:
- `postgres > db_dump.sql` cannot be used if the postgres container is stopped. Update command. - `postgres > db_dump.sql` cannot be used if the postgres container is stopped. Update command.
- Avoid troi radio to give duplicates (#2231) - Avoid troi radio to give duplicates (#2231)
- Do not cache all requests to avoid missing updates #2258
- Fix broken nginx templates for docker setup (#2252)
- Fix help messages for running scripts using funkwhale-manage - Fix help messages for running scripts using funkwhale-manage
- Fix missing og meta tags (#2208) - Fix missing og meta tags (#2208)
- Fix multiarch docker builds #2211 - Fix multiarch docker builds #2211
- Fix regression that prevent static files from being served in non-docker-deployments (#2256)
- Fixed an issue where the copy button didn't copy the Embed code in the embed modal. - Fixed an issue where the copy button didn't copy the Embed code in the embed modal.
- Fixed an issue with the nginx templates that caused issues when connecting to websockets. - Fixed an issue with the nginx templates that caused issues when connecting to websockets.
- Fixed development docker setup (2102) - Fixed development docker setup (2102)
@ -107,6 +100,79 @@ Other:
Removal: Removal:
- Drop support for python3.7 - Drop support for python3.7
- This release doesn't support Debian 10 anymore. If you are still on Debian 10, we recommend
updating to a later version. Alternatively, install a supported Python version (>= Python 3.8). Python 3.11 is recommended.
Contributors to our Issues:
- AMoonRabbit
- Alexandra Parker
- ChengChung
- Ciarán Ainsworth
- Georg Krause
- Ghost User
- Johann Queuniet
- JuniorJPDJ
- Kasper Seweryn
- Kay Borowski
- Marcos Peña
- Mathieu Jourdan
- Nicolas Derive
- Virgile Robles
- jooola
- petitminion
- theit8514
Contributors to our Merge Requests:
- AMoonRabbit
- Alexander Dunkel
- Alexander Torre
- Ciarán Ainsworth
- Georg Krause
- JuniorJPDJ
- Kasper Seweryn
- Kay Borowski
- Marcos Peña
- Mathieu Jourdan
- Philipp Wolfer
- Virgile Robles
- interfect
- jooola
- petitminion
Committers:
- Aitor
- Alexander Dunkel
- alextprog
- Aznörth Niryn
- Ciarán Ainsworth
- dignny
- drakonicguy
- Fun.k.whale Trad
- Georg krause
- Georg Krause
- Jérémie Lorente
- jo
- jooola
- josé m
- Julian-Samuel Gebühr
- JuniorJPDJ
- Kasper Seweryn
- Marcos Peña
- Mathieu Jourdan
- Matteo Piovanelli
- Matyáš Caras
- MhP
- omarmaciasmolina
- petitminion
- Philipp Wolfer
- ppom
- Quentin PAGÈS
- rinenweb
- Thomas
- Transcriber allium
## 1.3.4 (2023-11-16) ## 1.3.4 (2023-11-16)
@ -336,13 +402,13 @@ Update instructions:
2. Stop your containers using the **docker-compose** syntax. 2. Stop your containers using the **docker-compose** syntax.
```sh ```sh
sudo docker-compose down docker compose down
``` ```
3. Bring the containers back up using the **docker compose** syntax. 3. Bring the containers back up using the **docker compose** syntax.
```sh ```sh
sudo docker compose up -d docker compose up -d
``` ```
After this you can continue to use the **docker compose** syntax for all Docker management tasks. After this you can continue to use the **docker compose** syntax for all Docker management tasks.

View File

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

View File

@ -1,124 +0,0 @@
FROM alpine:3.17 as requirements
# We need this additional step to avoid having poetrys deps interacting with our
# dependencies. This is only required until alpine 3.16 is released, since this
# allows us to install poetry as package.
RUN set -eux; \
apk add --no-cache \
poetry \
py3-cryptography \
py3-pip \
python3
COPY pyproject.toml poetry.lock /
RUN set -eux; \
poetry export --without-hashes --extras typesense > requirements.txt; \
poetry export --without-hashes --with dev > dev-requirements.txt;
FROM alpine:3.17 as builder
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ARG PIP_NO_CACHE_DIR=1
ENV CARGO_NET_GIT_FETCH_WITH_CLI=true
RUN set -eux; \
apk add --no-cache \
cargo \
curl \
gcc \
g++ \
git \
jpeg-dev \
libffi-dev \
libldap \
libxml2-dev \
libxslt-dev \
make \
musl-dev \
openldap-dev \
openssl-dev \
postgresql-dev \
zlib-dev \
py3-cryptography=38.0.3-r1 \
py3-lxml=4.9.3-r1 \
py3-pillow=9.3.0-r0 \
py3-psycopg2=2.9.5-r0 \
py3-watchfiles=0.18.1-r0 \
python3-dev
# Create virtual env
RUN python3 -m venv --system-site-packages /venv
ENV PATH="/venv/bin:$PATH"
COPY --from=requirements /requirements.txt /requirements.txt
COPY --from=requirements /dev-requirements.txt /dev-requirements.txt
RUN --mount=type=cache,target=~/.cache/pip; \
set -eux; \
pip3 install --upgrade pip; \
pip3 install setuptools wheel; \
# Currently we are unable to relieably build rust-based packages on armv7. This
# is why we need to use the packages shipped by Alpine Linux.
# Since poetry does not allow in-place dependency pinning, we need
# to install the deps using pip.
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles' /requirements.txt \
| pip3 install -r /dev/stdin \
cryptography==38.0.3 \
lxml==4.9.3 \
pillow==9.3.0 \
psycopg2==2.9.5 \
watchfiles==0.18.1
ARG install_dev_deps=0
RUN --mount=type=cache,target=~/.cache/pip; \
set -eux; \
if [ "$install_dev_deps" = "1" ] ; then \
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles' /dev-requirements.txt \
| pip3 install -r /dev/stdin \
cryptography==38.0.3 \
lxml==4.9.3 \
pillow==9.3.0 \
psycopg2==2.9.5 \
watchfiles==0.18.1; \
fi
FROM alpine:3.17 as production
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ARG PIP_NO_CACHE_DIR=1
RUN set -eux; \
apk add --no-cache \
bash \
ffmpeg \
gettext \
jpeg-dev \
libldap \
libmagic \
libpq \
libxml2 \
libxslt \
py3-cryptography=38.0.3-r1 \
py3-lxml=4.9.3-r1 \
py3-pillow=9.3.0-r0 \
py3-psycopg2=2.9.5-r0 \
py3-watchfiles=0.18.1-r0 \
python3 \
tzdata
COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"
COPY . /app
WORKDIR /app
RUN --mount=type=cache,target=~/.cache/pip; \
set -eux; \
pip3 install --no-deps --editable .
ENV IS_DOCKER_SETUP=true
CMD ["./docker/server.sh"]

1
api/Dockerfile Symbolic link
View File

@ -0,0 +1 @@
Dockerfile.alpine

137
api/Dockerfile.alpine Normal file
View File

@ -0,0 +1,137 @@
FROM alpine:3.21 AS requirements
RUN set -eux; \
apk add --no-cache \
poetry \
py3-cryptography \
py3-pip \
python3
COPY pyproject.toml poetry.lock /
RUN set -eux; \
poetry export --without-hashes --extras typesense > requirements.txt; \
poetry export --without-hashes --with dev > dev-requirements.txt;
FROM alpine:3.21 AS builder
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ARG PIP_NO_CACHE_DIR=1
ENV CARGO_NET_GIT_FETCH_WITH_CLI=true
RUN set -eux; \
apk add --no-cache \
cargo \
curl \
gcc \
g++ \
git \
jpeg-dev \
libffi-dev \
libldap \
libxml2-dev \
libxslt-dev \
make \
musl-dev \
openldap-dev \
openssl-dev \
postgresql-dev \
zlib-dev \
py3-cryptography \
py3-lxml \
py3-pillow \
py3-psycopg2 \
py3-watchfiles \
python3-dev \
gfortran \
libgfortran \
openblas-dev \
py3-scipy \
py3-scikit-learn;
# Create virtual env
RUN python3 -m venv --system-site-packages /venv
ENV PATH="/venv/bin:$PATH"
COPY --from=requirements /requirements.txt /requirements.txt
COPY --from=requirements /dev-requirements.txt /dev-requirements.txt
RUN --mount=type=cache,target=~/.cache/pip; \
set -eux; \
pip3 install --upgrade pip;
RUN --mount=type=cache,target=~/.cache/pip; \
set -eux; \
pip3 install setuptools wheel;
RUN --mount=type=cache,target=~/.cache/pip; \
set -eux; \
# Currently we are unable to relieably build rust-based packages on armv7. This
# is why we need to use the packages shipped by Alpine Linux.
# Since poetry does not allow in-place dependency pinning, we need
# to install the deps using pip.
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles|scipy|scikit-learn' /requirements.txt \
| pip3 install -r /dev/stdin \
cryptography \
lxml \
pillow \
psycopg2 \
watchfiles \
scipy \
scikit-learn;
ARG install_dev_deps=0
RUN --mount=type=cache,target=~/.cache/pip; \
set -eux; \
if [ "$install_dev_deps" = "1" ] ; then \
grep -Ev 'cryptography|lxml|pillow|psycopg2|watchfiles' /dev-requirements.txt \
| pip3 install -r /dev/stdin \
cryptography \
lxml \
pillow \
psycopg2 \
watchfiles; \
fi
FROM alpine:3.21 AS production
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ARG PIP_NO_CACHE_DIR=1
RUN set -eux; \
apk add --no-cache \
bash \
ffmpeg \
gettext \
jpeg-dev \
libldap \
libmagic \
libpq \
libxml2 \
libxslt \
py3-cryptography \
py3-lxml \
py3-pillow \
py3-psycopg2 \
py3-watchfiles \
py3-scipy \
py3-scikit-learn \
python3 \
tzdata
COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"
COPY . /app
WORKDIR /app
RUN apk add --no-cache gfortran
RUN --mount=type=cache,target=~/.cache/pip; \
set -eux; \
pip3 install --no-deps --editable .
ENV IS_DOCKER_SETUP=true
CMD ["./docker/server.sh"]

71
api/Dockerfile.debian Normal file
View File

@ -0,0 +1,71 @@
FROM python:3.13-slim AS builder
ARG POETRY_VERSION=1.8
ENV DEBIAN_FRONTEND=noninteractive
ENV VIRTUAL_ENV=/venv
ENV PATH="/venv/bin:$PATH"
ENV POETRY_HOME=/opt/poetry
ENV POETRY_NO_INTERACTION=1
ENV POETRY_VIRTUALENVS_IN_PROJECT=1
ENV POETRY_VIRTUALENVS_CREATE=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Tell Poetry where to place its cache and virtual environment
ENV POETRY_CACHE_DIR=/opt/.cache
RUN pip install "poetry==${POETRY_VERSION}"
RUN --mount=type=cache,target=/var/lib/apt/lists \
apt update; \
apt install -y \
build-essential \
python3-dev \
libldap-dev \
libsasl2-dev \
slapd \
ldap-utils \
tox \
lcov \
valgrind
WORKDIR /app
COPY pyproject.toml .
RUN python3 -m venv --system-site-packages ${VIRTUAL_ENV} && . ${VIRTUAL_ENV}/bin/activate
RUN --mount=type=cache,target=/opt/.cache \
poetry install --no-root --extras typesense
FROM python:3.13-slim AS runtime
ARG POETRY_VERSION=1.8
ENV DEBIAN_FRONTEND=noninteractive
ENV VIRTUAL_ENV=/venv
ENV PATH="/venv/bin:$PATH"
RUN --mount=type=cache,target=/var/lib/apt/lists \
apt update; \
apt install -y \
ffmpeg \
gettext \
libjpeg-dev \
libldap-2.5-0 \
libmagic1 \
libpq5 \
libxml2 \
libxslt1.1
RUN pip install "poetry==${POETRY_VERSION}"
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
WORKDIR /app
COPY . /app
RUN poetry install --extras typesense
CMD ["./docker/server.sh"]

View File

@ -4,11 +4,12 @@ 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 \
--jobs=$(CPU_CORES) \ --jobs=$(CPU_CORES) \
--output-format=colorized \ --output-format=colorized \
--recursive=true \ --recursive=true \
--disable=C,R,W,I \
config funkwhale_api tests config funkwhale_api tests

View File

@ -299,10 +299,31 @@ def background_task(name):
# HOOKS # HOOKS
TRIGGER_THIRD_PARTY_UPLOAD = "third_party_upload"
"""
Called when a track is being listened
"""
LISTENING_CREATED = "listening_created" 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"
""" """

View File

@ -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(),

View File

@ -1,5 +1,3 @@
import os
from drf_spectacular.contrib.django_oauth_toolkit import OpenApiAuthenticationExtension from drf_spectacular.contrib.django_oauth_toolkit import OpenApiAuthenticationExtension
from drf_spectacular.plumbing import build_bearer_security_scheme_object from drf_spectacular.plumbing import build_bearer_security_scheme_object
@ -44,7 +42,6 @@ def custom_preprocessing_hook(endpoints):
filtered = [] filtered = []
# your modifications to the list of operations that are exposed in the schema # your modifications to the list of operations that are exposed in the schema
api_type = os.environ.get("API_TYPE", "v1")
for path, path_regex, method, callback in endpoints: for path, path_regex, method, callback in endpoints:
if path.startswith("/api/v1/providers"): if path.startswith("/api/v1/providers"):
@ -56,7 +53,7 @@ def custom_preprocessing_hook(endpoints):
if path.startswith("/api/v1/oauth/authorize"): if path.startswith("/api/v1/oauth/authorize"):
continue continue
if path.startswith(f"/api/{api_type}"): if path.startswith("/api/v1") or path.startswith("/api/v2"):
filtered.append((path, path_regex, method, callback)) filtered.append((path, path_regex, method, callback))
return filtered return filtered

View File

@ -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
@ -114,6 +114,7 @@ else:
logger.info("Loaded env file at %s/.env", path) logger.info("Loaded env file at %s/.env", path)
break break
FUNKWHALE_PLUGINS = env("FUNKWHALE_PLUGINS", default="")
FUNKWHALE_PLUGINS_PATH = env( FUNKWHALE_PLUGINS_PATH = env(
"FUNKWHALE_PLUGINS_PATH", default="/srv/funkwhale/plugins/" "FUNKWHALE_PLUGINS_PATH", default="/srv/funkwhale/plugins/"
) )
@ -224,6 +225,16 @@ 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 DJANGO_ALLOWED_HOSTS in .env.example for details
See https://docs.djangoproject.com/en/4.2/ref/settings/#csrf-trusted-origins
"""
# APP CONFIGURATION # APP CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
DJANGO_APPS = ( DJANGO_APPS = (
@ -269,6 +280,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",
@ -303,6 +315,7 @@ MIDDLEWARE = (
tuple(plugins.trigger_filter(plugins.MIDDLEWARES_BEFORE, [], enabled=True)) tuple(plugins.trigger_filter(plugins.MIDDLEWARES_BEFORE, [], enabled=True))
+ tuple(ADDITIONAL_MIDDLEWARES_BEFORE) + tuple(ADDITIONAL_MIDDLEWARES_BEFORE)
+ ( + (
"allauth.account.middleware.AccountMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware", "corsheaders.middleware.CorsMiddleware",
@ -601,7 +614,20 @@ if AWS_ACCESS_KEY_ID:
""" """
AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN", default=None) AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN", default=None)
""" """
Custom domain to use for your S3 storage. Custom domain for serving your S3 files.
Useful if your provider offers a CDN-like service for your bucket.
.. important::
The URL must not contain a scheme (:attr:`AWS_S3_URL_PROTOCOL` is
automatically prepended) nor a trailing slash.
"""
AWS_S3_URL_PROTOCOL = env("AWS_S3_URL_PROTOCOL", default="https:")
"""
Protocol to use when constructing the custom domain (see :attr:`AWS_S3_CUSTOM_DOMAIN`)
.. important::
It must end with a `:`, remove `//`.
""" """
AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", default=None) AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", default=None)
""" """
@ -830,7 +856,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`.
""" """
@ -881,7 +907,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``
""" """
@ -942,11 +968,28 @@ CELERY_BEAT_SCHEDULE = {
), ),
"options": {"expires": 60 * 60}, "options": {"expires": 60 * 60},
}, },
"typesense.build_canonical_index": { "listenbrainz.trigger_listening_sync_with_listenbrainz": {
"task": "listenbrainz.trigger_listening_sync_with_listenbrainz",
"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},
},
"tags.update_musicbrainz_genre": {
"task": "tags.update_musicbrainz_genre",
"schedule": crontab(day_of_month="2", minute="30", hour="3"),
"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", "task": "typesense.build_canonical_index",
"schedule": crontab(day_of_week="*/2", minute="0", hour="3"), "schedule": crontab(day_of_week="*/2", minute="0", hour="3"),
"options": {"expires": 60 * 60 * 24}, "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):
@ -1193,7 +1236,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
} }

View File

@ -2,8 +2,7 @@
Local settings Local settings
- Run in Debug mode - Run in Debug mode
- Use console backend for e-mails - Add Django Debug Toolbar when INTERNAL_IPS are given and match the request
- Add Django Debug Toolbar
- Add django-extensions as app - Add django-extensions as app
""" """
@ -25,11 +24,6 @@ SECRET_KEY = env(
"DJANGO_SECRET_KEY", default="mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0sw@r5sua*1zp4fmxc" "DJANGO_SECRET_KEY", default="mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0sw@r5sua*1zp4fmxc"
) )
# Mail settings
# ------------------------------------------------------------------------------
EMAIL_HOST = "localhost"
EMAIL_PORT = 1025
# django-debug-toolbar # django-debug-toolbar
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -96,8 +90,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",
@ -150,4 +142,16 @@ MIDDLEWARE = (
"funkwhale_api.common.middleware.PymallocMiddleware", "funkwhale_api.common.middleware.PymallocMiddleware",
) + MIDDLEWARE ) + MIDDLEWARE
TYPESENSE_API_KEY = "apikey" REST_FRAMEWORK.update(
{
"TEST_REQUEST_RENDERER_CLASSES": [
"rest_framework.renderers.MultiPartRenderer",
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.TemplateHTMLRenderer",
"funkwhale_api.playlists.renderers.PlaylistXspfRenderer",
],
}
)
# allows makemigrations and superuser creation
FORCE = env("FORCE", default=1)

View File

@ -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"

View File

@ -1,9 +0,0 @@
import os
os.environ.setdefault("FUNKWHALE_URL", "http://funkwhale.dev")
from .common import * # noqa
DEBUG = True
SECRET_KEY = "a_super_secret_key!"
TYPESENSE_API_KEY = "apikey"

View File

@ -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,41 @@ 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(
url(r"^accounts/", include("allauth.urls")), r"^api/v1/auth/",
include("funkwhale_api.users.rest_auth_urls"),
),
re_path(
r"^api/v2/auth/",
include("funkwhale_api.users.rest_auth_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 +55,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

View File

@ -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"))]

View File

@ -1,19 +1,36 @@
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
from . import api
router = common_routers.OptionalSlashRouter() 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_v2", "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"))] v2_paths = {
pattern.pattern.regex.pattern
for pattern in v2_patterns
if hasattr(pattern.pattern, "regex")
}
filtered_v1_patterns = [
pattern
for pattern in api.v1_patterns
if pattern.pattern.regex.pattern not in v2_paths
]
v2_patterns += filtered_v1_patterns
urlpatterns = [re_path("", include((v2_patterns, "v2"), namespace="v2"))]

View File

@ -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(

View File

@ -9,5 +9,5 @@ funkwhale-manage migrate
exec gunicorn config.asgi:application \ exec gunicorn config.asgi:application \
--workers "${FUNKWHALE_WEB_WORKERS-1}" \ --workers "${FUNKWHALE_WEB_WORKERS-1}" \
--worker-class uvicorn.workers.UvicornWorker \ --worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:5000 \ --bind 0.0.0.0:"${FUNKWHALE_API_PORT}" \
${GUNICORN_ARGS-} ${GUNICORN_ARGS-}

View File

@ -38,13 +38,27 @@ def combined_recent(limit, **kwargs):
def get_activity(user, limit=20): def get_activity(user, limit=20):
query = fields.privacy_level_query(user, lookup_field="user__privacy_level") query = fields.privacy_level_query(
user, "actor__user__privacy_level", "actor__user"
)
querysets = [ querysets = [
Listening.objects.filter(query).select_related( Listening.objects.filter(query)
"track", "user", "track__artist", "track__album__artist" .select_related(
"track",
"actor",
)
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
), ),
TrackFavorite.objects.filter(query).select_related( TrackFavorite.objects.filter(query)
"track", "user", "track__artist", "track__album__artist" .select_related(
"track",
"actor",
)
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
), ),
] ]
records = combined_recent(limit=limit, querysets=querysets) records = combined_recent(limit=limit, querysets=querysets)

View File

@ -21,7 +21,11 @@ TAG_FILTER = common_filters.MultipleQueryFilter(method=filter_tags)
class ChannelFilter(moderation_filters.HiddenContentFilterSet): class ChannelFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter( q = fields.SearchFilter(
search_fields=["artist__name", "actor__summary", "actor__preferred_username"] search_fields=[
"artist_credit__artist__name",
"actor__summary",
"actor__preferred_username",
]
) )
tag = TAG_FILTER tag = TAG_FILTER
scope = common_filters.ActorScopeFilter(actor_field="attributed_to", distinct=True) scope = common_filters.ActorScopeFilter(actor_field="attributed_to", distinct=True)

View File

@ -26,6 +26,7 @@ from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks
from funkwhale_api.music.serializers import COVER_WRITE_FIELD, CoverField from funkwhale_api.music.serializers import COVER_WRITE_FIELD, CoverField
from funkwhale_api.tags import models as tags_models from funkwhale_api.tags import models as tags_models
from funkwhale_api.tags import serializers as tags_serializers from funkwhale_api.tags import serializers as tags_serializers
@ -246,11 +247,14 @@ class SimpleChannelArtistSerializer(serializers.Serializer):
description = common_serializers.ContentSerializer(allow_null=True, required=False) description = common_serializers.ContentSerializer(allow_null=True, required=False)
cover = CoverField(allow_null=True, required=False) cover = CoverField(allow_null=True, required=False)
channel = serializers.UUIDField(allow_null=True, required=False) channel = serializers.UUIDField(allow_null=True, required=False)
tracks_count = serializers.IntegerField(source="_tracks_count", required=False) tracks_count = serializers.SerializerMethodField(required=False)
tags = serializers.ListField( tags = serializers.ListField(
child=serializers.CharField(), source="_prefetched_tagged_items", required=False child=serializers.CharField(), source="_prefetched_tagged_items", required=False
) )
def get_tracks_count(self, o) -> int:
return getattr(o, "_tracks_count", 0)
class ChannelSerializer(serializers.ModelSerializer): class ChannelSerializer(serializers.ModelSerializer):
artist = SimpleChannelArtistSerializer() artist = SimpleChannelArtistSerializer()
@ -749,7 +753,7 @@ class RssFeedItemSerializer(serializers.Serializer):
else: else:
existing_track = ( existing_track = (
music_models.Track.objects.filter( music_models.Track.objects.filter(
uuid=expected_uuid, artist__channel=channel uuid=expected_uuid, artist_credit__artist__channel=channel
) )
.select_related("description", "attachment_cover") .select_related("description", "attachment_cover")
.first() .first()
@ -765,7 +769,6 @@ class RssFeedItemSerializer(serializers.Serializer):
"disc_number": validated_data.get("itunes_season", 1) or 1, "disc_number": validated_data.get("itunes_season", 1) or 1,
"position": validated_data.get("itunes_episode", 1) or 1, "position": validated_data.get("itunes_episode", 1) or 1,
"title": validated_data["title"], "title": validated_data["title"],
"artist": channel.artist,
} }
) )
if "rights" in validated_data: if "rights" in validated_data:
@ -801,6 +804,21 @@ class RssFeedItemSerializer(serializers.Serializer):
**track_kwargs, **track_kwargs,
defaults=track_defaults, defaults=track_defaults,
) )
# channel only have one artist so we can safely update artist_credit
defaults = {
"artist": channel.artist,
"credit": channel.artist.name,
"joinphrase": "",
}
query = (
Q(artist=channel.artist) & Q(credit=channel.artist.name) & Q(joinphrase="")
)
artist_credit = tasks.get_best_candidate_or_create(
music_models.ArtistCredit, query, defaults, ["artist", "joinphrase"]
)
track.artist_credit.set([artist_credit[0]])
# optimisation for reducing SQL queries, because we cannot use select_related with # optimisation for reducing SQL queries, because we cannot use select_related with
# update or create, so we restore the cache by hand # update or create, so we restore the cache by hand
if existing_track: if existing_track:

View File

@ -27,7 +27,7 @@ ARTIST_PREFETCH_QS = (
"attachment_cover", "attachment_cover",
) )
.prefetch_related(music_views.TAG_PREFETCH) .prefetch_related(music_views.TAG_PREFETCH)
.annotate(_tracks_count=Count("tracks")) .annotate(_tracks_count=Count("artist_credit__tracks"))
) )
@ -103,7 +103,7 @@ class ChannelViewSet(
queryset = super().get_queryset() queryset = super().get_queryset()
if self.action == "retrieve": if self.action == "retrieve":
queryset = queryset.annotate( queryset = queryset.annotate(
_downloads_count=Sum("artist__tracks__downloads_count") _downloads_count=Sum("artist__artist_credit__tracks__downloads_count")
) )
return queryset return queryset
@ -192,7 +192,6 @@ class ChannelViewSet(
if object.attributed_to == actors.get_service_actor(): if object.attributed_to == actors.get_service_actor():
# external feed, we redirect to the canonical one # external feed, we redirect to the canonical one
return http.HttpResponseRedirect(object.rss_url) return http.HttpResponseRedirect(object.rss_url)
uploads = ( uploads = (
object.library.uploads.playable_by(None) object.library.uploads.playable_by(None)
.prefetch_related( .prefetch_related(

View File

@ -49,6 +49,7 @@ def handler_create_user(
utils.logger.warn("Unknown permission %s", permission) utils.logger.warn("Unknown permission %s", permission)
utils.logger.debug("Creating actor…") utils.logger.debug("Creating actor…")
user.actor = models.create_actor(user) user.actor = models.create_actor(user)
models.create_user_libraries(user)
user.save() user.save()
return user return user

View File

@ -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):

View File

@ -24,8 +24,20 @@ def privacy_level_query(user, lookup_field="privacy_level", user_field="user"):
if user.is_anonymous: if user.is_anonymous:
return models.Q(**{lookup_field: "everyone"}) return models.Q(**{lookup_field: "everyone"})
return models.Q(**{f"{lookup_field}__in": ["instance", "everyone"]}) | models.Q( followers_query = models.Q(
**{lookup_field: "me", user_field: user} **{
f"{lookup_field}": "followers",
f"{user_field}__actor__in": user.actor.get_approved_followings(),
}
)
# Federated TrackFavorite don't have an user associated with the trackfavorite.actor
no_user_query = models.Q(**{f"{user_field}__isnull": True})
return (
models.Q(**{f"{lookup_field}__in": ["instance", "everyone"]})
| models.Q(**{lookup_field: "me", user_field: user})
| followers_query
| no_user_query
) )

View File

@ -1,5 +1,4 @@
import os from django.conf import settings
from django.contrib.auth.management.commands.createsuperuser import ( from django.contrib.auth.management.commands.createsuperuser import (
Command as BaseCommand, Command as BaseCommand,
) )
@ -12,7 +11,8 @@ class Command(BaseCommand):
Creating Django Superusers would bypass some of our username checks, which can lead to unexpected behaviour. Creating Django Superusers would bypass some of our username checks, which can lead to unexpected behaviour.
We therefore prohibit the execution of the command. We therefore prohibit the execution of the command.
""" """
if not os.environ.get("FORCE") == "1": force = settings.FORCE
if not force == 1:
raise CommandError( raise CommandError(
"Running createsuperuser on your Funkwhale instance bypasses some of our checks " "Running createsuperuser on your Funkwhale instance bypasses some of our checks "
"which can lead to unexpected behavior of your instance. We therefore suggest to " "which can lead to unexpected behavior of your instance. We therefore suggest to "

View File

@ -68,22 +68,33 @@ def create_taggable_items(dependency):
CONFIG = [ CONFIG = [
{
"id": "artist_credit",
"model": music_models.ArtistCredit,
"factory": "music.ArtistCredit",
"factory_kwargs": {"joinphrase": ""},
"depends_on": [
{"field": "artist", "id": "artists", "default_factor": 0.5},
],
},
{ {
"id": "tracks", "id": "tracks",
"model": music_models.Track, "model": music_models.Track,
"factory": "music.Track", "factory": "music.Track",
"factory_kwargs": {"artist": None, "album": None}, "factory_kwargs": {"album": None},
"depends_on": [ "depends_on": [
{"field": "album", "id": "albums", "default_factor": 0.1}, {"field": "album", "id": "albums", "default_factor": 0.1},
{"field": "artist", "id": "artists", "default_factor": 0.05}, {"field": "artist_credit", "id": "artist_credit", "default_factor": 0.05},
], ],
}, },
{ {
"id": "albums", "id": "albums",
"model": music_models.Album, "model": music_models.Album,
"factory": "music.Album", "factory": "music.Album",
"factory_kwargs": {"artist": None}, "factory_kwargs": {},
"depends_on": [{"field": "artist", "id": "artists", "default_factor": 0.3}], "depends_on": [
{"field": "artist_credit", "id": "artist_credit", "default_factor": 0.3}
],
}, },
{"id": "artists", "model": music_models.Artist, "factory": "music.Artist"}, {"id": "artists", "model": music_models.Artist, "factory": "music.Artist"},
{ {
@ -310,12 +321,23 @@ class Command(BaseCommand):
candidates = list(queryset.values_list("pk", flat=True)) candidates = list(queryset.values_list("pk", flat=True))
picked_pks = [random.choice(candidates) for _ in objects] picked_pks = [random.choice(candidates) for _ in objects]
picked_objects = {o.pk: o for o in queryset.filter(pk__in=picked_pks)} picked_objects = {o.pk: o for o in queryset.filter(pk__in=picked_pks)}
saved_obj = []
for i, obj in enumerate(objects): for i, obj in enumerate(objects):
if create_dependencies: if create_dependencies:
value = random.choice(candidates) value = random.choice(candidates)
else: else:
value = picked_objects[picked_pks[i]] value = picked_objects[picked_pks[i]]
if dependency["field"] == "artist_credit":
obj.save()
obj.artist_credit.set([value])
saved_obj.append(obj)
else:
setattr(obj, dependency["field"], value) setattr(obj, dependency["field"], value)
if saved_obj:
return saved_obj
if not handler: if not handler:
objects = row["model"].objects.bulk_create(objects, batch_size=BATCH_SIZE) objects = row["model"].objects.bulk_create(objects, batch_size=BATCH_SIZE)
results[row["id"]] = objects results[row["id"]] = objects

View File

@ -1,5 +1,4 @@
import os from django.conf import settings
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.core.management.commands.makemigrations import Command as BaseCommand from django.core.management.commands.makemigrations import Command as BaseCommand
@ -11,8 +10,8 @@ class Command(BaseCommand):
We ensure the command is disabled, unless a specific env var is provided. We ensure the command is disabled, unless a specific env var is provided.
""" """
force = os.environ.get("FORCE") == "1" force = settings.FORCE
if not force: if not force == 1:
raise CommandError( raise CommandError(
"Running makemigrations on your Funkwhale instance can have desastrous" "Running makemigrations on your Funkwhale instance can have desastrous"
" consequences. This command is disabled, and should only be run in " " consequences. This command is disabled, and should only be run in "

View File

@ -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):

View File

@ -56,3 +56,59 @@ class OwnerPermission(BasePermission):
if not owner or not request.user.is_authenticated or owner != request.user: if not owner or not request.user.is_authenticated or owner != request.user:
raise owner_exception raise owner_exception
return True return True
class PrivacyLevelPermission(BasePermission):
"""
Ensure the request actor have access to the object considering the privacylevel configuration
of the user.
request.user is None if actor, else its Anonymous if user is not auth.
"""
def has_object_permission(self, request, view, obj):
if (
not hasattr(obj, "user")
and hasattr(obj, "actor")
and not obj.actor.is_local
):
# it's a remote actor object. It should be public.
# But we could trigger an update of the remote actor data
# to avoid leaking data (#2326)
return True
privacy_level = (
obj.actor.user.privacy_level
if hasattr(obj, "actor")
else obj.user.privacy_level
)
obj_actor = obj.actor if hasattr(obj, "actor") else obj.user.actor
if privacy_level == "everyone":
return True
# user is anonymous
if hasattr(request, "actor"):
request_actor = request.actor
elif request.user and request.user.is_authenticated:
request_actor = request.user.actor
else:
return False
if privacy_level == "instance":
# user is local
if request.user and hasattr(request.user, "actor"):
return True
elif hasattr(request, "actor") and request.actor and request.actor.is_local:
return True
else:
return False
elif privacy_level == "me" and obj_actor == request_actor:
return True
elif privacy_level == "followers" and (
request_actor in obj.user.actor.get_approved_followers()
):
return True
else:
return False

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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")
@ -293,7 +293,17 @@ class AttachmentSerializer(serializers.Serializer):
file = StripExifImageField(write_only=True) file = StripExifImageField(write_only=True)
urls = serializers.SerializerMethodField() urls = serializers.SerializerMethodField()
@extend_schema_field(OpenApiTypes.OBJECT) @extend_schema_field(
{
"type": "object",
"properties": {
"original": {"type": "string"},
"small_square_crop": {"type": "string"},
"medium_square_crop": {"type": "string"},
"large_square_crop": {"type": "string"},
},
}
)
def get_urls(self, o): def get_urls(self, o):
urls = {} urls = {}
urls["source"] = o.url urls["source"] = o.url

View File

@ -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()

View File

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

View File

@ -0,0 +1,13 @@
import logging
from config import plugins
from funkwhale_api.contrib.archivedl import tasks
from .funkwhale_startup import PLUGIN
logger = logging.getLogger(__name__)
@plugins.register_hook(plugins.TRIGGER_THIRD_PARTY_UPLOAD, PLUGIN)
def lauch_download(track, conf={}):
tasks.archive_download.delay(track_id=track.pk, conf=conf)

View File

@ -0,0 +1,10 @@
from config import plugins
PLUGIN = plugins.get_plugin_config(
name="archivedl",
label="Archive-dl",
description="",
version="0.1",
user=False,
conf=[],
)

View File

@ -0,0 +1,148 @@
import asyncio
import hashlib
import logging
import os
import tempfile
import urllib.parse
import requests
from django.core.files import File
from django.utils import timezone
from funkwhale_api.federation import actors
from funkwhale_api.music import models, utils
from funkwhale_api.taskapp import celery
logger = logging.getLogger(__name__)
def create_upload(url, track, files_data):
mimetype = f"audio/{files_data.get('format', 'unknown')}"
duration = files_data.get("mtime", 0)
filesize = files_data.get("size", 0)
bitrate = files_data.get("bitrate", 0)
service_library = models.Library.objects.create(
privacy_level="everyone",
actor=actors.get_service_actor(),
)
return models.Upload.objects.create(
mimetype=mimetype,
source=url,
third_party_provider="archive-dl",
creation_date=timezone.now(),
track=track,
duration=duration,
size=filesize,
bitrate=bitrate,
library=service_library,
from_activity=None,
import_status="finished",
)
@celery.app.task(name="archivedl.archive_download")
@celery.require_instance(models.Track.objects.select_related(), "track")
def archive_download(track, conf):
artist_name = utils.get_artist_credit_string(track)
query = f"mediatype:audio AND title:{track.title} AND creator:{artist_name}"
with requests.Session() as session:
url = get_search_url(query, page_size=1, page=1)
page_data = fetch_json(url, session)
for obj in page_data["response"]["docs"]:
logger.info(f"launching download item for {str(obj)}")
download_item(
item_data=obj,
session=session,
allowed_extensions=utils.SUPPORTED_EXTENSIONS,
track=track,
)
def fetch_json(url, session):
logger.info(f"Fetching {url}...")
with session.get(url) as response:
return response.json()
def download_item(
item_data,
session,
allowed_extensions,
track,
):
files_data = get_files_data(item_data["identifier"], session)
to_download = list(
filter_files(
files_data["result"],
allowed_extensions=allowed_extensions,
)
)
url = f"https://archive.org/download/{item_data['identifier']}/{to_download[0]['name']}"
upload = create_upload(url, track, to_download[0])
try:
with tempfile.TemporaryDirectory() as temp_dir:
path = os.path.join(temp_dir, to_download[0]["name"])
download_file(
path,
url=url,
session=session,
checksum=to_download[0]["sha1"],
upload=upload,
to_download=to_download,
)
logger.info(f"Finished to download item {item_data['identifier']}...")
except Exception as e:
upload.delete()
raise e
def check_integrity(path, expected_checksum):
with open(path, mode="rb") as f:
hash = hashlib.sha1()
hash.update(f.read())
return expected_checksum == hash.hexdigest()
def get_files_data(identifier, session):
url = f"https://archive.org/metadata/{identifier}/files"
logger.info(f"Fetching files data at {url}...")
with session.get(url) as response:
return response.json()
def download_file(path, url, session, checksum, upload, to_download):
if os.path.exists(path) and check_integrity(path, checksum):
logger.info(f"Skipping already downloaded file at {path}")
return
logger.info(f"Downloading file {url}...")
with open(path, mode="wb") as f:
try:
with session.get(url) as response:
f.write(response.content)
except asyncio.TimeoutError as e:
logger.error(f"Timeout error while downloading {url}: {e}")
with open(path, "rb") as f:
upload.audio_file.save(f"{to_download['name']}", File(f))
upload.import_status = "finished"
upload.url = url
upload.save()
return upload
def filter_files(files, allowed_extensions):
for f in files:
if allowed_extensions:
extension = os.path.splitext(f["name"])[-1][1:]
if extension not in allowed_extensions:
continue
yield f
def get_search_url(query, page_size, page):
q = urllib.parse.urlencode({"q": query})
return f"https://archive.org/advancedsearch.php?{q}&sort[]=addeddate+desc&rows={page_size}&page={page}&output=json"

View File

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

View File

@ -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,15 +40,97 @@ 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)
mbids = [ac.artist.mbid for ac in track.artist_credit.all() if ac.artist.mbid]
if track.artist.mbid: if mbids:
additional_info["artist_mbids"] = [str(track.artist.mbid)] additional_info["artist_mbids"] = mbids
upload = track.uploads.filter(duration__gte=0).first() upload = track.uploads.filter(duration__gte=0).first()
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,
listened_at=listening.creation_date.timestamp(),
artist_name=track.get_artist_credit_string,
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(actor=user.actor)
.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(actor=user.actor)
.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)

View File

@ -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",
},
], ],
) )

View File

@ -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, datetime.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, datetime.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, datetime.timezone.utc
),
track=track,
actor=user.actor,
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"], datetime.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(
actor=user.actor,
creation_date=datetime.datetime.fromtimestamp(
feedback["created"], datetime.timezone.utc
),
track=track,
source="Listenbrainz",
)
elif feedback["score"] == 0:
try:
favorites_models.TrackFavorite.objects.get(
actor=user.actor, track=track
).delete()
except favorites_models.TrackFavorite.DoesNotExist:
continue
elif feedback["score"] == -1:
logger.info("Funkwhale doesn't support disliked tracks")

View File

@ -37,7 +37,7 @@ def get_payload(listening, api_key, conf):
# See https://github.com/krateng/maloja/blob/master/API.md # See https://github.com/krateng/maloja/blob/master/API.md
payload = { payload = {
"key": api_key, "key": api_key,
"artists": [track.artist.name], "artists": [artist.name for artist in track.artist_credit.get_artists_list()],
"title": track.title, "title": track.title,
"time": int(listening.creation_date.timestamp()), "time": int(listening.creation_date.timestamp()),
"nofix": bool(conf.get("nofix")), "nofix": bool(conf.get("nofix")),
@ -46,8 +46,10 @@ def get_payload(listening, api_key, conf):
if track.album: if track.album:
if track.album.title: if track.album.title:
payload["album"] = track.album.title payload["album"] = track.album.title
if track.album.artist: if track.album.artist_credit.all():
payload["albumartists"] = [track.album.artist.name] payload["albumartists"] = [
artist.name for artist in track.album.artist_credit.get_artists_list()
]
upload = track.uploads.filter(duration__gte=0).first() upload = track.uploads.filter(duration__gte=0).first()
if upload: if upload:

View File

@ -29,7 +29,7 @@ def forward_to_scrobblers(listening, conf, **kwargs):
(username + " " + password).encode("utf-8") (username + " " + password).encode("utf-8")
).hexdigest() ).hexdigest()
cache_key = "lastfm:sessionkey:{}".format( cache_key = "lastfm:sessionkey:{}".format(
":".join([str(listening.user.pk), hashed_auth]) ":".join([str(listening.actor.pk), hashed_auth])
) )
PLUGIN["logger"].info("Forwarding scrobble to %s", LASTFM_SCROBBLER_URL) PLUGIN["logger"].info("Forwarding scrobble to %s", LASTFM_SCROBBLER_URL)
session_key = PLUGIN["cache"].get(cache_key) session_key = PLUGIN["cache"].get(cache_key)

View File

@ -84,7 +84,7 @@ def get_scrobble_payload(track, date, suffix="[0]"):
""" """
upload = track.uploads.filter(duration__gte=0).first() upload = track.uploads.filter(duration__gte=0).first()
data = { data = {
f"a{suffix}": track.artist.name, f"a{suffix}": track.get_artist_credit_string,
f"t{suffix}": track.title, f"t{suffix}": track.title,
f"l{suffix}": upload.duration if upload else 0, f"l{suffix}": upload.duration if upload else 0,
f"b{suffix}": (track.album.title if track.album else "") or "", f"b{suffix}": (track.album.title if track.album else "") or "",
@ -103,7 +103,7 @@ def get_scrobble2_payload(track, date, suffix="[0]"):
""" """
upload = track.uploads.filter(duration__gte=0).first() upload = track.uploads.filter(duration__gte=0).first()
data = { data = {
"artist": track.artist.name, "artist": track.get_artist_credit_string,
"track": track.title, "track": track.title,
"chosenByUser": 1, "chosenByUser": 1,
} }

View File

@ -314,9 +314,12 @@ class FunkwhaleProvider(internet_provider.Provider):
not random enough not random enough
""" """
def federation_url(self, prefix="", local=False): def federation_url(self, prefix="", obj_uuid=None, local=False):
if not obj_uuid:
obj_uuid = uuid.uuid4()
def path_generator(): def path_generator():
return f"{prefix}/{uuid.uuid4()}" return f"{prefix}/{obj_uuid}"
domain = settings.FEDERATION_HOSTNAME if local else self.domain_name() domain = settings.FEDERATION_HOSTNAME if local else self.domain_name()
protocol = "https" protocol = "https"

View File

@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.TrackFavoriteActivitySerializer)
@record.registry.register_consumer("favorites.TrackFavorite") @record.registry.register_consumer("favorites.TrackFavorite")
def broadcast_track_favorite_to_instance_activity(data, obj): def broadcast_track_favorite_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]: if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return return
channels.group_send( channels.group_send(

View File

@ -5,5 +5,5 @@ from . import models
@admin.register(models.TrackFavorite) @admin.register(models.TrackFavorite)
class TrackFavoriteAdmin(admin.ModelAdmin): class TrackFavoriteAdmin(admin.ModelAdmin):
list_display = ["user", "track", "creation_date"] list_display = ["actor", "track", "creation_date"]
list_select_related = ["user", "track"] list_select_related = ["actor", "track"]

View File

@ -1,14 +1,28 @@
import factory import factory
from django.conf import settings
from funkwhale_api.factories import NoUpdateOnCreate, registry from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import models
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.music.factories import TrackFactory from funkwhale_api.music.factories import TrackFactory
from funkwhale_api.users.factories import UserFactory
@registry.register @registry.register
class TrackFavorite(NoUpdateOnCreate, factory.django.DjangoModelFactory): class TrackFavorite(NoUpdateOnCreate, factory.django.DjangoModelFactory):
track = factory.SubFactory(TrackFactory) track = factory.SubFactory(TrackFactory)
user = factory.SubFactory(UserFactory) actor = factory.SubFactory(ActorFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
class Meta: class Meta:
model = "favorites.TrackFavorite" model = "favorites.TrackFavorite"
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
return
domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[
0
]
self.fid = f"https://{domain}/federation/music/favorite/{self.uuid}"
self.save(update_fields=["fid"])

View File

@ -9,7 +9,7 @@ class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter( q = fields.SearchFilter(
search_fields=["track__title", "track__artist__name", "track__album__title"] search_fields=["track__title", "track__artist__name", "track__album__title"]
) )
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite

View File

@ -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),
),
]

View File

@ -0,0 +1,102 @@
# Generated by Django 4.2.9 on 2024-03-28 23:32
import uuid
from django.db import migrations, models, transaction
import django.db.models.deletion
from django.conf import settings
from funkwhale_api.federation import utils
from django.urls import reverse
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("favorites", "TrackFavorite")
for row in MyModel.objects.all():
unique_uuid = uuid.uuid4()
while MyModel.objects.filter(uuid=unique_uuid).exists():
unique_uuid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:likes-detail", kwargs={"uuid": unique_uuid})
)
row.uuid = unique_uuid
row.fid = fid
row.save(update_fields=["uuid", "fid"])
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("favorites", "TrackFavorite")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
class Migration(migrations.Migration):
dependencies = [
("favorites", "0002_trackfavorite_source"),
]
operations = [
migrations.AddField(
model_name="trackfavorite",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="track_favorites",
to="federation.actor",
),
),
migrations.AddField(
model_name="trackfavorite",
name="fid",
field=models.URLField(
db_index=True,
default="https://default.fid",
max_length=500,
unique=True,
),
preserve_default=False,
),
migrations.AddField(
model_name="trackfavorite",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="trackfavorite",
name="uuid",
field=models.UUIDField(null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="trackfavorite",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True, null=False),
),
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="trackfavorite",
name="actor",
field=models.ForeignKey(
blank=False,
null=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="track_favorites",
to="federation.actor",
), ),
migrations.AlterUniqueTogether(
name="trackfavorite",
unique_together={("track", "actor")},
),
migrations.RemoveField(
model_name="trackfavorite",
name="user",
),
]

View File

@ -1,26 +1,91 @@
import uuid
from django.db import models from django.db import models
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
FAVORITE_PRIVACY_LEVEL_CHOICES = [
(k, l) for k, l in fields.PRIVACY_LEVEL_CHOICES if k != "followers"
]
class TrackFavorite(models.Model):
class TrackFavoriteQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
def viewable_by(self, actor):
if actor is None:
return self.filter(actor__user__privacy_level="everyone")
if hasattr(actor, "user"):
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
instance_query = models.Q(
actor__user__privacy_level="instance", actor__domain=actor.domain
)
instance_actor_query = models.Q(
actor__user__privacy_level="instance", actor__domain=actor.domain
)
return self.filter(
me_query
| instance_query
| instance_actor_query
| models.Q(actor__user__privacy_level="everyone")
)
class TrackFavorite(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey( actor = models.ForeignKey(
"users.User", related_name="track_favorites", on_delete=models.CASCADE "federation.Actor",
related_name="track_favorites",
on_delete=models.CASCADE,
null=False,
blank=False,
) )
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)
federation_namespace = "likes"
objects = TrackFavoriteQuerySet.as_manager()
class Meta: class Meta:
unique_together = ("track", "user") unique_together = ("track", "actor")
ordering = ("-creation_date",) ordering = ("-creation_date",)
@classmethod @classmethod
def add(cls, track, user): def add(cls, track, actor):
favorite, created = cls.objects.get_or_create(user=user, track=track) favorite, created = cls.objects.get_or_create(actor=actor, track=track)
return favorite return favorite
def get_activity_url(self): def get_activity_url(self):
return f"{self.user.get_activity_url()}/favorites/tracks/{self.pk}" return f"{self.actor.get_absolute_url()}/favorites/tracks/{self.pk}"
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)

View File

@ -1,10 +1,8 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models from . import models
@ -12,35 +10,24 @@ from . import models
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track") object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user") actor = federation_serializers.APIActorSerializer(read_only=True)
published = serializers.DateTimeField(source="creation_date") published = serializers.DateTimeField(source="creation_date")
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ["id", "local_id", "object", "type", "actor", "published"] fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj): def get_type(self, obj):
return "Like" return "Like"
class UserTrackFavoriteSerializer(serializers.ModelSerializer): class UserTrackFavoriteSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True) track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True) actor = federation_serializers.APIActorSerializer(read_only=True)
actor = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ("id", "user", "track", "creation_date", "actor") fields = ("id", "actor", "track", "creation_date", "actor")
actor = serializers.SerializerMethodField()
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer): class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer):

View File

@ -4,8 +4,10 @@ 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.federation import routes
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
from funkwhale_api.users.oauth import permissions as oauth_permissions from funkwhale_api.users.oauth import permissions as oauth_permissions
@ -22,7 +24,7 @@ class TrackFavoriteViewSet(
filterset_class = filters.TrackFavoriteFilter filterset_class = filters.TrackFavoriteFilter
serializer_class = serializers.UserTrackFavoriteSerializer serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all().select_related( queryset = models.TrackFavorite.objects.all().select_related(
"user__actor__attachment_icon" "actor__attachment_icon"
) )
permission_classes = [ permission_classes = [
oauth_permissions.ScopePermission, oauth_permissions.ScopePermission,
@ -31,6 +33,7 @@ class TrackFavoriteViewSet(
required_scope = "favorites" required_scope = "favorites"
anonymous_policy = "setting" anonymous_policy = "setting"
owner_checks = ["write"] owner_checks = ["write"]
owner_field = "actor.user"
def get_serializer_class(self): def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]: if self.request.method.lower() in ["head", "get", "options"]:
@ -44,7 +47,20 @@ 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)
routes.outbox.dispatch(
{"type": "Like", "object": {"type": "Track"}},
context={
"track": instance.track,
"actor": instance.actor,
"id": instance.fid,
},
)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers serializer.data, status=status.HTTP_201_CREATED, headers=headers
) )
@ -52,19 +68,30 @@ class TrackFavoriteViewSet(
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") fields.privacy_level_query(
self.request.user, "actor__user__privacy_level", "actor__user"
) )
tracks = Track.objects.with_playable_uploads( )
tracks = (
Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request) music_utils.get_actor_from_request(self.request)
).select_related(
"artist", "album__artist", "attributed_to", "album__attachment_cover"
) )
.prefetch_related(
"artist_credit__artist",
"album__artist_credit__artist",
)
.select_related(
"attributed_to",
"album__attachment_cover",
)
)
queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks)) queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
return queryset return queryset
def perform_create(self, serializer): def perform_create(self, serializer):
track = Track.objects.get(pk=serializer.data["track"]) track = Track.objects.get(pk=serializer.data["track"])
favorite = models.TrackFavorite.add(track=track, user=self.request.user) favorite = models.TrackFavorite.add(track=track, actor=self.request.user.actor)
return favorite return favorite
@extend_schema(operation_id="unfavorite_track") @extend_schema(operation_id="unfavorite_track")
@ -72,10 +99,19 @@ class TrackFavoriteViewSet(
def remove(self, request, *args, **kwargs): def remove(self, request, *args, **kwargs):
try: try:
pk = int(request.data["track"]) pk = int(request.data["track"])
favorite = request.user.track_favorites.get(track__pk=pk) favorite = request.user.actor.track_favorites.get(track__pk=pk)
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist): except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
return Response({}, status=400) return Response({}, status=400)
routes.outbox.dispatch(
{"type": "Dislike", "object": {"type": "Track"}},
context={"favorite": favorite},
)
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(
@ -92,7 +128,9 @@ class TrackFavoriteViewSet(
if not request.user.is_authenticated: if not request.user.is_authenticated:
return Response({"results": [], "count": 0}, status=401) return Response({"results": [], "count": 0}, status=401)
favorites = request.user.track_favorites.values("id", "track").order_by("id") favorites = request.user.actor.track_favorites.values("id", "track").order_by(
"id"
)
payload = serializers.AllFavoriteSerializer(favorites).data payload = serializers.AllFavoriteSerializer(favorites).data
return Response(payload, status=200) return Response(payload, status=200)

View File

@ -119,6 +119,9 @@ def should_reject(fid, actor_id=None, payload={}):
@transaction.atomic @transaction.atomic
def receive(activity, on_behalf_of, inbox_actor=None): def receive(activity, on_behalf_of, inbox_actor=None):
"""
Receive an activity, find his recipients and save it to the database before dispatching it
"""
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
from . import models, serializers, tasks from . import models, serializers, tasks
@ -223,6 +226,9 @@ class InboxRouter(Router):
""" """
from . import api_serializers, models from . import api_serializers, models
logger.debug(
f"[federation] Inbox dispatch payload : {payload} with context : {context}"
)
handlers = self.get_matching_handlers(payload) handlers = self.get_matching_handlers(payload)
for handler in handlers: for handler in handlers:
if call_handlers: if call_handlers:
@ -293,6 +299,59 @@ def schedule_key_rotation(actor_id, delay):
tasks.rotate_actor_key.apply_async(kwargs={"actor_id": actor_id}, countdown=delay) tasks.rotate_actor_key.apply_async(kwargs={"actor_id": actor_id}, countdown=delay)
def activity_pass_user_privacy_level(context, routing):
TYPE_FOLLOW_USER_PRIVACY_LEVEL = ["Listen", "Like", "Create"]
TYPE_IGNORE_USER_PRIVACY_LEVEL = ["Delete", "Accept", "Follow"]
MUSIC_OBJECT_TYPE = ["Audio", "Track", "Album", "Artist"]
actor = context.get("actor", False)
type = routing.get("type", False)
object_type = routing.get("object", {}).get("type", None)
if not actor:
logger.warning(
"No actor provided in activity context : \
we cannot follow actor.privacy_level, activity will be sent by default."
)
# We do not consider music metadata has private
if object_type in MUSIC_OBJECT_TYPE:
return True
if type:
if type in TYPE_IGNORE_USER_PRIVACY_LEVEL:
return True
if type in TYPE_FOLLOW_USER_PRIVACY_LEVEL and actor and actor.is_local:
if actor.user.privacy_level in [
"me",
"instance",
]:
return False
return True
return True
def activity_pass_object_privacy_level(context, routing):
MUSIC_OBJECT_TYPE = ["Audio", "Track", "Album", "Artist"]
# we only support playlist federation for now
object = context.get("playlist", False)
obj_privacy_level = object.privacy_level if object else None
object_type = routing.get("object", {}).get("type", None)
# We do not consider music metadata has private
if object_type in MUSIC_OBJECT_TYPE:
return True
if object and obj_privacy_level and obj_privacy_level in ["me", "instance"]:
return False
return True
class OutboxRouter(Router): class OutboxRouter(Router):
@transaction.atomic @transaction.atomic
def dispatch(self, routing, context): def dispatch(self, routing, context):
@ -305,6 +364,7 @@ class OutboxRouter(Router):
from . import models, tasks from . import models, tasks
logger.debug(f"[federation] Outbox dispatch context : {context}")
allow_list_enabled = preferences.get("moderation__allow_list_enabled") allow_list_enabled = preferences.get("moderation__allow_list_enabled")
allowed_domains = None allowed_domains = None
if allow_list_enabled: if allow_list_enabled:
@ -314,6 +374,18 @@ class OutboxRouter(Router):
) )
) )
if activity_pass_user_privacy_level(context, routing) is False:
logger.info(
"[federation] Discarding outbox dispatch due to user privacy_level"
)
return
if activity_pass_object_privacy_level(context, routing) is False:
logger.info(
"[federation] Discarding outbox dispatch due to object privacy_level"
)
return
for route, handler in self.routes: for route, handler in self.routes:
if not match_route(route, routing): if not match_route(route, routing):
continue continue
@ -397,6 +469,7 @@ class OutboxRouter(Router):
) )
for a in activities: for a in activities:
logger.info(f"[federation] OUtbox sending activity : {a.pk}")
funkwhale_utils.on_commit(tasks.dispatch_outbox.delay, activity_id=a.pk) funkwhale_utils.on_commit(tasks.dispatch_outbox.delay, activity_id=a.pk)
return activities return activities
@ -554,12 +627,6 @@ def get_actors_from_audience(urls):
final_query, Q(pk__in=actor_follows.values_list("actor", flat=True)) final_query, Q(pk__in=actor_follows.values_list("actor", flat=True))
) )
library_follows = models.LibraryFollow.objects.filter(
queries["followed"], approved=True
)
final_query = funkwhale_utils.join_queries_or(
final_query, Q(pk__in=library_follows.values_list("actor", flat=True))
)
if not final_query: if not final_query:
return models.Actor.objects.none() return models.Actor.objects.none()
return models.Actor.objects.filter(final_query) return models.Actor.objects.filter(final_query)

View File

@ -1,4 +1,5 @@
import datetime import datetime
from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.core import validators from django.core import validators
@ -55,7 +56,6 @@ class LibrarySerializer(serializers.ModelSerializer):
"uuid", "uuid",
"actor", "actor",
"name", "name",
"description",
"creation_date", "creation_date",
"uploads_count", "uploads_count",
"privacy_level", "privacy_level",
@ -97,6 +97,30 @@ class LibraryFollowSerializer(serializers.ModelSerializer):
return federation_serializers.APIActorSerializer(o.actor).data return federation_serializers.APIActorSerializer(o.actor).data
class FollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField(
"fid", federation_serializers.APIActorSerializer(), required=True
)
actor = serializers.SerializerMethodField()
class Meta:
model = models.Follow
fields = ["creation_date", "actor", "uuid", "target", "approved"]
read_only_fields = ["uuid", "actor", "approved", "creation_date"]
def validate_target(self, v):
request_actor = self.context["actor"]
if v == request_actor:
raise serializers.ValidationError("You cannot follow yourself")
if v.received_follows.filter(actor=request_actor).exists():
raise serializers.ValidationError("You are already following this user")
return v
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, o):
return federation_serializers.APIActorSerializer(o.actor).data
def serialize_generic_relation(activity, obj): def serialize_generic_relation(activity, obj):
data = {"type": obj._meta.label} data = {"type": obj._meta.label}
if data["type"] == "federation.Actor": if data["type"] == "federation.Actor":
@ -106,9 +130,11 @@ def serialize_generic_relation(activity, obj):
if data["type"] == "music.Library": if data["type"] == "music.Library":
data["name"] = obj.name data["name"] = obj.name
if data["type"] == "federation.LibraryFollow": if (
data["type"] == "federation.LibraryFollow"
or data["type"] == "federation.Follow"
):
data["approved"] = obj.approved data["approved"] = obj.approved
return data return data
@ -178,6 +204,17 @@ FETCH_OBJECT_CONFIG = {
FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG) FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG)
def convert_url_to_webginfer(url):
parsed_url = urlparse(url)
domain = parsed_url.netloc # e.g., "node1.funkwhale.test"
path_parts = parsed_url.path.strip("/").split("/")
# Ensure the path is in the expected format
if len(path_parts) > 0 and path_parts[0].startswith("@"):
username = path_parts[0][1:] # Remove the '@'
return f"{username}@{domain}"
return None
class FetchSerializer(serializers.ModelSerializer): class FetchSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True) actor = federation_serializers.APIActorSerializer(read_only=True)
object = serializers.CharField(write_only=True) object = serializers.CharField(write_only=True)
@ -207,6 +244,10 @@ class FetchSerializer(serializers.ModelSerializer):
] ]
def validate_object(self, value): def validate_object(self, value):
if value.startswith("https://"):
converted = convert_url_to_webginfer(value)
if converted:
value = converted
# if value is a webginfer lookup, we craft a special url # if value is a webginfer lookup, we craft a special url
if value.startswith("@"): if value.startswith("@"):
value = value.lstrip("@") value = value.lstrip("@")

View File

@ -5,6 +5,7 @@ from . import api_views
router = routers.OptionalSlashRouter() router = routers.OptionalSlashRouter()
router.register(r"fetches", api_views.FetchViewSet, "fetches") router.register(r"fetches", api_views.FetchViewSet, "fetches")
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows") router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows")
router.register(r"follows/user", api_views.UserFollowViewSet, "user-follows")
router.register(r"inbox", api_views.InboxItemViewSet, "inbox") router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
router.register(r"libraries", api_views.LibraryViewSet, "libraries") router.register(r"libraries", api_views.LibraryViewSet, "libraries")
router.register(r"domains", api_views.DomainViewSet, "domains") router.register(r"domains", api_views.DomainViewSet, "domains")

View File

@ -311,3 +311,106 @@ class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o) filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
) )
) )
@extend_schema_view(
list=extend_schema(operation_id="get_federation_received_follows"),
create=extend_schema(operation_id="create_federation_user_follow"),
)
class UserFollowViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
models.Follow.objects.all()
.order_by("-creation_date")
.select_related("actor", "target")
.filter(actor__type="Person")
)
serializer_class = api_serializers.FollowSerializer
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "follows"
ordering_fields = ("creation_date",)
@extend_schema(operation_id="get_federation_user_follow")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(operation_id="delete_federation_user_follow")
def destroy(self, request, uuid=None):
return super().destroy(request, uuid)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(
Q(target=self.request.user.actor) | Q(actor=self.request.user.actor)
).exclude(approved=False)
def perform_create(self, serializer):
follow = serializer.save(actor=self.request.user.actor)
routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow})
@transaction.atomic
def perform_destroy(self, instance):
routes.outbox.dispatch(
{"type": "Undo", "object": {"type": "Follow"}}, context={"follow": instance}
)
instance.delete()
def get_serializer_context(self):
context = super().get_serializer_context()
context["actor"] = self.request.user.actor
return context
@extend_schema(
operation_id="accept_federation_user_follow",
responses={404: None, 204: None},
)
@decorators.action(methods=["post"], detail=True)
def accept(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.Follow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=True)
return response.Response(status=204)
@extend_schema(operation_id="reject_federation_user_follow")
@decorators.action(methods=["post"], detail=True)
def reject(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.Follow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=False)
return response.Response(status=204)
@extend_schema(operation_id="get_all_federation_library_follows")
@decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
Return all the subscriptions of the current user, with only limited data
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
follows = list(
self.get_queryset().values_list("uuid", "target__fid", "approved")
)
payload = {
"results": [
{"uuid": str(u[0]), "actor": str(u[1]), "approved": u[2]}
for u in follows
],
"count": len(follows),
}
return response.Response(payload, status=200)

View File

@ -81,6 +81,7 @@ class SignatureAuthentication(authentication.BaseAuthentication):
fetch_delay = 24 * 3600 fetch_delay = 24 * 3600
now = timezone.now() now = timezone.now()
last_fetch = actor.domain.nodeinfo_fetch_date last_fetch = actor.domain.nodeinfo_fetch_date
if not actor.domain.is_local:
if not last_fetch or ( if not last_fetch or (
last_fetch < (now - datetime.timedelta(seconds=fetch_delay)) last_fetch < (now - datetime.timedelta(seconds=fetch_delay))
): ):

View File

@ -293,7 +293,10 @@ CONTEXTS = [
"Album": "fw:Album", "Album": "fw:Album",
"Track": "fw:Track", "Track": "fw:Track",
"Artist": "fw:Artist", "Artist": "fw:Artist",
"ArtistCredit": "fw:ArtistCredit",
"Library": "fw:Library", "Library": "fw:Library",
"Playlist": "fw:Playlist",
"PlaylistTrack": "fw:PlaylistTrack",
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"}, "bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"}, "size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
"position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"}, "position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"},
@ -302,13 +305,23 @@ CONTEXTS = [
"track": {"@id": "fw:track", "@type": "@id"}, "track": {"@id": "fw:track", "@type": "@id"},
"cover": {"@id": "fw:cover", "@type": "as:Link"}, "cover": {"@id": "fw:cover", "@type": "as:Link"},
"album": {"@id": "fw:album", "@type": "@id"}, "album": {"@id": "fw:album", "@type": "@id"},
"artist": {"@id": "fw:artist", "@type": "@id"},
"artists": {"@id": "fw:artists", "@type": "@id", "@container": "@list"}, "artists": {"@id": "fw:artists", "@type": "@id", "@container": "@list"},
"artist_credit": {
"@id": "fw:artist_credit",
"@type": "@id",
"@container": "@list",
},
"joinphrase": {"@id": "fw:joinphrase", "@type": "xsd:string"},
"credit": {"@id": "fw:credit", "@type": "xsd:string"},
"index": {"@id": "fw:index", "@type": "xsd:nonNegativeInteger"},
"released": {"@id": "fw:released", "@type": "xsd:date"}, "released": {"@id": "fw:released", "@type": "xsd:date"},
"musicbrainzId": "fw:musicbrainzId", "musicbrainzId": "fw:musicbrainzId",
"license": {"@id": "fw:license", "@type": "@id"}, "license": {"@id": "fw:license", "@type": "@id"},
"copyright": "fw:copyright", "copyright": "fw:copyright",
"category": "schema:category", "category": "schema:category",
"language": "schema:inLanguage", "language": "schema:inLanguage",
"playlist": {"@id": "fw:playlist", "@type": "@id"},
} }
}, },
}, },

View File

@ -128,11 +128,6 @@ class ActorFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta: class Meta:
model = models.Actor model = models.Actor
class Params:
with_real_keys = factory.Trait(
keys=factory.LazyFunction(keys.get_key_pair),
)
@factory.post_generation @factory.post_generation
def local(self, create, extracted, **kwargs): def local(self, create, extracted, **kwargs):
if not extracted and not kwargs: if not extracted and not kwargs:
@ -153,6 +148,26 @@ class ActorFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
extracted.actor = self extracted.actor = self
extracted.save(update_fields=["user"]) extracted.save(update_fields=["user"])
else: else:
user = UserFactory(actor=self, **kwargs)
user.actor = self
user.save()
@factory.post_generation
def user(self, create, extracted, **kwargs):
"""
Handle the creation or assignment of the related user instance.
If `actor__user` is passed, it will be linked; otherwise, no user is created.
"""
if not create:
return
if extracted: # If a User instance is provided
extracted.actor = self
extracted.save(update_fields=["actor"])
elif kwargs:
from funkwhale_api.users.factories import UserFactory
# Create a User linked to this Actor
self.user = UserFactory(actor=self, **kwargs) self.user = UserFactory(actor=self, **kwargs)
@ -170,22 +185,25 @@ class FollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
@registry.register @registry.register
class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
uuid = factory.Faker("uuid4")
actor = factory.SubFactory(ActorFactory) actor = factory.SubFactory(ActorFactory)
privacy_level = "me" privacy_level = "me"
name = factory.Faker("sentence") name = privacy_level
description = factory.Faker("sentence")
uploads_count = 0 uploads_count = 0
fid = factory.Faker("federation_url") fid = factory.Faker("federation_url")
followers_url = factory.LazyAttribute(
lambda o: o.fid + "/followers" if o.fid else None
)
class Meta: class Meta:
model = "music.Library" model = "music.Library"
class Params: class Params:
local = factory.Trait( local = factory.Trait(
fid=None, actor=factory.SubFactory(ActorFactory, local=True) fid=factory.Faker(
"federation_url",
local=True,
prefix="federation/music/libraries",
obj_uuid=factory.SelfAttribute("..uuid"),
),
actor=factory.SubFactory(ActorFactory, local=True),
) )

View File

@ -191,7 +191,6 @@ def prepare_for_serializer(payload, config, fallbacks={}):
value = noop value = noop
if not aliases: if not aliases:
continue continue
for a in aliases: for a in aliases:
try: try:
value = get_value( value = get_value(
@ -279,7 +278,6 @@ class JsonLdSerializer(serializers.Serializer):
for field in dereferenced_fields: for field in dereferenced_fields:
for i in get_ids(data[field]): for i in get_ids(data[field]):
dereferenced_ids.add(i) dereferenced_ids.add(i)
if dereferenced_ids: if dereferenced_ids:
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()

View File

@ -9,7 +9,7 @@ MODELS = [
(music_models.Album, ["fid"]), (music_models.Album, ["fid"]),
(music_models.Track, ["fid"]), (music_models.Track, ["fid"]),
(music_models.Upload, ["fid"]), (music_models.Upload, ["fid"]),
(music_models.Library, ["fid", "followers_url"]), (music_models.Library, ["fid"]),
( (
federation_models.Actor, federation_models.Actor,
[ [

View File

@ -218,7 +218,6 @@ class Actor(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="iconed_actor", related_name="iconed_actor",
) )
objects = ActorQuerySet.as_manager() objects = ActorQuerySet.as_manager()
class Meta: class Meta:
@ -251,9 +250,15 @@ class Actor(models.Model):
follows = self.received_follows.filter(approved=True) follows = self.received_follows.filter(approved=True)
return self.followers.filter(pk__in=follows.values_list("actor", flat=True)) return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
def get_approved_followings(self):
follows = self.emitted_follows.filter(approved=True)
return Actor.objects.filter(pk__in=follows.values_list("target", flat=True))
def should_autoapprove_follow(self, actor): def should_autoapprove_follow(self, actor):
if self.get_channel(): if self.get_channel():
return True return True
if self.user.privacy_level == "public":
return True
return False return False
def get_user(self): def get_user(self):

View File

@ -3,7 +3,10 @@ import uuid
from django.db.models import Q from django.db.models import Q
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.music import models as music_models
from funkwhale_api.playlists import models as playlist_models
from . import activity, actors, models, serializers from . import activity, actors, models, serializers
@ -163,7 +166,7 @@ def outbox_follow(context):
def outbox_create_audio(context): def outbox_create_audio(context):
upload = context["upload"] upload = context["upload"]
channel = upload.library.get_channel() channel = upload.library.get_channel()
followers_target = channel.actor if channel else upload.library followers_target = channel.actor if channel else upload.library.actor
actor = channel.actor if channel else upload.library.actor actor = channel.actor if channel else upload.library.actor
if channel: if channel:
serializer = serializers.ChannelCreateUploadSerializer(upload) serializer = serializers.ChannelCreateUploadSerializer(upload)
@ -293,7 +296,7 @@ def inbox_delete_audio(payload, context):
upload_fids = [payload["object"]["id"]] upload_fids = [payload["object"]["id"]]
query = Q(fid__in=upload_fids) & ( query = Q(fid__in=upload_fids) & (
Q(library__actor=actor) | Q(track__artist__channel__actor=actor) Q(library__actor=actor) | Q(track__artist_credit__artist__channel__actor=actor)
) )
candidates = music_models.Upload.objects.filter(query) candidates = music_models.Upload.objects.filter(query)
@ -307,8 +310,8 @@ def outbox_delete_audio(context):
uploads = context["uploads"] uploads = context["uploads"]
library = uploads[0].library library = uploads[0].library
channel = library.get_channel() channel = library.get_channel()
followers_target = channel.actor if channel else library
actor = channel.actor if channel else library.actor actor = channel.actor if channel else library.actor
followers_target = channel.actor if channel else actor
serializer = serializers.ActivitySerializer( serializer = serializers.ActivitySerializer(
{ {
"type": "Delete", "type": "Delete",
@ -577,7 +580,9 @@ def inbox_delete_album(payload, context):
logger.debug("Discarding deletion of empty library") logger.debug("Discarding deletion of empty library")
return return
query = Q(fid=album_id) & (Q(attributed_to=actor) | Q(artist__channel__actor=actor)) query = Q(fid=album_id) & (
Q(attributed_to=actor) | Q(artist_credit__artist__channel__actor=actor)
)
try: try:
album = music_models.Album.objects.get(query) album = music_models.Album.objects.get(query)
except music_models.Album.DoesNotExist: except music_models.Album.DoesNotExist:
@ -590,9 +595,10 @@ def inbox_delete_album(payload, context):
@outbox.register({"type": "Delete", "object.type": "Album"}) @outbox.register({"type": "Delete", "object.type": "Album"})
def outbox_delete_album(context): def outbox_delete_album(context):
album = context["album"] album = context["album"]
album_artist = album.artist_credit.all()[0].artist
actor = ( actor = (
album.artist.channel.actor album_artist.channel.actor
if album.artist.get_channel() if album_artist.get_channel()
else album.attributed_to else album.attributed_to
) )
actor = actor or actors.get_service_actor() actor = actor or actors.get_service_actor()
@ -608,3 +614,231 @@ def outbox_delete_album(context):
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}], to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
), ),
} }
@outbox.register({"type": "Like", "object.type": "Track"})
def outbox_create_track_favorite(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Like",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
}
)
yield {
"type": "Like",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Dislike", "object.type": "Track"})
def outbox_delete_favorite(context):
favorite = context["favorite"]
actor = favorite.actor
serializer = serializers.ActivitySerializer(
{"type": "Dislike", "object": {"type": "Track", "id": favorite.track.fid}}
)
yield {
"type": "Dislike",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Like", "object.type": "Track"})
def inbox_create_favorite(payload, context):
serializer = serializers.TrackFavoriteSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Dislike", "object.type": "Track"})
def inbox_delete_favorite(payload, context):
actor = context["actor"]
track_id = payload["object"].get("id")
query = Q(track__fid=track_id) & Q(actor=actor)
try:
favorite = favorites_models.TrackFavorite.objects.get(query)
except favorites_models.TrackFavorite.DoesNotExist:
logger.debug(
"Discarding deletion of unkwnown favorite with track : %s", track_id
)
return
favorite.delete()
# to do : test listening routes and broadcast
@outbox.register({"type": "Listen", "object.type": "Track"})
def outbox_create_listening(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Listen",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
}
)
yield {
"type": "Listen",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Delete", "object.type": "Listen"})
def outbox_delete_listening(context):
listening = context["listening"]
actor = listening.actor
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Listen", "id": listening.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Listen", "object.type": "Track"})
def inbox_create_listening(payload, context):
serializer = serializers.ListeningSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Listen"})
def inbox_delete_listening(payload, context):
actor = context["actor"]
listening_id = payload["object"].get("id")
query = Q(fid=listening_id) & Q(actor=actor)
try:
favorite = history_models.Listening.objects.get(query)
except history_models.Listening.DoesNotExist:
logger.debug("Discarding deletion of unkwnown listening %s", listening_id)
return
favorite.delete()
@outbox.register({"type": "Create", "object.type": "Playlist"})
def outbox_create_playlist(context):
playlist = context["playlist"]
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"actor": playlist.actor,
"id": playlist.fid,
"object": serializers.PlaylistSerializer(playlist).data,
}
)
yield {
"type": "Create",
"actor": playlist.actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": playlist.actor}],
),
}
@outbox.register({"type": "Delete", "object.type": "Playlist"})
def outbox_delete_playlist(context):
playlist = context["playlist"]
actor = playlist.actor
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Playlist", "id": playlist.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Create", "object.type": "Playlist"})
def inbox_create_playlist(payload, context):
serializer = serializers.PlaylistSerializer(data=payload["object"])
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Playlist"})
def inbox_delete_playlist(payload, context):
actor = context["actor"]
playlist_id = payload["object"].get("id")
query = Q(fid=playlist_id) & Q(actor=actor)
try:
playlist = playlist_models.Playlist.objects.get(query)
except playlist_models.Playlist.DoesNotExist:
logger.debug("Discarding deletion of unkwnown listening %s", playlist_id)
return
playlist.playlist_tracks.all().delete()
playlist.delete()
@inbox.register({"type": "Update", "object.type": "Playlist"})
def inbox_update_playlist(payload, context):
actor = context["actor"]
playlist_id = payload["object"].get("id")
if not actor.playlists.filter(fid=playlist_id).exists():
logger.debug("Discarding update of unkwnown playlist_id %s", playlist_id)
return
serializer = serializers.PlaylistSerializer(data=payload["object"])
if serializer.is_valid(raise_exception=True):
playlist = serializer.save()
# we trigger a scan since we use this activity to avoid sending many PlaylistTracks activities
playlist.schedule_scan(actors.get_service_actor())
return
else:
logger.debug(
"Discarding update of playlist_id %s because of payload errors: %s",
playlist_id,
serializer.errors,
)
@outbox.register({"type": "Update", "object.type": "Playlist"})
def outbox_update_playlist(context):
playlist = context["playlist"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.PlaylistSerializer(playlist).data}
)
yield {
"type": "Update",
"actor": playlist.actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": playlist.actor}],
),
}

View File

@ -1,27 +1,31 @@
import logging import logging
import os import os
import re
import urllib.parse import urllib.parse
import uuid import uuid
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import models as common_models from funkwhale_api.common import models as common_models
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import activity, actors, contexts, jsonld, models, utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals from funkwhale_api.moderation import signals as moderation_signals
from funkwhale_api.music import licenses from funkwhale_api.music import licenses
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks from funkwhale_api.music import tasks as music_tasks
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tags_models from funkwhale_api.tags import models as tags_models
from . import activity, actors, contexts, jsonld, models, utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -340,9 +344,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
ret["url"] = [ ret["url"] = [
{ {
"type": "Link", "type": "Link",
"href": instance.channel.get_absolute_url() "href": (
instance.channel.get_absolute_url()
if instance.channel.artist.is_local if instance.channel.artist.is_local
else instance.get_absolute_url(), else instance.get_absolute_url()
),
"mediaType": "text/html", "mediaType": "text/html",
}, },
{ {
@ -436,9 +442,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
common_utils.attach_file( common_utils.attach_file(
actor, actor,
"attachment_icon", "attachment_icon",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")} {"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value if new_value
else None, else None
),
) )
rss_url = get_by_media_type( rss_url = get_by_media_type(
@ -491,9 +499,11 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
common_utils.attach_file( common_utils.attach_file(
artist, artist,
"attachment_cover", "attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")} {"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value if new_value
else None, else None
),
) )
tags = [t["name"] for t in validated_data.get("tags", []) or []] tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(artist, *tags) tags_models.set_tags(artist, *tags)
@ -644,7 +654,6 @@ class FollowSerializer(serializers.Serializer):
def save(self, **kwargs): def save(self, **kwargs):
target = self.validated_data["object"] target = self.validated_data["object"]
if target._meta.label == "music.Library": if target._meta.label == "music.Library":
follow_class = models.LibraryFollow follow_class = models.LibraryFollow
else: else:
@ -812,7 +821,9 @@ class UndoFollowSerializer(serializers.Serializer):
actor=validated_data["actor"], target=target actor=validated_data["actor"], target=target
).get() ).get()
except follow_class.DoesNotExist: except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to remove") raise serializers.ValidationError(
f"No follow to remove follow_class = {follow_class}"
)
return validated_data return validated_data
def to_representation(self, instance): def to_representation(self, instance):
@ -879,7 +890,6 @@ class ActivitySerializer(serializers.Serializer):
object_serializer = OBJECT_SERIALIZERS[type] object_serializer = OBJECT_SERIALIZERS[type]
except KeyError: except KeyError:
raise serializers.ValidationError(f"Unsupported type {type}") raise serializers.ValidationError(f"Unsupported type {type}")
serializer = object_serializer(data=value) serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return serializer.data return serializer.data
@ -964,7 +974,7 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
first = common_utils.set_query_parameter(conf["id"], page=1) first = common_utils.set_query_parameter(conf["id"], page=1)
current = first current = first
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages) last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
d = { data = {
"id": conf["id"], "id": conf["id"],
"attributedTo": conf["actor"].fid, "attributedTo": conf["actor"].fid,
"totalItems": paginator.count, "totalItems": paginator.count,
@ -973,10 +983,10 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
"first": first, "first": first,
"last": last, "last": last,
} }
d.update(get_additional_fields(conf)) data.update(get_additional_fields(conf))
if self.context.get("include_ap_context", True): if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context() data["@context"] = jsonld.get_default_context()
return d return data
class LibrarySerializer(PaginatedCollectionSerializer): class LibrarySerializer(PaginatedCollectionSerializer):
@ -986,8 +996,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
actor = serializers.URLField(max_length=500, required=False) actor = serializers.URLField(max_length=500, required=False)
attributedTo = serializers.URLField(max_length=500, required=False) attributedTo = serializers.URLField(max_length=500, required=False)
name = serializers.CharField() name = serializers.CharField()
summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
followers = serializers.URLField(max_length=500)
audience = serializers.ChoiceField( audience = serializers.ChoiceField(
choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"], choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
required=False, required=False,
@ -1004,9 +1012,7 @@ class LibrarySerializer(PaginatedCollectionSerializer):
PAGINATED_COLLECTION_JSONLD_MAPPING, PAGINATED_COLLECTION_JSONLD_MAPPING,
{ {
"name": jsonld.first_val(contexts.AS.name), "name": jsonld.first_val(contexts.AS.name),
"summary": jsonld.first_val(contexts.AS.summary),
"audience": jsonld.first_id(contexts.AS.audience), "audience": jsonld.first_id(contexts.AS.audience),
"followers": jsonld.first_id(contexts.AS.followers),
"actor": jsonld.first_id(contexts.AS.actor), "actor": jsonld.first_id(contexts.AS.actor),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo), "attributedTo": jsonld.first_id(contexts.AS.attributedTo),
}, },
@ -1028,7 +1034,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
conf = { conf = {
"id": library.fid, "id": library.fid,
"name": library.name, "name": library.name,
"summary": library.description,
"page_size": 100, "page_size": 100,
"attributedTo": library.actor, "attributedTo": library.actor,
"actor": library.actor, "actor": library.actor,
@ -1039,7 +1044,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
r["audience"] = ( r["audience"] = (
contexts.AS.Public if library.privacy_level == "everyone" else "" contexts.AS.Public if library.privacy_level == "everyone" else ""
) )
r["followers"] = library.followers_url
return r return r
def create(self, validated_data): def create(self, validated_data):
@ -1059,8 +1063,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
defaults={ defaults={
"uploads_count": validated_data["totalItems"], "uploads_count": validated_data["totalItems"],
"name": validated_data["name"], "name": validated_data["name"],
"description": validated_data.get("summary"),
"followers_url": validated_data["followers"],
"privacy_level": privacy[validated_data["audience"]], "privacy_level": privacy[validated_data["audience"]],
}, },
) )
@ -1221,12 +1223,22 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
self.updateable_fields, validated_data, instance self.updateable_fields, validated_data, instance
) )
updated_fields = self.validate_updated_data(instance, updated_fields) updated_fields = self.validate_updated_data(instance, updated_fields)
set_ac = False
if "artist_credit" in updated_fields:
artist_credit = updated_fields.pop("artist_credit")
set_ac = True
if creating: if creating:
instance, created = self.Meta.model.objects.get_or_create( instance, created = self.Meta.model.objects.get_or_create(
fid=validated_data["id"], defaults=updated_fields fid=validated_data["id"], defaults=updated_fields
) )
if set_ac:
instance.artist_credit.set(artist_credit)
else: else:
music_tasks.update_library_entity(instance, updated_fields) obj = music_tasks.update_library_entity(instance, updated_fields)
if set_ac:
obj.artist_credit.set(artist_credit)
tags = [t["name"] for t in validated_data.get("tags", []) or []] tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(instance, *tags) tags_models.set_tags(instance, *tags)
@ -1288,7 +1300,6 @@ class ArtistSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"released": jsonld.first_val(contexts.FW.released), "released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"image": jsonld.first_obj(contexts.AS.image), "image": jsonld.first_obj(contexts.AS.image),
}, },
) )
@ -1300,9 +1311,9 @@ class ArtistSerializer(MusicEntitySerializer):
"name": instance.name, "name": instance.name,
"published": instance.creation_date.isoformat(), "published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None, "musicbrainzId": str(instance.mbid) if instance.mbid else None,
"attributedTo": instance.attributed_to.fid "attributedTo": (
if instance.attributed_to instance.attributed_to.fid if instance.attributed_to else None
else None, ),
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
include_content(d, instance.description) include_content(d, instance.description)
@ -1314,12 +1325,53 @@ class ArtistSerializer(MusicEntitySerializer):
create = MusicEntitySerializer.update_or_create create = MusicEntitySerializer.update_or_create
class ArtistCreditSerializer(jsonld.JsonLdSerializer):
artist = ArtistSerializer()
joinphrase = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
credit = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
published = serializers.DateTimeField()
id = serializers.URLField(max_length=500)
updateable_fields = [
("credit", "credit"),
("artist", "artist"),
("joinphrase", "joinphrase"),
]
class Meta:
model = music_models.ArtistCredit
jsonld_mapping = {
"artist": jsonld.first_obj(contexts.FW.artist),
"credit": jsonld.first_val(contexts.FW.credit),
"index": jsonld.first_val(contexts.FW.index),
"joinphrase": jsonld.first_val(contexts.FW.joinphrase),
"published": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, instance):
data = {
"type": "ArtistCredit",
"id": instance.fid,
"artist": ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data,
"joinphrase": instance.joinphrase,
"credit": instance.credit,
"index": instance.index,
"published": instance.creation_date.isoformat(),
}
if self.context.get("include_ap_context", self.parent is None):
data["@context"] = jsonld.get_default_context()
return data
class AlbumSerializer(MusicEntitySerializer): class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False) released = serializers.DateField(allow_null=True, required=False)
artists = serializers.ListField( artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
child=MultipleSerializer(allowed=[BasicActorSerializer, ArtistSerializer]),
min_length=1,
)
image = ImageSerializer( image = ImageSerializer(
allowed_mimetypes=["image/*"], allowed_mimetypes=["image/*"],
allow_null=True, allow_null=True,
@ -1332,7 +1384,7 @@ class AlbumSerializer(MusicEntitySerializer):
("musicbrainzId", "mbid"), ("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"), ("attributedTo", "attributed_to"),
("released", "release_date"), ("released", "release_date"),
("_artist", "artist"), ("artist_credit", "artist_credit"),
] ]
class Meta: class Meta:
@ -1341,62 +1393,60 @@ class AlbumSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"released": jsonld.first_val(contexts.FW.released), "released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"), "artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"image": jsonld.first_obj(contexts.AS.image), "image": jsonld.first_obj(contexts.AS.image),
}, },
) )
def to_representation(self, instance): def to_representation(self, instance):
d = { data = {
"type": "Album", "type": "Album",
"id": instance.fid, "id": instance.fid,
"name": instance.title, "name": instance.title,
"published": instance.creation_date.isoformat(), "published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None, "musicbrainzId": str(instance.mbid) if instance.mbid else None,
"released": instance.release_date.isoformat() "released": (
if instance.release_date instance.release_date.isoformat() if instance.release_date else None
else None, ),
"attributedTo": instance.attributed_to.fid "attributedTo": (
if instance.attributed_to instance.attributed_to.fid if instance.attributed_to else None
else None, ),
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
if instance.artist.get_channel():
d["artists"] = [ data["artist_credit"] = ArtistCreditSerializer(
{ instance.artist_credit.all(),
"type": instance.artist.channel.actor.type, context={"include_ap_context": False},
"id": instance.artist.channel.actor.fid, many=True,
}
]
else:
d["artists"] = [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data ).data
] include_content(data, instance.description)
include_content(d, instance.description)
if instance.attachment_cover: if instance.attachment_cover:
include_image(d, instance.attachment_cover) include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context() data["@context"] = jsonld.get_default_context()
return d return data
def validate(self, data): def validate(self, data):
validated_data = super().validate(data) validated_data = super().validate(data)
if not self.parent: if not self.parent:
artist_data = validated_data["artists"][0] artist_credit_data = validated_data["artist_credit"]
if artist_data.get("type", "Artist") == "Artist": if artist_credit_data[0]["artist"].get("type", "Artist") == "Artist":
validated_data["_artist"] = utils.retrieve_ap_object( acs = []
artist_data["id"], for ac in validated_data["artist_credit"]:
acs.append(
utils.retrieve_ap_object(
ac["id"],
actor=self.context.get("fetch_actor"), actor=self.context.get("fetch_actor"),
queryset=music_models.Artist, queryset=music_models.ArtistCredit,
serializer_class=ArtistSerializer, serializer_class=ArtistCreditSerializer,
) )
)
validated_data["artist_credit"] = acs
else: else:
# we have an actor as an artist, so it's a channel # we have an actor as an artist, so it's a channel
actor = actors.get_actor(artist_data["id"]) actor = actors.get_actor(artist_credit_data[0]["artist"]["id"])
validated_data["_artist"] = actor.channel.artist validated_data["artist_credit"] = [{"artist": actor.channel.artist}]
return validated_data return validated_data
@ -1406,7 +1456,7 @@ class AlbumSerializer(MusicEntitySerializer):
class TrackSerializer(MusicEntitySerializer): class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False) position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False) disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1) artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
album = AlbumSerializer() album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False) license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False) copyright = serializers.CharField(allow_null=True, required=False)
@ -1434,7 +1484,7 @@ class TrackSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"album": jsonld.first_obj(contexts.FW.album), "album": jsonld.first_obj(contexts.FW.album),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"), "artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"copyright": jsonld.first_val(contexts.FW.copyright), "copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc), "disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license), "license": jsonld.first_id(contexts.FW.license),
@ -1444,7 +1494,7 @@ class TrackSerializer(MusicEntitySerializer):
) )
def to_representation(self, instance): def to_representation(self, instance):
d = { data = {
"type": "Track", "type": "Track",
"id": instance.fid, "id": instance.fid,
"name": instance.title, "name": instance.title,
@ -1452,29 +1502,32 @@ class TrackSerializer(MusicEntitySerializer):
"musicbrainzId": str(instance.mbid) if instance.mbid else None, "musicbrainzId": str(instance.mbid) if instance.mbid else None,
"position": instance.position, "position": instance.position,
"disc": instance.disc_number, "disc": instance.disc_number,
"license": instance.local_license["identifiers"][0] "license": (
instance.local_license["identifiers"][0]
if instance.local_license if instance.local_license
else None, else None
),
"copyright": instance.copyright if instance.copyright else None, "copyright": instance.copyright if instance.copyright else None,
"artists": [ "artist_credit": ArtistCreditSerializer(
ArtistSerializer( instance.artist_credit.all(),
instance.artist, context={"include_ap_context": False} context={"include_ap_context": False},
).data many=True,
], ).data,
"album": AlbumSerializer( "album": AlbumSerializer(
instance.album, context={"include_ap_context": False} instance.album, context={"include_ap_context": False}
).data, ).data,
"attributedTo": instance.attributed_to.fid "attributedTo": (
if instance.attributed_to instance.attributed_to.fid if instance.attributed_to else None
else None, ),
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
include_content(d, instance.description) include_content(data, instance.description)
include_image(d, instance.attachment_cover) include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context() data["@context"] = jsonld.get_default_context()
return d return data
@transaction.atomic
def create(self, validated_data): def create(self, validated_data):
from funkwhale_api.music import tasks as music_tasks from funkwhale_api.music import tasks as music_tasks
@ -1490,18 +1543,21 @@ class TrackSerializer(MusicEntitySerializer):
validated_data, "album.attributedTo", permissive=True validated_data, "album.attributedTo", permissive=True
) )
) )
artists = ( artist_credit = (
common_utils.recursive_getattr(validated_data, "artists", permissive=True)
or []
)
album_artists = (
common_utils.recursive_getattr( common_utils.recursive_getattr(
validated_data, "album.artists", permissive=True validated_data, "artist_credit", permissive=True
) )
or [] or []
) )
for artist in artists + album_artists: album_artists_credit = (
actors_to_fetch.add(artist.get("attributedTo")) common_utils.recursive_getattr(
validated_data, "album.artist_credit", permissive=True
)
or []
)
for ac in artist_credit + album_artists_credit:
actors_to_fetch.add(ac["artist"].get("attributedTo"))
for url in actors_to_fetch: for url in actors_to_fetch:
if not url: if not url:
@ -1514,8 +1570,9 @@ class TrackSerializer(MusicEntitySerializer):
from_activity = self.context.get("activity") from_activity = self.context.get("activity")
if from_activity: if from_activity:
metadata["from_activity_id"] = from_activity.pk metadata["from_activity_id"] = from_activity.pk
track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True) track = music_tasks.get_track_from_import_metadata(
metadata, update_cover=True, query_mb=False
)
return track return track
def update(self, obj, validated_data): def update(self, obj, validated_data):
@ -1524,6 +1581,50 @@ class TrackSerializer(MusicEntitySerializer):
return super().update(obj, validated_data) return super().update(obj, validated_data)
def duration_int_to_xml(duration):
if not duration:
return None
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
ret = "P"
days, seconds = divmod(int(duration), multipliers["D"])
ret += f"{days:d}DT" if days > 0 else "T"
hours, seconds = divmod(seconds, multipliers["H"])
ret += f"{hours:d}H" if hours > 0 else ""
minutes, seconds = divmod(seconds, multipliers["M"])
ret += f"{minutes:d}M" if minutes > 0 else ""
ret += f"{seconds:d}S" if seconds > 0 or ret == "PT" else ""
return ret
class DayTimeDurationSerializer(serializers.DurationField):
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
def to_internal_value(self, value):
if isinstance(value, float):
return value
parsed = re.match(
r"P([0-9]+D)?T([0-9]+H)?([0-9]+M)?([0-9]+(?:\.[0-9]+)?S)?", str(value)
)
if parsed is not None:
return int(
sum(
[
self.multipliers[s[-1]] * float("0" + s[:-1])
for s in parsed.groups()
if s is not None
]
)
)
self.fail(
"invalid", format="https://www.w3.org/TR/xmlschema11-2/#dayTimeDuration"
)
def to_representation(self, value):
duration_int_to_xml(value)
class UploadSerializer(jsonld.JsonLdSerializer): class UploadSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Audio]) type = serializers.ChoiceField(choices=[contexts.AS.Audio])
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
@ -1533,7 +1634,7 @@ class UploadSerializer(jsonld.JsonLdSerializer):
updated = serializers.DateTimeField(required=False, allow_null=True) updated = serializers.DateTimeField(required=False, allow_null=True)
bitrate = serializers.IntegerField(min_value=0) bitrate = serializers.IntegerField(min_value=0)
size = serializers.IntegerField(min_value=0) size = serializers.IntegerField(min_value=0)
duration = serializers.IntegerField(min_value=0) duration = DayTimeDurationSerializer(min_value=0)
track = TrackSerializer(required=True) track = TrackSerializer(required=True)
@ -1645,7 +1746,7 @@ class UploadSerializer(jsonld.JsonLdSerializer):
"published": instance.creation_date.isoformat(), "published": instance.creation_date.isoformat(),
"bitrate": instance.bitrate, "bitrate": instance.bitrate,
"size": instance.size, "size": instance.size,
"duration": instance.duration, "duration": duration_int_to_xml(instance.duration),
"url": [ "url": [
{ {
"href": utils.full_url(instance.listen_url_no_download), "href": utils.full_url(instance.listen_url_no_download),
@ -1659,9 +1760,11 @@ class UploadSerializer(jsonld.JsonLdSerializer):
}, },
], ],
"track": TrackSerializer(track, context={"include_ap_context": False}).data, "track": TrackSerializer(track, context={"include_ap_context": False}).data,
"to": contexts.AS.Public "to": (
contexts.AS.Public
if instance.library.privacy_level == "everyone" if instance.library.privacy_level == "everyone"
else "", else ""
),
"attributedTo": instance.library.actor.fid, "attributedTo": instance.library.actor.fid,
} }
if instance.modification_date: if instance.modification_date:
@ -1780,7 +1883,7 @@ class ChannelOutboxSerializer(PaginatedCollectionSerializer):
"actor": channel.actor, "actor": channel.actor,
"items": channel.library.uploads.for_federation() "items": channel.library.uploads.for_federation()
.order_by("-creation_date") .order_by("-creation_date")
.filter(track__artist=channel.artist), .filter(track__artist_credit__artist=channel.artist),
"type": "OrderedCollection", "type": "OrderedCollection",
} }
r = super().to_representation(conf) r = super().to_representation(conf)
@ -1793,7 +1896,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1) url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1)
name = serializers.CharField() name = serializers.CharField()
published = serializers.DateTimeField(required=False) published = serializers.DateTimeField(required=False)
duration = serializers.IntegerField(min_value=0, required=False) duration = DayTimeDurationSerializer(required=False)
position = serializers.IntegerField(min_value=0, allow_null=True, required=False) position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False) disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
album = serializers.URLField(max_length=500, required=False) album = serializers.URLField(max_length=500, required=False)
@ -1850,7 +1953,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
actor=actors.get_service_actor(), actor=actors.get_service_actor(),
serializer_class=AlbumSerializer, serializer_class=AlbumSerializer,
queryset=music_models.Album.objects.filter( queryset=music_models.Album.objects.filter(
artist__channel=self.context["channel"] artist_credit__artist__channel=self.context["channel"]
), ),
) )
@ -1881,9 +1984,9 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
"name": upload.track.title, "name": upload.track.title,
"attributedTo": upload.library.channel.actor.fid, "attributedTo": upload.library.channel.actor.fid,
"published": upload.creation_date.isoformat(), "published": upload.creation_date.isoformat(),
"to": contexts.AS.Public "to": (
if upload.library.privacy_level == "everyone" contexts.AS.Public if upload.library.privacy_level == "everyone" else ""
else "", ),
"url": [ "url": [
{ {
"type": "Link", "type": "Link",
@ -1902,7 +2005,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
if upload.track.local_license: if upload.track.local_license:
data["license"] = upload.track.local_license["identifiers"][0] data["license"] = upload.track.local_license["identifiers"][0]
include_if_not_none(data, upload.duration, "duration") include_if_not_none(data, duration_int_to_xml(upload.duration), "duration")
include_if_not_none(data, upload.track.position, "position") include_if_not_none(data, upload.track.position, "position")
include_if_not_none(data, upload.track.disc_number, "disc") include_if_not_none(data, upload.track.disc_number, "disc")
include_if_not_none(data, upload.track.copyright, "copyright") include_if_not_none(data, upload.track.copyright, "copyright")
@ -1929,7 +2032,6 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
now = timezone.now() now = timezone.now()
track_defaults = { track_defaults = {
"fid": validated_data["id"], "fid": validated_data["id"],
"artist": channel.artist,
"position": validated_data.get("position", 1), "position": validated_data.get("position", 1),
"disc_number": validated_data.get("disc", 1), "disc_number": validated_data.get("disc", 1),
"title": validated_data["name"], "title": validated_data["name"],
@ -1942,17 +2044,42 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
track_defaults["license"] = licenses.match(validated_data["license"]) track_defaults["license"] = licenses.match(validated_data["license"])
track, created = music_models.Track.objects.update_or_create( track, created = music_models.Track.objects.update_or_create(
artist__channel=channel, fid=validated_data["id"], defaults=track_defaults fid=validated_data["id"],
defaults=track_defaults,
) )
# only one artist_credit per channel
query = (
Q(
artist=channel.artist,
)
& Q(credit__iexact=channel.artist.name)
& Q(joinphrase="")
)
defaults = {
"artist": channel.artist,
"joinphrase": "",
"credit": channel.artist.name,
}
ac_obj = music_tasks.get_best_candidate_or_create(
music_models.ArtistCredit,
query,
defaults=defaults,
sort_fields=["mbid", "fid"],
)
track.artist_credit.set([ac_obj[0].id])
if "image" in validated_data: if "image" in validated_data:
new_value = self.validated_data["image"] new_value = self.validated_data["image"]
common_utils.attach_file( common_utils.attach_file(
track, track,
"attachment_cover", "attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")} {"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value if new_value
else None, else None
),
) )
common_utils.attach_content( common_utils.attach_content(
@ -2076,3 +2203,254 @@ class IndexSerializer(jsonld.JsonLdSerializer):
if self.context.get("include_ap_context", True): if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context() d["@context"] = jsonld.get_default_context()
return d return d
class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Like])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
}
def to_representation(self, favorite):
payload = {
"type": "Like",
"id": favorite.fid,
"actor": favorite.actor.fid,
"object": favorite.track.fid,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return favorites_models.TrackFavorite.objects.create(
fid=validated_data.get("id"),
uuid=uuid.uuid4(),
actor=actor,
track=track,
)
class ListeningSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Listen])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
}
def to_representation(self, listening):
payload = {
"type": "Listen",
"id": listening.fid,
"actor": listening.actor.fid,
"object": listening.track.fid,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return history_models.Listening.objects.create(
fid=validated_data.get("id"),
uuid=validated_data["id"].rstrip("/").split("/")[-1],
actor=actor,
track=track,
)
class PlaylistTrackSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.FW.PlaylistTrack])
id = serializers.URLField(max_length=500)
track = serializers.URLField(max_length=500)
index = serializers.IntegerField()
creation_date = serializers.DateTimeField()
playlist = serializers.URLField(max_length=500, required=False)
class Meta:
model = playlists_models.PlaylistTrack
jsonld_mapping = {
"track": jsonld.first_id(contexts.FW.track),
"playlist": jsonld.first_id(contexts.FW.playlist),
"index": jsonld.first_val(contexts.FW.index),
"creation_date": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, plt):
payload = {
"type": "PlaylistTrack",
"id": plt.fid,
"track": plt.track.fid,
"index": plt.index,
"attributedTo": plt.playlist.actor.fid,
"published": plt.creation_date.isoformat(),
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
if self.context.get("include_playlist", True):
payload["playlist"] = plt.playlist.fid
return payload
def create(self, validated_data):
track = utils.retrieve_ap_object(
validated_data["track"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Track,
serializer_class=TrackSerializer,
)
playlist = utils.retrieve_ap_object(
validated_data["playlist"],
actor=self.context.get("fetch_actor"),
queryset=playlists_models.Playlist,
serializer_class=PlaylistTrackSerializer,
)
defaults = {
"track": track,
"index": validated_data["index"],
"creation_date": validated_data["creation_date"],
"playlist": playlist,
}
plt, created = playlists_models.PlaylistTrack.objects.update_or_create(
defaults,
**{
"uuid": validated_data["id"].rstrip("/").split("/")[-1],
"fid": validated_data["id"],
},
)
return plt
class PlaylistSerializer(jsonld.JsonLdSerializer):
"""
Used for playlist activities
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist, contexts.AS.Create])
id = serializers.URLField(max_length=500)
uuid = serializers.UUIDField(required=False)
name = serializers.CharField(required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
published = serializers.DateTimeField(required=False)
updated = serializers.DateTimeField(required=False)
audience = serializers.ChoiceField(
choices=[None, "https://www.w3.org/ns/activitystreams#Public"],
required=False,
allow_null=True,
allow_blank=True,
)
updateable_fields = [
("name", "title"),
("attributedTo", "attributed_to"),
]
class Meta:
model = playlists_models.Playlist
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"updated": jsonld.first_val(contexts.AS.published),
"audience": jsonld.first_id(contexts.AS.audience),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
},
)
def to_representation(self, playlist):
payload = {
"type": "Playlist",
"id": playlist.fid,
"name": playlist.name,
"attributedTo": playlist.actor.fid,
"published": playlist.creation_date.isoformat(),
"audience": playlist.privacy_level,
}
payload["audience"] = (
contexts.AS.Public if playlist.privacy_level == "everyone" else ""
)
if playlist.modification_date:
payload["updated"] = playlist.modification_date.isoformat()
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = utils.retrieve_ap_object(
validated_data["attributedTo"],
actor=self.context.get("fetch_actor"),
queryset=models.Actor,
serializer_class=ActorSerializer,
)
ap_to_fw_data = {
"actor": actor,
"name": validated_data["name"],
"creation_date": validated_data["published"],
"privacy_level": validated_data["audience"],
}
playlist, created = playlists_models.Playlist.objects.update_or_create(
defaults=ap_to_fw_data,
**{
"fid": validated_data["id"],
"uuid": validated_data.get(
"uuid", validated_data["id"].rstrip("/").split("/")[-1]
),
},
)
return playlist
def validate(self, data):
validated_data = super().validate(data)
if validated_data["audience"] not in [
"https://www.w3.org/ns/activitystreams#Public",
"everyone",
]:
raise serializers.ValidationError("Privacy_level must be everyone")
validated_data["audience"] = "everyone"
return validated_data
class PlaylistCollectionSerializer(PaginatedCollectionSerializer):
"""
Used for the federation view.
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist])
def to_representation(self, playlist):
conf = {
"id": playlist.fid,
"name": playlist.name,
"page_size": 100,
"actor": playlist.actor,
"items": playlist.playlist_tracks.order_by("index").prefetch_related(
"tracks",
),
"type": "Playlist",
}
r = super().to_representation(conf)
return r

View File

@ -30,7 +30,7 @@ def verify_date(raw_date):
ts = parse_http_date(raw_date) ts = parse_http_date(raw_date)
except ValueError as e: except ValueError as e:
raise forms.ValidationError(str(e)) raise forms.ValidationError(str(e))
dt = datetime.datetime.utcfromtimestamp(ts) dt = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
dt = dt.replace(tzinfo=ZoneInfo("UTC")) dt = dt.replace(tzinfo=ZoneInfo("UTC"))
delta = datetime.timedelta(seconds=DATE_HEADER_VALID_FOR) delta = datetime.timedelta(seconds=DATE_HEADER_VALID_FOR)
now = timezone.now() now = timezone.now()

View File

@ -5,6 +5,7 @@ import os
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.db.models.deletion import Collector from django.db.models.deletion import Collector
@ -18,6 +19,7 @@ from funkwhale_api.common import preferences, session
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.taskapp import celery from funkwhale_api.taskapp import celery
from . import ( from . import (
@ -665,3 +667,14 @@ def check_single_remote_instance_availability(domain):
domain.reachable = False domain.reachable = False
domain.save() domain.save()
return domain.reachable return domain.reachable
@celery.app.task(name="federation.trigger_playlist_ap_update")
def trigger_playlist_ap_update(playlist):
for playlist_uuid in cache.get("playlists_for_ap_update"):
routes.outbox.dispatch(
{"type": "Update", "object": {"type": "Playlist"}},
context={
"playlist": playlists_models.Playlist.objects.get(uuid=playlist_uuid)
},
)

View File

@ -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
@ -16,13 +17,18 @@ router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries") music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
music_router.register(r"uploads", views.MusicUploadViewSet, "uploads") music_router.register(r"uploads", views.MusicUploadViewSet, "uploads")
music_router.register(r"artists", views.MusicArtistViewSet, "artists") music_router.register(r"artists", views.MusicArtistViewSet, "artists")
music_router.register(r"artistcredit", views.MusicArtistCreditViewSet, "artistcredit")
music_router.register(r"albums", views.MusicAlbumViewSet, "albums") music_router.register(r"albums", views.MusicAlbumViewSet, "albums")
music_router.register(r"tracks", views.MusicTrackViewSet, "tracks") music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
music_router.register(r"likes", views.TrackFavoriteViewSet, "likes")
music_router.register(r"listenings", views.ListeningsViewSet, "listenings")
music_router.register(r"playlists", views.PlaylistViewSet, "playlists")
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")),
] ]

View File

@ -7,12 +7,16 @@ from django.urls import reverse
from rest_framework import exceptions, mixins, permissions, response, viewsets from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models from funkwhale_api.moderation import models as moderation_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 funkwhale_api.playlists import models as playlists_models
from . import ( from . import (
activity, activity,
@ -161,7 +165,9 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
"actor": channel.actor, "actor": channel.actor,
"items": channel.library.uploads.for_federation() "items": channel.library.uploads.for_federation()
.order_by("-creation_date") .order_by("-creation_date")
.prefetch_related("library__channel__actor", "track__artist"), .prefetch_related(
"library__channel__actor", "track__artist_credit__artist"
),
"item_serializer": serializers.ChannelCreateUploadSerializer, "item_serializer": serializers.ChannelCreateUploadSerializer,
} }
return get_collection_response( return get_collection_response(
@ -170,17 +176,115 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
collection_serializer=serializers.ChannelOutboxSerializer(channel), collection_serializer=serializers.ChannelOutboxSerializer(channel),
) )
@action(methods=["get"], detail=True) @action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def followers(self, request, *args, **kwargs): def followers(self, request, *args, **kwargs):
self.get_object() actor = self.get_object()
# XXX to implement followers = list(actor.get_approved_followers())
return response.Response({}) conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-followers",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followers,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(methods=["get"], detail=True) @action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def following(self, request, *args, **kwargs): def following(self, request, *args, **kwargs):
self.get_object() actor = self.get_object()
# XXX to implement followings = list(
return response.Response({}) actor.emitted_follows.filter(approved=True).values_list("target", flat=True)
)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-following",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followings,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def listens(self, request, *args, **kwargs):
actor = self.get_object()
listenings = history_models.Listening.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-listens",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": listenings,
"item_serializer": serializers.ListeningSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def likes(self, request, *args, **kwargs):
actor = self.get_object()
likes = favorites_models.TrackFavorite.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-likes",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": likes,
"item_serializer": serializers.TrackFavoriteSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
@ -283,28 +387,27 @@ class MusicLibraryViewSet(
"id": lb.get_federation_id(), "id": lb.get_federation_id(),
"actor": lb.actor, "actor": lb.actor,
"name": lb.name, "name": lb.name,
"summary": lb.description,
"items": lb.uploads.for_federation() "items": lb.uploads.for_federation()
.order_by("-creation_date") .order_by("-creation_date")
.prefetch_related( .prefetch_related(
Prefetch( Prefetch(
"track", "track",
queryset=music_models.Track.objects.select_related( queryset=music_models.Track.objects.select_related(
"album__artist__attributed_to",
"artist__attributed_to",
"artist__attachment_cover",
"attachment_cover", "attachment_cover",
"album__attributed_to", "album__attributed_to",
"attributed_to", "attributed_to",
"album__attachment_cover", "album__attachment_cover",
"album__artist__attachment_cover",
"description", "description",
).prefetch_related( ).prefetch_related(
"album__artist_credit__artist__attributed_to",
"artist_credit__artist__attributed_to",
"artist_credit__artist__attachment_cover",
"tagged_items__tag", "tagged_items__tag",
"album__tagged_items__tag", "album__tagged_items__tag",
"album__artist__tagged_items__tag", "album__artist_credit__artist__tagged_items__tag",
"artist__tagged_items__tag", "album__artist_credit__artist__attachment_cover",
"artist__description", "artist_credit__artist__tagged_items__tag",
"artist_credit__artist__description",
"album__description", "album__description",
), ),
) )
@ -331,16 +434,21 @@ class MusicUploadViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Upload.objects.local().select_related( queryset = (
music_models.Upload.objects.local()
.select_related(
"library__actor", "library__actor",
"track__artist",
"track__album__artist",
"track__description", "track__description",
"track__album__attachment_cover", "track__album__attachment_cover",
"track__album__artist__attachment_cover",
"track__artist__attachment_cover",
"track__attachment_cover", "track__attachment_cover",
) )
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
"track__album__artist_credit__artist__attachment_cover",
"track__artist_credit__artist__attachment_cover",
)
)
serializer_class = serializers.UploadSerializer serializer_class = serializers.UploadSerializer
lookup_field = "uuid" lookup_field = "uuid"
@ -393,13 +501,35 @@ class MusicArtistViewSet(
return response.Response(serializer.data) return response.Response(serializer.data)
class MusicArtistCreditViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.ArtistCredit.objects.local().prefetch_related("artist")
serializer_class = serializers.ArtistCreditSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class MusicAlbumViewSet( class MusicAlbumViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related( queryset = (
"artist__description", "description", "artist__attachment_cover" music_models.Album.objects.local()
.prefetch_related(
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
)
.select_related(
"description",
)
) )
serializer_class = serializers.AlbumSerializer serializer_class = serializers.AlbumSerializer
lookup_field = "uuid" lookup_field = "uuid"
@ -418,16 +548,22 @@ class MusicTrackViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related( queryset = (
"album__artist", music_models.Track.objects.local()
.select_related(
"album__description", "album__description",
"artist__description",
"description", "description",
"attachment_cover", "attachment_cover",
"album__artist__attachment_cover",
"album__attachment_cover", "album__attachment_cover",
"artist__attachment_cover",
) )
.prefetch_related(
"album__artist_credit__artist",
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
"album__artist_credit__artist__attachment_cover",
)
)
serializer_class = serializers.TrackSerializer serializer_class = serializers.TrackSerializer
lookup_field = "uuid" lookup_field = "uuid"
@ -527,3 +663,74 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
) )
return response.Response({}, status=200) return response.Response({}, status=200)
class TrackFavoriteViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = favorites_models.TrackFavorite.objects.local().select_related(
"track", "actor"
)
serializer_class = serializers.TrackFavoriteSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class ListeningsViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = history_models.Listening.objects.local().select_related("track", "actor")
serializer_class = serializers.ListeningSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class PlaylistViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = playlists_models.Playlist.objects.local().select_related("actor")
serializer_class = serializers.PlaylistCollectionSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
playlist = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(playlist.get_absolute_url())
conf = {
"id": playlist.fid,
"actor": playlist.actor,
"name": playlist.name,
"items": playlist.playlist_tracks.order_by("index").prefetch_related(
"track",
),
"item_serializer": serializers.PlaylistTrackSerializer,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.PlaylistCollectionSerializer(playlist),
)

View File

@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.ListeningActivitySerializer)
@record.registry.register_consumer("history.Listening") @record.registry.register_consumer("history.Listening")
def broadcast_listening_to_instance_activity(data, obj): def broadcast_listening_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]: if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return return
channels.group_send( channels.group_send(

View File

@ -5,6 +5,6 @@ from . import models
@admin.register(models.Listening) @admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin): class ListeningAdmin(admin.ModelAdmin):
list_display = ["track", "creation_date", "user", "session_key"] list_display = ["track", "creation_date", "actor", "session_key"]
search_fields = ["track__name", "user__username"] search_fields = ["track__name", "actor__user__username"]
list_select_related = ["user", "track"] list_select_related = ["actor", "track"]

View File

@ -1,14 +1,28 @@
import factory import factory
from django.conf import settings
from funkwhale_api.factories import NoUpdateOnCreate, registry from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import models
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.music import factories from funkwhale_api.music import factories
from funkwhale_api.users.factories import UserFactory
@registry.register @registry.register
class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory) actor = factory.SubFactory(ActorFactory)
track = factory.SubFactory(factories.TrackFactory) track = factory.SubFactory(factories.TrackFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
class Meta: class Meta:
model = "history.Listening" model = "history.Listening"
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
return
domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[
0
]
self.fid = f"https://{domain}/federation/music/favorite/{self.uuid}"
self.save(update_fields=["fid"])

View File

@ -7,9 +7,9 @@ from . import models
class ListeningFilter(moderation_filters.HiddenContentFilterSet): class ListeningFilter(moderation_filters.HiddenContentFilterSet):
username = django_filters.CharFilter("user__username") username = django_filters.CharFilter("actor__user__username")
domain = django_filters.CharFilter("user__actor__domain_id") domain = django_filters.CharFilter("actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta: class Meta:
model = models.Listening model = models.Listening

View File

@ -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),
),
]

View File

@ -0,0 +1,107 @@
import uuid
from django.db import migrations, models
from django.urls import reverse
from funkwhale_api.federation import utils
import django.db.models.deletion
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
unique_uuid = uuid.uuid4()
while MyModel.objects.filter(uuid=unique_uuid).exists():
unique_uuid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:listenings-detail", kwargs={"uuid": unique_uuid})
)
row.uuid = unique_uuid
row.fid = fid
row.save(update_fields=["uuid", "fid"])
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
class Migration(migrations.Migration):
dependencies = [
("history", "0003_listening_source"),
("federation", "0028_auto_20221027_1141"),
]
operations = [
migrations.AddField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
migrations.AddField(
model_name="listening",
name="fid",
field=models.URLField(
max_length=500,
null=True,
),
),
migrations.AddField(
model_name="listening",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
migrations.AlterField(
model_name="listening",
name="fid",
field=models.URLField(
unique=True,
db_index=True,
max_length=500,
),
),
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="listening",
name="user",
),
migrations.AlterField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=False,
null=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
]

View File

@ -1,25 +1,59 @@
import uuid
from django.db import models from django.db import models
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from funkwhale_api.common import models as common_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
class Listening(models.Model): class ListeningQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
pass
class Listening(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True) creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
track = models.ForeignKey( track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE Track, related_name="listenings", on_delete=models.CASCADE
) )
user = models.ForeignKey( actor = models.ForeignKey(
"users.User", "federation.Actor",
related_name="listenings", related_name="listenings",
null=True,
blank=True,
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=False,
blank=False,
) )
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)
federation_namespace = "listenings"
objects = ListeningQuerySet.as_manager()
class Meta: class Meta:
ordering = ("-creation_date",) ordering = ("-creation_date",)
def get_activity_url(self): def get_activity_url(self):
return f"{self.user.get_activity_url()}/listenings/tracks/{self.pk}" return f"{self.actor.get_absolute_url()}/listenings/tracks/{self.pk}"
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)

View File

@ -1,10 +1,8 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models from . import models
@ -12,47 +10,39 @@ from . import models
class ListeningActivitySerializer(activity_serializers.ModelSerializer): class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track") object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user") actor = federation_serializers.APIActorSerializer()
published = serializers.DateTimeField(source="creation_date") published = serializers.DateTimeField(source="creation_date")
class Meta: class Meta:
model = models.Listening model = models.Listening
fields = ["id", "local_id", "object", "type", "actor", "published"] fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj): def get_type(self, obj):
return "Listen" return "Listen"
class ListeningSerializer(serializers.ModelSerializer): class ListeningSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True) track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True) actor = federation_serializers.APIActorSerializer(read_only=True)
actor = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.Listening model = models.Listening
fields = ("id", "user", "track", "creation_date", "actor") fields = ("id", "actor", "track", "creation_date", "actor")
def create(self, validated_data): def create(self, validated_data):
validated_data["user"] = self.context["user"] validated_data["actor"] = self.context["user"].actor
return super().create(validated_data) return super().create(validated_data)
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
class ListeningWriteSerializer(serializers.ModelSerializer): class ListeningWriteSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True, required=False)
class Meta: class Meta:
model = models.Listening model = models.Listening
fields = ("id", "user", "track", "creation_date") fields = ("id", "actor", "track", "creation_date")
def create(self, validated_data): def create(self, validated_data):
validated_data["user"] = self.context["user"] validated_data["actor"] = self.context["user"].actor
return super().create(validated_data) return super().create(validated_data)

View File

@ -4,6 +4,7 @@ from rest_framework import mixins, viewsets
from config import plugins 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.federation import routes
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
from funkwhale_api.users.oauth import permissions as oauth_permissions from funkwhale_api.users.oauth import permissions as oauth_permissions
@ -18,9 +19,7 @@ class ListeningViewSet(
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
serializer_class = serializers.ListeningSerializer serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related( queryset = models.Listening.objects.all().select_related("actor__attachment_icon")
"user__actor__attachment_icon"
)
permission_classes = [ permission_classes = [
oauth_permissions.ScopePermission, oauth_permissions.ScopePermission,
@ -29,6 +28,7 @@ class ListeningViewSet(
required_scope = "listenings" required_scope = "listenings"
anonymous_policy = "setting" anonymous_policy = "setting"
owner_checks = ["write"] owner_checks = ["write"]
owner_field = "actor.user"
filterset_class = filters.ListeningFilter filterset_class = filters.ListeningFilter
def get_serializer_class(self): def get_serializer_class(self):
@ -38,23 +38,40 @@ class ListeningViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
r = super().perform_create(serializer) r = super().perform_create(serializer)
instance = serializer.instance
plugins.trigger_hook( plugins.trigger_hook(
plugins.LISTENING_CREATED, plugins.LISTENING_CREATED,
listening=serializer.instance, listening=instance,
confs=plugins.get_confs(self.request.user), confs=plugins.get_confs(self.request.user),
) )
routes.outbox.dispatch(
{"type": "Listen", "object": {"type": "Track"}},
context={
"track": instance.track,
"actor": instance.actor,
"id": instance.fid,
},
)
record.send(serializer.instance) record.send(serializer.instance)
return r return r
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") fields.privacy_level_query(
self.request.user, "actor__user__privacy_level", "actor__user"
) )
tracks = Track.objects.with_playable_uploads( )
tracks = (
Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request) music_utils.get_actor_from_request(self.request)
).select_related( )
"artist", "album__artist", "attributed_to", "artist__attachment_cover" .prefetch_related(
"artist_credit",
"album__artist_credit__artist",
"artist_credit__artist__attachment_cover",
)
.select_related("attributed_to")
) )
return queryset.prefetch_related(Prefetch("track", queryset=tracks)) return queryset.prefetch_related(Prefetch("track", queryset=tracks))

View File

@ -37,7 +37,7 @@ def get_content():
def get_top_music_categories(): def get_top_music_categories():
return ( return (
models.Track.objects.filter(artist__content_category="music") models.Track.objects.filter(artist_credit__artist__content_category="music")
.exclude(tagged_items__tag_id=None) .exclude(tagged_items__tag_id=None)
.values(name=F("tagged_items__tag__name")) .values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name")) .annotate(count=Count("name"))
@ -47,7 +47,7 @@ def get_top_music_categories():
def get_top_podcast_categories(): def get_top_podcast_categories():
return ( return (
models.Track.objects.filter(artist__content_category="podcast") models.Track.objects.filter(artist_credit__artist__content_category="podcast")
.exclude(tagged_items__tag_id=None) .exclude(tagged_items__tag_id=None)
.values(name=F("tagged_items__tag__name")) .values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name")) .annotate(count=Count("name"))

View File

@ -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.NodeInfo20.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

View File

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

View File

@ -171,6 +171,9 @@ class NodeInfo21(NodeInfo20):
if pref.get("federation__enabled"): if pref.get("federation__enabled"):
data["features"].append("federation") data["features"].append("federation")
if pref.get("music__only_allow_musicbrainz_tagged_files"):
data["features"].append("onlyMbidTaggedContent")
serializer = self.serializer_class(data) 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

View File

@ -1,6 +1,7 @@
import django_filters import django_filters
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 Collate
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from funkwhale_api.audio import models as audio_models from funkwhale_api.audio import models as audio_models
@ -96,12 +97,15 @@ class ManageAlbumFilterSet(filters.FilterSet):
search_fields={ search_fields={
"title": {"to": "title"}, "title": {"to": "title"},
"fid": {"to": "fid"}, "fid": {"to": "fid"},
"artist": {"to": "artist__name"}, "artist": {"to": "artist_credit__artist__name"},
"mbid": {"to": "mbid"}, "mbid": {"to": "mbid"},
}, },
filter_fields={ filter_fields={
"uuid": {"to": "uuid"}, "uuid": {"to": "uuid"},
"artist_id": {"to": "artist_id", "field": forms.IntegerField()}, "artist_id": {
"to": "artist_credit__artist_id",
"field": forms.IntegerField(),
},
"domain": { "domain": {
"handler": lambda v: federation_utils.get_domain_query_from_url(v) "handler": lambda v: federation_utils.get_domain_query_from_url(v)
}, },
@ -117,7 +121,7 @@ class ManageAlbumFilterSet(filters.FilterSet):
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ["title", "mbid", "fid", "artist"] fields = ["title", "mbid", "fid", "artist_credit"]
class ManageTrackFilterSet(filters.FilterSet): class ManageTrackFilterSet(filters.FilterSet):
@ -127,9 +131,9 @@ class ManageTrackFilterSet(filters.FilterSet):
"title": {"to": "title"}, "title": {"to": "title"},
"fid": {"to": "fid"}, "fid": {"to": "fid"},
"mbid": {"to": "mbid"}, "mbid": {"to": "mbid"},
"artist": {"to": "artist__name"}, "artist": {"to": "artist_credit__artist__name"},
"album": {"to": "album__title"}, "album": {"to": "album__title"},
"album_artist": {"to": "album__artist__name"}, "album_artist": {"to": "album__artist_credit__artist__name"},
"copyright": {"to": "copyright"}, "copyright": {"to": "copyright"},
}, },
filter_fields={ filter_fields={
@ -156,7 +160,7 @@ class ManageTrackFilterSet(filters.FilterSet):
class Meta: class Meta:
model = music_models.Track model = music_models.Track
fields = ["title", "mbid", "fid", "artist", "album", "license"] fields = ["title", "mbid", "fid", "artist_credit", "album", "license"]
class ManageLibraryFilterSet(filters.FilterSet): class ManageLibraryFilterSet(filters.FilterSet):
@ -370,6 +374,13 @@ class ManageTagFilterSet(filters.FilterSet):
model = tags_models.Tag model = tags_models.Tag
fields = [] fields = []
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.annotate(tag_deterministic=Collate("name", "und-x-icu"))
)
class ManageReportFilterSet(filters.FilterSet): class ManageReportFilterSet(filters.FilterSet):
q = fields.SmartSearchFilter( q = fields.SmartSearchFilter(

View File

@ -67,8 +67,8 @@ class ManageUserSerializer(serializers.ModelSerializer):
"date_joined", "date_joined",
"last_activity", "last_activity",
"permissions", "permissions",
"privacy_level",
"upload_quota", "upload_quota",
"privacy_level",
"full_username", "full_username",
) )
read_only_fields = [ read_only_fields = [
@ -451,17 +451,25 @@ class ManageNestedArtistSerializer(ManageBaseArtistSerializer):
pass pass
class ManageNestedArtistCreditSerializer(ManageBaseArtistSerializer):
artist = ManageNestedArtistSerializer()
class Meta:
model = music_models.ArtistCredit
fields = ["artist"]
class ManageAlbumSerializer( class ManageAlbumSerializer(
music_serializers.OptionalDescriptionMixin, ManageBaseAlbumSerializer music_serializers.OptionalDescriptionMixin, ManageBaseAlbumSerializer
): ):
attributed_to = ManageBaseActorSerializer() attributed_to = ManageBaseActorSerializer()
artist = ManageNestedArtistSerializer() artist_credit = ManageNestedArtistCreditSerializer(many=True)
tags = serializers.SerializerMethodField() tags = serializers.SerializerMethodField()
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + [ fields = ManageBaseAlbumSerializer.Meta.fields + [
"artist", "artist_credit",
"attributed_to", "attributed_to",
"tags", "tags",
"tracks_count", "tracks_count",
@ -477,17 +485,17 @@ class ManageAlbumSerializer(
class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer): class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer):
artist = ManageNestedArtistSerializer() artist_credit = ManageNestedArtistCreditSerializer(many=True)
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + ["artist"] fields = ManageBaseAlbumSerializer.Meta.fields + ["artist_credit"]
class ManageTrackSerializer( class ManageTrackSerializer(
music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer
): ):
artist = ManageNestedArtistSerializer() artist_credit = ManageNestedArtistCreditSerializer(many=True)
album = ManageTrackAlbumSerializer(allow_null=True) album = ManageTrackAlbumSerializer(allow_null=True)
attributed_to = ManageBaseActorSerializer(allow_null=True) attributed_to = ManageBaseActorSerializer(allow_null=True)
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
@ -497,7 +505,7 @@ class ManageTrackSerializer(
class Meta: class Meta:
model = music_models.Track model = music_models.Track
fields = ManageNestedTrackSerializer.Meta.fields + [ fields = ManageNestedTrackSerializer.Meta.fields + [
"artist", "artist_credit",
"album", "album",
"attributed_to", "attributed_to",
"uploads_count", "uploads_count",
@ -564,7 +572,6 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
domain = serializers.CharField(source="domain_name") domain = serializers.CharField(source="domain_name")
actor = ManageBaseActorSerializer() actor = ManageBaseActorSerializer()
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
followers_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = music_models.Library model = music_models.Library
@ -574,14 +581,11 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
"fid", "fid",
"url", "url",
"name", "name",
"description",
"domain", "domain",
"is_local", "is_local",
"creation_date", "creation_date",
"privacy_level", "privacy_level",
"uploads_count", "uploads_count",
"followers_count",
"followers_url",
"actor", "actor",
] ]
read_only_fields = [ read_only_fields = [
@ -597,10 +601,6 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
def get_uploads_count(self, obj) -> int: def get_uploads_count(self, obj) -> int:
return getattr(obj, "_uploads_count", int(obj.uploads_count)) return getattr(obj, "_uploads_count", int(obj.uploads_count))
@extend_schema_field(OpenApiTypes.INT)
def get_followers_count(self, obj):
return getattr(obj, "followers_count", None)
class ManageNestedLibrarySerializer(serializers.ModelSerializer): class ManageNestedLibrarySerializer(serializers.ModelSerializer):
domain = serializers.CharField(source="domain_name") domain = serializers.CharField(source="domain_name")
@ -614,12 +614,10 @@ class ManageNestedLibrarySerializer(serializers.ModelSerializer):
"fid", "fid",
"url", "url",
"name", "name",
"description",
"domain", "domain",
"is_local", "is_local",
"creation_date", "creation_date",
"privacy_level", "privacy_level",
"followers_url",
"actor", "actor",
] ]

View File

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

View File

@ -1,6 +1,6 @@
from django.db import transaction from django.db import transaction
from django.db.models import Count, OuterRef, Prefetch, Q, Subquery, Sum from django.db.models import Count, OuterRef, Prefetch, Q, Subquery, Sum
from django.db.models.functions import Coalesce, Length from django.db.models.functions import Coalesce, Collate, Length
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import decorators as rest_decorators from rest_framework import decorators as rest_decorators
@ -84,8 +84,8 @@ class ManageArtistViewSet(
music_models.Artist.objects.all() music_models.Artist.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "attachment_cover", "channel") .select_related("attributed_to", "attachment_cover", "channel")
.annotate(_tracks_count=Count("tracks", distinct=True)) .annotate(_tracks_count=Count("artist_credit__tracks", distinct=True))
.annotate(_albums_count=Count("albums", distinct=True)) .annotate(_albums_count=Count("artist_credit__albums", distinct=True))
.prefetch_related(music_views.TAG_PREFETCH) .prefetch_related(music_views.TAG_PREFETCH)
) )
serializer_class = serializers.ManageArtistSerializer serializer_class = serializers.ManageArtistSerializer
@ -98,7 +98,7 @@ class ManageArtistViewSet(
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
artist = self.get_object() artist = self.get_object()
tracks = music_models.Track.objects.filter( tracks = music_models.Track.objects.filter(
Q(artist=artist) | Q(album__artist=artist) Q(artist_credit__artist=artist) | Q(album__artist_credit__artist=artist)
) )
data = get_stats(tracks, artist) data = get_stats(tracks, artist)
return response.Response(data, status=200) return response.Response(data, status=200)
@ -128,8 +128,8 @@ class ManageAlbumViewSet(
queryset = ( queryset = (
music_models.Album.objects.all() music_models.Album.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "artist", "attachment_cover") .select_related("attributed_to", "attachment_cover")
.prefetch_related("tracks") .prefetch_related("tracks", "artist_credit__artist")
) )
serializer_class = serializers.ManageAlbumSerializer serializer_class = serializers.ManageAlbumSerializer
filterset_class = filters.ManageAlbumFilterSet filterset_class = filters.ManageAlbumFilterSet
@ -177,10 +177,10 @@ class ManageTrackViewSet(
queryset = ( queryset = (
music_models.Track.objects.all() music_models.Track.objects.all()
.order_by("-id") .order_by("-id")
.select_related( .prefetch_related(
"attributed_to", "attributed_to",
"artist", "artist_credit",
"album__artist", "album__artist_credit",
"album__attachment_cover", "album__attachment_cover",
"attachment_cover", "attachment_cover",
) )
@ -273,11 +273,11 @@ class ManageLibraryViewSet(
) )
artists = set( artists = set(
music_models.Album.objects.filter(pk__in=albums).values_list( music_models.Album.objects.filter(pk__in=albums).values_list(
"artist", flat=True "artist_credit__artist", flat=True
) )
) | set( ) | set(
music_models.Track.objects.filter(pk__in=tracks).values_list( music_models.Track.objects.filter(pk__in=tracks).values_list(
"artist", flat=True "artist_credit__artist", flat=True
) )
) )
@ -313,7 +313,11 @@ class ManageUploadViewSet(
queryset = ( queryset = (
music_models.Upload.objects.all() music_models.Upload.objects.all()
.order_by("-id") .order_by("-id")
.select_related("library__actor", "track__artist", "track__album__artist") .prefetch_related(
"library__actor",
"track__artist_credit__artist",
"track__album__artist_credit__artist",
)
) )
serializer_class = serializers.ManageUploadSerializer serializer_class = serializers.ManageUploadSerializer
filterset_class = filters.ManageUploadFilterSet filterset_class = filters.ManageUploadFilterSet
@ -579,6 +583,7 @@ class ManageTagViewSet(
.order_by("-creation_date") .order_by("-creation_date")
.annotate(items_count=Count("tagged_items")) .annotate(items_count=Count("tagged_items"))
.annotate(length=Length("name")) .annotate(length=Length("name"))
.annotate(tag_deterministic=Collate("name", "und-x-icu"))
) )
serializer_class = serializers.ManageTagSerializer serializer_class = serializers.ManageTagSerializer
filterset_class = filters.ManageTagFilterSet filterset_class = filters.ManageTagFilterSet
@ -702,8 +707,8 @@ class ManageChannelViewSet(
music_models.Artist.objects.all() music_models.Artist.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "attachment_cover", "channel") .select_related("attributed_to", "attachment_cover", "channel")
.annotate(_tracks_count=Count("tracks")) .annotate(_tracks_count=Count("artist_credit__tracks"))
.annotate(_albums_count=Count("albums")) .annotate(_albums_count=Count("artist_credit__albums"))
.prefetch_related(music_views.TAG_PREFETCH) .prefetch_related(music_views.TAG_PREFETCH)
), ),
) )
@ -719,7 +724,8 @@ class ManageChannelViewSet(
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
channel = self.get_object() channel = self.get_object()
tracks = music_models.Track.objects.filter( tracks = music_models.Track.objects.filter(
Q(artist=channel.artist) | Q(album__artist=channel.artist) Q(artist_credit__artist=channel.artist)
| Q(album__artist_credit__artist=channel.artist)
) )
data = get_stats(tracks, channel, ignore_fields=["libraries", "channels"]) data = get_stats(tracks, channel, ignore_fields=["libraries", "channels"])
data["follows"] = channel.actor.received_follows.count() data["follows"] = channel.actor.received_follows.count()

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