diff --git a/media/js/lib/get-youtube-id/.gitignore b/media/js/lib/get-youtube-id/.gitignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/media/js/lib/get-youtube-id/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/media/js/lib/get-youtube-id/LICENSE b/media/js/lib/get-youtube-id/LICENSE new file mode 100644 index 0000000000..ee27ba4b44 --- /dev/null +++ b/media/js/lib/get-youtube-id/LICENSE @@ -0,0 +1,18 @@ +This software is released under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/media/js/lib/get-youtube-id/README.md b/media/js/lib/get-youtube-id/README.md new file mode 100644 index 0000000000..53be5ed6a7 --- /dev/null +++ b/media/js/lib/get-youtube-id/README.md @@ -0,0 +1,39 @@ +# get-youtube-id + +Parse a youtube url returning the video ID. + +## Installation + +``` +npm install get-youtube-id +``` + +## Example + +``` js +var getYouTubeID = require('get-youtube-id'); + +var id = getYouTubeID("http://www.youtube.com/watch?v=9bZkp7q19f0"); +console.log(id); // "9bZkp7q19f0" + + +// Or, if you're using ES6 syntax: +import getYouTubeID from 'get-youtube-id'; +``` + +## Fuzzy matching + +By default `getYouTubeID` will make a last-ditch effort to look for anything that resembles +an 11-character id. If you want it to be more strict you can turn this off with an options +argument. + +```js +var getYouTubeID = require('get-youtube-id'); + +var id = getYouTubeID("youtube abcdefghijk", {fuzzy: false}); +console.log(id); // null +``` + +# License + +MIT diff --git a/media/js/lib/get-youtube-id/component.json b/media/js/lib/get-youtube-id/component.json new file mode 100644 index 0000000000..ee5bbcc416 --- /dev/null +++ b/media/js/lib/get-youtube-id/component.json @@ -0,0 +1,9 @@ +{ + "name": "get-youtube-id", + "version": "1.0.0", + "main": "./index.js", + "dependencies": {}, + "ignore": [ + "test/" + ] +} diff --git a/media/js/lib/get-youtube-id/index.d.ts b/media/js/lib/get-youtube-id/index.d.ts new file mode 100644 index 0000000000..355e08e941 --- /dev/null +++ b/media/js/lib/get-youtube-id/index.d.ts @@ -0,0 +1,2 @@ +declare const getYouTubeID: (url: string, opts?: { fuzzy: boolean }) => string | null; +export default getYouTubeID; diff --git a/media/js/lib/get-youtube-id/index.js b/media/js/lib/get-youtube-id/index.js new file mode 100644 index 0000000000..bdb14df5f0 --- /dev/null +++ b/media/js/lib/get-youtube-id/index.js @@ -0,0 +1,51 @@ + +(function (root, factory) { + if (typeof exports === 'object') { + module.exports = factory(); + } else if (typeof define === 'function' && define.amd) { + define(factory); + } else { + root.getYouTubeID = factory(); + } +}(this, function (exports) { + + return function (url, opts) { + if (opts == undefined) { + opts = {fuzzy: true}; + } + + if (/youtu\.?be/.test(url)) { + + // Look first for known patterns + var i; + var patterns = [ + /youtu\.be\/([^#\&\?]{11})/, // youtu.be/ + /\?v=([^#\&\?]{11})/, // ?v= + /\&v=([^#\&\?]{11})/, // &v= + /embed\/([^#\&\?]{11})/, // embed/ + /\/v\/([^#\&\?]{11})/ // /v/ + ]; + + // If any pattern matches, return the ID + for (i = 0; i < patterns.length; ++i) { + if (patterns[i].test(url)) { + return patterns[i].exec(url)[1]; + } + } + + if (opts.fuzzy) { + // If that fails, break it apart by certain characters and look + // for the 11 character key + var tokens = url.split(/[\/\&\?=#\.\s]/g); + for (i = 0; i < tokens.length; ++i) { + if (/^[^#\&\?]{11}$/.test(tokens[i])) { + return tokens[i]; + } + } + } + } + + return null; + }; + +})); diff --git a/media/js/lib/get-youtube-id/package.json b/media/js/lib/get-youtube-id/package.json new file mode 100644 index 0000000000..83edcdc4d7 --- /dev/null +++ b/media/js/lib/get-youtube-id/package.json @@ -0,0 +1,29 @@ +{ + "name": "get-youtube-id", + "version": "1.0.1", + "description": "Parse a youtube url returning the video ID.", + "main": "index.js", + "bin": {}, + "directories": { + "test": "test" + }, + "dependencies": {}, + "devDependencies": { + "tape": "~0.2.2" + }, + "scripts": { + "test": "node test" + }, + "repository": { + "type": "git", + "url": "git://github.com/jmorrell/get-youtube-id.git" + }, + "homepage": "https://github.com/jmorrell/get-youtube-id", + "keywords": [ + "youtube", + "url" + ], + "author": "Jeremy Morrell ", + "license": "MIT", + "readmeFilename": "README.md" +} diff --git a/media/js/lib/get-youtube-id/test/index.js b/media/js/lib/get-youtube-id/test/index.js new file mode 100644 index 0000000000..ae9949a56c --- /dev/null +++ b/media/js/lib/get-youtube-id/test/index.js @@ -0,0 +1,50 @@ +var test = require('tape'); +var getYouTubeID = require('../index.js'); + +// A bunch of youtube url formats collection from Stack Overflow. All of them +// should resolve to a specific video. Pull requests welcome if more example +// types can be found. +var tests = [ + { expectedID: '-wtIMTCHWuI', url: 'http://www.youtube.com/watch?v=-wtIMTCHWuI' }, + { expectedID: '-wtIMTCHWuI', url: 'http://www.youtube.com/v/-wtIMTCHWuI?version=3&autohide=1' }, + { expectedID: '-wtIMTCHWuI', url: 'http://youtu.be/-wtIMTCHWuI' }, + { expectedID: 'zc0s358b3Ys', url: 'http://www.youtube.com/embed/zc0s358b3Ys' }, + { expectedID: '-wtIMTCHWuI', url: ' http://www.youtube.com/watch?v=-wtIMTCHWuI ' }, + { expectedID: 'zc0s358b3Ys', url: 'http://youtu.be/zc0s358b3Ys' }, + { expectedID: 'u8nQa1cJyX8', url: 'http://www.youtube.com/watch?v=u8nQa1cJyX8&a=GxdCwVVULXctT2lYDEPllDR0LRTutYfW' }, + { expectedID: 'u8nQa1cJyX8', url: 'http://www.youtube.com/watch?v=u8nQa1cJyX8' }, + { expectedID: 'zc0s358b3Ys', url: 'http://youtu.be/zc0s358b3Ys' }, + { expectedID: 'zc0s358b3Ys', url: 'http://youtu.be/zc0s358b3Ys' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/watch?v=0zM3nApSvMg&feature=feedrec_grec_index' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/v/0zM3nApSvMg?fs=1&hl=en_US&rel=0' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/watch?v=0zM3nApSvMg#t=0m10s' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/embed/0zM3nApSvMg?rel=0' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/watch?v=0zM3nApSvMg' }, + { expectedID: '0zM3nApSvMg', url: 'http://youtu.be/0zM3nApSvMg' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/v/0zM3nApSvMg?fs=1&hl=en_US&rel=0' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/embed/0zM3nApSvMg?rel=0' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/watch?v=0zM3nApSvMg&feature=feedrec_grec_index' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/watch?v=0zM3nApSvMg' }, + { expectedID: '0zM3nApSvMg', url: 'http://youtu.be/0zM3nApSvMg' }, + { expectedID: '0zM3nApSvMg', url: 'http://www.youtube.com/watch?v=0zM3nApSvMg#t=0m10s' }, + { expectedID: 'QdK8U-VIH_o', url: 'http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/QdK8U-VIH_o' }, + { expectedID: 'LXilEPmkoQY', url: 'http://www.youtube.com/embed/LXilEPmkoQY' }, + { expectedID: 'LXilEPmkoQY', url: 'http://www.youtube.com/v/LXilEPmkoQY' }, + { expectedID: 'u8nQa1cJyX8', url: 'http://www.youtube.com/watch?argv=xyzxyzxyzxy&v=u8nQa1cJyX8' }, + { expectedID: '0zM3nApSvMg', url: 'youtube.com/watch?feature=feedrec_grec_index&v=0zM3nApSvMg ' }, + { expectedID: 'y_Rd2hByRyc', url: 'http://www.youtube.com/watch?feature=player_embedded&v=y_Rd2hByRyc' } +]; + +test('match example cases', function(t) { + t.plan(tests.length); + + tests.forEach(function(testCase) { + t.equal(getYouTubeID(testCase.url), testCase.expectedID, 'URL: ' + testCase.url); + }); + +}); + +test('disabling fuzzy mode', function(t) { + t.plan(1); + t.equal(getYouTubeID('youtube abcdefghijk', {fuzzy: false}), null); +}); diff --git a/media/js/utils.js b/media/js/utils.js new file mode 100644 index 0000000000..759eb7eecc --- /dev/null +++ b/media/js/utils.js @@ -0,0 +1,57 @@ +/** + * Random utility functions. Currently these are all related to + * importing media. + */ + +const isYouTubeURL = function(s) { + const re = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/; + return s.match(re); +}; + +const isImgUrl = function(url) { + const img = new Image(); + img.src = url; + return new Promise((resolve) => { + img.onerror = () => resolve(false); + img.onload = () => resolve(true); + }); +}; + +const getMediaType = function(url) { + if (isYouTubeURL(url)) { + return 'youtube'; + } + + return isImgUrl(url).then(function(result) { + if (result) { + return 'image'; + } + + return null; + }); +}; + +/** + * Refresh form display when import url changes. + */ +const refreshImportForm = function(sourceUrl, mediaType='image') { + if (mediaType === 'youtube') { + const youtubeId = getYouTubeID(sourceUrl); + sourceUrl = `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`; + } + + const thumbnailEl = document.getElementById('imported-thumbnail'); + thumbnailEl.src = sourceUrl; + thumbnailEl.style.display = 'block'; + + jQuery(thumbnailEl).on('load', function() { + document.getElementById('import-form-width').value = + thumbnailEl.naturalWidth; + document.getElementById('import-form-height').value = + thumbnailEl.naturalHeight; + }); +}; + +export { + isYouTubeURL, isImgUrl, getMediaType, refreshImportForm +}; diff --git a/mediathread/assetmgr/tests/test_views.py b/mediathread/assetmgr/tests/test_views.py index 6d2112d198..90f6326f31 100644 --- a/mediathread/assetmgr/tests/test_views.py +++ b/mediathread/assetmgr/tests/test_views.py @@ -18,7 +18,7 @@ EMBED_WIDTH, EMBED_HEIGHT, asset_workspace_courselookup, RedirectToExternalCollectionView, - RedirectToUploaderView, AssetCreateView, AssetEmbedListView, + RedirectToUploaderView, ExternalAssetCreateView, AssetEmbedListView, _parse_domain, AssetEmbedView, annotation_delete, annotation_create_global, annotation_create, AssetUpdateView ) @@ -86,7 +86,7 @@ def test_sources_from_args(self): 'image': 'x' * 5000, # too long 'url': 'https://www.youtube.com/abcdefghi'} request = RequestFactory().post('/save/', data) - sources = AssetCreateView.sources_from_args(request) + sources = ExternalAssetCreateView.sources_from_args(request) self.assertEquals(len(sources.keys()), 0) @@ -95,7 +95,7 @@ def test_sources_from_args(self): 'image': 'https://www.flickr.com/', 'image-metadata': ['w720h526;text/html']} request = RequestFactory().post('/save/', data) - sources = AssetCreateView.sources_from_args(request) + sources = ExternalAssetCreateView.sources_from_args(request) self.assertEquals(len(sources.keys()), 2) self.assertEquals(sources['image'].url, 'https://www.flickr.com/') self.assertTrue(sources['image'].primary) @@ -111,7 +111,7 @@ def test_sources_from_args(self): 'metadata-description': 'Video description', } request = RequestFactory().post('/save/', data) - sources = AssetCreateView.sources_from_args(request) + sources = ExternalAssetCreateView.sources_from_args(request) self.assertEquals(len(sources.keys()), 2) self.assertEquals(sources['video'].url, 'http://www.example.com/video.mp4') @@ -120,7 +120,7 @@ def test_sources_from_args(self): self.assertEquals(sources['video'].height, 358) def test_parse_user(self): - view = AssetCreateView() + view = ExternalAssetCreateView() request = RequestFactory().get('/') request.course = self.sample_course @@ -213,7 +213,7 @@ def test_manage_external_collection_add_suggested(self): ExternalCollection.objects.get(course=self.sample_course, title=suggested.title) - def test_asset_create_via_bookmarklet(self): + def test_asset_create_via_browser_extension(self): data = {'title': 'YouTube Asset', 'youtube': 'https://www.youtube.com/abcdefghi', 'asset-source': 'bookmarklet'} @@ -222,7 +222,7 @@ def test_asset_create_via_bookmarklet(self): request.user = self.instructor_one request.course = self.sample_course - view = AssetCreateView() + view = ExternalAssetCreateView() view.request = request response = view.post(request) @@ -240,7 +240,7 @@ def test_asset_create_via_bookmarklet(self): request.user = self.instructor_one request.course = self.sample_course - view = AssetCreateView() + view = ExternalAssetCreateView() view.request = request response = view.post(request) @@ -1025,7 +1025,7 @@ def test_update_primary_and_thumb(self): self.assertEquals(self.asset.thumb_url, 'new thumb') -class UploadedAssetCreateViewTest(MediathreadTestMixin, TestCase): +class AssetCreateViewTest(MediathreadTestMixin, TestCase): def setUp(self): self.setup_sample_course() self.setup_alternate_course() @@ -1108,6 +1108,42 @@ def test_post_asset(self): ]) self.assertContains(r, asset_url) + def test_post_bad_asset(self): + self.client.login( + username=self.instructor_three.username, password='test') + + url = 'https://private-dev-bucket.s3.amazonaws.com' + \ + '/private/2021/09/03/86bfdaae-8e1a-4768-8d04-83052ea97f62' + + r = self.client.post( + reverse('asset-create', args=[self.sample_course.pk]), { + 'title': 'Test asset 1 ', + 'url': url, + 'width': 200, + 'height': 100, + }, format='json', follow=True) + + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'item has been added to your collection.') + self.assertContains(r, 'Test asset 1') + + asset = Asset.objects.get(title='Test asset 1') + self.assertContains( + r, reverse('asset-view', kwargs={'asset_id': asset.pk})) + self.assertEqual(asset.author, self.instructor_three) + + primary_source = asset.primary + self.assertEqual(primary_source.url, url) + self.assertTrue(primary_source.is_image()) + self.assertEqual(primary_source.width, 200) + self.assertEqual(primary_source.height, 100) + self.assertEqual(asset.author, self.instructor_three) + + asset_url = reverse('asset-view', args=[ + self.sample_course.pk, asset.pk + ]) + self.assertContains(r, asset_url) + def test_staff_post_as(self): self.client.login( username=self.superuser.username, password='test') diff --git a/mediathread/assetmgr/urls.py b/mediathread/assetmgr/urls.py index 3bf275814b..eafb65a81d 100644 --- a/mediathread/assetmgr/urls.py +++ b/mediathread/assetmgr/urls.py @@ -9,7 +9,7 @@ MostRecentView, annotation_create, annotation_create_global, annotation_save, annotation_delete, asset_delete, final_cut_pro_xml, AnnotationCopyView, PDFViewerDetailView, S3SignView, - UploadedAssetCreateView + AssetCreateView ) from mediathread.djangosherd.apiviews import SherdNoteCreate from mediathread.assetmgr.apiviews import AssetUpdate @@ -36,7 +36,7 @@ path('/annotations//', AssetWorkspaceView.as_view(), {}, 'annotation-view'), - path('create/', UploadedAssetCreateView.as_view(), name='asset-create'), + path('create/', AssetCreateView.as_view(), name='asset-create'), path('create//annotations/', annotation_create), diff --git a/mediathread/assetmgr/views.py b/mediathread/assetmgr/views.py index c1141f685a..90b649b85d 100644 --- a/mediathread/assetmgr/views.py +++ b/mediathread/assetmgr/views.py @@ -155,7 +155,7 @@ def post(self, request): # This view is used by Mediathread's browser extension, so disable CSRF # until we implement this in the extension. @method_decorator(csrf_exempt, name='dispatch') -class AssetCreateView(View): +class ExternalAssetCreateView(View): OPERATION_TAGS = ('jump', 'title', 'noui', 'v', 'share', 'as', 'set_course', 'secret') @@ -328,9 +328,10 @@ def post(self, request): return HttpResponseRedirect(asset_url) -class UploadedAssetCreateView(LoggedInCourseMixin, View): +class AssetCreateView(LoggedInCourseMixin, View): """ - View for creating an Asset via an uploaded media object. + View for creating an Asset via an uploaded media object, or + a piece of media imported via the import form. """ http_method_names = ['post'] diff --git a/mediathread/templates/main/collection_add.html b/mediathread/templates/main/collection_add.html index 0bacbdfa6b..8df87f60dd 100644 --- a/mediathread/templates/main/collection_add.html +++ b/mediathread/templates/main/collection_add.html @@ -179,16 +179,71 @@
IMAGE
Import Media
+ +
+ {% csrf_token %} +
+ + + + Right-click source image and "Copy image address" + +
+ +
+ + + + + +
+ +
+ +
+

