/var/

Various programming stuff

Hello! If you are using an ad blocker but find something useful here and want to support me please consider disabling your ad blocker for this site.

Thank you,
Serafeim

Simple Django - DataTables integration

In this small post I’ll show a simple and quick way to integrate the jquery DataTables library with Django.

The DataTables library has a lot of features however in this article we’ll only take advantage of the basic features that should be enough for most use cases:

  • Ajax loading of data
  • Ajax pagination
  • Ajax search/filtering

If you want to use more features you can try a django package like django-ajax-datatable that supports most of the DataTables features; however because of the DataTables complexity you’ll see that it will need a lot of work even for a simple integration.

The following model will be used for our example:

class Item(models.Model):
    code = models.CharField(max_length=16, unique=True, )
    name = models.CharField(max_length=512, )
    name_en = models.CharField(max_length=512, )

We’ll create a Django class based view that will return a template with an empty <table> element that we’ll then fill with Ajax. The same view will check if it receives datatables requests and return the correct data in the format datatables expects:

from django.db.models import Q
from django.http import JsonResponse
from django.views.generic import ListView

class ItemListView(ListView):
    model = models.Item
    template_name = "item_datatable.html"

    def render_to_response(self, context, **response_kwargs):
        if self.request.GET.get("datatables"):
            draw = int(self.request.GET.get("draw", "1"))
            length = int(self.request.GET.get("length", "10"))
            start = int(self.request.GET.get("start", "0"))
            sv = self.request.GET.get("search[value]", None)
            qs = self.get_queryset().order_by("code")
            if sv:
                qs = qs.filter(
                    Q(name__icontains=sv)
                    | Q(code__icontains=sv)
                    | Q(name_en__icontains=sv)
                )
            filtered_count = qs.count()
            qs = qs[start : start + length]

            return JsonResponse(
                {
                    "recordsTotal": self.get_queryset().count(),
                    "recordsFiltered": filtered_count,
                    "draw": draw,
                    "data": list(qs.values()),
                },
                safe=False,
            )
        return super().render_to_response(context, **response_kwargs)

The above is a simple ListView for our Item model. It overrides the render_to_response method to return the Ajax json data if the request is a datatables request. To do that, it first checks to see if there’s a datatables parameter in the request.GET. If this isn’t a datatables request it will return the normal template response.

However, if it is a datatables request it will pick the draw, length, start and search[value] parameters from the request.GET (with default values if they aren’t there) and use them to prepare the response. Notice that:

  • for the filter we need to do an OR (|) because of how the datatables default fildering works (one single filter field for all columns)
  • we can select any of the fields we want to filter with by adding them to the OR expression
  • we’ll choose the correct page using qs[start:start+length], the length will be changed if the user uses the page-size field of the datatables
  • we need to count the filtered results before taking the slice or else Django will throw an error
  • the draw parameter should be converted to integer and passed back to the response (it is used in case there are multiple pending datatable ajax requests).

Finally, we return a JsonResponse with the correct data. The safe=False is needed because we are returning a list of dictionaries and not a single dictionary. Notice the recordsTotal and recordsFiltered keys; these are needed by datatables to know how many records there are in total and how many records are returned after filtering.

The item_datatable.html template for this view is the following:

{% extends "site_base.html" %}
{% load static %}

{% block extra_style %}
    <link rel="stylesheet" type="text/css" href="{% static 'datatables.min.css' %}"/>
{% endblock %}

{% block page_content %}

<div class="row">
    <div class="col-md-12">
        <table id='table' class='table'></table>
    </div>
</div>

{% endblock %}

{% block extra_script %}
<script type="text/javascript" src="{% static 'jquery.min.js' %}"></script>
<script type="text/javascript" src="{% static 'datatables.min.js' %}"></script>

<script>
$(function() {

  $('#table').DataTable( {
        "ordering": false,
        ajax: '{{ request.path }}?datatables=1',
        serverSide: true,
        columns: [
            { data: 'code', title: 'Κωδικός' },
            { data: 'name', title: 'Περιγραφή' },
            { data: 'name_en', title: 'Περιγραφή (αγγλικά)' },
        ],
        language: {
          "sDecimal":           ",",
          "sEmptyTable":        "Δεν υπάρχουν δεδομένα στον πίνακα",
          "sInfo":              "Εμφανίζονται _START_ έως _END_ από _TOTAL_ εγγραφές",
          "sInfoEmpty":         "Εμφανίζονται 0 έως 0 από 0 εγγραφές",
          "sInfoFiltered":      "(φιλτραρισμένες από _MAX_ συνολικά εγγραφές)",
          "sInfoPostFix":       "",
          "sInfoThousands":     ".",
          "sLengthMenu":        "Δείξε _MENU_ εγγραφές",
          "sLoadingRecords":    "Φόρτωση...",
          "sProcessing":        "Επεξεργασία...",
          "sSearch":            "Αναζήτηση:",
          "sSearchPlaceholder": "Αναζήτηση",
          "sThousands":         ".",
          "sUrl":               "",
          "sZeroRecords":       "Δεν βρέθηκαν εγγραφές που να ταιριάζουν",
          "oPaginate": {
              "sFirst":    "Πρώτη",
              "sPrevious": "Προηγούμενη",
              "sNext":     "Επόμενη",
              "sLast":     "Τελευταία"
          },
          "oAria": {
              "sSortAscending":  ": ενεργοποιήστε για αύξουσα ταξινόμηση της στήλης",
              "sSortDescending": ": ενεργοποιήστε για φθίνουσα ταξινόμηση της στήλης"
          }
      }
    } );
})
</script>
{% endblock %}

(Please ignore the language setting this is needed to translate the datatables messages to greek.)

The important part is that we add the jquery and datatable dependencies (remeber that datatables also has a css) and then add an empty table (<table class='table'></table>). Finally, after the page is loaded the table is initialized as datatable using $('table.table').DataTable(options).

The options we pass to enable the ajax functionality are:

{
    ordering: false,
    ajax: '{{ request.path }}?datatables=1',
    serverSide: true,
    columns: [
        { data: 'code', title: 'Κωδικός' },
        { data: 'name', title: 'Περιγραφή' },
        { data: 'name_en', title: 'Περιγραφή (αγγλικά)' },
    ]
}

I didn’t need ordering so I haven’t implemented it here however it would be possible to implement it by picking the order-related parameters from the request similar to the filtering and using them as order_by parameters to the queryset, see the order[i][column] and order[i][dir] here.

For the response, we use the current request url passing it the datatables=1 parameter as discussed before. We define the datatable columns using the columns attr; the data key is the name of the field in the json data returned by the server and the title is the title of the column. These columns must exist in the json data returned by the server.

Finally, we need to add the view to our urls.py:

    path(
        "item_datatable/",
        ItemListListView.as_view(),
        name="item_datatable",
    ),

The above is enough to have a working datatable with ajax loading and filtering in your Django list views.

AI auto-subtitling

Introduction

As English is not my native language, I rely on subtitles to fully enjoy and comprehend most movies. Unfortunately there are a lot of movies that don’t include subtitles or the subtitles that I am able to find are not synchronized with the movie.

In this small post I’ll give you some instructions on how to use the newest “AI” trends to automatically generate subtitles for a movie. The process does not achive 100% accuracy but it is very good and should definitely allow you to understand what’s being said.

Also, the described process will be very useful to people that do the actual subtitling for new movies since it should save them a lot of manual labour. Instead of adding subtitles and timings manually they can use this to generate an automatic subtitle draft and then edit it by hand and ear.

Finally, please notice that all tools described here are free and open source and you should be able to run everything on your PC even if it is very slow and doesn’t have a GPU.

Everything described here is for educational purposes only. Please don’t use it to generate subtitles for movies that you don’t own or have the rights to do so.

Whisper.cpp

For the auto-subtitling we’ll use the whisper.cpp library. This library can be used to auto-transcribe audio files i.e it gets audio as input and outputs the text that is being said in the audio.

This library can be compiled on your PC however to avoid complex workflows you can download the binaries for your system. I’d also recommend to download the BLAS binaries since they should work faster if your system supports them.

After you download the whisper.cpp extract it in a folder of your choice.

Beyond whisper.cpp, you need to download a model that will be used for the transcription. The simplest way is to go to the hugging face whisper.cpp model repository and download the model from there. You only need 1 model file. The largest models will give you better results but will be slower and require more resources. I think that the base or small models should be good and fast enough.

I’ll give you some test results later to see the differences.

Notice that if your movie is in english you should download the .en models.

To continue copy the downloaded models in the whisper.cpp folder.

Extract audio from the movie

The whisper.cpp library requires uncompressed audio (a .wav file) with specific characteristics (a sample rate of 16khz) to work.

To be able to extract that audio from our movie will use ffmpeg. Download the win64 binaries from here (get the ffmpeg-master-latest-win64-gpl.zip file) and copy over ffmpeg.exe to the whisper.cpp folder.

Then, to extract the audio from the movie you can use the following command:

ffmpeg.exe -i "movie.mp4" -f wav -vn -acodec pcm_s16le -ar 16000 -ac 1 "movie.wav"

(please change movie.mp4 and movie.wav with the correct names for your movie file).

To understand the command:

  • -i "movie.mp4": the input file
  • -f wav: the output format (a .wav file)
  • -vn: no video
  • -acodec pcm_s16le: the audio codec (the correct one for .wav file)
  • -ar 16000: the sample rate (16khz)
  • -ac 1: the number of channels (1 for mono, 2 for stereo)
  • "movie.wav": the output file

The process should be very fast and will give you a .wav file with the same length as the movie.

Transcribing the audio

The last step is to do the actual audio transcribing using whisper.cpp. The great thing about whisper.cpp is that it can directly create .srt (subtitle) files. The command to use is:

main.exe -osrt -m ggml-base.en.bin -f movie.wav

Notice that

  • The whisper.cpp bundle you downloaded should have a main.exe file
  • -osrt: Generate a .srt file as output
  • -m ggml-base.en.bin: The model to use for the transcription
  • -f movie.wav: The input file

The above command will start outputing the text it detects and when it finishes it will generate a movie.wav.srt. You can then use that .srt file to add subtitles for your movie!

Results

To test the process I used the first 14 minutes of the movie Kill Bill 2 as input. To generate the .wav file I used the following command:

ffmpeg.exe -i "kb2.mp4" -f wav -vn -acodec pcm_s16le -ar 16000 -ac 1  -ss 00:00:00 -to 00:14:00 "kb2.wav"

(please notice the -ss and -to to select the first 14 minutes of the movie).

This generated a 26 MB wav file. Then I tried the results using three different models:

  • ggml-tiny.en.bin with a size of 77 MB
  • ggml-base.en.bin with a size of 147 MB
  • ggml-small.en.bin with a size of 488 MB
  • ggml-medium.bin with a size of 1533 MB

ggml-tiny.en.bin

Some stats:

whisper_model_load: mem required  =  201.00 MB (+    3.00 MB per decoder)
whisper_model_load: model size    =   73.54 MB

whisper_print_timings:    total time = 166330.89 ms

So time needed was ~ 160 seconds

And the actual transcription:

