YouTube support.
authorRadek Czajka <rczajka@rczajka.pl>
Mon, 6 Apr 2020 11:52:53 +0000 (13:52 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Mon, 6 Apr 2020 11:52:53 +0000 (13:52 +0200)
34 files changed:
src/apiclient/__init__.py
src/apiclient/migrations/0002_youtubetoken.py [new file with mode: 0644]
src/apiclient/models.py
src/apiclient/settings.py
src/apiclient/urls.py
src/apiclient/views.py
src/archive/locale/pl/LC_MESSAGES/django.mo
src/archive/locale/pl/LC_MESSAGES/django.po
src/archive/migrations/0003_auto_20200401_1154.py [new file with mode: 0644]
src/archive/migrations/0004_audiobook_youtube_file.py [new file with mode: 0644]
src/archive/migrations/0005_audiobook_youtube_id.py [new file with mode: 0644]
src/archive/models.py
src/archive/tasks.py
src/archive/templates/archive/file_managed.html
src/archive/urls.py
src/archive/utils.py
src/archive/views.py
src/audiobooks/settings.py
src/audiobooks/urls.py
src/youtube/__init__.py [new file with mode: 0644]
src/youtube/admin.py [new file with mode: 0644]
src/youtube/apps.py [new file with mode: 0644]
src/youtube/locale/pl/LC_MESSAGES/django.mo [new file with mode: 0644]
src/youtube/locale/pl/LC_MESSAGES/django.po [new file with mode: 0644]
src/youtube/migrations/0001_initial.py [new file with mode: 0644]
src/youtube/migrations/0002_auto_20200330_1800.py [new file with mode: 0644]
src/youtube/migrations/0003_auto_20200331_1404.py [new file with mode: 0644]
src/youtube/migrations/__init__.py [new file with mode: 0644]
src/youtube/models.py [new file with mode: 0644]
src/youtube/tasks.py [new file with mode: 0644]
src/youtube/tests.py [new file with mode: 0644]
src/youtube/urls.py [new file with mode: 0644]
src/youtube/utils.py [new file with mode: 0644]
src/youtube/views.py [new file with mode: 0644]

index e489807..f4da3a5 100644 (file)
@@ -32,3 +32,8 @@ def api_call(user, path, method='POST', data=None, files=None):
     else:
         raise ApiError("WL API call error %s, path: %s" % (r.status_code, path))
 
+
+def youtube_call(method, url, params=None, data=None, media_data=None):
+    from .models import YouTubeToken
+    yt = YouTubeToken.objects.first()
+    return yt.call(method, url, params=params, data=data, media_data=media_data)
diff --git a/src/apiclient/migrations/0002_youtubetoken.py b/src/apiclient/migrations/0002_youtubetoken.py
new file mode 100644 (file)
index 0000000..c6772d4
--- /dev/null
@@ -0,0 +1,20 @@
+# Generated by Django 3.0.4 on 2020-04-06 10:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('apiclient', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='YouTubeToken',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('token', models.TextField()),
+            ],
+        ),
+    ]
index 83979e8..ae8c74a 100644 (file)
@@ -1,5 +1,8 @@
+import json
 from django.db import models
 from django.contrib.auth.models import User
+from requests_oauthlib import OAuth2Session
+from .settings import YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, YOUTUBE_TOKEN_URL
 
 
 class OAuthConnection(models.Model):
@@ -18,3 +21,40 @@ class OAuthConnection(models.Model):
             return o
 
 
+class YouTubeToken(models.Model):
+    token = models.TextField()
+
+    def token_updater(self, token):
+        self.token = json.dumps(token)
+        self.save()
+
+    def get_session(self):
+        return OAuth2Session(
+            client_id=YOUTUBE_CLIENT_ID,
+            auto_refresh_url=YOUTUBE_TOKEN_URL,
+            token=json.loads(self.token),
+            auto_refresh_kwargs={'client_id':YOUTUBE_CLIENT_ID,'client_secret':YOUTUBE_CLIENT_SECRET},
+            token_updater=self.token_updater
+        )
+
+    def call(self, method, url, params=None, data=None, media_data=None):
+        params = params or {}
+        params['uploadType'] = 'resumable'
+
+        session = self.get_session()
+        response = session.request(
+            method=method,
+            url=url,
+            json=data,
+            params=params,
+            headers={
+                'X-Upload-Content-Length': str(len(media_data)),
+                'x-upload-content-type': 'application/octet-stream',
+            }
+        )
+        location = response.headers['Location']
+        return session.put(
+            url=location,
+            data=media_data,
+            headers={"Content-Type": "application/octet-stream"},
+        )
index 49037fe..f347229 100755 (executable)
@@ -12,3 +12,13 @@ WL_ACCESS_TOKEN_URL = getattr(settings, 'APICLIENT_WL_ACCESS_TOKEN_URL',
         WL_API_URL + 'oauth/access_token/')
 WL_AUTHORIZE_URL = getattr(settings, 'APICLIENT_WL_AUTHORIZE_URL',
         WL_API_URL + 'oauth/authorize/')
+
+
+YOUTUBE_SCOPE = [
+    'https://www.googleapis.com/auth/youtube',
+]
+YOUTUBE_AUTH_URL = 'https://accounts.google.com/o/oauth2/auth'
+YOUTUBE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
+YOUTUBE_CLIENT_ID = getattr(settings, 'YOUTUBE_CLIENT_ID', None)
+YOUTUBE_CLIENT_SECRET = getattr(settings, 'YOUTUBE_CLIENT_SECRET', None)
+
index 09d4712..ad3f166 100755 (executable)
@@ -5,4 +5,6 @@ from . import views
 urlpatterns = [
     url(r'^oauth/$', views.oauth, name='apiclient_oauth'),
     url(r'^oauth_callback/$', views.oauth_callback, name='apiclient_oauth_callback'),
+    url(r'^oauth2/$', views.oauth2, name='apiclient_oauth2'),
+    url(r'^oauth2_redirect/$', views.oauth2_redirect, name='apiclient_oauth2_redirect'),
 ]
