Add support for YouTube video embeds

Fixes #75
This commit is contained in:
rubenwardy 2022-01-25 20:48:37 +00:00
parent e5a4161e76
commit 1018e1c29c
17 changed files with 256 additions and 76 deletions

@ -250,6 +250,7 @@ class PackageForm(FlaskForm):
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)])
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters = [lambda x: x or None])
submit = SubmitField(lazy_gettext("Save"))
@ -333,6 +334,7 @@ def create_edit(author=None, name=None):
"website": form.website.data,
"issueTracker": form.issueTracker.data,
"forums": form.forums.data,
"video_url": form.video_url.data,
})
if wasNew and package.repo is not None:

@ -89,6 +89,7 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `website`: Website URL.
* `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID.
* `video_url`: URL to a video, YouTube only for now.
* GET `/api/packages/<username>/<name>/dependencies/`
* Returns dependencies, with suggested candidates
* If query argument `only_hard` is present, only hard deps will be returned.

@ -61,6 +61,7 @@ It should be a JSON dictionary with one or more of the following optional keys:
* `website`: Website URL.
* `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID.
* `video_url`: URL to a video, YouTube only for now.
Use `null` to unset fields where relevant.

@ -23,6 +23,7 @@ from app.logic.LogicError import LogicError
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
License, UserRank, PackageDevState
from app.utils import addAuditLog
from app.utils.url import clean_youtube_url
def check(cond: bool, msg: str):
@ -61,6 +62,7 @@ ALLOWED_FIELDS = {
"issue_tracker": str,
"issueTracker": str,
"forums": int,
"video_url": str,
}
ALIASES = {
@ -128,8 +130,13 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
if "media_license" in data:
data["media_license"] = get_license(data["media_license"])
if "video_url" in data:
data["video_url"] = clean_youtube_url(data["video_url"])
if data["video_url"] is None:
raise LogicError(400, lazy_gettext("Video URL is not a YouTube video URL"))
for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
"repo", "website", "issueTracker", "forums"]:
"repo", "website", "issueTracker", "forums", "video_url"]:
if key in data:
setattr(package, key, data[key])

@ -389,6 +389,7 @@ class Package(db.Model):
website = db.Column(db.String(200), nullable=True)
issueTracker = db.Column(db.String(200), nullable=True)
forums = db.Column(db.Integer, nullable=True)
video_url = db.Column(db.String(200), nullable=True, default=None)
provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages")
@ -527,6 +528,7 @@ class Package(db.Model):
"website": self.website,
"issue_tracker": self.issueTracker,
"forums": self.forums,
"video_url": self.video_url,
"tags": [x.name for x in self.tags],
"content_warnings": [x.name for x in self.content_warnings],

@ -0,0 +1,24 @@
document.querySelectorAll(".video-embed").forEach(ele => {
const url = new URL(ele.getAttribute("href"));
if (url.host == "www.youtube.com") {
ele.addEventListener("click", () => {
ele.parentNode.classList.add("d-block");
ele.classList.add("embed-responsive");
ele.classList.add("embed-responsive-16by9");
ele.innerHTML = `
<iframe title="YouTube video player" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>`;
const embedURL = new URL("https://www.youtube.com/");
embedURL.pathname = "/embed/" + url.searchParams.get("v");
embedURL.searchParams.set("autoplay", "1");
const iframe = ele.children[0];
iframe.setAttribute("src", embedURL);
});
ele.removeAttribute("href");
}
});

@ -1,5 +1,6 @@
@import "components.scss";
@import "packages.scss";
@import "gallery.scss";
@import "packagegrid.scss";
@import "comments.scss";

84
app/scss/gallery.scss Normal file

