Skip to content

Commit 7ee2a16

Browse files
committed
Add import of YouTube session videos using YouTube Data API. Fixes ietf-tools#2249. Commit ready for merge.
- Legacy-Id: 13485
1 parent 930aacc commit 7ee2a16

12 files changed

Lines changed: 11074 additions & 36 deletions

File tree

ietf/api/tests.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import sys
33
import json
44
from importlib import import_module
5+
from mock import patch
56

67
from django.apps import apps
78
from django.test import Client
@@ -23,7 +24,8 @@
2324
)
2425

2526
class CustomApiTestCase(TestCase):
26-
def test_notify_meeting_import_audio_files(self):
27+
@patch('ietf.secr.proceedings.proc_utils.import_youtube_video_urls')
28+
def test_notify_meeting_import_audio_files(self, mock_import):
2729
meeting = make_meeting_test_data()
2830
client = Client(Accept='application/json')
2931
# try invalid method GET

ietf/meeting/forms.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,14 @@ def clean(self):
8080
if date:
8181
dates.append(date)
8282
if len(dates) < 2:
83-
return
83+
return self.cleaned_data
8484
dates.sort()
8585
last_date = dates[0]
8686
for date in dates[1:]:
87-
if last_date.day + 1 != date.day:
87+
if last_date + datetime.timedelta(days=1) != date:
8888
raise forms.ValidationError('For Multi-Day meetings, days must be consecutive')
8989
last_date = date
90+
return self.cleaned_data
9091

9192
class InterimMeetingModelForm(forms.ModelForm):
9293
group = GroupModelChoiceField(queryset=Group.objects.filter(type__in=('wg', 'rg'), state__in=('active', 'proposed', 'bof')).order_by('acronym'), required=False)

ietf/meeting/tests_views.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -970,7 +970,6 @@ def test_interim_request_multi_day(self):
970970
'session_set-INITIAL_FORMS':0}
971971

972972
r = self.client.post(urlreverse("ietf.meeting.views.interim_request"),data)
973-
974973
self.assertRedirects(r,urlreverse('ietf.meeting.views.upcoming'))
975974
meeting = Meeting.objects.order_by('id').last()
976975
self.assertEqual(meeting.type_id,'interim')

ietf/meeting/views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@
5858
from ietf.meeting.helpers import send_interim_announcement_request
5959
from ietf.meeting.utils import finalize
6060
from ietf.secr.proceedings.utils import handle_upload_file
61-
from ietf.secr.proceedings.proc_utils import get_progress_stats, post_process, import_audio_files
61+
from ietf.secr.proceedings.proc_utils import (get_progress_stats, post_process, import_audio_files,
62+
import_youtube_video_urls)
6263
from ietf.utils import log
6364
from ietf.utils.mail import send_mail_message
6465
from ietf.utils.pipe import pipe
@@ -2173,6 +2174,7 @@ def api_import_recordings(request, number):
21732174
if request.method == 'POST':
21742175
meeting = get_meeting(number)
21752176
import_audio_files(meeting)
2177+
import_youtube_video_urls(meeting)
21762178
return HttpResponse(status=201)
21772179
else:
21782180
return HttpResponse(status=405)

