Audiobooks: narrators as authors, and top-level players. master
authorRadek Czajka <rczajka@rczajka.pl>
Tue, 19 Aug 2025 10:14:04 +0000 (12:14 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Tue, 19 Aug 2025 10:14:04 +0000 (12:14 +0200)
12 files changed:
src/catalogue/migrations/0050_book_narrators.py [new file with mode: 0644]
src/catalogue/migrations/0051_book_has_audio.py [new file with mode: 0644]
src/catalogue/models/book.py
src/catalogue/models/bookmedia.py
src/catalogue/static/player/player.js
src/catalogue/templates/catalogue/book_box.html
src/catalogue/templates/catalogue/book_detail.html
src/catalogue/templates/catalogue/book_list.html
src/catalogue/templates/catalogue/snippets/jplayer.html
src/catalogue/templates/catalogue/snippets/jplayer_link.html [deleted file]
src/catalogue/views.py
src/wolnelektury/static/2022/styles/components/_player.scss

diff --git a/src/catalogue/migrations/0050_book_narrators.py b/src/catalogue/migrations/0050_book_narrators.py
new file mode 100644 (file)
index 0000000..388f150
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-08-12 09:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='narrators',
+            field=models.ManyToManyField(blank=True, related_name='narrated', to='catalogue.tag'),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0051_book_has_audio.py b/src/catalogue/migrations/0051_book_has_audio.py
new file mode 100644 (file)
index 0000000..e333bff
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-08-12 10:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0050_book_narrators'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='has_audio',
+            field=models.BooleanField(default=False),
+        ),
+    ]
index ff09853..0400656 100644 (file)
@@ -7,6 +7,8 @@ from datetime import date, timedelta
 from random import randint
 import os.path
 import re
+from slugify import slugify
+from sortify import sortify
 from urllib.request import urlretrieve
 from django.apps import apps
 from django.conf import settings
@@ -94,6 +96,8 @@ class Book(models.Model):
     tags = managers.TagDescriptor(Tag)
     tag_relations = GenericRelation(Tag.intermediary_table_model)
     translators = models.ManyToManyField(Tag, blank=True)
+    narrators = models.ManyToManyField(Tag, blank=True, related_name='narrated')
+    has_audio = models.BooleanField(default=False)
 
     html_built = django.dispatch.Signal()
     published = django.dispatch.Signal()
@@ -266,17 +270,6 @@ class Book(models.Model):
             return sibling.get_first_text()
         return self.parent.get_next_text(inside=False)
 
-    def get_child_audiobook(self):
-        BookMedia = apps.get_model('catalogue', 'BookMedia')
-        if not BookMedia.objects.filter(book__ancestor=self).exists():
-            return None
-        for child in self.children.order_by('parent_number').all():
-            if child.has_mp3_file():
-                return child
-            child_sub = child.get_child_audiobook()
-            if child_sub is not None:
-                return child_sub
-
     def get_siblings(self):
         if not self.parent:
             return []
@@ -337,9 +330,6 @@ class Book(models.Model):
         else:
             return self.media.filter(type=type_).exists()
 
-    def has_audio(self):
-        return self.has_media('mp3')
-
     def get_media(self, type_):
         if self.has_media(type_):
             if type_ in Book.formats:
@@ -505,7 +495,7 @@ class Book(models.Model):
     def media_audio_epub(self):
         return self.get_media('audio.epub')
 
-    def get_audiobooks(self):
+    def get_audiobooks(self, with_children=False, processing=False):
         ogg_files = {}
         for m in self.media.filter(type='ogg').order_by().iterator():
             ogg_files[m.name] = m
@@ -531,13 +521,27 @@ class Book(models.Model):
                 media['ogg'] = ogg
             audiobooks.append(media)
 
-        projects = sorted(projects)
-        total_duration = '%d:%02d' % (
-            total_duration // 60,
-            total_duration % 60
-        )
+        if with_children:
+            for child in self.get_children():
+                ch_audiobooks, ch_projects, ch_duration = child.get_audiobooks(
+                    with_children=True, processing=True)
+                audiobooks.append({'part': child})
+                audiobooks += ch_audiobooks
+                projects.update(ch_projects)
+                total_duration += ch_duration
+
+        if not processing:
+            projects = sorted(projects)
+            total_duration = '%d:%02d' % (
+                total_duration // 60,
+                total_duration % 60
+            )
+
         return audiobooks, projects, total_duration
 
+    def get_audiobooks_with_children(self):
+        return self.get_audiobooks(with_children=True)
+    
     def wldocument(self, parse_dublincore=True, inherit=True):
         from catalogue.import_utils import ORMDocProvider
         from librarian.parser import WLDocument
@@ -830,6 +834,42 @@ class Book(models.Model):
     def references(self):
         return self.reference_set.all().select_related('entity')
 
