夜明け前の最も暗いとき

技術的なやったことをメモするブログ

Python + Djangoで動画共有サイトを作る Part3

前回の続きです。前回はトップページを作成しましたので、残りを作っていきます。

タグ検索

タグ検索はURLが/tag/(検索文字列)の場合に検索文字列に合致する動画を返します。 動画の表示ページは前回construct_page関数として作ったので、表示する動画一覧を作って渡します。

  • video/views.py
def tag(request, tag_name, page=0):
    # tag_nameからIDを探し、見つかったIDを基にタグが付いた動画をフィルタする
    tag_id = VideoTagName.objects.filter(name=tag_name).get().id
    filtered_list = VideoTagList.objects.select_related('content').filter(tag=tag_id).order_by('-content__upload_date')

    max_page = filtered_list.count() // 10

    content_list = filtered_list[page*10:(page+1)*10]
    contents = [{'id':item.content.id, 'title':item.content.title} for item in content_list]

    return construct_page(request, filtered_list.values('content_id'), contents, page, max_page, 'video:tag', tag_name)

urls.pyのコメントアウトを外した後にrunserverを実行してWebサイトにアクセスします。タグのリンクをクリックすると/tag/TAG1のURLに飛び、検索タグに一致する動画が出てきます。

キーワード検索

キーワード検索では検索された文字列を基にデータベースを部分一致検索で探します。

def search(request, search_word, page=0):
    filtered_list = VideoContent.objects.filter(title__contains=search_word).order_by('-upload_date')
    max_page = filtered_list.count() // 10
    content_list = filtered_list[page*10:(page+1)*10]
    contents = [{'id':item.id, 'title':item.title} for item in content_list]

    return construct_page(request, filtered_list.values('id'), contents, page, max_page, 'video:search', search_word)

基本的な処理はタグ検索と同様です。データベースからの検索結果をconstruct_page関数に渡しページを作成します。

また、検索窓からのPOSTを受け持つ処理を実装します。

def search_post(request):
    if hasattr(request, 'POST') and 'search_text' in request.POST.keys():
        if request.POST['search_text'] != "":
            return HttpResponseRedirect(reverse('video:search', args=(request.POST['search_text'],)))

    return HttpResponseRedirect(reverse('video:index'))

検索ワードから検索ページに飛ばすURLへリダイレクトします。

アップロードページ作成

アップロードされたデータはストレージと呼ばれる保存領域に記録します。今回は/storage/video/というフォルダを作成し保存します。実際はデータストレージはWebサーバと別の筐体という構成にすることで可用性を向上させます。

# mkdir /storage/
# mkdir /storage/video
# ls /storage/ -l
total 0
drwxr-xr-x. 2 root root 6 Aug  4 17:32 video
# chown jinglan:jinglan /storage/video/
$ sudo ls /storage/ -l
total 0
drwxr-xr-x. 2 jinglan jinglan 6 Aug  4 17:32 video

フォルダを作成し権限を書き換えました。次に、設定ファイルにこのフォルダをストレージ領域として使うことを記述します。

  • mysite/settings.py
# Media directory
MEDIA_ROOT = '/storage/'
MEDIA_URL = '/media/'

また、URLからアクセスできるようサイトのurls.pyの最後にに追加します。

  • mysite/urls.py
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

保存場所の準備ができたのでアップロード画面を作成します。

  • video/templates/video/upload.html
{% extends "video/base.html" %}

{% block main %}
<br>
<br>

<form action="" method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_p }}
  <br>
  <input type="submit" value="アップロード">
</form>

<br>
{% endblock %}

アップロードページのフォームはform.as_pでviews.pyの定義から自動的に作成されます。views.pyでは以下のように汎用ビューを用いたクラスを追加します。

  • video/views.py
from django.urls import reverse, reverse_lazy
from django.views import generic
from django import forms
from django.core.files.storage import default_storage, FileSystemStorage
from django.utils import timezone
from django.conf import settings

import ffmpeg

DATA_DIR = settings.MEDIA_ROOT + 'video/'

class VideoUploadForm(forms.Form):
    file = forms.FileField()

class UploadView(generic.FormView):
    form_class = VideoUploadForm
    template_name = 'video/upload.html'

    def form_valid(self, form):
        upload_filename = form.cleaned_data["file"].name

        content = VideoContent(title=upload_filename, description="", upload_date=timezone.now(), original_name=upload_filename, filename="")
        content.save()

        try:
            storage = FileSystemStorage()
            storage.location = DATA_DIR + str(content.id)
            filename = storage.save(upload_filename, form.cleaned_data["file"])
            make_video_thumb(DATA_DIR + str(content.id) + "/" + filename, content.thumb_frame, DATA_DIR + str(content.id) + "/thumb.jpg")

        except:
            delete_video(content.id, filename)
            content.delete()
            raise

        else:
            content.filename = filename
            content.save()

            return HttpResponseRedirect(reverse('video:edit', args=(content.id,)))