index f41f8f3..25fd5c6 100644 (file)
@@ -1,12 +1,13 @@
+import json
 from django.contrib.auth.decorators import login_required
 from django.urls import reverse
 from django.http import HttpResponseRedirect, HttpResponse
 import requests
-from requests_oauthlib import OAuth1Session
+from requests_oauthlib import OAuth1Session, OAuth2Session
 
-from apiclient.models import OAuthConnection
+from apiclient.models import OAuthConnection, YouTubeToken
 from apiclient.settings import WL_CONSUMER_KEY, WL_CONSUMER_SECRET
-from apiclient.settings import WL_REQUEST_TOKEN_URL, WL_ACCESS_TOKEN_URL, WL_AUTHORIZE_URL
+from apiclient.settings import WL_REQUEST_TOKEN_URL, WL_ACCESS_TOKEN_URL, WL_AUTHORIZE_URL, YOUTUBE_AUTH_URL, YOUTUBE_TOKEN_URL, YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, YOUTUBE_SCOPE
 
 
 @login_required
@@ -42,3 +43,25 @@ def oauth_callback(request):
     conn.save()
 
     return HttpResponseRedirect('/')
+
+
+@login_required
+def oauth2(request):
+    redirect_uri = request.build_absolute_uri(reverse("apiclient_oauth2_redirect"))
+    oauth = OAuth2Session(YOUTUBE_CLIENT_ID, redirect_uri=redirect_uri, scope=YOUTUBE_SCOPE)
+    authorization_url, state = oauth.authorization_url(YOUTUBE_AUTH_URL)
+    return HttpResponseRedirect(authorization_url)
+
+
+@login_required
+def oauth2_redirect(request):
+    redirect_uri = request.build_absolute_uri(reverse("apiclient_oauth2_redirect"))
+    oauth = OAuth2Session(YOUTUBE_CLIENT_ID, redirect_uri=redirect_uri, scope=YOUTUBE_SCOPE)
+    token = oauth.fetch_token(
+            YOUTUBE_TOKEN_URL,
+            authorization_response=request.get_full_path(),
+            client_secret=YOUTUBE_CLIENT_SECRET,
+        )
+    YouTubeToken.objects.all().delete()
+    YouTubeToken.objects.create(token=json.dumps(token))
+    return HttpResponseRedirect('/')
index e1135bf..3156f35 100644 (file)
Binary files a/src/archive/locale/pl/LC_MESSAGES/django.mo and b/src/archive/locale/pl/LC_MESSAGES/django.mo differ
index 317ab76..7820153 100644 (file)
@@ -5,18 +5,19 @@
 #
 msgid ""
 msgstr ""
-"Project-Id-Version: PACKAGE VERSION\n"
+"Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2018-03-02 09:36+0100\n"
-"PO-Revision-Date: 2012-07-11 15:52+0100\n"
+"POT-Creation-Date: 2020-04-01 14:18+0200\n"
+"PO-Revision-Date: 2020-04-01 14:19+0200\n"
 "Last-Translator: Radek Czajka <radoslaw.czajka@nowoczesnapolska.org.pl>\n"
-"Language-Team: LANGUAGE <LL@li.org>\n"
-"Language: \n"
+"Language: pl\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
-"|| n%100>=20) ? 1 : 2)\n"
+"|| n%100>=20) ? 1 : 2);\n"
+"Language-Team: \n"
+"X-Generator: Poedit 2.2.4\n"
 
 #: constants.py:10
 msgid "Waiting"
@@ -34,111 +35,107 @@ msgstr "Opisywanie"
 msgid "Sending"
 msgstr "Wysyłanie"
 
-#: models.py:23 models.py:49
+#: models.py:20 models.py:46
 msgid "project"
 msgstr "projekt"
 
-#: models.py:24
+#: models.py:21
 msgid "projects"
 msgstr "projekty"
 
-#: models.py:37
+#: models.py:34
 msgid "source file"
 msgstr "plik źródłowy"
 
-#: models.py:40
+#: models.py:37
 msgid "title"
 msgstr "tytuł"
 
-#: models.py:41
+#: models.py:38
 msgid "part name"
 msgstr "nazwa części"
 
-#: models.py:41
+#: models.py:38
 msgid "eg. chapter in a novel"
 msgstr "np. rozdział w powieści"
 
-#: models.py:43
+#: models.py:40
 msgid "index"
 msgstr "numer"
 
-#: models.py:44
+#: models.py:41
 msgid "parts count"
 msgstr "liczba części"
 
-#: models.py:45
+#: models.py:42
 msgid "artist"
 msgstr "lektor"
 
-#: models.py:46
+#: models.py:43
 msgid "conductor"
 msgstr "reżyser"
 
-#: models.py:47
+#: models.py:44
 msgid "encoded by"
 msgstr "przyg. techn."
 
-#: models.py:48
+#: models.py:45
 msgid "date"
 msgstr "data"
 
-#: models.py:50
+#: models.py:47
 msgid "book url"
 msgstr "URL książki"
 
-#: models.py:51
+#: models.py:48
 msgid "translator"
 msgstr "tłumacz"
 
-#: models.py:71
+#: models.py:74
 msgid "audiobook"
 msgstr "audiobook"
 
-#: models.py:72
+#: models.py:75
 msgid "audiobooks"
 msgstr "audiobooki"
 