- Install Mediathread’s Google Chrome - extension to import assets like video, audio, and + Alternatively, you can install + Mediathread’s Google Chrome extension + to import assets like video, audio, and images into this course from various sites across the web. -

  • Visit the Chrome Web Store and make sure to click Add To Chrome.
  • -
  • Once added to your browser, you can click on the Extension icon next - to the Address Bar to pin the Mediathread extension for easier access.
  • -
  • You can click the extension to collect single media items from sites like Flickr, YouTube, and Google Images.
  • +
      +
    • + Visit the Chrome Web Store and + make sure to click Add To + Chrome. +
    • +
    • + Once added to your browser, you + can click on the Extension icon + next to the Address Bar to pin + the Mediathread extension for + easier access. +
    • +
    • + You can click the extension to + collect single media items from + sites like Flickr, YouTube, and + Google Images. +
    You must be using a browser in the Chrome family @@ -213,6 +268,7 @@
    Import Media
{% block js %} + + + + + {% endblock %} diff --git a/mediathread/urls.py b/mediathread/urls.py index 71cb443f1a..2de7efe262 100644 --- a/mediathread/urls.py +++ b/mediathread/urls.py @@ -23,7 +23,8 @@ AssetDetailView, ReactAssetDetailView, TagCollectionView, RedirectToExternalCollectionView, RedirectToUploaderView, - AssetCreateView, BookmarkletMigrationView, AssetUpdateView) + ExternalAssetCreateView, BookmarkletMigrationView, AssetUpdateView +) from mediathread.main.forms import CustomRegistrationForm from mediathread.main.views import ( error_500, @@ -284,8 +285,8 @@ path('course//reports/', include('mediathread.reports.urls')), - # Bookmarklet, Wardenclyffe, Staff custom asset entry - path('save/', AssetCreateView.as_view(), name='asset-save'), + # Browser Extension, Wardenclyffe, Staff custom asset entry + path('save/', ExternalAssetCreateView.as_view(), name='asset-save'), path('update/', AssetUpdateView.as_view(), name='asset-update-view'), path('setting//', set_user_setting),