Compare commits

...

5 commits

Author SHA1 Message Date
7c741a476f Bump version: 0.2.0 → 0.3.0
All checks were successful
continuous-integration/drone/push Build is passing
2022-06-27 23:09:56 +02:00
e5c1fbdfb4 add search 2022-06-27 23:09:42 +02:00
df90e42729 allow variable number of feed items 2022-06-27 22:44:16 +02:00
f21387a23c add opml download 2022-06-27 22:38:12 +02:00
ded1895adb limit downloads view to 100 items 2022-06-27 21:56:18 +02:00
18 changed files with 202 additions and 55 deletions

View file

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.2.0
current_version = 0.3.0
commit = True
tag = True

View file

@ -19,16 +19,16 @@
grid-column: auto
@include tablet
grid-template-columns: repeat(3,minmax(0,1fr))
grid-template-columns: repeat(3, minmax(0, 1fr))
@include desktop
grid-template-columns: repeat(4,minmax(0,1fr))
grid-template-columns: repeat(4, minmax(0, 1fr))
@include widescreen
grid-template-columns: repeat(5,minmax(0,1fr))
grid-template-columns: repeat(5, minmax(0, 1fr))
@include fullhd
grid-template-columns: repeat(6,minmax(0,1fr))
grid-template-columns: repeat(6, minmax(0, 1fr))
.video-card
display: flex
@ -43,3 +43,6 @@
// Fix almost invisible navbar items on mobile
.navbar-item
color: #fff
.overflow-x
overflow-x: auto

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "ucast"
version = "0.2.0"
version = "0.3.0"
description = "YouTube to Podcast converter"
authors = ["Theta-Dev <t.testboy@gmail.com>"]
packages = [

View file

@ -1,4 +1,4 @@
__version__ = "0.2.0"
__version__ = "0.3.0"
def template_context(request):

View file

@ -147,6 +147,12 @@ class UcastFeed(Feed):
return Channel.objects.get(slug=channel_slug)
def get_feed(self, channel: Channel, request: http.HttpRequest):
max_items = settings.FEED_MAX_ITEMS
try:
max_items = int(request.GET.get("items"))
except TypeError or ValueError:
pass
feed = self.feed_type(
title=channel.name,
link=channel.get_absolute_url(),
@ -158,7 +164,7 @@ class UcastFeed(Feed):
for video in channel.video_set.filter(downloaded__isnull=False).order_by(
"-published"
)[: settings.FEED_MAX_ITEMS]:
)[:max_items]:
feed.add_item(
title=video.title,
link=video.get_absolute_url(),

View file

@ -92,11 +92,11 @@ def get_failed_job_registry():
return registry.FailedJobRegistry(queue.name, queue.connection)
def get_downloading_videos():
def get_downloading_videos(offset=0, limit=-1):
queue = get_queue()
videos = {}
for job in queue.jobs:
for job in queue.get_jobs(offset, limit):
if (
job.func_name == "ucast.tasks.download.download_video"
and job.args

40
ucast/service/opml.py Normal file
View file

@ -0,0 +1,40 @@
from dataclasses import dataclass
from typing import Iterable
from django.utils.xmlutils import SimplerXMLGenerator
from ucast.models import Channel
@dataclass
class FeedElement:
url: str
title: str
def __add_feed_element(handler: SimplerXMLGenerator, element: FeedElement):
handler.addQuickElement(
"outline", attrs={"xmlUrl": element.url, "title": element.title}
)
def write_opml(elements: Iterable[FeedElement], outfile):
handler = SimplerXMLGenerator(outfile, "utf-8", short_empty_elements=True)
handler.startDocument()
handler.startElement("opml", {})
handler.addQuickElement("head")
handler.startElement("body", {"version": "1.0"})
for element in elements:
__add_feed_element(handler, element)
handler.endElement("body")
handler.endElement("opml")
handler.endDocument()
def write_channels_opml(channels: Iterable[Channel], site_url: str, key: str, outfile):
elements = [
FeedElement(f"{site_url}/feed/{c.slug}?key={key}", c.name) for c in channels
]
write_opml(elements, outfile)

View file

@ -10363,4 +10363,8 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
color: #fff;
}
.overflow-x {
overflow-x: auto;
}
/*# sourceMappingURL=style.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -27,6 +27,9 @@
</div>
<div class="navbar-end">
<a class="navbar-item" href="{% url 'search' %}">
Search
</a>
<a class="navbar-item" href="{% url 'downloads' %}">
Downloads
</a>

View file

@ -68,6 +68,10 @@
</div>
</div>
{% endfor %}
<div>
<a href="{% url 'channels_opml' %}">Download OPML</a>
</div>
{% endblock content %}
{% block javascript %}

View file

@ -1,36 +1,23 @@
{% extends 'base.html' %}
{% block title %}ucast - Errors{% endblock %}
{% block title %}ucast - Downloads{% endblock %}
{% block content %}
<div class="mb-4">
<div>
<span class="title">Downloading
{% if downloading_videos %}({{ downloading_videos.paginator.count }}){% endif %}
</span>
<span class="title">Downloading</span>
</div>
</div>
<div class="mb-4">
{% if downloading_videos %}
<table class="table">
<thead>
<tr>
<th>Video-ID</th>
<th>Title</th>
<th>Channel</th>
<th>Published</th>
</tr>
</thead>
<tbody hx-get="?items" hx-trigger="every 5s">
{% if downloading_videos %}
<div class="mb-4" hx-get="{% url 'downloads' %}" hx-trigger="every 5s">
{% include "ucast/downloads_items.html" %}
{% else %}
</div>
{% else %}
<div class="mb-4">
<p>Not downloading any videos</p>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="mb-4">
<div>

View file

@ -1,13 +1,26 @@
{% for video in downloading_videos %}
<tr {% if forloop.last and downloading_videos.has_next %}
hx-get="?page={{ downloading_videos.next_page_number }}"
hx-trigger="revealed"
hx-swap="afterend"
{% endif %}>
<td><a href="{{ video.get_absolute_url }}">{{ video.video_id }}</a></td>
<td>{{ video.title }}</td>
<td><a href="{% url 'videos' video.channel.slug %}">{{ video.channel.name }}</a>
</td>
<td>{{ video.published|date:"SHORT_DATE_FORMAT" }}</td>
</tr>
{% endfor %}
<div class="mb-4">
<a class="subtitle">{{ n_downloading_videos }} Videos</a>
</div>
<div class="mb-4 overflow-x">
<table class="table">
<thead>
<tr>
<th>Video-ID</th>
<th>Title</th>
<th>Channel</th>
<th>Published</th>
</tr>
</thead>
<tbody>
{% for video in downloading_videos %}
<tr>
<td><a href="{{ video.get_absolute_url }}">{{ video.video_id }}</a></td>
<td>{{ video.title }}</td>
<td><a href="{% url 'videos' video.channel.slug %}">{{ video.channel.name }}</a>
</td>
<td>{{ video.published|date:"SHORT_DATE_FORMAT" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View file

@ -0,0 +1,61 @@
{% extends 'base.html' %}
{% block title %}ucast - Search{% endblock %}
{% block content %}
<div class="box">
<form method="get">
<div class="field has-addons">
<div class="control is-flex-grow-1">
<input name="q" required class="input" type="text"
placeholder="Search" {% if query %}value="{{ query }}{% endif %}">
</div>
<div class="control">
<button type="submit" class="button is-primary">
<i class="fas fa-search"></i>
</button>
</div>
</div>
</form>
</div>
<div class="video-grid">
{% if videos %}
{% for video in videos %}
<div class="card video-card">
<a href="{{ video.get_absolute_url }}" target="_blank">
<img class="video-thumbnail"
src="/files/thumbnail/{{ video.channel.slug }}/{{ video.slug }}.webp?sm">
</a>
<div class="video-card-content is-flex-grow-1">
<a href="{{ video.get_absolute_url }}">{{ video.title }}</a>
</div>
<div class="video-card-content">
<div class="level">
<span class="tag">
<i
class="fas fa-calendar"></i>&nbsp; {{ video.published|date:"SHORT_DATE_FORMAT" }}
</span>
<div class="field has-addons">
<div class="control">
<a class="button is-small is-success"
href="/files/audio/{{ video.channel.slug }}/{{ video.slug }}.mp3"
target="_blank">
<i class="fas fa-play"></i>
</a>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
{% elif query %}
<p>No videos</p>
{% endif %}
</div>
{% endblock content %}

View file

@ -7,7 +7,7 @@
{% endif %}>
<a href="{{ video.get_absolute_url }}" target="_blank">
<img class="video-thumbnail"
src="/files/thumbnail/{{ channel.slug }}/{{ video.slug }}.webp?sm">
src="/files/thumbnail/{{ video.channel.slug }}/{{ video.slug }}.webp?sm">
</a>
<div class="video-card-content is-flex-grow-1">
@ -25,7 +25,7 @@
<div class="field has-addons">
<div class="control">
<a class="button is-small is-success"
href="/files/audio/{{ channel.slug }}/{{ video.slug }}.mp3"
href="/files/audio/{{ video.channel.slug }}/{{ video.slug }}.mp3"
target="_blank">
<i class="fas fa-play"></i>
</a>

View file

@ -30,6 +30,8 @@ urlpatterns = [
),
path("downloads/error/<str:job_id>", views.error_details, name="error_details"),
path("feed/<str:channel>", views.podcast_feed, name="feed"),
path("opml", views.channels_opml, name="channels_opml"),
path("search", views.search, name="search"),
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),

View file

@ -16,7 +16,7 @@ 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 controller, storage
from ucast.service import controller, opml, storage
from ucast.tasks import download
@ -159,12 +159,10 @@ def downloads(request: http.HttpRequest):
ids = freg.get_job_ids(0, 50)
failed_jobs = freg.job_class.fetch_many(ids, freg.connection, freg.serializer)
page_number = request.GET.get("page")
downloading_videos = queue.get_downloading_videos()
downloading_videos_p = Paginator(downloading_videos, 100)
downloading_videos = queue.get_downloading_videos(limit=100)
template_name = "ucast/downloads.html"
if request.htmx or request.GET.get("items"):
if request.htmx:
template_name = "ucast/downloads_items.html"
return render(
@ -172,7 +170,8 @@ def downloads(request: http.HttpRequest):
template_name,
{
"failed_jobs": failed_jobs,
"downloading_videos": downloading_videos_p.get_page(page_number),
"downloading_videos": downloading_videos,
"n_downloading_videos": 1,
},
)
@ -205,6 +204,31 @@ def download_errors_requeue_all(request: http.HttpRequest):
return http.HttpResponseRedirect(reverse(downloads))
@login_required
def channels_opml(request: http.HttpRequest):
response = http.HttpResponse(
content_type="application/xml",
headers={"Content-Disposition": "attachment; filename=ucast_channels.opml"},
)
site_url = add_domain(get_current_site(request).domain, "", request.is_secure())
opml.write_channels_opml(
Channel.objects.all(), site_url, request.user.get_feed_key(), response
)
return response
@login_required
def search(request: http.HttpRequest):
query = request.GET.get("q")
vids = []
if query:
vids = Video.objects.filter(downloaded__isnull=False, title__icontains=query)[
:30
]
return render(request, "ucast/search.html", {"query": query, "videos": vids})
def _channel_file(channel: str, get_file: Callable[[storage.ChannelFolder], Path]):
store = storage.Storage()