-#: templates/base.html:7
-msgid "Audiobook repository"
-msgstr "Repozytorium audiobooków"
-
-#: templates/archive/base.html:5
+#: templates/archive/base.html:6
 msgid "New"
 msgstr "Nowe"
 
-#: templates/archive/base.html:6
+#: templates/archive/base.html:7
 msgid "Unpublished"
 msgstr "Nie opublikowane"
 
-#: templates/archive/base.html:7 templates/archive/file_managed.html:11
+#: templates/archive/base.html:8 templates/archive/file_managed.html:12
 msgid "Publishing"
 msgstr "Publikacja"
 
-#: templates/archive/base.html:8
+#: templates/archive/base.html:9
 msgid "Published"
 msgstr "Opublikowane"
 
-#: templates/archive/base.html:9
+#: templates/archive/base.html:10
 msgid "Archive"
 msgstr "Archiwum"
 
-#: templates/archive/base.html:11
+#: templates/archive/base.html:15
 msgid "Projects"
 msgstr "Projekty"
 
-#: templates/archive/base.html:14
+#: templates/archive/base.html:18
 msgid "Logout"
 msgstr "Wyloguj"
 
-#: templates/archive/base.html:16 templates/registration/login.html:9
+#: templates/archive/base.html:20 templates/registration/login.html:9
 msgid "Login"
 msgstr "Zaloguj"
 
-#: templates/archive/base.html:19
+#: templates/archive/base.html:23
 msgid "Administration"
 msgstr "Administracja"
 
-#: templates/archive/file_managed.html:15
+#: templates/archive/file_managed.html:16
 msgid "Publishing pending"
 msgstr "Czeka na publikację"
 
@@ -146,73 +143,92 @@ msgstr "Czeka na publikację"
 msgid "Cancel publishing"
 msgstr "Anuluj publikację"
 
-#: templates/archive/file_managed.html:51
+#: templates/archive/file_managed.html:58
 msgid "Publish"
 msgstr "Opublikuj"
 
-#: templates/archive/file_managed.html:57
+#: templates/archive/file_managed.html:67
+#: templates/archive/file_managed.html:79
 msgid "Convert without publishing"
 msgstr "Konwertuj bez publikacji"
 
-#: templates/archive/file_managed.html:67
+#: templates/archive/file_managed.html:74
+msgid "Publish on YouTube"
+msgstr "Opublikuj na YouTube"
+
+#: templates/archive/file_managed.html:95
 msgid "MP3 file"
 msgstr "Plik MP3"
 
-#: templates/archive/file_managed.html:68
+#: templates/archive/file_managed.html:99
 msgid "Download MP3 file."
 msgstr "Pobierz plik MP3."
 
-#: templates/archive/file_managed.html:70
-#: templates/archive/file_managed.html:86
+#: templates/archive/file_managed.html:101
+#: templates/archive/file_managed.html:124
+#: templates/archive/file_managed.html:147
 msgid "Published:"
 msgstr "Opublikowano:"
 
-#: templates/archive/file_managed.html:75
-#: templates/archive/file_managed.html:91
+#: templates/archive/file_managed.html:106
+#: templates/archive/file_managed.html:129
+#: templates/archive/file_managed.html:152
 msgid "Not published yet."
 msgstr "Nie opublikowane."
 
-#: templates/archive/file_managed.html:78
+#: templates/archive/file_managed.html:109
 msgid "MP3 file hasn't been generated yet."
 msgstr "Plik MP3 nie został jeszcze wygenerowany."
 
-#: templates/archive/file_managed.html:83
+#: templates/archive/file_managed.html:118
 msgid "Ogg Vorbis file"
 msgstr "Plik Ogg Vorbis"
 
-#: templates/archive/file_managed.html:84
+#: templates/archive/file_managed.html:122
 msgid "Download Ogg Vorbis file."
 msgstr "Pobierz plik Ogg Vorbis."
 
-#: templates/archive/file_managed.html:94
+#: templates/archive/file_managed.html:132
 msgid "Ogg Vorbis file hasn't been generated yet."
 msgstr "Plik Ogg Vorbis nie został jeszcze wygenerowany."
 
-#: templates/archive/file_managed.html:105
-msgid "Update tags"
-msgstr "Uaktualnij tagi"
+#: templates/archive/file_managed.html:141
+msgid "YouTube"
+msgstr "YouTube"
 
-#: templates/archive/file_managed.html:117 templates/archive/file_new.html:19
-msgid "Commit"
-msgstr "Zatwierdź"
+#: templates/archive/file_managed.html:145
+msgid "Download YouTube file."
+msgstr "Pobierz plik dla YouTube."
 
-#: templates/archive/file_managed.html:128
+#: templates/archive/file_managed.html:155
+msgid "YouTube file hasn't been generated yet."
+msgstr "Plik dla YouTube nie został jeszcze wygenerowany."
+
+#: templates/archive/file_managed.html:174
 msgid "Are you sure you want to move this audiobook to archive?"
 msgstr "Czy na pewno chcesz przenieść ten plik to archiwum?"
 
-#: templates/archive/file_managed.html:130
+#: templates/archive/file_managed.html:176
 msgid "Remove to archive"
 msgstr "Usuń do archiwum"
 
-#: templates/archive/file_new.html:8
+#: templates/archive/file_managed.html:186
+msgid "Update tags"
+msgstr "Uaktualnij tagi"
+
+#: templates/archive/file_managed.html:193 templates/archive/file_new.html:25
+msgid "Commit"
+msgstr "Zatwierdź"
+
+#: templates/archive/file_new.html:13
 msgid "Move to archive"
 msgstr "Przenieś do archiwum"
 
