Skip to content

Commit

Permalink
Better async implementation
Browse files Browse the repository at this point in the history
- add api/www unit tests
- simpler filtering mode
  • Loading branch information
essembeh committed Jan 12, 2024
1 parent 8be8367 commit c388320
Show file tree
Hide file tree
Showing 15 changed files with 296 additions and 239 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
YOURSS_DEFAULT_CHANNELS=@jonnygiger
YOURSS_USER_demo=@jonnygiger,-@danielmadison,@lostangelus52
YOURSS_USER_demo=@jonnygiger,@danielmadison,@lostangelus52

# to use a redis cache:
# docker run --rm -ti -p 6379:6379 redis
Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,19 @@ $ xdg-open http://127.0.0.1:8000
Example:
```sh
YOURSS_DEFAULT_CHANNELS=@jonnygiger,UCa_Dlwrwv3ktrhCy91HpVRw
YOURSS_USER_foo=@jonnygiger,-@ellisfrost8646
YOURSS_USER_foo=@jonnygiger,@ellisfrost8646
YOURSS_USER_bar=@lostangelus52,UCB99aK4f2WaH96joccxLvSQ
```

> *Redis* can be used to cache feeds and avatar and speed up the application.
# Usage

- you can browse a single channel with: `https://your.yourss.instance/@jonnygiger`
- you can browse multiple channels in a single page: `https://your.yourss.instance/@jonnygiger,@lostangelus52`
- the original *RSS* feed can be access at `https://your.yourss.instance/api/rss/@jonnygiger`
- a *json* rss-like can be accessed at `https://your.yourss.instance/api/json/@jonnygiger`
- you will be redirected to the channel avatar with `https://your.yourss.instance/api/avatar/@jonnygiger`
- all your custom pages can be accessed to `https://your.yourss.instance/u/<user>`
- a channel which name begins with a `-` will be hidden by default

# Extenal documentation:

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "yourss"
version = "0.5.0"
version = "0.5.1"
description = "Simple youtube browser based on RSS feeds"
homepage = "https://github.com/essembeh/yourss"
authors = ["Sébastien MB <[email protected]>"]
Expand Down
22 changes: 12 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import asyncio
import pytest_asyncio
from fastapi.testclient import TestClient

import pytest
from yourss.webapp import app
from yourss.youtube import YoutubeWebClient


@pytest.fixture(scope="session")
def event_loop():
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture
async def yt_client():
yield YoutubeWebClient()


@pytest_asyncio.fixture
async def yourss_client():
yield TestClient(app)
46 changes: 46 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from .test_youtube import CHANNEL_ID, SLUG, USER


def test_version(yourss_client):
resp = yourss_client.get("/api/version")
resp.raise_for_status()


def test_api_avatar_slug(yourss_client):
resp = yourss_client.get(f"/api/avatar/{SLUG}", follow_redirects=False)
assert 300 <= resp.status_code < 400


def test_api_avatar_channel(yourss_client):
resp = yourss_client.get(f"/api/avatar/{CHANNEL_ID}", follow_redirects=False)
assert 300 <= resp.status_code < 400


def test_api_avatar_user(yourss_client):
resp = yourss_client.get(f"/api/avatar/{USER}", follow_redirects=False)
assert 300 <= resp.status_code < 400


def test_api_rss_slug(yourss_client):
resp = yourss_client.get(f"/api/rss/{SLUG}", follow_redirects=False)
assert 300 <= resp.status_code < 400


def test_api_rss_channel(yourss_client):
resp = yourss_client.get(f"/api/rss/{CHANNEL_ID}", follow_redirects=False)
assert 300 <= resp.status_code < 400


def test_api_rss_user(yourss_client):
resp = yourss_client.get(f"/api/rss/{USER}", follow_redirects=False)
assert 300 <= resp.status_code < 400


def test_homepage(yourss_client):
resp = yourss_client.get("/")
assert resp.status_code == 200


def test_page(yourss_client):
resp = yourss_client.get("/@jonnygiger")
assert resp.status_code == 200
36 changes: 13 additions & 23 deletions tests/test_youtube.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import pytest

from yourss.model import RssFeed
from yourss.youtube import (
YoutubeScrapper,
YoutubeUrl,
youtube_get_metadata,
youtube_get_rss_feed,
yt_html_get,
)
from yourss.youtube import YoutubeScrapper, YoutubeUrl

USER = "DAN1ELmadison"
USER_HOME = YoutubeUrl.user_home(USER)
Expand All @@ -22,15 +15,14 @@