ietf/secr/proceedings/proc_utils.py

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@
33
44
This module contains all the functions for generating static proceedings pages
55
'''
6-
from urllib2 import urlopen
76
import datetime
87
import glob
8+
import httplib2
99
import os
1010
import re
1111
import shutil
1212
import subprocess
13+
import urllib2
14+
from urllib import urlencode
1315

1416
import debug # pyflakes:ignore
1517

18+
from apiclient.discovery import build
1619
from django.conf import settings
1720
from django.core.exceptions import ObjectDoesNotExist
1821
from django.http import HttpRequest
@@ -34,10 +37,84 @@
3437
from ietf.utils.mail import send_mail
3538

3639
AUDIO_FILE_RE = re.compile(r'ietf(?P<number>[\d]+)-(?P<room>.*)-(?P<time>[\d]{8}-[\d]{4})')
40+
VIDEO_TITLE_RE = re.compile(r'IETF(?P<number>\d{2})-(?P<name>.*)-(?P<date>\d{8})-(?P<time>\d{4})')
3741

3842
# -------------------------------------------------
39-
# Helper Functions
43+
# Recording Functions
4044
# -------------------------------------------------
45+
46+
def import_youtube_video_urls(meeting, http=httplib2.Http()):
47+
'''Create Document and set external_url for session videos'''
48+
youtube = build(settings.YOUTUBE_API_SERVICE_NAME, settings.YOUTUBE_API_VERSION,
49+
developerKey=settings.YOUTUBE_API_KEY, http=http)
50+
playlistid = get_youtube_playlistid(youtube, 'IETF' + meeting.number)
51+
if playlistid is None:
52+
return None
53+
for video in get_youtube_videos(youtube, playlistid):
54+
match = VIDEO_TITLE_RE.match(video['title'])
55+
if match:
56+
session = _get_session(**match.groupdict())
57+
if session:
58+
url = video['url']
59+
get_or_create_recording_document(url,session)
60+
61+
def get_youtube_playlistid(youtube, title, http=httplib2.Http()):
62+
'''Returns the youtube playlistId matching title string, a string'''
63+
request = youtube.search().list(
64+
q=title,
65+
part='id,snippet',
66+
channelId=settings.YOUTUBE_IETF_CHANNEL_ID,
67+
type='playlist',
68+
maxResults=1
69+
)
70+
search_response = request.execute(http=http)
71+
72+
try:
73+
playlistid = search_response['items'][0]['id']['playlistId']
74+
except (KeyError, IndexError):
75+
return None
76+
return playlistid
77+
78+
def get_youtube_videos(youtube, playlistid, http=httplib2.Http()):
79+
'''Returns list of dictionaries with title, urls keys'''
80+
videos = []
81+
kwargs = dict(part="snippet",playlistId=playlistid,maxResults=50)
82+
playlistitems = youtube.playlistItems()
83+
request = playlistitems.list(**kwargs)
84+
# handle pagination
85+
while request is not None:
86+
playlistitems_doc = request.execute(http=http)
87+
videos.extend(_get_urls_from_json(playlistitems_doc))
88+
request = playlistitems.list_next(request, playlistitems_doc)
89+
return videos
90+
91+
def _get_session(number,name,date,time):
92+
'''Lookup session using data from video title'''
93+
meeting = Meeting.objects.get(number=number)
94+
schedule = meeting.agenda
95+
timeslot_time = datetime.datetime.strptime(date + time,'%Y%m%d%H%M')
96+
try:
97+
assignment = SchedTimeSessAssignment.objects.get(
98+
schedule = schedule,
99+
session__group__acronym = name.lower(),
100+
timeslot__time = timeslot_time,
101+
)
102+
except (SchedTimeSessAssignment.DoesNotExist, SchedTimeSessAssignment.MultipleObjectsReturned):
103+
return None
104+
105+
return assignment.session
106+
107+
def _get_urls_from_json(doc):
108+
'''Returns list of dictonary titel,url from search results'''
109+
urls = []
110+
for item in doc['items']:
111+
title = item['snippet']['title']
112+
#params = dict(v=item['snippet']['resourceId']['videoId'], list=item['snippet']['playlistId'])
113+
params = [('v',item['snippet']['resourceId']['videoId']), ('list',item['snippet']['playlistId'])]
114+
url = settings.YOUTUBE_BASE_URL + '?' + urlencode(params)
115+
urls.append(dict(title=title, url=url))
116+
return urls
117+
41118
def import_audio_files(meeting):
42119
'''
43120
Checks for audio files and creates corresponding materials (docs) for the Session
@@ -58,20 +135,9 @@ def import_audio_files(meeting):
58135
).exclude(session__agenda_note__icontains='canceled').order_by('timeslot__time')
59136
if not sessionassignments:
60137
continue
61-
doc = get_or_create_recording_document(filename,sessionassignments[0].session)
62-
for sessionassignment in sessionassignments:
63-
session = sessionassignment.session
64-
if doc not in session.materials.all():
65-
# add document to session
66-
presentation = SessionPresentation.objects.create(
67-
session=session,
68-
document=doc,
69-
rev=doc.rev)
70-
session.sessionpresentation_set.add(presentation)
71-
if not doc.docalias_set.filter(name__startswith='recording-{}-{}'.format(meeting.number,session.group.acronym)):
72-
sequence = get_next_sequence(session.group,session.meeting,'recording')
73-
name = 'recording-{}-{}-{}'.format(session.meeting.number,session.group.acronym,sequence)
74-
doc.docalias_set.create(name=name)
138+
url = settings.IETF_AUDIO_URL + 'ietf{}/{}'.format(meeting.number, filename)
139+
doc = get_or_create_recording_document(url,sessionassignments[0].session)
140+
attach_recording(doc, [ x.session for x in sessionassignments ])
75141
else:
76142
# use for reconciliation email
77143
unmatched_files.append(filename)
@@ -98,20 +164,30 @@ def get_timeslot_for_filename(filename):
98164
except (ObjectDoesNotExist, KeyError):
99165
return None
100166

