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))
 
     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 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):
 
 
 class OAuthConnection(models.Model):
@@ -18,3 +21,40 @@ class OAuthConnection(models.Model):
             return o
 
 
             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/')
         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'),
 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 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_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
 
 
 @login_required
@@ -42,3 +43,25 @@ def oauth_callback(request):
     conn.save()
 
     return HttpResponseRedirect('/')
     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 ""
 #
 msgid ""
 msgstr ""
-"Project-Id-Version: PACKAGE VERSION\n"
+"Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: \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"
 "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 "
 "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"
 
 #: constants.py:10
 msgid "Waiting"
@@ -34,111 +35,107 @@ msgstr "Opisywanie"
 msgid "Sending"
 msgstr "Wysyłanie"
 
 msgid "Sending"
 msgstr "Wysyłanie"
 
-#: models.py:23 models.py:49
+#: models.py:20 models.py:46
 msgid "project"
 msgstr "projekt"
 
 msgid "project"
 msgstr "projekt"
 
-#: models.py:24
+#: models.py:21
 msgid "projects"
 msgstr "projekty"
 
 msgid "projects"
 msgstr "projekty"
 
-#: models.py:37
+#: models.py:34
 msgid "source file"
 msgstr "plik źródłowy"
 
 msgid "source file"
 msgstr "plik źródłowy"
 
-#: models.py:40
+#: models.py:37
 msgid "title"
 msgstr "tytuł"
 
 msgid "title"
 msgstr "tytuł"
 
-#: models.py:41
+#: models.py:38
 msgid "part name"
 msgstr "nazwa części"
 
 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"
 
 msgid "eg. chapter in a novel"
 msgstr "np. rozdział w powieści"
 
-#: models.py:43
+#: models.py:40
 msgid "index"
 msgstr "numer"
 
 msgid "index"
 msgstr "numer"
 
-#: models.py:44
+#: models.py:41
 msgid "parts count"
 msgstr "liczba części"
 
 msgid "parts count"
 msgstr "liczba części"
 
-#: models.py:45
+#: models.py:42
 msgid "artist"
 msgstr "lektor"
 
 msgid "artist"
 msgstr "lektor"
 
-#: models.py:46
+#: models.py:43
 msgid "conductor"
 msgstr "reżyser"
 
 msgid "conductor"
 msgstr "reżyser"
 
-#: models.py:47
+#: models.py:44
 msgid "encoded by"
 msgstr "przyg. techn."
 
 msgid "encoded by"
 msgstr "przyg. techn."
 
-#: models.py:48
+#: models.py:45
 msgid "date"
 msgstr "data"
 
 msgid "date"
 msgstr "data"
 
-#: models.py:50
+#: models.py:47
 msgid "book url"
 msgstr "URL książki"
 
 msgid "book url"
 msgstr "URL książki"
 
-#: models.py:51
+#: models.py:48
 msgid "translator"
 msgstr "tłumacz"
 
 msgid "translator"
 msgstr "tłumacz"
 
-#: models.py:71
+#: models.py:74
 msgid "audiobook"
 msgstr "audiobook"
 
 msgid "audiobook"
 msgstr "audiobook"
 
-#: models.py:72
+#: models.py:75
 msgid "audiobooks"
 msgstr "audiobooki"
 
 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"
 
 msgid "New"
 msgstr "Nowe"
 
-#: templates/archive/base.html:6
+#: templates/archive/base.html:7
 msgid "Unpublished"
 msgstr "Nie opublikowane"
 
 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"
 
 msgid "Publishing"
 msgstr "Publikacja"
 
-#: templates/archive/base.html:8
+#: templates/archive/base.html:9
 msgid "Published"
 msgstr "Opublikowane"
 
 msgid "Published"
 msgstr "Opublikowane"
 
-#: templates/archive/base.html:9
+#: templates/archive/base.html:10
 msgid "Archive"
 msgstr "Archiwum"
 
 msgid "Archive"
 msgstr "Archiwum"
 
-#: templates/archive/base.html:11
+#: templates/archive/base.html:15
 msgid "Projects"
 msgstr "Projekty"
 
 msgid "Projects"
 msgstr "Projekty"
 
