Funkwhale layout ¶
See also
tree -L 6 ¶
-
179 directories
-
741 files
1.
2├── api
3│ ├── compose
4│ │ └── django
5│ │ ├── daphne.sh
6│ │ ├── dev-entrypoint.sh
7│ │ └── entrypoint.sh
8│ ├── config
9│ │ ├── api_urls.py
10│ │ ├── asgi.py
11│ │ ├── __init__.py
12│ │ ├── routing.py
13│ │ ├── settings
14│ │ │ ├── common.py
15│ │ │ ├── __init__.py
16│ │ │ ├── local.py
17│ │ │ └── production.py
18│ │ ├── spa_urls.py
19│ │ └── urls.py
20│ ├── Dockerfile
21│ ├── funkwhale_api
22│ │ ├── activity
23│ │ │ ├── apps.py
24│ │ │ ├── __init__.py
25│ │ │ ├── record.py
26│ │ │ ├── serializers.py
27│ │ │ ├── utils.py
28│ │ │ └── views.py
29│ │ ├── common
30│ │ │ ├── admin.py
31│ │ │ ├── apps.py
32│ │ │ ├── authentication.py
33│ │ │ ├── auth.py
34│ │ │ ├── channels.py
35│ │ │ ├── consumers.py
36│ │ │ ├── decorators.py
37│ │ │ ├── dynamic_preferences_registry.py
38│ │ │ ├── factories.py
39│ │ │ ├── fields.py
40│ │ │ ├── filters.py
41│ │ │ ├── __init__.py
42│ │ │ ├── management
43│ │ │ │ ├── commands
44│ │ │ │ │ ├── __init__.py
45│ │ │ │ │ ├── makemigrations.py
46│ │ │ │ │ ├── migrate.py
47│ │ │ │ │ └── script.py
48│ │ │ │ └── __init__.py
49│ │ │ ├── middleware.py
50│ │ │ ├── migrations
51│ │ │ │ ├── 0001_initial.py
52│ │ │ │ ├── 0002_mutation.py
53│ │ │ │ └── __init__.py
54│ │ │ ├── models.py
55│ │ │ ├── mutations.py
56│ │ │ ├── pagination.py
57│ │ │ ├── permissions.py
58│ │ │ ├── preferences.py
59│ │ │ ├── scripts
60│ │ │ │ ├── create_actors.py
61│ │ │ │ ├── create_image_variations.py
62│ │ │ │ ├── delete_pre_017_federated_uploads.py
63│ │ │ │ ├── django_permissions_to_user_permissions.py
64│ │ │ │ ├── __init__.py
65│ │ │ │ ├── migrate_to_user_libraries.py
66│ │ │ │ └── test.py
67│ │ │ ├── search.py
68│ │ │ ├── serializers.py
69│ │ │ ├── session.py
70│ │ │ ├── signals.py
71│ │ │ ├── storage.py
72│ │ │ ├── tasks.py
73│ │ │ ├── utils.py
74│ │ │ ├── validators.py
75│ │ │ └── views.py
76│ │ ├── contrib
77│ │ │ ├── __init__.py
78│ │ │ └── sites
79│ │ │ ├── __init__.py
80│ │ │ └── migrations
81│ │ │ ├── 0001_initial.py
82│ │ │ ├── 0002_set_site_domain_and_name.py
83│ │ │ ├── 0003_auto_20171214_2205.py
84│ │ │ └── __init__.py
85│ │ ├── factories.py
86│ │ ├── favorites
87│ │ │ ├── activities.py
88│ │ │ ├── admin.py
89│ │ │ ├── consumers.py
90│ │ │ ├── factories.py
91│ │ │ ├── filters.py
92│ │ │ ├── __init__.py
93│ │ │ ├── migrations
94│ │ │ │ ├── 0001_initial.py
95│ │ │ │ └── __init__.py
96│ │ │ ├── models.py
97│ │ │ ├── serializers.py
98│ │ │ ├── urls.py
99│ │ │ └── views.py
100│ │ ├── federation
101│ │ │ ├── activity.py
102│ │ │ ├── actors.py
103│ │ │ ├── admin.py
104│ │ │ ├── api_serializers.py
105│ │ │ ├── api_urls.py
106│ │ │ ├── api_views.py
107│ │ │ ├── authentication.py
108│ │ │ ├── dynamic_preferences_registry.py
109│ │ │ ├── exceptions.py
110│ │ │ ├── factories.py
111│ │ │ ├── fields.py
112│ │ │ ├── filters.py
113│ │ │ ├── __init__.py
114│ │ │ ├── keys.py
115│ │ │ ├── library.py
116│ │ │ ├── management
117│ │ │ │ ├── commands
118│ │ │ │ │ ├── fix_federation_ids.py
119│ │ │ │ │ └── __init__.py
120│ │ │ │ └── __init__.py
121│ │ │ ├── migrations
122│ │ │ │ ├── 0001_initial.py
123│ │ │ │ ├── 0002_auto_20180403_1620.py
124│ │ │ │ ├── 0003_auto_20180407_1010.py
125│ │ │ │ ├── 0004_auto_20180410_2025.py
126│ │ │ │ ├── 0005_auto_20180413_1723.py
127│ │ │ │ ├── 0006_auto_20180521_1702.py
128│ │ │ │ ├── 0007_auto_20180807_1748.py
129│ │ │ │ ├── 0008_auto_20180807_1748.py
130│ │ │ │ ├── 0009_auto_20180822_1956.py
131│ │ │ │ ├── 0010_auto_20180904_2011.py
132│ │ │ │ ├── 0011_auto_20180910_1902.py
133│ │ │ │ ├── 0012_auto_20180920_1803.py
134│ │ │ │ ├── 0013_auto_20181226_1935.py
135│ │ │ │ ├── 0014_auto_20181205_0958.py
136│ │ │ │ ├── 0015_populate_domains.py
137│ │ │ │ ├── 0016_auto_20181227_1605.py
138│ │ │ │ ├── 0017_auto_20190130_0926.py
139│ │ │ │ └── __init__.py
140│ │ │ ├── models.py
141│ │ │ ├── parsers.py
142│ │ │ ├── renderers.py
143│ │ │ ├── routes.py
144│ │ │ ├── serializers.py
145│ │ │ ├── signing.py
146│ │ │ ├── tasks.py
147│ │ │ ├── urls.py
148│ │ │ ├── utils.py
149│ │ │ ├── views.py
150│ │ │ └── webfinger.py
151│ │ ├── history
152│ │ │ ├── activities.py
153│ │ │ ├── admin.py
154│ │ │ ├── factories.py
155│ │ │ ├── filters.py
156│ │ │ ├── __init__.py
157│ │ │ ├── migrations
158│ │ │ │ ├── 0001_initial.py
159│ │ │ │ ├── 0002_auto_20180325_1433.py
160│ │ │ │ └── __init__.py
161│ │ │ ├── models.py
162│ │ │ ├── serializers.py
163│ │ │ ├── urls.py
164│ │ │ └── views.py
165│ │ ├── __init__.py
166│ │ ├── instance
167│ │ │ ├── consumers.py
168│ │ │ ├── dynamic_preferences_registry.py
169│ │ │ ├── __init__.py
170│ │ │ ├── nodeinfo.py
171│ │ │ ├── stats.py
172│ │ │ ├── urls.py
173│ │ │ └── views.py
174│ │ ├── manage
175│ │ │ ├── filters.py
176│ │ │ ├── __init__.py
177│ │ │ ├── serializers.py
178│ │ │ ├── urls.py
179│ │ │ └── views.py
180│ │ ├── moderation
181│ │ │ ├── admin.py
182│ │ │ ├── factories.py
183│ │ │ ├── filters.py
184│ │ │ ├── __init__.py
185│ │ │ ├── migrations
186│ │ │ │ ├── 0001_initial.py
187│ │ │ │ ├── 0002_auto_20190213_0927.py
188│ │ │ │ └── __init__.py
189│ │ │ ├── models.py
190│ │ │ ├── serializers.py
191│ │ │ ├── urls.py
192│ │ │ └── views.py
193│ │ ├── music
194│ │ │ ├── admin.py
195│ │ │ ├── dynamic_preferences_registry.py
196│ │ │ ├── factories.py
197│ │ │ ├── fake_data.py
198│ │ │ ├── filters.py
199│ │ │ ├── importers.py
200│ │ │ ├── __init__.py
201│ │ │ ├── licenses.py
202│ │ │ ├── lyrics.py
203│ │ │ ├── management
204│ │ │ │ ├── commands
205│ │ │ │ │ ├── check_licenses.py
206│ │ │ │ │ ├── fix_uploads.py
207│ │ │ │ │ ├── import_files.py
208│ │ │ │ │ └── __init__.py
209│ │ │ │ └── __init__.py
210│ │ │ ├── metadata.py
211│ │ │ ├── migrations
212│ │ │ │ ├── 0001_initial.py
213│ │ │ │ ├── 0002_auto_20151215_1645.py
214│ │ │ │ ├── 0003_auto_20151222_2233.py
215│ │ │ │ ├── 0004_track_tags.py
216│ │ │ │ ├── 0005_deduplicate.py
217│ │ │ │ ├── 0006_unique_mbid.py
218│ │ │ │ ├── 0007_track_position.py
219│ │ │ │ ├── 0008_auto_20160529_1456.py
220│ │ │ │ ├── 0009_auto_20160920_1614.py
221│ │ │ │ ├── 0010_auto_20160920_1742.py
222│ │ │ │ ├── 0011_rename_files.py
223│ │ │ │ ├── 0012_auto_20161122_1905.py
224│ │ │ │ ├── 0013_auto_20171213_2211.py
225│ │ │ │ ├── 0014_importjob_track_file.py
226│ │ │ │ ├── 0015_bind_track_file_to_import_job.py
227│ │ │ │ ├── 0016_trackfile_acoustid_track_id.py
228│ │ │ │ ├── 0017_auto_20171227_1728.py
229│ │ │ │ ├── 0018_auto_20180218_1554.py
230│ │ │ │ ├── 0019_populate_mimetypes.py
231│ │ │ │ ├── 0020_importbatch_status.py
232│ │ │ │ ├── 0021_populate_batch_status.py
233│ │ │ │ ├── 0022_importbatch_import_request.py
234│ │ │ │ ├── 0023_auto_20180407_1010.py
235│ │ │ │ ├── 0024_populate_uuid.py
236│ │ │ │ ├── 0025_auto_20180419_2023.py
237│ │ │ │ ├── 0026_trackfile_accessed_date.py
238│ │ │ │ ├── 0027_auto_20180515_1808.py
239│ │ │ │ ├── 0028_importjob_replace_if_duplicate.py
240│ │ │ │ ├── 0029_auto_20180807_1748.py
241│ │ │ │ ├── 0030_auto_20180825_1411.py
242│ │ │ │ ├── 0031_auto_20180914_2007.py
243│ │ │ │ ├── 0032_track_file_to_upload.py
244│ │ │ │ ├── 0033_auto_20181023_1837.py
245│ │ │ │ ├── 0034_auto_20181127_0325.py
246│ │ │ │ ├── 0035_auto_20181203_1515.py
247│ │ │ │ ├── 0036_track_disc_number.py
248│ │ │ │ ├── 0037_auto_20190103_1757.py
249│ │ │ │ └── __init__.py
250│ │ │ ├── models.py
251│ │ │ ├── mutations.py
252│ │ │ ├── serializers.py
253│ │ │ ├── signals.py
254│ │ │ ├── spa_views.py
255│ │ │ ├── tasks.py
256│ │ │ ├── utils.py
257│ │ │ └── views.py
258│ │ ├── musicbrainz
259│ │ │ ├── client.py
260│ │ │ ├── __init__.py
261│ │ │ ├── urls.py
262│ │ │ └── views.py
263│ │ ├── playlists
264│ │ │ ├── admin.py
265│ │ │ ├── dynamic_preferences_registry.py
266│ │ │ ├── factories.py
267│ │ │ ├── filters.py
268│ │ │ ├── __init__.py
269│ │ │ ├── migrations
270│ │ │ │ ├── 0001_initial.py
271│ │ │ │ ├── 0002_auto_20180316_2217.py
272│ │ │ │ ├── 0003_auto_20180319_1214.py
273│ │ │ │ ├── 0004_auto_20180320_1713.py
274│ │ │ │ └── __init__.py
275│ │ │ ├── models.py
276│ │ │ ├── serializers.py
277│ │ │ └── views.py
278│ │ ├── providers
279│ │ │ ├── __init__.py
280│ │ │ └── urls.py
281│ │ ├── radios
282│ │ │ ├── admin.py
283│ │ │ ├── factories.py
284│ │ │ ├── filtersets.py
285│ │ │ ├── filters.py
286│ │ │ ├── __init__.py
287│ │ │ ├── migrations
288│ │ │ │ ├── 0001_initial.py
289│ │ │ │ ├── 0002_radiosession_session_key.py
290│ │ │ │ ├── 0003_auto_20160521_1708.py
291│ │ │ │ ├── 0004_auto_20180107_1813.py
292│ │ │ │ └── __init__.py
293│ │ │ ├── models.py
294│ │ │ ├── radios.py
295│ │ │ ├── registries.py
296│ │ │ ├── serializers.py
297│ │ │ ├── urls.py
298│ │ │ └── views.py
299│ │ ├── requests
300│ │ │ ├── __init__.py
301│ │ │ ├── migrations
302│ │ │ │ ├── 0001_initial.py
303│ │ │ │ └── __init__.py
304│ │ │ └── models.py
305│ │ ├── static
306│ │ │ ├── css
307│ │ │ │ └── project.css
308│ │ │ ├── fonts
309│ │ │ ├── images
310│ │ │ │ └── favicon.ico
311│ │ │ ├── js
312│ │ │ │ └── project.js
313│ │ │ ├── music
314│ │ │ │ └── sample1.ogg
315│ │ │ └── sass
316│ │ │ └── project.scss
317│ │ ├── subsonic
318│ │ │ ├── authentication.py
319│ │ │ ├── dynamic_preferences_registry.py
320│ │ │ ├── filters.py
321│ │ │ ├── __init__.py
322│ │ │ ├── negotiation.py
323│ │ │ ├── renderers.py
324│ │ │ ├── serializers.py
325│ │ │ └── views.py
326│ │ ├── taskapp
327│ │ │ ├── celery.py
328│ │ │ └── __init__.py
329│ │ ├── templates
330│ │ │ ├── account
331│ │ │ │ └── email
332│ │ │ │ └── email_confirmation_message.txt
333│ │ │ └── registration
334│ │ │ └── password_reset_email.html
335│ │ └── users
336│ │ ├── adapters.py
337│ │ ├── admin.py
338│ │ ├── api_urls.py
339│ │ ├── auth_backends.py
340│ │ ├── dynamic_preferences_registry.py
341│ │ ├── factories.py
342│ │ ├── __init__.py
343│ │ ├── middleware.py
344│ │ ├── migrations
345│ │ │ ├── 0001_initial.py
346│ │ │ ├── 0002_auto_20171214_2205.py
347│ │ │ ├── 0003_auto_20171226_1357.py
348│ │ │ ├── 0004_user_privacy_level.py
349│ │ │ ├── 0005_user_subsonic_api_token.py
350│ │ │ ├── 0006_auto_20180517_2324.py
351│ │ │ ├── 0007_auto_20180524_2009.py
352│ │ │ ├── 0008_auto_20180617_1531.py
353│ │ │ ├── 0009_auto_20180619_2024.py
354│ │ │ ├── 0010_user_avatar.py
355│ │ │ ├── 0011_auto_20180721_1317.py
356│ │ │ ├── 0012_user_upload_quota.py
357│ │ │ ├── 0013_auto_20181206_1008.py
358│ │ │ └── __init__.py
359│ │ ├── models.py
360│ │ ├── permissions.py
361│ │ ├── rest_auth_urls.py
362│ │ ├── serializers.py
363│ │ └── views.py
364│ ├── install_os_dependencies.sh
365│ ├── manage.py
366│ ├── requirements
367│ │ ├── base.txt
368│ │ ├── local.txt
369│ │ └── test.txt
370│ ├── requirements.apt
371│ ├── requirements.pac
372│ ├── requirements.txt
373│ ├── setup.cfg
374│ └── tests
375│ ├── activity
376│ │ ├── __init__.py
377│ │ ├── test_record.py
378│ │ ├── test_serializers.py
379│ │ ├── test_utils.py
380│ │ └── test_views.py
381│ ├── channels
382│ │ ├── __init__.py
383│ │ ├── test_auth.py
384│ │ └── test_consumers.py
385│ ├── common
386│ │ ├── exif.jpg
387│ │ ├── __init__.py
388│ │ ├── test_decorators.py
389│ │ ├── test_fields.py
390│ │ ├── test_filters.py
391│ │ ├── test_middleware.py
392│ │ ├── test_models.py
393│ │ ├── test_mutations.py
394│ │ ├── test_permissions.py
395│ │ ├── test_preferences.py
396│ │ ├── test_scripts.py
397│ │ ├── test_search.py
398│ │ ├── test_serializers.py
399│ │ ├── test_session.py
400│ │ ├── test_tasks.py
401│ │ ├── test_utils.py
402│ │ └── test_views.py
403│ ├── conftest.py
404│ ├── data
405│ │ └── youtube.py
406│ ├── favorites
407│ │ ├── __init__.py
408│ │ ├── test_activity.py
409│ │ ├── test_favorites.py
410│ │ ├── test_filters.py
411│ │ └── test_views.py
412│ ├── federation
413│ │ ├── __init__.py
414│ │ ├── test_activity.py
415│ │ ├── test_actors.py
416│ │ ├── test_api_filters.py
417│ │ ├── test_api_serializers.py
418│ │ ├── test_api_views.py
419│ │ ├── test_authentication.py
420│ │ ├── test_commands.py
421│ │ ├── test_keys.py
422│ │ ├── test_migrations.py
423│ │ ├── test_models.py
424│ │ ├── test_routes.py
425│ │ ├── test_serializers.py
426│ │ ├── test_signing.py
427│ │ ├── test_tasks.py
428│ │ ├── test_utils.py
429│ │ ├── test_views.py
430│ │ └── test_webfinger.py
431│ ├── files
432│ │ ├── dummy_file.ogg
433│ │ └── utf8-éà◌.ogg
434│ ├── history
435│ │ ├── __init__.py
436│ │ ├── test_activity.py
437│ │ ├── test_filters.py
438│ │ ├── test_history.py
439│ │ └── test_views.py
440│ ├── __init__.py
441│ ├── instance
442│ │ ├── __init__.py
443│ │ ├── test_nodeinfo.py
444│ │ ├── test_preferences.py
445│ │ ├── test_stats.py
446│ │ └── test_views.py
447│ ├── manage
448│ │ ├── __init__.py
449│ │ ├── test_filters.py
450│ │ ├── test_serializers.py
451│ │ └── test_views.py
452│ ├── moderation
453│ │ ├── __init__.py
454│ │ ├── test_filters.py
455│ │ ├── test_serializers.py
456│ │ └── test_views.py
457│ ├── music
458│ │ ├── conftest.py
459│ │ ├── cover.jpg
460│ │ ├── cover.png
461│ │ ├── __init__.py
462│ │ ├── licenses.json
463│ │ ├── sample.flac
464│ │ ├── test_activity.py
465│ │ ├── test_api.py
466│ │ ├── test_commands.py
467│ │ ├── test_filters.py
468│ │ ├── test_import.py
469│ │ ├── test_licenses.py
470│ │ ├── test_lyrics.py
471│ │ ├── test_metadata.py
472│ │ ├── test_models.py
473│ │ ├── test.mp3
474│ │ ├── test_music.py
475│ │ ├── test_mutations.py
476│ │ ├── test.ogg
477│ │ ├── test.opus
478│ │ ├── test_serializers.py
479│ │ ├── test_spa_views.py
480│ │ ├── test_tasks.py
481│ │ ├── test_theora.ogg
482│ │ ├── test_utils.py
483│ │ ├── test_views.py
484│ │ ├── test_works.py
485│ │ ├── with_cover.ogg
486│ │ └── with_other_picture.mp3
487│ ├── musicbrainz
488│ │ ├── conftest.py
489│ │ ├── __init__.py
490│ │ ├── test_api.py
491│ │ └── test_cache.py
492│ ├── playlists
493│ │ ├── __init__.py
494│ │ ├── test_models.py
495│ │ ├── test_serializers.py
496│ │ └── test_views.py
497│ ├── radios
498│ │ ├── __init__.py
499│ │ ├── test_api.py
500│ │ └── test_radios.py
501│ ├── subsonic
502│ │ ├── test_authentication.py
503│ │ ├── test_renderers.py
504│ │ ├── test_serializers.py
505│ │ └── test_views.py
506│ ├── test_import_audio_file.py
507│ ├── test_jwt_querystring.py
508│ ├── test_tasks.py
509│ └── users
510│ ├── __init__.py
511│ ├── test_activity.py
512│ ├── test_admin.py
513│ ├── test_jwt.py
514│ ├── test_ldap.py
515│ ├── test_middleware.py
516│ ├── test_models.py
517│ ├── test_permissions.py
518│ └── test_views.py
519├── archi.txt
520├── CHANGELOG
521├── changes
522│ ├── changelog.d
523│ │ ├── 356.bugfix
524│ │ ├── 356.feature
525│ │ ├── 578.enhancement
526│ │ ├── 701.feature
527│ │ ├── 702.bugfix
528│ │ ├── 715.enhancement
529│ │ ├── 722.bugfix
530│ │ ├── 725.enhancement
531│ │ ├── buttons.enhancement
532│ │ ├── embed-wizard.enhancement
533│ │ ├── similar-radio.enhancement
534│ │ └── system-actor.enhancement
535│ ├── __init__.py
536│ ├── notes.rst
537│ └── template.rst
538├── CONTRIBUTING.rst
539├── CONTRIBUTORS.txt
540├── demo
541│ ├── env.sample
542│ ├── README.md
543│ └── setup.sh
544├── deploy
545│ ├── apache.conf
546│ ├── docker-compose.yml
547│ ├── docker.funkwhale_proxy.conf
548│ ├── docker.nginx.template
549│ ├── docker.proxy.template
550│ ├── env.prod.sample
551│ ├── FreeBSD
552│ │ ├── funkwhale_beat
553│ │ ├── funkwhale_server
554│ │ ├── funkwhale_worker
555│ │ └── README.md
556│ ├── funkwhale-beat.service
557│ ├── funkwhale_proxy.conf
558│ ├── funkwhale-server.service
559│ ├── funkwhale.target
560│ ├── funkwhale-worker.service
561│ └── nginx.template
562├── dev.yml
563├── docker
564│ ├── nginx
565│ │ ├── conf.dev
566│ │ └── entrypoint.sh
567│ ├── ssl
568│ │ ├── test.crt
569│ │ └── test.key
570│ ├── traefik.toml
571│ └── traefik.yml
572├── docs
573│ ├── api.rst
574│ ├── architecture.rst
575│ ├── build_docs.sh
576│ ├── build_swagger.sh
577│ ├── changelog.rst
578│ ├── configuration.rst
579│ ├── conf.py
580│ ├── contributing.rst
581│ ├── developers
582│ │ ├── index.rst
583│ │ └── subsonic.rst
584│ ├── Dockerfile
585│ ├── features.rst
586│ ├── federation
587│ │ └── index.rst
588│ ├── importing-music.rst
589│ ├── index.rst
590│ ├── installation
591│ │ ├── debian.rst
592│ │ ├── docker.rst
593│ │ ├── external_dependencies.rst
594│ │ ├── index.rst
595│ │ ├── ldap.rst
596│ │ ├── non_amd64_architectures.rst
597│ │ ├── optimization.rst
598│ │ └── systemd.rst
599│ ├── Makefile
600│ ├── serve.py
601│ ├── swagger.yml
602│ ├── third-party.rst
603│ ├── translators.rst
604│ ├── troubleshooting.rst
605│ ├── upgrading
606│ │ ├── 0.17.rst
607│ │ └── index.rst
608│ └── users
609│ ├── apps.rst
610│ ├── django.rst
611│ ├── index.rst
612│ ├── tagging.rst
613│ └── upload.rst
614├── front
615│ ├── babel.config.js
616│ ├── Dockerfile
617│ ├── locales
618│ │ ├── app.pot
619│ │ ├── ar
620│ │ │ └── LC_MESSAGES
621│ │ │ └── app.po
622│ │ ├── de
623│ │ │ └── LC_MESSAGES
624│ │ │ └── app.po
625│ │ ├── eo
626│ │ │ └── LC_MESSAGES
627│ │ │ └── app.po
628│ │ ├── es
629│ │ │ └── LC_MESSAGES
630│ │ │ └── app.po
631│ │ ├── eu
632│ │ │ └── LC_MESSAGES
633│ │ │ └── app.po
634│ │ ├── fr_FR
635│ │ │ └── LC_MESSAGES
636│ │ │ └── app.po
637│ │ ├── gl
638│ │ │ └── LC_MESSAGES
639│ │ │ └── app.po
640│ │ ├── it
641│ │ │ └── LC_MESSAGES
642│ │ │ └── app.po
643│ │ ├── nb_NO
644│ │ │ └── LC_MESSAGES
645│ │ │ └── app.po
646│ │ ├── nl
647│ │ │ └── LC_MESSAGES
648│ │ │ └── app.po
649│ │ ├── oc
650│ │ │ └── LC_MESSAGES
651│ │ │ └── app.po
652│ │ ├── pl
653│ │ │ └── LC_MESSAGES
654│ │ │ └── app.po
655│ │ ├── pt_BR
656│ │ │ └── LC_MESSAGES
657│ │ │ └── app.po
658│ │ ├── pt_PT
659│ │ │ └── LC_MESSAGES
660│ │ │ └── app.po
661│ │ ├── ru
662│ │ │ └── LC_MESSAGES
663│ │ │ └── app.po
664│ │ └── sv
665│ │ └── LC_MESSAGES
666│ │ └── app.po
667│ ├── package.json
668│ ├── public
669│ │ ├── custom.css
670│ │ ├── embed.html
671│ │ ├── favicon.png
672│ │ ├── index.html
673│ │ └── settings.json
674│ ├── scripts
675│ │ ├── i18n-compile.sh
676│ │ ├── i18n-extract.sh
677│ │ └── i18n-weblate-to-origin.sh
678│ ├── src
679│ │ ├── App.vue
680│ │ ├── assets
681│ │ │ ├── audio
682│ │ │ │ └── default-cover.png
683│ │ │ ├── embed
684│ │ │ │ └── default-cover.jpeg
685│ │ │ └── logo
686│ │ │ ├── License.md
687│ │ │ ├── logo-full-500.png
688│ │ │ ├── logo-full.png
689│ │ │ ├── logo.png
690│ │ │ ├── logos.png
691│ │ │ ├── logo.svg
692│ │ │ └── logo-with-text.svg
693│ │ ├── audio
694│ │ │ ├── backend.js
695│ │ │ └── formats.js
696│ │ ├── components
697│ │ │ ├── About.vue
698│ │ │ ├── admin
699│ │ │ │ └── SettingsGroup.vue
700│ │ │ ├── audio
701│ │ │ │ ├── album
702│ │ │ │ │ ├── Card.vue
703│ │ │ │ │ └── Widget.vue
704│ │ │ │ ├── artist
705│ │ │ │ │ └── Card.vue
706│ │ │ │ ├── EmbedWizard.vue
707│ │ │ │ ├── PlayButton.vue
708│ │ │ │ ├── Player.vue
709│ │ │ │ ├── SearchBar.vue
710│ │ │ │ ├── Search.vue
711│ │ │ │ ├── track
712│ │ │ │ │ ├── Row.vue
713│ │ │ │ │ ├── Table.vue
714│ │ │ │ │ └── Widget.vue
715│ │ │ │ └── Track.vue
716│ │ │ ├── auth
717│ │ │ │ ├── Login.vue
718│ │ │ │ ├── Logout.vue
719│ │ │ │ ├── Profile.vue
720│ │ │ │ ├── Settings.vue
721│ │ │ │ ├── Signup.vue
722│ │ │ │ └── SubsonicTokenForm.vue
723│ │ │ ├── common
724│ │ │ │ ├── ActionTable.vue
725│ │ │ │ ├── ActorAvatar.vue
726│ │ │ │ ├── ActorLink.vue
727│ │ │ │ ├── AjaxButton.vue
728│ │ │ │ ├── CopyInput.vue
729│ │ │ │ ├── DangerousButton.vue
730│ │ │ │ ├── Duration.vue
731│ │ │ │ ├── EmptyState.vue
732│ │ │ │ ├── HumanDate.vue
733│ │ │ │ ├── Message.vue
734│ │ │ │ ├── Tooltip.vue
735│ │ │ │ ├── UserLink.vue
736│ │ │ │ └── Username.vue
737│ │ │ ├── favorites
738│ │ │ │ ├── List.vue
739│ │ │ │ └── TrackFavoriteIcon.vue
740│ │ │ ├── federation
741│ │ │ │ └── LibraryWidget.vue
742│ │ │ ├── Footer.vue
743│ │ │ ├── forms
744│ │ │ │ └── PasswordInput.vue
745│ │ │ ├── globals.js
746│ │ │ ├── Home.vue
747│ │ │ ├── instance
748│ │ │ │ └── Stats.vue
749│ │ │ ├── library
750│ │ │ │ ├── Albums.vue
751│ │ │ │ ├── Album.vue
752│ │ │ │ ├── Artists.vue
753│ │ │ │ ├── Artist.vue
754│ │ │ │ ├── EditCard.vue
755│ │ │ │ ├── EditDetail.vue
756│ │ │ │ ├── EditForm.vue
757│ │ │ │ ├── EditList.vue
758│ │ │ │ ├── FileUpload.vue
759│ │ │ │ ├── FileUploadWidget.vue
760│ │ │ │ ├── Home.vue
761│ │ │ │ ├── Library.vue
762│ │ │ │ ├── radios
763│ │ │ │ │ ├── Builder.vue
764│ │ │ │ │ └── Filter.vue
765│ │ │ │ ├── Radios.vue
766│ │ │ │ ├── TrackBase.vue
767│ │ │ │ ├── TrackDetail.vue
768│ │ │ │ └── TrackEdit.vue
769│ │ │ ├── Logo.vue
770│ │ │ ├── manage
771│ │ │ │ ├── library
772│ │ │ │ │ └── EditsCardList.vue
773│ │ │ │ ├── moderation
774│ │ │ │ │ ├── AccountsTable.vue
775│ │ │ │ │ ├── DomainsTable.vue
776│ │ │ │ │ ├── InstancePolicyCard.vue
777│ │ │ │ │ └── InstancePolicyForm.vue
778│ │ │ │ └── users
779│ │ │ │ ├── InvitationForm.vue
780│ │ │ │ ├── InvitationsTable.vue
781│ │ │ │ └── UsersTable.vue
782│ │ │ ├── metadata
783│ │ │ │ ├── ArtistCard.vue
784│ │ │ │ ├── CardMixin.vue
785│ │ │ │ ├── ReleaseCard.vue
786│ │ │ │ └── Search.vue
787│ │ │ ├── mixins
788│ │ │ │ ├── Ordering.vue
789│ │ │ │ ├── Pagination.vue
790│ │ │ │ ├── SmartSearch.vue
791│ │ │ │ └── Translations.vue
792│ │ │ ├── moderation
793│ │ │ │ └── FilterModal.vue
794│ │ │ ├── notifications
795│ │ │ │ └── NotificationRow.vue
796│ │ │ ├── PageNotFound.vue
797│ │ │ ├── Pagination.vue
798│ │ │ ├── playlists
799│ │ │ │ ├── CardList.vue
800│ │ │ │ ├── Card.vue
801│ │ │ │ ├── Editor.vue
802│ │ │ │ ├── Form.vue
803│ │ │ │ ├── PlaylistModal.vue
804│ │ │ │ ├── TrackPlaylistIcon.vue
805│ │ │ │ └── Widget.vue
806│ │ │ ├── radios
807│ │ │ │ ├── Button.vue
808│ │ │ │ └── Card.vue
809│ │ │ ├── semantic
810│ │ │ │ └── Modal.vue
811│ │ │ ├── ServiceMessages.vue
812│ │ │ ├── SetInstanceModal.vue
813│ │ │ ├── ShortcutsModal.vue
814│ │ │ ├── Sidebar.vue
815│ │ │ └── utils
816│ │ │ └── global-events.vue
817│ │ ├── edits.js
818│ │ ├── EmbedFrame.vue
819│ │ ├── embed.js
820│ │ ├── filters.js
821│ │ ├── locales.js
822│ │ ├── lodash.js
823│ │ ├── logging.js
824│ │ ├── main.js
825│ │ ├── router
826│ │ │ └── index.js
827│ │ ├── search.js
828│ │ ├── semantic.js
829│ │ ├── store
830│ │ │ ├── auth.js
831│ │ │ ├── favorites.js
832│ │ │ ├── index.js
833│ │ │ ├── instance.js
834│ │ │ ├── moderation.js
835│ │ │ ├── player.js
836│ │ │ ├── playlists.js
837│ │ │ ├── queue.js
838│ │ │ ├── radios.js
839│ │ │ └── ui.js
840│ │ ├── style
841│ │ │ ├── _main.scss
842│ │ │ ├── _site.scss
843│ │ │ └── vendor
844│ │ │ └── _media.scss
845│ │ ├── utils
846│ │ │ ├── color.js
847│ │ │ ├── time.js
848│ │ │ └── url.js
849│ │ ├── vendor
850│ │ │ └── color-thief.js
851│ │ └── views
852│ │ ├── admin
853│ │ │ ├── library
854│ │ │ │ ├── Base.vue
855│ │ │ │ └── EditsList.vue
856│ │ │ ├── moderation
857│ │ │ │ ├── AccountsDetail.vue
858│ │ │ │ ├── AccountsList.vue
859│ │ │ │ ├── Base.vue
860│ │ │ │ ├── DomainsDetail.vue
861│ │ │ │ └── DomainsList.vue
862│ │ │ ├── Settings.vue
863│ │ │ └── users
864│ │ │ ├── Base.vue
865│ │ │ ├── InvitationsList.vue
866│ │ │ └── UsersList.vue
867│ │ ├── auth
868│ │ │ ├── EmailConfirm.vue
869│ │ │ ├── PasswordResetConfirm.vue
870│ │ │ └── PasswordReset.vue
871│ │ ├── content
872│ │ │ ├── Base.vue
873│ │ │ ├── Home.vue
874│ │ │ ├── libraries
875│ │ │ │ ├── Card.vue
876│ │ │ │ ├── DetailArea.vue
877│ │ │ │ ├── DetailMixin.vue
878│ │ │ │ ├── Detail.vue
879│ │ │ │ ├── FilesTable.vue
880│ │ │ │ ├── Files.vue
881│ │ │ │ ├── Form.vue
882│ │ │ │ ├── Home.vue
883│ │ │ │ ├── Quota.vue
884│ │ │ │ └── Upload.vue
885│ │ │ └── remote
886│ │ │ ├── Card.vue
887│ │ │ ├── Home.vue
888│ │ │ └── ScanForm.vue
889│ │ ├── Notifications.vue
890│ │ ├── playlists
891│ │ │ ├── Detail.vue
892│ │ │ └── List.vue
893│ │ └── radios
894│ │ └── Detail.vue
895│ ├── stats.json
896│ ├── tests
897│ │ └── unit
898│ │ ├── specs
899│ │ │ ├── components
900│ │ │ │ └── common.spec.js
901│ │ │ ├── filters
902│ │ │ │ └── filters.spec.js
903│ │ │ ├── search.spec.js
904│ │ │ └── store
905│ │ │ ├── auth.spec.js
906│ │ │ ├── favorites.spec.js
907│ │ │ ├── instance.spec.js
908│ │ │ ├── player.spec.js
909│ │ │ ├── playlists.spec.js
910│ │ │ ├── queue.spec.js
911│ │ │ ├── radios.spec.js
912│ │ │ └── ui.spec.js
913│ │ └── utils.js
914│ ├── vue.config.js
915│ └── yarn.lock
916├── LICENSE
917├── pyproject.toml
918├── README.rst
919├── scripts
920│ ├── clean-unused-artifacts.py
921│ └── set-api-build-metadata.sh
922└── TRANSLATORS.rst
923
924179 directories, 742 files
Python modules ¶
ls **/*.py | wc -l
416
-
416 fichiers Python
1api/config/api_urls.py
2api/config/asgi.py
3api/config/__init__.py
4api/config/routing.py
5api/config/settings/common.py
6api/config/settings/__init__.py
7api/config/settings/local.py
8api/config/settings/production.py
9api/config/spa_urls.py
10api/config/urls.py
11api/funkwhale_api/activity/apps.py
12api/funkwhale_api/activity/__init__.py
13api/funkwhale_api/activity/record.py
14api/funkwhale_api/activity/serializers.py
15api/funkwhale_api/activity/utils.py
16api/funkwhale_api/activity/views.py
17api/funkwhale_api/common/admin.py
18api/funkwhale_api/common/apps.py
19api/funkwhale_api/common/authentication.py
20api/funkwhale_api/common/auth.py
21api/funkwhale_api/common/channels.py
22api/funkwhale_api/common/consumers.py
23api/funkwhale_api/common/decorators.py
24api/funkwhale_api/common/dynamic_preferences_registry.py
25api/funkwhale_api/common/factories.py
26api/funkwhale_api/common/fields.py
27api/funkwhale_api/common/filters.py
28api/funkwhale_api/common/__init__.py
29api/funkwhale_api/common/management/commands/__init__.py
30api/funkwhale_api/common/management/commands/makemigrations.py
31api/funkwhale_api/common/management/commands/migrate.py
32api/funkwhale_api/common/management/commands/script.py
33api/funkwhale_api/common/management/__init__.py
34api/funkwhale_api/common/middleware.py
35api/funkwhale_api/common/migrations/0001_initial.py
36api/funkwhale_api/common/migrations/0002_mutation.py
37api/funkwhale_api/common/migrations/__init__.py
38api/funkwhale_api/common/models.py
39api/funkwhale_api/common/mutations.py
40api/funkwhale_api/common/pagination.py
41api/funkwhale_api/common/permissions.py
42api/funkwhale_api/common/preferences.py
43api/funkwhale_api/common/scripts/create_actors.py
44api/funkwhale_api/common/scripts/create_image_variations.py
45api/funkwhale_api/common/scripts/delete_pre_017_federated_uploads.py
46api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py
47api/funkwhale_api/common/scripts/__init__.py
48api/funkwhale_api/common/scripts/migrate_to_user_libraries.py
49api/funkwhale_api/common/scripts/test.py
50api/funkwhale_api/common/search.py
51api/funkwhale_api/common/serializers.py
52api/funkwhale_api/common/session.py
53api/funkwhale_api/common/signals.py
54api/funkwhale_api/common/storage.py
55api/funkwhale_api/common/tasks.py
56api/funkwhale_api/common/utils.py
57api/funkwhale_api/common/validators.py
58api/funkwhale_api/common/views.py
59api/funkwhale_api/contrib/__init__.py
60api/funkwhale_api/contrib/sites/__init__.py
61api/funkwhale_api/contrib/sites/migrations/0001_initial.py
62api/funkwhale_api/contrib/sites/migrations/0002_set_site_domain_and_name.py
63api/funkwhale_api/contrib/sites/migrations/0003_auto_20171214_2205.py
64api/funkwhale_api/contrib/sites/migrations/__init__.py
65api/funkwhale_api/factories.py
66api/funkwhale_api/favorites/activities.py
67api/funkwhale_api/favorites/admin.py
68api/funkwhale_api/favorites/consumers.py
69api/funkwhale_api/favorites/factories.py
70api/funkwhale_api/favorites/filters.py
71api/funkwhale_api/favorites/__init__.py
72api/funkwhale_api/favorites/migrations/0001_initial.py
73api/funkwhale_api/favorites/migrations/__init__.py
74api/funkwhale_api/favorites/models.py
75api/funkwhale_api/favorites/serializers.py
76api/funkwhale_api/favorites/urls.py
77api/funkwhale_api/favorites/views.py
78api/funkwhale_api/federation/activity.py
79api/funkwhale_api/federation/actors.py
80api/funkwhale_api/federation/admin.py
81api/funkwhale_api/federation/api_serializers.py
82api/funkwhale_api/federation/api_urls.py
83api/funkwhale_api/federation/api_views.py
84api/funkwhale_api/federation/authentication.py
85api/funkwhale_api/federation/dynamic_preferences_registry.py
86api/funkwhale_api/federation/exceptions.py
87api/funkwhale_api/federation/factories.py
88api/funkwhale_api/federation/fields.py
89api/funkwhale_api/federation/filters.py
90api/funkwhale_api/federation/__init__.py
91api/funkwhale_api/federation/keys.py
92api/funkwhale_api/federation/library.py
93api/funkwhale_api/federation/management/commands/fix_federation_ids.py
94api/funkwhale_api/federation/management/commands/__init__.py
95api/funkwhale_api/federation/management/__init__.py
96api/funkwhale_api/federation/migrations/0001_initial.py
97api/funkwhale_api/federation/migrations/0002_auto_20180403_1620.py
98api/funkwhale_api/federation/migrations/0003_auto_20180407_1010.py
99api/funkwhale_api/federation/migrations/0004_auto_20180410_2025.py
100api/funkwhale_api/federation/migrations/0005_auto_20180413_1723.py
101api/funkwhale_api/federation/migrations/0006_auto_20180521_1702.py
102api/funkwhale_api/federation/migrations/0007_auto_20180807_1748.py
103api/funkwhale_api/federation/migrations/0008_auto_20180807_1748.py
104api/funkwhale_api/federation/migrations/0009_auto_20180822_1956.py
105api/funkwhale_api/federation/migrations/0010_auto_20180904_2011.py
106api/funkwhale_api/federation/migrations/0011_auto_20180910_1902.py
107api/funkwhale_api/federation/migrations/0012_auto_20180920_1803.py
108api/funkwhale_api/federation/migrations/0013_auto_20181226_1935.py
109api/funkwhale_api/federation/migrations/0014_auto_20181205_0958.py
110api/funkwhale_api/federation/migrations/0015_populate_domains.py
111api/funkwhale_api/federation/migrations/0016_auto_20181227_1605.py
112api/funkwhale_api/federation/migrations/0017_auto_20190130_0926.py
113api/funkwhale_api/federation/migrations/__init__.py
114api/funkwhale_api/federation/models.py
115api/funkwhale_api/federation/parsers.py
116api/funkwhale_api/federation/renderers.py
117api/funkwhale_api/federation/routes.py
118api/funkwhale_api/federation/serializers.py
119api/funkwhale_api/federation/signing.py
120api/funkwhale_api/federation/tasks.py
121api/funkwhale_api/federation/urls.py
122api/funkwhale_api/federation/utils.py
123api/funkwhale_api/federation/views.py
124api/funkwhale_api/federation/webfinger.py
125api/funkwhale_api/history/activities.py
126api/funkwhale_api/history/admin.py
127api/funkwhale_api/history/factories.py
128api/funkwhale_api/history/filters.py
129api/funkwhale_api/history/__init__.py
130api/funkwhale_api/history/migrations/0001_initial.py
131api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py
132api/funkwhale_api/history/migrations/__init__.py
133api/funkwhale_api/history/models.py
134api/funkwhale_api/history/serializers.py
135api/funkwhale_api/history/urls.py
136api/funkwhale_api/history/views.py
137api/funkwhale_api/__init__.py
138api/funkwhale_api/instance/consumers.py
139api/funkwhale_api/instance/dynamic_preferences_registry.py
140api/funkwhale_api/instance/__init__.py
141api/funkwhale_api/instance/nodeinfo.py
142api/funkwhale_api/instance/stats.py
143api/funkwhale_api/instance/urls.py
144api/funkwhale_api/instance/views.py
145api/funkwhale_api/manage/filters.py
146api/funkwhale_api/manage/__init__.py
147api/funkwhale_api/manage/serializers.py
148api/funkwhale_api/manage/urls.py
149api/funkwhale_api/manage/views.py
150api/funkwhale_api/moderation/admin.py
151api/funkwhale_api/moderation/factories.py
152api/funkwhale_api/moderation/filters.py
153api/funkwhale_api/moderation/__init__.py
154api/funkwhale_api/moderation/migrations/0001_initial.py
155api/funkwhale_api/moderation/migrations/0002_auto_20190213_0927.py
156api/funkwhale_api/moderation/migrations/__init__.py
157api/funkwhale_api/moderation/models.py
158api/funkwhale_api/moderation/serializers.py
159api/funkwhale_api/moderation/urls.py
160api/funkwhale_api/moderation/views.py
161api/funkwhale_api/music/admin.py
162api/funkwhale_api/musicbrainz/client.py
163api/funkwhale_api/musicbrainz/__init__.py
164api/funkwhale_api/musicbrainz/urls.py
165api/funkwhale_api/musicbrainz/views.py
166api/funkwhale_api/music/dynamic_preferences_registry.py
167api/funkwhale_api/music/factories.py
168api/funkwhale_api/music/fake_data.py
169api/funkwhale_api/music/filters.py
170api/funkwhale_api/music/importers.py
171api/funkwhale_api/music/__init__.py
172api/funkwhale_api/music/licenses.py
173api/funkwhale_api/music/lyrics.py
174api/funkwhale_api/music/management/commands/check_licenses.py
175api/funkwhale_api/music/management/commands/fix_uploads.py
176api/funkwhale_api/music/management/commands/import_files.py
177api/funkwhale_api/music/management/commands/__init__.py
178api/funkwhale_api/music/management/__init__.py
179api/funkwhale_api/music/metadata.py
180api/funkwhale_api/music/migrations/0001_initial.py
181api/funkwhale_api/music/migrations/0002_auto_20151215_1645.py
182api/funkwhale_api/music/migrations/0003_auto_20151222_2233.py
183api/funkwhale_api/music/migrations/0004_track_tags.py
184api/funkwhale_api/music/migrations/0005_deduplicate.py
185api/funkwhale_api/music/migrations/0006_unique_mbid.py
186api/funkwhale_api/music/migrations/0007_track_position.py
187api/funkwhale_api/music/migrations/0008_auto_20160529_1456.py
188api/funkwhale_api/music/migrations/0009_auto_20160920_1614.py
189api/funkwhale_api/music/migrations/0010_auto_20160920_1742.py
190api/funkwhale_api/music/migrations/0011_rename_files.py
191api/funkwhale_api/music/migrations/0012_auto_20161122_1905.py
192api/funkwhale_api/music/migrations/0013_auto_20171213_2211.py
193api/funkwhale_api/music/migrations/0014_importjob_track_file.py
194api/funkwhale_api/music/migrations/0015_bind_track_file_to_import_job.py
195api/funkwhale_api/music/migrations/0016_trackfile_acoustid_track_id.py
196api/funkwhale_api/music/migrations/0017_auto_20171227_1728.py
197api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py
198api/funkwhale_api/music/migrations/0019_populate_mimetypes.py
199api/funkwhale_api/music/migrations/0020_importbatch_status.py
200api/funkwhale_api/music/migrations/0021_populate_batch_status.py
201api/funkwhale_api/music/migrations/0022_importbatch_import_request.py
202api/funkwhale_api/music/migrations/0023_auto_20180407_1010.py
203api/funkwhale_api/music/migrations/0024_populate_uuid.py
204api/funkwhale_api/music/migrations/0025_auto_20180419_2023.py
205api/funkwhale_api/music/migrations/0026_trackfile_accessed_date.py
206api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py
207api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py
208api/funkwhale_api/music/migrations/0029_auto_20180807_1748.py
209api/funkwhale_api/music/migrations/0030_auto_20180825_1411.py
210api/funkwhale_api/music/migrations/0031_auto_20180914_2007.py
211api/funkwhale_api/music/migrations/0032_track_file_to_upload.py
212api/funkwhale_api/music/migrations/0033_auto_20181023_1837.py
213api/funkwhale_api/music/migrations/0034_auto_20181127_0325.py
214api/funkwhale_api/music/migrations/0035_auto_20181203_1515.py
215api/funkwhale_api/music/migrations/0036_track_disc_number.py
216api/funkwhale_api/music/migrations/0037_auto_20190103_1757.py
217api/funkwhale_api/music/migrations/__init__.py
218api/funkwhale_api/music/models.py
219api/funkwhale_api/music/mutations.py
220api/funkwhale_api/music/serializers.py
221api/funkwhale_api/music/signals.py
222api/funkwhale_api/music/spa_views.py
223api/funkwhale_api/music/tasks.py
224api/funkwhale_api/music/utils.py
225api/funkwhale_api/music/views.py
226api/funkwhale_api/playlists/admin.py
227api/funkwhale_api/playlists/dynamic_preferences_registry.py
228api/funkwhale_api/playlists/factories.py
229api/funkwhale_api/playlists/filters.py
230api/funkwhale_api/playlists/__init__.py
231api/funkwhale_api/playlists/migrations/0001_initial.py
232api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py
233api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py
234api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py
235api/funkwhale_api/playlists/migrations/__init__.py
236api/funkwhale_api/playlists/models.py
237api/funkwhale_api/playlists/serializers.py
238api/funkwhale_api/playlists/views.py
239api/funkwhale_api/providers/__init__.py
240api/funkwhale_api/providers/urls.py
241api/funkwhale_api/radios/admin.py
242api/funkwhale_api/radios/factories.py
243api/funkwhale_api/radios/filtersets.py
244api/funkwhale_api/radios/filters.py
245api/funkwhale_api/radios/__init__.py
246api/funkwhale_api/radios/migrations/0001_initial.py
247api/funkwhale_api/radios/migrations/0002_radiosession_session_key.py
248api/funkwhale_api/radios/migrations/0003_auto_20160521_1708.py
249api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py
250api/funkwhale_api/radios/migrations/__init__.py
251api/funkwhale_api/radios/models.py
252api/funkwhale_api/radios/radios.py
253api/funkwhale_api/radios/registries.py
254api/funkwhale_api/radios/serializers.py
255api/funkwhale_api/radios/urls.py
256api/funkwhale_api/radios/views.py
257api/funkwhale_api/requests/__init__.py
258api/funkwhale_api/requests/migrations/0001_initial.py
259api/funkwhale_api/requests/migrations/__init__.py
260api/funkwhale_api/requests/models.py
261api/funkwhale_api/subsonic/authentication.py
262api/funkwhale_api/subsonic/dynamic_preferences_registry.py
263api/funkwhale_api/subsonic/filters.py
264api/funkwhale_api/subsonic/__init__.py
265api/funkwhale_api/subsonic/negotiation.py
266api/funkwhale_api/subsonic/renderers.py
267api/funkwhale_api/subsonic/serializers.py
268api/funkwhale_api/subsonic/views.py
269api/funkwhale_api/taskapp/celery.py
270api/funkwhale_api/taskapp/__init__.py
271api/funkwhale_api/users/adapters.py
272api/funkwhale_api/users/admin.py
273api/funkwhale_api/users/api_urls.py
274api/funkwhale_api/users/auth_backends.py
275api/funkwhale_api/users/dynamic_preferences_registry.py
276api/funkwhale_api/users/factories.py
277api/funkwhale_api/users/__init__.py
278api/funkwhale_api/users/middleware.py
279api/funkwhale_api/users/migrations/0001_initial.py
280api/funkwhale_api/users/migrations/0002_auto_20171214_2205.py
281api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py
282api/funkwhale_api/users/migrations/0004_user_privacy_level.py
283api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py
284api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py
285api/funkwhale_api/users/migrations/0007_auto_20180524_2009.py
286api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py
287api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
288api/funkwhale_api/users/migrations/0010_user_avatar.py
289api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py
290api/funkwhale_api/users/migrations/0012_user_upload_quota.py
291api/funkwhale_api/users/migrations/0013_auto_20181206_1008.py
292api/funkwhale_api/users/migrations/__init__.py
293api/funkwhale_api/users/models.py
294api/funkwhale_api/users/permissions.py
295api/funkwhale_api/users/rest_auth_urls.py
296api/funkwhale_api/users/serializers.py
297api/funkwhale_api/users/views.py
298api/manage.py
299api/tests/activity/__init__.py
300api/tests/activity/test_record.py
301api/tests/activity/test_serializers.py
302api/tests/activity/test_utils.py
303api/tests/activity/test_views.py
304api/tests/channels/__init__.py
305api/tests/channels/test_auth.py
306api/tests/channels/test_consumers.py
307api/tests/common/__init__.py
308api/tests/common/test_decorators.py
309api/tests/common/test_fields.py
310api/tests/common/test_filters.py
311api/tests/common/test_middleware.py
312api/tests/common/test_models.py
313api/tests/common/test_mutations.py
314api/tests/common/test_permissions.py
315api/tests/common/test_preferences.py
316api/tests/common/test_scripts.py
317api/tests/common/test_search.py
318api/tests/common/test_serializers.py
319api/tests/common/test_session.py
320api/tests/common/test_tasks.py
321api/tests/common/test_utils.py
322api/tests/common/test_views.py
323api/tests/conftest.py
324api/tests/data/youtube.py
325api/tests/favorites/__init__.py
326api/tests/favorites/test_activity.py
327api/tests/favorites/test_favorites.py
328api/tests/favorites/test_filters.py
329api/tests/favorites/test_views.py
330api/tests/federation/__init__.py
331api/tests/federation/test_activity.py
332api/tests/federation/test_actors.py
333api/tests/federation/test_api_filters.py
334api/tests/federation/test_api_serializers.py
335api/tests/federation/test_api_views.py
336api/tests/federation/test_authentication.py
337api/tests/federation/test_commands.py
338api/tests/federation/test_keys.py
339api/tests/federation/test_migrations.py
340api/tests/federation/test_models.py
341api/tests/federation/test_routes.py
342api/tests/federation/test_serializers.py
343api/tests/federation/test_signing.py
344api/tests/federation/test_tasks.py
345api/tests/federation/test_utils.py
346api/tests/federation/test_views.py
347api/tests/federation/test_webfinger.py
348api/tests/history/__init__.py
349api/tests/history/test_activity.py
350api/tests/history/test_filters.py
351api/tests/history/test_history.py
352api/tests/history/test_views.py
353api/tests/__init__.py
354api/tests/instance/__init__.py
355api/tests/instance/test_nodeinfo.py
356api/tests/instance/test_preferences.py
357api/tests/instance/test_stats.py
358api/tests/instance/test_views.py
359api/tests/manage/__init__.py
360api/tests/manage/test_filters.py
361api/tests/manage/test_serializers.py
362api/tests/manage/test_views.py
363api/tests/moderation/__init__.py
364api/tests/moderation/test_filters.py
365api/tests/moderation/test_serializers.py
366api/tests/moderation/test_views.py
367api/tests/musicbrainz/conftest.py
368api/tests/musicbrainz/__init__.py
369api/tests/musicbrainz/test_api.py
370api/tests/musicbrainz/test_cache.py
371api/tests/music/conftest.py
372api/tests/music/__init__.py
373api/tests/music/test_activity.py
374api/tests/music/test_api.py
375api/tests/music/test_commands.py
376api/tests/music/test_filters.py
377api/tests/music/test_import.py
378api/tests/music/test_licenses.py
379api/tests/music/test_lyrics.py
380api/tests/music/test_metadata.py
381api/tests/music/test_models.py
382api/tests/music/test_music.py
383api/tests/music/test_mutations.py
384api/tests/music/test_serializers.py
385api/tests/music/test_spa_views.py
386api/tests/music/test_tasks.py
387api/tests/music/test_utils.py
388api/tests/music/test_views.py
389api/tests/music/test_works.py
390api/tests/playlists/__init__.py
391api/tests/playlists/test_models.py
392api/tests/playlists/test_serializers.py
393api/tests/playlists/test_views.py
394api/tests/radios/__init__.py
395api/tests/radios/test_api.py
396api/tests/radios/test_radios.py
397api/tests/subsonic/test_authentication.py
398api/tests/subsonic/test_renderers.py
399api/tests/subsonic/test_serializers.py
400api/tests/subsonic/test_views.py
401api/tests/test_import_audio_file.py
402api/tests/test_jwt_querystring.py
403api/tests/test_tasks.py
404api/tests/users/__init__.py
405api/tests/users/test_activity.py
406api/tests/users/test_admin.py
407api/tests/users/test_jwt.py
408api/tests/users/test_ldap.py
409api/tests/users/test_middleware.py
410api/tests/users/test_models.py
411api/tests/users/test_permissions.py
412api/tests/users/test_views.py
413changes/__init__.py
414docs/conf.py
415docs/serve.py
416scripts/clean-unused-artifacts.py
Django Models ¶
ls **/models.py | wc -l
10
api/funkwhale_api/common/models.py
api/funkwhale_api/favorites/models.py
api/funkwhale_api/federation/models.py
api/funkwhale_api/history/models.py
api/funkwhale_api/moderation/models.py
api/funkwhale_api/music/models.py
api/funkwhale_api/playlists/models.py
api/funkwhale_api/radios/models.py
api/funkwhale_api/requests/models.py
api/funkwhale_api/users/models.py
api/funkwhale_api/common/models.py ¶
1import uuid
2
3from django.conf import settings
4from django.contrib.contenttypes.fields import GenericForeignKey
5from django.contrib.contenttypes.models import ContentType
6from django.contrib.postgres.fields import JSONField
7from django.db import models
8from django.db import transaction
9from django.urls import reverse
10from django.utils import timezone
11from funkwhale_api.federation import utils as federation_utils
12
13
14class LocalFromFidQuerySet:
15 def local(self, include=True):
16 host = settings.FEDERATION_HOSTNAME
17 query = models.Q(fid__startswith="http://{}/".format(host)) | models.Q(
18 fid__startswith="https://{}/".format(host)
19 )
20 if include:
21 return self.filter(query)
22 else:
23 return self.filter(~query)
24
25
26class MutationQuerySet(models.QuerySet):
27 def get_for_target(self, target):
28 content_type = ContentType.objects.get_for_model(target)
29 return self.filter(target_content_type=content_type, target_id=target.pk)
30
31
32class Mutation(models.Model):
33 fid = models.URLField(unique=True, max_length=500, db_index=True)
34 uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
35 created_by = models.ForeignKey(
36 "federation.Actor",
37 related_name="created_mutations",
38 on_delete=models.SET_NULL,
39 null=True,
40 blank=True,
41 )
42 approved_by = models.ForeignKey(
43 "federation.Actor",
44 related_name="approved_mutations",
45 on_delete=models.SET_NULL,
46 null=True,
47 blank=True,
48 )
49
50 type = models.CharField(max_length=100, db_index=True)
51 # None = no choice, True = approved, False = refused
52 is_approved = models.NullBooleanField(default=None)
53
54 # None = not applied, True = applied, False = failed
55 is_applied = models.NullBooleanField(default=None)
56 creation_date = models.DateTimeField(default=timezone.now, db_index=True)
57 applied_date = models.DateTimeField(null=True, blank=True, db_index=True)
58 summary = models.TextField(max_length=2000, null=True, blank=True)
59
60 payload = JSONField()
61 previous_state = JSONField(null=True, default=None)
62
63 target_id = models.IntegerField(null=True)
64 target_content_type = models.ForeignKey(
65 ContentType,
66 null=True,
67 on_delete=models.CASCADE,
68 related_name="targeting_mutations",
69 )
70 target = GenericForeignKey("target_content_type", "target_id")
71
72 objects = MutationQuerySet.as_manager()
73
74 def get_federation_id(self):
75 if self.fid:
76 return self.fid
77
78 return federation_utils.full_url(
79 reverse("federation:edits-detail", kwargs={"uuid": self.uuid})
80 )
81
82 def save(self, **kwargs):
83 if not self.pk and not self.fid:
84 self.fid = self.get_federation_id()
85
86 return super().save(**kwargs)
87
88 @transaction.atomic
89 def apply(self):
90 from . import mutations
91
92 if self.is_applied:
93 raise ValueError("Mutation was already applied")
94
95 previous_state = mutations.registry.apply(
96 type=self.type, obj=self.target, payload=self.payload
97 )
98 self.previous_state = previous_state
99 self.is_applied = True
100 self.applied_date = timezone.now()
101 self.save(update_fields=["is_applied", "applied_date", "previous_state"])
102 return previous_state
api/funkwhale_api/favorites/models.py ¶
1from django.db import models
2from django.utils import timezone
3from funkwhale_api.music.models import Track
4
5
6class TrackFavorite(models.Model):
7 creation_date = models.DateTimeField(default=timezone.now)
8 user = models.ForeignKey(
9 "users.User", related_name="track_favorites", on_delete=models.CASCADE
10 )
11 track = models.ForeignKey(
12 Track, related_name="track_favorites", on_delete=models.CASCADE
13 )
14
15 class Meta:
16 unique_together = ("track", "user")
17 ordering = ("-creation_date",)
18
19 @classmethod
20 def add(cls, track, user):
21 favorite, created = cls.objects.get_or_create(user=user, track=track)
22 return favorite
23
24 def get_activity_url(self):
25 return "{}/favorites/tracks/{}".format(self.user.get_activity_url(), self.pk)
api/funkwhale_api/federation/models.py ¶
1import tempfile
2import uuid
3
4from django.conf import settings
5from django.contrib.contenttypes.fields import GenericForeignKey
6from django.contrib.contenttypes.models import ContentType
7from django.contrib.postgres.fields import JSONField
8from django.core.exceptions import ObjectDoesNotExist
9from django.core.serializers.json import DjangoJSONEncoder
10from django.db import models
11from django.urls import reverse
12from django.utils import timezone
13from funkwhale_api.common import session
14from funkwhale_api.common import utils as common_utils
15from funkwhale_api.common import validators as common_validators
16from funkwhale_api.music import utils as music_utils
17
18from . import utils as federation_utils
19
20TYPE_CHOICES = [
21 ("Person", "Person"),
22 ("Application", "Application"),
23 ("Group", "Group"),
24 ("Organization", "Organization"),
25 ("Service", "Service"),
26]
27
28
29def empty_dict():
30 return {}
31
32
33def get_shared_inbox_url():
34 return federation_utils.full_url(reverse("federation:shared-inbox"))
35
36
37class FederationMixin(models.Model):
38 # federation id/url
39 fid = models.URLField(unique=True, max_length=500, db_index=True)
40 url = models.URLField(max_length=500, null=True, blank=True)
41
42 class Meta:
43 abstract = True
44
45
46class ActorQuerySet(models.QuerySet):
47 def local(self, include=True):
48 if include:
49 return self.filter(domain__name=settings.FEDERATION_HOSTNAME)
50 return self.exclude(domain__name=settings.FEDERATION_HOSTNAME)
51
52 def with_current_usage(self):
53 qs = self
54 for s in ["pending", "skipped", "errored", "finished"]:
55 qs = qs.annotate(
56 **{
57 "_usage_{}".format(s): models.Sum(
58 "libraries__uploads__size",
59 filter=models.Q(libraries__uploads__import_status=s),
60 )
61 }
62 )
63
64 return qs
65
66 def with_uploads_count(self):
67 return self.annotate(
68 uploads_count=models.Count("libraries__uploads", distinct=True)
69 )
70
71
72class DomainQuerySet(models.QuerySet):
73 def external(self):
74 return self.exclude(pk=settings.FEDERATION_HOSTNAME)
75
76 def with_actors_count(self):
77 return self.annotate(actors_count=models.Count("actors", distinct=True))
78
79 def with_outbox_activities_count(self):
80 return self.annotate(
81 outbox_activities_count=models.Count(
82 "actors__outbox_activities", distinct=True
83 )
84 )
85
86
87class Domain(models.Model):
88 name = models.CharField(
89 primary_key=True,
90 max_length=255,
91 validators=[common_validators.DomainValidator()],
92 )
93 creation_date = models.DateTimeField(default=timezone.now)
94 nodeinfo_fetch_date = models.DateTimeField(default=None, null=True, blank=True)
95 nodeinfo = JSONField(default=empty_dict, max_length=50000, blank=True)
96 service_actor = models.ForeignKey(
97 "Actor",
98 related_name="managed_domains",
99 on_delete=models.SET_NULL,
100 null=True,
101 blank=True,
102 )
103 objects = DomainQuerySet.as_manager()
104
105 def __str__(self):
106 return self.name
107
108 def save(self, **kwargs):
109 lowercase_fields = ["name"]
110 for field in lowercase_fields:
111 v = getattr(self, field, None)
112 if v:
113 setattr(self, field, v.lower())
114
115 super().save(**kwargs)
116
117 def get_stats(self):
118 from funkwhale_api.music import models as music_models
119
120 data = Domain.objects.filter(pk=self.pk).aggregate(
121 actors=models.Count("actors", distinct=True),
122 outbox_activities=models.Count("actors__outbox_activities", distinct=True),
123 libraries=models.Count("actors__libraries", distinct=True),
124 received_library_follows=models.Count(
125 "actors__libraries__received_follows", distinct=True
126 ),
127 emitted_library_follows=models.Count(
128 "actors__library_follows", distinct=True
129 ),
130 )
131 data["artists"] = music_models.Artist.objects.filter(
132 from_activity__actor__domain_id=self.pk
133 ).count()
134 data["albums"] = music_models.Album.objects.filter(
135 from_activity__actor__domain_id=self.pk
136 ).count()
137 data["tracks"] = music_models.Track.objects.filter(
138 from_activity__actor__domain_id=self.pk
139 ).count()
140
141 uploads = music_models.Upload.objects.filter(library__actor__domain_id=self.pk)
142 data["uploads"] = uploads.count()
143 data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0
144 data["media_downloaded_size"] = (
145 uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0
146 )
147 return data
148
149
150class Actor(models.Model):
151 ap_type = "Actor"
152
153 fid = models.URLField(unique=True, max_length=500, db_index=True)
154 url = models.URLField(max_length=500, null=True, blank=True)
155 outbox_url = models.URLField(max_length=500)
156 inbox_url = models.URLField(max_length=500)
157 following_url = models.URLField(max_length=500, null=True, blank=True)
158 followers_url = models.URLField(max_length=500, null=True, blank=True)
159 shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
160 type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25)
161 name = models.CharField(max_length=200, null=True, blank=True)
162 domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name="actors")
163 summary = models.CharField(max_length=500, null=True, blank=True)
164 preferred_username = models.CharField(max_length=200, null=True, blank=True)
165 public_key = models.TextField(max_length=5000, null=True, blank=True)
166 private_key = models.TextField(max_length=5000, null=True, blank=True)
167 creation_date = models.DateTimeField(default=timezone.now)
168 last_fetch_date = models.DateTimeField(default=timezone.now)
169 manually_approves_followers = models.NullBooleanField(default=None)
170 followers = models.ManyToManyField(
171 to="self",
172 symmetrical=False,
173 through="Follow",
174 through_fields=("target", "actor"),
175 related_name="following",
176 )
177
178 objects = ActorQuerySet.as_manager()
179
180 class Meta:
181 unique_together = ["domain", "preferred_username"]
182
183 @property
184 def webfinger_subject(self):
185 return "{}@{}".format(self.preferred_username, settings.FEDERATION_HOSTNAME)
186
187 @property
188 def private_key_id(self):
189 return "{}#main-key".format(self.fid)
190
191 @property
192 def full_username(self):
193 return "{}@{}".format(self.preferred_username, self.domain_id)
194
195 def __str__(self):
196 return "{}@{}".format(self.preferred_username, self.domain_id)
197
198 @property
199 def is_local(self):
200 return self.domain_id == settings.FEDERATION_HOSTNAME
201
202 def get_approved_followers(self):
203 follows = self.received_follows.filter(approved=True)
204 return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
205
206 def should_autoapprove_follow(self, actor):
207 return False
208
209 def get_user(self):
210 try:
211 return self.user
212 except ObjectDoesNotExist:
213 return None
214
215 def get_current_usage(self):
216 actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get()
217 data = {}
218 for s in ["pending", "skipped", "errored", "finished"]:
219 data[s] = getattr(actor, "_usage_{}".format(s)) or 0
220
221 data["total"] = sum(data.values())
222 return data
223
224 def get_stats(self):
225 from funkwhale_api.music import models as music_models
226
227 data = Actor.objects.filter(pk=self.pk).aggregate(
228 outbox_activities=models.Count("outbox_activities", distinct=True),
229 libraries=models.Count("libraries", distinct=True),
230 received_library_follows=models.Count(
231 "libraries__received_follows", distinct=True
232 ),
233 emitted_library_follows=models.Count("library_follows", distinct=True),
234 )
235 data["artists"] = music_models.Artist.objects.filter(
236 from_activity__actor=self.pk
237 ).count()
238 data["albums"] = music_models.Album.objects.filter(
239 from_activity__actor=self.pk
240 ).count()
241 data["tracks"] = music_models.Track.objects.filter(
242 from_activity__actor=self.pk
243 ).count()
244
245 uploads = music_models.Upload.objects.filter(library__actor=self.pk)
246 data["uploads"] = uploads.count()
247 data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0
248 data["media_downloaded_size"] = (
249 uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0
250 )
251 return data
252
253 @property
254 def keys(self):
255 return self.private_key, self.public_key
256
257 @keys.setter
258 def keys(self, v):
259 self.private_key = v[0].decode("utf-8")
260 self.public_key = v[1].decode("utf-8")
261
262
263class InboxItem(models.Model):
264 """
265 Store activities binding to local actors, with read/unread status.
266 """
267
268 actor = models.ForeignKey(
269 Actor, related_name="inbox_items", on_delete=models.CASCADE
270 )
271 activity = models.ForeignKey(
272 "Activity", related_name="inbox_items", on_delete=models.CASCADE
273 )
274 type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")])
275 is_read = models.BooleanField(default=False)
276
277
278class Delivery(models.Model):
279 """
280 Store deliveries attempt to remote inboxes
281 """
282
283 is_delivered = models.BooleanField(default=False)
284 last_attempt_date = models.DateTimeField(null=True, blank=True)
285 attempts = models.PositiveIntegerField(default=0)
286 inbox_url = models.URLField(max_length=500)
287
288 activity = models.ForeignKey(
289 "Activity", related_name="deliveries", on_delete=models.CASCADE
290 )
291
292
293class Activity(models.Model):
294 actor = models.ForeignKey(
295 Actor, related_name="outbox_activities", on_delete=models.CASCADE
296 )
297 recipients = models.ManyToManyField(
298 Actor, related_name="inbox_activities", through=InboxItem
299 )
300 uuid = models.UUIDField(default=uuid.uuid4, unique=True)
301 fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
302 url = models.URLField(max_length=500, null=True, blank=True)
303 payload = JSONField(default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder)
304 creation_date = models.DateTimeField(default=timezone.now, db_index=True)
305 type = models.CharField(db_index=True, null=True, max_length=100)
306
307 # generic relations
308 object_id = models.IntegerField(null=True)
309 object_content_type = models.ForeignKey(
310 ContentType,
311 null=True,
312 on_delete=models.SET_NULL,
313 related_name="objecting_activities",
314 )
315 object = GenericForeignKey("object_content_type", "object_id")
316 target_id = models.IntegerField(null=True)
317 target_content_type = models.ForeignKey(
318 ContentType,
319 null=True,
320 on_delete=models.SET_NULL,
321 related_name="targeting_activities",
322 )
323 target = GenericForeignKey("target_content_type", "target_id")
324 related_object_id = models.IntegerField(null=True)
325 related_object_content_type = models.ForeignKey(
326 ContentType,
327 null=True,
328 on_delete=models.SET_NULL,
329 related_name="related_objecting_activities",
330 )
331 related_object = GenericForeignKey(
332 "related_object_content_type", "related_object_id"
333 )
334
335
336class AbstractFollow(models.Model):
337 ap_type = "Follow"
338 fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
339 uuid = models.UUIDField(default=uuid.uuid4, unique=True)
340 creation_date = models.DateTimeField(default=timezone.now)
341 modification_date = models.DateTimeField(auto_now=True)
342 approved = models.NullBooleanField(default=None)
343
344 class Meta:
345 abstract = True
346
347 def get_federation_id(self):
348 return federation_utils.full_url(
349 "{}#follows/{}".format(self.actor.fid, self.uuid)
350 )
351
352
353class Follow(AbstractFollow):
354 actor = models.ForeignKey(
355 Actor, related_name="emitted_follows", on_delete=models.CASCADE
356 )
357 target = models.ForeignKey(
358 Actor, related_name="received_follows", on_delete=models.CASCADE
359 )
360
361 class Meta:
362 unique_together = ["actor", "target"]
363
364
365class LibraryFollow(AbstractFollow):
366 actor = models.ForeignKey(
367 Actor, related_name="library_follows", on_delete=models.CASCADE
368 )
369 target = models.ForeignKey(
370 "music.Library", related_name="received_follows", on_delete=models.CASCADE
371 )
372
373 class Meta:
374 unique_together = ["actor", "target"]
375
376
377class Library(models.Model):
378 creation_date = models.DateTimeField(default=timezone.now)
379 modification_date = models.DateTimeField(auto_now=True)
380 fetched_date = models.DateTimeField(null=True, blank=True)
381 actor = models.OneToOneField(
382 Actor, on_delete=models.CASCADE, related_name="library"
383 )
384 uuid = models.UUIDField(default=uuid.uuid4)
385 url = models.URLField(max_length=500)
386
387 # use this flag to disable federation with a library
388 federation_enabled = models.BooleanField()
389 # should we mirror files locally or hotlink them?
390 download_files = models.BooleanField()
391 # should we automatically import new files from this library?
392 autoimport = models.BooleanField()
393 tracks_count = models.PositiveIntegerField(null=True, blank=True)
394 follow = models.OneToOneField(
395 Follow, related_name="library", null=True, blank=True, on_delete=models.SET_NULL
396 )
397
398
399get_file_path = common_utils.ChunkedPath("federation_cache")
400
401
402class LibraryTrack(models.Model):
403 url = models.URLField(unique=True, max_length=500)
404 audio_url = models.URLField(max_length=500)
405 audio_mimetype = models.CharField(max_length=200)
406 audio_file = models.FileField(upload_to=get_file_path, null=True, blank=True)
407
408 creation_date = models.DateTimeField(default=timezone.now)
409 modification_date = models.DateTimeField(auto_now=True)
410 fetched_date = models.DateTimeField(null=True, blank=True)
411 published_date = models.DateTimeField(null=True, blank=True)
412 library = models.ForeignKey(
413 Library, related_name="tracks", on_delete=models.CASCADE
414 )
415 artist_name = models.CharField(max_length=500)
416 album_title = models.CharField(max_length=500)
417 title = models.CharField(max_length=500)
418 metadata = JSONField(
419 default=empty_dict, max_length=10000, encoder=DjangoJSONEncoder
420 )
421
422 @property
423 def mbid(self):
424 try:
425 return self.metadata["recording"]["musicbrainz_id"]
426 except KeyError:
427 pass
428
429 def download_audio(self):
430 from . import actors
431
432 auth = actors.SYSTEM_ACTORS["library"].get_request_auth()
433 remote_response = session.get_session().get(
434 self.audio_url,
435 auth=auth,
436 stream=True,
437 timeout=20,
438 verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
439 headers={"Content-Type": "application/activity+json"},
440 )
441 with remote_response as r:
442 remote_response.raise_for_status()
443 extension = music_utils.get_ext_from_type(self.audio_mimetype)
444 title = " - ".join([self.title, self.album_title, self.artist_name])
445 filename = "{}.{}".format(title, extension)
446 tmp_file = tempfile.TemporaryFile()
447 for chunk in r.iter_content(chunk_size=512):
448 tmp_file.write(chunk)
449 self.audio_file.save(filename, tmp_file)
450
451 def get_metadata(self, key):
452 return self.metadata.get(key)
api/funkwhale_api/history/models.py ¶
1from django.db import models
2from django.utils import timezone
3from funkwhale_api.music.models import Track
4
5
6class Listening(models.Model):
7 creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
8 track = models.ForeignKey(
9 Track, related_name="listenings", on_delete=models.CASCADE
10 )
11 user = models.ForeignKey(
12 "users.User",
13 related_name="listenings",
14 null=True,
15 blank=True,
16 on_delete=models.CASCADE,
17 )
18 session_key = models.CharField(max_length=100, null=True, blank=True)
19
20 class Meta:
21 ordering = ("-creation_date",)
22
23 def get_activity_url(self):
24 return "{}/listenings/tracks/{}".format(self.user.get_activity_url(), self.pk)
api/funkwhale_api/moderation/models.py ¶
1import urllib.parse
2import uuid
3
4from django.db import models
5from django.utils import timezone
6
7
8class InstancePolicyQuerySet(models.QuerySet):
9 def active(self):
10 return self.filter(is_active=True)
11
12 def matching_url(self, *urls):
13 if not urls:
14 return self.none()
15 query = None
16 for url in urls:
17 new_query = self.matching_url_query(url)
18 if query:
19 query = query | new_query
20 else:
21 query = new_query
22 return self.filter(query)
23
24 def matching_url_query(self, url):
25 parsed = urllib.parse.urlparse(url)
26 return models.Q(target_domain_id=parsed.hostname) | models.Q(
27 target_actor__fid=url
28 )
29
30
31class InstancePolicy(models.Model):
32 uuid = models.UUIDField(default=uuid.uuid4, unique=True)
33 actor = models.ForeignKey(
34 "federation.Actor",
35 related_name="created_instance_policies",
36 on_delete=models.SET_NULL,
37 null=True,
38 blank=True,
39 )
40 target_domain = models.OneToOneField(
41 "federation.Domain",
42 related_name="instance_policy",
43 on_delete=models.CASCADE,
44 null=True,
45 blank=True,
46 )
47 target_actor = models.OneToOneField(
48 "federation.Actor",
49 related_name="instance_policy",
50 on_delete=models.CASCADE,
51 null=True,
52 blank=True,
53 )
54 creation_date = models.DateTimeField(default=timezone.now)
55
56 is_active = models.BooleanField(default=True)
57 # a summary explaining why the policy is in place
58 summary = models.TextField(max_length=10000, null=True, blank=True)
59 # either block everything (simpler, but less granularity)
60 block_all = models.BooleanField(default=False)
61 # or pick individual restrictions below
62 # do not show in timelines/notifications, except for actual followers
63 silence_activity = models.BooleanField(default=False)
64 silence_notifications = models.BooleanField(default=False)
65 # do not download any media from the target
66 reject_media = models.BooleanField(default=False)
67
68 objects = InstancePolicyQuerySet.as_manager()
69
70 @property
71 def target(self):
72 if self.target_actor:
73 return {"type": "actor", "obj": self.target_actor}
74 if self.target_domain_id:
75 return {"type": "domain", "obj": self.target_domain}
76
77
78class UserFilter(models.Model):
79 uuid = models.UUIDField(default=uuid.uuid4, unique=True)
80 creation_date = models.DateTimeField(default=timezone.now)
81 target_artist = models.ForeignKey(
82 "music.Artist", on_delete=models.CASCADE, related_name="user_filters"
83 )
84 user = models.ForeignKey(
85 "users.User", on_delete=models.CASCADE, related_name="content_filters"
86 )
87
88 class Meta:
89 unique_together = ("user", "target_artist")
90
91 @property
92 def target(self):
93 if self.target_artist:
94 return {"type": "artist", "obj": self.target_artist}
api/funkwhale_api/music/models.py ¶
1import datetime
2import logging
3import mimetypes
4import os
5import tempfile
6import uuid
7
8import markdown
9import pendulum
10import pydub
11from django.conf import settings
12from django.contrib.postgres.fields import JSONField
13from django.core.files.base import ContentFile
14from django.core.serializers.json import DjangoJSONEncoder
15from django.db import models
16from django.db import transaction
17from django.db.models.signals import post_save
18from django.dispatch import receiver
19from django.urls import reverse
20from django.utils import timezone
21from funkwhale_api import musicbrainz
22from funkwhale_api.common import fields
23from funkwhale_api.common import models as common_models
24from funkwhale_api.common import session
25from funkwhale_api.common import utils as common_utils
26from funkwhale_api.federation import models as federation_models
27from funkwhale_api.federation import utils as federation_utils
28from taggit.managers import TaggableManager
29from versatileimagefield.fields import VersatileImageField
30from versatileimagefield.image_warmer import VersatileImageFieldWarmer
31
32from . import importers
33from . import metadata
34from . import utils
35
36logger = logging.getLogger(__name__)
37
38
39def empty_dict():
40 return {}
41
42
43class APIModelMixin(models.Model):
44 fid = models.URLField(unique=True, max_length=500, db_index=True, null=True)
45 mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True)
46 uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
47 from_activity = models.ForeignKey(
48 "federation.Activity", null=True, blank=True, on_delete=models.SET_NULL
49 )
50 api_includes = []
51 creation_date = models.DateTimeField(default=timezone.now, db_index=True)
52 import_hooks = []
53
54 class Meta:
55 abstract = True
56 ordering = ["-creation_date"]
57
58 @classmethod
59 def get_or_create_from_api(cls, mbid):
60 try:
61 return cls.objects.get(mbid=mbid), False
62 except cls.DoesNotExist:
63 return cls.create_from_api(id=mbid), True
64
65 def get_api_data(self):
66 return self.__class__.api.get(id=self.mbid, includes=self.api_includes)[
67 self.musicbrainz_model
68 ]
69
70 @classmethod
71 def create_from_api(cls, **kwargs):
72 if kwargs.get("id"):
73 raw_data = cls.api.get(id=kwargs["id"], includes=cls.api_includes)[
74 cls.musicbrainz_model
75 ]
76 else:
77 raw_data = cls.api.search(**kwargs)[
78 "{0}-list".format(cls.musicbrainz_model)
79 ][0]
80 cleaned_data = cls.clean_musicbrainz_data(raw_data)
81 return importers.load(cls, cleaned_data, raw_data, cls.import_hooks)
82
83 @classmethod
84 def clean_musicbrainz_data(cls, data):
85 cleaned_data = {}
86 mapping = importers.Mapping(cls.musicbrainz_mapping)
87 for key, value in data.items():
88 try:
89 cleaned_key, cleaned_value = mapping.from_musicbrainz(key, value)
90 cleaned_data[cleaned_key] = cleaned_value
91 except KeyError:
92 pass
93 return cleaned_data
94
95 @property
96 def musicbrainz_url(self):
97 if self.mbid:
98 return "https://musicbrainz.org/{}/{}".format(
99 self.musicbrainz_model, self.mbid
100 )
101
102 def get_federation_id(self):
103 if self.fid:
104 return self.fid
105
106 return federation_utils.full_url(
107 reverse(
108 "federation:music:{}-detail".format(self.federation_namespace),
109 kwargs={"uuid": self.uuid},
110 )
111 )
112
113 def save(self, **kwargs):
114 if not self.pk and not self.fid:
115 self.fid = self.get_federation_id()
116
117 return super().save(**kwargs)
118
119
120class License(models.Model):
121 code = models.CharField(primary_key=True, max_length=100)
122 url = models.URLField(max_length=500)
123
124 # if true, license is a copyleft license, meaning that derivative
125 # work must be shared under the same license
126 copyleft = models.BooleanField()
127 # if true, commercial use of the work is allowed
128 commercial = models.BooleanField()
129 # if true, attribution to the original author is required when reusing
130 # the work
131 attribution = models.BooleanField()
132 # if true, derivative work are allowed
133 derivative = models.BooleanField()
134 # if true, redistribution of the wor is allowed
135 redistribute = models.BooleanField()
136
137 @property
138 def conf(self):
139 from . import licenses
140
141 for row in licenses.LICENSES:
142 if self.code == row["code"]:
143 return row
144 logger.warning("%s do not match any registered license", self.code)
145
146
147class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
148 def with_albums_count(self):
149 return self.annotate(_albums_count=models.Count("albums"))
150
151 def with_albums(self):
152 return self.prefetch_related(
153 models.Prefetch("albums", queryset=Album.objects.with_tracks_count())
154 )
155
156 def annotate_playable_by_actor(self, actor):
157 tracks = (
158 Upload.objects.playable_by(actor)
159 .filter(track__artist=models.OuterRef("id"))
160 .order_by("id")
161 .values("id")[:1]
162 )
163 subquery = models.Subquery(tracks)
164 return self.annotate(is_playable_by_actor=subquery)
165
166 def playable_by(self, actor, include=True):
167 tracks = Track.objects.playable_by(actor, include)
168 matches = self.filter(tracks__in=tracks).values_list("pk")
169 if include:
170 return self.filter(pk__in=matches)
171 else:
172 return self.exclude(pk__in=matches)
173
174
175class Artist(APIModelMixin):
176 name = models.CharField(max_length=255)
177 federation_namespace = "artists"
178 musicbrainz_model = "artist"
179 musicbrainz_mapping = {
180 "mbid": {"musicbrainz_field_name": "id"},
181 "name": {"musicbrainz_field_name": "name"},
182 }
183 api = musicbrainz.api.artists
184 objects = ArtistQuerySet.as_manager()
185
186 def __str__(self):
187 return self.name
188
189 @property
190 def tags(self):
191 t = []
192 for album in self.albums.all():
193 for tag in album.tags:
194 t.append(tag)
195 return set(t)
196
197 @classmethod
198 def get_or_create_from_name(cls, name, **kwargs):
199 kwargs.update({"name": name})
200 return cls.objects.get_or_create(name__iexact=name, defaults=kwargs)
201
202
203def import_artist(v):
204 a = Artist.get_or_create_from_api(mbid=v[0]["artist"]["id"])[0]
205 return a
206
207
208def parse_date(v):
209 d = pendulum.parse(v).date()
210 return d
211
212
213def import_tracks(instance, cleaned_data, raw_data):
214 for track_data in raw_data["medium-list"][0]["track-list"]:
215 track_cleaned_data = Track.clean_musicbrainz_data(track_data["recording"])
216 track_cleaned_data["album"] = instance
217 track_cleaned_data["position"] = int(track_data["position"])
218 importers.load(Track, track_cleaned_data, track_data, Track.import_hooks)
219
220
221class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
222 def with_tracks_count(self):
223 return self.annotate(_tracks_count=models.Count("tracks"))
224
225 def annotate_playable_by_actor(self, actor):
226 tracks = (
227 Upload.objects.playable_by(actor)
228 .filter(track__album=models.OuterRef("id"))
229 .order_by("id")
230 .values("id")[:1]
231 )
232 subquery = models.Subquery(tracks)
233 return self.annotate(is_playable_by_actor=subquery)
234
235 def playable_by(self, actor, include=True):
236 tracks = Track.objects.playable_by(actor, include)
237 matches = self.filter(tracks__in=tracks).values_list("pk")
238 if include:
239 return self.filter(pk__in=matches)
240 else:
241 return self.exclude(pk__in=matches)
242
243 def with_prefetched_tracks_and_playable_uploads(self, actor):
244 tracks = Track.objects.with_playable_uploads(actor)
245 return self.prefetch_related(models.Prefetch("tracks", queryset=tracks))
246
247
248class Album(APIModelMixin):
249 title = models.CharField(max_length=255)
250 artist = models.ForeignKey(Artist, related_name="albums", on_delete=models.CASCADE)
251 release_date = models.DateField(null=True, blank=True)
252 release_group_id = models.UUIDField(null=True, blank=True)
253 cover = VersatileImageField(
254 upload_to="albums/covers/%Y/%m/%d", null=True, blank=True
255 )
256 TYPE_CHOICES = (("album", "Album"),)
257 type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album")
258
259 api_includes = ["artist-credits", "recordings", "media", "release-groups"]
260 api = musicbrainz.api.releases
261 federation_namespace = "albums"
262 musicbrainz_model = "release"
263 musicbrainz_mapping = {
264 "mbid": {"musicbrainz_field_name": "id"},
265 "position": {
266 "musicbrainz_field_name": "release-list",
267 "converter": lambda v: int(v[0]["medium-list"][0]["position"]),
268 },
269 "release_group_id": {
270 "musicbrainz_field_name": "release-group",
271 "converter": lambda v: v["id"],
272 },
273 "title": {"musicbrainz_field_name": "title"},
274 "release_date": {"musicbrainz_field_name": "date", "converter": parse_date},
275 "type": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()},
276 "artist": {
277 "musicbrainz_field_name": "artist-credit",
278 "converter": import_artist,
279 },
280 }
281 objects = AlbumQuerySet.as_manager()
282
283 def get_image(self, data=None):
284 if data:
285 extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
286 extension = extensions.get(data["mimetype"], "jpg")
287 if data.get("content"):
288 # we have to cover itself
289 f = ContentFile(data["content"])
290 elif data.get("url"):
291 # we can fetch from a url
292 try:
293 response = session.get_session().get(
294 data.get("url"),
295 timeout=3,
296 verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
297 )
298 response.raise_for_status()
299 except Exception as e:
300 logger.warn(
301 "Cannot download cover at url %s: %s", data.get("url"), e
302 )
303 return
304 else:
305 f = ContentFile(response.content)
306 self.cover.save("{}.{}".format(self.uuid, extension), f, save=False)
307 self.save(update_fields=["cover"])
308 return self.cover.file
309 if self.mbid:
310 image_data = musicbrainz.api.images.get_front(str(self.mbid))
311 f = ContentFile(image_data)
312 self.cover.save("{0}.jpg".format(self.mbid), f, save=False)
313 self.save(update_fields=["cover"])
314 return self.cover.file
315
316 def __str__(self):
317 return self.title
318
319 @property
320 def tags(self):
321 t = []
322 for track in self.tracks.all():
323 for tag in track.tags.all():
324 t.append(tag)
325 return set(t)
326
327 @classmethod
328 def get_or_create_from_title(cls, title, **kwargs):
329 kwargs.update({"title": title})
330 return cls.objects.get_or_create(title__iexact=title, defaults=kwargs)
331
332
333def import_tags(instance, cleaned_data, raw_data):
334 MINIMUM_COUNT = 2
335 tags_to_add = []
336 for tag_data in raw_data.get("tag-list", []):
337 try:
338 if int(tag_data["count"]) < MINIMUM_COUNT:
339 continue
340 except ValueError:
341 continue
342 tags_to_add.append(tag_data["name"])
343 instance.tags.add(*tags_to_add)
344
345
346def import_album(v):
347 a = Album.get_or_create_from_api(mbid=v[0]["id"])[0]
348 return a
349
350
351def link_recordings(instance, cleaned_data, raw_data):
352 tracks = [r["target"] for r in raw_data["recording-relation-list"]]
353 Track.objects.filter(mbid__in=tracks).update(work=instance)
354
355
356def import_lyrics(instance, cleaned_data, raw_data):
357 try:
358 url = [
359 url_data
360 for url_data in raw_data["url-relation-list"]
361 if url_data["type"] == "lyrics"
362 ][0]["target"]
363 except (IndexError, KeyError):
364 return
365 l, _ = Lyrics.objects.get_or_create(work=instance, url=url)
366
367 return l
368
369
370class Work(APIModelMixin):
371 language = models.CharField(max_length=20)
372 nature = models.CharField(max_length=50)
373 title = models.CharField(max_length=255)
374
375 api = musicbrainz.api.works
376 api_includes = ["url-rels", "recording-rels"]
377 musicbrainz_model = "work"
378 federation_namespace = "works"
379
380 musicbrainz_mapping = {
381 "mbid": {"musicbrainz_field_name": "id"},
382 "title": {"musicbrainz_field_name": "title"},
383 "language": {"musicbrainz_field_name": "language"},
384 "nature": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()},
385 }
386 import_hooks = [import_lyrics, link_recordings]
387
388 def fetch_lyrics(self):
389 lyric = self.lyrics.first()
390 if lyric:
391 return lyric
392 data = self.api.get(self.mbid, includes=["url-rels"])["work"]
393 lyric = import_lyrics(self, {}, data)
394
395 return lyric
396
397 def get_federation_id(self):
398 if self.fid:
399 return self.fid
400
401 return None
402
403
404class Lyrics(models.Model):
405 uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
406 work = models.ForeignKey(
407 Work, related_name="lyrics", null=True, blank=True, on_delete=models.CASCADE
408 )
409 url = models.URLField(unique=True)
410 content = models.TextField(null=True, blank=True)
411
412 @property
413 def content_rendered(self):
414 return markdown.markdown(
415 self.content,
416 safe_mode=True,
417 enable_attributes=False,
418 extensions=["markdown.extensions.nl2br"],
419 )
420
421
422class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
423 def for_nested_serialization(self):
424 return self.select_related().select_related("album__artist", "artist")
425
426 def annotate_playable_by_actor(self, actor):
427 files = (
428 Upload.objects.playable_by(actor)
429 .filter(track=models.OuterRef("id"))
430 .order_by("id")
431 .values("id")[:1]
432 )
433 subquery = models.Subquery(files)
434 return self.annotate(is_playable_by_actor=subquery)
435
436 def playable_by(self, actor, include=True):
437 files = Upload.objects.playable_by(actor, include)
438 matches = self.filter(uploads__in=files).values_list("pk")
439 if include:
440 return self.filter(pk__in=matches)
441 else:
442 return self.exclude(pk__in=matches)
443
444 def with_playable_uploads(self, actor):
445 uploads = Upload.objects.playable_by(actor).select_related("track")
446 return self.prefetch_related(
447 models.Prefetch("uploads", queryset=uploads, to_attr="playable_uploads")
448 )
449
450 def order_for_album(self):
451 """
452 Order by disc number then position
453 """
454 return self.order_by("disc_number", "position", "title")
455
456
457def get_artist(release_list):
458 return Artist.get_or_create_from_api(
459 mbid=release_list[0]["artist-credits"][0]["artists"]["id"]
460 )[0]
461
462
463class Track(APIModelMixin):
464 title = models.CharField(max_length=255)
465 artist = models.ForeignKey(Artist, related_name="tracks", on_delete=models.CASCADE)
466 disc_number = models.PositiveIntegerField(null=True, blank=True)
467 position = models.PositiveIntegerField(null=True, blank=True)
468 album = models.ForeignKey(
469 Album, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE
470 )
471 work = models.ForeignKey(
472 Work, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE
473 )
474 license = models.ForeignKey(
475 License,
476 null=True,
477 blank=True,
478 on_delete=models.DO_NOTHING,
479 related_name="tracks",
480 )
481 copyright = models.CharField(max_length=500, null=True, blank=True)
482 federation_namespace = "tracks"
483 musicbrainz_model = "recording"
484 api = musicbrainz.api.recordings
485 api_includes = ["artist-credits", "releases", "media", "tags", "work-rels"]
486 musicbrainz_mapping = {
487 "mbid": {"musicbrainz_field_name": "id"},
488 "title": {"musicbrainz_field_name": "title"},
489 "artist": {
490 "musicbrainz_field_name": "artist-credit",
491 "converter": lambda v: Artist.get_or_create_from_api(
492 mbid=v[0]["artist"]["id"]
493 )[0],
494 },
495 "album": {"musicbrainz_field_name": "release-list", "converter": import_album},
496 }
497 import_hooks = [import_tags]
498 objects = TrackQuerySet.as_manager()
499 tags = TaggableManager(blank=True)
500
501 class Meta:
502 ordering = ["album", "disc_number", "position"]
503
504 def __str__(self):
505 return self.title
506
507 def save(self, **kwargs):
508 try:
509 self.artist
510 except Artist.DoesNotExist:
511 self.artist = self.album.artist
512 super().save(**kwargs)
513
514 def get_work(self):
515 if self.work:
516 return self.work
517 data = self.api.get(self.mbid, includes=["work-rels"])
518 try:
519 work_data = data["recording"]["work-relation-list"][0]["work"]
520 except (IndexError, KeyError):
521 return
522 work, _ = Work.get_or_create_from_api(mbid=work_data["id"])
523 return work
524
525 def get_lyrics_url(self):
526 return reverse("api:v1:tracks-lyrics", kwargs={"pk": self.pk})
527
528 @property
529 def full_name(self):
530 try:
531 return "{} - {} - {}".format(self.artist.name, self.album.title, self.title)
532 except AttributeError:
533 return "{} - {}".format(self.artist.name, self.title)
534
535 def get_activity_url(self):
536 if self.mbid:
537 return "https://musicbrainz.org/recording/{}".format(self.mbid)
538 return settings.FUNKWHALE_URL + "/tracks/{}".format(self.pk)
539
540 @classmethod
541 def get_or_create_from_title(cls, title, **kwargs):
542 kwargs.update({"title": title})
543 return cls.objects.get_or_create(title__iexact=title, defaults=kwargs)
544
545 @classmethod
546 def get_or_create_from_release(cls, release_mbid, mbid):
547 release_mbid = str(release_mbid)
548 mbid = str(mbid)
549 try:
550 return cls.objects.get(mbid=mbid), False
551 except cls.DoesNotExist:
552 pass
553
554 album = Album.get_or_create_from_api(release_mbid)[0]
555 data = musicbrainz.client.api.releases.get(
556 str(album.mbid), includes=Album.api_includes
557 )
558 tracks = [t for m in data["release"]["medium-list"] for t in m["track-list"]]
559 track_data = None
560 for track in tracks:
561 if track["recording"]["id"] == str(mbid):
562 track_data = track
563 break
564 if not track_data:
565 raise ValueError("No track found matching this ID")
566
567 track_artist_mbid = None
568 for ac in track_data["recording"]["artist-credit"]:
569 try:
570 ac_mbid = ac["artist"]["id"]
571 except TypeError:
572 # it's probably a string, like "feat."
573 continue
574
575 if ac_mbid == str(album.artist.mbid):
576 continue
577
578 track_artist_mbid = ac_mbid
579 break
580 track_artist_mbid = track_artist_mbid or album.artist.mbid
581 if track_artist_mbid == str(album.artist.mbid):
582 track_artist = album.artist
583 else:
584 track_artist = Artist.get_or_create_from_api(track_artist_mbid)[0]
585 return cls.objects.update_or_create(
586 mbid=mbid,
587 defaults={
588 "position": int(track["position"]),
589 "title": track["recording"]["title"],
590 "album": album,
591 "artist": track_artist,
592 },
593 )
594
595 @property
596 def listen_url(self):
597 return reverse("api:v1:listen-detail", kwargs={"uuid": self.uuid})
598
599 @property
600 def local_license(self):
601 """
602 Since license primary keys are strings, and we can get the data
603 from our hardcoded licenses.LICENSES list, there is no need
604 for extra SQL joins / queries.
605 """
606 from . import licenses
607
608 return licenses.LICENSES_BY_ID.get(self.license_id)
609
610
611class UploadQuerySet(models.QuerySet):
612 def playable_by(self, actor, include=True):
613 libraries = Library.objects.viewable_by(actor)
614
615 if include:
616 return self.filter(library__in=libraries, import_status="finished")
617 return self.exclude(library__in=libraries, import_status="finished")
618
619 def local(self, include=True):
620 return self.exclude(library__actor__user__isnull=include)
621
622 def for_federation(self):
623 return self.filter(import_status="finished", mimetype__startswith="audio/")
624
625 def with_file(self):
626 return self.exclude(audio_file=None).exclude(audio_file="")
627
628
629TRACK_FILE_IMPORT_STATUS_CHOICES = (
630 ("pending", "Pending"),
631 ("finished", "Finished"),
632 ("errored", "Errored"),
633 ("skipped", "Skipped"),
634)
635
636
637def get_file_path(instance, filename):
638 if isinstance(instance, UploadVersion):
639 return common_utils.ChunkedPath("transcoded")(instance, filename)
640
641 if instance.library.actor.get_user():
642 return common_utils.ChunkedPath("tracks")(instance, filename)
643 else:
644 # we cache remote tracks in a different directory
645 return common_utils.ChunkedPath("federation_cache/tracks")(instance, filename)
646
647
648def get_import_reference():
649 return str(uuid.uuid4())
650
651
652class Upload(models.Model):
653 fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
654 uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
655 track = models.ForeignKey(
656 Track, related_name="uploads", on_delete=models.CASCADE, null=True, blank=True
657 )
658 audio_file = models.FileField(upload_to=get_file_path, max_length=255)
659 source = models.CharField(
660 # URL validators are not flexible enough for our file:// and upload:// schemes
661 null=True,
662 blank=True,
663 max_length=500,
664 )
665 creation_date = models.DateTimeField(default=timezone.now, db_index=True)
666 modification_date = models.DateTimeField(default=timezone.now, null=True)
667 accessed_date = models.DateTimeField(null=True, blank=True)
668 duration = models.IntegerField(null=True, blank=True)
669 size = models.IntegerField(null=True, blank=True)
670 bitrate = models.IntegerField(null=True, blank=True)
671 acoustid_track_id = models.UUIDField(null=True, blank=True)
672 mimetype = models.CharField(null=True, blank=True, max_length=200)
673 library = models.ForeignKey(
674 "library",
675 null=True,
676 blank=True,
677 related_name="uploads",
678 on_delete=models.CASCADE,
679 )
680
681 # metadata from federation
682 metadata = JSONField(
683 default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder
684 )
685 import_date = models.DateTimeField(null=True, blank=True)
686 # optionnal metadata provided during import
687 import_metadata = JSONField(
688 default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder
689 )
690 # status / error details for the import
691 import_status = models.CharField(
692 default="pending", choices=TRACK_FILE_IMPORT_STATUS_CHOICES, max_length=25
693 )
694 # a short reference provided by the client to group multiple files
695 # in the same import
696 import_reference = models.CharField(max_length=50, default=get_import_reference)
697
698 # optionnal metadata about import results (error messages, etc.)
699 import_details = JSONField(
700 default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder
701 )
702 from_activity = models.ForeignKey(
703 "federation.Activity", null=True, on_delete=models.SET_NULL
704 )
705
706 objects = UploadQuerySet.as_manager()
707
708 def download_audio_from_remote(self, user):
709 from funkwhale_api.common import session
710 from funkwhale_api.federation import signing
711
712 if user.is_authenticated and user.actor:
713 auth = signing.get_auth(user.actor.private_key, user.actor.private_key_id)
714 else:
715 auth = None
716
717 remote_response = session.get_session().get(
718 self.source,
719 auth=auth,
720 stream=True,
721 timeout=20,
722 headers={"Content-Type": "application/octet-stream"},
723 verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
724 )
725 with remote_response as r:
726 remote_response.raise_for_status()
727 extension = utils.get_ext_from_type(self.mimetype)
728 title = " - ".join(
729 [self.track.title, self.track.album.title, self.track.artist.name]
730 )
731 filename = "{}.{}".format(title, extension)
732 tmp_file = tempfile.TemporaryFile()
733 for chunk in r.iter_content(chunk_size=512):
734 tmp_file.write(chunk)
735 self.audio_file.save(filename, tmp_file, save=False)
736 self.save(update_fields=["audio_file"])
737
738 def get_federation_id(self):
739 if self.fid:
740 return self.fid
741
742 return federation_utils.full_url(
743 reverse("federation:music:uploads-detail", kwargs={"uuid": self.uuid})
744 )
745
746 @property
747 def filename(self):
748 return "{}.{}".format(self.track.full_name, self.extension)
749
750 @property
751 def extension(self):
752 try:
753 return utils.MIMETYPE_TO_EXTENSION[self.mimetype]
754 except KeyError:
755 pass
756 if self.audio_file:
757 return os.path.splitext(self.audio_file.name)[-1].replace(".", "", 1)
758 if self.in_place_path:
759 return os.path.splitext(self.in_place_path)[-1].replace(".", "", 1)
760
761 def get_file_size(self):
762 if self.audio_file:
763 return self.audio_file.size
764
765 if self.source.startswith("file://"):
766 return os.path.getsize(self.source.replace("file://", "", 1))
767
768 def get_audio_file(self):
769 if self.audio_file:
770 return self.audio_file.open()
771 if self.source.startswith("file://"):
772 return open(self.source.replace("file://", "", 1), "rb")
773
774 def get_audio_data(self):
775 audio_file = self.get_audio_file()
776 if not audio_file:
777 return
778 audio_data = utils.get_audio_file_data(audio_file)
779 if not audio_data:
780 return
781 return {
782 "duration": int(audio_data["length"]),
783 "bitrate": audio_data["bitrate"],
784 "size": self.get_file_size(),
785 }
786
787 def get_audio_segment(self):
788 input = self.get_audio_file()
789 if not input:
790 return
791
792 input_format = utils.MIMETYPE_TO_EXTENSION[self.mimetype]
793 audio = pydub.AudioSegment.from_file(input, format=input_format)
794 return audio
795
796 def save(self, **kwargs):
797 if not self.mimetype:
798 if self.audio_file:
799 self.mimetype = utils.guess_mimetype(self.audio_file)
800 elif self.source and self.source.startswith("file://"):
801 self.mimetype = mimetypes.guess_type(self.source)[0]
802 if not self.size and self.audio_file:
803 self.size = self.audio_file.size
804 if not self.pk and not self.fid and self.library.actor.get_user():
805 self.fid = self.get_federation_id()
806 return super().save(**kwargs)
807
808 def get_metadata(self):
809 audio_file = self.get_audio_file()
810 if not audio_file:
811 return
812 return metadata.Metadata(audio_file)
813
814 @property
815 def listen_url(self):
816 return self.track.listen_url + "?upload={}".format(self.uuid)
817
818 def get_transcoded_version(self, format):
819 mimetype = utils.EXTENSION_TO_MIMETYPE[format]
820 existing_versions = list(self.versions.filter(mimetype=mimetype))
821 if existing_versions:
822 # we found an existing version, no need to transcode again
823 return existing_versions[0]
824
825 return self.create_transcoded_version(mimetype, format)
826
827 @transaction.atomic
828 def create_transcoded_version(self, mimetype, format):
829 # we create the version with an empty file, then
830 # we'll write to it
831 f = ContentFile(b"")
832 version = self.versions.create(
833 mimetype=mimetype, bitrate=self.bitrate or 128000, size=0
834 )
835 # we keep the same name, but we update the extension
836 new_name = os.path.splitext(os.path.basename(self.audio_file.name))[
837 0
838 ] + ".{}".format(format)
839 version.audio_file.save(new_name, f)
840 utils.transcode_audio(
841 audio=self.get_audio_segment(),
842 output=version.audio_file,
843 output_format=utils.MIMETYPE_TO_EXTENSION[mimetype],
844 )
845 version.size = version.audio_file.size
846 version.save(update_fields=["size"])
847
848 return version
849
850 @property
851 def in_place_path(self):
852 if not self.source or not self.source.startswith("file://"):
853 return
854 return self.source.lstrip("file://")
855
856
857MIMETYPE_CHOICES = [(mt, ext) for ext, mt in utils.AUDIO_EXTENSIONS_AND_MIMETYPE]
858
859
860class UploadVersion(models.Model):
861 upload = models.ForeignKey(
862 Upload, related_name="versions", on_delete=models.CASCADE
863 )
864 mimetype = models.CharField(max_length=50, choices=MIMETYPE_CHOICES)
865 creation_date = models.DateTimeField(default=timezone.now)
866 accessed_date = models.DateTimeField(null=True, blank=True)
867 audio_file = models.FileField(upload_to=get_file_path, max_length=255)
868 bitrate = models.PositiveIntegerField()
869 size = models.IntegerField()
870
871 class Meta:
872 unique_together = ("upload", "mimetype", "bitrate")
873
874 @property
875 def filename(self):
876 return self.upload.filename
877
878
879IMPORT_STATUS_CHOICES = (
880 ("pending", "Pending"),
881 ("finished", "Finished"),
882 ("errored", "Errored"),
883 ("skipped", "Skipped"),
884)
885
886
887class ImportBatch(models.Model):
888 uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
889 IMPORT_BATCH_SOURCES = [
890 ("api", "api"),
891 ("shell", "shell"),
892 ("federation", "federation"),
893 ]
894 source = models.CharField(
895 max_length=30, default="api", choices=IMPORT_BATCH_SOURCES
896 )
897 creation_date = models.DateTimeField(default=timezone.now)
898 submitted_by = models.ForeignKey(
899 "users.User",
900 related_name="imports",
901 null=True,
902 blank=True,
903 on_delete=models.CASCADE,
904 )
905 status = models.CharField(
906 choices=IMPORT_STATUS_CHOICES, default="pending", max_length=30
907 )
908 import_request = models.ForeignKey(
909 "requests.ImportRequest",
910 related_name="import_batches",
911 null=True,
912 blank=True,
913 on_delete=models.SET_NULL,
914 )
915 library = models.ForeignKey(
916 "Library",
917 related_name="import_batches",
918 null=True,
919 blank=True,
920 on_delete=models.CASCADE,
921 )
922
923 class Meta:
924 ordering = ["-creation_date"]
925
926 def __str__(self):
927 return str(self.pk)
928
929 def update_status(self):
930 old_status = self.status
931 self.status = utils.compute_status(self.jobs.all())
932 if self.status == old_status:
933 return
934 self.save(update_fields=["status"])
935 if self.status != old_status and self.status == "finished":
936 from . import tasks
937
938 tasks.import_batch_notify_followers.delay(import_batch_id=self.pk)
939
940 def get_federation_id(self):
941 return federation_utils.full_url(
942 "/federation/music/import/batch/{}".format(self.uuid)
943 )
944
945
946class ImportJob(models.Model):
947 uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
948 replace_if_duplicate = models.BooleanField(default=False)
949 batch = models.ForeignKey(
950 ImportBatch, related_name="jobs", on_delete=models.CASCADE
951 )
952 upload = models.ForeignKey(
953 Upload, related_name="jobs", null=True, blank=True, on_delete=models.CASCADE
954 )
955 source = models.CharField(max_length=500)
956 mbid = models.UUIDField(editable=False, null=True, blank=True)
957
958 status = models.CharField(
959 choices=IMPORT_STATUS_CHOICES, default="pending", max_length=30
960 )
961 audio_file = models.FileField(
962 upload_to="imports/%Y/%m/%d", max_length=255, null=True, blank=True
963 )
964
965 library_track = models.ForeignKey(
966 "federation.LibraryTrack",
967 related_name="import_jobs",
968 on_delete=models.SET_NULL,
969 null=True,
970 blank=True,
971 )
972 audio_file_size = models.IntegerField(null=True, blank=True)
973
974 class Meta:
975 ordering = ("id",)
976
977 def save(self, **kwargs):
978 if self.audio_file and not self.audio_file_size:
979 self.audio_file_size = self.audio_file.size
980 return super().save(**kwargs)
981
982
983LIBRARY_PRIVACY_LEVEL_CHOICES = [
984 (k, l) for k, l in fields.PRIVACY_LEVEL_CHOICES if k != "followers"
985]
986
987
988class LibraryQuerySet(models.QuerySet):
989 def with_follows(self, actor):
990 return self.prefetch_related(
991 models.Prefetch(
992 "received_follows",
993 queryset=federation_models.LibraryFollow.objects.filter(actor=actor),
994 to_attr="_follows",
995 )
996 )
997
998 def viewable_by(self, actor):
999 from funkwhale_api.federation.models import LibraryFollow
1000
1001 if actor is None:
1002 return Library.objects.filter(privacy_level="everyone")
1003
1004 me_query = models.Q(privacy_level="me", actor=actor)
1005 instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain)
1006 followed_libraries = LibraryFollow.objects.filter(
1007 actor=actor, approved=True
1008 ).values_list("target", flat=True)
1009 return Library.objects.filter(
1010 me_query
1011 | instance_query
1012 | models.Q(privacy_level="everyone")
1013 | models.Q(pk__in=followed_libraries)
1014 )
1015
1016
1017class Library(federation_models.FederationMixin):
1018 uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
1019 actor = models.ForeignKey(
1020 "federation.Actor", related_name="libraries", on_delete=models.CASCADE
1021 )
1022 followers_url = models.URLField(max_length=500)
1023 creation_date = models.DateTimeField(default=timezone.now)
1024 name = models.CharField(max_length=100)
1025 description = models.TextField(max_length=5000, null=True, blank=True)
1026 privacy_level = models.CharField(
1027 choices=LIBRARY_PRIVACY_LEVEL_CHOICES, default="me", max_length=25
1028 )
1029 uploads_count = models.PositiveIntegerField(default=0)
1030 objects = LibraryQuerySet.as_manager()
1031
1032 def get_federation_id(self):
1033 return federation_utils.full_url(
1034 reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid})
1035 )
1036
1037 def save(self, **kwargs):
1038 if not self.pk and not self.fid and self.actor.get_user():
1039 self.fid = self.get_federation_id()
1040 self.followers_url = self.fid + "/followers"
1041
1042 return super().save(**kwargs)
1043
1044 def should_autoapprove_follow(self, actor):
1045 if self.privacy_level == "everyone":
1046 return True
1047 if self.privacy_level == "instance" and actor.get_user():
1048 return True
1049 return False
1050
1051 def schedule_scan(self, actor, force=False):
1052 latest_scan = (
1053 self.scans.exclude(status="errored").order_by("-creation_date").first()
1054 )
1055 delay_between_scans = datetime.timedelta(seconds=3600 * 24)
1056 now = timezone.now()
1057 if (
1058 not force
1059 and latest_scan
1060 and latest_scan.creation_date + delay_between_scans > now
1061 ):
1062 return
1063
1064 scan = self.scans.create(total_files=self.uploads_count, actor=actor)
1065 from . import tasks
1066
1067 common_utils.on_commit(tasks.start_library_scan.delay, library_scan_id=scan.pk)
1068 return scan
1069
1070
1071SCAN_STATUS = [
1072 ("pending", "pending"),
1073 ("scanning", "scanning"),
1074 ("errored", "errored"),
1075 ("finished", "finished"),
1076]
1077
1078
1079class LibraryScan(models.Model):
1080 actor = models.ForeignKey(
1081 "federation.Actor", null=True, blank=True, on_delete=models.CASCADE
1082 )
1083 library = models.ForeignKey(Library, related_name="scans", on_delete=models.CASCADE)
1084 total_files = models.PositiveIntegerField(default=0)
1085 processed_files = models.PositiveIntegerField(default=0)
1086 errored_files = models.PositiveIntegerField(default=0)
1087 status = models.CharField(default="pending", max_length=25)
1088 creation_date = models.DateTimeField(default=timezone.now)
1089 modification_date = models.DateTimeField(null=True, blank=True)
1090
1091
1092@receiver(post_save, sender=ImportJob)
1093def update_batch_status(sender, instance, **kwargs):
1094 instance.batch.update_status()
1095
1096
1097@receiver(post_save, sender=ImportBatch)
1098def update_request_status(sender, instance, created, **kwargs):
1099 update_fields = kwargs.get("update_fields", []) or []
1100 if not instance.import_request:
1101 return
1102
1103 if not created and "status" not in update_fields:
1104 return
1105
1106 r_status = instance.import_request.status
1107 status = instance.status
1108
1109 if status == "pending" and r_status == "pending":
1110 # let's mark the request as accepted since we started an import
1111 instance.import_request.status = "accepted"
1112 return instance.import_request.save(update_fields=["status"])
1113
1114 if status == "finished" and r_status == "accepted":
1115 # let's mark the request as imported since the import is over
1116 instance.import_request.status = "imported"
1117 return instance.import_request.save(update_fields=["status"])
1118
1119
1120@receiver(models.signals.post_save, sender=Album)
1121def warm_album_covers(sender, instance, **kwargs):
1122 if not instance.cover or not settings.CREATE_IMAGE_THUMBNAILS:
1123 return
1124 album_covers_warmer = VersatileImageFieldWarmer(
1125 instance_or_queryset=instance, rendition_key_set="square", image_attr="cover"
1126 )
1127 num_created, failed_to_create = album_covers_warmer.warm()
api/funkwhale_api/playlists/models.py ¶
1from django.db import models
2from django.db import transaction
3from django.utils import timezone
4from funkwhale_api.common import fields
5from funkwhale_api.common import preferences
6from funkwhale_api.music import models as music_models
7from rest_framework import exceptions
8
9
10class PlaylistQuerySet(models.QuerySet):
11 def with_tracks_count(self):
12 return self.annotate(_tracks_count=models.Count("playlist_tracks"))
13
14 def with_duration(self):
15 return self.annotate(
16 duration=models.Sum("playlist_tracks__track__uploads__duration")
17 )
18
19 def with_covers(self):
20 album_prefetch = models.Prefetch(
21 "album", queryset=music_models.Album.objects.only("cover", "artist_id")
22 )
23 track_prefetch = models.Prefetch(
24 "track",
25 queryset=music_models.Track.objects.prefetch_related(album_prefetch).only(
26 "id", "album_id"
27 ),
28 )
29
30 plt_prefetch = models.Prefetch(
31 "playlist_tracks",
32 queryset=PlaylistTrack.objects.all()
33 .exclude(track__album__cover=None)
34 .exclude(track__album__cover="")
35 .order_by("index")
36 .only("id", "playlist_id", "track_id")
37 .prefetch_related(track_prefetch),
38 to_attr="plts_for_cover",
39 )
40 return self.prefetch_related(plt_prefetch)
41
42 def with_playable_plts(self, actor):
43 return self.prefetch_related(
44 models.Prefetch(
45 "playlist_tracks",
46 queryset=PlaylistTrack.objects.playable_by(actor),
47 to_attr="playable_plts",
48 )
49 )
50
51 def playable_by(self, actor, include=True):
52 plts = PlaylistTrack.objects.playable_by(actor, include)
53 if include:
54 return self.filter(playlist_tracks__in=plts).distinct()
55 else:
56 return self.exclude(playlist_tracks__in=plts).distinct()
57
58
59class Playlist(models.Model):
60 name = models.CharField(max_length=50)
61 user = models.ForeignKey(
62 "users.User", related_name="playlists", on_delete=models.CASCADE
63 )
64 creation_date = models.DateTimeField(default=timezone.now)
65 modification_date = models.DateTimeField(auto_now=True)
66 privacy_level = fields.get_privacy_field()
67
68 objects = PlaylistQuerySet.as_manager()
69
70 def __str__(self):
71 return self.name
72
73 @transaction.atomic
74 def insert(self, plt, index=None):
75 """
76 Given a PlaylistTrack, insert it at the correct index in the playlist,
77 and update other tracks index if necessary.
78 """
79 old_index = plt.index
80 move = old_index is not None
81 if index is not None and index == old_index:
82 # moving at same position, just skip
83 return index
84
85 existing = self.playlist_tracks.select_for_update()
86 if move:
87 existing = existing.exclude(pk=plt.pk)
88 total = existing.filter(index__isnull=False).count()
89
90 if index is None:
91 # we simply increment the last track index by 1
92 index = total
93
94 if index > total:
95 raise exceptions.ValidationError("Index is not continuous")
96
97 if index < 0:
98 raise exceptions.ValidationError("Index must be zero or positive")
99
100 if move:
101 # we remove the index temporarily, to avoid integrity errors
102 plt.index = None
103 plt.save(update_fields=["index"])
104 if index > old_index:
105 # new index is higher than current, we decrement previous tracks
106 to_update = existing.filter(index__gt=old_index, index__lte=index)
107 to_update.update(index=models.F("index") - 1)
108 if index < old_index:
109 # new index is lower than current, we increment next tracks
110 to_update = existing.filter(index__lt=old_index, index__gte=index)
111 to_update.update(index=models.F("index") + 1)
112 else:
113 to_update = existing.filter(index__gte=index)
114 to_update.update(index=models.F("index") + 1)
115
116 plt.index = index
117 plt.save(update_fields=["index"])
118 self.save(update_fields=["modification_date"])
119 return index
120
121 @transaction.atomic
122 def remove(self, index):
123 existing = self.playlist_tracks.select_for_update()
124 self.save(update_fields=["modification_date"])
125 to_update = existing.filter(index__gt=index)
126 return to_update.update(index=models.F("index") - 1)
127
128 @transaction.atomic
129 def insert_many(self, tracks):
130 existing = self.playlist_tracks.select_for_update()
131 now = timezone.now()
132 total = existing.filter(index__isnull=False).count()
133 max_tracks = preferences.get("playlists__max_tracks")
134 if existing.count() + len(tracks) > max_tracks:
135 raise exceptions.ValidationError(
136 "Playlist would reach the maximum of {} tracks".format(max_tracks)
137 )
138 self.save(update_fields=["modification_date"])
139 start = total
140 plts = [
141 PlaylistTrack(
142 creation_date=now, playlist=self, track=track, index=start + i
143 )
144 for i, track in enumerate(tracks)
145 ]
146 return PlaylistTrack.objects.bulk_create(plts)
147
148
149class PlaylistTrackQuerySet(models.QuerySet):
150 def for_nested_serialization(self, actor=None):
151 tracks = music_models.Track.objects.with_playable_uploads(actor)
152 tracks = tracks.select_related("artist", "album__artist")
153 return self.prefetch_related(
154 models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track")
155 )
156
157 def annotate_playable_by_actor(self, actor):
158 tracks = (
159 music_models.Upload.objects.playable_by(actor)
160 .filter(track__pk=models.OuterRef("track"))
161 .order_by("id")
162 .values("id")[:1]
163 )
164 subquery = models.Subquery(tracks)
165 return self.annotate(is_playable_by_actor=subquery)
166
167 def playable_by(self, actor, include=True):
168 tracks = music_models.Track.objects.playable_by(actor, include)
169 if include:
170 return self.filter(track__pk__in=tracks).distinct()
171 else:
172 return self.exclude(track__pk__in=tracks).distinct()
173
174
175class PlaylistTrack(models.Model):
176 track = models.ForeignKey(
177 "music.Track", related_name="playlist_tracks", on_delete=models.CASCADE
178 )
179 index = models.PositiveIntegerField(null=True, blank=True)
180 playlist = models.ForeignKey(
181 Playlist, related_name="playlist_tracks", on_delete=models.CASCADE
182 )
183 creation_date = models.DateTimeField(default=timezone.now)
184
185 objects = PlaylistTrackQuerySet.as_manager()
186
187 class Meta:
188 ordering = ("-playlist", "index")
189 unique_together = ("playlist", "index")
190
191 def delete(self, *args, **kwargs):
192 playlist = self.playlist
193 index = self.index
194 update_indexes = kwargs.pop("update_indexes", False)
195 r = super().delete(*args, **kwargs)
196 if index is not None and update_indexes:
197 playlist.remove(index)
198 return r
api/funkwhale_api/radios/models.py ¶
1from django.contrib.contenttypes.fields import GenericForeignKey
2from django.contrib.contenttypes.models import ContentType
3from django.contrib.postgres.fields import JSONField
4from django.core.serializers.json import DjangoJSONEncoder
5from django.db import models
6from django.utils import timezone
7from funkwhale_api.music.models import Track
8
9from . import filters
10
11
12class Radio(models.Model):
13 CONFIG_VERSION = 0
14 user = models.ForeignKey(
15 "users.User",
16 related_name="radios",
17 null=True,
18 blank=True,
19 on_delete=models.CASCADE,
20 )
21 name = models.CharField(max_length=100)
22 description = models.TextField(blank=True)
23 creation_date = models.DateTimeField(default=timezone.now)
24 is_public = models.BooleanField(default=False)
25 version = models.PositiveIntegerField(default=0)
26 config = JSONField(encoder=DjangoJSONEncoder)
27
28 def get_candidates(self):
29 return filters.run(self.config)
30
31
32class RadioSession(models.Model):
33 user = models.ForeignKey(
34 "users.User",
35 related_name="radio_sessions",
36 null=True,
37 blank=True,
38 on_delete=models.CASCADE,
39 )
40 session_key = models.CharField(max_length=100, null=True, blank=True)
41 radio_type = models.CharField(max_length=50)
42 custom_radio = models.ForeignKey(
43 Radio, related_name="sessions", null=True, blank=True, on_delete=models.CASCADE
44 )
45 creation_date = models.DateTimeField(default=timezone.now)
46 related_object_content_type = models.ForeignKey(
47 ContentType, blank=True, null=True, on_delete=models.CASCADE
48 )
49 related_object_id = models.PositiveIntegerField(blank=True, null=True)
50 related_object = GenericForeignKey(
51 "related_object_content_type", "related_object_id"
52 )
53
54 def save(self, **kwargs):
55 self.radio.clean(self)
56 super().save(**kwargs)
57
58 @property
59 def next_position(self):
60 next_position = 1
61
62 last_session_track = self.session_tracks.all().order_by("-position").first()
63 if last_session_track:
64 next_position = last_session_track.position + 1
65
66 return next_position
67
68 def add(self, track):
69 new_session_track = RadioSessionTrack.objects.create(
70 track=track, session=self, position=self.next_position
71 )
72
73 return new_session_track
74
75 @property
76 def radio(self):
77 from .registries import registry
78
79 return registry[self.radio_type](session=self)
80
81
82class RadioSessionTrack(models.Model):
83 session = models.ForeignKey(
84 RadioSession, related_name="session_tracks", on_delete=models.CASCADE
85 )
86 position = models.IntegerField(default=1)
87 track = models.ForeignKey(
88 Track, related_name="radio_session_tracks", on_delete=models.CASCADE
89 )
90
91 class Meta:
92 ordering = ("session", "position")
93 unique_together = ("session", "position")
api/funkwhale_api/requests/models.py ¶
1from django.db import models
2from django.utils import timezone
3
4NATURE_CHOICES = [("artist", "artist"), ("album", "album"), ("track", "track")]
5
6STATUS_CHOICES = [
7 ("pending", "pending"),
8 ("accepted", "accepted"),
9 ("imported", "imported"),
10 ("closed", "closed"),
11]
12
13
14class ImportRequest(models.Model):
15 creation_date = models.DateTimeField(default=timezone.now)
16 imported_date = models.DateTimeField(null=True, blank=True)
17 user = models.ForeignKey(
18 "users.User", related_name="import_requests", on_delete=models.CASCADE
19 )
20 artist_name = models.CharField(max_length=250)
21 albums = models.CharField(max_length=3000, null=True, blank=True)
22 status = models.CharField(choices=STATUS_CHOICES, max_length=50, default="pending")
23 comment = models.TextField(null=True, blank=True, max_length=3000)
api/funkwhale_api/users/models.py ¶
1from __future__ import absolute_import
2from __future__ import unicode_literals
3
4import binascii
5import datetime
6import os
7import random
8import string
9import uuid
10
11from django.conf import settings
12from django.contrib.auth.models import AbstractUser
13from django.db import models
14from django.dispatch import receiver
15from django.urls import reverse
16from django.utils import timezone
17from django.utils.encoding import python_2_unicode_compatible
18from django.utils.translation import ugettext_lazy as _
19from django_auth_ldap.backend import populate_user as ldap_populate_user
20from funkwhale_api.common import fields
21from funkwhale_api.common import preferences
22from funkwhale_api.common import utils as common_utils
23from funkwhale_api.common import validators as common_validators
24from funkwhale_api.federation import keys
25from funkwhale_api.federation import models as federation_models
26from funkwhale_api.federation import utils as federation_utils
27from versatileimagefield.fields import VersatileImageField
28from versatileimagefield.image_warmer import VersatileImageFieldWarmer
29
30
31def get_token():
32 return binascii.b2a_hex(os.urandom(15)).decode("utf-8")
33
34
35PERMISSIONS_CONFIGURATION = {
36 "moderation": {
37 "label": "Moderation",
38 "help_text": "Block/mute/remove domains, users and content",
39 },
40 "library": {
41 "label": "Manage library",
42 "help_text": "Manage library, delete files, tracks, artists, albums...",
43 },
44 "settings": {"label": "Manage instance-level settings", "help_text": ""},
45}
46
47PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys())
48
49
50get_file_path = common_utils.ChunkedPath("users/avatars", preserve_file_name=False)
51
52
53@python_2_unicode_compatible
54class User(AbstractUser):
55
56 # First Name and Last Name do not cover name patterns
57 # around the globe.
58 name = models.CharField(_("Name of User"), blank=True, max_length=255)
59
60 # updated on logout or password change, to invalidate JWT
61 secret_key = models.UUIDField(default=uuid.uuid4, null=True)
62 privacy_level = fields.get_privacy_field()
63
64 # Unfortunately, Subsonic API assumes a MD5/password authentication
65 # scheme, which is weak in terms of security, and not achievable
66 # anyway since django use stronger schemes for storing passwords.
67 # Users that want to use the subsonic API from external client
68 # should set this token and use it as their password in such clients
69 subsonic_api_token = models.CharField(blank=True, null=True, max_length=255)
70
71 # permissions
72 permission_moderation = models.BooleanField(
73 PERMISSIONS_CONFIGURATION["moderation"]["label"],
74 help_text=PERMISSIONS_CONFIGURATION["moderation"]["help_text"],
75 default=False,
76 )
77 permission_library = models.BooleanField(
78 PERMISSIONS_CONFIGURATION["library"]["label"],
79 help_text=PERMISSIONS_CONFIGURATION["library"]["help_text"],
80 default=False,
81 )
82 permission_settings = models.BooleanField(
83 PERMISSIONS_CONFIGURATION["settings"]["label"],
84 help_text=PERMISSIONS_CONFIGURATION["settings"]["help_text"],
85 default=False,
86 )
87
88 last_activity = models.DateTimeField(default=None, null=True, blank=True)
89
90 invitation = models.ForeignKey(
91 "Invitation",
92 related_name="users",
93 null=True,
94 blank=True,
95 on_delete=models.SET_NULL,
96 )
97 avatar = VersatileImageField(
98 upload_to=get_file_path,
99 null=True,
100 blank=True,
101 max_length=150,
102 validators=[
103 common_validators.ImageDimensionsValidator(min_width=50, min_height=50),
104 common_validators.FileValidator(
105 allowed_extensions=["png", "jpg", "jpeg", "gif"],
106 max_size=1024 * 1024 * 2,
107 ),
108 ],
109 )
110 actor = models.OneToOneField(
111 "federation.Actor",
112 related_name="user",
113 on_delete=models.SET_NULL,
114 null=True,
115 blank=True,
116 )
117
118 upload_quota = models.PositiveIntegerField(null=True, blank=True)
119
120 def __str__(self):
121 return self.username
122
123 def get_permissions(self, defaults=None):
124 defaults = defaults or preferences.get("users__default_permissions")
125 perms = {}
126 for p in PERMISSIONS:
127 v = (
128 self.is_superuser
129 or getattr(self, "permission_{}".format(p))
130 or p in defaults
131 )
132 perms[p] = v
133 return perms
134
135 @property
136 def all_permissions(self):
137 return self.get_permissions()
138
139 def has_permissions(self, *perms, **kwargs):
140 operator = kwargs.pop("operator", "and")
141 if operator not in ["and", "or"]:
142 raise ValueError("Invalid operator {}".format(operator))
143 permissions = self.get_permissions()
144 checker = all if operator == "and" else any
145 return checker([permissions[p] for p in perms])
146
147 def get_absolute_url(self):
148 return reverse("users:detail", kwargs={"username": self.username})
149
150 def update_secret_key(self):
151 self.secret_key = uuid.uuid4()
152 return self.secret_key
153
154 def update_subsonic_api_token(self):
155 self.subsonic_api_token = get_token()
156 return self.subsonic_api_token
157
158 def set_password(self, raw_password):
159 super().set_password(raw_password)
160 self.update_secret_key()
161 if self.subsonic_api_token:
162 self.update_subsonic_api_token()
163
164 def get_activity_url(self):
165 return settings.FUNKWHALE_URL + "/@{}".format(self.username)
166
167 def record_activity(self):
168 """
169 Simply update the last_activity field if current value is too old
170 than a threshold. This is useful to keep a track of inactive accounts.
171 """
172 current = self.last_activity
173 delay = 60 * 15 # fifteen minutes
174 now = timezone.now()
175
176 if current is None or current < now - datetime.timedelta(seconds=delay):
177 self.last_activity = now
178 self.save(update_fields=["last_activity"])
179
180 def create_actor(self):
181 self.actor = create_actor(self)
182 self.save(update_fields=["actor"])
183 return self.actor
184
185 def get_upload_quota(self):
186 return self.upload_quota or preferences.get("users__upload_quota")
187
188 def get_quota_status(self):
189 data = self.actor.get_current_usage()
190 max_ = self.get_upload_quota()
191 return {
192 "max": max_,
193 "remaining": max(max_ - (data["total"] / 1000 / 1000), 0),
194 "current": data["total"] / 1000 / 1000,
195 "skipped": data["skipped"] / 1000 / 1000,
196 "pending": data["pending"] / 1000 / 1000,
197 "finished": data["finished"] / 1000 / 1000,
198 "errored": data["errored"] / 1000 / 1000,
199 }
200
201 def get_channels_groups(self):
202 groups = ["imports", "inbox"]
203
204 return ["user.{}.{}".format(self.pk, g) for g in groups]
205
206 def full_username(self):
207 return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
208
209
210def generate_code(length=10):
211 return "".join(
212 random.SystemRandom().choice(string.ascii_uppercase) for _ in range(length)
213 )
214
215
216class InvitationQuerySet(models.QuerySet):
217 def open(self, include=True):
218 now = timezone.now()
219 qs = self.annotate(_users=models.Count("users"))
220 query = models.Q(_users=0, expiration_date__gt=now)
221 if include:
222 return qs.filter(query)
223 return qs.exclude(query)
224
225
226class Invitation(models.Model):
227 creation_date = models.DateTimeField(default=timezone.now)
228 expiration_date = models.DateTimeField()
229 owner = models.ForeignKey(
230 User, related_name="invitations", on_delete=models.CASCADE
231 )
232 code = models.CharField(max_length=50, unique=True)
233
234 objects = InvitationQuerySet.as_manager()
235
236 def save(self, **kwargs):
237 if not self.code:
238 self.code = generate_code()
239 if not self.expiration_date:
240 self.expiration_date = self.creation_date + datetime.timedelta(
241 days=settings.USERS_INVITATION_EXPIRATION_DAYS
242 )
243
244 return super().save(**kwargs)
245
246
247def get_actor_data(username):
248 slugified_username = federation_utils.slugify_username(username)
249 return {
250 "preferred_username": slugified_username,
251 "domain": federation_models.Domain.objects.get_or_create(
252 name=settings.FEDERATION_HOSTNAME
253 )[0],
254 "type": "Person",
255 "name": username,
256 "manually_approves_followers": False,
257 "fid": federation_utils.full_url(
258 reverse(
259 "federation:actors-detail",
260 kwargs={"preferred_username": slugified_username},
261 )
262 ),
263 "shared_inbox_url": federation_models.get_shared_inbox_url(),
264 "inbox_url": federation_utils.full_url(
265 reverse(
266 "federation:actors-inbox",
267 kwargs={"preferred_username": slugified_username},
268 )
269 ),
270 "outbox_url": federation_utils.full_url(
271 reverse(
272 "federation:actors-outbox",
273 kwargs={"preferred_username": slugified_username},
274 )
275 ),
276 "followers_url": federation_utils.full_url(
277 reverse(
278 "federation:actors-followers",
279 kwargs={"preferred_username": slugified_username},
280 )
281 ),
282 "following_url": federation_utils.full_url(
283 reverse(
284 "federation:actors-following",
285 kwargs={"preferred_username": slugified_username},
286 )
287 ),
288 }
289
290
291def create_actor(user):
292 args = get_actor_data(user.username)
293 private, public = keys.get_key_pair()
294 args["private_key"] = private.decode("utf-8")
295 args["public_key"] = public.decode("utf-8")
296
297 return federation_models.Actor.objects.create(user=user, **args)
298
299
300@receiver(ldap_populate_user)
301def init_ldap_user(sender, user, ldap_user, **kwargs):
302 if not user.actor:
303 user.actor = create_actor(user)
304
305
306@receiver(models.signals.post_save, sender=User)
307def warm_user_avatar(sender, instance, **kwargs):
308 if not instance.avatar or not settings.CREATE_IMAGE_THUMBNAILS:
309 return
310 user_avatar_warmer = VersatileImageFieldWarmer(
311 instance_or_queryset=instance, rendition_key_set="square", image_attr="avatar"
312 )
313 num_created, failed_to_create = user_avatar_warmer.warm()