Models for managing audio files used in experiments
Our examples will assume that a playlist with the following csv data has been added to an empty database:
(
"Lion King,Hakuna Matata,0.0,10.0,/my/experiment/lionking1.mp3,Disney,happy"
"Lion King,Hakuna Matata,30.0,10.0,/my/experiment/lionking2.mp3,Disney,happy"
"Frozen,Let It Go,0.0,10.0,/my/experiment/frozen1.mp3,Disney,sad"
"Frozen,Let It Go,30.0,10.0,/my/experiment/frozen2.mp3,Disney,sad"
"West Side Story,America,0.0,10.0,/my/experiment/westsidestory1.mp3,Musical,happy"
"West Side Story,America,30.0,10.0,/my/experiment/westsidestory2.mp3,Musical,happy"
"Porgy & Bess,Summertime,0.0,10.0,/my/experiment/porgyandbess1.mp3,Musical,sad"
"Porgy & Bess,Summertime,30.0,10.0,/my/experiment/porgyandbess2.mp3,Musical,sad"
)
Playlist
Bases: Model
A model defining a list of sections to be played in a Block
Attributes:
Name |
Type |
Description |
name |
str
|
|
url_prefix |
str
|
prefix for sections served from an external site
|
process_csv |
bool
|
whether a csv file should be processed to create or edit this playlist
|
csv |
str
|
a csv file which can be processed to create or edit this playlist
|
Source code in section/models.py
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 | class Playlist(models.Model):
"""A model defining a list of sections to be played in a Block
Attributes:
name (str): playlist name
url_prefix (str): prefix for sections served from an external site
process_csv (bool): whether a csv file should be processed to create or edit this playlist
csv (str): a csv file which can be processed to create or edit this playlist
"""
name = models.CharField(db_index=True, max_length=64)
url_prefix = models.CharField(max_length=128,
blank=True,
default='',
validators=[url_prefix_validator])
process_warning = 'Warning: Processing a live playlist may affect the result data'
process_csv = models.BooleanField(default=False, help_text=process_warning)
default_csv_row = 'CSV Format: artist_name [string],\
song_name [string], start_position [float], duration [float],\
"path/filename.mp3" [string], tag [string], group [string]'
csv = models.TextField(blank=True, help_text=default_csv_row)
CSV_OK = 0
CSV_ERROR = 10
def clean_csv(self):
errors = []
sections = Section.objects.filter(playlist=self)
for section in sections:
filename = str(section.filename)
try:
file_exists_validator(filename)
except ValidationError as e:
errors.append(e)
if errors:
raise ValidationError(errors)
return self.csv
def save(self, *args, **kwargs):
if self.process_csv is False and self.id:
self.csv = self._update_admin_csv()
if self.url_prefix and self.url_prefix[-1] != '/':
self.url_prefix += '/'
self.process_csv = False
super(Playlist, self).save(*args, **kwargs)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def _section_count(self):
"""Number of sections, as displayed in the admin interface"""
return self.section_set.count()
_section_count.short_description = "Sections"
def _block_count(self):
"""Number of Blocks"""
return self.block_set.count()
_block_count.short_description = "Blocks"
def _update_sections(self):
"""update sections associated with a Playlist object based on its `csv` field"""
# CSV empty
if len(self.csv) == 0:
# Delete all existing sections
self.section_set.all().delete()
return {
'status': self.CSV_OK,
'message': "No sections added. Deleted all existing sections."
}
# Store existing sections
existing_sections = [section for section in self.section_set.all()]
# Add new sections from csv
try:
reader = csv.DictReader(self.csv.splitlines(), fieldnames=(
'artist', 'name', 'start_time', 'duration', 'filename', 'tag', 'group'))
except csv.Error:
return {
'status': self.CSV_ERROR,
'message': "Error: could not initialize csv.DictReader"
}
def is_number(string):
try:
float(string)
return True
except ValueError:
return False
sections = []
updated = 0
lines = 0
for row in reader:
lines += 1
# Check for valid row length in csv. If it has less than 8 entries, csv.DictReader will assign None to values of missing keys
if None in row.values():
return {
'status': self.CSV_ERROR,
'message': "Error: Invalid row length, line: " + str(lines)
}
# check for valid numbers
if not (is_number(row['start_time'])
and is_number(row['duration'])):
return {
'status': self.CSV_ERROR,
'message': "Error: Expected number fields on line: " + str(lines)
}
# Retrieve or create Song object
song = None
if row['artist'] or row['name']:
song = get_or_create_song(row['artist'], row['name'])
# create new section
section = Section(playlist=self,
start_time=float(row['start_time']),
duration=float(row['duration']),
filename=row['filename'],
tag=row['tag'],
group=row['group'],
)
section.song = song
# if same section already exists, update it with new info
for ex_section in existing_sections:
if ex_section.filename == section.filename:
if song:
ex_section.song = song
ex_section.save()
ex_section.start_time = section.start_time
ex_section.duration = section.duration
ex_section.tag = section.tag
ex_section.group = section.group
ex_section.save()
updated += 1
# Remove from existing sections list
existing_sections.remove(ex_section)
section = None
break
# append section
if section:
sections.append(section)
# Add sections
Section.objects.bulk_create(sections)
# Remove obsolete sections
delete_ids = [ex_section.id for ex_section in existing_sections]
self.section_set.filter(pk__in=delete_ids).delete()
# Reset process csv option and save playlist
self.process_csv = False
self.save()
return {
'status': self.CSV_OK,
'message': "Sections processed from CSV. Added: " + str(len(sections)) + " - Updated: " + str(updated) + " - Removed: " + str(len(delete_ids))
}
def _export_admin(self):
"""Export data for admin"""
return {
"exportedAt": timezone.now().isoformat(),
"playlist": {
"id": self.id,
"name": self.name,
"sections": [
section._export_admin() for section in self.section_set.all()
],
},
}
def _export_sections(self):
# export section objects
return self.section_set.all()
def _update_admin_csv(self):
"""Update csv data for admin"""
csvfile = CsvStringBuilder()
writer = csv.writer(csvfile)
for section in self.section_set.all():
if section.song:
this_artist = section.artist_name()
this_name = section.song_name()
else:
this_artist = ''
this_name = ''
writer.writerow([this_artist,
this_name,
section.start_time,
section.duration,
section.filename,
section.tag,
section.group])
csv_string = csvfile.csv_string
return ''.join(csv_string)
def get_section(
self, filter_by: dict = {}, exclude: dict = {}, song_ids: list = []
):
"""Get a random section from this playlist
Optionally, limit to specific song_ids and filter conditions
`filter_by` and `exclude` use [Django's querying syntax](https://docs.djangoproject.com/en/4.2/topics/db/queries/)
Attributes:
filter_by: a dictionary defining conditions a section should meet
exclude: a dictionary defining conditions by which to exclude sections from selection
song_ids: a list of identifiers of `Song` objects from which the section should be sampled
Examples:
>>> playlist.get_section(exclude={'group': 'Disney})
West Side Story - America (0.0 - 10.0) OR West Side Story - America (30.0 - 40.0) OR
Porgy and Bess - Summertime (0.0 - 10.0) OR Porgy and Bess - Summertime (30.0 - 40.0)
>>> example_playlist.get_section({'tag': 'happy', 'start_time__gt': 20.0})
West Side Story - America (30.0 - 40.0) OR Lion King - Hakuna Matata (30.0 - 40.0)
>>> playlist.get_section(song_ids=[1])
Frozen - Let It Go (0.0 - 10.0) OR Frozen - Let It Go (30.0 - 40.0)
"""
if song_ids:
sections = self.section_set.filter(song__id__in=song_ids)
else:
sections = self.section_set
pks = (
sections.exclude(**exclude).filter(**filter_by).values_list("pk", flat=True)
)
if len(pks) == 0:
raise Section.DoesNotExist
return self.section_set.get(pk=random.choice(pks))
|
get_section(filter_by={}, exclude={}, song_ids=[])
Get a random section from this playlist
Optionally, limit to specific song_ids and filter conditions
filter_by
and exclude
use Django’s querying syntax
Attributes:
Name |
Type |
Description |
filter_by |
|
a dictionary defining conditions a section should meet
|
exclude |
|
a dictionary defining conditions by which to exclude sections from selection
|
song_ids |
|
a list of identifiers of Song objects from which the section should be sampled
|
Examples:
>>> playlist.get_section(exclude={'group': 'Disney})
West Side Story - America (0.0 - 10.0) OR West Side Story - America (30.0 - 40.0) OR
Porgy and Bess - Summertime (0.0 - 10.0) OR Porgy and Bess - Summertime (30.0 - 40.0)
>>> example_playlist.get_section({'tag': 'happy', 'start_time__gt': 20.0})
West Side Story - America (30.0 - 40.0) OR Lion King - Hakuna Matata (30.0 - 40.0)
>>> playlist.get_section(song_ids=[1])
Frozen - Let It Go (0.0 - 10.0) OR Frozen - Let It Go (30.0 - 40.0)
Source code in section/models.py
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 | def get_section(
self, filter_by: dict = {}, exclude: dict = {}, song_ids: list = []
):
"""Get a random section from this playlist
Optionally, limit to specific song_ids and filter conditions
`filter_by` and `exclude` use [Django's querying syntax](https://docs.djangoproject.com/en/4.2/topics/db/queries/)
Attributes:
filter_by: a dictionary defining conditions a section should meet
exclude: a dictionary defining conditions by which to exclude sections from selection
song_ids: a list of identifiers of `Song` objects from which the section should be sampled
Examples:
>>> playlist.get_section(exclude={'group': 'Disney})
West Side Story - America (0.0 - 10.0) OR West Side Story - America (30.0 - 40.0) OR
Porgy and Bess - Summertime (0.0 - 10.0) OR Porgy and Bess - Summertime (30.0 - 40.0)
>>> example_playlist.get_section({'tag': 'happy', 'start_time__gt': 20.0})
West Side Story - America (30.0 - 40.0) OR Lion King - Hakuna Matata (30.0 - 40.0)
>>> playlist.get_section(song_ids=[1])
Frozen - Let It Go (0.0 - 10.0) OR Frozen - Let It Go (30.0 - 40.0)
"""
if song_ids:
sections = self.section_set.filter(song__id__in=song_ids)
else:
sections = self.section_set
pks = (
sections.exclude(**exclude).filter(**filter_by).values_list("pk", flat=True)
)
if len(pks) == 0:
raise Section.DoesNotExist
return self.section_set.get(pk=random.choice(pks))
|
Section
Bases: Model
A snippet/section of a song, belonging to a Playlist
Attributes:
Name |
Type |
Description |
playlist |
Playlist
|
a Many-To-One relationship to a Playlist object
|
song |
Song
|
a Many-To-One relationship to a Playlist object (can be null)
|
start_time |
float
|
the start time of the section in seconds, typically 0.0
|
duration |
float
|
the duration of the section in seconds, typically the duration of the audio file
|
filename |
str
|
the filename on the local file system or a link to an external file
|
play_count |
int
|
a counter for how often a given section has been played
|
tag |
str
|
a string with which to categorize the section
|
group |
str
|
another string with which to categorize the section
|
Examples:
After adding the example playlist, the database would contain 8 Section objects
Source code in section/models.py
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 | class Section(models.Model):
"""A snippet/section of a song, belonging to a Playlist
Attributes:
playlist (Playlist): a Many-To-One relationship to a Playlist object
song (Song): a Many-To-One relationship to a Playlist object (can be null)
start_time (float): the start time of the section in seconds, typically 0.0
duration (float): the duration of the section in seconds, typically the duration of the audio file
filename (str): the filename on the local file system or a link to an external file
play_count (int): a counter for how often a given section has been played
tag (str): a string with which to categorize the section
group (str): another string with which to categorize the section
Examples:
After adding the example playlist, the database would contain 8 Section objects
"""
playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE)
song = models.ForeignKey(Song, on_delete=models.CASCADE, blank=True, null=True)
start_time = models.FloatField(db_index=True, default=0.0) # start time in seconds
duration = models.FloatField(default=0.0) # end time in seconds
filename = models.FileField(
upload_to=_audio_upload_path,
max_length=255,
validators=[audio_file_validator()],
)
play_count = models.PositiveIntegerField(default=0)
tag = models.CharField(max_length=128, default='0', blank=True)
group = models.CharField(max_length=128, default='0', blank=True)
class Meta:
ordering = ['song__artist', 'song__name', 'start_time']
def __str__(self):
return f"{self.song_label()} ({self.start_time_str()}-{self.end_time_str()})"
def artist_name(self, placeholder: str = "") -> str:
"""
Attributes:
placeholder: a placeholder in case the section does not have an associated Song
Returns:
artist of associated song or placeholder
"""
if self.song:
return self.song.artist
else:
return placeholder
def song_name(self, placeholder: str = "") -> str:
"""
Attributes:
placeholder: a placeholder in case the section does not have an associated Song
Returns:
name of associated song or placeholder
"""
if self.song:
return self.song.name
else:
return placeholder
def song_label(self) -> str:
"""
Returns:
formatted artist and name of associated song, if available
"""
if self.artist_name() or self.song_name():
return f"{self.artist_name()} - {self.song_name()}"
return ""
def start_time_str(self) -> str:
"""
Returns:
the start time in minutes:seconds.milliseconds format
"""
return datetime.datetime.strftime(
datetime.datetime.fromtimestamp(self.start_time), "%M:%S.%f"
)[:-3]
def end_time_str(self) -> str:
"""
Returns:
the end time in minutes:seconds.milliseconds format
"""
return datetime.datetime.strftime(
datetime.datetime.fromtimestamp(self.start_time + self.duration), "%M:%S.%f"
)[:-3]
def add_play_count(self):
"""Increase play count for this section"""
self.play_count += 1
def absolute_url(self) -> str:
"""
Returns:
a url consisting of the BASE_URL configured for Django, plus the filename
"""
base_url = getattr(settings, 'BASE_URL', '')
sections_url = reverse("section:section", args=[self.pk])
return base_url.rstrip('/') + sections_url
def _export_admin(self):
"""Export data for admin"""
return {
'id': self.id,
'artist': self.song.artist,
'name': self.song.name,
'play_count': self.play_count
}
def _export_admin_csv(self):
"""Export csv data for admin"""
return [
self.song.artist,
self.song.name,
self.start_time,
self.duration,
self.filename,
self.tag,
self.group,
]
|
absolute_url()
Returns:
Type |
Description |
str
|
a url consisting of the BASE_URL configured for Django, plus the filename
|
Source code in section/models.py
400
401
402
403
404
405
406
407 | def absolute_url(self) -> str:
"""
Returns:
a url consisting of the BASE_URL configured for Django, plus the filename
"""
base_url = getattr(settings, 'BASE_URL', '')
sections_url = reverse("section:section", args=[self.pk])
return base_url.rstrip('/') + sections_url
|
add_play_count()
Increase play count for this section
Source code in section/models.py
| def add_play_count(self):
"""Increase play count for this section"""
self.play_count += 1
|
artist_name(placeholder='')
Attributes:
Name |
Type |
Description |
placeholder |
|
a placeholder in case the section does not have an associated Song
|
Returns:
Type |
Description |
str
|
artist of associated song or placeholder
|
Source code in section/models.py
343
344
345
346
347
348
349
350
351
352
353
354 | def artist_name(self, placeholder: str = "") -> str:
"""
Attributes:
placeholder: a placeholder in case the section does not have an associated Song
Returns:
artist of associated song or placeholder
"""
if self.song:
return self.song.artist
else:
return placeholder
|
end_time_str()
Returns:
Type |
Description |
str
|
the end time in minutes:seconds.milliseconds format
|
Source code in section/models.py
387
388
389
390
391
392
393
394 | def end_time_str(self) -> str:
"""
Returns:
the end time in minutes:seconds.milliseconds format
"""
return datetime.datetime.strftime(
datetime.datetime.fromtimestamp(self.start_time + self.duration), "%M:%S.%f"
)[:-3]
|
song_label()
Returns:
Type |
Description |
str
|
formatted artist and name of associated song, if available
|
Source code in section/models.py
369
370
371
372
373
374
375
376 | def song_label(self) -> str:
"""
Returns:
formatted artist and name of associated song, if available
"""
if self.artist_name() or self.song_name():
return f"{self.artist_name()} - {self.song_name()}"
return ""
|
song_name(placeholder='')
Attributes:
Name |
Type |
Description |
placeholder |
|
a placeholder in case the section does not have an associated Song
|
Returns:
Type |
Description |
str
|
name of associated song or placeholder
|
Source code in section/models.py
356
357
358
359
360
361
362
363
364
365
366
367 | def song_name(self, placeholder: str = "") -> str:
"""
Attributes:
placeholder: a placeholder in case the section does not have an associated Song
Returns:
name of associated song or placeholder
"""
if self.song:
return self.song.name
else:
return placeholder
|
start_time_str()
Returns:
Type |
Description |
str
|
the start time in minutes:seconds.milliseconds format
|
Source code in section/models.py
378
379
380
381
382
383
384
385 | def start_time_str(self) -> str:
"""
Returns:
the start time in minutes:seconds.milliseconds format
"""
return datetime.datetime.strftime(
datetime.datetime.fromtimestamp(self.start_time), "%M:%S.%f"
)[:-3]
|
Song
Bases: Model
A Song object with an artist and name, artist / name combinations must be unique
Attributes:
Name |
Type |
Description |
artist |
str
|
|
name |
str
|
|
Examples:
After adding the example playlist, the database would contain 4 Song objects
Source code in section/models.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298 | class Song(models.Model):
"""A Song object with an artist and name, artist / name combinations must be unique
Attributes:
artist (str): the artist of a song
name (str): the name of a song
Examples:
After adding the example playlist, the database would contain 4 Song objects
"""
artist = models.CharField(db_index=True, blank=True, default='', max_length=128)
name = models.CharField(db_index=True, blank=True, default='', max_length=128)
class Meta:
unique_together = ("artist", "name")
|