@ -0,0 +1,84 @@
.gallery {
list-style: none;
padding: 0;
margin: 0 0 2em;
overflow: auto hidden;
li, li a {
list-style: none;
margin: 0;
padding: 0;
}
li {
display: inline-block;
vertical-align: middle;
margin: 5px;
padding: 0;
a {
display: block;
text-decoration: none;
&:hover {
text-decoration: none;
}
}
}
.gallery-image {
position: relative;
&:hover img {
filter: brightness(1.1);
}
}
img {
width: 200px;
height: 133px;
object-fit: cover;
}
}
.video-embed {
min-width: 200px;
min-height: 133px;
background: #111;
position: relative;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer;
.fas {
display: block;
font-size: 200%;
color: #f44;
}
&:hover {
background: #191919;
.fas {
color: red;
}
}
}
.screenshot-add {
display: block !important;
width: 200px;
height: 133px;
background: #444;
color: #666;
text-align: center;
line-height: 133px !important;
font-size: 80px;
&:hover {
background: #555;
color: #999;
text-decoration: none;
}
}

@ -1,32 +1,3 @@
.screenshot_list {
list-style: none;
padding: 0;
margin: 0 0 2em;
li, li a {
list-style: none;
margin: 0;
padding: 0;
}
li {
display: inline-block;
vertical-align: middle;
margin: 5px;
padding: 0;
a {
display: block;
}
}
img {
width: 200px;
height: 133px;
object-fit: cover;
}
}
.badge-tr {
position: absolute;
top: 5px;
@ -34,23 +5,6 @@
color: #ccc !important;;
}
.screenshot-add {
display: block !important;
width: 200px;
height: 133px;
background: #444;
color: #666;
text-align: center;
line-height: 133px !important;
font-size: 80px;
&:hover {
background: #555;
color: #999;
text-decoration: none;
}
}
.info-row {
vertical-align: middle;

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title>
<link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=32">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=33">
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
<link rel="shortcut icon" href="/favicon-16.png" sizes="16x16">
<link rel="icon" href="/favicon-128.png" sizes="128x128">

@ -117,6 +117,7 @@
pattern="[0-9]+",
prefix="forum.minetest.net/viewtopic.php?t=",
placeholder=_("Tip: paste in a forum topic URL")) }}
{{ render_field(form.video_url, class_="pkg_meta", hint=_("Only supports YouTube, for now")) }}
</fieldset>
<div class="pkg_meta mt-5">{{ render_submit_field(form.submit) }}</div>

@ -78,6 +78,11 @@
{{ render_submit_field(form.submit, tabindex=280) }}
</form>
<h2>{{ _("Videos") }}</h2>
<p>
{{ _("You can set a video on the Edit Details page") }}
</p>
{% endblock %}
{% block scriptextra %}

@ -17,6 +17,10 @@
{% endif %}
{% endblock %}
{% block scriptextra %}
<script src="/static/video_embed.js"></script>
{% endblock %}
{% macro render_license(license) %}
{% if license.url %}
<a href="{{ license.url }}">{{ license.name }}</a>
@ -89,19 +93,19 @@
<div class="container">
<div class="btn-group float-right mb-4">
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %}
<a class="btn btn-primary" href="{{ package.getURL("packages.create_edit") }}">
<a class="btn btn-primary" href="{{ package.getURL('packages.create_edit') }}">
<i class="fas fa-pen mr-1"></i>
{{ _("Edit") }}
</a>
{% endif %}
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
<a class="btn btn-primary" href="{{ package.getURL("packages.create_release") }}">
<a class="btn btn-primary" href="{{ package.getURL('packages.create_release') }}">
<i class="fas fa-plus mr-1"></i>
{{ _("Release") }}
</a>
{% endif %}
{% if package.checkPerm(current_user, "DELETE_PACKAGE") or package.checkPerm(current_user, "UNAPPROVE_PACKAGE") %}
<a class="btn btn-danger" href="{{ package.getURL("packages.remove") }}">
<a class="btn btn-danger" href="{{ package.getURL('packages.remove') }}">
<i class="fas fa-trash mr-1"></i>
{{ _("Remove") }}
</a>
@ -236,33 +240,44 @@
<div class="row">
<div class="col-md-9" style="padding-right: 45px;">
{% set screenshots = package.screenshots.all() %}
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
<a href="{{ package.getURL("packages.screenshots") }}" class="btn btn-primary float-right">
<i class="fas fa-images mr-1"></i>
{{ _("Edit") }}
</a>
{% endif %}
<ul class="screenshot_list">
{% for ss in screenshots %}
{% if ss.approved or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
<li>
<a href="{{ ss.url }}" class="position-relative">
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
{% if not ss.approved %}
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% else %}
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
<a href="{{ package.getURL('packages.screenshots') }}" class="btn btn-primary float-right">
<i class="fas fa-images mr-1"></i>
{{ _("Edit") }}
</a>
{% endif %}
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") or package.video_url %}
<ul class="gallery">
{% if package.video_url %}
<li>
<a href="{{ package.getURL("packages.create_screenshot") }}">
<i class="fas fa-plus screenshot-add"></i>
<a href="{{ package.video_url }}" class="video-embed" title="{{ _('YouTube video embed') }}">
<i class="fas fa-play"></i>
</a>
</li>
{% endfor %}
{% endif %}
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
{% for ss in screenshots %}
{% if ss.approved or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
<li>
<a href="{{ ss.url }}" class="gallery-image">
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
{% if not ss.approved %}
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% else %}
<li>
<a href="{{ package.getURL('packages.create_screenshot') }}">
<i class="fas fa-plus screenshot-add"></i>
</a>
</li>
{% endfor %}
{% endif %}
</ul>
{% endif %}