VideoUploadFormクラスはアップロードページのフォームを規定します。今回はファイルのみです。 正常にユーザからファイルがアップロードされた後にform_validが呼ばれます。 content.save()によりデータベースに新しくレコードを追加され、ユニークなIDが割り当てられます。このIDを基に動画を保存するフォルダを作成し保存します。 正常に保存されたらmake_video_thumb関数によりサムネイルを作成します。

  • video/views.py
def make_video_thumb(src_filename, capture_frame, dst_filename=None):
    probe = ffmpeg.probe(src_filename)
    video_info = next(x for x in probe['streams'] if x['codec_type'] == 'video')
    nframes = video_info['nb_frames']
    avg_frame_rate = (lambda x: int(x[0])/int(x[1])) (video_info['avg_frame_rate'].split('/'))
    start_position = int(capture_frame)/avg_frame_rate

    if dst_filename == None:
        out_target = 'pipe:'
    else:
        out_target = dst_filename

    im = (
        ffmpeg.input(src_filename, ss=start_position)
        .filter('scale', 200, -1)
        .output(out_target, vframes=1, format='image2', vcodec='mjpeg', loglevel='warning')
        .overwrite_output()
        .run(capture_stdout=True)
    )

    return im

ffmpegを使用し指定されたフレームの画像を生成しています。

もし、アップロード処理中にエラーが発生した場合はexceptが呼ばれ、delete_video関数により動画は削除されます。

  • video/views.py
def delete_video(content_id, video_filename):
    print('remove files at ' + str(content_id) + '/')
    storage = FileSystemStorage()
    storage.location = DATA_DIR
    storage.delete(str(content_id) + '/' + video_filename)
    storage.delete(str(content_id) + '/' + 'thumb.jpg')
    storage.delete(str(content_id) + '/')

正常に動画をアップロードできた場合はHttpResponseRedirectにより編集ページにリダイレクトされます。今回はまだ作成していないのでダミーページを返すようにします。

  • video/views.py
def edit(request, content_id):
    return HttpResponse("dummy")

それでは、urls.pyを編集してからアップロードしてみます。アップロード後はダミーページに飛ばされますがトップページに戻ると動画リストが増えています。

f:id:jianlan:20190805202650j:plain

編集ページ作成

アップロード後に動画の情報を入力する編集ページを作成します。このページでは、動画情報の編集のほかに、サムネイルの変更、タグの追加削除、動画削除機能を提供します。

  • video/templates/video/edit.html
{% extends "video/base.html" %}

{% block header %}
{% load static %}
  <script src="{% static 'video/jquery-3.4.1.min.js' %}"></script>
  <script src="{% static 'video/edit.js' %}"></script>
{% endblock %}

{% block main %}
<h2>編集中: {{ content.id }} - {{ content.title }}</h2>

<h3>VIDEO</h3>
<form action="{% url 'video:update' content.id %}" method="post">
  {% csrf_token %}
  <table border="0">
    <tr><td>thumb:</td>
    <td>
    <img id="thumb" src="{% url 'video:thumb' content.id content.thumb_frame %}">
    <input name="frame" id="thumb_frame" type="range" style="width:500px" min="0" max="{{ video_info.max_frame }}" step="1" value="{{ content.thumb_frame }}">
    <span id="current_thumb_frame">{{ content.thumb_frame }} </span><br>
    </td>
    </tr><tr><td>title:</td>
    <td>
    <input name="title" type="text" size="100" value="{{ content.title }}"><br>
    </td>
    </tr><tr><td>description:</td>
    <td>
    <textarea name="desc" rows="5" cols="100">{{ content.description }}</textarea>
    </td>
    </tr>
  </table>
  <br>
  <input type="submit" value="決定">
</form>
<br>

<hr>

<h3>TAG</h3>
<form action="{% url 'video:update_add_tag' content.id %}" method="post">
  <table border="0">
  {% for tag in tags %}
    <tr><td>{{ tag.tag.name }}</td><td><input type="submit" value="削除" formaction="{% url 'video:update_remove_tag' content.id tag.tag.name %}"></td></tr>
  {% endfor %}
  </table>
  <input type="text" name="tag" value="">
  <input type="submit" value="追加">
  {% csrf_token %}
</form>
<br>
<hr>

<a href="{% url 'video:delete' content.id %}">削除する</a>
<br>
{% endblock %}

サムネイルの取得にはⒿjQueryを使い非同期的に行います。jquery-3.4.1.min.jsを公式サイトからダウンロードしstaitcに配置します。 また、サムネイル取得を実行するedit.jsも作成します。

  • video/static/video/edit.js
