Compare commits

...

5 commits

Author SHA1 Message Date
16a509ee20 add view tests if logged in
All checks were successful
continuous-integration/drone/push Build is passing
2022-06-02 01:58:28 +02:00
48cecbb621 add main page, protect feed 2022-06-02 01:41:48 +02:00
55d7d5f3b2 add tests for views and feed 2022-05-28 22:16:25 +02:00
630b29951a add unique id field to channels and videos 2022-05-26 20:45:18 +02:00
2bb670a5c6 add podcast feed 2022-05-26 00:49:26 +02:00
46 changed files with 1439 additions and 211 deletions

View file

@ -10,5 +10,5 @@ max_line_length = 88
[{Makefile,*.go}] [{Makefile,*.go}]
indent_style = tab indent_style = tab
[*.{json,md,rst,ini,yml,yaml}] [*.{json,md,rst,ini,yml,yaml,html,js,jsx,ts,tsx,vue}]
indent_size = 2 indent_size = 2

2
.env
View file

@ -1,2 +0,0 @@
UCAST_DEBUG=True
UCAST_WORKDIR=_run

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
UCAST_DEBUG=True
UCAST_WORKDIR=_run
UCAST_ALLOWED_HOSTS=localhost,127.0.0.1

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ node_modules
.ipynb_checkpoints .ipynb_checkpoints
# Application data # Application data
/.env
/_run* /_run*
*.sqlite3 *.sqlite3

View file

@ -1,9 +1,8 @@
// 1. Import the initial variables
@import "../../node_modules/bulma/sass/utilities/initial-variables" @import "../../node_modules/bulma/sass/utilities/initial-variables"
// 2. Set your own initial variables
// 3. Import the rest of Bulma
@import "../../node_modules/bulma/bulma" @import "../../node_modules/bulma/bulma"
// 4. Import your stuff here .channel-icon
max-height: 64px
.video-thumbnail
max-height: 128px

View file

@ -12,3 +12,11 @@ services:
- "127.0.0.1:9181:9181" - "127.0.0.1:9181:9181"
environment: environment:
RQ_DASHBOARD_REDIS_URL: "redis://redis:6379" RQ_DASHBOARD_REDIS_URL: "redis://redis:6379"
nginx:
image: nginx:1
network_mode: "host"
volumes:
- "./nginx:/etc/nginx/conf.d:ro"
- "../_run/static:/static:ro"
- "../_run/data:/files:ro"

26
deploy/nginx/ucast.conf Normal file
View file

@ -0,0 +1,26 @@
server {
listen 80;
server_name localhost;
client_max_body_size 1M;
# serve media files
location /static/ {
alias /static/;
}
location /internal_files/ {
internal;
alias /files/;
}
location / {
proxy_set_header Host $http_host;
proxy_pass http://127.0.0.1:8000;
}
# location /errors/ {
# alias /etc/nginx/conf.d/errorpages/;
# internal;
# }
}

34
notes/Feed.md Normal file
View file

@ -0,0 +1,34 @@
Django-Klasse: `django.utils.feedgenerator.Rss201rev2Feed`
### Channel-Attribute
| Tag | Beschreibung | Django-Attribut |
|--------------------------------------|-----------------------------------------|----------------------|
| `\<atom:link href="" rel="self">` | Feed-URL | `feed_url` |
| `\<title>` | Kanalname | `title` |
| `\<language>` | Sprache | `language` |
| `\<lastBuildDate>` | Datum der letzten Veränderung des Feeds | `latest_post_date()` |
| `\<description>` | Kanalbeschreibung | `description` |
| `\<link>` | Link zum Kanal | `link` |
| `\<copyright>` | Autor | `feed_copyright` |
| `\<image><url><title><link></image>` | Cover-URL / Kanalname / Link | - |
| `\<itunes:image href="">` | Cover-URL | - |
| `\<itunes:author>` | Autor | - |
| `\<itunes:summary>` | Kanalbeschreibung | - |
### Item-Attribute
| Tag | Beschreibung | Django-Attribut |
|--------------------------------------------------|------------------------|-----------------|
| `\<title>` | Titel | `title` |
| `\<itunes:title>` | Titel | - |
| `\<description>` | Beschreibung | `description` |
| `\<pubDate>` | Veröffentlichungsdatum | `pubdate` |
| `\<link>` | Link | `link` |
| `\<guid>` | Eindeutige ID/ | `unique_id` |
| `\<itunes:summary>` | Bechreibung | - |
| `\<itunes:author>` | Autor | - |
| `\<enclosure url="" type="audio/mpeg" length=1>` | Audiodatei | `enclosures ` |
| `\<itunes:duration>00:40:35</itunes:duration>` | Dauer | - |
| `\<itunes:image href="">` | Cover-URL | - |

47
poetry.lock generated
View file