-#: templates/archive/file_unmanaged.html:6
+#: templates/archive/file_unmanaged.html:7
 msgid "File with same name already exists!"
 msgstr "Plik o tej nazwie już istnieje!"
 
-#: templates/archive/file_unmanaged.html:28
+#: templates/archive/file_unmanaged.html:23
 msgid "Move to new files"
 msgstr "Przenieś do nowych plików"
 
@@ -240,6 +256,10 @@ msgstr "Audiobooki archiwalne"
 msgid "Unpublished audiobooks"
 msgstr "Nie opublikowane audiobooki"
 
+#: templates/base.html:12
+msgid "Audiobook repository"
+msgstr "Repozytorium audiobooków"
+
 #~ msgid "arranger"
 #~ msgstr "aranżer"
 
diff --git a/src/archive/migrations/0003_auto_20200401_1154.py b/src/archive/migrations/0003_auto_20200401_1154.py
new file mode 100644 (file)
index 0000000..6556f0d
--- /dev/null
@@ -0,0 +1,38 @@
+# Generated by Django 3.0.4 on 2020-04-01 11:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('archive', '0002_auto_20200330_1446'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='audiobook',
+            name='youtube_published',
+            field=models.DateTimeField(editable=False, null=True),
+        ),
+        migrations.AddField(
+            model_name='audiobook',
+            name='youtube_published_tags',
+            field=models.TextField(editable=False, null=True),
+        ),
+        migrations.AddField(
+            model_name='audiobook',
+            name='youtube_status',
+            field=models.SmallIntegerField(choices=[(1, 'Waiting'), (2, 'Encoding'), (3, 'Tagging'), (4, 'Sending')], editable=False, null=True),
+        ),
+        migrations.AddField(
+            model_name='audiobook',
+            name='youtube_tags',
+            field=models.TextField(editable=False, null=True),
+        ),
+        migrations.AddField(
+            model_name='audiobook',
+            name='youtube_task',
+            field=models.CharField(editable=False, max_length=64, null=True),
+        ),
+    ]
diff --git a/src/archive/migrations/0004_audiobook_youtube_file.py b/src/archive/migrations/0004_audiobook_youtube_file.py
new file mode 100644 (file)
index 0000000..990d968
--- /dev/null
@@ -0,0 +1,19 @@
+# Generated by Django 3.0.4 on 2020-04-01 13:17
+
+import archive.utils
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('archive', '0003_auto_20200401_1154'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='audiobook',
+            name='youtube_file',
+            field=models.FileField(editable=False, null=True, storage=archive.utils.OverwriteStorage(), upload_to='archive/final'),
+        ),
+    ]
diff --git a/src/archive/migrations/0005_audiobook_youtube_id.py b/src/archive/migrations/0005_audiobook_youtube_id.py
new file mode 100644 (file)
index 0000000..683a75c
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.4 on 2020-04-06 11:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('archive', '0004_audiobook_youtube_file'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='audiobook',
+            name='youtube_id',
+            field=models.CharField(blank=True, default='', max_length=255),
+        ),
+    ]
index f0b751c..746f46b 100644 (file)
@@ -63,6 +63,14 @@ class Audiobook(models.Model):
     ogg_published_tags = models.TextField(null=True, editable=False)
     ogg_published = models.DateTimeField(null=True, editable=False)
 
+    youtube_status = models.SmallIntegerField(null=True, editable=False, choices=status.choices)
+    youtube_task = models.CharField(max_length=64, null=True, editable=False)
+    youtube_tags = models.TextField(null=True, editable=False)
+    youtube_file = models.FileField(null=True, upload_to='archive/final', storage=OverwriteStorage(), editable=False)
+    youtube_published_tags = models.TextField(null=True, editable=False)
+    youtube_published = models.DateTimeField(null=True, editable=False)
+    youtube_id = models.CharField(max_length=255, blank=True, default='')
+
     class Meta:
         verbose_name = _("audiobook")
         verbose_name_plural = _("audiobooks")
index 76f640b..b5675ce 100644 (file)
@@ -31,7 +31,7 @@ class AudioFormatTask(Task):
     @classmethod
     def set_status(cls, aid, status):
         Audiobook.objects.filter(pk=aid).update(
-            **{'%s_status' % cls.ext: status})
+            **{'%s_status' % cls.prefix: status})
 
     @staticmethod
     def encode(in_path, out_path):
@@ -39,7 +39,7 @@ class AudioFormatTask(Task):
 
     @classmethod
     def set_tags(cls, audiobook, file_name):
-        tags = getattr(audiobook, "get_%s_tags" % cls.ext)()['tags']
+        tags = getattr(audiobook, "get_%s_tags" % cls.prefix)()['tags']
         if not tags.get('flac_sha1'):
             tags['flac_sha1'] = audiobook.get_source_sha1()
         audio = File(file_name)
@@ -49,7 +49,7 @@ class AudioFormatTask(Task):
 
     @classmethod
     def save(cls, audiobook, file_name):
