Merge branch 'master' into appdev
authorRadek Czajka <rczajka@rczajka.pl>
Wed, 8 Oct 2025 12:43:07 +0000 (14:43 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Wed, 8 Oct 2025 12:43:07 +0000 (14:43 +0200)
1  2 
src/catalogue/api/serializers.py
src/catalogue/api/urls2.py
src/catalogue/api/views.py
src/catalogue/models/book.py
src/catalogue/models/bookmedia.py
src/catalogue/views.py
src/social/views.py

@@@ -50,7 -50,7 +50,7 @@@ class AuthorItemSerializer(serializers.
      class Meta:
          model = Tag
          fields = [
 -            'url', 'href', 'name'
 +            'url', 'href', 'name', 'slug'
          ]
  
  class AuthorSerializer(AuthorItemSerializer):
@@@ -59,7 -59,7 +59,7 @@@
      class Meta:
          model = Tag
          fields = [
 -            'url', 'href', 'name', 'slug', 'sort_key', 'description',
 +            'id', 'url', 'href', 'name', 'slug', 'sort_key', 'description',
              'genitive', 'photo', 'photo_thumb', 'photo_attribution',
          ]
  
@@@ -71,7 -71,7 +71,7 @@@ class EpochItemSerializer(serializers.M
      )
      class Meta:
          model = Tag
 -        fields = ['url', 'href', 'name']
 +        fields = ['url', 'href', 'name', 'slug']
  
  class EpochSerializer(EpochItemSerializer):
      class Meta:
@@@ -89,7 -89,7 +89,7 @@@ class GenreItemSerializer(serializers.M
      )
      class Meta:
          model = Tag
 -        fields = ['url', 'href', 'name']
 +        fields = ['url', 'href', 'name', 'slug']
  
  class GenreSerializer(GenreItemSerializer):
      class Meta:
@@@ -107,7 -107,7 +107,7 @@@ class KindItemSerializer(serializers.Mo
      )
      class Meta:
          model = Tag
 -        fields = ['url', 'href', 'name']
 +        fields = ['url', 'href', 'name', 'slug']
  
  class KindSerializer(KindItemSerializer):
      class Meta:
              'collective_noun',
          ]
  
 +class ThemeSerializer(serializers.ModelSerializer):
 +    url = AbsoluteURLField()
 +    href = AbsoluteURLField(
 +        view_name='catalogue_api_theme',
 +        view_args=('slug',)
 +    )
 +    class Meta:
 +        model = Tag
 +        fields = [
 +            'url', 'href', 'name', 'slug', 'sort_key', 'description',
 +        ]
 +
  
  class TranslatorSerializer(serializers.Serializer):
      name = serializers.CharField(source='*')
@@@ -261,6 -249,7 +261,7 @@@ class MediaSerializer2(MediaSerializer)
      def get_size(self, obj):
          return obj.file.size
  
  class BookDetailSerializer(LegacyMixin, serializers.ModelSerializer):
      url = AbsoluteURLField()
  
@@@ -388,4 -377,4 +389,4 @@@ class FragmentSerializer2(serializers.M
  class FilterTagSerializer(serializers.ModelSerializer):
      class Meta:
          model = Tag
 -        fields = ['id', 'category', 'name']
 +        fields = ['id', 'category', 'name', 'slug']
@@@ -23,9 -23,9 +23,11 @@@ urlpatterns = 
           piwik_track_view(views.BookFragmentView.as_view()),
           name='catalogue_api_book_fragment'
           ),
-     path('books/<slug:slug>/media/<slug:type>/', views.BookMediaView.as_view()),
+     path('books/<slug:slug>/media/<slug:type>/',
+          views.BookMediaView.as_view()
+          ),
 +    path('books/<slug:slug>.json',
 +        views.BookJsonView.as_view()),
  
      path('suggested-tags/',
           piwik_track_view(views.SuggestedTags.as_view()),
      path('genres/<slug:slug>/',
           piwik_track_view(views.GenreView.as_view()),
           name='catalogue_api_genre'),
 +    path('themes/',
 +         piwik_track_view(views.ThemeList.as_view()),
 +         name="catalogue_api_theme_list"),
 +    path('themes/<slug:slug>/',
 +         piwik_track_view(views.ThemeView.as_view()),
 +         name='catalogue_api_theme'),
  ]
@@@ -6,7 -6,7 +6,7 @@@ import os.pat
  from urllib.request import urlopen
  from django.conf import settings
  from django.core.files.base import ContentFile
 -from django.http import Http404, HttpResponse
 +from django.http import Http404, HttpResponse, JsonResponse
  from django.utils.decorators import method_decorator
  from django.views.decorators.cache import never_cache
  from django_filters import rest_framework as dfilters
@@@ -198,11 -198,6 +198,11 @@@ class BookFilter(dfilters.FilterSet)
          queryset=Tag.objects.filter(category__in=('author', 'epoch', 'genre', 'kind')),
          conjoined=True,
      )
 +    translator = dfilters.ModelMultipleChoiceFilter(
 +        field_name='translators',
 +        queryset=Tag.objects.filter(category='author'),
 +        conjoined=True,
 +    )
  
  
  class BookList2(ListAPIView):