@ -203,21 +203,20 @@ django = ">=2.2"
[[package]] [[package]]
name = "fakeredis" name = "fakeredis"
version = "1.7.5" version = "1.8"
description = "Fake implementation of redis API for testing purposes." description = "Fake implementation of redis API for testing purposes."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7,<4.0"
[package.dependencies] [package.dependencies]
packaging = "*"
redis = "<=4.3.1" redis = "<=4.3.1"
six = ">=1.12" six = ">=1.16.0,<2.0.0"
sortedcontainers = "*" sortedcontainers = ">=2.4.0,<3.0.0"
[package.extras] [package.extras]
aioredis = ["aioredis"] aioredis = ["aioredis (>=2.0.1,<3.0.0)"]
lua = ["lupa"] lua = ["lupa (>=1.13,<2.0)"]
[[package]] [[package]]
name = "feedparser" name = "feedparser"
@ -307,19 +306,6 @@ category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "mock"
version = "4.0.3"
description = "Rolling backport of unittest.mock for all Pythons"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.extras]
build = ["twine", "wheel", "blurb"]
docs = ["sphinx"]
test = ["pytest (<5.4)", "pytest-cov"]
[[package]] [[package]]
name = "mutagen" name = "mutagen"
version = "1.45.1" version = "1.45.1"
@ -594,14 +580,6 @@ urllib3 = ">=1.21.1,<1.27"
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
name = "rfeed"
version = "1.1.1"
description = "Python RSS 2.0 Generator"
category = "main"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "rq" name = "rq"
version = "1.10.1" version = "1.10.1"
@ -765,7 +743,7 @@ websockets = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "ad3a5ecd6fc1152dfdfda51ed1e401ec11a048661a04f42985c15bc28e8eda9f" content-hash = "1d1799636eadf391bd9545eb3d8750a1e882cdb9b84d47395284d90a3cfeb609"
[metadata.files] [metadata.files]
asgiref = [ asgiref = [
@ -1020,8 +998,8 @@ django-bulma = [
{file = "django_bulma-0.8.3-py3-none-any.whl", hash = "sha256:0ef6e5c171c2a32010e724a8be61ba6cd0e55ebbd242cf6780560518483c4d00"}, {file = "django_bulma-0.8.3-py3-none-any.whl", hash = "sha256:0ef6e5c171c2a32010e724a8be61ba6cd0e55ebbd242cf6780560518483c4d00"},
] ]
fakeredis = [ fakeredis = [
{file = "fakeredis-1.7.5-py3-none-any.whl", hash = "sha256:c4ca2be686e7e7637756ccc7dcad8472a5e4866b065431107d7a4b7a250d4e6f"}, {file = "fakeredis-1.8-py3-none-any.whl", hash = "sha256:65dcd78c0cd29d17daccce9f58698f6ab61ad7a404eab373fcad2b76fe8db03d"},
{file = "fakeredis-1.7.5.tar.gz", hash = "sha256:49375c630981dd4045d9a92e2709fcd4476c91f927e0228493eefa625e705133"}, {file = "fakeredis-1.8.tar.gz", hash = "sha256:cbf8d74ae06672d40b2fa88b9ee4f1d6efd56b06b2e7f0be2c639647f00643f1"},
] ]
feedparser = [ feedparser = [
{file = "feedparser-6.0.10-py3-none-any.whl", hash = "sha256:79c257d526d13b944e965f6095700587f27388e50ea16fd245babe4dfae7024f"}, {file = "feedparser-6.0.10-py3-none-any.whl", hash = "sha256:79c257d526d13b944e965f6095700587f27388e50ea16fd245babe4dfae7024f"},
@ -1061,10 +1039,6 @@ invoke = [
{file = "invoke-1.7.1-py3-none-any.whl", hash = "sha256:2dc975b4f92be0c0a174ad2d063010c8a1fdb5e9389d69871001118b4fcac4fb"}, {file = "invoke-1.7.1-py3-none-any.whl", hash = "sha256:2dc975b4f92be0c0a174ad2d063010c8a1fdb5e9389d69871001118b4fcac4fb"},
{file = "invoke-1.7.1.tar.gz", hash = "sha256:7b6deaf585eee0a848205d0b8c0014b9bf6f287a8eb798818a642dff1df14b19"}, {file = "invoke-1.7.1.tar.gz", hash = "sha256:7b6deaf585eee0a848205d0b8c0014b9bf6f287a8eb798818a642dff1df14b19"},
] ]
mock = [
{file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"},
{file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"},
]
mutagen = [ mutagen = [
{file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"}, {file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"},
{file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"}, {file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"},
@ -1261,9 +1235,6 @@ requests = [
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
{file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
] ]
rfeed = [
{file = "rfeed-1.1.1.tar.gz", hash = "sha256:aa9506f2866b74f5a322d394a14a63c19a6825c2d94755ff19d46dd1e2434819"},
]
rq = [ rq = [
{file = "rq-1.10.1-py2.py3-none-any.whl", hash = "sha256:92f4cf38b2364c1697b541e77c0fe62b7e5242fa864324f262be126ee2a07e3a"}, {file = "rq-1.10.1-py2.py3-none-any.whl", hash = "sha256:92f4cf38b2364c1697b541e77c0fe62b7e5242fa864324f262be126ee2a07e3a"},
{file = "rq-1.10.1.tar.gz", hash = "sha256:62d06b44c3acfa5d1933c5a4ec3fbc2484144a8af60e318d0b8447c5236271e2"}, {file = "rq-1.10.1.tar.gz", hash = "sha256:62d06b44c3acfa5d1933c5a4ec3fbc2484144a8af60e318d0b8447c5236271e2"},

View file

@ -13,7 +13,6 @@ python = "^3.10"
Django = "^4.0.4" Django = "^4.0.4"
yt-dlp = "^2022.3.8" yt-dlp = "^2022.3.8"
requests = "^2.27.1" requests = "^2.27.1"
rfeed = "^1.1.1"
feedparser = "^6.0.8" feedparser = "^6.0.8"
Pillow = "^9.1.0" Pillow = "^9.1.0"
colorthief = "^0.2.1" colorthief = "^0.2.1"
@ -28,6 +27,7 @@ python-slugify = "^6.1.2"
mutagen = "^1.45.1" mutagen = "^1.45.1"
rq = "^1.10.1" rq = "^1.10.1"
rq-scheduler = "^0.11.0" rq-scheduler = "^0.11.0"
pycryptodomex = "^3.14.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.1.1" pytest = "^7.1.1"

View file

@ -130,3 +130,19 @@ def worker(c, n=2):
m.loop() m.loop()
sys.exit(m.returncode) sys.exit(m.returncode)
@task
def optimize_svg(c):
out_dir = Path("ucast/static/ucast")
for icon in (Path("assets/icons/logo.svg"), Path("assets/icons/logo_dark.svg")):
c.run(
f"scour --indent=none --no-line-breaks --enable-comment-stripping {icon} {out_dir / icon.name}"
)
@task
def build_sass(c):
c.run("npm run build")
collectstatic(c)

View file

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from ucast.models import Channel, Video from ucast.models import Channel, User, Video
class ChannelAdmin(admin.ModelAdmin): class ChannelAdmin(admin.ModelAdmin):
@ -14,3 +14,4 @@ class VideoAdmin(admin.ModelAdmin):
admin.site.register(Channel, ChannelAdmin) admin.site.register(Channel, ChannelAdmin)
admin.site.register(Video, VideoAdmin) admin.site.register(Video, VideoAdmin)
admin.site.register(User)

192
ucast/feed.py Normal file
View file

@ -0,0 +1,192 @@
import re
from xml.sax import saxutils
from django import http
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.syndication.views import Feed, add_domain
from django.utils import feedgenerator
from django.utils.feedgenerator import Rss201rev2Feed, rfc2822_date
from django.utils.xmlutils import SimplerXMLGenerator
from ucast.models import Channel, Video
from ucast.service import util
URL_REGEX = r"""http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"""
class PodcastFeedType(Rss201rev2Feed):
content_type = "application/xml; charset=utf-8"
def rss_attributes(self):
attrs = super().rss_attributes()
attrs["xmlns:itunes"] = "http://www.itunes.com/dtds/podcast-1.0.dtd"
return attrs
@staticmethod
def _xml_escape(text: str) -> str:
text = saxutils.escape(text)
text = re.sub(URL_REGEX, lambda m: f'<a href="{m[0]}">{m[0]}</a>', text)
text = text.replace("\n", "<br>")
return text
@staticmethod
def _add_text_element(handler: SimplerXMLGenerator, text: str):
handler.startElement("description", {})
handler.ignorableWhitespace(f"<![CDATA[{PodcastFeedType._xml_escape(text)}]]>")
handler.endElement("description")
@staticmethod
def _format_secs(secs: int) -> str:
mm, ss = divmod(secs, 60)
hh, mm = divmod(mm, 60)
s = "%02d:%02d:%02d" % (hh, mm, ss)
return s
def add_root_elements(self, handler: SimplerXMLGenerator):
handler.addQuickElement("title", self.feed["title"])
handler.addQuickElement("link", self.feed["link"])
self._add_text_element(handler, self.feed["description"])
if self.feed["feed_url"] is not None:
handler.addQuickElement(
"atom:link", None, {"rel": "self", "href": self.feed["feed_url"]}
)
if self.feed["language"] is not None:
handler.addQuickElement("language", self.feed["language"])
for cat in self.feed["categories"]:
handler.addQuickElement("category", cat)
if self.feed["feed_copyright"] is not None:
handler.addQuickElement("copyright", self.feed["feed_copyright"])
handler.addQuickElement("lastBuildDate", rfc2822_date(self.latest_post_date()))
if self.feed["ttl"] is not None:
handler.addQuickElement("ttl", self.feed["ttl"])
if self.feed.get("image_url") is not None:
handler.startElement("image", {})
handler.addQuickElement("url", self.feed["image_url"])
handler.addQuickElement("title", self.feed["title"])
handler.addQuickElement("link", self.feed["link"])
handler.endElement("image")
handler.addQuickElement(
"itunes:image", None, {"href": self.feed["image_url"]}
)
def add_item_elements(self, handler: SimplerXMLGenerator, item):
handler.addQuickElement("title", item["title"])
handler.addQuickElement("link", item["link"])
if item["description"] is not None:
self._add_text_element(handler, item["description"])
# Author information.
if item["author_name"] and item["author_email"]:
handler.addQuickElement(
"author", "%s (%s)" % (item["author_email"], item["author_name"])
)
elif item["author_email"]:
handler.addQuickElement("author", item["author_email"])
elif item["author_name"]:
handler.addQuickElement(
"dc:creator",
item["author_name"],
{"xmlns:dc": "http://purl.org/dc/elements/1.1/"},
)
if item["pubdate"] is not None:
handler.addQuickElement("pubDate", rfc2822_date(item["pubdate"]))
if item["comments"] is not None:
handler.addQuickElement("comments", item["comments"])
if item["unique_id"] is not None:
guid_attrs = {}
if isinstance(item.get("unique_id_is_permalink"), bool):
guid_attrs["isPermaLink"] = str(item["unique_id_is_permalink"]).lower()
handler.addQuickElement("guid", item["unique_id"], guid_attrs)
if item["ttl"] is not None:
handler.addQuickElement("ttl", item["ttl"])
# Enclosure.
if item["enclosures"]:
enclosures = list(item["enclosures"])
if len(enclosures) > 1:
raise ValueError(
"RSS feed items may only have one enclosure, see "
"http://www.rssboard.org/rss-profile#element-channel-item-enclosure"
)
enclosure = enclosures[0]
handler.addQuickElement(
"enclosure",
"",
{
"url": enclosure.url,
"length": enclosure.length,
"type": enclosure.mime_type,
},
)
# Categories.
for cat in item["categories"]:
handler.addQuickElement("category", cat)
# Cover image
if item.get("image_url"):
handler.addQuickElement("itunes:image", None, {"href": item["image_url"]})
# Duration
if item.get("duration"):
handler.addQuickElement(
"itunes:duration", self._format_secs(item["duration"])
)
class UcastFeed(Feed):
feed_type = PodcastFeedType
def get_object(self, request, *args, **kwargs):
channel_slug = kwargs["channel"]
return Channel.objects.get(slug=channel_slug)
def get_feed(self, channel: Channel, request: http.HttpRequest):
feed = self.feed_type(
title=channel.name,
link=channel.get_absolute_url(),
description=channel.description,
language=self.language,
feed_url=self.full_link_url(request, f"/feed/{channel.slug}"),
image_url=self.full_link_url(request, f"/files/avatar/{channel.slug}.jpg"),
)
for video in channel.video_set.order_by("-published"):
feed.add_item(
title=video.title,
link=video.get_absolute_url(),
description=video.description,
unique_id=video.get_absolute_url(),
unique_id_is_permalink=True,
enclosures=self.item_enclosures_domain(video, request),
pubdate=video.published,
updateddate=video.downloaded,
image_url=self.full_link_url(
request, f"/files/cover/{channel.slug}/{video.slug}.png"
),
duration=video.duration,
)
return feed
@staticmethod
def full_link_url(request: http.HttpRequest, page_url: str) -> str:
anon_url = add_domain(
get_current_site(request).domain,
page_url,
request.is_secure(),
)
return util.add_key_to_url(anon_url, request.user.get_feed_key())
def item_enclosures_domain(self, item: Video, request: http.HttpRequest):
enc = feedgenerator.Enclosure(
url=self.full_link_url(
request, f"/files/audio/{item.channel.slug}/{item.slug}.mp3"
),
length=str(item.download_size),
mime_type="audio/mpg",
)
return [enc]

5
ucast/forms.py Normal file
View file

@ -0,0 +1,5 @@
from django import forms
class AddChannelForm(forms.Form):
channel_str = forms.CharField(label="Channel-ID / URL")

View file

@ -1,6 +1,9 @@
# Generated by Django 4.0.4 on 2022-05-05 00:02 # Generated by Django 4.0.4 on 2022-05-29 21:01
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models from django.db import migrations, models
@ -8,7 +11,9 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [] dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
@ -16,15 +21,21 @@ class Migration(migrations.Migration):
fields=[ fields=[
( (
"id", "id",
models.CharField(max_length=30, primary_key=True, serialize=False), models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
), ),
("channel_id", models.CharField(max_length=30)),
("name", models.CharField(max_length=100)), ("name", models.CharField(max_length=100)),
("slug", models.CharField(max_length=100)), ("slug", models.CharField(max_length=100)),
("description", models.TextField()), ("description", models.TextField()),
("subscribers", models.CharField(max_length=20, null=True)),
("active", models.BooleanField(default=True)), ("active", models.BooleanField(default=True)),
("skip_livestreams", models.BooleanField(default=True)), ("skip_livestreams", models.BooleanField(default=True)),
("skip_shorts", models.BooleanField(default=True)), ("skip_shorts", models.BooleanField(default=True)),
("keep_videos", models.IntegerField(default=None, null=True)),
("avatar_url", models.CharField(max_length=250, null=True)), ("avatar_url", models.CharField(max_length=250, null=True)),
], ],
), ),
@ -33,16 +44,16 @@ class Migration(migrations.Migration):
fields=[ fields=[
( (
"id", "id",
models.CharField(max_length=30, primary_key=True, serialize=False), models.BigAutoField(
), auto_created=True,
("title", models.CharField(max_length=200)), primary_key=True,
("slug", models.CharField(max_length=209)), serialize=False,
( verbose_name="ID",
"channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="ucast.channel"
), ),
), ),
("video_id", models.CharField(max_length=30)),
("title", models.CharField(max_length=200)),
("slug", models.CharField(max_length=209)),
("published", models.DateTimeField()), ("published", models.DateTimeField()),
("downloaded", models.DateTimeField(null=True)), ("downloaded", models.DateTimeField(null=True)),
("description", models.TextField()), ("description", models.TextField()),
@ -50,6 +61,127 @@ class Migration(migrations.Migration):
("is_livestream", models.BooleanField(default=False)), ("is_livestream", models.BooleanField(default=False)),
("is_short", models.BooleanField(default=False)), ("is_short", models.BooleanField(default=False)),
("download_size", models.IntegerField(null=True)), ("download_size", models.IntegerField(null=True)),
(
"channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="ucast.channel"
),
),
],
),
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
("feed_key", models.CharField(default=None, max_length=50, null=True)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
], ],
), ),
] ]

View file