-        field = "%s_file" % cls.ext
+        field = "%s_file" % cls.prefix
         getattr(audiobook, field).save(
             "%d.%s" % (audiobook.pk, cls.ext),
             ExistingFile(file_name),
@@ -62,16 +62,16 @@ class AudioFormatTask(Task):
     @classmethod
     def published(cls, aid):
         kwargs = {
-            "%s_published_tags" % cls.ext: F("%s_tags" % cls.ext),
-            "%s_tags" % cls.ext: None,
-            "%s_published" % cls.ext: datetime.now(),
-            '%s_status' % cls.ext: None,
+            "%s_published_tags" % cls.prefix: F("%s_tags" % cls.prefix),
+            "%s_tags" % cls.prefix: None,
+            "%s_published" % cls.prefix: datetime.now(),
+            '%s_status' % cls.prefix: None,
         }
         Audiobook.objects.filter(pk=aid).update(**kwargs)
 
     @classmethod
     def put(cls, user, audiobook, path):
-        tags = getattr(audiobook, "get_%s_tags" % cls.ext)()
+        tags = getattr(audiobook, "get_%s_tags" % cls.prefix)()
         data = {
             'book': tags['url'],
             'type': cls.ext,
@@ -122,7 +122,7 @@ class AudioFormatTask(Task):
 
 
 class Mp3Task(AudioFormatTask):
-    ext = 'mp3'
+    prefix = ext = 'mp3'
 
     # these shouldn't be staticmethods
     def id3_text(tag, text):
@@ -186,7 +186,7 @@ class Mp3Task(AudioFormatTask):
 
 
 class OggTask(AudioFormatTask):
-    ext = 'ogg'
+    prefix = ext = 'ogg'
 
     @staticmethod
     def encode(in_path, out_path):
index 25a6b81..f7992e4 100644 (file)
@@ -12,7 +12,7 @@
     <h2>{% trans "Publishing" %}</h2>
   </div>
   <div class="card-body">
-    {% if audiobook.mp3_status or audiobook.ogg_status %}
+    {% if audiobook.mp3_status or audiobook.ogg_status or audiobook.youtube_status %}
       <h2>{% trans "Publishing pending" %}</h2>
       <form method="post" action="{% url 'cancel_publishing' audiobook.id %}">
         {% csrf_token %}
         <p>Status: <b>{{ audiobook.get_ogg_status_display }}</b></p>
       {% endif %}
 
+      {% if audiobook.youtube_status %}
+        <hr/>
+        <h2>YouTube</h2>
+
+        <p>Status: <b>{{ audiobook.get_youtube_status_display }}</b></p>
+      {% endif %}
+
     {% else %}
 
       <table class='table'>
         {% tags_table audiobook.new_publish_tags 0 %}
         <tr><th></th><td>
 
-          {% if user_can_publish %}
-            <form method="post" action="{% url 'publish' audiobook.id %}">
-              {% csrf_token %}
-              <input class="btn btn-primary" type="submit" value="{% trans "Publish" %}" />
-            </form>
-          {% else %}
-            <a class="btn btn-primary" href="{% url 'apiclient_oauth' %}">Podłącz się</a>
-          {% endif %}
-
-          {% if not audiobook.mp3_published or not audiobook.ogg_published %}
-            <form class="mt-3" method="post" action="{% url 'convert' audiobook.id %}">
-              {% csrf_token %}
-              <input class="btn btn-secondary" type="submit" value="{% trans "Convert without publishing" %}" />
-            </form>
-            {% endif %}
-              </div>
-              </div>
-
+          <div class="row">
+            <div class="col-md-6">
+              {% if user_can_publish %}
+                <form method="post" action="{% url 'publish' audiobook.id %}">
+                  {% csrf_token %}
+                  <input class="btn btn-primary" type="submit" value="{% trans "Publish" %}" />
+                </form>
+              {% else %}
+                <a class="btn btn-primary" href="{% url 'apiclient_oauth' %}">Podłącz się</a>
+              {% endif %}
+
+              {% if not audiobook.mp3_published or not audiobook.ogg_published %}
+                <form class="mt-3" method="post" action="{% url 'convert' audiobook.id %}">
+                  {% csrf_token %}
+                  <input class="btn btn-secondary" type="submit" value="{% trans "Convert without publishing" %}" />
+                </form>
+              {% endif %}
+            </div>
+            <div class="col-md-6">
+              <form method="post" action="{% url 'youtube_publish' audiobook.id %}">
+                {% csrf_token %}
+                <input class="btn btn-primary" type="submit" value="{% trans "Publish on YouTube" %}" />
+              </form>
+
+              <form class="mt-3" method="post" action="{% url 'youtube_convert' audiobook.id %}">
+                {% csrf_token %}
+                <input class="btn btn-secondary" type="submit" value="{% trans "Convert without publishing" %}" />
+              </form>
+
+            </div>
+          </div>
         </td></tr>
       </table>
     {% endif %}
@@ -69,7 +89,7 @@
 
 
 <div class="row">
-  <div class="col-xl-6">
+  <div class="col-xl-4">
     <div class="card mt-4">
       <div class="card-header">
        <h2>{% trans "MP3 file" %}</h2>
     </div>
   </div>
 
-  <div class="col-xl-6">
+  <div class="col-xl-4">
     <div class="card mt-4">
       <div class="card-header">
         <h2>{% trans "Ogg Vorbis file" %}</h2>
     </div>
   </div>
 
+  <div class="col-xl-4">
+    <div class="card mt-4">
+      <div class="card-header">
+        <h2>{% trans "YouTube" %}</h2>
+      </div>
+      <div class="card-body">
+        {% if audiobook.youtube_file %}
+          <p><a href="{% url 'download' audiobook.id 'mkv' %}">{% trans "Download YouTube file." %}</a></p>
+          {% if audiobook.youtube_published %}
+            <p>{% trans "Published:" %} {{ audiobook.youtube_published }}</a></p>
+            {% if audiobook.get_youtube_published_tags.tags %}
+              {% tags_table audiobook.get_youtube_published_tags.tags %}
+            {% endif %}
+          {% else %}
+            <p>{% trans "Not published yet." %}</p>
+          {% endif %}
+        {% else %}
+          <p>{% trans "YouTube file hasn't been generated yet." %}</p>
+        {% endif %}
+      </div>
+    </div>
+  </div>
+
   <div class="col-xl-6">
     <div class="card mt-4">
       <div class="card-header">
index 0deb995..32a6f73 100644 (file)
@@ -16,7 +16,7 @@ urlpatterns = [
     url(r'^publish/(\d+)/$', views.publish, name="publish"),
     url(r'^convert/(\d+)/$', views.publish, {'publish': False}, name="convert"),
     url(r'^download/(\d+)/$', views.download, name="download"),
-    url(r'^download/(\d+)\.(mp3|ogg)$', views.download, name="download"),
+    url(r'^download/(\d+)\.(mp3|ogg|mkv)$', views.download, name="download"),
     url(r'^cancel/(\d+)/$', views.cancel_publishing, name="cancel_publishing"),
     url(r'^remove_to_archive/(\d+)/$', views.remove_to_archive, name="remove_to_archive"),
 
index 476d506..b8598df 100644 (file)
@@ -1,12 +1,12 @@
 from hashlib import sha1
 import os
 import os.path
+import subprocess
 from django.core.files.storage import FileSystemStorage
 from django.core.files.uploadedfile import UploadedFile
 
 
 class ExistingFile(UploadedFile):
-
     def __init__(self, path, *args, **kwargs):
         self.path = path
         return super(ExistingFile, self).__init__(*args, **kwargs)
@@ -19,7 +19,6 @@ class ExistingFile(UploadedFile):
 
 
 class OverwriteStorage(FileSystemStorage):
-
     def _save(self, name, content):
         if self.exists(name):
             self.delete(name)
@@ -31,7 +30,7 @@ class OverwriteStorage(FileSystemStorage):
 
 def sha1_file(f):
     sha = sha1()
-    for piece in iter(lambda: f.read(1024*1024), b''):
+    for piece in iter(lambda: f.read(1024 * 1024), b""):
         sha.update(piece)
     return sha.hexdigest()
 
@@ -40,5 +39,4 @@ def all_files(root_path):
     root_len = len(root_path)
     for path, dirs, files in os.walk(root_path):
         for fname in files:
-            yield os.path.join(path, fname)[root_len:].lstrip('/')
-
+            yield os.path.join(path, fname)[root_len:].lstrip("/")
index 8fbcd7e..b39ac72 100644 (file)
@@ -184,15 +184,19 @@ def cancel_publishing(request, aid):
     # TODO: cancel tasks
     audiobook.mp3_status = None
     audiobook.ogg_status = None
+    audiobook.youtube_status = None
     audiobook.save()
     return redirect(file_managed, aid)
 
 
 def download(request, aid, which="source"):
-    if which not in ("source", "mp3", "ogg"):
+    if which not in ("source", "mp3", "ogg", 'mkv'):
         raise Http404
     audiobook = get_object_or_404(models.Audiobook, id=aid)
-    file_ = getattr(audiobook, "%s_file" % which)
+    field = which
+    if which == 'mkv':
+        field = 'youtube'
+    file_ = getattr(audiobook, "%s_file" % field)
     if not file_:
         raise Http404
     ext = file_.path.rsplit('.', 1)[-1]
@@ -200,7 +204,9 @@ def download(request, aid, which="source"):
     
     response['Content-Disposition'] = "attachment; filename*=UTF-8''%s.%s" % (
         quote(audiobook.title.encode('utf-8'), safe=''), ext)
-    response['X-Sendfile'] = file_.path.encode('utf-8')
+    with open(file_.path, 'rb') as f:
+        response.write(f.read())
+    #response['X-Sendfile'] = file_.path.encode('utf-8')
     return response
 
 
index cfb9114..ca3d16e 100644 (file)
@@ -131,6 +131,7 @@ INSTALLED_APPS = (
 
     'apiclient',
     'archive',
+    'youtube',
 )
 
 # A sample logging configuration. The only tangible logging
index 5bde7e4..792a50f 100644 (file)
@@ -10,6 +10,7 @@ admin.autodiscover()
 urlpatterns = [
     url(r'^$', RedirectView.as_view(url='archive/', permanent=False)),
     url(r'^archive/', include('archive.urls')),
+    url(r'^youtube/', include('youtube.urls')),
     url(r'^publish/', include('apiclient.urls')),
 
     url(r'^admin/', admin.site.urls),
diff --git a/src/youtube/__init__.py b/src/youtube/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/youtube/admin.py b/src/youtube/admin.py
new file mode 100644 (file)
index 0000000..e6fe370
--- /dev/null
@@ -0,0 +1,5 @@
+from django.contrib import admin
+from . import models
+
+
+admin.site.register(models.YouTube)
diff --git a/src/youtube/apps.py b/src/youtube/apps.py
new file mode 100644 (file)
index 0000000..b8743a0
--- /dev/null
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class YoutubeConfig(AppConfig):
+    name = 'youtube'
diff --git a/src/youtube/locale/pl/LC_MESSAGES/django.mo b/src/youtube/locale/pl/LC_MESSAGES/django.mo
new file mode 100644 (file)
index 0000000..1e86547
Binary files /dev/null and b/src/youtube/locale/pl/LC_MESSAGES/django.mo differ
diff --git a/src/youtube/locale/pl/LC_MESSAGES/django.po b/src/youtube/locale/pl/LC_MESSAGES/django.po
new file mode 100644 (file)
index 0000000..6446cb2
--- /dev/null
@@ -0,0 +1,29 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2020-04-01 14:18+0200\n"
+"PO-Revision-Date: 2020-04-01 14:18+0200\n"
+"Language: pl\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n"
+"%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n"
+"%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"X-Generator: Poedit 2.2.4\n"
+
+#: models.py:23
+msgid "YouTube configuration"
+msgstr "konfiguracja YouTube"
+
+#: models.py:24
+msgid "YouTube configurations"
+msgstr "konfiguracje YouTube"
diff --git a/src/youtube/migrations/0001_initial.py b/src/youtube/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..e45ce04
--- /dev/null
@@ -0,0 +1,35 @@
+# Generated by Django 3.0.4 on 2020-03-30 17:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='YouTube',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('client_secrets', models.TextField(blank=True)),
+                ('oauth2_storage', models.TextField(blank=True, editable=False)),
+                ('title_template', models.CharField(blank=True, max_length=1024)),
+                ('description_template', models.TextField(blank=True)),
+                ('category', models.IntegerField(null=True)),
+                ('intro_card', models.FileField(blank=True, upload_to='')),
+                ('intro_card_length', models.FloatField(null=True)),
+                ('card', models.FileField(blank=True, upload_to='')),
+                ('loop_video', models.FileField(blank=True, upload_to='')),
+                ('outro_card', models.FileField(blank=True, upload_to='')),
+                ('outro_card_length', models.FloatField(null=True)),
+            ],
+            options={
+                'verbose_name': 'YouTube configuration',
+                'verbose_name_plural': 'YouTube configurations',
+            },
+        ),
+    ]
diff --git a/src/youtube/migrations/0002_auto_20200330_1800.py b/src/youtube/migrations/0002_auto_20200330_1800.py
new file mode 100644 (file)
index 0000000..a79fc82
--- /dev/null
@@ -0,0 +1,28 @@
+# Generated by Django 3.0.4 on 2020-03-30 18:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('youtube', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='youtube',
+            name='category',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='youtube',
+            name='intro_card_length',
+            field=models.FloatField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='youtube',
+            name='outro_card_length',
+            field=models.FloatField(blank=True, null=True),
+        ),
+    ]
diff --git a/src/youtube/migrations/0003_auto_20200331_1404.py b/src/youtube/migrations/0003_auto_20200331_1404.py
new file mode 100644 (file)
index 0000000..adc0032
--- /dev/null
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.4 on 2020-03-31 14:04
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('youtube', '0002_auto_20200330_1800'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='youtube',
+            old_name='intro_card_length',
+            new_name='intro_card_duration',
+        ),
+        migrations.RenameField(
+            model_name='youtube',
+            old_name='outro_card_length',
+            new_name='outro_card_duration',
+        ),
+    ]
diff --git a/src/youtube/migrations/__init__.py b/src/youtube/migrations/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/youtube/models.py b/src/youtube/models.py
new file mode 100644 (file)
index 0000000..423f338
--- /dev/null
@@ -0,0 +1,123 @@
+from os import unlink
+from tempfile import NamedTemporaryFile
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from django.template import Template, Context
+from apiclient import youtube_call
+from .utils import (
+    video_from_image,
+    cut_video,
+    concat_videos,
+    get_duration,
+    get_framerate,
+    mux,
+)
+
+
+class YouTube(models.Model):
+    title_template = models.CharField(max_length=1024, blank=True)
+    description_template = models.TextField(blank=True)
+    category = models.IntegerField(null=True, blank=True)  # get categories
+    intro_card = models.FileField(blank=True)
+    intro_card_duration = models.FloatField(null=True, blank=True)
+    card = models.FileField(blank=True)
+    loop_video = models.FileField(blank=True)
+    outro_card = models.FileField(blank=True)
+    outro_card_duration = models.FloatField(null=True, blank=True)
+
+    class Meta:
+        verbose_name = _("YouTube configuration")
+        verbose_name_plural = _("YouTube configurations")
+
+    def publish(self, audiobook, path):
+        ctx = Context(dict(audiobook=audiobook))
+        description = Template(self.description_template).render(ctx)
+        title = Template(self.title_template).render(ctx)
+        privacy = 'private'
+
+        data = dict(
+            snippet=dict(
+                title=title,
+                description=description,
+                # tags=tags,
+                # categoryId=category,
+                # defaultLanguage
+            ),
+            status=dict(
+                privacyStatus=privacy,
+                # license
+                # selfDeclaredMadeForKids
+            ),
+            # recordingDetails=dict(
+            # recordingDate
+            # ),
+        )
+        part = ",".join(data.keys())
+
+        with open(path, "rb") as f:
+            response = youtube_call(
+                "POST",
+                "https://www.googleapis.com/upload/youtube/v3/videos",
+                params={'part': part},
+                data=data,
+                media_data=f.read(),
+            )
+        data = response.json()
+        audiobook.youtube_id = data['id']
+        audiobook.save(update_fields=['youtube_id'])
+        return response
+
+    def prepare_file(self, input_path, output_path=None):
+        duration = get_duration(input_path)
+        video = self.prepare_video(duration)
+        output = mux([video, input_path], output_path=output_path)
+        unlink(video)
+        return output
+
+    def prepare_video(self, duration):
+        concat = []
+        delete = []
+
+        if self.loop_video:
+            fps = get_framerate(self.loop_video.path)
+        else:
+            fps = 25
+
+        loop_duration = duration
+        if self.intro_card and self.intro_card_duration:
+            loop_duration -= self.intro_card_duration
+            intro = video_from_image(
+                self.intro_card.path, self.intro_card_duration, fps=fps
+            )
+            concat.append(intro)
+            delete.append(intro)
+
+        if self.outro_card and self.outro_card_duration:
+            loop_duration -= self.outro_card_duration
+            outro = video_from_image(
+                self.outro_card.path, self.outro_card_duration, fps=fps
+            )
+            concat.append(outro)
+            delete.append(outro)
+
+        if self.loop_video:
+            loop_video_duration = get_duration(self.loop_video.path)
+            times_loop = int(loop_duration // loop_video_duration)
+
+            leftover_duration = loop_duration % loop_video_duration
+            leftover = cut_video(self.loop_video.path, leftover_duration)
+            concat[1:1] = [self.loop_video.path] * times_loop + [leftover]
+            delete.append(leftover)
+        else:
+            leftover = video_from_image(self.card.path, loop_duration)
+            concat.insert(1, video_from_image(self.card.path, loop_duration, fps=fps))
+            delete.append(leftover)
+
+        output = concat_videos(concat)
+        for p in delete:
+            unlink(p)
+        return output
+
+    # tags
+    # license
+    # selfDeclaredMadeForKids
diff --git a/src/youtube/tasks.py b/src/youtube/tasks.py
new file mode 100644 (file)
index 0000000..f58df0a
--- /dev/null
@@ -0,0 +1,16 @@
+from archive.tasks import AudioFormatTask
+from .models import YouTube
+
+
+class YouTubeTask(AudioFormatTask):
+    ext = 'mkv'
+    prefix = 'youtube'
+
+    def encode(self, in_path, out_path):
+        YouTube.objects.first().prepare_file(in_path, out_path)
+
+    def set_tags(self, audiobook, filename):
+        pass
+
+    def put(self, user, audiobook, filename):
+        YouTube.objects.first().publish(audiobook, filename)
diff --git a/src/youtube/tests.py b/src/youtube/tests.py
new file mode 100644 (file)
index 0000000..7ce503c
--- /dev/null
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/src/youtube/urls.py b/src/youtube/urls.py
new file mode 100644 (file)
index 0000000..f170e2b
--- /dev/null
@@ -0,0 +1,7 @@
+from django.conf.urls import url
+from . import views
+
+urlpatterns = [
+    url(r'^publish/(\d+)/$', views.publish, name="youtube_publish"),
+    url(r'^convert/(\d+)/$', views.publish, {'publish': False}, name="youtube_convert"),
+]
diff --git a/src/youtube/utils.py b/src/youtube/utils.py
new file mode 100644 (file)
index 0000000..59d1033
--- /dev/null
@@ -0,0 +1,92 @@
+import subprocess
+from tempfile import NamedTemporaryFile
+
+
+def video_from_image(img_path, duration, fps=25):
+    tmp = NamedTemporaryFile(prefix='image', suffix='.mkv', delete=False)
+    tmp.close()
+    subprocess.run(
+        ['ffmpeg', '-y', '-framerate', f'1/{duration}',  '-i', img_path, '-c:v', 'libx264', '-vf', f'fps={fps},format=yuv420p', tmp.name], check=True)
+    return tmp.name
+
+
+def cut_video(video_path, duration):
+    tmp = NamedTemporaryFile(prefix='cut', suffix='.mkv', delete=False)
+    tmp.close()
+    subprocess.run(
+        ['ffmpeg', '-y', '-i', video_path, '-t', str(duration), tmp.name], check=True)
+    return tmp.name
+
+
+def concat_videos(paths):
+    filelist = NamedTemporaryFile(prefix='concat', suffix='.txt')
+    for path in paths:
+        filelist.write(f"file '{path}'\n".encode('utf-8'))
+    filelist.flush()
+
+    output = NamedTemporaryFile(prefix='concat', suffix='.mkv', delete=False)
+    output.close()
+        
+    subprocess.run(
+        ['ffmpeg', '-y', '-safe', '0', '-f', 'concat', '-i', filelist.name, '-c', 'copy', output.name],
+        check=True)
+
+    filelist.close()
+    return output.name
+
+
+def mux(channels, output_path=None):
+    if not output_path:
+        output = NamedTemporaryFile(prefix='concat', suffix='.mkv', delete=False)
+        output.close()
+        output_path = output.name
+    args = ['ffmpeg']
+    for c in channels:
+        args.extend(['-i', c])
+    args.extend(['-shortest', '-y', output_path])
+    subprocess.run(args, check=True)
+    return output_path
+
+
+def get_duration(path):
+    return float(
+        subprocess.run(
+            [
+                "ffprobe",
+                "-i",
+                path,
+                "-show_entries",
+                "format=duration",
+                "-v",
+                "quiet",
+                "-of",
+                "csv=p=0",
+            ],
+            capture_output=True,
+            text=True,
+            check=True,
+        ).stdout
+    )
+
+
+def get_framerate(path):
+    rates = subprocess.run(
+            [
+                "ffprobe",
+                "-i",
+                path,
+                "-show_entries",
+                "stream=r_frame_rate",
+                "-v",
+                "quiet",
+                "-of",
+                "csv=p=0",
+            ],
+            capture_output=True,
+            text=True,
+            check=True,
+        ).stdout.strip().split('\n')
+    for rate in rates:
+        a, b = rate.split('/')
+        if b == '1':
+            return int(a)
diff --git a/src/youtube/views.py b/src/youtube/views.py
new file mode 100644 (file)
index 0000000..1247c50
--- /dev/null
@@ -0,0 +1,21 @@
+from django.contrib.auth.decorators import permission_required
+from django.shortcuts import redirect, get_object_or_404
+from django.urls import reverse
+from django.views.decorators.http import require_POST
+from archive.constants import status
+from archive.models import Audiobook
+from . import tasks
+
+
+
+@require_POST
+@permission_required('archive.change_audiobook')
+def publish(request, aid, publish=True):
+    audiobook = get_object_or_404(Audiobook, id=aid)
+    tags = {}
+    #audiobook.set_youtube_tags(tags)
+    audiobook.youtube_status = status.WAITING
+    audiobook.save(update_fields=['youtube_status'])
+    audiobook.youtube_task = tasks.YouTubeTask.delay(request.user.id, aid, publish).task_id
+    audiobook.save(update_fields=['youtube_task'])
+    return redirect(reverse('file', args=[aid]))