167+
def attach_recording(doc, sessions):
168+
'''Associate recording document with sessions'''
169+
for session in sessions:
170+
if doc not in session.materials.all():
171+
# add document to session
172+
presentation = SessionPresentation.objects.create(
173+
session=session,
174+
document=doc,
175+
rev=doc.rev)
176+
session.sessionpresentation_set.add(presentation)
177+
if not doc.docalias_set.filter(name__startswith='recording-{}-{}'.format(session.meeting.number,session.group.acronym)):
178+
sequence = get_next_sequence(session.group,session.meeting,'recording')
179+
name = 'recording-{}-{}-{}'.format(session.meeting.number,session.group.acronym,sequence)
180+
doc.docalias_set.create(name=name)
181+
101182
def normalize_room_name(name):
102183
'''Returns room name converted to be used as portion of filename'''
103184
return name.lower().replace(' ','').replace('/','_')
104185

105-
def get_or_create_recording_document(filename,session):
106-
meeting = session.meeting
107-
url = settings.IETF_AUDIO_URL + 'ietf{}/{}'.format(meeting.number, filename)
186+
def get_or_create_recording_document(url,session):
108187
try:
109-
doc = Document.objects.get(external_url=url)
110-
return doc
188+
return Document.objects.get(external_url=url)
111189
except ObjectDoesNotExist:
112-
pass
113-
return create_recording(session,url)
114-
190+
return create_recording(session,url)
115191

116192
def create_recording(session,url):
117193
'''
@@ -182,6 +258,10 @@ def mycomp(timeslot):
182258
key = None
183259
return key
184260

261+
# -------------------------------------------------
262+
# End Recording Functions
263+
# -------------------------------------------------
264+
185265
def get_progress_stats(sdate,edate):
186266
'''
187267
This function takes a date range and produces a dictionary of statistics / objects for
@@ -489,7 +569,7 @@ def gen_agenda(context):
489569

490570
# get the text agenda from datatracker
491571
url = 'https://datatracker.ietf.org/meeting/%s/agenda.txt' % meeting.number
492-
text = urlopen(url).read()
572+
text = urllib2.urlopen(url).read()
493573
path = os.path.join(settings.SECR_PROCEEDINGS_DIR,meeting.number,'agenda.txt')
494574
write_html(path,text)
495575

ietf/secr/proceedings/tests.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
11
import debug # pyflakes:ignore
2+
import json
23
import os
34
import shutil
5+
from apiclient.discovery import build
6+
from apiclient.http import HttpMock
7+
from mock import patch
48

59
from django.conf import settings
610
from django.urls import reverse
711

812
from ietf.doc.models import Document
913
from ietf.group.models import Group
14+
from ietf.meeting.factories import SessionFactory
1015
from ietf.meeting.models import Session, TimeSlot, SchedTimeSessAssignment
1116
from ietf.meeting.test_data import make_meeting_test_data
17+
from ietf.name.models import SessionStatusName
1218
from ietf.utils.test_data import make_test_data
1319
from ietf.utils.test_utils import TestCase
1420
from ietf.utils.mail import outbox
15-
16-
from ietf.name.models import SessionStatusName
17-
from ietf.meeting.factories import SessionFactory
18-
1921
from ietf.secr.proceedings.proc_utils import (create_proceedings, import_audio_files,
2022
get_timeslot_for_filename, normalize_room_name, send_audio_import_warning,
21-
get_or_create_recording_document, create_recording, get_next_sequence)
23+
get_or_create_recording_document, create_recording, get_next_sequence,
24+
get_youtube_playlistid, get_youtube_videos, import_youtube_video_urls,
25+
_get_session, _get_urls_from_json)
2226

2327

2428
SECR_USER='secretary'
@@ -37,6 +41,68 @@ def test_main(self):
3741
response = self.client.get(url)
3842
self.assertEqual(response.status_code, 200)
3943