@ -1,5 +1,8 @@
import base64
import datetime import datetime
from Cryptodome import Random
from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from ucast.service import util from ucast.service import util
@ -28,14 +31,14 @@ def _get_unique_slug(
class Channel(models.Model): class Channel(models.Model):
id = models.CharField(max_length=30, primary_key=True) channel_id = models.CharField(max_length=30)
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
slug = models.CharField(max_length=100) slug = models.CharField(max_length=100)
description = models.TextField() description = models.TextField()
subscribers = models.CharField(max_length=20, null=True)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
skip_livestreams = models.BooleanField(default=True) skip_livestreams = models.BooleanField(default=True)
skip_shorts = models.BooleanField(default=True) skip_shorts = models.BooleanField(default=True)
keep_videos = models.IntegerField(null=True, default=None)
avatar_url = models.CharField(max_length=250, null=True) avatar_url = models.CharField(max_length=250, null=True)
@classmethod @classmethod
@ -43,17 +46,34 @@ class Channel(models.Model):
return _get_unique_slug(name, cls.objects, "channel") return _get_unique_slug(name, cls.objects, "channel")
def get_full_description(self) -> str: def get_full_description(self) -> str:
desc = f"https://www.youtube.com/channel/{self.id}" desc = f"https://www.youtube.com/channel/{self.channel_id}"
if self.description: if self.description:
desc = f"{self.description}\n\n{desc}" desc = f"{self.description}\n\n{desc}"
return desc return desc
def get_absolute_url(self) -> str:
return "https://www.youtube.com/channel/" + self.channel_id
def should_download(self, video: "Video") -> bool:
if self.skip_livestreams and video.is_livestream:
return False
if self.skip_shorts and video.is_short:
return False
return True
def download_size(self) -> int:
return self.video_set.aggregate(models.Sum("download_size")).get(
"download_size__sum"
)
def __str__(self): def __str__(self):
return self.name return self.name
class Video(models.Model): class Video(models.Model):
id = models.CharField(max_length=30, primary_key=True) video_id = models.CharField(max_length=30)
title = models.CharField(max_length=200) title = models.CharField(max_length=200)
slug = models.CharField(max_length=209) slug = models.CharField(max_length=209)
channel = models.ForeignKey(Channel, on_delete=models.CASCADE) channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
@ -70,14 +90,37 @@ class Video(models.Model):
title_w_date = f"{date.strftime('%Y%m%d')}_{title}" title_w_date = f"{date.strftime('%Y%m%d')}_{title}"
return _get_unique_slug( return _get_unique_slug(
title_w_date, cls.objects.filter(channel_id=channel_id), "video" title_w_date, cls.objects.filter(channel__channel_id=channel_id), "video"
) )
def get_full_description(self) -> str: def get_full_description(self) -> str:
desc = f"https://youtu.be/{self.id}" desc = f"https://youtu.be/{self.video_id}"
if self.description: if self.description:
desc = f"{self.description}\n\n{desc}" desc = f"{self.description}\n\n{desc}"
return desc return desc
def get_absolute_url(self) -> str:
return f"https://www.youtube.com/watch?v={self.video_id}"
def __str__(self): def __str__(self):
return self.title return self.title
class User(AbstractUser):
feed_key = models.CharField(max_length=50, null=True, default=None)
def generate_feed_key(self):
for _ in range(0, User.objects.count()):
key = base64.urlsafe_b64encode(Random.get_random_bytes(18)).decode()
if not User.objects.filter(feed_key=key).exists():
self.feed_key = key
self.save()
return
raise Exception("unique feed key could not be found")
def get_feed_key(self) -> str:
if self.feed_key is None:
self.generate_feed_key()
return self.feed_key

View file

@ -52,6 +52,12 @@ class Storage:
self.dir_data = settings.DOWNLOAD_ROOT self.dir_data = settings.DOWNLOAD_ROOT
def get_channel_folder(self, channel_slug: str) -> ChannelFolder: def get_channel_folder(self, channel_slug: str) -> ChannelFolder:
cf = ChannelFolder(self.dir_data / channel_slug)
if not cf.does_exist():
raise FileNotFoundError
return cf
def get_or_create_channel_folder(self, channel_slug: str) -> ChannelFolder:
cf = ChannelFolder(self.dir_data / channel_slug) cf = ChannelFolder(self.dir_data / channel_slug)
if not cf.does_exist(): if not cf.does_exist():
cf.create() cf.create()

View file

@ -3,6 +3,7 @@ import io
import json import json
from pathlib import Path from pathlib import Path
from typing import Any, Union from typing import Any, Union
from urllib import parse
import requests import requests
import slugify import slugify
@ -108,3 +109,57 @@ def to_json(o, pretty=False) -> str:
return json.dumps( return json.dumps(
o, default=serializer, indent=2 if pretty else None, ensure_ascii=False o, default=serializer, indent=2 if pretty else None, ensure_ascii=False
) )
def _urlencode(query, safe="", encoding=None, errors=None, quote_via=parse.quote_plus):
"""
Same as the urllib.parse.urlencode function, but does not add an
equals sign to no-value flags.
"""
if hasattr(query, "items"):
query = query.items()
else:
# It's a bother at times that strings and string-like objects are
# sequences.
try:
# non-sequence items should not work with len()
# non-empty strings will fail this
if len(query) and not isinstance(query[0], tuple):
raise TypeError
# Zero-length sequences of all types will get here and succeed,
# but that's a minor nit. Since the original implementation
# allowed empty dicts that type of behavior probably should be
# preserved for consistency
except TypeError:
raise TypeError("not a valid non-string sequence " "or mapping object")
lst = []
for k, v in query:
if isinstance(k, bytes):
k = quote_via(k, safe)
else:
k = quote_via(str(k), safe, encoding, errors)
if isinstance(v, bytes):
v = quote_via(v, safe)
else:
v = quote_via(str(v), safe, encoding, errors)
if v:
lst.append(k + "=" + v)
else:
lst.append(k)
return "&".join(lst)
def add_key_to_url(url: str, key: str):
if not key:
return url
url_parts = list(parse.urlparse(url))
query = dict(parse.parse_qsl(url_parts[4], keep_blank_values=True))
query["key"] = key
url_parts[4] = _urlencode(query)
return parse.urlunparse(url_parts)

View file

@ -1,19 +1,25 @@
from datetime import date
from pathlib import Path from pathlib import Path
from mutagen import id3 from mutagen import id3
from ucast.models import Video
def tag_audio(
def tag_audio(audio_path: Path, video: Video, cover_path: Path): audio_path: Path,
title_text = f"{video.published.date().isoformat()} {video.title}" title: str,
channel: str,
published: date,
description: str,
cover_path: Path,
):
title_text = f"{published.isoformat()} {title}"
tag = id3.ID3(audio_path) tag = id3.ID3(audio_path)
tag["TPE1"] = id3.TPE1(encoding=3, text=video.channel.name) # Artist tag["TPE1"] = id3.TPE1(encoding=3, text=channel) # Artist
tag["TALB"] = id3.TALB(encoding=3, text=video.channel.name) # Album tag["TALB"] = id3.TALB(encoding=3, text=channel) # Album
tag["TIT2"] = id3.TIT2(encoding=3, text=title_text) # Title tag["TIT2"] = id3.TIT2(encoding=3, text=title_text) # Title
tag["TDRC"] = id3.TDRC(encoding=3, text=video.published.date().isoformat()) # Date tag["TDRC"] = id3.TDRC(encoding=3, text=published.isoformat()) # Date
tag["COMM"] = id3.COMM(encoding=3, text=video.get_full_description()) # Comment tag["COMM"] = id3.COMM(encoding=3, text=description) # Comment
with open(cover_path, "rb") as albumart: with open(cover_path, "rb") as albumart:
tag["APIC"] = id3.APIC( tag["APIC"] = id3.APIC(

View file

@ -5,7 +5,7 @@ import shutil
from dataclasses import dataclass from dataclasses import dataclass
from operator import itemgetter from operator import itemgetter
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import Generator, List, Optional
import feedparser import feedparser
import requests import requests
@ -96,6 +96,7 @@ class ChannelMetadata:
name: str name: str
description: str description: str
avatar_url: str avatar_url: str
subscribers: Optional[str]
def download_thumbnail(vinfo: VideoDetails, download_path: Path): def download_thumbnail(vinfo: VideoDetails, download_path: Path):
@ -215,6 +216,15 @@ def get_channel_metadata(channel_url: str) -> ChannelMetadata:
name = metadata["title"] name = metadata["title"]
description = metadata["description"].strip() description = metadata["description"].strip()
avatar = metadata["avatar"]["thumbnails"][0]["url"] avatar = metadata["avatar"]["thumbnails"][0]["url"]
subscribers = None
# The subscriber count is not always visible
try:
raw_subscribers = data["header"]["c4TabbedHeaderRenderer"][
"subscriberCountText"
]["simpleText"]
subscribers = raw_subscribers.split(" ", 1)[0]
except KeyError:
pass
if not CHANID_REGEX.match(channel_id): if not CHANID_REGEX.match(channel_id):
raise InvalidMetadataError(f"got invalid channel id {repr(channel_id)}") raise InvalidMetadataError(f"got invalid channel id {repr(channel_id)}")
@ -227,7 +237,7 @@ def get_channel_metadata(channel_url: str) -> ChannelMetadata:
f"got invalid avatar url for channel {channel_id}: {avatar}" f"got invalid avatar url for channel {channel_id}: {avatar}"
) )
return ChannelMetadata(channel_id, name, description, avatar) return ChannelMetadata(channel_id, name, description, avatar, subscribers)
def get_channel_videos_from_feed(channel_id: str) -> List[VideoScraped]: def get_channel_videos_from_feed(channel_id: str) -> List[VideoScraped]:
@ -262,16 +272,14 @@ def get_channel_videos_from_feed(channel_id: str) -> List[VideoScraped]:
def get_channel_videos_from_scraper( def get_channel_videos_from_scraper(
channel_id: str, limit: int = None channel_id: str, limit: int = None
) -> List[VideoScraped]: ) -> Generator[VideoScraped, None, None]:
""" """
Return all videos of a channel by scraping the YouTube website. Return all videos of a channel by scraping the YouTube website.
May take a while depending on the number of videos.
:param channel_id: YouTube channel id :param channel_id: YouTube channel id
:param limit: Limit number of scraped videos :param limit: Limit number of scraped videos
:return: Videos: video_id -> VideoScraped :return: Generator of Videos
""" """
videos = []
for item in scrapetube.get_channel(channel_url_from_id(channel_id), limit): for item in scrapetube.get_channel(channel_url_from_id(channel_id), limit):
video_id = item.get("videoId") video_id = item.get("videoId")
@ -281,6 +289,4 @@ def get_channel_videos_from_scraper(
) )
continue continue
videos.append(VideoScraped(video_id, None)) yield VideoScraped(video_id, None)
return videos

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="svg5" width="68.5mm" height="15.79mm" version="1.1" viewBox="0 0 68.5 15.79" xmlns="http://www.w3.org/2000/svg"><g id="layer1" transform="translate(-1.4688 -18.46)" fill="none" stroke-linecap="square"><path id="path3041" d="m67.469 21.167h-10.583" stroke="#282828"/><path id="path3043" d="m62.177 21.445v10.305" stroke="#282828" stroke-width=".98677"/><path id="path3572" d="m3.9688 21.167v6.6146l3.9687 3.9688h2.6458l3.9688-3.9688v-6.6146" stroke="#e00"/><path id="path3687" d="m27.781 21.167h-6.6146l-3.9688 3.9688v2.6458l3.9688 3.9688h6.6146" stroke="#282828"/><path id="path3802" d="m30.427 31.75v-5.2917l5.2917-5.2917 5.2917 5.2917v5.2917" stroke="#282828"/><path id="path3954" d="m54.24 21.167h-7.9375l-2.6458 2.6458 2.6458 2.6458h5.2917l2.6458 2.6458-2.6458 2.6458h-7.9375" stroke="#282828"/></g></svg>

After

Width:  |  Height:  |  Size: 858 B

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="svg5" width="68.5mm" height="15.79mm" version="1.1" viewBox="0 0 68.5 15.79" xmlns="http://www.w3.org/2000/svg"><g id="layer1" transform="translate(-1.4688 -18.46)" fill="none" stroke-linecap="square"><path id="path3041" d="m67.469 21.167h-10.583" stroke="#fff"/><path id="path3043" d="m62.177 21.445v10.305" stroke="#fff" stroke-width=".98677"/><path id="path3572" d="m3.9688 21.167v6.6146l3.9687 3.9688h2.6458l3.9688-3.9688v-6.6146" stroke="#e00"/><path id="path3687" d="m27.781 21.167h-6.6146l-3.9688 3.9688v2.6458l3.9688 3.9688h6.6146" stroke="#fff"/><path id="path3802" d="m30.427 31.75v-5.2917l5.2917-5.2917 5.2917 5.2917v5.2917" stroke="#fff"/><path id="path3954" d="m54.24 21.167h-7.9375l-2.6458 2.6458 2.6458 2.6458h5.2917l2.6458 2.6458-2.6458 2.6458h-7.9375" stroke="#fff"/></g></svg>

After

Width:  |  Height:  |  Size: 843 B

View file