@pytest.mark.asyncio
async def test_channel_rssfeed():
feed = RssFeed.fromresponse(await youtube_get_rss_feed(CHANNEL_ID))
assert feed is not None
async def test_channel_rssfeed(yt_client):
feed = await yt_client.get_rss_feed(CHANNEL_ID)
assert feed.title == "Jeremy Griffith"


@pytest.mark.asyncio
async def test_channel_metadata():
response = await yt_html_get(CHANNEL_ID_HOME)
async def test_channel_metadata(yt_client):
response = await yt_client.get_html(CHANNEL_ID_HOME)
metadata = YoutubeScrapper.fromresponse(response)
assert metadata.title == "Jeremy Griffith"
assert (
Expand All @@ -41,15 +33,14 @@ async def test_channel_metadata():


@pytest.mark.asyncio
async def test_user_rssfeed():
feed = RssFeed.fromresponse(await youtube_get_rss_feed(USER))
assert feed is not None
async def test_user_rssfeed(yt_client):
feed = await yt_client.get_rss_feed(USER)
assert feed.title == "Daniel Madison"


@pytest.mark.asyncio
async def test_user_metadata():
response = await yt_html_get(USER_HOME)
async def test_user_metadata(yt_client):
response = await yt_client.get_html(USER_HOME)
metadata = YoutubeScrapper.fromresponse(response)
assert metadata.title == "Daniel Madison"
assert (
Expand All @@ -60,8 +51,8 @@ async def test_user_metadata():


@pytest.mark.asyncio
async def test_slug_metadata():
response = await yt_html_get(SLUG_HOME)
async def test_slug_metadata(yt_client):
response = await yt_client.get_html(SLUG_HOME)
metadata = YoutubeScrapper.fromresponse(response)
assert metadata.title == "Jeremy Griffith"
assert (
Expand All @@ -72,8 +63,7 @@ async def test_slug_metadata():


@pytest.mark.asyncio
async def test_avatar():
metadata = await youtube_get_metadata("@jonnygiger")
async def test_avatar(yt_client):
metadata = await yt_client.get_metadata("@jonnygiger")
assert metadata is not None
print(">>>>>>>>>>>>>>", metadata.avatar_url)
assert metadata.avatar_url is not None
43 changes: 17 additions & 26 deletions yourss/cache.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import json
from functools import wraps
from typing import Iterable

from loguru import logger
from redis import Redis

from .config import config
from .model import RssFeed
from .youtube import youtube_get_metadata, youtube_get_rss_feed
from .config import current_config
from .youtube import YoutubeWebClient


def create_redis(url: str | None) -> Redis | None:
def _redis_connect(url: str | None) -> Redis | None:
if isinstance(url, str) and len(url) > 0:
out = Redis.from_url(url)
if out.ping():
Expand All @@ -22,7 +20,7 @@ def create_redis(url: str | None) -> Redis | None:
logger.info("No redis cache defined")


redis = create_redis(config.REDIS_URL)
current_redis = _redis_connect(current_config.REDIS_URL)


def redis_cached(ttl: int | None = None):
Expand All @@ -34,24 +32,24 @@ def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# Test is redis is declared
if redis is None:
if current_redis is None:
return await func(*args, **kwargs)

# Test if redis is ready
if not redis.ping():
logger.warning("Redis ping failed: {}", redis)
if not current_redis.ping():
logger.warning("Redis ping failed: {}", current_redis)
return await func(*args, **kwargs)

# Generate the cache key from the function's arguments.
key_parts = [func.__name__] + list(args)
key_parts = [func.__name__] + list(map(str, args))
key = ":".join(key_parts)
result = redis.get(key)
result = current_redis.get(key)

if result is None:
# Run the function and cache the result for next time.
value = await func(*args, **kwargs)
value_json = json.dumps(value)
redis.set(key, value_json, ex=ttl)
current_redis.set(key, value_json, ex=ttl)
else:
# Skip the function entirely and use the cached value instead.
value_json = result.decode("utf-8")
Expand All @@ -64,18 +62,11 @@ async def wrapper(*args, **kwargs):
return decorator


@redis_cached(ttl=config.TTL_AVATAR)
async def get_avatar_url(name: str) -> str | None:
return (await youtube_get_metadata(name)).avatar_url
class YoutubeWebClientWithCache(YoutubeWebClient):
@redis_cached(ttl=current_config.TTL_AVATAR)
async def get_avatar_url(self, name: str) -> str | None:
return await super().get_avatar_url(name)


async def get_rssfeeds(
names: Iterable[str], workers: int = 4
) -> dict[str, RssFeed | None]:
@redis_cached(ttl=config.TTL_RSS)
async def get_response_text(name) -> str:
resp = await youtube_get_rss_feed(name)
resp.raise_for_status()
return resp.text

return {n: RssFeed.fromstring(await get_response_text(n)) for n in names}
@redis_cached(ttl=current_config.TTL_RSS)
async def get_rss_xml(self, name: str) -> str:
return await super().get_rss_xml(name)
4 changes: 2 additions & 2 deletions yourss/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ class AppConfig:
if k.startswith(YOURSS_USER_PREFIX)
}

config = environ.to_config(AppConfig)
current_config = environ.to_config(AppConfig)

logger.debug("Loaded configuration: {}", config)
logger.debug("Loaded configuration: {}", current_config)
for user, channels in YOURSS_USERS.items():
logger.info("Found user {}: {}", user, channels)
27 changes: 11 additions & 16 deletions yourss/static/yourss.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,33 +70,28 @@ $(document).ready(function () {
* Handle filter toggle
*/
function toggle_filter(button) {
if ((channel_id = $(button).data("channel-id"))) {
if (hidden_channel_ids.includes(channel_id)) {
hidden_channel_ids.splice(hidden_channel_ids.indexOf(channel_id), 1)
} else {
hidden_channel_ids.push(channel_id)
}
}
selected_channel_id = $(button).hasClass("btn-secondary")
? $(button).data("channel-id")
: null
$(".yourss-filter").each(function () {
if (hidden_channel_ids.includes($(this).data("channel-id"))) {
if ($(this).data("channel-id") === selected_channel_id) {
$(this).addClass("btn-primary")
$(this).removeClass("btn-secondary")
$(this).addClass("btn-outline-danger")
} else {
$(this).removeClass("btn-outline-danger")
$(this).addClass("btn-secondary")
$(this).removeClass("btn-primary")
}
})
$(".yourss-filterable").each(function () {
if (hidden_channel_ids.includes($(this).data("channel-id"))) {
$(this).css("display", "none")
} else {
if (selected_channel_id === null) {
$(this).css("display", "block")
} else if ($(this).data("channel-id") === selected_channel_id) {
$(this).css("display", "block")
} else {
$(this).css("display", "none")
}
})
}
$(document).ready(function () {
toggle_filter()
})

/**
* Handle modal player
Expand Down
13 changes: 3 additions & 10 deletions yourss/templates/view.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@
RSS
</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false"
aria-label="Toggle navigation">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
Expand Down Expand Up @@ -59,11 +58,9 @@
<div id="video-container" class="row">
{% for feed in feeds %}
{% for video in feed.entries %}
<div class="col-xxl-2 col-xl-3 col-lg-3 col-md-4 col-sm-6 mb-3 yourss-filterable" id="yourss-video-{{ video.video_id }}" data-video-id="{{ video.video_id }}"
data-channel-id="{{ feed.channel_id }}" data-channel-title="{{ feed.title|escape }}" data-video-title="{{ video.title|escape }}" data-published="{{ video.published }}">
<div class="col-xxl-2 col-xl-3 col-lg-3 col-md-4 col-sm-6 mb-3 yourss-filterable" id="yourss-video-{{ video.video_id }}" data-video-id="{{ video.video_id }}" data-channel-id="{{ feed.channel_id }}" data-channel-title="{{ feed.title|escape }}" data-video-title="{{ video.title|escape }}" data-published="{{ video.published }}">
<div class="card h-100">
<img class="card-img-top" src="{{ video.thumbnail_url }}" style="cursor: pointer" alt="{{ video.title|escape }}"
onclick="play_video('#yourss-video-{{ video.video_id }}')" />
<img class="card-img-top" src="{{ video.thumbnail_url }}" style="cursor: pointer" alt="{{ video.title|escape }}" onclick="play_video('#yourss-video-{{ video.video_id }}')" />
<div class="card-body d-flex flex-column">
<h5 class="card-title h5">
<img src="/api/avatar/{{ feed.channel_id }}" width="30" height="30" class="rounded-circle" />
Expand Down Expand Up @@ -118,10 +115,6 @@ <h5 class="card-title h5">
</div>
</main>

<script>
// Initialize hidden channels
var hidden_channel_ids = {{ hidden_channel_ids| safe }}
</script>
<script src="/static/yourss.js"></script>
</body>

Expand Down
11 changes: 2 additions & 9 deletions yourss/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,2 @@
def parse_channel_names(
text: str, delimiter: str = ",", disabled_prefix: str = "-"
) -> dict[str, bool]:
return {
s.removeprefix(disabled_prefix): not s.startswith(disabled_prefix)
for s in filter(
lambda x: len(x.removeprefix(disabled_prefix)) > 0, text.split(delimiter)
)
}
def parse_channel_names(text: str, delimiter: str = ",") -> set[str]:
return set(filter(None, text.split(delimiter)))
Loading

0 comments on commit c388320

Please sign in to comment.