b467a68bbb84cac5eb64cfb8fdb0a09d8b592c49
[audio.git] / src / archive / models.py
1 import io
2 import json
3 from os import unlink
4 import os.path
5 from urllib.parse import urljoin
6
7 from django.db import models
8 from time import sleep
9 from django.contrib.sites.models import Site
10 from django.utils.functional import cached_property
11 from django.utils.timezone import now
12 from django.utils.translation import gettext_lazy as _
13 from django_pglocks import advisory_lock
14 import requests
15 from archive.constants import status
16 from archive.settings import FILES_SAVE_PATH, ADVERT, ORGANIZATION, PROJECT
17 from archive.utils import OverwriteStorage, sha1_file
18 from youtube.utils import concat_audio, standardize_audio
19
20
21 class License(models.Model):
22     uri = models.CharField(max_length=255, unique=True)
23     name = models.CharField(max_length=255)
24
25     def __str__(self):
26         return self.name
27
28
29 class Project(models.Model):
30     """ an audiobook project, needed for specyfing sponsors """
31
32     name = models.CharField(max_length=128, unique=True, db_index=True, verbose_name="Nazwa")
33     sponsors = models.TextField(blank=True, null=True, verbose_name="Sponsorzy")
34     description = models.TextField(blank=True, verbose_name="Opis")
35     config = models.ForeignKey('Config', models.PROTECT)
36     youtube = models.ForeignKey('youtube.YouTube', models.PROTECT)
37     icon = models.FileField(upload_to='archive/project', blank=True, null=True)
38     info_flac = models.FileField(upload_to='archive/info_flac', blank=True)
39
40     class Meta:
41         verbose_name = _("project")
42         verbose_name_plural = _("projects")
43         ordering = ("name",)
44
45     def __str__(self):
46         return self.name
47
48     def get_description(self):
49         if self.description:
50             return self.description
51         return "Audiobook nagrany w ramach projektu %s%s." % (
52             self.name,
53             " finansowanego przez %s" % self.sponsors if self.sponsors else "",
54         )
55
56     def get_icon_url(self):
57         if not self.icon:
58             return ''
59         return urljoin(
60             'https://' + Site.objects.get_current().domain,
61             self.icon.url
62         )
63
64
65 class Config(models.Model):
66     name = models.CharField(max_length=255)
67     intro_flac = models.FileField(upload_to='config/intro_flac', blank=True)
68     intro_min_seconds = models.IntegerField()
69     outro_flac = models.FileField(upload_to='config/outro_flac', blank=True)
70     outro_min_seconds = models.IntegerField()
71
72     class Meta:
73         verbose_name = _("Configuration")
74         verbose_name_plural = _("Configurations")
75
76     def __str__(self):
77         return self.name
78
79     def prepare_audio(self, audiobook):
80         total_duration = audiobook.total_duration
81         files = []
82         if self.intro_flac and total_duration > self.intro_min_seconds and audiobook.is_first:
83             files.append(standardize_audio(self.intro_flac.path))
84         files.append(standardize_audio(audiobook.source_file.path))
85         if self.outro_flac and total_duration > self.outro_min_seconds and audiobook.is_last:
86             files.append(standardize_audio(self.outro_flac.path))
87         output = concat_audio(files)
88         for d in files:
89             unlink(d)
90         return output
91
92
93 def source_upload_to(intance, filename):
94     return os.path.join(FILES_SAVE_PATH, filename) # FIXME: what about really long file names?
95
96
97 class Audiobook(models.Model):
98     source_file = models.FileField(upload_to=source_upload_to, max_length=255, 
99             verbose_name=_('source file'), editable=False)
100     source_sha1 = models.CharField(max_length=40, editable=False)
101     duration = models.FloatField(null=True, editable=False)
102
103     title = models.CharField(max_length=255, verbose_name=_('title'))
104     part_name = models.CharField(max_length=255, verbose_name=_('part name'), help_text=_('eg. chapter in a novel'),
105                                  default='', blank=True)
106     index = models.IntegerField(verbose_name=_('index'), default=0, help_text=_('Ordering of parts of a book.'))
107     youtube_volume = models.CharField(
108         _("Volume name for YouTube"),
109         max_length=100,
110         blank=True,
111         help_text=_(
112             "If set, audiobooks with the save value will be published as single YouTube video."
113         ),
114     )
115     artist = models.CharField(max_length=255, verbose_name=_('artist'))
116     conductor = models.CharField(max_length=255, verbose_name=_('conductor'))
117     encoded_by = models.CharField(max_length=255, verbose_name=_('encoded by'))
118     date = models.CharField(max_length=255, verbose_name=_('date'))
119     project = models.ForeignKey(Project, models.PROTECT, verbose_name=_('project'))
120     slug = models.SlugField(max_length=120, blank=True, help_text=_('WL catalogue slug of the book.'))
121     translator = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('translator'))
122     modified = models.DateTimeField(null=True, editable=False)
123     license = models.ForeignKey(License, models.PROTECT, null=True, blank=True, verbose_name=_('license'))
124
125     # publishing process
126     mp3_status = models.SmallIntegerField(null=True, editable=False, choices=status.choices)
127     mp3_task = models.CharField(max_length=64, null=True, editable=False)
128     mp3_tags = models.TextField(null=True, editable=False)
129     mp3_file = models.FileField(null=True, upload_to='archive/final', storage=OverwriteStorage(), editable=False)
130     mp3_published_tags = models.TextField(null=True, editable=False)
131     mp3_published = models.DateTimeField(null=True, editable=False)
132
133     ogg_status = models.SmallIntegerField(null=True, editable=False, choices=status.choices)
134     ogg_task = models.CharField(max_length=64, null=True, editable=False)
135     ogg_tags = models.TextField(null=True, editable=False)
136     ogg_file = models.FileField(null=True, upload_to='archive/final', storage=OverwriteStorage(), editable=False)
137     ogg_published_tags = models.TextField(null=True, editable=False)
138     ogg_published = models.DateTimeField(null=True, editable=False)
139
140     youtube_status = models.SmallIntegerField(null=True, editable=False, choices=status.choices)
141     youtube_task = models.CharField(max_length=64, null=True, editable=False)
142     youtube_tags = models.TextField(null=True, editable=False)
143     youtube_published_tags = models.TextField(null=True, editable=False)
144     youtube_published = models.DateTimeField(null=True, editable=False)
145     youtube_id = models.CharField(max_length=255, blank=True, default='')
146     youtube_queued = models.DateTimeField(null=True, blank=True)
147
148     class Meta:
149         verbose_name = _("audiobook")
150         verbose_name_plural = _("audiobooks")
151         ordering = ("title",)
152
153     def __str__(self):
154         return self.title
155
156     @property
157     def url(self):
158         return f'https://wolnelektury.pl/katalog/lektura/{self.slug}/'
159
160     @property
161     def parts_count(self):
162         return type(self).objects.filter(slug=self.slug).count()
163
164     @property
165     def total_duration(self):
166         return type(self).objects.filter(slug=self.slug).aggregate(s=models.Sum('duration'))['s']
167
168     @property
169     def is_first(self):
170         return not type(self).objects.filter(slug=self.slug, index__lte=self.index).exclude(pk=self.pk).exists()
171
172     @property
173     def is_last(self):
174         return not type(self).objects.filter(slug=self.slug, index__gte=self.index).exclude(pk=self.pk).exists()
175     
176     @property
177     def youtube_volume_count(self):
178         total = 0
179         prev_volume = None
180         for a in type(self).objects.filter(slug=self.slug).order_by("index"):
181             if not a.youtube_volume or a.youtube_volume != prev_volume:
182                 total += 1
183             prev_volume = a.youtube_volume
184         return total
185
186     @property
187     def youtube_volume_index(self):
188         index = 0
189         prev_volume = None
190         for a in type(self).objects.filter(slug=self.slug, index__lte=self.index).order_by("index"):
191             if not a.youtube_volume or a.youtube_volume != prev_volume:
192                 index += 1
193             prev_volume = a.youtube_volume
194         return index
195
196     @property
197     def is_youtube_publishable(self):
198         return (
199             not self.youtube_volume
200             or not type(self)
201             .objects.filter(slug=self.slug, youtube_volume=self.youtube_volume, index__lt=self.index)
202             .exists()
203         )
204
205     def youtube_publish(self):
206         if not self.is_youtube_publishable:
207             return False
208         self.youtube_status = status.QUEUED
209         self.youtube_queued = now()
210         self.save(update_fields=['youtube_status', 'youtube_queued'])
211
212     def get_mp3_tags(self): return json.loads(self.mp3_tags) if self.mp3_tags else None
213     def get_ogg_tags(self): return json.loads(self.ogg_tags) if self.ogg_tags else None
214     def get_mp3_published_tags(self): return json.loads(self.mp3_published_tags) if self.mp3_published_tags else None
215     def get_ogg_published_tags_tags(self): return json.loads(self.ogg_published_tags) if self.ogg_published_tags else None
216     def set_mp3_tags(self, tags): self.mp3_tags = json.dumps(tags)
217     def set_ogg_tags(self, tags): self.ogg_tags = json.dumps(tags)
218
219     def published(self):
220         return self.mp3_published and self.ogg_published
221
222     def prepare_for_publish(self):
223         tags = {
224             'name': self.title,
225             'url': self.url,
226             'tags': self.new_publish_tags(),
227         }
228         self.set_mp3_tags(tags)
229         self.set_ogg_tags(tags)
230         self.mp3_status = self.ogg_status = status.WAITING
231         self.save()
232     
233     def publish(self, user, publish=True):
234         from . import tasks
235         # isn't there a race here?
236         self.mp3_task = tasks.Mp3Task.delay(user.id, self.pk, publish=publish).task_id
237         self.ogg_task = tasks.OggTask.delay(user.id, self.pk, publish=publish).task_id
238         self.save()
239
240     def get_source_sha1(self):
241         assert self.pk or self.source_sha1
242         if not self.source_sha1:
243             with advisory_lock(f'get_source_sha1_{self.pk}'):
244                 with open(self.source_file.path, 'rb') as f:
245                     self.source_sha1 = sha1_file(f)
246                 self.save(update_fields=['source_sha1'])
247         return self.source_sha1
248
249     def new_publish_tags(self):
250         title = self.title
251         if self.translator:
252             title += ' (tłum. %s)' % self.translator
253
254         copyright = "%s %s." % (
255                 self.date, ORGANIZATION)
256         if self.license:
257             copyright += " Licensed to the public under %s verify at %s" % (
258                 self.license.uri, self.url)
259
260         comment = "\n".join((
261             self.project.get_description(),
262             ADVERT
263         ))
264
265         tags = {
266             'album': PROJECT,
267             'albumartist': ORGANIZATION,
268             'artist': self.artist,
269             'comment': comment,
270             'conductor': self.conductor,
271             'contact': self.url,
272             'copyright': copyright,
273             'date': self.date,
274             'genre': 'Speech',
275             'language': 'pol',
276             'organization': ORGANIZATION,
277             'title': title,
278             'project': self.project.name,
279         }
280         if self.license:
281             tags['license'] = self.license.uri
282         if self.project.sponsors:
283             tags['funded_by'] = self.project.sponsors
284
285         if self.source_sha1:
286             tags['flac_sha1'] = self.source_sha1
287         return tags
288
289     def prepare_audio(self):
290         return self.project.config.prepare_audio(self)
291     
292     @cached_property
293     def book(self):
294         if self.slug:
295             apidata = requests.get(f'https://wolnelektury.pl/api/books/{self.slug}/').json()
296         else:
297             return {}
298         return apidata
299
300     @property
301     def document(self):
302         from librarian.document import WLDocument, parser
303         from lxml import etree
304
305         xml_url = self.book.get('xml', None)
306         if xml_url is None:
307             return None
308
309         return WLDocument(
310                 etree.parse(
311                     io.BytesIO(
312                         requests.get(xml_url).content
313                     )
314                     ,parser = parser
315                 )
316             )