@ -7,48 +7,55 @@ from ucast.models import Channel, Video
from ucast.service import cover, storage, util, videoutil, youtube from ucast.service import cover, storage, util, videoutil, youtube
def _get_or_create_channel(channel_id: str) -> Channel: def _get_or_create_channel(channel_str: str) -> Channel:
if youtube.CHANID_REGEX.match(channel_str):
try:
return Channel.objects.get(channel_id=channel_str)
except Channel.DoesNotExist:
pass
channel_url = youtube.channel_url_from_str(channel_str)
channel_data = youtube.get_channel_metadata(channel_url)
try: try:
return Channel.objects.get(id=channel_id) return Channel.objects.get(channel_id=channel_data.id)
except Channel.DoesNotExist: except Channel.DoesNotExist:
channel_data = youtube.get_channel_metadata( pass
youtube.channel_url_from_id(channel_id)
)
channel_slug = Channel.get_new_slug(channel_data.name)
store = storage.Storage()
channel_folder = store.get_channel_folder(channel_slug)
util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar) channel_slug = Channel.get_new_slug(channel_data.name)
util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm) store = storage.Storage()
channel_folder = store.get_or_create_channel_folder(channel_slug)
channel = Channel( util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar)
id=channel_id, util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm)
name=channel_data.name,
slug=channel_slug, channel = Channel(
description=channel_data.description, channel_id=channel_data.id,
) name=channel_data.name,
channel.save() slug=channel_slug,
return channel description=channel_data.description,
subscribers=channel_data.subscribers,
)
channel.save()
return channel
def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel): def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
if Video.objects.filter(id=vid.id).exists(): if Video.objects.filter(video_id=vid.id).exists():
return return
details = youtube.get_video_details(vid.id) details = youtube.get_video_details(vid.id)
# Check filter # Dont load active livestreams
if ( if details.is_currently_live:
details.is_currently_live
or (details.is_short and channel.skip_shorts)
or (details.is_livestream and channel.skip_livestreams)
):
return return
slug = Video.get_new_slug(details.title, details.published.date(), channel.id) slug = Video.get_new_slug(
details.title, details.published.date(), channel.channel_id
)
video = Video( video = Video(
id=details.id, video_id=details.id,
title=details.title, title=details.title,
slug=slug, slug=slug,
channel=channel, channel=channel,
@ -60,7 +67,8 @@ def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
) )
video.save() video.save()
queue.enqueue(download_video, video) if channel.should_download(video):
queue.enqueue(download_video, video)
def download_video(video: Video): def download_video(video: Video):
@ -71,10 +79,10 @@ def download_video(video: Video):
:param video: Video object :param video: Video object
""" """
store = storage.Storage() store = storage.Storage()
channel_folder = store.get_channel_folder(video.channel.slug) channel_folder = store.get_or_create_channel_folder(video.channel.slug)
audio_file = channel_folder.get_audio(video.slug) audio_file = channel_folder.get_audio(video.slug)
details = youtube.download_audio(video.id, audio_file) details = youtube.download_audio(video.video_id, audio_file)
# Download/convert thumbnails # Download/convert thumbnails
tn_path = channel_folder.get_thumbnail(video.slug) tn_path = channel_folder.get_thumbnail(video.slug)
@ -90,34 +98,39 @@ def download_video(video: Video):
cover_file, cover_file,
) )
videoutil.tag_audio(audio_file, video, cover_file) videoutil.tag_audio(
audio_file,
video.title,
video.channel.name,
video.published.date(),
video.get_full_description(),
cover_file,
)
video.downloaded = timezone.now() video.downloaded = timezone.now()
video.download_size = os.path.getsize(audio_file) video.download_size = os.path.getsize(audio_file)
video.save() video.save()
def import_channel(channel_id: str, limit: int = None): def import_channel(channel_str: str, limit: int = None):
""" """
Add a new channel to ucast and download all existing videos. Add a new channel to ucast and download all existing videos.
:param channel_id: YT-Channel-ID :param channel_str: YT-Channel-ID / URL
:param limit: Maximum number of videos to download :param limit: Maximum number of videos to download
""" """
channel = _get_or_create_channel(channel_id) channel = _get_or_create_channel(channel_str)
if limit == 0: if limit == 0:
return return
videos = youtube.get_channel_videos_from_scraper(channel_id, limit) for vid in youtube.get_channel_videos_from_scraper(channel.channel_id, limit):
for vid in videos[:limit]:
_load_scraped_video(vid, channel) _load_scraped_video(vid, channel)
def update_channel(channel: Channel): def update_channel(channel: Channel):
"""Update a single channel from its RSS feed""" """Update a single channel from its RSS feed"""
videos = youtube.get_channel_videos_from_feed(channel.id) videos = youtube.get_channel_videos_from_feed(channel.channel_id)
for vid in videos: for vid in videos:
_load_scraped_video(vid, channel) _load_scraped_video(vid, channel)

View file

@ -4,7 +4,7 @@ from django.utils import timezone
from ucast import queue from ucast import queue
from ucast.models import Channel, Video from ucast.models import Channel, Video
from ucast.service import cover, storage, util, youtube from ucast.service import cover, storage, util, videoutil, youtube
def recreate_cover(video: Video): def recreate_cover(video: Video):
@ -13,12 +13,13 @@ def recreate_cover(video: Video):
thumbnail_file = cf.get_thumbnail(video.slug) thumbnail_file = cf.get_thumbnail(video.slug)
cover_file = cf.get_cover(video.slug) cover_file = cf.get_cover(video.slug)
audio_file = cf.get_audio(video.slug)
if not os.path.isfile(cf.file_avatar): if not os.path.isfile(cf.file_avatar):
raise FileNotFoundError(f"could not find avatar for channel {video.channel_id}") raise FileNotFoundError(f"could not find avatar for channel {video.channel_id}")
if not os.path.isfile(thumbnail_file): if not os.path.isfile(thumbnail_file):
raise FileNotFoundError(f"could not find thumbnail for video {video.id}") raise FileNotFoundError(f"could not find thumbnail for video {video.video_id}")
cover.create_cover_file( cover.create_cover_file(
thumbnail_file, thumbnail_file,
@ -29,6 +30,15 @@ def recreate_cover(video: Video):
cover_file, cover_file,
) )
videoutil.tag_audio(
audio_file,
video.title,
video.channel.name,
video.published.date(),
video.get_full_description(),
cover_file,
)
def recreate_covers(): def recreate_covers():
for video in Video.objects.filter(downloaded__isnull=False): for video in Video.objects.filter(downloaded__isnull=False):
@ -39,7 +49,13 @@ def update_file_storage():
store = storage.Storage() store = storage.Storage()
for video in Video.objects.all(): for video in Video.objects.all():
cf = store.get_channel_folder(video.channel.slug) try:
cf = store.get_channel_folder(video.channel.slug)
except FileNotFoundError:
video.downloaded = None
video.download_size = None
video.save()
return
audio_file = cf.get_audio(video.slug) audio_file = cf.get_audio(video.slug)
cover_file = cf.get_cover(video.slug) cover_file = cf.get_cover(video.slug)
@ -66,11 +82,13 @@ def update_file_storage():
def update_channel_info(channel: Channel): def update_channel_info(channel: Channel):
channel_data = youtube.get_channel_metadata(youtube.channel_url_from_id(channel.id)) channel_data = youtube.get_channel_metadata(
youtube.channel_url_from_id(channel.channel_id)
)
if channel_data.avatar_url != channel.avatar_url: if channel_data.avatar_url != channel.avatar_url:
store = storage.Storage() store = storage.Storage()
channel_folder = store.get_channel_folder(channel.slug) channel_folder = store.get_or_create_channel_folder(channel.slug)
util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar) util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar)
util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm) util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm)
@ -79,6 +97,7 @@ def update_channel_info(channel: Channel):
channel.name = channel_data.name channel.name = channel_data.name
channel.description = channel_data.description channel.description = channel_data.description
channel.subscribers = channel_data.subscribers
channel.save() channel.save()

View file

@ -4,7 +4,7 @@ from datetime import datetime
from django.conf import settings from django.conf import settings
from ucast import queue from ucast import queue
from ucast.tasks import download from ucast.tasks import download, library
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -26,3 +26,10 @@ def register_scheduled_jobs():
id="schedule_update_channels", id="schedule_update_channels",
interval=settings.YT_UPDATE_INTERVAL, interval=settings.YT_UPDATE_INTERVAL,
) )
scheduler.schedule(
datetime.utcnow(),
library.update_channel_infos,
id="schedule_update_channel_infos",
interval=24 * 3600,
)

View file

@ -0,0 +1,77 @@
{% load static bulma_tags %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock title %}</title>
{% block css %}
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v6.1.1/css/all.css" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'bulma/css/style.min.css' %}">
{% block extra_css %}{% endblock extra_css %}
{% endblock css %}
</head>
<body>
{% block header %}
<section>
<nav class="navbar is-dark">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img src="{% static 'ucast/logo_dark.svg' %}">
</a>
</div>
<div class="navbar-end">
{% url 'login' as login_url %}
{% url 'logout' as logout_url %}
{% if user.is_authenticated %}
<a class="navbar-item is-hidden-desktop-only" href="{{ logout_url }}">
Logout
</a>
{% else %}
<a class="navbar-item is-hidden-desktop-only" href="{{ login_url }}">
Login
</a>
{% endif %}
</div>
</nav>
</section>
{% endblock header %}
{% block hero %}{% endblock hero %}
<section class="section">
<div class="container">
{% block messages %}
{% if messages %}
<div class="messages columns is-desktop">
<div class="column is-4 is-offset-4">
{% for message in messages %}
<div
class="message {% if message.tags %}is-{{ message.tags|bulma_message_tag }}{% endif %}">
<div class="message-body">{{ message }}</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock messages %}
{% block content_area %}
{% block content_title %}{% endblock content_title %}
{% block content %}{% endblock content %}
{% endblock content_area %}
</div>
</section>
{% block modal %}{% endblock modal %}
{% block footer %}
{% endblock footer %}
{% block javascript %}
{% block extra_javascript %}{% endblock extra_javascript %}
{% endblock javascript %}
</body>
</html>

View file

@ -0,0 +1,48 @@
{% extends 'base.html' %}
{% block title %}ucast - Channels{% endblock %}
{% block content %}
<h1 class="title">Channels</h1>
<div class="box">
<form action="" method="post">
{% csrf_token %}
<div class="field has-addons">
<div class="control is-flex-grow-1">
<input name="channel_str" required class="input" type="text" placeholder="Channel-ID / URL">
</div>
<div class="control">
<button type="submit" class="button is-primary">
<i class="fas fa-add"></i>
</button>
</div>
</div>
</form>
</div>
{% for channel in channels %}
<div class="box is-flex">
<div class="is-flex">
<a href="/channel/{{ channel.slug }}">
<img class="channel-icon" src="/files/avatar/{{ channel.slug }}.webp?sm">
</a>
</div>
<div class="ml-3 is-flex is-flex-direction-column is-flex-grow-1">
<a class="subtitle" href="/channel/{{ channel.slug }}">{{ channel.name }}</a>
<div class="field has-addons">
<div class="control is-flex-grow-1">
<input class="input" type="text"
value="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"
readonly>
</div>
<div class="control">
<button class="button is-primary">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock content %}

View file

@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Ucast</title>
</head>
<body>
<h1>Ucast</h1>
Hello World!
</body>
</html>

View file

