Compare commits
5 commits
ede37ebe17
...
7c741a476f
Author | SHA1 | Date | |
---|---|---|---|
7c741a476f | |||
e5c1fbdfb4 | |||
df90e42729 | |||
f21387a23c | |||
ded1895adb |
18 changed files with 202 additions and 55 deletions
|
@ -1,5 +1,5 @@
|
|||
[bumpversion]
|
||||
current_version = 0.2.0
|
||||
current_version = 0.3.0
|
||||
commit = True
|
||||
tag = True
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
|
||||
def template_context(request):
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
40
ucast/service/opml.py
Normal 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)
|
|
@ -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
2
ucast/static/bulma/css/style.min.css
vendored
2
ucast/static/bulma/css/style.min.css
vendored
File diff suppressed because one or more lines are too long
|
@ -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>
|
||||
|
|
|
@ -68,6 +68,10 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div>
|
||||
<a href="{% url 'channels_opml' %}">Download OPML</a>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block javascript %}
|
||||
|
|
|
@ -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">
|
||||
<div class="mb-4" hx-get="{% url 'downloads' %}" hx-trigger="every 5s">
|
||||
{% include "ucast/downloads_items.html" %}
|
||||
{% else %}
|
||||
<p>Not downloading any videos</p>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-4">
|
||||
<p>Not downloading any videos</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
|
|
|
@ -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 %}>
|
||||
<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 %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
61
ucast/templates/ucast/search.html
Normal file
61
ucast/templates/ucast/search.html
Normal 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> {{ 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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in a new issue