diff --git a/docs/api/restapi.rst b/docs/api/restapi.rst index 297b88d7bb7..d3b7fe48a3e 100644 --- a/docs/api/restapi.rst +++ b/docs/api/restapi.rst @@ -390,6 +390,11 @@ This route will return all releases to display inside Nextcloud's apps admin are "smallThumbnail": "" } ], + "videos": [ + { + "url": "https://peertube.tv/videos/embed/dMWVlMwd9ecp5UVAOUhTDt" + } + ], "translations": { "en": { "name": "News", @@ -416,6 +421,11 @@ screenshots smallThumbnail Small thumbnail which can be used as preview image. Guaranteed to be HTTPS. Not required, so if not present or an empty string, use the screenshot url instead. +videos + PeerTube videos declared in the app's info.xml. Guaranteed to be HTTPS. Each entry has: + + * **url**: Normalized PeerTube embed URL suitable for iframes + download Download archive location, guaranteed to be HTTPS @@ -603,6 +613,11 @@ This route will return all releases to display inside Nextcloud's apps admin are "smallThumbnail": "" } ], + "videos": [ + { + "url": "https://peertube.tv/videos/embed/dMWVlMwd9ecp5UVAOUhTDt" + } + ], "translations": { "en": { "name": "News", @@ -629,6 +644,11 @@ screenshots smallThumbnail Small thumbnail which can be used as preview image. Guaranteed to be HTTPS. Not required, so if not present or an empty string, use the screenshot url instead. +videos + PeerTube videos declared in the app's info.xml. Guaranteed to be HTTPS. Each entry has: + + * **url**: Normalized PeerTube embed URL suitable for iframes + download Download archive location, guaranteed to be HTTPS @@ -789,6 +809,7 @@ If the app release version is the latest version, everything is updated. If it's * bugs * website * screenshot + * video For more information about validation and which **info.xml** fields are parsed, see :ref:`app-metadata` diff --git a/docs/developer.rst b/docs/developer.rst index 9e1f947dc73..cdada17b68d 100644 --- a/docs/developer.rst +++ b/docs/developer.rst @@ -211,6 +211,8 @@ A full blown example would look like this (needs to be utf-8 encoded): https://your.forum.com https://github.com/nextcloud/news/issues https://github.com/nextcloud/news + + https://example.com/1.png https://example.com/2.jpg https://paypal.com/example-link @@ -435,6 +437,17 @@ repository * must contain an URL to the project's repository * can contain a **type** attribute, **git**, **mercurial**, **subversion** and **bzr** are allowed values, defaults to **git** * currently not used +video + * optional + * can occur multiple times (up to 10) + * must contain an HTTPS URL to a PeerTube video in one of these forms: + + * ``https://host/w/`` + * ``https://host/videos/watch/`` + * ``https://host/videos/embed/`` + + * the store normalizes the URL to an embed URL and shows it in the app detail page gallery (before screenshots), in the given order + * other video hosts (for example YouTube) are not supported screenshot * optional * must contain an HTTPS URL to an image diff --git a/nextcloudappstore/api/v1/release/importer.py b/nextcloudappstore/api/v1/release/importer.py index 2a2197a61d5..49bbf9bee3a 100644 --- a/nextcloudappstore/api/v1/release/importer.py +++ b/nextcloudappstore/api/v1/release/importer.py @@ -11,6 +11,7 @@ from django.utils import timezone from semantic_version import Version # type: ignore +from nextcloudappstore.api.v1.release.peertube import peertube_embed_url from nextcloudappstore.core.facades import all_match from nextcloudappstore.core.models import ( App, @@ -28,6 +29,7 @@ PhpExtensionDependency, Screenshot, ShellCommand, + Video, ) from nextcloudappstore.core.versioning import to_raw_spec, to_spec @@ -156,6 +158,19 @@ def create_screenshot(img: dict[str, str]) -> Screenshot: obj.screenshots.set(list(shots)) +class VideosImporter(ScalarImporter): + def import_data(self, key: str, value: Any, obj: Any) -> None: + def create_video(data: dict[str, str]) -> Video: + return Video.objects.create( + url=peertube_embed_url(data["url"].strip()), + app=obj, + ordering=data["ordering"], + ) + + videos = map(lambda val: create_video(val["video"]), value) + obj.videos.set(list(videos)) + + class DonationsImporter(ScalarImporter): def import_data(self, key: str, value: Any, obj: Any) -> None: def create_donation(dnt: dict[str, str]) -> Donation: @@ -287,6 +302,7 @@ def __init__( self, release_importer: AppReleaseImporter, screenshots_importer: ScreenshotsImporter, + videos_importer: VideosImporter, donations_importer: DonationsImporter, attribute_importer: StringAttributeImporter, l10n_importer: L10NImporter, @@ -298,6 +314,7 @@ def __init__( { "release": release_importer, "screenshots": screenshots_importer, + "videos": videos_importer, "donations": donations_importer, "user_docs": attribute_importer, "admin_docs": attribute_importer, @@ -332,6 +349,7 @@ def _before_import(self, key: str, value: Any, obj: Any) -> tuple[Any, Any]: if self._should_update_everything(value): # clear all relations obj.screenshots.all().delete() + obj.videos.all().delete() obj.donations.all().delete() obj.authors.all().delete() obj.categories.clear() diff --git a/nextcloudappstore/api/v1/release/info.xsd b/nextcloudappstore/api/v1/release/info.xsd index b6e594a8bde..a7517b2e66e 100644 --- a/nextcloudappstore/api/v1/release/info.xsd +++ b/nextcloudappstore/api/v1/release/info.xsd @@ -38,6 +38,8 @@ maxOccurs="1"/> + + + + + + + diff --git a/nextcloudappstore/api/v1/release/peertube.py b/nextcloudappstore/api/v1/release/peertube.py new file mode 100644 index 00000000000..9080027b802 --- /dev/null +++ b/nextcloudappstore/api/v1/release/peertube.py @@ -0,0 +1,37 @@ +""" +SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later +""" + +import re +from urllib.parse import urlparse, urlunparse + +from rest_framework.exceptions import ValidationError + +PEERTUBE_PATH = re.compile( + r"^/(w|videos/watch|videos/embed)/(?P[A-Za-z0-9_-]+)/?$", +) + + +class InvalidPeerTubeUrl(ValidationError): + """Raised when a URL is not a recognized PeerTube watch or embed URL.""" + + +def peertube_embed_url(url: str) -> str: + """Normalize a PeerTube watch/short/embed URL to an embed URL. + + Accepts: + - https://host/w/ + - https://host/videos/watch/ + - https://host/videos/embed/ + """ + parsed = urlparse(url.strip()) + if parsed.scheme != "https" or not parsed.netloc: + raise InvalidPeerTubeUrl(f"PeerTube URL must be HTTPS with a host: {url}") + + match = PEERTUBE_PATH.match(parsed.path) + if not match: + raise InvalidPeerTubeUrl(f"PeerTube URL path must be /w/, /videos/watch/, or /videos/embed/: {url}") + + video_id = match.group("video_id") + return urlunparse(("https", parsed.netloc, f"/videos/embed/{video_id}", "", "", "")) diff --git a/nextcloudappstore/api/v1/release/pre-info.xslt b/nextcloudappstore/api/v1/release/pre-info.xslt index e00f6c0f1d7..cd0a76f1763 100644 --- a/nextcloudappstore/api/v1/release/pre-info.xslt +++ b/nextcloudappstore/api/v1/release/pre-info.xslt @@ -49,6 +49,7 @@ + diff --git a/nextcloudappstore/api/v1/serializers.py b/nextcloudappstore/api/v1/serializers.py index dbcaeb6b19b..68a8d17ce5b 100644 --- a/nextcloudappstore/api/v1/serializers.py +++ b/nextcloudappstore/api/v1/serializers.py @@ -22,6 +22,7 @@ NextcloudRelease, PhpExtensionDependency, Screenshot, + Video, ) from nextcloudappstore.core.validators import HttpsUrlValidator @@ -178,10 +179,17 @@ class Meta: fields = ("url", "small_thumbnail") +class VideoSerializer(serializers.ModelSerializer): + class Meta: + model = Video + fields = ("url",) + + class AppSerializer(serializers.ModelSerializer): releases = AppReleaseSerializer(many=True, read_only=True) discussion = SerializerMethodField() screenshots = ScreenshotSerializer(many=True, read_only=True) + videos = VideoSerializer(many=True, read_only=True) authors = AuthorSerializer(many=True, read_only=True) translations = TranslatedFieldsField(shared_model=App) last_modified = DateTimeField(source="last_release") @@ -200,6 +208,7 @@ class Meta: "last_modified", "releases", "screenshots", + "videos", "translations", "is_featured", "authors", diff --git a/nextcloudappstore/api/v1/tests/data/infoxmls/fullimport.xml b/nextcloudappstore/api/v1/tests/data/infoxmls/fullimport.xml index 22ffba3f039..7fa70befa7b 100644 --- a/nextcloudappstore/api/v1/tests/data/infoxmls/fullimport.xml +++ b/nextcloudappstore/api/v1/tests/data/infoxmls/fullimport.xml @@ -42,6 +42,9 @@ https://github.com/owncloud/news/issues + + + https://example.com/1.png diff --git a/nextcloudappstore/api/v1/tests/data/infoxmls/videos.xml b/nextcloudappstore/api/v1/tests/data/infoxmls/videos.xml new file mode 100644 index 00000000000..a886df7d8f8 --- /dev/null +++ b/nextcloudappstore/api/v1/tests/data/infoxmls/videos.xml @@ -0,0 +1,22 @@ + + + + news + News + An RSS/Atom feed reader + An RSS/Atom feed reader + 8.8.2 + AGPL-3.0-or-later + Bernhard Posselt + multimedia + https://github.com/nextcloud/news/issues + + + https://example.com/1.png + + + + diff --git a/nextcloudappstore/api/v1/tests/test_parser.py b/nextcloudappstore/api/v1/tests/test_parser.py index 8213368a535..713398cc10b 100644 --- a/nextcloudappstore/api/v1/tests/test_parser.py +++ b/nextcloudappstore/api/v1/tests/test_parser.py @@ -46,6 +46,7 @@ def test_parse_minimal(self): "discussion": None, "website": None, "issue_tracker": "https://github.com/nextcloud/news/issues", + "videos": [], "screenshots": [], "categories": [{"category": {"id": "multimedia"}}], "donations": [], @@ -515,11 +516,33 @@ def test_map_data(self): {"screenshot": {"url": "https://example.com/1.png", "small_thumbnail": None, "ordering": 1}}, {"screenshot": {"url": "https://example.com/2.jpg", "small_thumbnail": None, "ordering": 2}}, ], + "videos": [], "donations": [], } } self.assertDictEqual(expected, result) + def test_parse_videos(self): + xml = self._get_contents("data/infoxmls/videos.xml") + result = parse_app_metadata(xml, self.config.info_schema, self.config.pre_info_xslt, self.config.info_xslt) + self.assertEqual( + [ + { + "video": { + "url": "https://peertube.tv/w/dMWVlMwd9ecp5UVAOUhTDt", + "ordering": 1, + } + }, + { + "video": { + "url": "https://peertube.tv/videos/embed/TpUpEIu3PkYqljmQw7T0jR", + "ordering": 2, + } + }, + ], + result["app"]["videos"], + ) + def test_parse_changelog_empty(self): changelog = parse_changelog("", "9.0") self.assertEqual("", changelog) diff --git a/nextcloudappstore/api/v1/tests/test_peertube.py b/nextcloudappstore/api/v1/tests/test_peertube.py new file mode 100644 index 00000000000..88f46c2feb7 --- /dev/null +++ b/nextcloudappstore/api/v1/tests/test_peertube.py @@ -0,0 +1,34 @@ +""" +SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later +""" + +from django.test import TestCase + +from nextcloudappstore.api.v1.release.peertube import ( + InvalidPeerTubeUrl, + peertube_embed_url, +) + + +class PeerTubeEmbedUrlTest(TestCase): + def test_short_watch_url(self): + self.assertEqual( + peertube_embed_url("https://peertube.tv/w/dMWVlMwd9ecp5UVAOUhTDt"), + "https://peertube.tv/videos/embed/dMWVlMwd9ecp5UVAOUhTDt", + ) + + def test_watch_url(self): + self.assertEqual( + peertube_embed_url("https://peertube.tv/videos/watch/TpUpEIu3PkYqljmQw7T0jR"), + "https://peertube.tv/videos/embed/TpUpEIu3PkYqljmQw7T0jR", + ) + + def test_embed_url_passthrough(self): + url = "https://peertube.tv/videos/embed/TpUpEIu3PkYqljmQw7T0jR" + self.assertEqual(peertube_embed_url(url), url) + + def test_rejects_non_peertube_path(self): + with self.assertRaises(InvalidPeerTubeUrl) as ctx: + peertube_embed_url("https://peertube.tv/about") + self.assertIn("/w/", str(ctx.exception.detail[0])) diff --git a/nextcloudappstore/api/v1/tests/test_release_importer.py b/nextcloudappstore/api/v1/tests/test_release_importer.py index 53d5dc92397..1f35de90676 100644 --- a/nextcloudappstore/api/v1/tests/test_release_importer.py +++ b/nextcloudappstore/api/v1/tests/test_release_importer.py @@ -99,12 +99,16 @@ def test_full(self): ) release = app.releases.all()[0] screenshots = app.screenshots.all() + videos = app.videos.all() extensions = release.php_extensions.all() databases = release.databases.all() self.assertEqual(2, screenshots.count()) self.assertEqual("https://example.com/1-thumb.png", screenshots[0].small_thumbnail) self.assertEqual("", screenshots[1].small_thumbnail) + self.assertEqual(2, videos.count()) + self.assertEqual("https://peertube.tv/videos/embed/dMWVlMwd9ecp5UVAOUhTDt", videos[0].url) + self.assertEqual("https://peertube.tv/videos/embed/TpUpEIu3PkYqljmQw7T0jR", videos[1].url) self.assertEqual(3, databases.count()) self.assertEqual(4, extensions.count()) diff --git a/nextcloudappstore/api/v1/views.py b/nextcloudappstore/api/v1/views.py index b7ed2e60177..a5ee27bc685 100644 --- a/nextcloudappstore/api/v1/views.py +++ b/nextcloudappstore/api/v1/views.py @@ -55,6 +55,7 @@ BASIC_PREFETCH_LIST = [ "authors", "screenshots", + "videos", "categories", "translations", ] diff --git a/nextcloudappstore/core/admin.py b/nextcloudappstore/core/admin.py index 21858a50e97..e39e3bf1d15 100644 --- a/nextcloudappstore/core/admin.py +++ b/nextcloudappstore/core/admin.py @@ -26,6 +26,7 @@ Podcast, Screenshot, ShellCommand, + Video, ) @@ -147,6 +148,13 @@ class ScreenshotAdmin(admin.ModelAdmin): list_filter = ("app__id",) +@admin.register(Video) +class VideoAdmin(admin.ModelAdmin): + ordering = ("app", "ordering") + list_display = ("url", "app", "ordering") + list_filter = ("app__id",) + + @admin.register(Donation) class DonationAdmin(admin.ModelAdmin): ordering = ("app", "ordering") diff --git a/nextcloudappstore/core/migrations/0038_video.py b/nextcloudappstore/core/migrations/0038_video.py new file mode 100644 index 00000000000..9a06fb63699 --- /dev/null +++ b/nextcloudappstore/core/migrations/0038_video.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.30 on 2026-08-20 19:19 +# +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0037_convert_screenshot_urls_to_usercontent"), + ] + + operations = [ + migrations.CreateModel( + name="Video", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("url", models.URLField(max_length=256, verbose_name="Embed URL")), + ("ordering", models.IntegerField(verbose_name="Ordering")), + ( + "app", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="videos", + to="core.app", + verbose_name="App", + ), + ), + ], + options={ + "verbose_name": "Video", + "verbose_name_plural": "Videos", + "ordering": ["ordering"], + }, + ), + ] diff --git a/nextcloudappstore/core/models.py b/nextcloudappstore/core/models.py index 3e7bc5332f5..301dbedf77c 100644 --- a/nextcloudappstore/core/models.py +++ b/nextcloudappstore/core/models.py @@ -663,6 +663,20 @@ def __str__(self) -> str: return self.url +class Video(Model): + url = URLField(max_length=256, verbose_name=_("Embed URL")) + app = ForeignKey("App", on_delete=CASCADE, verbose_name=_("App"), related_name="videos") + ordering = IntegerField(verbose_name=_("Ordering")) + + class Meta: + verbose_name = _("Video") + verbose_name_plural = _("Videos") + ordering = ["ordering"] + + def __str__(self) -> str: + return self.url + + class Donation(Model): url = URLField(max_length=256, verbose_name=_("Donation URL")) type = CharField(max_length=256, verbose_name=_("Donation Type"), default="other") diff --git a/nextcloudappstore/core/static/assets/app/app/views/Detail.ts b/nextcloudappstore/core/static/assets/app/app/views/Detail.ts index 09e24555472..b48c4c7558f 100644 --- a/nextcloudappstore/core/static/assets/app/app/views/Detail.ts +++ b/nextcloudappstore/core/static/assets/app/app/views/Detail.ts @@ -46,10 +46,14 @@ ready.then(() => { loadUserRatings(ratingUrl, urlParams.get('bad_comment_lang') || currentLang, fallbackLang, config); }); - // fullscreen bindings + // fullscreen bindings (images only; skip iframe video slides) id('app-gallery-container', HTMLElement).ifPresent((gallery) => { const item = queryOrThrow('.carousel-inner', HTMLElement, gallery); - item.addEventListener('click', () => { + item.addEventListener('click', (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (target && (target.tagName === 'IFRAME' || target.closest('iframe'))) { + return; + } if (screenfull && screenfull.isEnabled) { item.classList.toggle('fullscreen'); screenfull?.toggle(gallery); diff --git a/nextcloudappstore/core/static/assets/css/img-slider.css b/nextcloudappstore/core/static/assets/css/img-slider.css index 0788ce9bcc6..7a31eda20e4 100644 --- a/nextcloudappstore/core/static/assets/css/img-slider.css +++ b/nextcloudappstore/core/static/assets/css/img-slider.css @@ -67,7 +67,7 @@ line-height: 40px; } -.carousel-inner:not(.fullscreen) { +.carousel-inner:not(.fullscreen) .item.active:has(img) { cursor: zoom-in; } @@ -75,10 +75,19 @@ cursor: zoom-out; } -.carousel-inner img { +.carousel-inner img, +.carousel-inner iframe { min-width: 100%; } +.carousel-inner iframe { + display: block; + width: calc(100% - 140px); + min-width: 0; + aspect-ratio: 16 / 9; + margin: 0 70px; +} + @media screen and (max-width: 991px) { span.control-text { display: none !important; diff --git a/nextcloudappstore/core/templates/app/detail.html b/nextcloudappstore/core/templates/app/detail.html index 8d19a958097..a674ac7fb6c 100644 --- a/nextcloudappstore/core/templates/app/detail.html +++ b/nextcloudappstore/core/templates/app/detail.html @@ -21,27 +21,43 @@ {% endblock %} {% block apps %} - {% if object.screenshots.all %} + {% with videos=object.videos.all screenshots=object.screenshots.all %} + {% with media_count=videos.count|add:screenshots.count %} + {% if media_count %} {% endif %} + {% endwith %} + {% endwith %} {% if object.is_server_bundled %}