-#: templates/archive/base.html:14
+#: templates/archive/base.html:18
 msgid "Logout"
 msgstr "Wyloguj"
 
 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"
 
 msgid "Login"
 msgstr "Zaloguj"
 
-#: templates/archive/base.html:19
+#: templates/archive/base.html:23
 msgid "Administration"
 msgstr "Administracja"
 
 msgid "Administration"
 msgstr "Administracja"
 
-#: templates/archive/file_managed.html:15
+#: templates/archive/file_managed.html:16
 msgid "Publishing pending"
 msgstr "Czeka na publikację"
 
 msgid "Publishing pending"
 msgstr "Czeka na publikację"
 
@@ -146,73 +143,92 @@ msgstr "Czeka na publikację"
 msgid "Cancel publishing"
 msgstr "Anuluj publikację"
 
 msgid "Cancel publishing"
 msgstr "Anuluj publikację"
 
-#: templates/archive/file_managed.html:51
+#: templates/archive/file_managed.html:58
 msgid "Publish"
 msgstr "Opublikuj"
 
 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"
 
 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"
 
 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."
 
 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:"
 
 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."
 
 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."
 
 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"
 
 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."
 
 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."
 
 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?"
 
 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"
 
 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"
 
 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!"
 
 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"
 
 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"
 
 msgid "Unpublished audiobooks"
 msgstr "Nie opublikowane audiobooki"
 
+#: templates/base.html:12
+msgid "Audiobook repository"
+msgstr "Repozytorium audiobooków"
+
 #~ msgid "arranger"
 #~ msgstr "aranżer"
 
 #~ 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)
 
     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")
     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(
     @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):
 
     @staticmethod
     def encode(in_path, out_path):
@@ -39,7 +39,7 @@ class AudioFormatTask(Task):
 
     @classmethod
     def set_tags(cls, audiobook, file_name):
 
     @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)
         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):
 
     @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),
         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 = {
     @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):
         }
         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,
         data = {
             'book': tags['url'],
             'type': cls.ext,
@@ -122,7 +122,7 @@ class AudioFormatTask(Task):
 
 
 class Mp3Task(AudioFormatTask):
 
 
 class Mp3Task(AudioFormatTask):
-    ext = 'mp3'
+    prefix = ext = 'mp3'
 
     # these shouldn't be staticmethods
     def id3_text(tag, text):
 
     # these shouldn't be staticmethods
     def id3_text(tag, text):
@@ -186,7 +186,7 @@ class Mp3Task(AudioFormatTask):
 
 
 class OggTask(AudioFormatTask):
 
 
 class OggTask(AudioFormatTask):
-    ext = 'ogg'
+    prefix = ext = 'ogg'
 
     @staticmethod
     def encode(in_path, out_path):
 
     @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">
     <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 %}
       <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 %}
 
         <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>
 
     {% 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 %}
         </td></tr>
       </table>
     {% endif %}
@@ -69,7 +89,7 @@
 
 
 <div class="row">
 
 
 <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 class="card mt-4">
       <div class="card-header">
        <h2>{% trans "MP3 file" %}</h2>
     </div>
   </div>
 
     </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 class="card mt-4">
       <div class="card-header">
         <h2>{% trans "Ogg Vorbis file" %}</h2>
     </div>
   </div>
 
     </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">
   <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'^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"),
 
     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
 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):
 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)
     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):
 
 
 class OverwriteStorage(FileSystemStorage):
-
     def _save(self, name, content):
         if self.exists(name):
             self.delete(name)
     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()
 
 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()
 
         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:
     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
     # 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"):
     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)
         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]
     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['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
 
 
     return response
 
 
index cfb9114..ca3d16e 100644 (file)
@@ -131,6 +131,7 @@ INSTALLED_APPS = (
 
     'apiclient',
     'archive',
 
     'apiclient',
     'archive',
+    'youtube',
 )
 
 # A sample logging configuration. The only tangible logging
 )
 
 # 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')),
 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),
     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]))