$(function() {
  $('#thumb_frame').on('input', function() {
    $('#current_thumb_frame').text($('#thumb_frame').val());
  });

  $('#thumb_frame').change(function() {
    $('#current_thumb_frame').text($('#thumb_frame').val());

    var image_dir = $('#thumb').attr('src').split('/');
    image_dir.pop();
    $('#thumb').attr('src', image_dir.join('/') + '/' + $('#thumb_frame').val());
  });
});

range入力が変更されるたびにサーバにframe情報を送り、サムネイル画像を取得します。

それではviews.pyに編集ページを実装します。

  • video/views.py
def edit(request, content_id):
    content = get_object_or_404(VideoContent, pk=content_id)

    probe = ffmpeg.probe(DATA_DIR + str(content.id) + "/" + content.filename)
    video_info = next(x for x in probe['streams'] if x['codec_type'] == 'video')
    info = {'max_frame': video_info['nb_frames']}

    tags = VideoTagList.objects.filter(content_id=content_id).select_related('content')

    return render(request, 'video/edit.html', {'content':content, 'video_info':info, 'tags':tags})

urls.pyを編集し、編集ページにアクセスします。ページは表示されますがサムネイルやボタンは動作を実装していないため動きません。

では、まず最初にサムネイルから実装します。サムネイルは/video/edit/(動画ID)/thumb/(フレーム)というURLで読み込まれます。

  • video/views.py
def thumb(request, content_id, frame):
    content = get_object_or_404(VideoContent, pk=content_id)
    im = make_video_thumb(DATA_DIR + str(content.id) + "/" + content.filename, frame)
    return HttpResponse(im, content_type="image/jpeg")

URLを基にサムネイルを作成し画像であるimage/jpegとして返します。

f:id:jianlan:20190805221028j:plain

これでスライダーを変更したときに動的にサムネイル画像をプレビューできるようになりました。 続いて、変更した値を保存する処理を実装します。

  • video/views.py
def update(request, content_id):
    content = get_object_or_404(VideoContent, pk=content_id)
    content.title = request.POST['title']
    content.thumb_frame = request.POST['frame']
    content.description = request.POST['desc']
    content.save()

    make_video_thumb(DATA_DIR + str(content.id) + "/" + content.filename, content.thumb_frame, DATA_DIR + str(content.id) + "/thumb.jpg")

    return HttpResponseRedirect(reverse('video:index'))

これで編集ページで書き換えた値やサムネイルが一覧に反映されるようになりました。

f:id:jianlan:20190805222058j:plain

今度はタグの編集機能を実装します。

  • video/views.py
def update_add_tag(request, content_id):
    if request.POST["tag"] != "":
        tag = VideoTagName.objects.filter(name=request.POST["tag"])
        if len(tag) == 0:
            tag = VideoTagName(name=request.POST["tag"])
            tag.save()
        else:
            tag = tag[0]

        tag_list = VideoTagList.objects.filter(tag_id=tag.id, content_id=content_id)
        if len(tag_list) == 0:
            tag_list = VideoTagList(tag_id=tag.id, content_id=content_id)
            tag_list.save()

    return HttpResponseRedirect(reverse('video:edit', kwargs={'content_id': content_id}))

タグの追加では、まずタグが存在するか確認し存在しない場合はVideoTagNameに追加します。その後、VideoTagListに動画IDとタグIDを紐づけて登録します。

  • video/views.py
def update_remove_tag(request, content_id, tag_name):
    tag = VideoTagName.objects.filter(name=tag_name)
    if len(tag) != 0:
        tag_list = VideoTagList.objects.filter(tag_id=tag[0].id, content_id=content_id)
        tag_list.delete()

    return HttpResponseRedirect(reverse('video:edit', kwargs={'content_id': content_id}))

タグの削除では、動画IDとタグIDが一致するレコードをVideoTagListから削除します。

それでは、実際に動かしてみます。

f:id:jianlan:20190805223522j:plain

編集画面からTAG1と新しいTAG3を付けることができました。

最後に、動画の削除機能を実装します。

  • video/templates/video/delete.html
{% extends "video/base.html" %}

{% block main %}
<br>
<br>

<form method="post">
  {% csrf_token %}
  <img src="/media/video/{{ object.id }}/thumb.jpg"><br>
  {{ object.title }}を削除します。<br><br>
  <input type="submit" value="削除">
</form>

{% endblock %}

削除処理自体は以前に作成したdelete_video関数を利用します。

  • video/views.py
class DeleteView(generic.DeleteView):
    model = VideoContent
    template_name = 'video/delete.html'
    success_url = reverse_lazy('video:index')

    def delete(self, request, *args, **kwargs):
        content_id = self.kwargs['pk']
        filename = VideoContent.objects.filter(id=content_id)[0].filename
        delete_video(content_id, filename)

        return super().delete(request, *args, **kwargs)

f:id:jianlan:20190805224226j:plain

削除するボタンを押すとストレージから動画自体が削除され、データベースから登録が消去されます。

Djangoで動画共有サイトを作る Part4へ続きます。