+    def update_has_audio(self):
+        self.has_audio = False
+        if self.media.filter(type='mp3').exists():
+            self.has_audio = True
+        if self.descendant.filter(has_audio=True).exists():
+            self.has_audio = True
+        self.save(update_fields=['has_audio'])
+        if self.parent is not None:
+            self.parent.update_has_audio()
+
+    def update_narrators(self):
+        narrator_names = set()
+        for bm in self.media.filter(type='mp3'):
+            narrator_names.update(set(
+                a.strip() for a in re.split(r',|\si\s', bm.artist)
+            ))
+        narrators = []
+
+        for name in narrator_names:
+            if not name: continue
+            slug = slugify(name)
+            try:
+                t = Tag.objects.get(category='author', slug=slug)
+            except Tag.DoesNotExist:
+                sort_key = sortify(
+                    ' '.join(name.rsplit(' ', 1)[::-1]).lower()
+                )
+                t = Tag.objects.create(
+                    category='author',
+                    name_pl=name,
+                    slug=slug,
+                    sort_key=sort_key,
+                )
+            narrators.append(t)
+        self.narrators.set(narrators)
+
     @classmethod
     @transaction.atomic
     def repopulate_ancestors(cls):
index 0a1544f..87d7f78 100644 (file)
@@ -101,7 +101,9 @@ class BookMedia(models.Model):
         self.extra_info = json.dumps(extra_info)
         self.source_sha1 = self.read_source_sha1(self.file.path, self.type)
         self.duration = self.read_duration()
-        return super(BookMedia, self).save(*args, **kwargs)
+        super(BookMedia, self).save(*args, **kwargs)
+        self.book.update_narrators()
+        self.book.update_has_audio()
 
     def read_duration(self):
         try:
index cbf9026..f94dc87 100644 (file)
                 $(".c-media__caption .license", $root).html($(".license", elem).html());
                 $(".c-media__caption .project-logo", $root).html($(".project-icon", elem).html());
 
-                console.log('sm 1');
                 doesUpdateSynchro = false;
                 if (!$currentMedia || $currentMedia[0] != elem[0]) {
-                    console.log('set', player.jPlayer("setMedia", media))
                     player.jPlayer("option", "playbackRate", speed);
                 }
                 doesUpdateSynchro = true;
                 player.jPlayer(cmd, time);
 
                 $currentMedia = elem;
-                $(".play-next", $root).prop("disabled", !elem.next().length);
+                $(".play-next", $root).prop("disabled", !elem.nextAll('li').length);
 
                 let du = parseFloat(elem.data('duration'));
                 currentDuration = du;
