Funkwhale layout

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