@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% block title %}ucast - Videos{% endblock %}
{% block content %}
<div class="level">
<h1 class="title">{{ channel.name }}</h1>
<div class="tags">
<span class="tag"><i
class="fas fa-user-group"></i>&nbsp; {{ channel.subscribers }}</span>
<span class="tag"><i
class="fas fa-video"></i>&nbsp; {{ channel.video_set.count }}</span>
<span class="tag"><i
class="fas fa-database"></i>&nbsp; {{ channel.download_size|filesizeformat }}</span>
<a class="tag" href="{{ channel.get_absolute_url }}" target="_blank"><i
class="fa-brands fa-youtube"></i>&nbsp; {{ channel.channel_id }}</a>
</div>
<div class="field has-addons">
<div class="control">
<a href="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"
class="button">
<i class="fas fa-rss"></i>
</a>
</div>
<div class="control">
<button class="button is-success">
<i class="fas fa-power-off"></i>
</button>
</div>
<div class="control">
<button class="button is-info">
<i class="fas fa-edit"></i>
</button>
</div>
<div class="control">
<button class="button is-danger">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
<div class="level">
</div>
{% for video in videos %}
<div class="box is-flex">
<div class="is-flex">
<a href="{{ video.get_absolute_url }}" target="_blank">
<img class="video-thumbnail"
src="/files/thumbnail/{{ channel.slug }}/{{ video.slug }}.webp?sm">
</a>
</div>
<div class="ml-3 is-flex is-flex-direction-column is-flex-grow-1">
<a class="subtitle" href="{{ video.get_absolute_url }}"
target="_blank">{{ video.title }}</a>
<div class="tags">
<span class="tag"><i
class="fas fa-calendar"></i>&nbsp; {{ video.published|date }}</span>
<span class="tag"><i
class="fas fa-database"></i>&nbsp; {{ video.download_size|filesizeformat }}</span>
<a class="tag" href="{{ video.get_absolute_url }}" target="_blank"><i
class="fa-brands fa-youtube"></i>&nbsp; {{ video.video_id }}</a>
</div>
</div>
<div class="field has-addons">
{% if video.downloaded %}
<div class="control">
<a class="button is-success"
href="/files/audio/{{ channel.slug }}/{{ video.slug }}.mp3"
target="_blank">
<i class="fas fa-play"></i>
</a>
</div>
<div class="control">
<button class="button is-danger">
<i class="fas fa-trash"></i>
</button>
</div>
{% else %}
<div class="control">
<button class="button is-primary">
<i class="fas fa-download"></i>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% endblock content %}

View file

@ -11,7 +11,7 @@ DIR_TESTFILES = resources.path("ucast.tests", "_testfiles")
def get_video_details(video_id: str): def get_video_details(video_id: str):
with open(DIR_TESTFILES / "fixture" / "videodetails.json") as f: with open(DIR_TESTFILES / "object" / "videodetails.json") as f:
videodetails = json.load(f) videodetails = json.load(f)
vd_raw = videodetails[video_id] vd_raw = videodetails[video_id]
@ -21,7 +21,7 @@ def get_video_details(video_id: str):
def get_channel_metadata(channel_url: str): def get_channel_metadata(channel_url: str):
with open(DIR_TESTFILES / "fixture" / "channelmeta.json") as f: with open(DIR_TESTFILES / "object" / "channelmeta.json") as f:
channelmeta = json.load(f) channelmeta = json.load(f)
return youtube.ChannelMetadata(**channelmeta[channel_url]) return youtube.ChannelMetadata(**channelmeta[channel_url])

View file