44+
45+
class VideoRecordingTestCase(TestCase):
46+
@patch('ietf.secr.proceedings.proc_utils.get_youtube_videos')
47+
@patch('ietf.secr.proceedings.proc_utils.get_youtube_playlistid')
48+
def test_import_youtube_video_urls(self, mock_playlistid, mock_videos):
49+
meeting = make_meeting_test_data()
50+
session = Session.objects.filter(meeting=meeting, group__acronym='mars').first()
51+
title = self._get_video_title_for_session(session)
52+
url = 'https://youtube.com?v=test'
53+
mock_playlistid.return_value = 'PLC86T-6ZTP5g87jdxNqdWV5475U-yEE8M'
54+
mock_videos.return_value = [{'title':title,'url':url}]
55+
discovery = os.path.join(settings.BASE_DIR, "../test/data/youtube-discovery.json")
56+
http = HttpMock(discovery, {'status': '200'})
57+
import_youtube_video_urls(meeting=meeting, http=http)
58+
doc = Document.objects.get(external_url=url)
59+
self.assertTrue(doc in session.materials.all())
60+
61+
def _get_video_title_for_session(self, session):
62+
'''Returns the youtube video title of a session recording given session'''
63+
timeslot = session.official_timeslotassignment().timeslot
64+
return "{prefix}-{group}-{date}".format(
65+
prefix=session.meeting.type.slug + session.meeting.number,
66+
group=session.group.acronym,
67+
date=timeslot.time.strftime('%Y%m%d-%H%M')).upper()
68+
69+
def test_get_youtube_playlistid(self):
70+
discovery = os.path.join(settings.BASE_DIR, "../test/data/youtube-discovery.json")
71+
http = HttpMock(discovery, {'status': '200'})
72+
youtube = build(settings.YOUTUBE_API_SERVICE_NAME, settings.YOUTUBE_API_VERSION,
73+
developerKey='',http=http)
74+
path = os.path.join(settings.BASE_DIR, "../test/data/youtube-playlistid.json")
75+
http = HttpMock(path, {'status': '200'})
76+
self.assertEqual(get_youtube_playlistid(youtube, 'IETF98', http=http),'PLC86T-test')
77+
78+
def test_get_youtube_videos(self):
79+
discovery = os.path.join(settings.BASE_DIR, "../test/data/youtube-discovery.json")
80+
http = HttpMock(discovery, {'status': '200'})
81+
youtube = build(settings.YOUTUBE_API_SERVICE_NAME, settings.YOUTUBE_API_VERSION,
82+
developerKey='',http=http)
83+
path = os.path.join(settings.BASE_DIR, "../test/data/youtube-playlistitems.json")
84+
http = HttpMock(path, {'status': '200'})
85+
videos = get_youtube_videos(youtube, 'PLC86T', http=http)
86+
self.assertEqual(len(videos),2)
87+
88+
def test_get_session(self):
89+
meeting = make_meeting_test_data()
90+
session = Session.objects.filter(meeting=meeting, group__acronym='mars').first()
91+
number = meeting.number
92+
name = session.group.acronym
93+
date = session.official_timeslotassignment().timeslot.time.strftime('%Y%m%d')
94+
time = session.official_timeslotassignment().timeslot.time.strftime('%H%M')
95+
self.assertEqual(_get_session(number,name,date,time),session)
96+
97+
def test_get_urls_from_json(self):
98+
path = os.path.join(settings.BASE_DIR, "../test/data/youtube-playlistitems.json")
99+
with open(path) as f:
100+
doc = json.load(f)
101+
urls = _get_urls_from_json(doc)
102+
self.assertEqual(len(urls),2)
103+
self.assertEqual(urls[0]['title'],'IETF98 Wrap Up')
104+
self.assertEqual(urls[0]['url'],'https://www.youtube.com/watch?v=lhYWB5FFkg4&list=PLC86T-6ZTP5jo6kIuqdyeYYhsKv9sUwG1')
105+
40106
class RecordingTestCase(TestCase):
41107
def setUp(self):
42108
self.meeting_recordings_dir = self.tempdir('meeting-recordings')

ietf/secr/proceedings/views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,8 @@ def recording(request, meeting_num):
289289
session.
290290
'''
291291
meeting = get_object_or_404(Meeting, number=meeting_num)
292-
sessions = meeting.session_set.filter(type__in=('session','plenary','other'),status='sched').order_by('group__acronym')
292+
assignments = meeting.agenda.assignments.exclude(session__type__in=('reg','break')).order_by('session__group__acronym')
293+
sessions = [ x.session for x in assignments ]
293294

294295
if request.method == 'POST':
295296
form = RecordingForm(request.POST,meeting=meeting)

ietf/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,11 @@ def skip_unreadable_post(record):
732732
REGISTRATION_ATTENDEES_BASE_URL = 'https://ietf.org/registration/attendees/'
733733
NEW_PROCEEDINGS_START = 95
734734
USE_ETAGS=True
735+
YOUTUBE_API_KEY = ''
736+
YOUTUBE_API_SERVICE_NAME = 'youtube'
737+
YOUTUBE_API_VERSION = 'v3'
738+
YOUTUBE_BASE_URL = 'https://www.youtube.com/watch'
739+
YOUTUBE_IETF_CHANNEL_ID = 'UC8dtK9njBLdFnBahHFp0eZQ'
735740

736741
PRODUCTION_TIMEZONE = "America/Los_Angeles"
737742

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ django-tastypie>=0.13.2
2020
django-widget-tweaks>=1.3
2121
docutils>=0.12
2222
factory-boy>=2.8.1
23+
google-api-python-client
2324
# Faker # from factory-boy
2425
hashids>=1.1.0
2526
html5lib>=0.90,<0.99999999 # ietf.utils.html needs a rewrite for html5lib 1.x -- major code changes in sanitizer

0 commit comments

Comments
 (0)