-                elem.nextAll().each(function() {
+                elem.nextAll('li').each(function() {
                     du += parseFloat($(this).data('duration'));
                 });
                 totalDurationLeft = du;
 
                 let pdu = 0;
-                elem.prevAll().each(function() {
+                elem.prevAll('li').each(function() {
                     pdu += parseFloat($(this).data('duration'));
                 });
                 totalDurationBefore = pdu;
-                console.log('sm 3', du, pdu);
 
                 return player;
             };
                     // TODO: if snap then roll
                     locator.removeClass('up').removeClass('down');
                     if (locator.hasClass('snap')) {
-                        console.log('SCROLL!');
                         scrollTo();
                     } else {
                         if (y < miny) {
                 });
 
                 $('.play-next', $root).click(function() {
-                    let p = $currentMedia.next();
+                    let p = $currentMedia.nextAll('li').first();
                     if (p.length) {
                         setMedia(p).jPlayer("play");
                         _paq.push(['trackEvent', 'audiobook', 'next']);
                     }
                 });
                 $('.play-prev', $root).click(function() {
-                    let p = $currentMedia.prev();
+                    let p = $currentMedia.prevAll('li').first();
                     if (p.length) {
                         setMedia(p).jPlayer("play");
                         _paq.push(['trackEvent', 'audiobook', 'prev']);
                     _paq.push(['trackEvent', 'audiobook', 'chapter']);
                 });
 
-                console.log('READY 3!');
                 var initialElem = $('.jp-playlist li', $root).first();
                 var initialTime = 0;
-                console.log('READY 4!');
                 if (true || Modernizr.localstorage) {
                     try {
                         let speedStr = localStorage['audiobook-speed'];
                         initialTime = last[2];
                     }
                 }
-                console.log('READY 5!', initialElem, initialTime);
                 setMedia($(initialElem), initialTime);
-                console.log('READY 6!');
             },
 
             timeupdate: function(event) {
 
 
             ended: function(event) {
-                let p = $currentMedia.next();
+                let p = $currentMedia.nextAll('li');
                 if (p.length) {
                     setMedia(p).jPlayer("play");
                 }
index 4458b5a..b82d926 100644 (file)
@@ -34,7 +34,7 @@
     {% if book.is_book %}
       <span class="icon icon-book-alt" title="{% trans 'książka' %}"></span>
     {% endif %}
-    {% if book.has_mp3_file %}
+    {% if book.has_audio %}
       <span class="icon icon-audio" title="{% trans 'audiobook' %}"></span>
     {% endif %}
     {% if book.is_picture %}
index 6c89f18..016b2f2 100644 (file)
                       </div>
                       <div class="c-media__actions lay-col lay-l-row">
                         <div class="c-media__btn">
-                          {% if book.has_mp3_file %}
+                          {% if book.has_audio %}
                             <button class="l-button l-button--media" id="audiobook"><i class="icon icon-audio"></i> {% trans "pobierz audiobook" %}</button>
                           {% endif %}
                         </div>
                   </div>
 
 
-
-                  {% if book.has_mp3_file %}
+                  {% if book.has_audio %}
                     {% include 'catalogue/snippets/jplayer.html' %}
-                  {% else %}
-                    {% with ch=book.get_child_audiobook %}
-                      {% if ch %}
-                        {% include 'catalogue/snippets/jplayer_link.html' with book=ch %}
-                      {% endif %}
-                    {% endwith %}
-
                   {% endif %}
 
 
-
-
                   <div class="c-media__popup" data-popup="ebook">
                     <div class="c-media__popup__box">
                       <div class="c-media__popup__box__lead">
index c57bdd3..2a3db14 100644 (file)
     </div>
   {% endif %}
 
+  {% if narrated %}
+    <div class="l-section l-section--col">
+      <h2 class="header">Audiobooki</h2>
+      <div class="l-books__grid" id="book-list">
+        {% for book in narrated %}
+          {% include "catalogue/book_box.html" %}
+        {% endfor %}
+      </div>
+    </div>
+  {% endif %}
+
   {% if main_tag %}
     <section class="l-section">
       <div class="l-author">
index e0893fa..2399a09 100644 (file)
@@ -1,6 +1,6 @@
 {% load i18n l10n catalogue_tags %}
 
-{% with audiobooks=book.get_audiobooks %}
+{% with audiobooks=book.get_audiobooks_with_children %}
   <div class="c-media__player" id="jp_container_{{ book.pk }}" data-book-slug="{{ book.slug }}">
     <div class="jp-jplayer" data-player="jp_container_{{ book.pk }}"
          data-supplied="oga,mp3"></div>
@@ -38,6 +38,9 @@
           <ul class="jp-playlist">
             {% localize off %}
             {% for i in audiobooks.0 %}
+            {% if i.part %}
+              <div class="title"><strong>{{ i.part.title }}</strong></div>
+            {% else %}
               <li
                   data-mp3='{{ i.mp3.file.url }}'
                   data-ogg='{{ i.ogg.file.url }}'
@@ -67,6 +70,7 @@
                   </span>
                {% endwith %}
               </li>
+              {% endif %}
             {% endfor %}
             {% endlocalize %}
           </ul>
diff --git a/src/catalogue/templates/catalogue/snippets/jplayer_link.html b/src/catalogue/templates/catalogue/snippets/jplayer_link.html
deleted file mode 100644 (file)
index 7bbddcd..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-{% load i18n catalogue_tags %}
-
-<div class="c-media__player" id="jp_container_{{ book.pk }}">
-  <div class="c-player__head">
-    <span>&nbsp;</span>
-  </div>
-
-  <div class="c-player">
-    <div class="c-player__btns">
-      <button disabled class="play-prev"><i class="icon icon-prev"></i></button>
-      <button disabled class="c-player__btn--md"><i class="icon icon-play"></i></button>
-      <form action='{{ book.get_absolute_url }}'>
-        <button class="play-next"><i class="icon icon-next"></i></button>
-      </form>
-    </div>
-
-    <div class="c-player__timeline">
-      <div class="c-player__info">{{ book.pretty_title }}</div>
-    </div>
-
-  </div>
-  <div class="c-media__caption">
-  </div>
-</div>
index 3c4a55f..d5b83ab 100644 (file)
@@ -227,6 +227,7 @@ class TaggedObjectList(BookList):
         ]
         if len(self.ctx['tags']) == 1 and self.ctx['main_tag'].category == 'author':
             self.ctx['translation_list'] = self.ctx['main_tag'].book_set.all()
+            self.ctx['narrated'] = self.ctx['main_tag'].narrated.all()
 
     def get_queryset(self):
         qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True)
index 52729f9..d1fb096 100644 (file)
           display: inline;
       }
     }
+    div.title {
+      font-size: 12px;
+      line-height: 140%;
+      letter-spacing: 0.05em;
+      list-style-type: decimal;
+      margin: 5px 0;
+      color: white;
+      cursor: pointer;
+    }
   }
 }