@ -1,54 +1,57 @@
[ [
{ {
"model": "ucast.channel", "model": "ucast.channel",
"pk": "UCGiJh0NZ52wRhYKYnuZI08Q", "pk": 1,
"fields": { "fields": {
"channel_id": "UCGiJh0NZ52wRhYKYnuZI08Q",
"name": "ThetaDev", "name": "ThetaDev",
"slug": "ThetaDev", "slug": "ThetaDev",
"description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.", "description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.",
"subscribers": "37",
"active": true, "active": true,
"skip_livestreams": true, "skip_livestreams": true,
"skip_shorts": true, "skip_shorts": true,
"keep_videos": null,
"avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj" "avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj"
} }
}, },
{ {
"model": "ucast.channel", "model": "ucast.channel",
"pk": "UC2TXq_t06Hjdr2g_KdKpHQg", "pk": 2,
"fields": { "fields": {
"channel_id": "UC2TXq_t06Hjdr2g_KdKpHQg",
"name": "media.ccc.de", "name": "media.ccc.de",
"slug": "media_ccc_de", "slug": "media_ccc_de",
"description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)", "description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)",
"subscribers": "166K",
"active": true, "active": true,
"skip_livestreams": true, "skip_livestreams": true,
"skip_shorts": true, "skip_shorts": true,
"keep_videos": null,
"avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj" "avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj"
} }
}, },
{ {
"model": "ucast.channel", "model": "ucast.channel",
"pk": "UCmLTTbctUZobNQrr8RtX8uQ", "pk": 3,
"fields": { "fields": {
"channel_id": "UCmLTTbctUZobNQrr8RtX8uQ",
"name": "Creative Commons", "name": "Creative Commons",
"slug": "Creative_Commons", "slug": "Creative_Commons",
"description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.", "description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.",
"subscribers": null,
"active": true, "active": true,
"skip_livestreams": true, "skip_livestreams": true,
"skip_shorts": true, "skip_shorts": true,
"keep_videos": null,
"avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj" "avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj"
} }
}, },
{ {
"model": "ucast.video", "model": "ucast.video",
"pk": "ZPxEr4YdWt8", "pk": 1,
"fields": { "fields": {
"video_id": "ZPxEr4YdWt8",
"title": "ThetaDev @ Embedded World 2019", "title": "ThetaDev @ Embedded World 2019",
"slug": "20190602_ThetaDev_Embedded_World_2019", "slug": "20190602_ThetaDev_Embedded_World_2019",
"channel": "UCGiJh0NZ52wRhYKYnuZI08Q", "channel": 1,
"published": "2019-06-02T00:00:00Z", "published": "2019-06-02T00:00:00Z",
"downloaded": "2022-05-15T22:16:03.096Z", "downloaded": "2022-05-15T22:16:03.096Z",
"description": "This february I spent one day at the Embedded World in Nuremberg. They showed tons of interesting electronics stuff, so I had to take some pictures and videos for you to see ;-)\n\nSorry for the late upload, I just didn't have time to edit my footage.\n\nEmbedded World: https://www.embedded-world.de/\n\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev", "description": "This february I spent one day at the Embedded World in Nuremberg. They showed tons of interesting electronics stuff, so I had to take some pictures and videos for you to see ;-)\n\nSorry for the late upload, I just didn't have time to edit my footage.\n\nEmbedded World: https://www.embedded-world.de/\n\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev",
@ -60,11 +63,12 @@
}, },
{ {
"model": "ucast.video", "model": "ucast.video",
"pk": "_I5IFObm_-k", "pk": 2,
"fields": { "fields": {
"video_id": "_I5IFObm_-k",
"title": "Easter special: 3D printed Bunny", "title": "Easter special: 3D printed Bunny",
"slug": "20180331_Easter_special_3D_printed_Bunny", "slug": "20180331_Easter_special_3D_printed_Bunny",
"channel": "UCGiJh0NZ52wRhYKYnuZI08Q", "channel": 1,
"published": "2018-03-31T00:00:00Z", "published": "2018-03-31T00:00:00Z",
"downloaded": "2022-05-15T22:16:12.514Z", "downloaded": "2022-05-15T22:16:12.514Z",
"description": "Happy Easter 2018!\nThis is just a special video where I print a little bunny as an Easter gift for friends or relatives. I hope you like the model, too.\n\nSadly my camera doesn't support timelapses, so I had to record the whole 4h printing process in real time, resulting in 30GB of footage. But I think it was worth it ;-)\n\n__PROJECT_LINKS___________________________\nBunny: https://www.thingiverse.com/thing:287884\n\n__COMPONENT_SUPPLIERS__________________\n3D printer: https://www.prusa3d.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev", "description": "Happy Easter 2018!\nThis is just a special video where I print a little bunny as an Easter gift for friends or relatives. I hope you like the model, too.\n\nSadly my camera doesn't support timelapses, so I had to record the whole 4h printing process in real time, resulting in 30GB of footage. But I think it was worth it ;-)\n\n__PROJECT_LINKS___________________________\nBunny: https://www.thingiverse.com/thing:287884\n\n__COMPONENT_SUPPLIERS__________________\n3D printer: https://www.prusa3d.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev",
@ -76,11 +80,12 @@
}, },
{ {
"model": "ucast.video", "model": "ucast.video",
"pk": "mmEDPbbSnaY", "pk": 3,
"fields": { "fields": {
"video_id": "mmEDPbbSnaY",
"title": "ThetaDevlog#2 - MySensors singleLED", "title": "ThetaDevlog#2 - MySensors singleLED",
"slug": "20180326_ThetaDevlog_2_MySensors_singleLED", "slug": "20180326_ThetaDevlog_2_MySensors_singleLED",
"channel": "UCGiJh0NZ52wRhYKYnuZI08Q", "channel": 1,
"published": "2018-03-26T00:00:00Z", "published": "2018-03-26T00:00:00Z",
"downloaded": "2022-05-15T22:16:20.280Z", "downloaded": "2022-05-15T22:16:20.280Z",
"description": "The PCBs and components for the MySensors smart home devices arrived!\nIn this video I'll show you how to build the singleLED controller to switch/dim your 12V led lights. Detailed building instructions can be found on OpenHardware or GitHub.\n\n__PROJECT_LINKS___________________________\nOpenHardware: https://www.openhardware.io/view/563\nGitHub: https://github.com/Theta-Dev/MySensors-singleLED\n\nProgramming adapter: https://thdev.org/?Projects___misc___micro_JST\nBoard definitions: http://files.thdev.org/arduino/atmega.zip\n\n__COMPONENT_SUPPLIERS__________________\nElectronic components: https://www.aliexpress.com/\nPCBs: http://www.allpcb.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev\n______________________________________________\nMusic by Bartlebeats: https://bartlebeats.bandcamp.com", "description": "The PCBs and components for the MySensors smart home devices arrived!\nIn this video I'll show you how to build the singleLED controller to switch/dim your 12V led lights. Detailed building instructions can be found on OpenHardware or GitHub.\n\n__PROJECT_LINKS___________________________\nOpenHardware: https://www.openhardware.io/view/563\nGitHub: https://github.com/Theta-Dev/MySensors-singleLED\n\nProgramming adapter: https://thdev.org/?Projects___misc___micro_JST\nBoard definitions: http://files.thdev.org/arduino/atmega.zip\n\n__COMPONENT_SUPPLIERS__________________\nElectronic components: https://www.aliexpress.com/\nPCBs: http://www.allpcb.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev\n______________________________________________\nMusic by Bartlebeats: https://bartlebeats.bandcamp.com",
@ -92,11 +97,12 @@
}, },
{ {
"model": "ucast.video", "model": "ucast.video",
"pk": "Cda4zS-1j-k", "pk": 4,
"fields": { "fields": {
"video_id": "Cda4zS-1j-k",
"title": "ThetaDevlog#1 - MySensors Smart Home!", "title": "ThetaDevlog#1 - MySensors Smart Home!",
"slug": "20180217_ThetaDevlog_1_MySensors_Smart_Home", "slug": "20180217_ThetaDevlog_1_MySensors_Smart_Home",
"channel": "UCGiJh0NZ52wRhYKYnuZI08Q", "channel": 1,
"published": "2018-02-17T00:00:00Z", "published": "2018-02-17T00:00:00Z",
"downloaded": "2022-05-15T22:16:25.237Z", "downloaded": "2022-05-15T22:16:25.237Z",
"description": "Smart Home devices have been around for some time and can really make your life easier. But most of them are quite pricey and not always worth the money.\n\nHow about a sytem that costs only 5€ per device and has all the benefits of the expensive solutions? The open source project MySensors claims to do that. In this series I'll try this and find out whether it works!\n\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev", "description": "Smart Home devices have been around for some time and can really make your life easier. But most of them are quite pricey and not always worth the money.\n\nHow about a sytem that costs only 5€ per device and has all the benefits of the expensive solutions? The open source project MySensors claims to do that. In this series I'll try this and find out whether it works!\n\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev",
@ -108,11 +114,12 @@
}, },
{ {
"model": "ucast.video", "model": "ucast.video",
"pk": "2xfXsqyd8YA", "pk": 5,
"fields": { "fields": {
"video_id": "2xfXsqyd8YA",
"title": "cy: Log4Shell - Bug oder Feature", "title": "cy: Log4Shell - Bug oder Feature",
"slug": "20220521_cy_Log4Shell_Bug_oder_Feature", "slug": "20220521_cy_Log4Shell_Bug_oder_Feature",
"channel": "UC2TXq_t06Hjdr2g_KdKpHQg", "channel": 2,
"published": "2022-05-21T00:00:00Z", "published": "2022-05-21T00:00:00Z",
"downloaded": null, "downloaded": null,
"description": "https://media.ccc.de/v/gpn20-60-log4shell-bug-oder-feature\n\n\n\nUm den Jahreswechsel ging ein Aufschrei durch die IT-Abteilungen der Welt, der es bis in die Mainstream-Medien geschafft hat. Noch Wochen später zeigen sich Folgeprobleme in weit verbreiteter Software.\n \nIn Log4j, einer weit verbreiteten Java-Bibliothek wurde eine massive Sicherheitslücke gefunden, die die Ausführung von Schadcode auf einem entfernten System erlaubt.\nIn diesem Vortrag soll rekapitulierend erklärt werden, warum und wann es zu dem Problem kam und welche Auswirkungen bisher erkennbar sind. Ausserdem werden die technischen Details der Schwachstelle erklärt und in einer Live-Demo gezeigt, wie die Schwachstelle ausgenutzt werden kann.\n\n\n\ncy\n\nhttps://cfp.gulas.ch/gpn20/talk/77BCXN/\n\n#gpn20 #Security", "description": "https://media.ccc.de/v/gpn20-60-log4shell-bug-oder-feature\n\n\n\nUm den Jahreswechsel ging ein Aufschrei durch die IT-Abteilungen der Welt, der es bis in die Mainstream-Medien geschafft hat. Noch Wochen später zeigen sich Folgeprobleme in weit verbreiteter Software.\n \nIn Log4j, einer weit verbreiteten Java-Bibliothek wurde eine massive Sicherheitslücke gefunden, die die Ausführung von Schadcode auf einem entfernten System erlaubt.\nIn diesem Vortrag soll rekapitulierend erklärt werden, warum und wann es zu dem Problem kam und welche Auswirkungen bisher erkennbar sind. Ausserdem werden die technischen Details der Schwachstelle erklärt und in einer Live-Demo gezeigt, wie die Schwachstelle ausgenutzt werden kann.\n\n\n\ncy\n\nhttps://cfp.gulas.ch/gpn20/talk/77BCXN/\n\n#gpn20 #Security",
@ -124,11 +131,12 @@
}, },
{ {
"model": "ucast.video", "model": "ucast.video",
"pk": "I0RRENheeTo", "pk": 6,
"fields": { "fields": {
"video_id": "I0RRENheeTo",
"title": "No copyright intro free fire intro | no text | free copy right | free templates | free download", "title": "No copyright intro free fire intro | no text | free copy right | free templates | free download",
"slug": "20211010_No_copyright_intro_free_fire_intro_no_text_free_copy_right_free_templates_free_download", "slug": "20211010_No_copyright_intro_free_fire_intro_no_text_free_copy_right_free_templates_free_download",
"channel": "UCmLTTbctUZobNQrr8RtX8uQ", "channel": 3,
"published": "2021-10-10T00:00:00Z", "published": "2021-10-10T00:00:00Z",
"downloaded": null, "downloaded": null,
"description": "Like Video▬▬▬▬▬❤\uD83D\uDC4D❤\n▬▬\uD83D\uDC47SUBSCRIBE OUR CHANNEL FOR LATEST UPDATES\uD83D\uDC46▬▬\nThis Channel: https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ?sub_confirmation=1\nOther Channel: https://www.youtube.com/channel/UCKtfYFXi5A4KLIUdjgvfmHg?sub_confirmation=1\n▬▬▬▬▬▬▬▬/Subscription Free\\▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬\uD83C\uDF81...Share Video To Friends...\uD83C\uDF81▬▬▬▬▬▬▬\n▬▬▬▬\uD83E\uDD14...Comment Any Questions....\uD83E\uDD14▬▬▬▬▬▬\nHello friends, \n Shahzaib Hassan and you are watching Creative Commons YouTube channel. On this channel, you will find all the videos absolutely free copyright which you can download and use in any project.\n It is copyright free so you won't have any problem using end screen for YouTube. if you use it or download and reupload it to your channel. By doing this you can use it for YouTube its use is absolutely free.\n ►I hope you'll like the video.◄\n ►Thanks For Watching◄ \nIf you really like this video then please don't forget to...\n\n\n▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬▬▬▬▬▬Tags\uD83D\uDC47▬▬▬▬▬▬▬▬▬▬\n#Creativecommons #commoncreative #free #freecopyright #nocopyright #nowatermark #freetouse #intro #notext #fireefire #channelintro", "description": "Like Video▬▬▬▬▬❤\uD83D\uDC4D❤\n▬▬\uD83D\uDC47SUBSCRIBE OUR CHANNEL FOR LATEST UPDATES\uD83D\uDC46▬▬\nThis Channel: https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ?sub_confirmation=1\nOther Channel: https://www.youtube.com/channel/UCKtfYFXi5A4KLIUdjgvfmHg?sub_confirmation=1\n▬▬▬▬▬▬▬▬/Subscription Free\\▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬\uD83C\uDF81...Share Video To Friends...\uD83C\uDF81▬▬▬▬▬▬▬\n▬▬▬▬\uD83E\uDD14...Comment Any Questions....\uD83E\uDD14▬▬▬▬▬▬\nHello friends, \n Shahzaib Hassan and you are watching Creative Commons YouTube channel. On this channel, you will find all the videos absolutely free copyright which you can download and use in any project.\n It is copyright free so you won't have any problem using end screen for YouTube. if you use it or download and reupload it to your channel. By doing this you can use it for YouTube its use is absolutely free.\n ►I hope you'll like the video.◄\n ►Thanks For Watching◄ \nIf you really like this video then please don't forget to...\n\n\n▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬▬▬▬▬▬Tags\uD83D\uDC47▬▬▬▬▬▬▬▬▬▬\n#Creativecommons #commoncreative #free #freecopyright #nocopyright #nowatermark #freetouse #intro #notext #fireefire #channelintro",
@ -137,5 +145,24 @@
"is_short": false, "is_short": false,
"download_size": null "download_size": null
} }
},
{
"model": "ucast.user",
"pk": 1,
"fields": {
"password": "pbkdf2_sha256$320000$2XXzT2OZlzSOnB1n7NCgAB$9zBJXdGbJv9YnS+kP5RUMkGxeIuqAbDRBBzXlmPJizw=",
"last_login": "2022-05-29T21:08:21.383Z",
"is_superuser": true,
"username": "admin",
"first_name": "",
"last_name": "",
"email": "admin@example.com",
"is_staff": true,
"is_active": true,
"date_joined": "2022-05-29T21:05:24.014Z",
"feed_key": null,
"groups": [],
"user_permissions": []
}
} }
] ]

View file

@ -3,18 +3,21 @@
"id": "UCGiJh0NZ52wRhYKYnuZI08Q", "id": "UCGiJh0NZ52wRhYKYnuZI08Q",
"name": "ThetaDev", "name": "ThetaDev",
"description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.", "description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.",
"avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj" "avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj",
"subscribers": "37"
}, },
"https://www.youtube.com/channel/UC2TXq_t06Hjdr2g_KdKpHQg": { "https://www.youtube.com/channel/UC2TXq_t06Hjdr2g_KdKpHQg": {
"id": "UC2TXq_t06Hjdr2g_KdKpHQg", "id": "UC2TXq_t06Hjdr2g_KdKpHQg",
"name": "media.ccc.de", "name": "media.ccc.de",
"description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)", "description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)",
"avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj" "avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj",
"subscribers": "166K"
}, },
"https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ": { "https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ": {
"id": "UCmLTTbctUZobNQrr8RtX8uQ", "id": "UCmLTTbctUZobNQrr8RtX8uQ",
"name": "Creative Commons", "name": "Creative Commons",
"description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.", "description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.",
"avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj" "avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj",
"subscribers": null
} }
} }

View file

@ -1,6 +1,10 @@
import json
import shutil import shutil
import tempfile import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Tuple
from unittest import mock from unittest import mock
import pytest import pytest
@ -10,19 +14,24 @@ from django.core.management import call_command
from fakeredis import FakeRedis from fakeredis import FakeRedis
from ucast import queue, tests from ucast import queue, tests
from ucast.models import Video from ucast.models import User
from ucast.service import cover, storage, util, videoutil, youtube from ucast.service import cover, storage, util, videoutil, youtube
@pytest.fixture(scope="session", autouse=True)
def default_config():
settings.DOWNLOAD_ROOT = Path("does/not/exist")
settings.REDIS_URL = "no redis"
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def django_db_setup(django_db_setup, django_db_blocker): def django_db_setup(django_db_setup, django_db_blocker):
with django_db_blocker.unblock(): with django_db_blocker.unblock():
fixture_path = tests.DIR_TESTFILES / "fixture" / "videos.json" fixture_path = tests.DIR_TESTFILES / "fixture" / "models.json"
call_command("loaddata", fixture_path) call_command("loaddata", fixture_path)
@pytest.fixture def _create_download_dir() -> Tuple[Path, TemporaryDirectory]:
def download_dir() -> Path:
tmpdir_o = tempfile.TemporaryDirectory() tmpdir_o = tempfile.TemporaryDirectory()
tmpdir = Path(tmpdir_o.name) tmpdir = Path(tmpdir_o.name)
settings.DOWNLOAD_ROOT = tmpdir settings.DOWNLOAD_ROOT = tmpdir
@ -35,40 +44,76 @@ def download_dir() -> Path:
("media_ccc_de", "a3"), ("media_ccc_de", "a3"),
("Creative_Commons", "a4"), ("Creative_Commons", "a4"),
): ):
cf = store.get_channel_folder(slug) cf = store.get_or_create_channel_folder(slug)
shutil.copyfile( shutil.copyfile(
tests.DIR_TESTFILES / "avatar" / f"{avatar}.jpg", cf.file_avatar tests.DIR_TESTFILES / "avatar" / f"{avatar}.jpg", cf.file_avatar
) )
util.resize_avatar(cf.file_avatar, cf.file_avatar_sm) util.resize_avatar(cf.file_avatar, cf.file_avatar_sm)
return tmpdir, tmpdir_o
def _add_download_dir_content():
store = storage.Storage()
with open(tests.DIR_TESTFILES / "object" / "videodetails.json") as f:
videodetails = json.load(f)
for vid in ("ZPxEr4YdWt8", "_I5IFObm_-k", "mmEDPbbSnaY", "Cda4zS-1j-k"):
video_detail = videodetails[vid]
channel_name = video_detail["channel_name"]
channel_slug = util.get_slug(channel_name)
published = datetime.fromisoformat(video_detail["published"])
title = video_detail["title"]
video_slug = util.get_slug(f"{published.strftime('%Y%m%d')}_{title}")
description = video_detail["description"]
cf = store.get_or_create_channel_folder(channel_slug)
file_audio = cf.get_audio(video_slug)
file_tn = cf.get_thumbnail(video_slug)
file_cover = cf.get_cover(video_slug)
shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", file_audio)
shutil.copyfile(tests.DIR_TESTFILES / "thumbnail" / f"{vid}.webp", file_tn)
util.resize_thumbnail(file_tn, cf.get_thumbnail(video_slug, True))
cover.create_cover_file(
file_tn,
cf.file_avatar,
title,
channel_name,
cover.COVER_STYLE_BLUR,
file_cover,
)
videoutil.tag_audio(
file_audio,
title,
channel_name,
published.date(),
f"{description}\n\nhttps://youtu.be/{vid}",
file_cover,
)
@pytest.fixture
def download_dir() -> Path:
tmpdir, tmpdir_o = _create_download_dir()
yield tmpdir
@pytest.fixture(scope="session")
def download_dir_content() -> Path:
tmpdir, tmpdir_o = _create_download_dir()
settings.DOWNLOAD_ROOT = tmpdir
_add_download_dir_content()
yield tmpdir yield tmpdir
@pytest.fixture @pytest.fixture
@pytest.mark.django_db def download_dir_content_mut() -> Path:
def download_dir_content(download_dir) -> Path: tmpdir, tmpdir_o = _create_download_dir()
store = storage.Storage() settings.DOWNLOAD_ROOT = tmpdir
_add_download_dir_content()
for video in Video.objects.filter(downloaded__isnull=False): yield tmpdir
cf = store.get_channel_folder(video.channel.slug)
file_audio = cf.get_audio(video.slug)
file_tn = cf.get_thumbnail(video.slug)
file_cover = cf.get_cover(video.slug)
shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", file_audio)
shutil.copyfile(tests.DIR_TESTFILES / "thumbnail" / f"{video.id}.webp", file_tn)
util.resize_thumbnail(file_tn, cf.get_thumbnail(video.slug, True))
cover.create_cover_file(
file_tn,
cf.file_avatar,
video.title,
video.channel.name,
cover.COVER_STYLE_BLUR,
file_cover,
)
videoutil.tag_audio(file_audio, video, file_cover)
yield download_dir
@pytest.fixture @pytest.fixture
@ -104,3 +149,13 @@ def mock_get_channel_metadata(mocker) -> mock.Mock:
channel_meta_mock: mock.Mock = mocker.patch.object(youtube, "get_channel_metadata") channel_meta_mock: mock.Mock = mocker.patch.object(youtube, "get_channel_metadata")
channel_meta_mock.side_effect = tests.get_channel_metadata channel_meta_mock.side_effect = tests.get_channel_metadata
return channel_meta_mock return channel_meta_mock
@pytest.fixture
def user_admin(db):
return User.objects.get(id=1)
@pytest.fixture
def feed_key(user_admin) -> str:
return user_admin.get_feed_key()

View file

@ -11,8 +11,8 @@ def test_create_channel_folders(settings):
settings.DOWNLOAD_ROOT = tmpdir settings.DOWNLOAD_ROOT = tmpdir
store = storage.Storage() store = storage.Storage()
cf1 = store.get_channel_folder("ThetaDev") cf1 = store.get_or_create_channel_folder("ThetaDev")
cf2 = store.get_channel_folder("Jeff_Geerling") cf2 = store.get_or_create_channel_folder("Jeff_Geerling")
cf1b = store.get_channel_folder("ThetaDev") cf1b = store.get_channel_folder("ThetaDev")
cf1_path = tmpdir / "ThetaDev" cf1_path = tmpdir / "ThetaDev"

View file

@ -90,3 +90,23 @@ def test_resize_thumbnail():
def test_slug(text: str, expected_slug: str): def test_slug(text: str, expected_slug: str):
slug = util.get_slug(text) slug = util.get_slug(text)
assert slug == expected_slug assert slug == expected_slug
@pytest.mark.parametrize(
"url,expect",
[
("/files/audio/My_Video.mp3", "/files/audio/My_Video.mp3?key=my-key"),
(
"https://example.com/files/audio/My_Video.mp3",
"https://example.com/files/audio/My_Video.mp3?key=my-key",
),
("/files/avatar/ThetaDev.webp?sm", "/files/avatar/ThetaDev.webp?sm&key=my-key"),
(
"https://example.com/files/avatar/ThetaDev.webp?sm",
"https://example.com/files/avatar/ThetaDev.webp?sm&key=my-key",
),
],
)
def test_add_key_to_url(url: str, expect: str):
url_w_key = util.add_key_to_url(url, "my-key")
assert url_w_key == expect

View file

@ -14,7 +14,7 @@ from ucast.service import videoutil
@pytest.mark.django_db @pytest.mark.django_db
def test_tag_audio(): def test_tag_audio():
video = Video.objects.get(id="ZPxEr4YdWt8") video = Video.objects.get(video_id="ZPxEr4YdWt8")
tmpdir_o = tempfile.TemporaryDirectory() tmpdir_o = tempfile.TemporaryDirectory()
tmpdir = Path(tmpdir_o.name) tmpdir = Path(tmpdir_o.name)
@ -22,7 +22,14 @@ def test_tag_audio():
cover_file = tests.DIR_TESTFILES / "cover" / "c1_blur.png" cover_file = tests.DIR_TESTFILES / "cover" / "c1_blur.png"
shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", audio_file) shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", audio_file)
videoutil.tag_audio(audio_file, video, cover_file) videoutil.tag_audio(
audio_file,
video.title,
video.channel.name,
video.published.date(),
video.get_full_description(),
cover_file,
)
tag = id3.ID3(audio_file) tag = id3.ID3(audio_file)
assert tag["TPE1"].text[0] == "ThetaDev" assert tag["TPE1"].text[0] == "ThetaDev"

View file

@ -169,6 +169,6 @@ def test_get_channel_videos_from_scraper():
videos = youtube.get_channel_videos_from_scraper(CHANNEL_ID_THETADEV) videos = youtube.get_channel_videos_from_scraper(CHANNEL_ID_THETADEV)
assert videos assert videos
v1 = videos[0] v1 = videos.__next__()
assert len(v1.id) == 11 assert len(v1.id) == 11
assert v1.published is None assert v1.published is None

View file

@ -14,11 +14,11 @@ VIDEO_SLUG_INTRO = "20211010_No_copyright_intro_free_fire_intro_no_text_free_cop
@pytest.mark.django_db @pytest.mark.django_db
def test_download_video(download_dir, rq_queue): def test_download_video(download_dir, rq_queue):
video = Video.objects.get(id=VIDEO_ID_INTRO) video = Video.objects.get(video_id=VIDEO_ID_INTRO)
job = queue.enqueue(download.download_video, video) job = queue.enqueue(download.download_video, video)
store = storage.Storage() store = storage.Storage()
cf = store.get_channel_folder(video.channel.slug) cf = store.get_or_create_channel_folder(video.channel.slug)
assert job.is_finished assert job.is_finished
@ -33,8 +33,8 @@ def test_import_channel(
download_dir, rq_queue, mock_get_video_details, mock_download_audio download_dir, rq_queue, mock_get_video_details, mock_download_audio
): ):
# Remove 2 videos from the database so they can be imported # Remove 2 videos from the database so they can be imported
Video.objects.get(id="ZPxEr4YdWt8").delete() Video.objects.get(video_id="ZPxEr4YdWt8").delete()
Video.objects.get(id="_I5IFObm_-k").delete() Video.objects.get(video_id="_I5IFObm_-k").delete()
job = rq_queue.enqueue(download.import_channel, CHANNEL_ID_THETADEV) job = rq_queue.enqueue(download.import_channel, CHANNEL_ID_THETADEV)
assert job.is_finished assert job.is_finished
@ -54,10 +54,10 @@ def test_update_channel(
download_dir, rq_queue, mock_get_video_details, mock_download_audio download_dir, rq_queue, mock_get_video_details, mock_download_audio
): ):
# Remove 2 videos from the database so they can be imported # Remove 2 videos from the database so they can be imported
Video.objects.get(id="ZPxEr4YdWt8").delete() Video.objects.get(video_id="ZPxEr4YdWt8").delete()
Video.objects.get(id="_I5IFObm_-k").delete() Video.objects.get(video_id="_I5IFObm_-k").delete()
channel = Channel.objects.get(id=CHANNEL_ID_THETADEV) channel = Channel.objects.get(channel_id=CHANNEL_ID_THETADEV)
job = rq_queue.enqueue(download.update_channel, channel) job = rq_queue.enqueue(download.update_channel, channel)
assert job.is_finished assert job.is_finished

View file

@ -11,13 +11,13 @@ CHANNEL_ID_THETADEV = "UCGiJh0NZ52wRhYKYnuZI08Q"
@pytest.mark.django_db @pytest.mark.django_db
def test_recreate_cover(download_dir_content, rq_queue, mocker): def test_recreate_cover(download_dir_content_mut, rq_queue, mocker):
create_cover_mock: mock.Mock = mocker.patch.object(cover, "create_cover_file") create_cover_mock: mock.Mock = mocker.patch.object(cover, "create_cover_file")
video = Video.objects.get(id="ZPxEr4YdWt8") video = Video.objects.get(video_id="ZPxEr4YdWt8")
store = storage.Storage() store = storage.Storage()
cf = store.get_channel_folder(video.channel.slug) cf = store.get_or_create_channel_folder(video.channel.slug)
job = rq_queue.enqueue(library.recreate_cover, video) job = rq_queue.enqueue(library.recreate_cover, video)
assert job.is_finished assert job.is_finished
@ -45,20 +45,29 @@ def test_recreate_covers(rq_queue, mocker):
@pytest.mark.django_db @pytest.mark.django_db
def test_update_channel_info(rq_queue, mock_get_channel_metadata): def test_update_channel_info(rq_queue, mock_get_channel_metadata):
channel = Channel.objects.get(id=CHANNEL_ID_THETADEV) channel = Channel.objects.get(channel_id=CHANNEL_ID_THETADEV)
channel.name = "Old name"
channel.description = "Old description" channel.description = "Old description"
channel.subscribers = "Old"
channel.avatar_url = "Old avatar url"
channel.save() channel.save()
job = rq_queue.enqueue(library.update_channel_info, channel) job = rq_queue.enqueue(library.update_channel_info, channel)
assert job.is_finished assert job.is_finished
channel.refresh_from_db() channel.refresh_from_db()
assert channel.name == "ThetaDev"
assert ( assert (
channel.description channel.description
== "I'm ThetaDev. I love creating cool projects \ == "I'm ThetaDev. I love creating cool projects \
using electronics, 3D printers and other awesome tech-based stuff." using electronics, 3D printers and other awesome tech-based stuff."
) )
assert channel.subscribers == "37"
assert (
channel.avatar_url
== "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj"
)
@pytest.mark.django_db @pytest.mark.django_db

85
ucast/tests/test_feed.py Normal file
View file

@ -0,0 +1,85 @@
import feedparser
import pytest
from django.test.client import Client
from django.utils.feedgenerator import rfc2822_date
from ucast import feed
from ucast.models import Video
@pytest.mark.django_db
def test_feed(feed_key):
client = Client()
response = client.get(f"/feed/ThetaDev?key={feed_key}")
parsed_feed = feedparser.parse(response.getvalue())
assert parsed_feed["bozo"] is False
assert parsed_feed["namespaces"] == {
"": "http://www.w3.org/2005/Atom",
"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd",
}
meta = parsed_feed["feed"]
assert meta["title"] == "ThetaDev"
assert meta["link"] == "https://www.youtube.com/channel/UCGiJh0NZ52wRhYKYnuZI08Q"
assert (
meta["subtitle"]
== "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff."
)
assert meta["updated"] == "Sun, 15 May 2022 22:16:25 +0000"
assert (
meta["image"]["href"]
== f"http://testserver/files/avatar/ThetaDev.jpg?key={feed_key}"
)
assert len(parsed_feed["entries"]) == 4
video_ids = ["ZPxEr4YdWt8", "_I5IFObm_-k", "mmEDPbbSnaY", "Cda4zS-1j-k"]
for i, entry in enumerate(parsed_feed["entries"]):
video = Video.objects.get(video_id=video_ids[i])
assert entry["title"] == video.title
assert entry["link"] == video.get_absolute_url()
assert entry["summary"] == feed.PodcastFeedType._xml_escape(
video.description
).replace("<br>", "<br />")
assert entry["published"] == rfc2822_date(video.published)
assert entry["id"] == video.get_absolute_url()
assert (
entry["image"]["href"]
== f"http://testserver/files/cover/ThetaDev/{video.slug}.png?key={feed_key}"
)
assert entry["itunes_duration"] == feed.PodcastFeedType._format_secs(
video.duration
)
@pytest.mark.parametrize(
"text,expect",
[
("Hello <World>", "Hello &lt;World&gt;"),
(
"go to https://example.org/test",
'go to <a href="https://example.org/test">https://example.org/test</a>',
),
("line1\nline2\nline3", "line1<br>line2<br>line3"),
],
)
def test_xml_escape(text: str, expect: str):
escaped = feed.PodcastFeedType._xml_escape(text)
assert escaped == expect
@pytest.mark.parametrize(
"secs,expect",
[
(0, "00:00:00"),
(16, "00:00:16"),
(100, "00:01:40"),
(3800, "01:03:20"),
],
)
def test_format_secs(secs: int, expect: str):
time_str = feed.PodcastFeedType._format_secs(secs)
assert time_str == expect

106
ucast/tests/test_views.py Normal file
View file

@ -0,0 +1,106 @@
import pytest
from django.conf import settings
from django.test.client import Client
from ucast.service import util
@pytest.mark.parametrize(
"url_path,internal_path",
[
(
"audio/ThetaDev/20190602_ThetaDev_Embedded_World_2019.mp3",
"/internal_files/ThetaDev/20190602_ThetaDev_Embedded_World_2019.mp3",
),
(
"cover/ThetaDev/20190602_ThetaDev_Embedded_World_2019.png",
"/internal_files/ThetaDev/_ucast/covers/20190602_ThetaDev_Embedded_World_2019.png",
),
(
"thumbnail/ThetaDev/20190602_ThetaDev_Embedded_World_2019.webp",
"/internal_files/ThetaDev/_ucast/thumbnails/20190602_ThetaDev_Embedded_World_2019.webp",
),
(
"thumbnail/ThetaDev/20190602_ThetaDev_Embedded_World_2019.webp?sm",
"/internal_files/ThetaDev/_ucast/thumbnails/20190602_ThetaDev_Embedded_World_2019_sm.webp",
),
(
"avatar/ThetaDev.jpg",
"/internal_files/ThetaDev/_ucast/avatar.jpg",
),
(
"avatar/ThetaDev.webp?sm",
"/internal_files/ThetaDev/_ucast/avatar_sm.webp",
),
],
)
def test_files_internal_redirect(
url_path: str, internal_path: str, download_dir_content, feed_key, admin_client
):
settings.INTERNAL_REDIRECT_HEADER = "X-Accel-Redirect"
def check_response(resp):
assert resp.getvalue() == b""
assert resp.headers.get("X-Accel-Redirect") == internal_path
assert "Content-Type" not in resp.headers
# Access with key
client = Client()
url = util.add_key_to_url("/files/" + url_path, feed_key)
response = client.get(url)
check_response(response)
# Access with login
response = admin_client.get("/files/" + url_path)
check_response(response)
@pytest.mark.parametrize(
"url_path,file_path",
[
(
"audio/ThetaDev/20190602_ThetaDev_Embedded_World_2019.mp3",
"ThetaDev/20190602_ThetaDev_Embedded_World_2019.mp3",
),
(
"cover/ThetaDev/20190602_ThetaDev_Embedded_World_2019.png",
"ThetaDev/_ucast/covers/20190602_ThetaDev_Embedded_World_2019.png",
),
(
"thumbnail/ThetaDev/20190602_ThetaDev_Embedded_World_2019.webp",
"ThetaDev/_ucast/thumbnails/20190602_ThetaDev_Embedded_World_2019.webp",
),
(
"thumbnail/ThetaDev/20190602_ThetaDev_Embedded_World_2019.webp?sm",
"ThetaDev/_ucast/thumbnails/20190602_ThetaDev_Embedded_World_2019_sm.webp",
),
(
"avatar/ThetaDev.jpg",
"ThetaDev/_ucast/avatar.jpg",
),
(
"avatar/ThetaDev.webp?sm",
"ThetaDev/_ucast/avatar_sm.webp",
),
],
)
def test_files_response(
url_path: str, file_path: str, download_dir_content, feed_key, admin_client
):
settings.INTERNAL_REDIRECT_HEADER = ""
response_file = settings.DOWNLOAD_ROOT / file_path
with open(response_file, "rb") as f:
file_bts = f.read()
# Access with key
client = Client()
url = util.add_key_to_url("/files/" + url_path, feed_key)
response = client.get(url)
response_bts = response.getvalue()
assert response_bts == file_bts
# Access with login
response = admin_client.get("/files/" + url_path)
response_bts = response.getvalue()
assert response_bts == file_bts

View file

@ -2,4 +2,12 @@ from django.urls import path
from ucast import views from ucast import views
urlpatterns = [path("", views.home)] urlpatterns = [
path("", views.home),
path("channel/<str:channel>", views.videos),
path("feed/<str:channel>", views.podcast_feed),
path("files/audio/<str:channel>/<str:video>", views.audio),
path("files/cover/<str:channel>/<str:video>", views.cover),
path("files/thumbnail/<str:channel>/<str:video>", views.thumbnail),
path("files/avatar/<str:channel>", views.avatar),
]

View file

@ -1,6 +1,145 @@
import os
from functools import wraps
from pathlib import Path
from typing import Callable
from django import http from django import http
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.syndication.views import add_domain
from django.middleware.http import ConditionalGetMiddleware
from django.shortcuts import render from django.shortcuts import render
from django.utils.decorators import decorator_from_middleware
from ucast import feed, forms, queue
from ucast.models import Channel, User, Video
from ucast.service import storage
from ucast.tasks import download
@login_required
def home(request: http.HttpRequest): def home(request: http.HttpRequest):
return render(request, "ucast/main.html") channels = Channel.objects.all()
site_url = add_domain(get_current_site(request).domain, "", request.is_secure())
if request.method == "POST":
form = forms.AddChannelForm(request.POST)
if form.is_valid():
channel_str = form.cleaned_data["channel_str"]
queue.enqueue(download.import_channel, channel_str)
return render(
request,
"ucast/channels.html",
{
"channels": channels,
"site_url": site_url,
"add_channel_form": forms.AddChannelForm,
},
)
@login_required
def videos(request: http.HttpRequest, channel: str):
chan = Channel.objects.get(slug=channel)
vids = Video.objects.filter(channel=chan).order_by("-published")
site_url = add_domain(get_current_site(request).domain, "", request.is_secure())
return render(
request,
"ucast/videos.html",
{"videos": vids, "channel": chan, "site_url": site_url},
)
def _channel_file(channel: str, get_file: Callable[[storage.ChannelFolder], Path]):
store = storage.Storage()
try:
cf = store.get_channel_folder(channel)
except FileNotFoundError:
raise http.Http404
file_path = get_file(cf)
if not os.path.isfile(file_path):
raise http.Http404
if not settings.INTERNAL_REDIRECT_HEADER:
return http.FileResponse(open(file_path, "rb"))
file_path_rel = file_path.relative_to(store.dir_data)
url_path_internal = f"/{settings.INTERNAL_FILES_ROOT}/{file_path_rel.as_posix()}"
response = http.HttpResponse()
response.headers[settings.INTERNAL_REDIRECT_HEADER] = url_path_internal
# Content type is set to text/html by default and has to be unset
del response.headers["Content-Type"]
return response
def login_or_key_required(function):
def decorator(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
key = request.GET.get("key")
if key:
try:
request.user = User.objects.get(feed_key=key)
except User.DoesNotExist:
pass
if request.user.is_authenticated:
return view_func(request, *args, **kwargs)
return http.HttpResponse("401 Unauthorized", status=401)
return _wrapped_view
return decorator(function)
@login_or_key_required
def audio(request: http.HttpRequest, channel: str, video: str):
# Trim off file extension
video_slug = video.rsplit(".")[0]
return _channel_file(channel, lambda cf: cf.get_audio(video_slug))
@login_or_key_required
def cover(request: http.HttpRequest, channel: str, video: str):
# Trim off file extension
video_slug = video.rsplit(".")[0]
return _channel_file(channel, lambda cf: cf.get_cover(video_slug))
@login_or_key_required
def thumbnail(request: http.HttpRequest, channel: str, video: str):
# Trim off file extension
video_slug = video.rsplit(".")[0]
is_sm = "sm" in request.GET
return _channel_file(channel, lambda cf: cf.get_thumbnail(video_slug, is_sm))
@login_or_key_required
def avatar(request: http.HttpRequest, channel: str):
# Trim off file extension
channel_slug = channel.rsplit(".")[0]
is_sm = "sm" in request.GET
if is_sm:
return _channel_file(channel_slug, lambda cf: cf.file_avatar_sm)
return _channel_file(channel_slug, lambda cf: cf.file_avatar)
@login_or_key_required
@decorator_from_middleware(ConditionalGetMiddleware)
def podcast_feed(request: http.HttpRequest, *args, **kwargs):
return feed.UcastFeed()(request, *args, **kwargs)

View file

@ -36,6 +36,13 @@ def get_env_path(name, default=None):
return Path(raw_env).absolute() return Path(raw_env).absolute()
def get_env_list(name):
raw_env = get_env(name)
if not raw_env:
return []
return [i.strip() for i in raw_env.split(",")]
def _load_dotenv() -> Path: def _load_dotenv() -> Path:
""" """
Look for a .env file in the current working directory or Look for a .env file in the current working directory or
@ -78,7 +85,7 @@ SECRET_KEY = get_env(
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = get_env("DEBUG", False) DEBUG = get_env("DEBUG", False)
ALLOWED_HOSTS = [] ALLOWED_HOSTS = get_env_list("ALLOWED_HOSTS")
# Application definition # Application definition
@ -176,6 +183,11 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
AUTH_USER_MODEL = "ucast.user"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/4.0/topics/i18n/ # https://docs.djangoproject.com/en/4.0/topics/i18n/
@ -206,3 +218,6 @@ REDIS_QUEUE_TIMEOUT = get_env("REDIS_QUEUE_TIMEOUT", 600)
REDIS_QUEUE_RESULT_TTL = 600 REDIS_QUEUE_RESULT_TTL = 600
YT_UPDATE_INTERVAL = get_env("YT_UPDATE_INTERVAL", 900) YT_UPDATE_INTERVAL = get_env("YT_UPDATE_INTERVAL", 900)
INTERNAL_FILES_ROOT = get_env("INTERNAL_FILES_ROOT", "internal_files")
INTERNAL_REDIRECT_HEADER = get_env("INTERNAL_REDIRECT_HEADER", "X-Accel-Redirect")

View file

@ -18,5 +18,6 @@ from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls")),
path("", include("ucast.urls")), path("", include("ucast.urls")),
] ]