[00:00:00.000 --> 00:00:03.000]   [MUSIC PLAYING]
[00:00:03.000 --> 00:00:16.480]   Do you finally sit just now?
[00:00:16.480 --> 00:00:19.120]   [MUSIC PLAYING]
[00:00:19.120 --> 00:00:21.480]   No, I can't do.
[00:00:21.480 --> 00:00:27.600]   I'd like to believe you're aware enough even now.
[00:00:27.600 --> 00:00:35.080]   No, that there is nothing suggesting in my actions.
[00:00:35.080 --> 00:00:44.000]   This moment, this is me and my most nice against them.
[00:00:44.000 --> 00:00:47.440]   Well, it's your name.
[00:00:47.440 --> 00:00:52.920]   [MUSIC PLAYING]
[00:00:52.920 --> 00:00:55.120]   But Dad didn't I?
[00:00:55.120 --> 00:00:56.640]   Well, I wasn't.
[00:00:56.640 --> 00:00:58.040]   But it wasn't from lack of trying.
[00:00:58.040 --> 00:01:00.360]   I can tell you that.
[00:01:00.360 --> 00:01:03.600]   Actually, Bill's last bullet put me in a coma.
[00:01:03.600 --> 00:01:07.600]   A coma, I was to lie in for four years.
[00:01:07.600 --> 00:01:09.360]   And I woke up.
[00:01:09.360 --> 00:01:11.280]   I went on with a movie advertisements
[00:01:11.280 --> 00:01:15.720]   for two as a roaring rampage of revenge.
[00:01:15.720 --> 00:01:17.120]   I roared.
[00:01:17.120 --> 00:01:18.800]   And I relanged.
[00:01:18.800 --> 00:01:22.480]   And I got bloody satisfaction.
[00:01:22.480 --> 00:01:26.360]   I've killed a hell of a lot of people to get to this point.
[00:01:26.360 --> 00:01:29.720]   But I have only one more.
[00:01:29.720 --> 00:01:31.760]   The last one.
[00:01:31.760 --> 00:01:35.360]   The one I'm driving to right now.
[00:01:35.360 --> 00:01:38.280]   The only one left.
[00:01:38.280 --> 00:01:42.040]   And when I arrive at my destination,
[00:01:42.040 --> 00:01:44.200]   I have going to kill Bill.
[00:01:44.200 --> 00:01:47.200]   [MUSIC PLAYING]
[00:01:47.200 --> 00:02:10.880]   [MUSIC PLAYING]
[00:02:10.880 --> 00:02:13.480]   Now the incident that happened at the two pines wedding
[00:02:13.480 --> 00:02:17.360]   chapel that put this whole gory story into motion
[00:02:17.360 --> 00:02:20.120]   has since become legend.
[00:02:20.120 --> 00:02:22.800]   Massacre at two pines.
[00:02:22.800 --> 00:02:24.800]   That's what the newspapers called it.
[00:02:24.800 --> 00:02:28.760]   The local TV news called it the El Paso, Texas wedding
[00:02:28.760 --> 00:02:30.920]   chapel massacre.
[00:02:30.920 --> 00:02:32.280]   How it happened?
[00:02:32.280 --> 00:02:33.680]   Who was there?
[00:02:33.680 --> 00:02:36.600]   How many got killed and who killed them?
[00:02:36.600 --> 00:02:40.320]   Changes depending on who's telling the story.
[00:02:40.320 --> 00:02:43.080]   In actual fact, the massacre didn't happen
[00:02:43.080 --> 00:02:45.720]   during a wedding at all.
[00:02:45.720 --> 00:02:48.360]   It was a wedding rehearsal.
[00:02:48.360 --> 00:02:51.800]   Now when we come to the park where I say you make kiss,
[00:02:51.800 --> 00:02:55.120]   the bride, you make kiss, the bride.
[00:02:55.120 --> 00:02:56.960]   But don't stick your tongue in her mouth.
[00:02:56.960 --> 00:03:01.840]   This might be funny to your friends,
[00:03:01.840 --> 00:03:06.440]   but it would be embarrassing to your parents.
[00:03:06.440 --> 00:03:08.200]   We'll try to do strange things.
[00:03:08.200 --> 00:03:11.080]   [LAUGHTER]
[00:03:11.080 --> 00:03:12.040]   Y'all got a song?
[00:03:12.040 --> 00:03:18.000]   How about love me, tender?
[00:03:18.000 --> 00:03:18.840]   I'd play that.
[00:03:18.840 --> 00:03:24.360]   Let me tender be great.
[00:03:24.360 --> 00:03:27.520]   Rufus, he's the man.
[00:03:27.520 --> 00:03:30.640]   Rufus, who was that he used to play for?
[00:03:30.640 --> 00:03:32.640]   Rufus Thomas.
[00:03:32.640 --> 00:03:34.760]   Rufus Thomas.
[00:03:34.760 --> 00:03:35.680]   Rufus Thomas.
[00:03:35.680 --> 00:03:37.080]   I was a dreel.
[00:03:37.080 --> 00:03:38.600]   I was a drifter.
[00:03:38.600 --> 00:03:40.080]   I was a colister.
[00:03:40.080 --> 00:03:41.920]   I was a part of the game.
[00:03:41.920 --> 00:03:43.760]   I was a bar kid.
[00:03:43.760 --> 00:03:48.200]   If they come through Texas, I can play with him.
[00:03:48.200 --> 00:03:50.720]   Rufus, he's the man.
[00:03:50.720 --> 00:03:56.840]   I never forgot anything.
[00:03:56.840 --> 00:04:00.440]   Oh, yes, you forgot the seating arrangements.
[00:04:00.440 --> 00:04:03.200]   Thank you, Mother.
[00:04:03.200 --> 00:04:07.840]   Now the way we normally do this, we have the bride's side,
[00:04:07.840 --> 00:04:09.560]   and then we have the groom's side.
[00:04:09.560 --> 00:04:12.880]   But since the bride ain't got nobody coming,
[00:04:12.880 --> 00:04:16.960]   and the groom's got far too many people coming--
[00:04:16.960 --> 00:04:18.920]   Well, yeah, they're coming all the way from Oklahoma.
[00:04:18.920 --> 00:04:21.600]   [LAUGHTER]
[00:04:21.600 --> 00:04:23.160]   Right.
[00:04:23.160 --> 00:04:27.880]   Well, I don't see no problem with the groom's side
[00:04:27.880 --> 00:04:29.840]   sharing the bride's side.
[00:04:29.840 --> 00:04:30.760]   Do you, Mother?
[00:04:30.760 --> 00:04:32.720]   Not a problem with that.
[00:04:32.720 --> 00:04:38.880]   But honey, you know, it would be good if you had somebody come.
[00:04:38.880 --> 00:04:42.840]   You know, it was a sign of good faith.
[00:04:42.840 --> 00:04:49.400]   Well, I don't have anybody except for Tommy and my friends.
[00:04:49.400 --> 00:04:52.000]   You have no family?
[00:04:52.000 --> 00:04:53.600]   Well, I'm working on changing that.
[00:04:53.600 --> 00:04:55.360]   Mrs. Harmony, we're all the family.
[00:04:55.360 --> 00:04:56.760]   This is Langel's ever going to need.
[00:04:56.760 --> 00:05:01.760]   I don't feel very well in this bitch.
[00:05:01.760 --> 00:05:03.680]   This started to piss me off.
[00:05:03.680 --> 00:05:08.280]   So while you all blather on, I'm going to go outside and get some air.
[00:05:08.280 --> 00:05:10.320]   I'm a Reverend, sorry.
[00:05:10.320 --> 00:05:11.840]   She's going to go out and get some air.
[00:05:11.840 --> 00:05:12.440]   Yeah.
[00:05:12.440 --> 00:05:15.320]   Given her delicate condition, she just needs a few minutes
[00:05:15.320 --> 00:05:15.920]   to get it together.
[00:05:15.920 --> 00:05:16.920]   She'll be OK.
[00:05:16.920 --> 00:05:19.880]   [MUSIC PLAYING]
[00:05:19.880 --> 00:05:23.880]   [MUSIC PLAYING]
[00:05:23.880 --> 00:05:27.880]   [MUSIC PLAYING]
[00:05:27.880 --> 00:05:31.880]   [MUSIC PLAYING]
[00:05:31.880 --> 00:05:35.880]   [MUSIC PLAYING]
[00:05:35.880 --> 00:05:39.880]   [MUSIC PLAYING]
[00:05:39.880 --> 00:05:43.880]   [MUSIC PLAYING]
[00:05:43.880 --> 00:05:47.880]   [MUSIC PLAYING]
[00:05:47.880 --> 00:05:51.880]   [MUSIC PLAYING]
[00:05:51.880 --> 00:05:55.880]   [MUSIC PLAYING]
[00:05:55.880 --> 00:05:59.880]   [MUSIC PLAYING]
[00:05:59.880 --> 00:06:01.880]   [MUSIC PLAYING]
[00:06:01.880 --> 00:06:03.880]   [MUSIC PLAYING]
[00:06:03.880 --> 00:06:05.880]   [MUSIC PLAYING]
[00:06:05.880 --> 00:06:07.880]   [MUSIC PLAYING]
[00:06:07.880 --> 00:06:09.880]   [MUSIC PLAYING]
[00:06:35.880 --> 00:06:37.880]   Hello, kiddo.
[00:06:37.880 --> 00:06:45.880]   How did you find me?
[00:06:45.880 --> 00:06:46.880]   I'm the man.
[00:06:46.880 --> 00:06:54.880]   What are you doing here?
[00:06:54.880 --> 00:06:57.880]   Am I doing?
[00:06:57.880 --> 00:07:02.880]   Well, I'm only going to play my flute.
[00:07:02.880 --> 00:07:05.880]   [MUSIC PLAYING]
[00:07:05.880 --> 00:07:14.880]   This moment, I'm looking at the most beautiful
[00:07:14.880 --> 00:07:18.880]   bright and these old eyes of every scene.
[00:07:18.880 --> 00:07:20.880]   Where are you here?
[00:07:20.880 --> 00:07:21.880]   Nice look.
[00:07:21.880 --> 00:07:26.880]   Are you going to be nice?
[00:07:26.880 --> 00:07:30.880]   I've never been nice my whole life.
[00:07:30.880 --> 00:07:33.880]   But I'll do my best to be sweet.
[00:07:33.880 --> 00:07:42.880]   I was told you, your sweet side is your best side.
[00:07:42.880 --> 00:07:45.880]   I guess that's why you're the only one who's ever seen it.
[00:07:45.880 --> 00:07:51.880]   See, you got a bun in the oven.
[00:07:51.880 --> 00:07:54.880]   Hmm.
[00:07:54.880 --> 00:07:57.880]   I'm knocked out.
[00:07:57.880 --> 00:07:59.880]   I'm not sure what you're doing.
[00:07:59.880 --> 00:08:01.880]   I'm not sure what you're doing.
[00:08:01.880 --> 00:08:03.880]   I'm not sure what you're doing.
[00:08:03.880 --> 00:08:05.880]   I'm not sure what you're doing.
[00:08:05.880 --> 00:08:07.880]   I'm not sure what you're doing.
[00:08:07.880 --> 00:08:09.880]   I'm not sure what you're doing.
[00:08:09.880 --> 00:08:11.880]   I'm not sure what you're doing.
[00:08:11.880 --> 00:08:13.880]   I'm not sure what you're doing.
[00:08:13.880 --> 00:08:15.880]   I'm not sure what you're doing.
[00:08:15.880 --> 00:08:17.880]   I'm not sure what you're doing.
[00:08:17.880 --> 00:08:19.880]   I'm not sure what you're doing.
[00:08:19.880 --> 00:08:21.880]   I'm not sure what you're doing.
[00:08:21.880 --> 00:08:23.880]   I'm not sure what you're doing.
[00:08:23.880 --> 00:08:25.880]   I'm not sure what you're doing.
[00:08:25.880 --> 00:08:28.880]   That's hardly a prompt.
[00:08:28.880 --> 00:08:30.880]   But you're right.
[00:08:30.880 --> 00:08:35.880]   What is your young man do for a living?
[00:08:35.880 --> 00:08:39.880]   He owns a used record store here in El Paso.
[00:08:39.880 --> 00:08:41.880]   Music lover, right?
[00:08:41.880 --> 00:08:44.880]   He's fond of music.
[00:08:44.880 --> 00:08:46.880]   Not me all.
[00:08:46.880 --> 00:08:54.880]   And what are you doing for a J.O.B. these days?
[00:08:55.880 --> 00:08:58.880]   I work in the record store.
[00:08:58.880 --> 00:09:06.880]   Oh, so it all suddenly seems so clear.
[00:09:06.880 --> 00:09:09.880]   Do you like it?
[00:09:09.880 --> 00:09:13.880]   Yeah, I like it a lot, smartass.
[00:09:13.880 --> 00:09:15.880]   I get to listen to music all day.
[00:09:15.880 --> 00:09:17.880]   Talk about music all day.
[00:09:17.880 --> 00:09:20.880]   It's really cool.
[00:09:20.880 --> 00:09:22.880]   It's going to be a great environment
[00:09:22.880 --> 00:09:27.880]   for a little girl to grow up in.
[00:09:27.880 --> 00:09:30.880]   As opposed to getting around the world,
[00:09:30.880 --> 00:09:33.880]   killing human beings, and being paid best
[00:09:33.880 --> 00:09:36.880]   sums of money.
[00:09:36.880 --> 00:09:38.880]   Precisely.
[00:09:38.880 --> 00:09:41.880]   Well, my own friend.
[00:09:41.880 --> 00:09:43.880]   Do we choose someone?
[00:09:43.880 --> 00:09:48.880]   However, all cocklockery aside,
[00:09:48.880 --> 00:09:51.880]   I am looking forward to meeting your young man.
[00:09:51.880 --> 00:09:54.880]   I happen to be more or less particular.
[00:09:54.880 --> 00:09:59.880]   Who might get married?
[00:09:59.880 --> 00:10:01.880]   You want to come to the wedding?
[00:10:01.880 --> 00:10:04.880]   Only if I can sit on the bright side.
[00:10:04.880 --> 00:10:07.880]   You'll find it a bit lonely on my side.
[00:10:07.880 --> 00:10:11.880]   Your side always was a bit lonely.
[00:10:11.880 --> 00:10:15.880]   But I wouldn't sit anywhere else.
[00:10:15.880 --> 00:10:20.880]   You know, I had a lovely stream of money.
[00:10:20.880 --> 00:10:22.880]   Lovely stream about you.
[00:10:22.880 --> 00:10:23.880]   Oh, here's Tommy.
[00:10:23.880 --> 00:10:25.880]   Call me, Arlene.
[00:10:25.880 --> 00:10:26.880]   You must be Tommy.
[00:10:26.880 --> 00:10:27.880]   Uh-huh.
[00:10:27.880 --> 00:10:29.880]   Arlene's told me so much about you.
[00:10:29.880 --> 00:10:30.880]   Aren't you okay?
[00:10:30.880 --> 00:10:31.880]   Oh, I'm fine.
[00:10:31.880 --> 00:10:34.880]   Tommy, I'd like you to meet my father.
[00:10:34.880 --> 00:10:37.880]   Oh, my God.
[00:10:37.880 --> 00:10:39.880]   Oh, my God, this is great.
[00:10:39.880 --> 00:10:41.880]   I'm so glad to meet you, sir.
[00:10:41.880 --> 00:10:42.880]   Oh, Dad.
[00:10:42.880 --> 00:10:44.880]   The name is Bill.
[00:10:44.880 --> 00:10:46.880]   Well, it's great to meet you.
[00:10:46.880 --> 00:10:47.880]   Bill.
[00:10:47.880 --> 00:10:49.880]   Arlene told me you could make it.
[00:10:49.880 --> 00:10:50.880]   Surprise.
[00:10:50.880 --> 00:10:52.880]   That's my pop for you.
[00:10:52.880 --> 00:10:54.880]   Always full of surprises.
[00:10:54.880 --> 00:11:00.880]   Well, in the surprise department, the apple doesn't fall far from the tree.
[00:11:00.880 --> 00:11:02.880]   When did you get in?
[00:11:02.880 --> 00:11:03.880]   Just now.
[00:11:03.880 --> 00:11:05.880]   Did you come straight from Australia?
[00:11:05.880 --> 00:11:06.880]   Of course.
[00:11:06.880 --> 00:11:08.880]   Daddy, I told Tommy that you were in Perth,
[00:11:08.880 --> 00:11:11.880]   lining for silver and no one could meet you.
[00:11:11.880 --> 00:11:15.880]   Lucky for us all, that's not the case.
[00:11:15.880 --> 00:11:19.880]   So, what's this all about?
[00:11:19.880 --> 00:11:26.880]   I've heard of wedding rehearsals, but I don't believe I've ever heard of a wedding dress rehearsal before.
[00:11:26.880 --> 00:11:33.880]   We thought, well, I paid so much money for a dress you only going to wear once, especially when Arlene looks so goddamn beautiful in it.
[00:11:33.880 --> 00:11:38.880]   So, uh, we're going to try to get all the mileage we can out of it.
[00:11:38.880 --> 00:11:43.880]   Isn't it supposed to be bad luck for the groom to see the bride and her wedding dress?
[00:11:43.880 --> 00:11:45.880]   People with a ceremony?
[00:11:45.880 --> 00:11:50.880]   Well, I guess I just believe I'm having dangerous.
[00:11:50.880 --> 00:11:53.880]   I know just what you mean.
[00:11:53.880 --> 00:11:54.880]   Some.
[00:11:54.880 --> 00:11:56.880]   Some of us are places to be.
[00:11:56.880 --> 00:11:58.880]   Show them to do.
[00:11:58.880 --> 00:12:01.880]   Look, we got to go through this one more time.
[00:12:01.880 --> 00:12:03.880]   So, uh, why don't you have a--
[00:12:03.880 --> 00:12:05.880]   Oh, my God.
[00:12:05.880 --> 00:12:06.880]   What am I thinking?
[00:12:06.880 --> 00:12:07.880]   You should give her away.
[00:12:07.880 --> 00:12:10.880]   Tommy, that's not exactly Daddy's cup of tea.
[00:12:10.880 --> 00:12:15.880]   I'm not even sure if you're much more comfortable sitting with the rest of the cats.
[00:12:15.880 --> 00:12:17.880]   Really?
[00:12:17.880 --> 00:12:20.880]   That's asking a lot.
[00:12:20.880 --> 00:12:21.880]   No.
[00:12:21.880 --> 00:12:22.880]   Okay.
[00:12:22.880 --> 00:12:23.880]   Forget it.
[00:12:23.880 --> 00:12:25.880]   But how about we go out to dinner tonight, celebrate?
[00:12:25.880 --> 00:12:28.880]   Only on the condition that I pay for everything.
[00:12:28.880 --> 00:12:29.880]   Deal.
[00:12:29.880 --> 00:12:31.880]   We have to do this now.
[00:12:31.880 --> 00:12:32.880]   Can I watch?
[00:12:32.880 --> 00:12:33.880]   Absolutely.
[00:12:33.880 --> 00:12:35.880]   Have a seat.
[00:12:35.880 --> 00:12:37.880]   Which is the bride's side.
[00:12:37.880 --> 00:12:39.880]   Right over here.
[00:12:39.880 --> 00:12:44.880]   Mother, here we go.
[00:12:44.880 --> 00:12:45.880]   Yes.
[00:12:45.880 --> 00:12:48.880]   Now, son, pop them, bowels.
[00:12:48.880 --> 00:12:49.880]   Yeah.
[00:12:49.880 --> 00:12:51.880]   You can't hear me.
[00:12:51.880 --> 00:12:53.880]   You should be.
[00:12:53.880 --> 00:12:54.880]   No.
[00:12:54.880 --> 00:12:58.880]   I just don't want to.
[00:12:58.880 --> 00:13:00.880]   You don't want me to put them there.
[00:13:00.880 --> 00:13:07.880]   If he's the man you want, then go stand by.
[00:13:07.880 --> 00:13:31.880]   [MUSIC]
[00:13:31.880 --> 00:13:33.880]   Does it look pretty?
[00:13:33.880 --> 00:13:35.880]   Oh, yeah.
[00:13:35.880 --> 00:13:45.880]   [MUSIC]
[00:13:45.880 --> 00:13:47.880]   Thank you.
[00:13:47.880 --> 00:13:52.880]   [MUSIC]
[00:13:52.880 --> 00:13:59.880]   [MUSIC]

ggml-base.en.bin

Some stats:

whisper_model_load: mem required  =  310.00 MB (+    6.00 MB per decoder)
whisper_model_load: model size    =  140.54 MB

whisper_print_timings:    total time = 433205.62 ms

So time needed was ~ 433 seconds

Actual transcription:

[00:00:00.000 --> 00:00:15.000]   [Music]
[00:00:15.000 --> 00:00:17.000]   Do you find me sadistic?
[00:00:17.000 --> 00:00:20.000]   No, Kato.
[00:00:20.000 --> 00:00:23.000]   I'd like to believe
[00:00:23.000 --> 00:00:27.000]   you're aware enough, even now.
[00:00:27.000 --> 00:00:33.000]   I know that there is nothing sadistic in my actions.
[00:00:33.000 --> 00:00:36.000]   This moment,
[00:00:36.000 --> 00:00:40.000]   this is me,
[00:00:40.000 --> 00:00:44.000]   and I most miss the case.
[00:00:44.000 --> 00:00:45.000]   Well,
[00:00:45.000 --> 00:00:48.000]   it's your baby.
[00:00:48.000 --> 00:00:54.000]   The dead didn't I?
[00:00:55.000 --> 00:00:57.000]   Well, I wasn't.
[00:00:57.000 --> 00:01:00.000]   But it wasn't from lack of try and I can tell you that.
[00:01:00.000 --> 00:01:03.000]   Actually, Bill's last bullet put me in a coma.
[00:01:03.000 --> 00:01:07.000]   A coma I was to lie in for four years.
[00:01:07.000 --> 00:01:09.000]   When I woke up,
[00:01:09.000 --> 00:01:15.000]   I went on with the movie advertisements referred to as a roaring rampage of revenge.
[00:01:15.000 --> 00:01:17.000]   I roared,
[00:01:17.000 --> 00:01:19.000]   and I rampaged,
[00:01:19.000 --> 00:01:22.000]   and I got bloody satisfaction.
[00:01:22.000 --> 00:01:26.000]   I've killed a hell of a lot of people who get to this point.
[00:01:26.000 --> 00:01:29.000]   But I have only one more.
[00:01:29.000 --> 00:01:31.000]   The last one.
[00:01:31.000 --> 00:01:35.000]   The one I'm driving to right now.
[00:01:35.000 --> 00:01:38.000]   The only one left.
[00:01:38.000 --> 00:01:41.000]   And when I arrive at my destination,
[00:01:41.000 --> 00:01:45.000]   I am gonna kill Bill.
[00:01:45.000 --> 00:02:02.000]   [Music]
[00:02:02.000 --> 00:02:14.000]   Now the incident that happened at the two pines wedding chapel
[00:02:14.000 --> 00:02:20.000]   that put this whole gory story into motion has since become legend.
[00:02:20.000 --> 00:02:23.000]   Massacre at two pines.
[00:02:23.000 --> 00:02:25.000]   That's what the newspapers called it.
[00:02:25.000 --> 00:02:31.000]   The local TV news called it the El Paso Texas Wedding Chapel Massacre.
[00:02:31.000 --> 00:02:32.000]   How it happened?
[00:02:32.000 --> 00:02:33.000]   Who was there?
[00:02:33.000 --> 00:02:36.000]   How many got killed and who killed them?
[00:02:36.000 --> 00:02:40.000]   Changes depending on who's telling the story.
[00:02:40.000 --> 00:02:42.000]   In actual fact,
[00:02:42.000 --> 00:02:46.000]   the massacre didn't happen during a wedding at all.
[00:02:46.000 --> 00:02:48.000]   It was a wedding rehearsal.
[00:02:48.000 --> 00:02:53.000]   Now when we come to the park where I say you may kiss the bride,
[00:02:53.000 --> 00:02:55.000]   you may kiss the bride.
[00:02:55.000 --> 00:02:59.000]   But don't stick your tongue in her mouth.
[00:02:59.000 --> 00:03:02.000]   This might be funny to your friends,
[00:03:02.000 --> 00:03:06.000]   but it would be embarrassing to your parents.
[00:03:06.000 --> 00:03:11.000]   We'll try to be strange.
[00:03:11.000 --> 00:03:16.000]   Y'all got a song?
[00:03:16.000 --> 00:03:18.000]   How about "Love Me Tender"?
[00:03:18.000 --> 00:03:22.000]   I can play that.
[00:03:22.000 --> 00:03:24.000]   Let me tend to be great.
[00:03:24.000 --> 00:03:27.000]   Rufus, he's the man.
[00:03:27.000 --> 00:03:28.000]   Rufus?
[00:03:28.000 --> 00:03:30.000]   Who was that to use to play for?
[00:03:30.000 --> 00:03:32.000]   Rufus Thomas.
[00:03:32.000 --> 00:03:34.000]   Rufus Thomas.
[00:03:34.000 --> 00:03:36.000]   Rufus Thomas.
[00:03:36.000 --> 00:03:37.000]   I was a drill.
[00:03:37.000 --> 00:03:38.000]   I was a drifted.
[00:03:38.000 --> 00:03:40.000]   I was a coaster.
[00:03:40.000 --> 00:03:42.000]   I was part of the gang.
[00:03:42.000 --> 00:03:44.000]   I was a bar-k.
[00:03:44.000 --> 00:03:48.000]   If they come through Texas, I'd play with him.
[00:03:48.000 --> 00:03:50.000]   Rufus?
[00:03:50.000 --> 00:03:54.000]   He's the man.
[00:03:54.000 --> 00:03:57.000]   Have I forgot anything?
[00:03:57.000 --> 00:04:00.000]   Oh yes, you forgot the seating arrangements.
[00:04:00.000 --> 00:04:03.000]   Thank you, mother.
[00:04:03.000 --> 00:04:05.000]   Now the way we normally do this,
[00:04:05.000 --> 00:04:08.000]   we have the bride's side,
[00:04:08.000 --> 00:04:12.000]   but since the bride ain't got nobody coming,
[00:04:12.000 --> 00:04:16.000]   and the groom's got far too many people coming,
[00:04:16.000 --> 00:04:20.000]   well yeah, they're coming all the way from Oklahoma.
[00:04:20.000 --> 00:04:22.000]   Right.
[00:04:22.000 --> 00:04:27.000]   Well, I don't see no problem with the groom's side
[00:04:27.000 --> 00:04:29.000]   sharing the bride's side.
[00:04:29.000 --> 00:04:30.000]   Do you, mother?
[00:04:30.000 --> 00:04:32.000]   Not a problem with that,
[00:04:32.000 --> 00:04:37.000]   but honey, you know, it would be good if you had somebody come
[00:04:37.000 --> 00:04:41.000]   You know, is it sign of good faith?
[00:04:41.000 --> 00:04:44.000]   Well, I don't have anybody,
[00:04:44.000 --> 00:04:48.000]   except for Tommy and my friends.
[00:04:48.000 --> 00:04:51.000]   You have no family?
[00:04:51.000 --> 00:04:53.000]   Well, I'm working on changing then.
[00:04:53.000 --> 00:04:55.000]   Mrs. Harmony, we're all the family
[00:04:55.000 --> 00:04:58.000]   this Alangel's ever going to need.
[00:04:58.000 --> 00:05:00.000]   I'm not feeling very well,
[00:05:00.000 --> 00:05:03.000]   and this bitch is starting to piss me off.
[00:05:03.000 --> 00:05:05.000]   So while you all bother on,
[00:05:05.000 --> 00:05:08.000]   I'm going to go outside and get some air.
[00:05:08.000 --> 00:05:10.000]   I'm sorry.
[00:05:10.000 --> 00:05:12.000]   She's going to go out and get some air.
[00:05:12.000 --> 00:05:14.000]   Yeah, given her delicate condition,
[00:05:14.000 --> 00:05:17.000]   she just needs a few minutes to give it to get us to be okay.
[00:05:18.000 --> 00:05:21.000]   [music]
[00:05:22.000 --> 00:05:25.000]   [music]
[00:05:26.000 --> 00:05:29.000]   [music]
[00:05:30.000 --> 00:05:33.000]   [music]
[00:05:34.000 --> 00:05:37.000]   [music]
[00:05:38.000 --> 00:05:41.000]   [music]
[00:05:42.000 --> 00:05:45.000]   [music]
[00:05:46.000 --> 00:05:49.000]   [music]
[00:05:50.000 --> 00:05:53.000]   [music]
[00:05:53.000 --> 00:05:57.000]   [music]
[00:05:57.000 --> 00:06:01.000]   [music]
[00:06:01.000 --> 00:06:05.000]   [music]
[00:06:05.000 --> 00:06:09.000]   [music]
[00:06:09.000 --> 00:06:13.000]   [music]
[00:06:36.000 --> 00:06:40.000]   Hello, kiddo.
[00:06:40.000 --> 00:06:46.000]   How did you find me?
[00:06:46.000 --> 00:06:50.000]   I'm the man.
[00:06:50.000 --> 00:06:54.000]   What are you doing here?
[00:06:54.000 --> 00:06:58.000]   What am I doing?
[00:06:58.000 --> 00:07:00.000]   Well,
[00:07:00.000 --> 00:07:06.000]   I was playing my flute.
[00:07:06.000 --> 00:07:12.000]   At this moment,
[00:07:12.000 --> 00:07:15.000]   I'm looking at the most beautiful bride
[00:07:15.000 --> 00:07:19.000]   in these old eyes of every scene.
[00:07:19.000 --> 00:07:21.000]   Why are you here?
[00:07:21.000 --> 00:07:25.000]   Last look.
[00:07:25.000 --> 00:07:27.000]   Are you going to be nice?
[00:07:27.000 --> 00:07:31.000]   I've never been nice my whole life.
[00:07:31.000 --> 00:07:36.000]   But I'll do my best to be sweet.
[00:07:36.000 --> 00:07:39.000]   I always told you,
[00:07:39.000 --> 00:07:42.000]   your sweet side is your best side.
[00:07:42.000 --> 00:07:49.000]   I guess that's why you're the only one who's ever seen it.
[00:07:49.000 --> 00:07:54.000]   See, you got a bun in the oven?
[00:07:54.000 --> 00:07:58.000]   I'm knocked up.
[00:07:58.000 --> 00:08:00.000]   Chase Louise.
[00:08:00.000 --> 00:08:02.000]   That young man here is sure
[00:08:02.000 --> 00:08:06.000]   it doesn't believe in wasting time, does he?
[00:08:06.000 --> 00:08:10.000]   Have you seen Tommy?
[00:08:10.000 --> 00:08:12.000]   A guy on the tux?
[00:08:12.000 --> 00:08:13.000]   Yes.
[00:08:13.000 --> 00:08:16.000]   And I saw him.
[00:08:16.000 --> 00:08:20.000]   I like his hair.
[00:08:20.000 --> 00:08:24.000]   You promised you'd be nice.
[00:08:24.000 --> 00:08:26.000]   I said I'd do my best.
[00:08:26.000 --> 00:08:29.000]   That's hardly a promise.
[00:08:29.000 --> 00:08:31.000]   But you're right.
[00:08:31.000 --> 00:08:36.000]   What does your young man do for a living?
[00:08:36.000 --> 00:08:40.000]   He owns a used record store here in El Paso.
[00:08:40.000 --> 00:08:42.000]   He's a lover, right?
[00:08:42.000 --> 00:08:45.000]   He's fond of music.
[00:08:45.000 --> 00:08:51.000]   Aren't we all?
[00:08:51.000 --> 00:08:56.000]   And what are you doing for a J.O.B. these days?
[00:08:56.000 --> 00:08:59.000]   I work in the record store.
[00:08:59.000 --> 00:09:03.000]   Ah, so...
[00:09:03.000 --> 00:09:08.000]   it all suddenly seems so clear.
[00:09:08.000 --> 00:09:10.000]   Do you like it?
[00:09:10.000 --> 00:09:14.000]   Yeah, I like it a lot, smart ass.
[00:09:14.000 --> 00:09:17.000]   I get to listen to music all day.
[00:09:17.000 --> 00:09:21.000]   Talk about music all day. It's really cool.
[00:09:21.000 --> 00:09:28.000]   It's going to be a great environment for my little girl to grow up in.
[00:09:28.000 --> 00:09:33.000]   As opposed to jetting around the world, killing human beams,
[00:09:33.000 --> 00:09:38.000]   and being paid best sums of money.
[00:09:38.000 --> 00:09:40.000]   Precisely.
[00:09:40.000 --> 00:09:44.000]   I have a friend.
[00:09:44.000 --> 00:09:47.000]   Do each you own.
[00:09:47.000 --> 00:09:51.000]   However, all clockwork re-assigned.
[00:09:51.000 --> 00:09:55.000]   I am looking forward to meeting your young man.
[00:09:55.000 --> 00:09:59.000]   I happen to be more or less particular.
[00:09:59.000 --> 00:10:03.000]   Oh, my God, Mary.
[00:10:03.000 --> 00:10:05.000]   You want to come to the wedding?
[00:10:05.000 --> 00:10:08.000]   Only if I can sit on the bride's side.
[00:10:08.000 --> 00:10:12.000]   Your side always was a bit lonely.
[00:10:12.000 --> 00:10:17.000]   But I wouldn't sit anywhere else.
[00:10:17.000 --> 00:10:22.000]   You know, I had a lovely stream about you.
[00:10:22.000 --> 00:10:25.000]   Oh, here's Tommy. Call me Arlene.
[00:10:25.000 --> 00:10:29.000]   You must be Tommy. Arlene's told me so much about you.
[00:10:29.000 --> 00:10:31.000]   Are you okay?
[00:10:31.000 --> 00:10:35.000]   Oh, I'm fine. Tommy, I'd like you to meet my father.
[00:10:35.000 --> 00:10:38.000]   Oh, my God.
[00:10:38.000 --> 00:10:40.000]   Oh, my God, this is great.
[00:10:40.000 --> 00:10:42.000]   I'm so glad to meet you, sir.
[00:10:42.000 --> 00:10:45.000]   Oh, Dad, the name is Bill.
[00:10:45.000 --> 00:10:48.000]   Well, it's great to meet you. Bill.
[00:10:48.000 --> 00:10:50.000]   Arlene told me you could make it.
[00:10:50.000 --> 00:10:51.000]   Surprise.
[00:10:51.000 --> 00:10:54.000]   That's my pot for you. Always full of surprises.
[00:10:54.000 --> 00:10:58.000]   Well, in a surprise department.
[00:10:58.000 --> 00:11:01.000]   The Apple doesn't fall far from the tree.
[00:11:01.000 --> 00:11:04.000]   When did you get in? Just now.
[00:11:04.000 --> 00:11:06.000]   Did you come straight from Australia?
[00:11:06.000 --> 00:11:07.000]   Of course.
[00:11:07.000 --> 00:11:09.000]   Daddy, I told Tommy that you were in Perth,
[00:11:09.000 --> 00:11:13.000]   finding for silver, and no one could reach you.
[00:11:13.000 --> 00:11:16.000]   Lucky for us all, that's not the case.
[00:11:16.000 --> 00:11:20.000]   So, what's this all about?
[00:11:20.000 --> 00:11:27.000]   I've heard of wedding rehearsals, but I don't believe I've ever heard of a wedding dress rehearsal before.
[00:11:27.000 --> 00:11:31.000]   We thought, "Why pay so much money for a dress you only gonna wear once?"
[00:11:31.000 --> 00:11:34.000]   Especially when Arlene looks so goddamn beautiful in it.
[00:11:34.000 --> 00:11:39.000]   So, we're gonna try to get all the mileage we can out of it.
[00:11:39.000 --> 00:11:44.000]   Isn't it supposed to be bad luck for the groom to see the bride in her wedding dress?
[00:11:44.000 --> 00:11:46.000]   People with a ceremony?
[00:11:46.000 --> 00:11:51.000]   Wow. I guess I just believe in them dangerously.
[00:11:51.000 --> 00:11:54.000]   I know just what you mean.
[00:11:54.000 --> 00:11:59.000]   "San, Sama Lacha, places to be. It's your duty."
[00:11:59.000 --> 00:12:02.000]   But we gotta go through this one more time.
[00:12:02.000 --> 00:12:05.000]   So, uh, why don't you have a... oh my god.
[00:12:05.000 --> 00:12:08.000]   What am I thinking? You should give her away!
[00:12:08.000 --> 00:12:11.000]   Tommy, that's not exactly Daddy's cup of tea.
[00:12:11.000 --> 00:12:16.000]   I think Father seemed much more comfortable sitting with the rest of the guests.
[00:12:16.000 --> 00:12:18.000]   Really?
[00:12:18.000 --> 00:12:21.000]   That's asking a lot.
[00:12:21.000 --> 00:12:24.000]   Oh. Okay. We'll forget it.
[00:12:24.000 --> 00:12:26.000]   But how about we go out to dinner tonight and celebrate?
[00:12:26.000 --> 00:12:29.000]   Only on the condition that I pay for everything.
[00:12:29.000 --> 00:12:32.000]   Deal. We have to do this now.
[00:12:32.000 --> 00:12:33.000]   Can I watch?
[00:12:33.000 --> 00:12:36.000]   Absolutely. You have a seat.
[00:12:36.000 --> 00:12:38.000]   Which is the bride's side?
[00:12:38.000 --> 00:12:40.000]   Right over here.
[00:12:40.000 --> 00:12:45.000]   Father, here we go.
[00:12:45.000 --> 00:12:50.000]   Yeah. Now, Sean, drop them bows.
[00:12:50.000 --> 00:12:53.000]   Yeah.
[00:12:53.000 --> 00:12:56.000]   Oh, Sean.
[00:12:56.000 --> 00:12:59.000]   Oh. I just want...
[00:12:59.000 --> 00:13:03.000]   You don't want me a damn thing.
[00:13:03.000 --> 00:13:08.000]   If he's the man you want, then go stand by.
[00:13:08.000 --> 00:13:11.000]   [Sighs]
[00:13:11.000 --> 00:13:34.000]   Does it look pretty?
[00:13:34.000 --> 00:13:37.000]   Oh, yes.
[00:13:37.000 --> 00:13:40.000]   [Sighs]
[00:13:40.000 --> 00:13:49.000]   Thank you.
[00:13:49.000 --> 00:13:56.000]   [Sighs]
[00:13:56.000 --> 00:14:00.000]   [Music]

ggml-small.en.bin

Some stats:

whisper_model_load: mem required  =  743.00 MB (+   16.00 MB per decoder)
whisper_model_load: model size    = 464.44 MB

whisper_print_timings:    total time = 1713762.12 ms

So time needed was ~ 1713 seconds (almost half an hour)

And the actual transcription:

[00:00:00.000 --> 00:00:10.000]   [MUSIC]
[00:00:10.000 --> 00:00:17.000]   Do you find me sadistic?
[00:00:17.000 --> 00:00:21.000]   No, kiddo.
[00:00:21.000 --> 00:00:27.000]   I'd like to believe you're aware enough, even now,
[00:00:27.000 --> 00:00:33.000]   to know that there is nothing sadistic in my actions.
[00:00:33.000 --> 00:00:44.000]   This moment, this is me and my most nicer kiss to be.
[00:00:44.000 --> 00:00:48.000]   Well, it's your baby.
[00:00:48.000 --> 00:00:55.000]   Look, Dad, didn't I?
[00:00:55.000 --> 00:01:00.000]   Well, I wasn't. But it wasn't from lack of trying, I can tell you that.
[00:01:00.000 --> 00:01:03.000]   Actually, Bill's last bullet put me in a coma.
[00:01:03.000 --> 00:01:07.000]   A coma I was to lie in for four years.
[00:01:07.000 --> 00:01:11.000]   When I woke up, I went on with the movie advertisements
[00:01:11.000 --> 00:01:15.000]   referred to as a roaring rampage of revenge.
[00:01:15.000 --> 00:01:22.000]   I roared, and I rampaged, and I got bloody satisfaction.
[00:01:22.000 --> 00:01:26.000]   I've killed a hell of a lot of people to get to this point.
[00:01:26.000 --> 00:01:29.000]   But I have only one more.
[00:01:29.000 --> 00:01:31.000]   The last one.
[00:01:31.000 --> 00:01:35.000]   The one I'm driving to right now.
[00:01:35.000 --> 00:01:38.000]   The only one left.
[00:01:38.000 --> 00:01:45.000]   And when I arrive at my destination, I am gonna kill Bill.
[00:01:45.000 --> 00:02:10.000]   [Music]
[00:02:10.000 --> 00:02:14.000]   Now, the incident that happened at the Two Pines Wedding Chapel
[00:02:14.000 --> 00:02:20.000]   that put this whole gory story into motion has since become legend.
[00:02:20.000 --> 00:02:22.000]   Massacre at Two Pines.
[00:02:22.000 --> 00:02:24.000]   That's what the newspapers called it.
[00:02:24.000 --> 00:02:30.000]   The local TV news called it the El Paso, Texas Wedding Chapel Massacre.
[00:02:30.000 --> 00:02:36.000]   How it happened, who was there, how many got killed, and who killed them.
[00:02:36.000 --> 00:02:40.000]   Changes depending on who's telling the story.
[00:02:40.000 --> 00:02:45.000]   In actual fact, the massacre didn't happen during a wedding at all.
[00:02:45.000 --> 00:02:48.000]   It was a wedding rehearsal.
[00:02:48.000 --> 00:02:53.000]   Now, when we come to the park where I say you may kiss the bride,
[00:02:53.000 --> 00:02:55.000]   you may kiss the bride.
[00:02:55.000 --> 00:02:59.000]   But don't stick your tongue in her mouth.
[00:02:59.000 --> 00:03:06.000]   This might be funny to your friends, but it would be embarrassing to your parents.
[00:03:06.000 --> 00:03:10.000]   We'll try and restrain ourselves from that.
[00:03:10.000 --> 00:03:16.000]   Y'all got a song?
[00:03:16.000 --> 00:03:19.000]   How about "Love Me, Tender"? I can play that.
[00:03:19.000 --> 00:03:21.000]   Sure.
[00:03:21.000 --> 00:03:24.000]   "Love Me, Tender" would be great.
[00:03:24.000 --> 00:03:27.000]   Rufus, he's the man.
[00:03:27.000 --> 00:03:30.000]   Rufus, who was that you used to play for?
[00:03:30.000 --> 00:03:32.000]   Rufus Thomas.
[00:03:32.000 --> 00:03:34.000]   Rufus Thomas.
[00:03:34.000 --> 00:03:35.000]   Rufus Thomas.
[00:03:35.000 --> 00:03:39.000]   I was a drill, I was a drifter, I was a coaster,
[00:03:39.000 --> 00:03:43.000]   I was part of the gang, I was a bar-k.
[00:03:43.000 --> 00:03:47.000]   If they come through Texas, I haven't played with them.
[00:03:47.000 --> 00:03:53.000]   Rufus, he's the man.
[00:03:53.000 --> 00:03:56.000]   Have you forgotten anything?
[00:03:56.000 --> 00:04:00.000]   Oh, yes, you forgot the seating arrangements.
[00:04:00.000 --> 00:04:02.000]   Thank you, Mother.
[00:04:02.000 --> 00:04:07.000]   Now, the way we normally do this, we have the bride's side,
[00:04:07.000 --> 00:04:09.000]   and then we have the groom's side.
[00:04:09.000 --> 00:04:12.000]   But since the bride ain't got nobody coming,
[00:04:12.000 --> 00:04:16.000]   and the groom's got far too many people coming...
[00:04:16.000 --> 00:04:19.000]   Well, yeah, they're coming all the way from Oklahoma.
[00:04:19.000 --> 00:04:22.000]   Right.
[00:04:22.000 --> 00:04:29.000]   Well, I don't see no problem with the groom's side sharing the bride's side.
[00:04:29.000 --> 00:04:30.000]   Do you, Mother?
[00:04:30.000 --> 00:04:32.000]   I don't have a problem with that.
[00:04:32.000 --> 00:04:38.000]   But, honey, you know, it would be good if you had somebody come.
[00:04:38.000 --> 00:04:41.000]   You know, is that a sign of good faith?
[00:04:41.000 --> 00:04:48.000]   Well, I don't have anybody, except for Tommy and my friends.
[00:04:48.000 --> 00:04:51.000]   You have no family?
[00:04:51.000 --> 00:04:53.000]   Well, I'm working on changing that.
[00:04:53.000 --> 00:04:57.000]   Mrs. Harmony, we're all the family this little angel's ever gonna need.
[00:04:57.000 --> 00:05:03.000]   I'm not feeling very well, and this bitch is starting to piss me off.
[00:05:03.000 --> 00:05:07.000]   So while you all blather on, I'm gonna go outside and get some air.
[00:05:07.000 --> 00:05:09.000]   Um, uh, Reverend, sorry.
[00:05:09.000 --> 00:05:11.000]   She's gonna go out and get some air.
[00:05:11.000 --> 00:05:13.000]   Yeah, given her delicate condition.
[00:05:13.000 --> 00:05:16.000]   She just needs a few minutes to get it together. She'll be okay.
[00:05:16.000 --> 00:05:18.000]   Okay.
[00:05:19.000 --> 00:05:22.000]   [♪♪♪]
[00:05:23.000 --> 00:05:25.000]   [♪♪♪]
[00:05:26.000 --> 00:05:28.000]   [♪♪♪]
[00:05:28.000 --> 00:05:30.000]   [♪♪♪]
[00:05:30.000 --> 00:05:32.000]   [♪♪♪]
[00:05:33.000 --> 00:05:35.000]   [♪♪♪]
[00:05:36.000 --> 00:05:38.000]   [♪♪♪]
[00:05:39.000 --> 00:05:41.000]   [♪♪♪]
[00:05:41.000 --> 00:05:43.000]   [♪♪♪]
[00:05:43.000 --> 00:05:45.000]   [♪♪♪]
[00:05:45.000 --> 00:05:47.000]   [♪♪♪]
[00:05:47.000 --> 00:05:49.000]   [♪♪♪]
[00:05:49.000 --> 00:05:51.000]   [♪♪♪]
[00:05:51.000 --> 00:05:53.000]   [♪♪♪]
[00:05:53.000 --> 00:05:55.000]   [♪♪♪]
[00:05:55.000 --> 00:05:57.000]   [♪♪♪]
[00:05:57.000 --> 00:05:59.000]   [♪♪♪]
[00:05:59.000 --> 00:06:01.000]   [♪♪♪]
[00:06:01.000 --> 00:06:03.000]   [♪♪♪]
[00:06:03.000 --> 00:06:05.000]   [♪♪♪]
[00:06:05.000 --> 00:06:07.000]   [♪♪♪]
[00:06:07.000 --> 00:06:09.000]   [♪♪♪]
[00:06:36.000 --> 00:06:38.000]   Hello, kiddo.
[00:06:38.000 --> 00:06:45.000]   How did you find me?
[00:06:45.000 --> 00:06:48.000]   I'm the man.
[00:06:48.000 --> 00:06:53.000]   What are you doing here?
[00:06:53.000 --> 00:06:57.000]   What am I doing?
[00:06:57.000 --> 00:07:03.000]   Well, a moment ago I was playing my flute.
[00:07:04.000 --> 00:07:06.000]   [♪♪♪]
[00:07:06.000 --> 00:07:15.000]   At this moment, I'm looking at the most beautiful bride
[00:07:15.000 --> 00:07:18.000]   these old eyes have ever seen.
[00:07:18.000 --> 00:07:22.000]   Why are you here?
[00:07:22.000 --> 00:07:25.000]   Last look.
[00:07:25.000 --> 00:07:28.000]   Are you gonna be nice?
[00:07:28.000 --> 00:07:30.000]   I've never been nice my whole life.
[00:07:32.000 --> 00:07:34.000]   I'm just a guest to be sweet.
[00:07:34.000 --> 00:07:42.000]   I always told you, your sweet side is your best side.
[00:07:42.000 --> 00:07:47.000]   I guess that's why you're the only one who's ever seen it.
[00:07:47.000 --> 00:07:52.000]   See, you got a bun in the oven.
[00:07:52.000 --> 00:07:57.000]   I'm knocked up.
[00:07:58.000 --> 00:08:01.000]   I'm not a man of the way.
[00:08:01.000 --> 00:08:04.000]   I'm not a man of the way.
[00:08:04.000 --> 00:08:07.000]   I'm not a man of the way.
[00:08:07.000 --> 00:08:10.000]   I'm not a man of the way.
[00:08:10.000 --> 00:08:13.000]   I'm not a man of the way.
[00:08:13.000 --> 00:08:16.000]   I'm not a man of the way.
[00:08:16.000 --> 00:08:19.000]   I'm not a man of the way.
[00:08:19.000 --> 00:08:22.000]   I'm not a man of the way.
[00:08:22.000 --> 00:08:25.000]   I'm not a man of the way.
[00:08:26.000 --> 00:08:28.000]   It's hardly a promise.
[00:08:28.000 --> 00:08:31.000]   But you're right.
[00:08:31.000 --> 00:08:34.000]   What does your young man do for a living?
[00:08:34.000 --> 00:08:39.000]   He owns a used record store here in El Paso.
[00:08:39.000 --> 00:08:42.000]   A music lover, right?
[00:08:42.000 --> 00:08:44.000]   He's fond of music.
[00:08:44.000 --> 00:08:47.000]   Aren't we all?
[00:08:47.000 --> 00:08:55.000]   And what are you doing for a J-O-B these days?
[00:08:56.000 --> 00:08:58.000]   I work in the record store.
[00:08:58.000 --> 00:09:01.000]   Ah, so...
[00:09:01.000 --> 00:09:06.000]   it all suddenly seems so clear.
[00:09:06.000 --> 00:09:10.000]   Do you like it?
[00:09:10.000 --> 00:09:13.000]   Yeah, I like it a lot, smartass.
[00:09:13.000 --> 00:09:16.000]   I get to listen to music all day,
[00:09:16.000 --> 00:09:19.000]   talk about music all day. It's really cool.
[00:09:19.000 --> 00:09:24.000]   It's gonna be a great environment for my little girl to grow up in.
[00:09:24.000 --> 00:09:30.000]   As opposed to jetting around the world,
[00:09:30.000 --> 00:09:32.000]   killing human beings,
[00:09:32.000 --> 00:09:35.000]   and being paid vast sums of money.
[00:09:35.000 --> 00:09:39.000]   Precisely.
[00:09:39.000 --> 00:09:41.000]   Well, my old friend,
[00:09:41.000 --> 00:09:43.000]   to each his own.
[00:09:43.000 --> 00:09:45.000]   However,
[00:09:45.000 --> 00:09:49.000]   all cock-blockery aside,
[00:09:49.000 --> 00:09:52.000]   I am looking forward to meeting your young man.
[00:09:52.000 --> 00:09:55.000]   I happen to be more or less particular
[00:09:55.000 --> 00:09:58.000]   who my gal marries.
[00:09:58.000 --> 00:10:02.000]   You wanna come to the wedding?
[00:10:02.000 --> 00:10:04.000]   Only if I can sit on the bride's side.
[00:10:04.000 --> 00:10:08.000]   You'll find it a bit lonely on my side.
[00:10:08.000 --> 00:10:12.000]   Your side always was a bit lonely,
[00:10:12.000 --> 00:10:15.000]   but I wouldn't sit anywhere else.
[00:10:15.000 --> 00:10:19.000]   You know,
[00:10:19.000 --> 00:10:22.000]   I had the loveliest dream about you.
[00:10:22.000 --> 00:10:24.000]   Oh, here's Tommy. Call me Arlene.
[00:10:24.000 --> 00:10:27.000]   You must be Tommy.
[00:10:27.000 --> 00:10:29.000]   Arlene's told me so much about you.
[00:10:29.000 --> 00:10:31.000]   Honey, you okay?
[00:10:31.000 --> 00:10:32.000]   Oh, I'm fine.
[00:10:32.000 --> 00:10:35.000]   Tommy, I'd like you to meet my father.
[00:10:35.000 --> 00:10:38.000]   Oh, my God.
[00:10:38.000 --> 00:10:40.000]   Oh, my God, this is great.
[00:10:40.000 --> 00:10:42.000]   I'm so glad to meet you, sir.
[00:10:42.000 --> 00:10:43.000]   Oh, Dad.
[00:10:43.000 --> 00:10:45.000]   The name's Bill.
[00:10:45.000 --> 00:10:47.000]   Well, it's great to meet you.
[00:10:47.000 --> 00:10:50.000]   So, Arlene told me you couldn't make it.
[00:10:50.000 --> 00:10:51.000]   Surprise.
[00:10:51.000 --> 00:10:53.000]   That's my pot for you.
[00:10:53.000 --> 00:10:55.000]   Always full of surprises.
[00:10:55.000 --> 00:10:58.000]   Well, in the surprise department,
[00:10:58.000 --> 00:11:01.000]   the apple doesn't fall far from the tree.
[00:11:01.000 --> 00:11:03.000]   When did you get in?
[00:11:03.000 --> 00:11:04.000]   Just now.
[00:11:04.000 --> 00:11:06.000]   Did you come straight from Australia?
[00:11:06.000 --> 00:11:07.000]   Of course.
[00:11:07.000 --> 00:11:09.000]   Daddy, I told Tommy that you were in Perth
[00:11:09.000 --> 00:11:12.000]   lining for Silver and no one could reach you.
[00:11:12.000 --> 00:11:16.000]   Lucky for us all, that's not the case.
[00:11:16.000 --> 00:11:20.000]   So, what's this all about?
[00:11:20.000 --> 00:11:22.000]   I've heard of wedding rehearsals,
[00:11:22.000 --> 00:11:24.000]   but I don't believe I've ever heard
[00:11:24.000 --> 00:11:26.000]   of a wedding dress rehearsal before.
[00:11:26.000 --> 00:11:29.000]   We thought, why pay so much money for a dress
[00:11:29.000 --> 00:11:31.000]   you're only gonna wear once?
[00:11:31.000 --> 00:11:34.000]   Especially when Arlene looks so goddamn beautiful in it.
[00:11:34.000 --> 00:11:38.000]   So, I think we're gonna try to get all the mileage we can out of it.
[00:11:38.000 --> 00:11:41.000]   Isn't it supposed to be bad luck
[00:11:41.000 --> 00:11:44.000]   for the groom to see the bride in her wedding dress?
[00:11:44.000 --> 00:11:46.000]   People of the ceremony?
[00:11:46.000 --> 00:11:50.000]   I guess I just believe in living dangerously.
[00:11:50.000 --> 00:11:54.000]   I know just what you mean.
[00:11:54.000 --> 00:11:57.000]   Some...some of us are places to be.
[00:11:57.000 --> 00:11:59.000]   It's your duty.
[00:11:59.000 --> 00:12:01.000]   Look, we gotta go through this one more time.
[00:12:01.000 --> 00:12:03.000]   So, why don't you have a s...
[00:12:03.000 --> 00:12:05.000]   Oh, my God.
[00:12:05.000 --> 00:12:07.000]   What am I thinking? You should give her away.
[00:12:07.000 --> 00:12:11.000]   Tommy, that's not exactly Daddy's cup of tea.
[00:12:11.000 --> 00:12:14.000]   I think Father would be much more comfortable
[00:12:14.000 --> 00:12:16.000]   sitting with the rest of the guests.
[00:12:16.000 --> 00:12:18.000]   Really?
[00:12:18.000 --> 00:12:20.000]   That's asking a lot.
[00:12:20.000 --> 00:12:24.000]   Oh. Okay. We'll forget it.
[00:12:24.000 --> 00:12:26.000]   But how about we go out to dinner tonight and celebrate?
[00:12:26.000 --> 00:12:29.000]   Only on the condition that I pay for everything.
[00:12:29.000 --> 00:12:32.000]   Deal. We gotta do this now.
[00:12:32.000 --> 00:12:33.000]   Can I watch?
[00:12:33.000 --> 00:12:35.000]   Absolutely. Have a seat.
[00:12:35.000 --> 00:12:38.000]   Which is the bride's side?
[00:12:38.000 --> 00:12:40.000]   Right over here.
[00:12:41.000 --> 00:12:44.000]   Father, here we go.
[00:12:44.000 --> 00:12:45.000]   Yes.
[00:12:45.000 --> 00:12:48.000]   Now, son, about them vows.
[00:12:48.000 --> 00:12:57.000]   Belle.
[00:12:57.000 --> 00:12:58.000]   I just don't want...
[00:12:58.000 --> 00:13:01.000]   You know only a damn thing.
[00:13:01.000 --> 00:13:04.000]   If he's the man you want,
[00:13:04.000 --> 00:13:07.000]   then go stand by.
[00:13:07.000 --> 00:13:09.000]   Stand by.
[00:13:09.000 --> 00:13:33.000]   Do I look pretty?
[00:13:33.000 --> 00:13:35.000]   Oh, yes.
[00:13:35.000 --> 00:13:37.000]   Thank you.
[00:13:38.000 --> 00:13:40.000]   Thank you.
[00:13:41.000 --> 00:13:43.000]   Thank you.
[00:13:44.000 --> 00:13:46.000]   Thank you.
[00:13:47.000 --> 00:13:49.000]   Thank you.
[00:13:49.000 --> 00:13:52.000]   [♪♪♪]
[00:13:53.000 --> 00:13:55.000]   [♪♪♪]
[00:13:55.000 --> 00:13:57.420]   (soft music)
[00:13:57.420 --> 00:13:59.320]   (slow, dramatic music)

ggml-medium.en.bin

Some stats:

whisper_model_load: mem required  = 1899.00 MB (+   43.00 MB per decoder)
whisper_model_load: model size    = 1462.12 MB

whisper_print_timings:    total time = 3563774.75 ms

So time needed was ~ 3563 seconds (almost 1 hour)

And the actual transcription:

[00:00:00.000 --> 00:00:15.000]   [Music]
[00:00:15.000 --> 00:00:19.000]   Do you find me sadistic?
[00:00:19.000 --> 00:00:21.000]   No, kiddo.
[00:00:21.000 --> 00:00:24.000]   I'd like to believe
[00:00:24.000 --> 00:00:27.000]   you're aware enough, even now,
[00:00:27.000 --> 00:00:31.000]   to know that there's nothing sadistic
[00:00:31.000 --> 00:00:34.000]   in my actions.
[00:00:34.000 --> 00:00:38.000]   This moment,
[00:00:38.000 --> 00:00:41.000]   this is me
[00:00:41.000 --> 00:00:44.000]   and my most masochistic.
[00:00:44.000 --> 00:00:46.000]   Well,
[00:00:46.000 --> 00:00:48.000]   it's your baby.
[00:00:48.000 --> 00:00:50.000]   [Gunshot]
[00:00:50.000 --> 00:00:53.000]   [Music]
[00:00:53.000 --> 00:00:55.000]   You looked dead, didn't I?
[00:00:55.000 --> 00:00:57.000]   Well, I wasn't.
[00:00:57.000 --> 00:01:00.000]   But it wasn't from lack of trying, I can tell you that.
[00:01:00.000 --> 00:01:03.000]   Actually, Bill's last bullet put me in a coma.
[00:01:03.000 --> 00:01:07.000]   A coma I was to lie in for four years.
[00:01:07.000 --> 00:01:09.000]   When I woke up,
[00:01:09.000 --> 00:01:15.000]   I went on what the movie advertisements refer to as a "roaring rampage of revenge."
[00:01:15.000 --> 00:01:18.000]   I roared, and I rampaged,
[00:01:18.000 --> 00:01:22.000]   and I got bloody satisfaction.
[00:01:22.000 --> 00:01:26.000]   I've killed a hell of a lot of people to get to this point.
[00:01:26.000 --> 00:01:29.000]   But I have only one more.
[00:01:29.000 --> 00:01:31.000]   The last one.
[00:01:31.000 --> 00:01:35.000]   The one I'm driving to right now.
[00:01:35.000 --> 00:01:38.000]   The only one left.
[00:01:38.000 --> 00:01:42.000]   And when I arrive at my destination,
[00:01:42.000 --> 00:01:45.000]   I am gonna kill Bill.
[00:01:45.000 --> 00:02:10.000]   [Music]
[00:02:10.000 --> 00:02:17.000]   Now, the incident that happened at the Two Pines wedding chapel that put this whole gory story into motion
[00:02:17.000 --> 00:02:20.000]   has since become legend.
[00:02:20.000 --> 00:02:22.000]   "Massacre at Two Pines."
[00:02:22.000 --> 00:02:24.000]   That's what the newspapers called it.
[00:02:24.000 --> 00:02:30.000]   The local TV news called it the "El Paso, Texas Wedding Chapel Massacre."
[00:02:30.000 --> 00:02:32.000]   How it happened.
[00:02:32.000 --> 00:02:33.000]   Who was there.
[00:02:33.000 --> 00:02:36.000]   How many got killed and who killed them.
[00:02:36.000 --> 00:02:40.000]   Changes depending on who's telling the story.
[00:02:40.000 --> 00:02:45.000]   In actual fact, the massacre didn't happen during a wedding at all.
[00:02:45.000 --> 00:02:48.000]   It was a wedding rehearsal.
[00:02:48.000 --> 00:02:55.000]   Now, when we come to the part where I say, "You may kiss the bride, you may kiss the bride,
[00:02:55.000 --> 00:02:59.000]   but don't stick your tongue in her mouth."
[00:02:59.000 --> 00:03:06.000]   This might be funny to your friends, but it would be embarrassing to your parents.
[00:03:06.000 --> 00:03:11.000]   We'll try to restrain ourselves from it.
[00:03:11.000 --> 00:03:16.000]   Y'all got a song?
[00:03:16.000 --> 00:03:20.000]   How about "Love Me Tender." I can play that.
[00:03:20.000 --> 00:03:22.000]   Sure.
[00:03:22.000 --> 00:03:24.000]   "Love Me Tender" would be great.
[00:03:24.000 --> 00:03:27.000]   Rufus, he's the man.
[00:03:27.000 --> 00:03:30.000]   Rufus, who was that you used to play for?
[00:03:30.000 --> 00:03:32.000]   Rufus Thomas.
[00:03:32.000 --> 00:03:35.000]   Rufus Thomas. Rufus Thomas.
[00:03:35.000 --> 00:03:43.000]   I was a drill, I was a drifter, I was a coaster, I was part of the gang, I was a bar-quet.
[00:03:43.000 --> 00:03:47.000]   If they come through Texas, I done played with them.
[00:03:47.000 --> 00:03:51.000]   Rufus, he's the man.
[00:03:54.000 --> 00:03:57.000]   Have you ever forgotten anything?
[00:03:57.000 --> 00:04:00.000]   Oh yes, you forgot the seating arrangements.
[00:04:00.000 --> 00:04:03.000]   Thank you, Mother.
[00:04:03.000 --> 00:04:10.000]   Now, the way we normally do this, we have the bride's side and then we have the groom's side.
[00:04:10.000 --> 00:04:17.000]   But since the bride ain't got nobody coming, and the groom's got far too many people coming.
[00:04:17.000 --> 00:04:21.000]   Well yeah, they're coming all the way from Oklahoma.
[00:04:21.000 --> 00:04:30.000]   Right. Well I don't see no problem with the groom's side sharing the bride's side. Do you, Mother?
[00:04:30.000 --> 00:04:32.000]   No, I don't have a problem with that.
[00:04:32.000 --> 00:04:38.000]   But, honey, you know it would be good if you had somebody come.
[00:04:38.000 --> 00:04:42.000]   You know, is it a sign of good faith?
[00:04:42.000 --> 00:04:49.000]   Well, I don't have anybody. Except for Tommy and my friends.
[00:04:49.000 --> 00:04:52.000]   You have no family?
[00:04:52.000 --> 00:04:54.000]   Well, I'm working on changing that.
[00:04:54.000 --> 00:04:58.000]   Mrs. Harmony, we're all the family this little angel's ever gonna need.
[00:04:58.000 --> 00:05:04.000]   I'm not feeling very well, and this bitch is starting to piss me off.
[00:05:04.000 --> 00:05:08.000]   So while you all blather on, I'm gonna go outside and get some air.
[00:05:08.000 --> 00:05:10.000]   Um, uh, Reverend, sorry.
[00:05:10.000 --> 00:05:12.000]   She's gonna go out and get some air?
[00:05:12.000 --> 00:05:14.000]   Yeah, given her delicate condition.
[00:05:14.000 --> 00:05:19.000]   She just needs a few minutes to get it together. She'll be okay.
[00:05:20.000 --> 00:05:25.000]   [Music]
[00:05:26.000 --> 00:05:31.000]   [Music]
[00:05:31.000 --> 00:05:36.000]   [Music]
[00:05:36.000 --> 00:05:41.000]   [Music]
[00:05:41.000 --> 00:05:46.000]   [Music]
[00:05:46.000 --> 00:05:51.000]   [Music]
[00:05:51.000 --> 00:05:56.000]   [Music]
[00:05:56.000 --> 00:06:01.000]   [Music]
[00:06:01.000 --> 00:06:06.000]   [Music]
[00:06:06.000 --> 00:06:11.000]   [Music]
[00:06:11.000 --> 00:06:16.000]   [Music]
[00:06:16.000 --> 00:06:21.000]   [Music]
[00:06:21.000 --> 00:06:26.000]   [Music]
[00:06:26.000 --> 00:06:31.000]   [Music]
[00:06:31.000 --> 00:06:36.000]   [Music]
[00:06:36.000 --> 00:06:38.000]   Hello, kiddo.
[00:06:38.000 --> 00:06:45.000]   How did you find me?
[00:06:45.000 --> 00:06:48.000]   I'm the man.
[00:06:48.000 --> 00:06:53.000]   What are you doing here?
[00:06:55.000 --> 00:06:57.000]   What am I doing?
[00:06:57.000 --> 00:07:03.000]   Well, a moment ago I was playing my flute.
[00:07:03.000 --> 00:07:17.000]   This moment, I'm looking at the most beautiful bride these whole eyes have ever seen.
[00:07:17.000 --> 00:07:21.000]   Why are you here?
[00:07:21.000 --> 00:07:23.000]   Last look.
[00:07:24.000 --> 00:07:26.000]   Are you gonna be nice?
[00:07:26.000 --> 00:07:29.000]   I've never been nice my whole life.
[00:07:29.000 --> 00:07:34.000]   But I'll do my best to be sweet.
[00:07:34.000 --> 00:07:41.000]   I always told you, your sweet side is your best side.
[00:07:41.000 --> 00:07:46.000]   I guess that's why you're the only one who's ever seen it.
[00:07:46.000 --> 00:07:51.000]   See, you got a bun in the oven.
[00:07:52.000 --> 00:07:53.000]   Hmm.
[00:07:53.000 --> 00:07:56.000]   I'm knocked up.
[00:07:56.000 --> 00:07:59.000]   Jeez, Louise.
[00:07:59.000 --> 00:08:04.000]   That young man of yours sure doesn't believe in wasting time, does he?
[00:08:04.000 --> 00:08:07.000]   Have you seen Tommy?
[00:08:07.000 --> 00:08:11.000]   Big guy in the tux?
[00:08:11.000 --> 00:08:12.000]   Yes.
[00:08:12.000 --> 00:08:14.000]   Then I saw him.
[00:08:14.000 --> 00:08:18.000]   I like his hair.
[00:08:20.000 --> 00:08:22.000]   You promised you'd be nice.
[00:08:22.000 --> 00:08:28.000]   I said I'd do my best. That's hardly a promise.
[00:08:28.000 --> 00:08:30.000]   But you're right.
[00:08:30.000 --> 00:08:34.000]   What does your young man do for a living?
[00:08:34.000 --> 00:08:39.000]   He owns a used record store here in El Paso.
[00:08:39.000 --> 00:08:41.000]   Ah. Music lover, eh?
[00:08:41.000 --> 00:08:44.000]   He's fond of music.
[00:08:44.000 --> 00:08:47.000]   Aren't we all?
[00:08:48.000 --> 00:08:52.000]   And what are you doing for a J.O.B. these days?
[00:08:52.000 --> 00:08:55.000]   I work in the record store.
[00:08:55.000 --> 00:08:59.000]   Ah, so...
[00:08:59.000 --> 00:09:03.000]   it all suddenly seems so clear.
[00:09:03.000 --> 00:09:07.000]   Do you like it?
[00:09:07.000 --> 00:09:10.000]   Yeah, I like it a lot, smartass.
[00:09:10.000 --> 00:09:13.000]   I get to listen to music all day.
[00:09:14.000 --> 00:09:17.000]   Talk about music all day. It's really cool.
[00:09:17.000 --> 00:09:22.000]   It's gonna be a great environment for my little girl to grow up in.
[00:09:22.000 --> 00:09:30.000]   As opposed to jetting around the world, killing human beings,
[00:09:30.000 --> 00:09:33.000]   and being paid vast sums of money?
[00:09:33.000 --> 00:09:36.000]   Precisely.
[00:09:36.000 --> 00:09:39.000]   Well, my old friend,
[00:09:39.000 --> 00:09:41.000]   to each his own,
[00:09:42.000 --> 00:09:44.000]   to each his own.
[00:09:44.000 --> 00:09:49.000]   However, all cock-luckery aside,
[00:09:49.000 --> 00:09:52.000]   I am looking forward to meeting your young man.
[00:09:52.000 --> 00:09:56.000]   I happen to be more or less particular,
[00:09:56.000 --> 00:09:58.000]   whom my gout marries.
[00:09:58.000 --> 00:10:02.000]   You wanna come to the wedding?
[00:10:02.000 --> 00:10:05.000]   Only if I can sit on the bride's side.
[00:10:05.000 --> 00:10:09.000]   You'll find it a bit lonely on my side.
[00:10:09.000 --> 00:10:12.000]   Your side always was a bit lonely.
[00:10:12.000 --> 00:10:15.000]   But I wouldn't sit anywhere else.
[00:10:15.000 --> 00:10:19.000]   You know,
[00:10:19.000 --> 00:10:22.000]   I had the loveliest dream about you.
[00:10:22.000 --> 00:10:25.000]   Oh, here's Tommy. Call me Arlene.
[00:10:25.000 --> 00:10:27.000]   You must be Tommy.
[00:10:27.000 --> 00:10:29.000]   Arlene's told me so much about you.
[00:10:29.000 --> 00:10:31.000]   Arlene, you okay?
[00:10:31.000 --> 00:10:32.000]   Oh, I'm fine.
[00:10:32.000 --> 00:10:35.000]   Tommy, I'd like you to meet my father.
[00:10:35.000 --> 00:10:38.000]   Oh, my God!
[00:10:38.000 --> 00:10:40.000]   Oh, my God! This is great!
[00:10:40.000 --> 00:10:42.000]   I'm so glad to meet you, sir.
[00:10:42.000 --> 00:10:43.000]   Oh, Dad.
[00:10:43.000 --> 00:10:45.000]   The name's Bill.
[00:10:45.000 --> 00:10:47.000]   Well, it's great to meet you, Bill.
[00:10:47.000 --> 00:10:49.000]   Arlene told me you couldn't make it.
[00:10:49.000 --> 00:10:51.000]   Surprise.
[00:10:51.000 --> 00:10:52.000]   That's my pop for you.
[00:10:52.000 --> 00:10:54.000]   Always full of surprises.
[00:10:54.000 --> 00:10:57.000]   Well, in the surprise department,
[00:10:57.000 --> 00:11:00.000]   the apple doesn't fall far from the tree.
[00:11:00.000 --> 00:11:02.000]   When did you get in?
[00:11:02.000 --> 00:11:03.000]   Just now.
[00:11:03.000 --> 00:11:05.000]   Did you come straight from Australia?
[00:11:05.000 --> 00:11:07.000]   Of course.
[00:11:07.000 --> 00:11:09.000]   Daddy, I told Tommy that you were in Perth
[00:11:09.000 --> 00:11:12.000]   mining for silver, and no one could reach you.
[00:11:12.000 --> 00:11:16.000]   Lucky for us all, that's not the case.
[00:11:16.000 --> 00:11:20.000]   So, what's this all about?
[00:11:20.000 --> 00:11:22.000]   I've heard of wedding rehearsals,
[00:11:22.000 --> 00:11:24.000]   but I don't believe I've ever heard
[00:11:24.000 --> 00:11:27.000]   of a wedding dress rehearsal before.
[00:11:27.000 --> 00:11:28.000]   We thought,
[00:11:28.000 --> 00:11:29.000]   "Why pay so much money for a dress
[00:11:29.000 --> 00:11:31.000]   you're only gonna wear once?"
[00:11:31.000 --> 00:11:34.000]   Especially when Arlene looks so goddamn beautiful in it.
[00:11:34.000 --> 00:11:36.000]   So, uh, I think we're gonna try to get all the mileage
[00:11:36.000 --> 00:11:37.000]   we can out of it.
[00:11:37.000 --> 00:11:41.000]   Isn't it supposed to be bad luck
[00:11:41.000 --> 00:11:44.000]   for the groom to see the bride in her wedding dress
[00:11:44.000 --> 00:11:46.000]   before the ceremony?
[00:11:46.000 --> 00:11:50.000]   Well, I guess I just believe I live in danger, so...
[00:11:50.000 --> 00:11:54.000]   I know just what you mean.
[00:11:54.000 --> 00:11:57.000]   Son, some of us have places to be.
[00:11:57.000 --> 00:11:59.000]   It's your old dude.
[00:11:59.000 --> 00:12:01.000]   Look, we gotta go through this one more time.
[00:12:01.000 --> 00:12:03.000]   So, uh, why don't you have a s--
[00:12:03.000 --> 00:12:05.000]   Oh, my God.
[00:12:05.000 --> 00:12:07.000]   What am I thinking? You should give her away.
[00:12:07.000 --> 00:12:11.000]   Tommy, that's not exactly Daddy's cup of tea.
[00:12:11.000 --> 00:12:14.000]   I think Father would be much more comfortable
[00:12:14.000 --> 00:12:16.000]   sitting with the rest of the guests.
[00:12:16.000 --> 00:12:17.000]   Really?
[00:12:17.000 --> 00:12:19.000]   That's asking a lot.
[00:12:19.000 --> 00:12:21.000]   Oh.
[00:12:21.000 --> 00:12:23.000]   Okay. Well, forget it.
[00:12:23.000 --> 00:12:26.000]   But how about we go out to dinner tonight and celebrate?
[00:12:26.000 --> 00:12:29.000]   Only on the condition that I pay for everything.
[00:12:29.000 --> 00:12:31.000]   Deal. We gotta do this now.
[00:12:31.000 --> 00:12:33.000]   Can I watch?
[00:12:33.000 --> 00:12:35.000]   Absolutely. Have a seat.
[00:12:35.000 --> 00:12:37.000]   Which is the bride's side?
[00:12:37.000 --> 00:12:39.000]   Right over here.
[00:12:39.000 --> 00:12:44.000]   Mother, here we go.
[00:12:44.000 --> 00:12:49.000]   Now, son, about them vows.
[00:12:49.000 --> 00:12:57.000]   No.
[00:12:57.000 --> 00:12:59.000]   I just don't want...
[00:12:59.000 --> 00:13:02.000]   You don't owe me a damn thing.
[00:13:03.000 --> 00:13:05.000]   If he's the man you want,
[00:13:05.000 --> 00:13:08.000]   then go stand by.
[00:13:08.000 --> 00:13:10.000]   [chuckles]
[00:13:10.000 --> 00:13:34.000]   Do I look pretty?
[00:13:34.000 --> 00:13:36.000]   Oh, yeah.
[00:13:36.000 --> 00:13:39.000]   [♪♪♪]
[00:13:39.000 --> 00:13:48.000]   Thank you.
[00:13:48.000 --> 00:13:51.000]   [♪♪♪]
[00:13:51.000 --> 00:13:54.000]   [♪♪♪]
[00:13:54.000 --> 00:13:57.000]   [♪♪♪]
[00:13:57.000 --> 00:13:59.920]   [MUSIC]

Comparison of results

tiny 160 s base 433 s small 1713 s medium 3563 s
Do you finally sit just now? Do you find me sadistic? Do you find me sadistic? Do you find me sadistic?
[MUSIC PLAYING] No, Kato. No, kiddo. No, kiddo.
No, I can’t do. I’d like to believe I’d like to believe you’re aware enough, even now, I’d like to believe
I’d like to believe you’re aware enough even now. you’re aware enough, even now. to know that there is nothing sadistic in my actions. you’re aware enough, even now,
No, that there is nothing suggesting in my actions. I know that there is nothing sadistic in my actions. This moment, this is me and my most nicer kiss to be. to know that there’s nothing sadistic
This moment, this is me and my most nice against them. This moment, Well, it’s your baby. in my actions.
Well, it’s your name. this is me, Look, Dad, didn’t I? This moment,
[MUSIC PLAYING] and I most miss the case. Well, I wasn’t. But it wasn’t from lack of trying, I can tell you that. this is me
But Dad didn’t I? Well, Actually, Bill’s last bullet put me in a coma. and my most masochistic.
Well, I wasn’t. it’s your baby. A coma I was to lie in for four years. Well,
But it wasn’t from lack of trying. The dead didn’t I? When I woke up, I went on with the movie advertisements it’s your baby.
I can tell you that. Well, I wasn’t. referred to as a roaring rampage of revenge. [Gunshot]
Actually, Bill’s last bullet put me in a coma. But it wasn’t from lack of try and I can tell you that. I roared, and I rampaged, and I got bloody satisfaction. [Music]
A coma, I was to lie in for four years. Actually, Bill’s last bullet put me in a coma. I’ve killed a hell of a lot of people to get to this point. You looked dead, didn’t I?
And I woke up. A coma I was to lie in for four years. But I have only one more. Well, I wasn’t.
I went on with a movie advertisements When I woke up, The last one. But it wasn’t from lack of trying, I can tell you that.
for two as a roaring rampage of revenge. I went on with the movie advertisements referred to as a roaring rampage of The one I’m driving to right now. Actually, Bill’s last bullet put me in a coma.
I roared. I roared, The only one left. A coma I was to lie in for four years.
And I relanged. and I rampaged, And when I arrive at my destination, I am gonna kill Bill. When I woke up,
And I got bloody satisfaction. and I got bloody satisfaction. I went on what the movie advertisements refer to as a “roaring rampage of revenege.”
I’ve killed a hell of a lot of people to get to this point. I’ve killed a hell of a lot of people who get to this point. I roared, and I rampaged,
But I have only one more. But I have only one more. and I got bloody satisfaction.
The last one. The last one. I’ve killed a hell of a lot of people to get to this point.
The one I’m driving to right now. The one I’m driving to right now. But I have only one more.
The only one left. The only one left. The last one.
And when I arrive at my destination, And when I arrive at my destination, The one I’m driving to right now.
I have going to kill Bill. I am gonna kill Bill. The only one left.
And when I arrive at my destination,
I am gonna kill Bill.

