/
api.py
846 lines (684 loc) · 33.5 KB
/
api.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
from datetime import datetime
import psycopg2
import orjson
from brainzutils.musicbrainz_db import engine as mb_engine
from brainzutils.ratelimit import ratelimit
from flask import Blueprint, request, jsonify, current_app
import listenbrainz.db.playlist as db_playlist
import listenbrainz.db.user as db_user
import listenbrainz.db.external_service_oauth as db_external_service_oauth
import listenbrainz.webserver.redis_connection as redis_connection
from listenbrainz.db.lb_radio_artist import lb_radio_artist
from data.model.external_service import ExternalServiceType
from listenbrainz.db import listens_importer, tags
from listenbrainz.db.exceptions import DatabaseException
from listenbrainz.listenstore.timescale_listenstore import TimescaleListenStoreException
from listenbrainz.webserver import timescale_connection, db_conn, ts_conn
from listenbrainz.webserver.decorators import api_listenstore_needed
from listenbrainz.webserver.decorators import crossdomain
from listenbrainz.webserver.errors import APIBadRequest, APIInternalServerError, APINotFound, APIServiceUnavailable, \
APIUnauthorized, ListenValidationError, APIForbidden
from listenbrainz.webserver.models import SubmitListenUserMetadata
from listenbrainz.webserver.utils import REJECT_LISTENS_WITHOUT_EMAIL_ERROR
from listenbrainz.webserver.views.api_tools import insert_payload, log_raise_400, validate_listen, \
is_valid_uuid, MAX_LISTEN_PAYLOAD_SIZE, MAX_LISTENS_PER_REQUEST, MAX_LISTEN_SIZE, LISTEN_TYPE_SINGLE, \
LISTEN_TYPE_IMPORT, _validate_get_endpoint_params, LISTEN_TYPE_PLAYING_NOW, validate_auth_header, \
get_non_negative_param, _parse_int_arg
api_bp = Blueprint('api_v1', __name__)
DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL = 25
SEARCH_USER_LIMIT = 10
@api_bp.route('/search/users/', methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def search_user():
"""Search a ListenBrainz-registered user.
:param search_term: Input on which search operation is to be performed.
"""
search_term = request.args.get("search_term")
if search_term:
users = db_user.search_user_name(db_conn, search_term, SEARCH_USER_LIMIT)
else:
users = []
return jsonify({'users': users})
@api_bp.route("/submit-listens", methods=["POST", "OPTIONS"])
@crossdomain
@ratelimit()
def submit_listen():
"""
Submit listens to the server. A user token (found on https://listenbrainz.org/settings/ ) must
be provided in the Authorization header! Each request should also contain at least one listen
in the payload.
Listens should be submitted for tracks when the user has listened to half the track or 4 minutes of
the track, whichever is lower. If the user hasn't listened to 4 minutes or half the track, it doesn't
fully count as a listen and should not be submitted.
For complete details on the format of the JSON to be POSTed to this endpoint, see :ref:`json-doc`.
:reqheader Authorization: Token <user token>
:reqheader Content-Type: *application/json*
:statuscode 200: listen(s) accepted.
:statuscode 400: invalid JSON sent, see error message for details.
:statuscode 401: invalid authorization. See error message for details.
:resheader Content-Type: *application/json*
"""
user = validate_auth_header(fetch_email=True)
if mb_engine and current_app.config["REJECT_LISTENS_WITHOUT_USER_EMAIL"] and not user["email"]:
raise APIUnauthorized(REJECT_LISTENS_WITHOUT_EMAIL_ERROR)
raw_data = request.get_data()
if len(raw_data) > MAX_LISTEN_PAYLOAD_SIZE:
log_raise_400(
"Payload too large. Payload cannot exceed %s bytes" % MAX_LISTEN_PAYLOAD_SIZE
)
try:
data = orjson.loads(raw_data.decode("utf-8"))
except ValueError as e:
log_raise_400("Cannot parse JSON document: %s" % e)
try:
if not isinstance(data, dict):
raise APIBadRequest("Invalid JSON document submitted. Top level of "
"JSON document should be a json object.")
payload = data['payload']
if not isinstance(payload, list):
raise APIBadRequest("The payload in the JSON document should be a list of listens.", payload)
if len(payload) == 0:
log_raise_400(
"JSON document does not contain any listens", payload)
if len(payload) > MAX_LISTENS_PER_REQUEST:
log_raise_400(
"Too many listens. You may not submit more than %s listens at once." % MAX_LISTENS_PER_REQUEST
)
if len(raw_data) > len(payload) * MAX_LISTEN_SIZE:
log_raise_400("JSON document is too large. Each listens may not "
"be larger than %d bytes." % MAX_LISTEN_SIZE, payload)
if data['listen_type'] not in ('playing_now', 'single', 'import'):
log_raise_400(
"JSON document requires a valid listen_type key.", payload)
listen_type = _get_listen_type(data['listen_type'])
if (listen_type == LISTEN_TYPE_SINGLE or listen_type == LISTEN_TYPE_PLAYING_NOW) and len(payload) > 1:
log_raise_400("JSON document contains more than listen for a single/playing_now. "
"It should contain only one.", payload)
except KeyError:
log_raise_400("Invalid JSON document submitted.", raw_data)
try:
# validate listens to make sure json is okay
validated_payload = [validate_listen(listen, listen_type) for listen in payload]
except ListenValidationError as err:
raise APIBadRequest(err.message, err.payload)
user_metadata = SubmitListenUserMetadata(user_id=user['id'], musicbrainz_id=user['musicbrainz_id'])
insert_payload(validated_payload, user_metadata, listen_type)
return jsonify({'status': 'ok'})
@api_bp.route("/user/<user_name>/listens", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
@api_listenstore_needed
def get_listens(user_name):
"""
Get listens for user ``user_name``. The format for the JSON returned is defined in our :ref:`json-doc`.
If none of the optional arguments are given, this endpoint will return the :data:`~webserver.views.api.DEFAULT_ITEMS_PER_GET` most recent listens.
The optional ``max_ts`` and ``min_ts`` UNIX epoch timestamps control at which point in time to start returning listens. You may specify max_ts or
min_ts, but not both in one call. Listens are always returned in descending timestamp order.
:param max_ts: If you specify a ``max_ts`` timestamp, listens with listened_at less than (but not including) this value will be returned.
:param min_ts: If you specify a ``min_ts`` timestamp, listens with listened_at greater than (but not including) this value will be returned.
:param count: Optional, number of listens to return. Default: :data:`~webserver.views.api.DEFAULT_ITEMS_PER_GET` . Max: :data:`~webserver.views.api.MAX_ITEMS_PER_GET`
:statuscode 200: Yay, you have data!
:statuscode 404: The requested user was not found.
:resheader Content-Type: *application/json*
"""
user = db_user.get_by_mb_id(db_conn, user_name)
if user is None:
raise APINotFound("Cannot find user: %s" % user_name)
min_ts, max_ts, count = _validate_get_endpoint_params()
if min_ts and max_ts and min_ts >= max_ts:
raise APIBadRequest("min_ts should be less than max_ts")
listens, min_ts_per_user, max_ts_per_user = timescale_connection._ts.fetch_listens(
user,
limit=count,
from_ts=datetime.utcfromtimestamp(min_ts) if min_ts else None,
to_ts=datetime.utcfromtimestamp(max_ts) if max_ts else None
)
listen_data = []
for listen in listens:
listen_data.append(listen.to_api())
return jsonify({'payload': {
'user_id': user_name,
'count': len(listen_data),
'listens': listen_data,
'latest_listen_ts': int(max_ts_per_user.timestamp()),
'oldest_listen_ts': int(min_ts_per_user.timestamp()),
}})
@api_bp.route("/user/<user_name>/listen-count", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
@api_listenstore_needed
def get_listen_count(user_name):
"""
Get the number of listens for a user ``user_name``.
The returned listen count has an element 'payload' with only key: 'count'
which unsurprisingly contains the listen count for the user.
:statuscode 200: Yay, you have listen counts!
:statuscode 404: The requested user was not found.
:resheader Content-Type: *application/json*
"""
user = db_user.get_by_mb_id(db_conn, user_name)
if user is None:
raise APINotFound("Cannot find user: %s" % user_name)
try:
listen_count = timescale_connection._ts.get_listen_count_for_user(user["id"])
except psycopg2.OperationalError as err:
current_app.logger.error("cannot fetch user listen count: ", str(err))
raise APIServiceUnavailable(
"Cannot fetch user listen count right now.")
return jsonify({'payload': {
'count': listen_count
}})
@api_bp.route("/user/<user_name>/playing-now", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def get_playing_now(user_name):
"""
Get the listen being played right now for user ``user_name``.
This endpoint returns a JSON document with a single listen in the same format as the ``/user/<user_name>/listens`` endpoint,
with one key difference, there will only be one listen returned at maximum and the listen will not contain a ``listened_at`` element.
The format for the JSON returned is defined in our :ref:`json-doc`.
:statuscode 200: Yay, you have data!
:statuscode 404: The requested user was not found.
:resheader Content-Type: *application/json*
"""
user = db_user.get_by_mb_id(db_conn, user_name)
if user is None:
raise APINotFound("Cannot find user: %s" % user_name)
playing_now_listen = redis_connection._redis.get_playing_now(user['id'])
listen_data = []
count = 0
if playing_now_listen:
count = 1
listen_data = [playing_now_listen.to_api()]
return jsonify({
'payload': {
'count': count,
'user_id': user_name,
'playing_now': True,
'listens': listen_data,
},
})
@api_bp.route("/user/<user_name>/similar-users", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def get_similar_users(user_name):
"""
Get list of users who have similar music tastes (based on their listen history)
for a given user. Returns an array of dicts like these:
.. code-block:: json
{
"user_name": "hwnrwx",
"similarity": 0.1938480256
}
:param user_name: the MusicBrainz ID of the user whose similar users are being requested.
:statuscode 200: Yay, you have data!
:resheader Content-Type: *application/json*
:statuscode 404: The requested user was not found.
"""
user = db_user.get_by_mb_id(db_conn, user_name)
if not user:
raise APINotFound("User %s not found" % user_name)
similar_users = db_user.get_similar_users(db_conn, user['id'])
return jsonify({
"payload": [
{
"user_name": r["musicbrainz_id"],
"similarity": r["similarity"]
}
for r in similar_users
]
})
@api_bp.route("/user/<user_name>/similar-to/<other_user_name>", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def get_similar_to_user(user_name, other_user_name):
"""
Get the similarity of the user and the other user, based on their listening history.
Returns a single dict:
.. code-block:: json
{
"user_name": "other_user",
"similarity": 0.1938480256
}
:param user_name: the MusicBrainz ID of the the one user
:param other_user_name: the MusicBrainz ID of the other user whose similar users are
:statuscode 200: Yay, you have data!
:resheader Content-Type: *application/json*
:statuscode 404: The requested user was not found.
"""
user = db_user.get_by_mb_id(db_conn, user_name)
if not user:
raise APINotFound("User %s not found" % user_name)
similar_users = db_user.get_similar_users(db_conn, user['id'])
# Constructing an id-similarity map
id_similarity_map = {r["musicbrainz_id"]: r["similarity"] for r in similar_users}
try:
return jsonify({'payload': {"user_name": other_user_name, "similarity": id_similarity_map[other_user_name]}})
except (KeyError, AttributeError):
raise APINotFound("Similar-to user not found")
@api_bp.route('/latest-import', methods=['GET', 'POST', 'OPTIONS'])
@crossdomain
@ratelimit()
def latest_import():
"""
Get and update the timestamp of the newest listen submitted in previous imports to ListenBrainz.
In order to get the timestamp for a user, make a GET request to this endpoint. The data returned will
be JSON of the following format:
.. code-block:: json
{
"musicbrainz_id": "the MusicBrainz ID of the user",
"latest_import": "the timestamp of the newest listen submitted in previous imports. Defaults to 0"
}
:param user_name: the MusicBrainz ID of the user whose data is needed
:type user_name: ``str``
:statuscode 200: Yay, you have data!
:resheader Content-Type: *application/json*
In order to update the timestamp of a user, you'll have to provide a user token in the Authorization
Header. User tokens can be found on https://listenbrainz.org/settings/ .
The JSON that needs to be posted must contain a field named `ts` in the root with a valid unix timestamp.
:reqheader Authorization: Token <user token>
:reqheader Content-Type: *application/json*
:statuscode 200: latest import timestamp updated
:statuscode 400: invalid JSON sent, see error message for details.
:statuscode 401: invalid authorization. See error message for details.
:statuscode 404: user or service not found. See error message for details.
"""
if request.method == 'GET':
user_name = request.args.get('user_name', '')
service_name = request.args.get('service', 'lastfm')
try:
service = ExternalServiceType[service_name.upper()]
except KeyError:
raise APINotFound("Service does not exist: {}".format(service_name))
user = db_user.get_by_mb_id(db_conn, user_name)
if user is None:
raise APINotFound("Cannot find user: {user_name}".format(user_name=user_name))
latest_import_ts = listens_importer.get_latest_listened_at(db_conn, user["id"], service)
return jsonify({
'musicbrainz_id': user['musicbrainz_id'],
'latest_import': 0 if not latest_import_ts else int(latest_import_ts.strftime('%s'))
})
elif request.method == 'POST':
user = validate_auth_header()
try:
data = orjson.loads(request.get_data())
ts = int(data.get('ts', 0))
service_name = data.get('service', 'lastfm')
service = ExternalServiceType[service_name.upper()]
except (ValueError, KeyError):
raise APIBadRequest('Invalid data sent')
try:
last_import_ts = listens_importer.get_latest_listened_at(db_conn, user["id"], service)
last_import_ts = 0 if not last_import_ts else int(last_import_ts.strftime('%s'))
if ts > last_import_ts:
listens_importer.update_latest_listened_at(db_conn, user["id"], service, ts)
except DatabaseException:
current_app.logger.error("Error while updating latest import: ", exc_info=True)
raise APIInternalServerError('Could not update latest_import, try again')
return jsonify({'status': 'ok'})
@api_bp.route('/validate-token', methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def validate_token():
"""
Check whether a User Token is a valid entry in the database.
In order to query this endpoint, send a GET request with the Authorization
header set to the value ``Token [the token value]``.
.. note::
This endpoint also checks for `token` argument in query params
(example: /validate-token?token=token-to-check) if the Authorization
header is missing for backward compatibility.
A JSON response, with the following format, will be returned.
- If the given token is valid:
.. code-block:: json
{
"code": 200,
"message": "Token valid.",
"valid": true,
"user_name": "MusicBrainz ID of the user with the passed token"
}
- If the given token is invalid:
.. code-block:: json
{
"code": 200,
"message": "Token invalid.",
"valid": false,
}
:statuscode 200: The user token is valid/invalid.
:statuscode 400: No token was sent to the endpoint.
"""
header = request.headers.get('Authorization')
if header and header.lower().startswith("token "):
auth_token = header.split(" ")[1]
else:
# for backwards compatibility, check for auth token in query parameters as well
auth_token = request.args.get('token', '')
if not auth_token:
raise APIBadRequest("You need to provide an Authorization token.")
user = db_user.get_by_token(db_conn, auth_token)
if user is None:
return jsonify({
'code': 200,
'message': 'Token invalid.',
'valid': False,
})
else:
return jsonify({
'code': 200,
'message': 'Token valid.',
'valid': True,
'user_name': user['musicbrainz_id'],
})
@api_bp.route('/delete-listen', methods=['POST', 'OPTIONS'])
@crossdomain
@ratelimit()
@api_listenstore_needed
def delete_listen():
"""
Delete a particular listen from a user's listen history.
This checks for the correct authorization token and deletes the listen.
.. note::
The listen is not deleted immediately, but is scheduled for deletion, which
usually happens shortly after the hour.
The format of the JSON to be POSTed to this endpoint is:
.. code-block:: json
{
"listened_at": 1,
"recording_msid": "d23f4719-9212-49f0-ad08-ddbfbfc50d6f"
}
:reqheader Authorization: Token <user token>
:reqheader Content-Type: *application/json*
:statuscode 200: listen deleted.
:statuscode 400: invalid JSON sent, see error message for details.
:statuscode 401: invalid authorization. See error message for details.
:resheader Content-Type: *application/json*
"""
user = validate_auth_header()
data = request.json
if "listened_at" not in data:
log_raise_400("Listen timestamp missing.")
try:
listened_at = datetime.utcfromtimestamp(int(data["listened_at"]))
except ValueError:
log_raise_400("%s: Listen timestamp invalid." % data["listened_at"])
if "recording_msid" not in data:
log_raise_400("Recording MSID missing.")
recording_msid = data["recording_msid"]
if not is_valid_uuid(recording_msid):
log_raise_400("%s: Recording MSID format invalid." % recording_msid)
try:
timescale_connection._ts.delete_listen(listened_at=listened_at,
recording_msid=recording_msid, user_id=user["id"])
except TimescaleListenStoreException as e:
current_app.logger.error("Cannot delete listen for user: %s" % str(e))
raise APIServiceUnavailable(
"We couldn't delete the listen. Please try again later.")
except Exception as e:
current_app.logger.error("Cannot delete listen for user: %s" % str(e))
raise APIInternalServerError(
"We couldn't delete the listen. Please try again later.")
return jsonify({'status': 'ok'})
def serialize_playlists(playlists, playlist_count, count, offset):
"""
Serialize the playlist metadata for the get playlists commands.
"""
items = []
for playlist in playlists:
items.append(playlist.serialize_jspf())
return {"playlists": items,
"playlist_count": playlist_count,
"offset": offset,
"count": count}
@api_bp.route("/user/<playlist_user_name>/playlists", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def get_playlists_for_user(playlist_user_name):
"""
Fetch playlist metadata in JSPF format without recordings for the given user.
If a user token is provided in the Authorization header, return private playlists as well
as public playlists for that user.
:param count: The number of playlists to return (for pagination). Default
:data:`~webserver.views.api.DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL`
:type count: ``int``
:param offset: The offset of into the list of playlists to return (for pagination)
:type offset: ``int``
:statuscode 200: Yay, you have data!
:statuscode 404: User not found
:resheader Content-Type: *application/json*
"""
user = validate_auth_header(optional=True)
count = get_non_negative_param(
'count', DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL)
offset = get_non_negative_param('offset', 0)
playlist_user = db_user.get_by_mb_id(db_conn, playlist_user_name)
if playlist_user is None:
raise APINotFound("Cannot find user: %s" % playlist_user_name)
include_private = True if user and user["id"] == playlist_user["id"] else False
playlists, playlist_count = db_playlist.get_playlists_for_user(db_conn, ts_conn, playlist_user["id"],
include_private=include_private,
load_recordings=False, count=count, offset=offset)
return jsonify(serialize_playlists(playlists, playlist_count, count, offset))
@api_bp.route("/user/<playlist_user_name>/playlists/createdfor", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def get_playlists_created_for_user(playlist_user_name):
"""
Fetch playlist metadata in JSPF format without recordings that have been created for the user.
Createdfor playlists are all public, so no Authorization is needed for this call.
:param count: The number of playlists to return (for pagination). Default
:data:`~webserver.views.api.DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL`
:type count: ``int``
:param offset: The offset of into the list of playlists to return (for pagination)
:type offset: ``int``
:statuscode 200: Yay, you have data!
:statuscode 404: User not found
:resheader Content-Type: *application/json*
"""
count = get_non_negative_param(
'count', DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL)
offset = get_non_negative_param('offset', 0)
playlist_user = db_user.get_by_mb_id(db_conn, playlist_user_name)
if playlist_user is None:
raise APINotFound("Cannot find user: %s" % playlist_user_name)
playlists, playlist_count = db_playlist.get_playlists_created_for_user(
db_conn, ts_conn, playlist_user["id"], load_recordings=False, count=count, offset=offset
)
return jsonify(serialize_playlists(playlists, playlist_count, count, offset))
@api_bp.route("/user/<playlist_user_name>/playlists/collaborator", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def get_playlists_collaborated_on_for_user(playlist_user_name):
"""
Fetch playlist metadata in JSPF format without recordings for which a user is a collaborator.
If a playlist is private, it will only be returned if the caller is authorized to edit that playlist.
:param count: The number of playlists to return (for pagination). Default
:data:`~webserver.views.api.DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL`
:type count: ``int``
:param offset: The offset of into the list of playlists to return (for pagination)
:type offset: ``int``
:statuscode 200: Yay, you have data!
:statuscode 404: User not found
:resheader Content-Type: *application/json*
"""
user = validate_auth_header(optional=True)
count = get_non_negative_param(
'count', DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL)
offset = get_non_negative_param('offset', 0)
playlist_user = db_user.get_by_mb_id(db_conn, playlist_user_name)
if playlist_user is None:
raise APINotFound("Cannot find user: %s" % playlist_user_name)
# TODO: This needs to be passed to the DB layer
include_private = True if user and user["id"] == playlist_user["id"] else False
playlists, playlist_count = db_playlist.get_playlists_collaborated_on(db_conn, ts_conn,
playlist_user["id"],
include_private=include_private,
load_recordings=False,
count=count,
offset=offset)
return jsonify(serialize_playlists(playlists, playlist_count, count, offset))
@api_bp.route("/user/<playlist_user_name>/playlists/recommendations", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
@api_listenstore_needed
def user_recommendations(playlist_user_name):
"""
Fetch recommendation playlist metadata in JSPF format without recordings for playlist_user_name.
This endpoint only lists playlists that are to be shown on the listenbrainz.org recommendations
pages.
:statuscode 200: success
:statuscode 404: user not found
:resheader Content-Type: *application/json*
"""
playlist_user = db_user.get_by_mb_id(db_conn, playlist_user_name)
if playlist_user is None:
raise APINotFound("Cannot find user: %s" % playlist_user_name)
playlists = db_playlist.get_recommendation_playlists_for_user(db_conn, ts_conn, playlist_user.id)
return jsonify(serialize_playlists(playlists, len(playlists), 0, 0))
@api_bp.route("/user/<user_name>/services", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def get_service_details(user_name):
"""
Get list of services which are connected to a given user's account.
.. code-block:: json
{
"user_name": "hwnrwx",
"services": ["spotify"]
}
:param user_name: the MusicBrainz ID of the user whose similar users are being requested.
:resheader Content-Type: *application/json*
:statuscode 200: Yay, you have data!
:statuscode 401: Invalid authorization. See error message for details.
:statuscode 403: Forbidden, you do not have permissions to view this user's information.
:statuscode 404: The requested user was not found.
"""
user = validate_auth_header(fetch_email=True)
if user_name != user['musicbrainz_id']:
raise APIForbidden("You don't have permissions to view this user's information.")
services = db_external_service_oauth.get_services(db_conn, user["id"])
return jsonify({'user_name': user_name, 'services': services})
@api_bp.route("/lb-radio/tags", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def get_tags_dataset():
""" Get recordings for use in LB radio with the specified tags that match the requested criteria.
.. code-block:: json
:param tag: the MusicBrainz tag to fetch recordings for, this parameter can be specified multiple times. if more
than one tag is specified, the operator param should also be specified.
:param operator: specify AND to retrieve recordings that have all the tags, otherwise specify OR to retrieve
recordings that have any one of the tags.
:param pop_begin: percent is a measure of the recording's popularity, pop_begin denotes a preferred
lower bound on the popularity of recordings to be returned.
:param pop_end: percent is a measure of the recording's popularity, pop_end denotes a preferred
upper bound on the popularity of recordings to be returned.
:param count: number of recordings to return for the
:resheader Content-Type: *application/json*
:statuscode 200: Yay, you have data!
:statuscode 400: Invalid or missing param in request, see error message for details.
"""
tag = request.args.getlist("tag")
if tag is None:
raise APIBadRequest("tag param is missing")
operator = request.args.get("operator")
# if there is only one tag, then we can use any of the operator's query to retrieve data
if len(tag) == 1 and operator is None:
operator = "OR"
if operator is None:
raise APIBadRequest("multiple tags are specified but the operator param is missing")
operator = operator.upper()
if operator != "AND" and operator != "OR":
raise APIBadRequest("operator param should be either 'AND' or 'OR'")
try:
pop_begin = request.args.get("pop_begin")
if pop_begin is None:
raise APIBadRequest("pop_begin param is missing")
pop_begin = float(pop_begin) / 100
if pop_begin < 0 or pop_begin > 1:
raise APIBadRequest("pop_begin should be between the range: 0 to 100")
except ValueError:
raise APIBadRequest(f"pop_begin: '{pop_begin}' is not a valid number")
try:
pop_end = request.args.get("pop_end")
if pop_end is None:
raise APIBadRequest("pop_end param is missing")
pop_end = float(pop_end) / 100
if pop_end < 0 or pop_end > 1:
raise APIBadRequest("pop_end should be between the range: 0 to 100")
except ValueError:
raise APIBadRequest(f"pop_end: '{pop_end}' is not a valid number")
try:
count = request.args.get("count")
if count is None:
raise APIBadRequest("count param is missing")
count = int(count)
if count <= 0:
raise APIBadRequest("count should be a positive number")
except ValueError:
raise APIBadRequest(f"count: '{count}' is not a valid positive number")
if operator == "AND":
recordings = tags.get_and(tag, pop_begin, pop_end, count)
else:
recordings = tags.get_or(tag, pop_begin, pop_end, count)
return jsonify(recordings[:count])
def _get_listen_type(listen_type):
return {
'single': LISTEN_TYPE_SINGLE,
'import': LISTEN_TYPE_IMPORT,
'playing_now': LISTEN_TYPE_PLAYING_NOW
}.get(listen_type)
@api_bp.route("/lb-radio/artist/<seed_artist_mbid>", methods=['GET', 'OPTIONS'])
@crossdomain
@ratelimit()
def get_artist_radio_recordings(seed_artist_mbid):
""" Get recordings for use in LB radio with the given seed artist. The endpoint
returns a dict of all the similar artists, including the seed artist. For each artists,
there will be a list of dicts that contain recording_mbid, similar_artist_mbid and total_listen_count:
.. code-block:: json
{
"recording_mbid": "401c1a5d-56e7-434d-b07e-a14d4e7eb83c",
"similar_artist_mbid": "cb67438a-7f50-4f2b-a6f1-2bb2729fd538",
"similar_artist_name": "Boo Hoo Boys",
"total_listen_count": 232361
}
:param mode: mode is the LB radio mode to be used for this query. Must be one of "easy", "medium", "hard".
:param max_similar_artists: The maximum number of similar artists to return recordings for.
:param max_recordings_per_artist: The maximum number of recordings to return for each artist. If there are aren't enough recordings, all available recordings will be returned.
:param pop_begin: Popularity range percentage lower bound. A popularity range is given to narrow down the recordings into a smaller target group. The most popular recording(s) on LB have a pop percent of 100. The least popular recordings have a score of 0. This range is not coupled to the specified mode, but the mode would often determine the popularity range, so that less popular recordings can be returned on the medium and harder modes.
:param pop_end: Popularity range percentage upper bound. See above.
:resheader Content-Type: *application/json*
:statuscode 200: Yay, you have data!
:statuscode 400: Invalid or missing param in request, see error message for details.
"""
if not is_valid_uuid(seed_artist_mbid):
log_raise_400("Seed artist mbid is not a valid UUID.")
max_similar_artists = _parse_int_arg("max_similar_artists")
if max_similar_artists is None:
raise APIBadRequest("Argument max_similar_artists must be specified.")
max_recordings_per_artist = _parse_int_arg("max_recordings_per_artist")
if max_recordings_per_artist is None:
raise APIBadRequest("Argument max_recordings_per_artist must be specified.")
mode = request.args.get("mode")
if mode is None:
raise APIBadRequest("mode param is missing")
if mode not in ("easy", "medium", "hard"):
raise APIBadRequest("mode must be one of: easy, medium or hard.")
try:
pop_begin = request.args.get("pop_begin")
if pop_begin is None:
raise APIBadRequest("pop_begin param is missing")
pop_begin = float(pop_begin) / 100
if pop_begin < 0 or pop_begin > 1:
raise APIBadRequest("pop_begin should be between the range: 0 to 100")
except ValueError:
raise APIBadRequest(f"pop_begin: '{pop_begin}' is not a valid number")
try:
pop_end = request.args.get("pop_end")
if pop_end is None:
raise APIBadRequest("pop_end param is missing")
pop_end = float(pop_end) / 100
if pop_end < 0 or pop_end > 1:
raise APIBadRequest("pop_end should be between the range: 0 to 100")
except ValueError:
raise APIBadRequest(f"pop_end: '{pop_end}' is not a valid number")
return jsonify(lb_radio_artist(mode, seed_artist_mbid, max_similar_artists, max_recordings_per_artist, pop_begin, pop_end))