@@@ -406,15 -401,6 +406,15 @@@ class KindView(RetrieveAPIView)
      queryset = Tag.objects.filter(category='kind')
      lookup_field = 'slug'
  
 +class ThemeList(ListAPIView):
 +    serializer_class = serializers.ThemeSerializer
 +    queryset = Tag.objects.filter(category='theme')
 +
 +class ThemeView(RetrieveAPIView):
 +    serializer_class = serializers.ThemeSerializer
 +    queryset = Tag.objects.filter(category='theme')
 +    lookup_field = 'slug'
 +
  
  class TagView(RetrieveAPIView):
      permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
@@@ -537,15 -523,7 +537,17 @@@ class BookMediaView(ListAPIView)
      pagination_class = None
  
      def get_queryset(self):
-         return BookMedia.objects.filter(book__slug=self.kwargs['slug'], type=self.kwargs['type']).order_by('index')
+         return BookMedia.objects.filter(
+             book__slug=self.kwargs['slug'],
+             type=self.kwargs['type']
+         ).order_by('index')
 +
 +
 +from .tojson import conv
 +from lxml import etree
 +from rest_framework.views import APIView
 +class BookJsonView(APIView):
 +    def get(self, request, slug):
 +        book = get_object_or_404(Book, slug=slug)
 +        js = conv(etree.parse(book.xml_file.path))
 +        return JsonResponse(js, json_dumps_params={'ensure_ascii': False})
@@@ -7,6 -7,8 +7,8 @@@ from datetime import date, timedelt
  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
@@@ -92,8 -94,10 +94,10 @@@ class Book(models.Model)
      objects = models.Manager()
      tagged = managers.ModelTaggedItemManager(Tag)
      tags = managers.TagDescriptor(Tag)
 -    tag_relations = GenericRelation(Tag.intermediary_table_model)
 +    tag_relations = GenericRelation(Tag.intermediary_table_model, related_query_name='tagged_book')
      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()
              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 []
          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:
      def has_sync_file(self):
          return settings.FEATURE_SYNCHRO and self.has_media("sync")
  
+     def build_sync_file(self):
+         from lxml import html
+         from django.core.files.base import ContentFile
+         with self.html_file.open('rb') as f:
+             h = html.fragment_fromstring(f.read().decode('utf-8'))
+         durations = [
+             m['mp3'].duration
+             for m in self.get_audiobooks()[0]
+         ]
+         if settings.MOCK_DURATIONS:
+             durations = settings.MOCK_DURATIONS
+         sync = []
+         ts = None
+         sid = 1
+         dirty = False
+         for elem in h.iter():
+             if elem.get('data-audio-ts'):
+                 part, ts = int(elem.get('data-audio-part')), float(elem.get('data-audio-ts'))
+                 ts = str(round(sum(durations[:part - 1]) + ts, 3))
+                 # check if inside verse
+                 p = elem.getparent()
+                 while p is not None:
+                     # Workaround for missing ids.
+                     if 'verse' in p.get('class', ''):
+                         if not p.get('id'):
+                             p.set('id', f'syn{sid}')
+                             dirty = True
+                             sid += 1
+                         sync.append((ts, p.get('id')))
+                         ts = None
+                         break
+                     p = p.getparent()
+             elif ts:
+                 cls = elem.get('class', '')
+                 # Workaround for missing ids.
+                 if 'paragraph' in cls or 'verse' in cls or elem.tag in ('h1', 'h2', 'h3', 'h4'):
+                     if not elem.get('id'):
+                         elem.set('id', f'syn{sid}')
+                         dirty = True
+                         sid += 1
+                     sync.append((ts, elem.get('id')))
+                     ts = None
+         if dirty:
+             htext = html.tostring(h, encoding='utf-8')
+             with open(self.html_file.path, 'wb') as f:
+                 f.write(htext)
+         try:
+             bm = self.media.get(type='sync')
+         except:
+             bm = BookMedia(book=self, type='sync')
+         sync = (
+             '27\n' + '\n'.join(
+                 f'{s[0]}\t{sync[i+1][0]}\t{s[1]}' for i, s in enumerate(sync[:-1])
+             )).encode('latin1')
+         bm.file.save(
+             None, ContentFile(sync)
+             )
+     
      def get_sync(self):
          with self.get_media('sync').first().file.open('r') as f:
              sync = f.read().split('\n')
      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
                  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
      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):
              return None
  
      def update_popularity(self):
 -        count = self.tags.filter(category='set').values('user').order_by('user').distinct().count()
 +        count = self.userlistitem_set.values('list__user').order_by('list__user').distinct().count()
          try:
              pop = self.popularity
              pop.count = count
      def ridero_link(self):
          return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
  
 -    def like(self, user):
 -        from social.utils import likes, get_set, set_sets
 -        if not likes(user, self):
 -            tag = get_set(user, '')
 -            set_sets(user, self, [tag])
 -
 -    def unlike(self, user):
 -        from social.utils import likes, set_sets
 -        if likes(user, self):
 -            set_sets(user, self, [])
 -
      def full_sort_key(self):
          return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
  