Conclusion

If you take a peek at the above results (and you remember the movie or download an .srt of the actual subtitles) you’ll see that the results for the small and medium model were almost perfect! The base model was also good enough considering that it took much less time than these two.

The tiny model wasn’t so good however even that model is good enough to understand everything that is being said in the movie from the subtitles and context. Finally, consider that both the tiny and base models were faster than real-time even on my (very slow) computer.

HTML form disable after submit

One of the most common problems I get in my apps is double submissions of forms. A lot of users can’t understand the difference between single and double click and end up double clicking the form submit button. Also, if the form takes too long to submit they might thing that they didn’t press the button correctly and click it again.

This, depending on how your app is built could result in either working perfectly, or showing errors to users or (which is the worst) duplicate entries in your database.

There is a very simple fix for that: Disable the submit button after the first click. Here’s how to do it with jQuery:

$(document).ready(function () {
    $('form').submit(function () {
        let submit = $(this).find(':input[type=submit]')
        submit.prop('disabled', true);
        if(submit.val()) {
            submit.val(submit.val() + ' ⌛' )
        } else {
            submit.html(submit.html() + ' ⌛')
        }
    })
});

and with vanilla.js if you don’t use jquery

document.addEventListener('DOMContentLoaded', function () {
    document.querySelectorAll('form').forEach(function (form) {
        form.addEventListener('submit', function (event) {
            let submit = this.querySelector('input[type="submit"], button[type="submit"]');
            submit.disabled = true;
            if(submit.value) {
                submit.value = submit.value + ' ⌛';
            } else {
                submit.innerHTML = submit.innerHTML + ' ⌛';
            }
        });
    });
});

The above will find all forms and add an event listener to the submit event. When the form is submitted it will find the submit button and disable it. Finally, it adds a unicode hourglass character (⌛) to the displayed button text so the user gets a quick feedback that the form is being submitted.

The above snippets should work correctly no matter if you use an <input type="submit"> or a <button type="submit"> element (that’s why we use :input in jquery to capture both types of elements or we do the double check on the querySelector, also notice that it checks if the element has a val()/value and sets sets val()/value or html()/innerHTML accordingly).

I use the above snippet on every project I work on and it has saved me a lot of headaches. Please be advised that if you do funny JS things with your form this snippet may not work and break its functionality, but in this case you are probably handling the form disabling yourself.

Multiple storages for the same FileField in Django

When you need to support user-uploaded files from Django (usually called media) you will probably use a FileField in your models. This is translated to a simple varchar (text) field in the database that contains a unique identifier for the file. This usually would be the path to the file, however this is not always the case!

What really happens is that Django has an underlying concept called a File Storage which is a class that has information on how to talk to the actual storage backend, and particularly how to translate the unique identifier stored on the db to an actual file object. By default Django stores files in the file system using the FileSystemStorage however it is possible to use different backends through an add-on (for example Amazon S3) or even write your own.

Each FileField can be configured to use a different storage backend by passing the storage parameter; if you don’t use this parameter then the default storage backend is used. So you can easily configure a FileField that would upload files to your filesystem and another one that would upload files to S3.

However, one thing that is not supported though is to use multiple storages for the same FileField depending on some parameter of the model instance. Unfortunately, in a recent project I had to do exactly that: We had a FileField on a model that contained hundreds of GBs of files stored on the filesystem; we wanted to be able to upload the files of new instances of that model on S3 but also wanted to keep the old files on the filesystem to avoid moving all these to S3 (which would result to a lot of downtime). I also wanted a way to be “flexible” on this i.e to be able to change again the storage backend for some instances if needed and definitely not move/copy all these files!

If you take a peek at the FileField options you’ll see that there’s a storage parameter that can be a callable. However this callable is initialized with the models and is not evaluated again until the app is restarted so it can’t be used to decide on the storage for each model instance.

The only thing that is evaluated each time a file is uploaded through the FileField is when upload_to is a function. This function receives the model instance and returns the path that the file will be uploaded to.

The idea is to use this upload_to function to return a different path depending on the model instance and then use a custom storage backend that will use the path to decide on the actual storage backend to use.

This is the code I ended up with for the upload_to function:

def file_upload_path(instance, filename):
    dt_str = app.created_on.strftime("%Y/%m/%d")
    file_storage = ""

    if instance.id >= settings.STORAGE_CHANGE_ID: 
        file_storage = settings.STORAGE_SELECTION_STR + "/"

    return "protected/{0}{1}/{2}/{3}".format(file_storage, dt_str, instance.id, filename)

class Model(models.Model):
    file = models.FileField(upload_to=file_upload_path)

What happens here is that I have a setting STORAGE_CHANGE_ID that is the id of the instance after which all instances will use the different storage backend. You can use whatever method you want here to decide on the storage that would be used; the only thing to keep in mind is to put the storage somewhere on the returned path.

I also have a setting STORAGE_SELECTION_STR that is the string that will be used in the path to differentiate the storage backend. The STORAGE_SELECTION_STR has the value of minios3 for this project.

Using this function the paths of the instances that are >= STORAGE_CHANGE_ID will be of the form protected/minios3/2021/04/11/1234/filename.ext while for the old files these will be of the form protected/2021/04/11/1234/filename.ext. Notice the minios3 string in between.

Of course this is not enough. We also need to tell Django to use the different storage backend for the new files. In order to do this we have to implement a custom storage class like this:

from django.core.files.storage import FileSystemStorage, Storage
from storages.backends.s3boto3 import S3Boto3Storage
from django.conf import settings


class FilenameBasedStorage(Storage):
    minio_choice = settings.STORAGE_SELECTION_STR

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def _open(self, name, mode="rb"):
        if self.minio_choice in name:
            return S3Boto3Storage().open(name, mode)
        else:
            return FileSystemStorage().open(name, mode)

    def _save(self, name, content):
        if self.minio_choice in name:
            return S3Boto3Storage().save(name, content)
        else:
            return FileSystemStorage().save(name, content)

    def delete(self, name):
        if self.minio_choice in name:
            return S3Boto3Storage().delete(name)
        else:
            return FileSystemStorage().delete(name)

    def exists(self, name):
        if self.minio_choice in name:
            return S3Boto3Storage().exists(name)
        else:
            return FileSystemStorage().exists(name)

    def size(self, name):
        if self.minio_choice in name:
            return S3Boto3Storage().size(name)
        else:
            return FileSystemStorage().size(name)

    def url(self, name):
        if self.minio_choice in name:
            return S3Boto3Storage().url(name)
        else:
            return FileSystemStorage().url(name)

    def path(self, name):
        if self.minio_choice in name:
            return S3Boto3Storage().path(name)
        else:
            return FileSystemStorage().path(name)

This class should be self explainable: It uses the settings.STORAGE_SELECTION_STR we mentioned above to decide which storage backend to use and then it forwards each method to the corresponding backend (either the filesystem storage or the S3 storage).

One thing to notice is that the django.core.files.storage.Storage class this class inherits from has more methods that can be implemented (and would raise if called without implementing them) however this implementation works fine for my needs.

One question some readers may have is what happens if the user uploads a file named test-minios3.pdf (i.e a file containing the STORAGE_SELECTION_STR). Well you may just as well ignore it; it will just be saved always on the minio-s3 storage backend. Or you can make sure to remove that string from the filename before saving it on the file_upload_path. I chose to ignore it since it doesn’t matter for my use case.

Finally, we need to tell Django to use this storage class for the file field. We can do this by adding it to the FileField like:

file = models.FileField(upload_to=file_upload_path, storage=FilenameBasedStorage)

or we can configure it on the DEFAULT_FILE_STORAGE setting (for Django < 4.2) or on the STORAGES dict (for Django >= 4.2).

I hope this helps someone else that needs to do something similar!

Using Unpoly with Django

Over the past few years, there has been a surge in the popularity of frontend frameworks, such as React and Vue. While there are certainly valid use cases for these frameworks, I believe that they are often unnecessary, as most web applications can be adequately served by traditional request/response web pages without any frontend framework. The high usage of these frameworks is largely driven by FOMO and lack of knowledge about alternatives. However, using such frameworks can add unnecessary complexity to your project, as you now have to develop two projects in parallel (the frontend and the backend) and maintain two separate codebases.

That being said, I understand that some projects may require extra UX enhancements such as modals, navigation and form submissions without full page reloads, immediate form validation feedback, page fragment updates etc. If you want some of this functionality but do not want to hop on the JS framework train, you can use the Unpoly library.

Unpoly is similar to other libraries like intercooler, htmx or turbo however I find it to be the easiest to be used in the kind of projects I work on. These libraries allow you to write dynamic web applications with minimal changes to your existing server-side code.

In this guide, we’ll go over how to use Unpoly with Django. Specifically, we’ll cover the following topics:

  • An unpoly demo
  • Integrating unpoly with Django
  • Navigation improvements
  • Form improvements
  • Modal improvements (layers)
  • Integration with (some) django packages
  • More advanced concepts

The unpoly demo

Unpoly provides a demo application written in Ruby. You can go on and play with it for a bit to understand what it offers compared to a traditional web app.

I’ve re-implemented this in Django so you can compare the code with a non-unpoly Django app. It can be found on https://github.com/spapas/django-unpoly-demo and the actual demo in Django is at: https://unpoly-demo.spapas.net or https://unpoly-demo.fly.dev/ (deployed on fly.io) or https://unpoly-demo.onrender.com/ (deployed on render.com; notice the free tier of render.com is very slow, this isn’t related to the app). The demo app uses an ephemeral database so the data may be deleted at any time.

Try navigating the demo site and you’ll see things like:

  • Navigation feedback
  • Navigation without page reloads
  • Forms opening in modals
  • Modals over modals
  • Form submissions without page reloads
  • Form validation feedback without page reloads

All this is implemented mostly with traditional Django class based views and templates in addition to a few unpoly attributes.

To understand how much of a difference this makes, after you have taken a peek at the “companies” functionality in the demo, take a look at the actual code that implementation (I’m only pasting the views, the other components are exactly the same as in a normal Django app):

class FormMixin:
    def form_valid(self, form):

        if form.is_valid() and not self.request.up.validate:
            if hasattr(self, "success_message"):
                messages.success(self.request, self.success_message)
            return super().form_valid(form)
        return self.render_to_response(self.get_context_data(form=form))

    def get_initial(self):
        initial = super().get_initial()

        initial.update(self.request.GET.dict())
        return initial


class CompanyListView(ListView):
    model = models.Company


class CompanyDetailView(DetailView):
    model = models.Company


class CompanyCreateView(FormMixin, CreateView):
    success_message = "Company created successfully"
    model = models.Company
    fields = ["name", "address"]


class CompanyUpdateView(FormMixin, UpdateView):
    model = models.Company
    success_message = "Company updated successfully"
    fields = ["name", "address"]


class CompanyDeleteView(DeleteView):
    model = models.Company

    def get_success_url(self):
        return reverse("company-list")

    def form_valid(self, form):
        self.request.up.layer.emit("company:destroyed", {})
        messages.success(self.request, "Company deleted successfully")
        return super().form_valid(form)

Experienced Django developers will immediately recognize that the above code has only two small diferences from what a traditional Django app would have:

  • the check for self.request.up.validate on the form_valid of the FormMixin
  • the self.request.up.layer.emit on the DeleteView form_valid

We’ll explain these later. However the thing to keep is that this is the same as a good-old Django app, without the need to implement special functionality like checks for ajax views, fragments, special form handling etc.

Integrating unpoly with Django

To integrate unpoly with Django you only need to include the unpoly JavaScript and CSS library to your project. This is a normal .js file that you can retrieve from the unpoly install page. Also, if you are using Bootstrap 3,4 or 5 I recommend to also download the corresponding unpoly-bootstrapX.js file.

Unpoly communicates with your backend through custom X-HTTP-UP headers. You could use the headers directly however it is also possible to install the python-unpoly library to make things easier. After installing that library you’ll add the unpoly.contrib.django.UnpolyMiddleware in your MIDDLEWARE list resulting in an extra up attribute to your request. You can then use this up attribute through the API for easier access to the unpoly headers.

To access up through your Django templates you can use request.up or add it to the default context using a context processor so you can access it directly.

To make sure that everything works, add the up-follow to one of your links, i.e change <a href='linkto'>link</a> to <a up-follow href='linkto'>link</a>. When you click on this link you should observe that instead of a full-page reload you’ll get the response immediately! What really happens is that unpoly will make an AJAX request to the server, retrieve the response and render it on the current page making the response seem much faster!

Unpoly configuration

The main way to use unpoly is to add up-x attributes to your html elements to enable unpoly behavior. However it is possible to use the unpoly js API (window.up or up) to set some global configuration. For example, you can use up.log.enable() and up.log.disable() to enable/disable the unpoly logging to your console. I recommend enabling it for your development environment because it will help you debug when things don’t seem to be working.

To use up to configure unpoly you only need to add it on a <script> element after loading the unpoly library, for example:

<script src="{% static 'unpoly/unpoly.min.js' %}"></script>
<script src="{% static 'unpoly/unpoly-bootstrap4.min.js' %}"></script>
<script src="{% static 'application.js' %}"></script>

And in application.js you can use up directly, for example to enable logging:

  up.log.enable()

We’ll see more up configuration directives later, however keep in mind that for a lot of up-x attributes it is possible to use the config to automatically add that attribute to multiple elements using a selector.

Using the up-follow directive you can start adding up-follow to all your links and you’ll get a much more responsive application. This is very simple and easy.