@ -0,0 +1,13 @@
from app.utils.url import clean_youtube_url
def test_clean_youtube_url():
assert clean_youtube_url(
"https://www.youtube.com/watch?v=AABBCC") == "https://www.youtube.com/watch?v=AABBCC"
assert clean_youtube_url(
"https://www.youtube.com/watch?v=boGcB4H5-WA&other=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
assert clean_youtube_url("https://www.youtube.com/watch?kk=boGcB4H5-WA&other=1") is None
assert clean_youtube_url("https://www.bob.com/watch?v=AABBCC") is None
assert clean_youtube_url("https://youtu.be/boGcB4H5-WA") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
assert clean_youtube_url("https://youtu.be/boGcB4H5-WA?this=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA"

@ -14,13 +14,12 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import re
import secrets
from .flask import *
from .models import *
from .user import *
import re
YESES = ["yes", "true", "1", "on"]

46
app/utils/url.py Normal file

@ -0,0 +1,46 @@
# ContentDB
# Copyright (C) 2022 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import urllib.parse as urlparse
from typing import Optional, Dict, List
def url_set_query(url: str, params: Dict[str, str]) -> str:
url_parts = list(urlparse.urlparse(url))
query = dict(urlparse.parse_qsl(url_parts[4]))
query.update(params)
url_parts[4] = urlparse.urlencode(query)
return urlparse.urlunparse(url_parts)
def url_get_query(parsed_url: urlparse.ParseResult) -> Dict[str, List[str]]:
return urlparse.parse_qs(parsed_url.query)
def clean_youtube_url(url: str) -> Optional[str]:
parsed = urlparse.urlparse(url)
print(parsed)
if (parsed.netloc == "www.youtube.com" or parsed.netloc == "youtube.com") and parsed.path == "/watch":
print(url_get_query(parsed))
video_id = url_get_query(parsed).get("v", [None])[0]
if video_id:
return url_set_query("https://www.youtube.com/watch", {"v": video_id})
elif parsed.netloc == "youtu.be":
return url_set_query("https://www.youtube.com/watch", {"v": parsed.path[1:]})
return None

@ -0,0 +1,25 @@
"""empty message
Revision ID: 011e42c52d21
Revises: 6e57b2b4dcdf
Create Date: 2022-01-25 18:48:46.367409
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '011e42c52d21'
down_revision = '6e57b2b4dcdf'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('package', sa.Column('video_url', sa.String(length=200), nullable=True))
def downgrade():
op.drop_column('package', 'video_url')