@@@ -70,6 -70,8 +70,8 @@@ class BookMedia(models.Model)
          return f'{name}.{ext}'
  
      def save(self, parts_count=None, *args, **kwargs):
+         if self.type in ('daisy', 'audio.epub'):
+             return super().save(*args, **kwargs)
          from catalogue.utils import ExistingFile, remove_zip
  
          if not parts_count:
@@@ -87,7 -89,7 +89,7 @@@
          except BookMedia.DoesNotExist:
              old = None
  
 -        super(BookMedia, self).save(*args, **kwargs)
 +        #super(BookMedia, self).save(*args, **kwargs)
          
          # remove the zip package for book with modified media
          if old:
          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:
diff --combined src/catalogue/views.py
@@@ -23,7 -23,6 +23,7 @@@ from club.forms import DonationStep1For
  from club.models import Club
  from annoy.models import DynamicTextInsert
  from pdcounter import views as pdcounter_views
 +from social.models import UserList
  from wolnelektury.utils import is_ajax
  from catalogue import constants
  from catalogue import forms
@@@ -212,42 -211,26 +212,43 @@@ class AudiobooksView(LiteratureView)
  class TaggedObjectList(BookList):
      def analyse(self):
          super().analyse()
 +
          self.ctx['tags'] = analyse_tags(self.request, self.kwargs['tags'])
 -        self.ctx['fragment_tags'] = [t for t in self.ctx['tags'] if t.category in ('theme', 'object')]
 -        self.ctx['work_tags'] = [t for t in self.ctx['tags'] if t not in self.ctx['fragment_tags']]
 +        self.ctx.update({
 +            'fragment_tags': [],
 +            'work_tags': [],
 +            'user_lists': [],
 +        })
 +        for tag in self.ctx['tags']:
 +            if isinstance(tag, UserList):
 +                self.ctx['user_lists'].append(tag)
 +            elif tag.category == 'theme':
 +                self.ctx['fragment_tags'].append(tag)
 +            else:
 +                self.ctx['work_tags'].append(tag)
 +
          self.is_themed = self.ctx['has_theme'] = bool(self.ctx['fragment_tags'])
          if self.is_themed:
              self.ctx['main_tag'] = self.ctx['fragment_tags'][0]
 -        elif self.ctx['tags']:
 -            self.ctx['main_tag'] = self.ctx['tags'][0]
 +        elif self.ctx['work_tags']:
 +            self.ctx['main_tag'] = self.ctx['work_tags'][0]
          else:
              self.ctx['main_tag'] = None
          self.ctx['filtering_tags'] = [
              t for t in self.ctx['tags']
              if t is not self.ctx['main_tag']
          ]
 -        if len(self.ctx['tags']) == 1 and self.ctx['main_tag'].category == 'author':
 +        if len(self.ctx['tags']) == 1 and self.ctx['main_tag'] is not None 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)
 +        if self.ctx['work_tags']:
 +            qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True)
 +        else:
 +            qs = Book.objects.filter(findable=True)
 +        for ul in self.ctx['user_lists']:
 +            qs = qs.filter(id__in=[i.id for i in ul.get_books()])
          qs = qs.exclude(ancestor__in=qs)
          if self.is_themed:
              fqs = Fragment.tagged.with_all(self.ctx['fragment_tags'])
          return qs
  
      def get_suggested_tags(self, queryset):
 +        if self.ctx['user_lists']:
 +            # TODO
 +            return []
          tag_ids = [t.id for t in self.ctx['tags']]
          if self.is_themed:
              related_tags = []
                      containing_books,
                  ).exclude(category='set').exclude(pk__in=tag_ids)
              ))
 +            ### FIXME: These won't be tags
              if self.request.user.is_authenticated:
                  related_tags.extend(list(
                      Tag.objects.usage_for_queryset(
@@@ -314,7 -293,6 +315,7 @@@ def object_list(request, objects, list_
              Tag.objects.usage_for_queryset(
                  objects, counts=True
              ).exclude(category='set'))
 +        ### FIXME: these won't be tags
          if request.user.is_authenticated:
              related_tag_lists.append(
                  Tag.objects.usage_for_queryset(
              .only('name', 'sort_key', 'category', 'slug'))
          if isinstance(objects, QuerySet):
              objects = prefetch_relations(objects, 'author')
 -    
 +
      categories = split_tags(*related_tag_lists)
      suggest = []
      for c in ['set', 'author', 'epoch', 'kind', 'genre']:
      }
  
      template = 'catalogue/author_detail.html'
 -        
 +
      return render(
          request, template, result,
      )
diff --combined src/social/views.py
@@@ -8,7 -8,8 +8,7 @@@ from django.views.decorators.cache impo
  from django.views.decorators.http import require_POST
  from django.views.generic.edit import FormView
  
 -from catalogue.models import Book, Tag
 -import catalogue.models.tag
 +from catalogue.models import Book
  from social import forms, models
  from wolnelektury.utils import is_ajax
  
  # ====================
  
  
- @require_POST
+ @login_required
  def like_book(request, slug):
-     if not request.user.is_authenticated:
-         return HttpResponseForbidden('Login required.')
      book = get_object_or_404(Book, slug=slug)
  
 -    book.like(request.user)
+     if request.method != 'POST':
+         return redirect(book)
 +    models.UserList.like(request.user, book)
  
      if is_ajax(request):
          return JsonResponse({"success": True, "msg": "ok", "like": True})
@@@ -49,13 -51,14 +50,14 @@@ class RemoveSetView(AddSetView)
      form_class = forms.RemoveSetForm
  
  
- @require_POST
+ @login_required
  def unlike_book(request, slug):
-     if not request.user.is_authenticated:
-         return HttpResponseForbidden('Login required.')
      book = get_object_or_404(Book, slug=slug)
  
 -    book.unlike(request.user)
+     if request.method != 'POST':
+         return redirect(book)
 +    models.UserList.unlike(request.user, book)
  
      if is_ajax(request):
          return JsonResponse({"success": True, "msg": "ok", "like": False})
  @login_required
  def my_shelf(request):
      template_name = 'social/my_shelf.html'
 -    tags = list(request.user.tag_set.all())
 -    suggest = [t for t in tags if t.name]
 -    print(suggest)
 +    ulists = list(request.user.userlist_set.all())
 +    suggest = [t for t in ulists if t.name]
          
      return render(request, template_name, {
 -        'tags': tags,
 -        'books': Book.tagged.with_any(tags),
 +        'tags': ulists,
 +        'books': Book.objects_filter(userlistitem__list__user=request.user),
          'suggest': suggest,
      })
  
  
  def get_sets_for_book_ids(book_ids, user):
      data = {}
 -    tagged = catalogue.models.tag.TagRelation.objects.filter(
 -        tag__user=user,
 -        #content_type= # for books,
 -        object_id__in=book_ids
 -    ).order_by('tag__sort_key')
 +    tagged = models.UserListItem.objects.filter(
 +        list__user=user,
 +        book_id__in=book_ids
 +    ).order_by('list__name')
      for t in tagged:
 -        # related?
 -        item = data.setdefault(t.object_id, [])
 -        if t.tag.name:
 +        item = data.setdefault(t.book_id, [])
 +        if t.list.name:
              item.append({
 -                "slug": t.tag.slug,
 -                "url": t.tag.get_absolute_url(),
 -                "name": t.tag.name,
 +                "slug": t.list.slug,
 +                "url": t.list.get_absolute_url(),
 +                "name": t.list.name,
              })
      for b in book_ids:
          if b not in data:
@@@ -113,12 -119,12 +115,12 @@@ def my_liked(request)
  @login_required
  def my_tags(request):
      term = request.GET.get('term', '')
 -    tags =             Tag.objects.filter(user=request.user).order_by('sort_key')
 +    tags = models.UserList.objects.filter(user=request.user).order_by('name')
      if term:
 -        tags = tags.filter(name__icontains=term)
 +        ulists = tags.filter(name__icontains=term)
      return JsonResponse(
          [
 -            t.name for t in tags
 +            ul.name for ul in ulists
          ], safe=False
      )