One interesting thing is that we didn’t need to change anything on the backend. The whole response will be retrieved by unpoly and will replace the body of the current page. Actually, it is possible to instruct unpoly to replace only a specific part of the page using a css selector (i.e replace only the #content div). To do this you can add the up-target attribute to the link, i.e <a up-target='#content' up-follow href='linkto'>link</a>. When unpoly retrieves the response, it will make sure that it has an #content element and put its contents to the original page #content element.

This technique is called linking to fragments in the unpoly docs. To see this in action, try going to the tasks in the demo and add a couple of new task. Then try to edit a that task. You’ll notice that the edit form of the task will replace the task show card! To do that, unpoly loads the edit task form and matches the .task element there with the current .task element and does the replacement (see here for rules on how this works).

Beyond the up-follow, you can also use two more directives to further improve the navigation:

  • up-instant to follow the link on mousedown (without waiting for the user releasing the mouse button)
  • up-preload to follow the link when the mouse hovers over the link

Using the up-main

To make things simpler, you can declare an element to be the default replacement target. This is done by adding the up-main attribute to an element. This way, all up-follow links will replace that particular element by default unless they have an up-target element themselves.

What I usually do is that I’ve got a base.html template looking something like this:

    {% include "partials/_nav.html" %}
    <div up-main class="container">
      {% include "partials/_messages.html" %}
      {% block content %}
      {% endblock %}
    </div>
    {% include "partials/_footer.html" %}

See the up-main on the .container? This way, all my up-follow links will replace the contents of the .container element by default. If I wanted to replace a specific part of the page, I could add the up-target attribute to the link.

If there’s no up-main element, unpoly will replace the whole body element.

It is possible to make all links (or links that follow a selector) followable by default by using the up.link.config.followSelectors option. I would recommend to only do this on greenfield projects where you’ll test the functionality anyway. For existing projects I think it’s better to add the up-follow attribute explicitly to the links you want to make followable.

This is recommend it because there are cases where using unpoly will break some pages, especially if you have some JavaScript code that relies on the page being loaded. We’ll talk about this in the up.compiler section.

If you have made all the links followable but you want to skip some links and do a full page reload instead, add the up-follow=false attribute to the link or use the up.link.config.noFollowSelectors config to make multiple links non-followable.

You can also make all links instant or preload for example by using up.link.config.instantSelectors.push('a[href]') to make all followable links load on mousedown. This should be safe because it will only work on links that are already followable.

One very useful feature of unpoly is that it adds more or less free navigation feedback. This can be enabled by adding an [up-nav] element to the navigation section of your page. Unpoly then will add an up-current class to the links in that section that match the current URL. This works no matter if you are using up-follow or not. You can then style .up-current links as you want.

If you are using Bootstrap along with the unpoly-bootstrap integrations you’ll get all that without any extra work! The unpoly-bootstrap has the following configuration:

up.feedback.config.currentClasses.push('active');
up.feedback.config.navSelectors.push('.nav', '.navbar');

So it will automatically add the up-nav element on .nav and .navbar elements and will add the active class to the current link (in addition to the .up-current class). This is what happens in the demo, if you take a peek you’ll see that there are no up-nav elements (since these are marked by the unpoly-bootstrap integration) in the navigation bar and we style the .active nav links.

Aliases for navigation feedback

Unpoly also allows you to add aliases for the navigation feedback. For example, you may have /companies/ and /companies/new and you want the companies nav link to be active on both of them. To allow that you need to use the up-alias attribute on the link like

<a class='nav-item nav-link' up-follow href='{% url "company-list" %}' up-alias='{% url "company-list" %}new/'>Companies</a>

(notice that in my case the url of company-list is /companies/ that’s why I added {% url "company-list" %}new/ on the alias so the resulting alias path would be /companies/new/), or even add multiple links to the alias

<a class='nav-item nav-link' up-follow href='{% url "company-list" %}' up-alias='{% url "company-list" %}*'>Companies</a>

This will add the up-current class to the a element whenever the url starts with /companies/ (i.e /companies/, /companies/new, /companies/1/edit etc).

Please notice that it is recommended to have a proper url hierarchy for this to work better. For example, if you have /companies_list/ and /add_new_company/ you’ll need to add the aliases like up-alias='/companies_list/ /add_new_company/' (notice the space between the urls to add two aliases). Also, if you want to also handle URLS with query parameters i.e /companies/?name=foo then you’ll need to add ?* i.e /companies/?*. These urls aren’t aliased by default so /companies/ doesn’t match /companies/?name=foo unless you add an alias.

One final remark is that it is possible to do some trickery to automatically add up-alias to all your nav links. This is useful in case you have many nav elements and you don’t want to add aliases to each one of them, for example, using this code:

  up.compiler('nav a[href]', (link) => {
    if(!link.href.endsWith('#')) link.setAttribute('up-alias', link.href + '*')
  })

an up-alias attribute will be added to all links. The callback of the compiler will be called when the selector is matched and in this case add the up-alias attribute to the link. We’ll talk later about compilers more.

Handling forms

Unpoly can also be used to handle forms without page reloads, similar to following links. This is simple to do by adding an up-submit attribute to your form. Also similar to links you can make all your forms handled by unpoly but I recommend to be cautious before doing this on existing projects to make sure that stuff doesn’t break.

When you add an up-submit to a form unpoly will do an AJAX post to submit the form and replace the contents of the up-target element with the response (if you don’t specify an up-target element, it will use the up-main element in a similar way as links). This works fine with the default Django behavior, i.e when the form is valid Django will do a redirect to the success url, unpoly will follow that link and render the response of the redirect.

Integrating with messages

Django has the messages framework that can be used to add one-time flash messages after a form is successfully submitted. You need to make sure that these messages are actually rendered! For example, in the base.htm template I mentioned before, we’ve got the following:

    {% include "partials/_nav.html" %}
    <div up-main class="container">
      {% include "partials/_messages.html" %}
      {% block content %}
      {% endblock %}
    </div>
    {% include "partials/_footer.html" %}

please notice that we’ve got the partials/_messages.html template included in the up-main element (inside the container). This means that when unpoly replaces the contents of the up-main element with the response of the form submission, the messages will be rendered as well. So it will work fine in this case.

However, if you are using up-target to render only particular parts of the page the flash messages will be actually lost! This happens because unpoly will load the page with the flash messages normally, so these messages will be consumed; then it will match the .target and display only that part of the response.

To resolve that you can use the up-hungry attribute on your messages. For example, in the partials/_messages.html template we’ve got the following:

<div id='flash-messages' class="flash-messages" up-hungry>
    {% for message in messages %}
        <div class="alert fade show {% if message.tags %} alert-{% if 'error' in message.tags %}danger{% else %}{{ message.tags }}{% endif %}{% endif %}">
            {{ message }}
        </div>
    {% endfor %}
</div>

The up-hungry attribute will make unpoly refresh that particular part of the page on every page load even if it’s not on the target. For example notice how the message is displayed when you edit or mark as done an existing task in the demo.

However also notice that no messages are displayed if you create a new task! This happens because the actual response is “eaten” by the layer and the messages are discarded! We’ll see how to fix that later.

Immediate form validation

Another area in which unpoly helps with our forms is that if we add the up-validate attribute to our form, unpoly will do an AJAX post to the server whenever the input focus changes and will display the errors in the form without reloading the page. For this we need a little modification to our views to check if the unpoly wants to validate the form. I’m using the following form_valid on a form mixin:

def form_valid(self, form):

    if form.is_valid() and not self.request.up.validate:
        if hasattr(self, "success_message"):
            messages.success(self.request, self.success_message)
        return super().form_valid(form)
    return self.render_to_response(self.get_context_data(form=form))

So if the form is not valid or we get an unpoly validate request from unpoly we’ll render the response - this will render the form with or without errors. However if the form is actually valid and this is not an unpoly validate request we’ll do the usual form save and redirect to the success url. This is enough to handle all cases and is very simple and straightforward. It works fine without unpoly as well since the up.validate will be always False in this case.

One thing to keep in mind is that this works fine in most cases but may result to problematic behavior if you use components that rely on javascript onload events. The up-validate will behave more or less the same as with up-follow links.

Other form helpers

Beyond these, unpoly offers a bunch of form helpers to run callbacks or auto-submit a form when a field is changed. Most of this functionality can be replicated by other js libraries (i.e jquery) or even by vanilla.js and is geared towards the front-end so I won’t cover it more here.

Understanding layers

One of the most powerful features of unpoly is layers. To understand the terminology, a layer is any page that is stacked on top of another. The initial page is called the root layer, all other layers are called overlays. Layers can be arbitrary opened and stacked, there’s no limit on the number of layers that can be opened.

An overlay can be rendered like a modal / popup / drawer. The simplest way to use an overlay is to add an up-layer='new' attribute to a link. For example, in the demo app, the link to open a company is like this:

  <a
    up-layer='new'
    up-on-dismissed="up.reload('.table', { focus: ':main' })"
    up-dismiss-event='company:destroyed'
    href="{% url 'company-detail' company.id %}">{{ company.name }}</a>

(ignore the dismiss-related attributes for now). This opens a new modal dialog with the contents of the company detail. It will render the whole contents of the up-main element inside the modal since we don’t provide an up-target. If we added an up-target='.projects' attribute to this it would render only the .projects element inside the modal (but remember that it will retrieve the whole response since the /companies/detail/id is a normal django DetailView). So with up-layer='new' we open a page on a new overlay/modal. If we also add an up-target to it we’ll open only a particular part of that page.

You can use up-mode attribute to change the kind of overlay; the default is a modal. Also if you want to configure the ways this modal closes you can use the up-dismissable attribute, for example add up-dismissable='button' to allow closing only with the X button on the top right. Another useful thing is that there’s an up-size attribute for changing the size of the overlay. I recommend playing a bit with these options to have a feel on how they are working and what you can do with them.

Static overlay content

An overlay can also contain “static” content (i.e not follow a link but display some html) by using the up-content attribute. This is how the green dots are implemented, their html is similar to this:

<a href="#" class="tour-dot viewed" up-layer="new popup" up-content="<p>Navigation links have the <code>[up-follow]</code> attribute. 
    <p>
        <a href=&quot;#&quot; up-dismiss class=&quot;btn btn-success btn-sm&quot;>OK</a>
    </p>
    " up-position="right" up-align="top" up-class="tour-hint" up-size="medium">
</a>

Notice that the up-content contains a whole html snippet. This is implemented in Django using the following template tag:

@register.tag("tourdot")
def do_tourdot(parser, token):
    nodelist = parser.parse(("endtourdot",))
    parser.delete_first_token()
    return TourDotNode(nodelist)


class TourDotNode(template.Node):
    def __init__(self, nodelist):
        self.nodelist = nodelist

    def render(self, context):
        rendered = self.nodelist.render(context).strip()
        size = "medium"
        if len(strip_tags(rendered)) > 400:
            size = "large"
        if not rendered.startswith("<p"):
            rendered = "<p>{}</p>".format(rendered)

        rendered += """
        <p>
            <a href="#" up-dismiss class="btn btn-success btn-sm">OK</a>
        </p>
        """
        from django.utils.html import escape

        output = escape(rendered)
        return """
        <a 
            href="#" class="tour-dot" up-layer="new popup" 
            up-position="right" up-align="top" up-class="tour-hint"
            up-content="{}"
            up-size="{}"
            >
        </a>
        """.format(
            output, size
        )

So we can do something like this in our Django templates:

{% tourdot %}
  <p>Navigation links have the <code>[up-follow]</code> attribute. Clicking such links only updates a <b>page fragment</b>. The remaining DOM is not changed.</p>
{% endtourdot %}

Advanced layers/overlays

Opening overlays for popups or for modals to view links that don’t have interactivity is simple. However, when you open forms with overlays and need to handle these (i.e close the modals only when the form is submitted succesfully) the situation unfortunately starts to get more complex. I recommend to start by reading the subinteractions section of the unpoly documentation to understand how these things work. In the following subsections we’ll talk about specific cases and how to handle them with unpoly layers and Django.

Opening new layers over existing ones

How opening a new layer over an existing layer (i.e a modal inside a modal) would work? All links and forms that are handled in an existing layer will be handled in the same layer. So if we have opened a layer and there are up-follow links in the html of the layer, the user would be able to follow them normally inside that layer (of course if there are non up-follow links then a full page reload will be performed and the layer will disappear without a trace).

If we want to open a new layer we need to use the up-layer='new' attribute on that link; it doesn’t matter if this is inside an already opened layer, it will work as expected and open a layer-in-a-layer. If the parent layer is an overlay then it will open an overlay-in-an-overlay.

In the demo, if you click on an existing company to see its details you’ll get an overlay. If you try to edit that company the edit for will be opened in the same layer (notice that if you press the X button to close it you’ll go back to the company list without layers). Compare this with the behavior when adding a new project or viewing an existing one. You’ll get an overlay inside the parent overlay (both overlays should be visible). You need to close both overlays to go back to the company detail at the root layer.

Even more impressive: Go to the company detail layer, click an existing project to get to the project detail layer, click edit; this will be opened on the project detail layer. You can edit the project or even delete it, when you click that overlay the company overlay will be updated with the new data and work fine! All this also works fine from the project detail list without any modifications on the Django code.

The thing to remember here is that the layer behavior is very intuitive and is compatible with how a server side application works. Everything should work the same no matter if the link is opened in an overlay or in a new page or even an overlay over an overlay.

Closing overlays

There are three main ways to close an overlay (beyond of course using the (X) button or esc etc):

  • Visiting a pre-defined link
  • Explicitly closing the overlay from the server
  • Emitting an unpoly event

Also, when an overlay is closed we can decide if the overlay did something (i.e the user saved the form) or not (i.e the user clicked the X button). This is called accepted or dismissed respectively. We can use this to do different things. All the methods of closing an overlay have a version for accepting or dismissing the overlay.

To close the overlay on visiting a link we’ll use the up-accept-location and up-dismiss-location respectively. For example, let’s take a peek on the new company link:

  <a
    class='btn btn-primary'
    up-layer='new'
    up-on-accepted="up.reload('.table', { focus: ':main' })"
    up-accept-location='/core/companies/detail/$id/'
    href='{% url "company-create" %}'>New company</a>

The important thing here is the up-accept-location. When Django creates a new object it redirects to the detail view of that object. In our case this detail view is '/core/companies/detail/$id/'; the $id is an unpoly thingie that will be replaced by the id of the new object and will be the result value of the overlay. This value (the id) can then be used on the up-on-accepted callback if we want.

Now, let’s suppose that we want to close the overlay when the user clicks on a cancel button that returns to the list of companies. We can do that by adding the up-dismiss-location attribute to that <a>

up-dismiss-location='{% url "company-list" %}'

The difference between these two is that the up-on-accepted event will only be called when the overlay is accepted and not on dismissed.

Handling hardcoded urls

One thing that Django developers may not like is that the url is hardcoded here. This is because using {% url "company-detail" "$id" %} will not work with our urls since we use have the following path for the company detail "companies/detail/<int:pk>/". We can change it to "companies/detail/<str:pk>/", to make it work but then it will allow strings in the url and it will throw 500 error instead of 404 when the user uses a string there (to improve that we have to override the get_object of the DetailView to handle the string case). Another way to improve that is to create a urlid template tag like this:

from django.urls import reverse

@register.simple_tag
def urlid(path, arg):
    if arg == "$id":
        arg = 999

    url = reverse(path, args=[arg])
    return url.replace("999", "$id")

And then using it like this on the up-accept-location:

up-accept-location='{% urlid "company-detail" "$id" %}'

Explicitly closing the layer

To close the layer from the server you can you use the X-Up-Accept-Layer or X-Up-Dismiss-Layer response header. When unpoly sees this header in a response it will close the overlay by accepting/dismissing it.

To do that from Django if you have integrated the unpoly middleware, call request.up.layer.accept() and request.up.layer.dismiss() respectively (passing an optional value if you want).

The same feature can be used to close the overlay from the client side. For example, if you want to close the overlay when the user clicks on a cancel button that returns to the list of companies you can do that by adding the up-accept or up-dismiss attribute, like:

<a href='{% urlid "company-detail" "$id" %}' up-dismiss>Return</a>

Please notice that the href here could be like href='#' since this is javascript only to close the overlay, however we added the correct href to make sure the return button will also work when we open the link in a new page (without any overlays).

Please notice that difference between this and up-accept-location or up-dismiss-location we mentioned before. In this case the up-accept/dismiss directive in placed in the a link that closes the overlay. In the former case the up-accept/dismiss-location directive is placed in the link that opens the overlay.

Closing the layer by emitting an unpoly event

The final way to close an overlay is by emitting an event. Unpoly can emit events both from the server, using the X-Up-Event response header or using request.up.emit(event_type, data) from the unpoly Django integration. Also events can be emitted from the client side using the up-emit attribute.

To close the overlay from an event we need to use up-accept-event and up-dismiss-event on the link that opens the overlay.

Let’s see what happens when we delete a company. We’ve got a form like this:

<form up-submit up-confirm='Really?' class="d-inline" method='POST' action='{% url "company-delete" company.id %}'>
  {% csrf_token %}
  <input type='submit' value='Delete' class='btn btn-danger mr-3' />
</form>

This form asks the user for confirmation (using the up-confirm directive) and then submits the form on the company delete view. The CompanyDeleteView is like this:

class CompanyDeleteView(DeleteView):
    model = models.Company

    def get_success_url(self):
        return reverse("company-list")

    def form_valid(self, form):
        self.request.up.layer.emit("company:destroyed", {})
        return super().form_valid(form)

So, it will emit the company:destroyed event and redirect to the list of companies (this is needed to make sure that delete works fine if we call it from a full page instead of an overlay). The company detail view overlay is opened from the following a link:

<a
  up-layer='new'
  up-on-dismissed="up.reload('.table', { focus: ':main' })"
  up-dismiss-event='company:destroyed'
  href="{% url 'company-detail' company.id %}">{{ company.name }}</a>

Notice that we have the up-dismiss-event here. If we didn’t have that then the overlay wouldn’t be closed when we deleted the company but we’d see the list of companies on the overlay because of the redirect on the Django side! Also, instead of the up-dismiss-event we could use the up-dismiss-location='{% url "company-list" %}' similar to how we discussed before. If we did it this way we wouldn’t even need to do anything unpoly related in our DeleteView, however using events for this is useful for educational reasons and we’ll see later how events will help us to dispaly a message when companies are deleted.

Doing stuff when a layer is closed

After a layer is closed (and depending if it was accepted or dismissed) unpoly allows us to use callbacks to do stuff. The most obvious things are to reload the list of results if a result is added/edited/deleted or to choose a result in a form if we used the overlay as an object picker.

The callbacks are up-on-accepted and up-on-dismissed.

Let’s see some examples from the demo.

On on the new company link we’ve got up-on-accepted="up.reload('.table', { focus: ':main' })". However on the show details company link we’ve got up-on-dismissed="up.reload('.table', { focus: ':main' })". This is a little strange (why up-on-accepted on the new vs up-on-dismissed on the detail) at first but we can explain it.

First of all, the up.reload method will do an HTTP request and reload that specific element from the server (in our case the .table element that contains the list of companies). The focus option that is passed instructs unopoly to move the focus to (that element)[https://unpoly.com/focus-option].

For the “Add new” company we reload the companies when the form is accepted (when the user clicks on the “Save” button). However for the show details we’ll reload every time the overlay is dismissed because when the user edits a company the layer will not be closed but will display the edit company data. Also when we delete the company the layer will be dismissed.

Notice that if the user clicks the company details and then presses the (X) button we’ll still do a reload even though it may not be needed because we can’t know if the user actually edited the company or not before closing the overlay. This is a little bit of a tradeoff but it’s not a big deal.

Actually, it is possible to know if the overlay was dismissed because the user clicked the (X) button (or pressed escape) or if the overlay was dismissed because the object was deleted. This is useful if we wanted to display a message to the user when the company was deleted since we’d need to differentiate between these cases. We’ll see how the section about overlays and messages.

On the company detail we’ve got up-on-accepted='up.reload(".projects")' for adding a new project but same as before we’ve got up-on-dismissed='up.reload(".projects")' for viewing the project detail. The .projects element is the projects holder inside the company detail. This is exactly the same behavior we explained before.

On the project form we’ve got up-on-accepted both on the suggest name and on the new company button. In the first case, we are opening the name suggestion overlay like this:

<a
    up-layer='new popup'
    up-align='left'
    up-size='large'
    up-accept-event='name:select'
    up-on-accepted="up.fragment.get('#id_name').value = value.name"
    href='{% url "project-suggest-name" %}'>Suggest name</a>

Notice that this overlay will be accepted when it receives the name:select event. This event passes it the selected name so it will put it on the #id_name input. The up.fragment.get is used to retrieve the input. To understand how this works we need to also see the name suggestion overlay. This is more or less similar to:

  {% for n in names %}
    <a up-emit="name:select"
       up-emit-props='{"name": "{{ n }}"}'
       class="btn btn-info text-light mb-2 mr-1"
       tabindex="0">
      {{ n }}
    </a>
  {% endfor %}

So we are using the up-emit directive here to emit the name:select event and we pass it some data which must be a json object. Then this data will be available as a javascript object named value on the up-on-accepted callback.

So the flow is:

  1. When we click the suggest name link we open a new overlay and wait for the name:select event to be emitted. We don’t care if we are a full page or already inside an overlay
  2. The suggest name overlay displays a link of <a> elements that emit the name:select event when clicked and also pass the selected name as data on the event
  3. The overlay opener receives the name:select event and closes the overlay. It then uses the data to fill the #id_name input

The second case is similar but instead of filling an input it opens a new overlay to create and select a new company. This is the create company link from inside the project form:

  <a href='{% url "company-create" %}'
      up-layer='new'
      up-accept-location='{% urlid "company-detail" "$id" %}'
      up-on-accepted="up.validate('form', { params: { 'company': value.id } })"
  >
      New company
  </a>

Nothing extra is needed from the company form side! We use the up-accept-location to accept the overlay when the company is created (so the user will be redirect to the company-detail view). Then we call up.validate('form', { params: { 'company': value.id } }) after the overlay is accepted. First of all, please remember that when we use the up-accept-location the overlay result will be an object with the captured parts of the url. In this case we capture the new company id. Then, we call up.validate passing it the form and the company id we just retrieved (i.e the id of the newly created company).

It is important to understand that we do up.validate here instead of simply setting the value of the select to the newly created id (similar to what we did before with the name) because the newly created value is not in the options that this select contains so it can’t be picked at this time; when the validate returns though it will contain the newly created company to the options so it will be selected then.

If we wanted to select the newly created company without doing the validate instead we’d need to first add a new option to the select with the correct id and then set it to that value (which is a little bit more complex since we don’t know the name of the new company at this point). To properly implement that and to further understand how unpoly works, we’d need to emit a company:create event from our CreateCompanyView which would contain as data both the id and the name of the newly created company. Then we’d change our accept condition to up-accept-event='company:create'. Finally, our up-on-accepted would add a new option with the value.name and value.id it received from the event and select that option.

Overlays and messages

This probably is the most complex part of integrating unpoly with Django. The problem is that when we do an action the messages will be displayed on the page that our response redirects to. If we don’t display that page but we only use it as on-accept-location we’ll miss these messages. There are various solutions on how this can be fixed, and there also is a long discussion in the unpoly repo discussions about that.

We’ve already discussed about the up-hungry in your messages container element that will reread its contents from all responses. This will resolve all cases where the response is not discarded. For example, try to edit a project and you’ll see the edit message on the overlay (instead of the main page). This is because the overlay is contained in the up-main element so it will be rendered in the response in the overlay.

The problematic behavior is when creating a new project or deleting one. In both these cases we discard the response so the messages are lost. The simplest way to actually fix this is to ignore the server side message and render again the message from unpoly. This avoids changing anything on your server-side code. So, in order to implement this, we’ll use this function which we add on application.js (after a suggestion on the afforementioned discussion):

async function reloadWithFlash(selector, flash) {
  await up.reload(selector)
  up.element.affix(document.getElementById('flash-messages'), '.alert.fade.show.alert-success', { text: flash })
}

This function will call up.reload with a selection we pass to it (f.e up.reload('.table')) and wait until this function finished. Then it will add a new element on the flash-messages container with the flash text we pass to it. In order to use it, we’ll change the new company link to:

      <a
        class='btn btn-primary'
        up-layer='new'
        up-on-accepted="reloadWithFlash('.table', 'Company created!')"
        up-accept-location='{% urlid "company-detail" "$id" %}'
        href='{% url "company-create" %}'>New company</a>

(remember that up-on-accepted before was up-on-accepted="up.reload('.table', { focus: ':main' })"), let’s skip focus for now it’s not important). If we try it this way we’ll notice that we’ll get the Company created message after the overlay is closed! As I said before, the problem with this is that we ignore the server side message and duplicate the message both on server and on client side. The Django side message will be used when we open the /companies/new link on a new page (not an overlay) so the overlay functionality won’t be used and the message will be rendered properly on the response. When we use an overlay the client side message will be rendered instead.

Another solution would be to change our CompanyCreateView to redirect to the companies list page (instead of the newly created page). In this case, we can change the new company form like:

<form up-submit up-validate method="POST" up-layer='root'>
  ...
</form>

Adding the up-layer='root' will render the response in the root layer which will close the overlay and render everything on the up-main element. Since we redirect to the companies list, we’ll get the list of companies along with the server-side message. This solution is actually simpler but modifies our server-side app (instead of the usual behavior of redirecting to the new company detail well’ll redirect to the companies list).

Let’s now talk about delete. As we’ve already discussed above, the company detail overlay will be closed either when the user closes it explicitly by clicking the (X) button or because the company was deleted. In both cases we want to reload the companies but when the company is deleted we also want to display a message. So we need to know when the overlay was closed because the company was deleted vs when the overlay was closed explicitly by the user.

Right now we’ve got

<a
  up-layer='new'
  up-dismissable='button'
  up-on-dismissed="up.reload('.table', { focus: ':main' })"
  up-dismiss-event='company:destroyed'
  href="{% url 'company-detail' company.id %}">{{ company.name }}</a>

For starters, we’ll add the following function:

async function reloadWithFlashIfEvent(selector, flash, value) {
  await up.reload(selector, { focus: ':main' })
  if(value instanceof Event) {
    up.element.affix(document.getElementById('flash-messages'), '.alert.fade.show.alert-danger', { text: flash })
  }
}

and change up-on-dismissed to up-on-dismissed="reloadWithFlashIfEvent('.table', 'Company deleted!', value)" on the open overlay link.

The up-on-dismissed and up-on-accepted callbacks are passed these paremeters by unpoly: * this The link that originally opened the overlay * layer An up.Layer object for the dismissed overlay * value The overlay’s dismissal value * event An up:layer:dismissed event

If the event was dismissed because the user clicked the (X) button, the value would have a similar to :button (there are same string values for pressing escape or clicking outside the modal). However if it was dismissed because of the company:destroyed event, the value would be an Event object. So we pass the value to our reloadWithFlashIfEvent callback and check if the value is an Event object. If it is, we know that the overlay was dismissed because the company was deleted and we can display the flash message. If it’s not, we know that the overlay was dismissed because the user clicked the (X) button and we won’t display the flash message.

Another way we could implement this would be if we closed the company detail overlay when the company was deleted and returned the response (which is the company list view) to the root layer. Something like this:

<form up-submit up-confirm='Really?' class="d-inline" method='POST' action='{% url "company-delete" company.id %}' up-layer='root'>

(notice we added the up-layer='root' attribute). For this to work we need to not reload in the up-on-dismissed function because if we reload the companies list the contents of the flash-messages will be re-read (because it has the up-hungry attr) and be immediately cleared out! However in this case we need to reload because a company may be edited!

Improving delete

Right now, the delete button is a form, similar to this:

<form up-submit up-confirm='Really?' class="d-inline" method='POST' action='{% url "company-delete" company.id %}'>
  {% csrf_token %}
  <input type='submit' value='Delete' class='btn btn-danger mr-3'  />
</form>

So this is an unpoly-handled form and will display a Really? javascript prompt to make sure the user really wants to delete the company.

I have to confess that I don’t like javascript prompts because they can’t be styled and seem out of context from the app. However we can improve that behavior with unpoly. Here’s an improved version of the delete functionality:

<a class='btn btn-danger' up-layer="new" up-content='
      <h3>Delete company {{ company.name }}</h3>
      Do you want to delete the company? 
      <form up-submit up-target=".table" up-layer="root" class="d-inline" method="POST" action="{% url "company-delete" company.id %}">
        {% csrf_token %}
        <input type="submit" value="Yes" class="btn btn-danger mr-3"/>
        <a href="#" class="btn btn-secondary"  up-dismiss>No</a>
      </form>
      '>Delete</a>

We changed the delete button to open a new layer. Instead of having a special view for the delete confirmation, we’re using the up-content attribute to directly pass the static HTML for the confirmation, which actually includes the delete form like before. Notice that we also include an up-dismiss button that clears the overlay when the user presses No. The up-layer of the form is root so when the form is submitted it will close both the confirmation overlay and the company detail overlay! Now, we’ll change the reloadWithFlashIfEvent like this:

async function reloadWithFlashIfEvent(selector, flash, value) {
  await up.reload(selector, { focus: ':main' })
  if(value instanceof Event || value == ':peel') {
    up.element.affix(document.getElementById('flash-messages'), '.alert.fade.show.alert-danger', { text: flash })
  }
}

This checks if the value is an event or :peel; this is the value that is passed when the overlay is dismissed because we use the up-layer='root' from the delete form.

Improving interaction with Django packages

There are two very important packages that I use on almost all my projects: django-tables2 and django-filter. You can see these in action at the /core/tf/ path on the demo app. You’ll see that:

  • Filtering is instant (when entering a character it will filter without the need to submit the form explicitly )
  • The row detail links open in an overlay
  • Sorting and pagination are handled by unpoly (so they don’t do full page reloads)

To have the instant filtering we’ve changed our filter form like this:

<form up-autosubmit up-delay='250' class='form-inline' method='GET' action='' up-target='.form-data'>
    {{ filter.form|crispy }}
    <input class='btn btn-info' type='submit' value='Filter'>
    <a up-follow href='{{ request.path }}' class='btn btn-secondary'>Reset</a>
</form>

Notice the up-autosubmit; this will submit the form when a field changes. Also the up-delay adds a small delay before the form is submitted so when the user writes foo it will do 1 query instead of 3 (if he writes fast enough of course). The up-target attribute is used to specify the element that will be updated with the response. In this case we’re using a form-data element that includes the whole table (using django-tables2 of course):

<div class='form-data'>
  {% render_table table %}
</div>

To open the links in a layer we only need to pass the correct parameters to the table field, for example in our case the table is like this:

class CompanyTable(tables.Table):
    id = tables.LinkColumn(
        "company-detail",
        args=[A("id")],
        attrs={
            "a": {
                "class": "btn btn-primary btn-sm",
                "up-on-dismissed": "up.reload('.table', { focus: ':main' })",
                "up-layer": "new",
            }
        },
    )

    class Meta:
        model = models.Company
        template_name = "django_tables2/bootstrap4.html"
        fields = ("id", "name", "address")

So we a pass the attributes directly to the link’s a element. Nothing really fancy is needed.

Furthermore, notice that we use the builtin bootstrap4 template. We don’t change the template at all. The original django-tables2 template does not have unpoly interation! So if we leave it like this the pagination and header links will start a full request/response. To fix that, we could override the template with our own however this is not the ideal solution for me.

Instead, we can use up.compiler:

up.compiler('.pagination .page-item a.page-link', (link) => {
  link.setAttribute('up-target', ".table-container")
})

up.compiler('th.orderable a[href]', (link) => {
  link.setAttribute('up-target', ".table-container")
})

The up.compiler function takes a CSS selector and a callback function. The callback function is called when a snippet matching the selector is added to the DOM. In this case we’re adding the up-target='.table-container' unpoly attributes to both the pagination and the table header order links. The .table-container is the element that contains the table (it is added by django-tables2).

This way, when unpoly sees these links it will add the up-target attribute (and functionality) to these without the need to override any templates.

Advanced concepts

We’ll discuss some more advanced concepts of unpoly now.

More about up.compiler

The up.compiler function is very powerful. We already used it to add functionality to all our nav links (see the navigation aliases before) to avoid forgetting it and to add the up-target to our table links to avoid overriding the django-table2 templates.

Beyond these, the most important functionality of up.compiler is to replace the javascript on load (or jquery $(function() {})) event. Most common javascript libraries will be initialized when the document is ready. Unfortunately, when a page is loaded through unpoly this event will not be trigger, so the javascript elements will not be initialized! Let’s suppose that we’ve got a bunch traditional jquery ui datepicker elements and all these have the .datepicker css class. Normally we’d initialize it like

$(function() {
  $('.datepicker').datepicker()
})

If we are to load a form with these elements through unpoly we won’t get the datepicker functionality. To fix this we can use up.compiler:

up.compiler('.datepicker', (element) => {
  $(element).datepicker()
})

So when unpoly sees a .datepicker element it will call that callback function and initialize it! This will work properly if you follow links through up-follow or open new overlays with up-layer='new'.

Passing context from unpoly to server

Unpoly has an up-context attribute that can be used to pass context to the server. This must be a json object and can then be used to change the response based on that context. If we are using the unpoly python package then the context will be available in the request.up.context dictionary.

Let’s see a particular example from the demo. When we create a new task we’ve got the following link:

<a
    class='btn btn-primary'
    up-layer='new'
    up-context='{"new_task": true}'
    up-accept-location='/core/tasks/detail/$id/'
    up-on-accepted="reloadWithFlash('.tasks', 'Task created!')"
    href='{% url "task-create" %}'>New task</a>
</div>

Compare this with the edit link:

<a up-target='.task' href='{% url "task-update" task.id %}' class='btn btn-sm btn-outline-secondary'>Edit</a>

Notice that the up-context is only included in the new link. Now, let’s see how the task form is implemented:

<form up-submit {% if not up.context.new_task %}up-target='.task'{% endif %} class='task card' method="POST">
    {% csrf_token %}
    <div class="card-body d-flex flex-column">
        <div class="form-group flex-grow-1 mb-0">
            {{ form|crispy }}
        </div>
        <div class="flex-grow-0">
            <input type='submit' class='btn btn-primary mt-2' value='Save'>
        </div>
    </div>
</form>

So, using Django we check to see if there’s new_task in the context and add an up-target='.task' if not. This way, we’ll get an up-target='.task' in the form only if we click the edit task button. Beyond this the form is the same for both the new and edit links.

This is needed because when we open the edit task it will be loaded in the same .task element we clicked the edit link from (remember that unpoly is smart enough to match closer elements). When the form is submitted we want the detail view of the task to also be rendered on the same .task element so we use the up-target='.task'. This isn’t needed in the create new since it will be rendered in a new layer and we want to reload the the tasks with the flash message when the new task is created.

Please notice that if we were to use up-target='.task' for both the new and edit form we’d get an error when the new task form was submitted because it wouldn’t be able to match the target .task element!

Listening to unpoly events

For most things happening in unpoly you’ll find out that there are events that you can listen to and add behavior. There are cases where handling these is useful.

For example, I’ve observed that if you’re using bootstrap dropdowns and click a link while the dropdowns are opened, the dropdowns will remain open when the fragment has been loaded! This is very annoying. One simple way to resolve that is include the navigation inside your up-main element so the dropdowns will be reloaded. However there’s a better way by using unpoly events like in the following snippet:

    up.on('up:link:follow', function(event, link) {
      // Hide visible dropdowns
      const dropdownElementList = document.querySelectorAll('.dropdown-toggle.show')
      const dropdownList = [...dropdownElementList].map(dropdownToggleEl => new bootstrap.Dropdown(dropdownToggleEl))
      dropdownList.forEach(dropdown => dropdown.hide())
    })

Please notice that this code is for bootstrap 5 (not 4 as the remaining code in the demo since it’s from a different project). So what happens is that whenever a link is followed from unpoly we’ll clear the open dropdowns (the code isn’t very important here).

Updating history

One thing to consider when using unpoly is when we actually need to update the browser history and url. By default, unpoly will update the url only if the up-target matches up-main (so if there’s no up-target the url will always be upgraded).

This can be configured through the up-history attribute. By default this has the value 'auto' and we can set to 'true' or 'false' if we want to configure it so that unpoly updates the history or not for a particlar link or form submission.

Let’s see a particular example from the demo. Because the up-target of the filter form on the is set to .form-data:

<form up-autosubmit up-delay='250' class='form-inline' method='GET' action='' up-target='.form-data'>

the url will not be updated when the filter is changed. This is contrary to the usual way these kind of filters work (i.e update the url with the filter parameters). So we can add the up-history attribute:

<form up-autosubmit up-history='true' up-delay='250' class='form-inline' method='GET' action='' up-target='.form-data'>

The same applies for the pagination and header ordering links. They update the .table-container element so we’ll need to add up-history=true also to them. Thus we’ll change the up.compiler for these elements like this:

up.compiler('.pagination .page-item a.page-link', (link) => {
  link.setAttribute('up-follow', link.href)
  link.setAttribute('up-target', ".table-container")
  link.setAttribute('up-history', "true")
})

up.compiler('th.orderable a[href]', (link) => {
  link.setAttribute('up-follow', link.href)
  link.setAttribute('up-target', ".table-container")
  link.setAttribute('up-history', "true")
})

This way, both the ordering links and the selected page will be reflected on the url history.

The final result is that this filter/table page will have the usual functionality of updating the url when the filter is changed or the table sorting/pagination links are used.

Troubleshooting

As I’ve already mentioned, the most common problem you are going to have with unpoly is when you use javascript on your page ready event. Unfortunately there’s a lot of functionality that relies on that event and pages will break when you use unpoly in these cases. That’s why I recommend to use up-follow and up-submit for your links and forms on a case-by-case basis on non greenfield projects so you’ve got more control on what works with unpoly and what is not working. Another thing that is very important to notice here is that I’ve stumbled upon libraries that not only rely on the load event but actually there’s no other way to initialize them! For example, there’ are js libraries that have code like

$(function() {
  let initElement = function(el) {
    ...
  }
  $('.selected-elements').each(function() {
    initElement(this)
  })
})

so the actual function that does the initialization (initElement) isn’t public and you can’t call it from the up.compiler. In these cases you’ll need to somehow make the initElement public so you can call it from the up.compiler or use a different library!

The other major case for headaches in unpoly overlays. Although they are very powerful I recommend to not abuse them and use them only when you feel that are really needed and would improve the UX of the user. For example, I’d recommend using them to add new options on a select list (similar to how the project form works for companies and of course similar to how django admin does it). Also you could use overlays to have a functionality similar to django-inlines (see how the projects are added to the company) however I’d probably prefer to do that using normal django inlines especially when the standalone child edit functionality isn’t needed.

Special care must be taken for the integration between layers and messages. I have tried to provide a solution in the previous sections by proposing flashing the message with javascript on the cases where the message will be “eaten” by a discarded response however I’m afraid that depending on how you’ve architectured your app you’ll may still get problems. The important thing is to understand how messages work (or not) and in which cases you may skip using messages at all since the feedback would be immediate and the users don’t really need messages.

Conclusion

In conclusion, using unpoly with your Django apps can enhance the UX of your users by reducing page reloads and providing a more responsive and intuitive interface with little work from the developer. I recommend everybody to start integrating unpoly in their projects and see how it can improve the UX of your users!