Compare commits

...
10 Commits
38 changed files with 911 additions and 574 deletions
+1 -1
View File
@@ -10,4 +10,4 @@ max_line_length = 120
[*.md] [*.md]
max_line_length = off max_line_length = off
trim_trailing_whitespace = false trim_trailing_whitespace = true
+7
View File
@@ -1,2 +1,9 @@
[presets.python.tasks.setup.playwright] [presets.python.tasks.setup.playwright]
conditions = ["{{ '.venv/bin/playwright' | file }}"]
commands = [".venv/bin/playwright install chromium --with-deps --only-shell"] commands = [".venv/bin/playwright install chromium --with-deps --only-shell"]
[presets.locales.tasks.build.locales]
priority = 1
conditions = ["{{ 'locales/ru/LC_MESSAGES' | file }}"]
cwd = "locales/ru/LC_MESSAGES"
commands = ["msgfmt messages.po"]
+68 -6
View File
@@ -1,13 +1,75 @@
# API Gallery # API Gallery
Weather and TV program API Currently implemented APIs:
![API Gallery](docs/screenshot.png "API Gallery") - Weather forecast
- TV program
## View ## API
Swagger: https://api.shmyga.ru/docs
### Weather
#### Providers list
```
https://api.shmyga.ru/api/weather/providers
```
#### Locations search
```
https://api.shmyga.ru/api/weather/{provider}/locations?query={query}
```
#### Weather forecast
```
https://api.shmyga.ru/api/weather/{provider}/{location_id}/day/{date}
https://api.shmyga.ru/api/weather/{provider}/{location_id}/days/{days_count}
```
### TV program
#### Providers list
```
https://api.shmyga.ru/api/schedule/providers
```
#### Channels search
```
https://api.shmyga.ru/api/schedule/{provider}/channels?query={query}
```
#### TV program
```
https://api.shmyga.ru/api/schedule/{provider}/{channel_id}/{date}
```
## Providers
### Weather
| Provider | Site | API sample |
| :- | :- | :- |
| gismeteo | https://www.gismeteo.ru | https://api.shmyga.ru/api/weather/gismeteo/orel-4432/day/today |
| openweather | https://openweathermap.org | https://api.shmyga.ru/api/weather/openweather/52.9685433%3A36.0692477/day/today |
| yandex | https://yandex.ru/pogoda | https://api.shmyga.ru/api/weather/yandex/10/day/today |
| rp5 | https://rp5.ru | https://api.shmyga.ru/api/weather/rp5/Погода_в_Орле%2C_Орловская_область/day/today |
### TV program
| Provider | Site | API sample |
| :- | :- | :- |
| matchtv | https://matchtv.ru/tvguide | https://api.shmyga.ru/api/schedule/matchtv/igra/today |
| yandextv | https://tv.yandex.ru | https://api.shmyga.ru/api/schedule/yandextv/146/today |
## Demo
https://api.shmyga.ru https://api.shmyga.ru
## Swagger ![API Gallery](docs/screenshot.png "API Gallery")
https://api.shmyga.ru/docs
+25
View File
@@ -0,0 +1,25 @@
import datetime
from typing import Annotated
from fastapi import Depends, HTTPException, status
def date_parser(date: str) -> datetime.date:
if date == "today":
return datetime.date.today()
elif date == "tomorrow":
return datetime.date.today() + datetime.timedelta(days=1)
elif date.startswith("day-"):
days = int(date.split("-")[-1])
return datetime.date.today() + datetime.timedelta(days=days)
else:
try:
return datetime.date.strptime(date, "%Y-%m-%d")
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"date": f"{exc}"},
) from exc
DateDepends = Annotated[datetime.date, Depends(date_parser)]
+3 -3
View File
@@ -1,5 +1,3 @@
import datetime
from fastapi import APIRouter from fastapi import APIRouter
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
@@ -7,6 +5,8 @@ from gallery.easel.depends.schedule import ScheduleApiDepends
from gallery.sketch.schedule.api import ScheduleApi from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, Schedule from gallery.sketch.schedule.model import Channel, Schedule
from ...depends.date import DateDepends
router = APIRouter(prefix="/schedule", tags=["Schedule"]) router = APIRouter(prefix="/schedule", tags=["Schedule"])
@@ -22,6 +22,6 @@ async def find_api_schedule_channels(schedule_api: ScheduleApiDepends, query: st
@router.get("/{provider}/{channel}/{date}") @router.get("/{provider}/{channel}/{date}")
async def get_api_schedule_channel_schedule( async def get_api_schedule_channel_schedule(
schedule_api: ScheduleApiDepends, channel: str, date: datetime.date schedule_api: ScheduleApiDepends, channel: str, date: DateDepends
) -> Schedule: ) -> Schedule:
return await schedule_api.get_schedule(channel, date) return await schedule_api.get_schedule(channel, date)
+3 -3
View File
@@ -1,5 +1,3 @@
import datetime
from fastapi import APIRouter from fastapi import APIRouter
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
@@ -7,6 +5,8 @@ from gallery.easel.depends.weather import WeatherApiDepends
from gallery.sketch.weather.api import WeatherApi from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import Location, WeatherResponse from gallery.sketch.weather.model import Location, WeatherResponse
from ...depends.date import DateDepends
router = APIRouter(prefix="/weather", tags=["Weather"]) router = APIRouter(prefix="/weather", tags=["Weather"])
@@ -21,7 +21,7 @@ async def find_api_weather_locations(weather_api: WeatherApiDepends, query: str)
@router.get("/{provider}/{location}/day/{date}") @router.get("/{provider}/{location}/day/{date}")
async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: datetime.date) -> WeatherResponse: async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: DateDepends) -> WeatherResponse:
return await weather_api.get_day(location, date) return await weather_api.get_day(location, date)
@@ -23,7 +23,6 @@
<div class="app col-lg-8 mx-auto p-3 py-md-5"> <div class="app col-lg-8 mx-auto p-3 py-md-5">
{% if not is_widget %} {% if not is_widget %}
<header class="app-header pb-3 mb-5 border-bottom"> <header class="app-header pb-3 mb-5 border-bottom">
<div>{{request.query_params.widget}}</div>
<div class="link-list"> <div class="link-list">
<app-link href="/" <app-link href="/"
icon="gear">API Gallery</app-link> icon="gear">API Gallery</app-link>
@@ -1,57 +0,0 @@
import datetime
from enum import Enum
from typing import NamedTuple
class TagType(str, Enum):
DAY = "day"
DAYS = "days"
class Tag(NamedTuple):
type: TagType
date: datetime.date
days: int = 1
def __str__(self) -> str:
if self.type == TagType.DAY:
today = datetime.date.today()
day = (self.date - today).days
return f"day-{day}"
elif self.type == TagType.DAYS:
return f"days-{self.days}"
else:
raise ValueError(self.type)
class TagUtil:
@classmethod
def parse_tag(cls, tag: str) -> Tag:
if tag == "today":
return Tag(TagType.DAY, datetime.date.today())
elif tag == "tomorrow":
return Tag(TagType.DAY, datetime.date.today() + datetime.timedelta(days=1))
elif tag.startswith("day-"):
days = int(tag.split("-")[-1])
return Tag(
TagType.DAY,
datetime.date.today() + datetime.timedelta(days=days),
)
elif tag.startswith("days-"):
days = int(tag.split("-")[-1])
return Tag(TagType.DAYS, datetime.date.today(), days)
raise ValueError(tag)
@classmethod
def create_tag(
cls,
tag_type: TagType,
date: datetime.date,
day: int = 0,
days: int = 10,
) -> Tag:
if tag_type == TagType.DAY:
return Tag(tag_type, date + datetime.timedelta(days=day))
if tag_type == TagType.DAYS:
return Tag(tag_type, date, days)
raise ValueError(tag_type)
@@ -1,6 +1,7 @@
import datetime import datetime
import typing import typing
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit
from babel.dates import format_date from babel.dates import format_date
from fastapi import Request from fastapi import Request
@@ -10,7 +11,6 @@ from gallery.easel.core import AppRequest
from gallery.version import __version__ from gallery.version import __version__
from ...translation import _ from ...translation import _
from .tag import TagUtil
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]] ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
@@ -28,6 +28,26 @@ def base_context_processor(request: Request) -> dict:
} }
def build_url(
request: Request, *, path: list[str] | None = None, up: int | None = None, query: dict | None = None
) -> str:
parts = urlsplit(str(request.url))
if up:
path = [".."] * up
if path:
path_parts = parts.path.split("/")
for item in path:
if item == "..":
path_parts = path_parts[:-1]
else:
path_parts.append(str(item))
parts = parts._replace(path="/".join(path_parts))
if query:
query_dict = {**dict(parse_qsl(parts.query)), **query}
parts = parts._replace(query=urlencode(query_dict))
return parts.geturl()
def build_templates( def build_templates(
templates_dir: Path | None = None, filters: dict | None = None, context_processor: ContextProcessor | None = None templates_dir: Path | None = None, filters: dict | None = None, context_processor: ContextProcessor | None = None
) -> Jinja2Templates: ) -> Jinja2Templates:
@@ -44,8 +64,8 @@ def build_templates(
"version": __version__, "version": __version__,
"format_date": format_date, "format_date": format_date,
"datetime": datetime, "datetime": datetime,
"tag_util": TagUtil,
"DATE_FORMAT": "E, d MMMM Y", "DATE_FORMAT": "E, d MMMM Y",
"build_url": build_url,
} }
) )
if filters: if filters:
+1 -70
View File
@@ -1,70 +1 @@
import datetime from .route import router
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.easel.depends.api import api_resolver
from gallery.easel.depends.schedule import ScheduleApiDepends
from gallery.sketch.schedule.api import ScheduleApi
from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import timedelta_format
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(ScheduleApi),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
"timedelta_format": timedelta_format,
},
context_procesor,
)
router = APIRouter(prefix="/schedule")
@router.get("/", response_class=HTMLResponse)
async def get_schedule_index(request: AppRequest, provider: str | None = None, query: str | None = None):
if query and provider:
schedule_api = api_resolver(ScheduleApi)(request, provider)
channels = await schedule_api.find_channels(query)
else:
channels = []
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"channels": channels,
},
)
@router.get("/{provider}/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/tag/today")
@router.get("/{provider}/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
if tag_value.type == TagType.DAY:
response = await schedule_api.get_schedule(channel, tag_value.date)
else:
raise ValueError(tag)
return templates.TemplateResponse(
request=request,
name="channel.html",
context={
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
)
@@ -0,0 +1,18 @@
GROUP_MAP = {
"central": [
("yandextv", "146"), # pervyy
("yandextv", "587"), # rossya-1
("yandextv", "162"), # ntv
("yandextv", "79"), # sts
],
"sport": [
("matchtv", "matchtv"),
("matchtv", "igra"),
("matchtv", "arena"),
("matchtv", "futbol-1"),
("matchtv", "futbol-2"),
("matchtv", "futbol-3"),
("matchtv", "strana"),
("yandextv", "1799"), # start
],
}
@@ -0,0 +1,86 @@
import datetime
from pathlib import Path
from urllib import parse
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.easel.depends.api import api_resolver
from gallery.easel.depends.date import DateDepends
from gallery.easel.depends.schedule import ScheduleApiDepends
from gallery.sketch.bundle import ApiBundle
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Schedule
from ..common.utils.template import build_templates
from .bundle import GROUP_MAP
from .filters import timedelta_format
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(ScheduleApi),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
"timedelta_format": timedelta_format,
},
context_procesor,
)
router = APIRouter(prefix="/schedule")
@router.get("/", response_class=HTMLResponse)
async def get_schedule_index(request: AppRequest, provider: str | None = None, query: str | None = None):
if query and provider:
schedule_api = api_resolver(ScheduleApi)(request, provider)
channels = await schedule_api.find_channels(query)
else:
channels = []
return templates.TemplateResponse(
request=request,
name="index.html",
context={"channels": [item.model_dump() for item in channels], "groups": GROUP_MAP.keys()},
)
async def _get_group_schedule(api: ApiBundle, channels: list[tuple[str, str]], date: datetime.date) -> list[Schedule]:
return [
await api.get_api(ScheduleApi, provider).get_schedule(channel_id, date) for provider, channel_id in channels
]
@router.get("/group/{group_id}", response_class=RedirectResponse)
async def get_group_default(group_id: str):
return RedirectResponse(f"{parse.quote(group_id)}/today")
@router.get("/group/{group_id}/{date}", response_class=HTMLResponse)
async def get_group(request: AppRequest, group_id: str, date: DateDepends):
channels = GROUP_MAP.get(group_id, [tuple(item.split(":")) for item in group_id.split(",")])
responses = await _get_group_schedule(request.app.state.api, channels, date)
return templates.TemplateResponse(
request=request,
name="group.html",
context={"responses": responses},
)
@router.get("/{provider}/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/today")
@router.get("/{provider}/{channel}/{date}", response_class=HTMLResponse)
async def get_channel(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, date: DateDepends):
response = await schedule_api.get_schedule(channel, date)
return templates.TemplateResponse(
request=request,
name="channel.html",
context={"response": response},
)
@@ -1,4 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set live = request.query_params.live == '1' %}
{% set date = response.date %}
{% block title %} {% block title %}
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, {{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
locale=request.state.language)}} locale=request.state.language)}}
@@ -10,19 +13,23 @@ locale=request.state.language)}}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h4> <h4 class="mb-4">
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}" <a class="icon-link {{'disabled' if date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}"> href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
<i class="bi bi-arrow-left-square"></i> <i class="bi bi-arrow-left-square"></i>
</a> </a>
<a class="icon-link" <a class="icon-link"
href="../../.."> href="{{build_url(request, up=3)}}">
<i class="bi bi-arrow-up-square"></i> <i class="bi bi-arrow-up-square"></i>
</a> </a>
<a class="icon-link {{'text-secondary' if not live else ''}}"
href="{{build_url(request, query={'live': 0 if live else 1})}}">
<i class="bi bi-cast"></i>
</a>
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span> <span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span> <span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<a class="icon-link" <a class="icon-link"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}"> href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
<i class="bi bi-arrow-right-square"></i> <i class="bi bi-arrow-right-square"></i>
</a> </a>
</h4> </h4>
@@ -30,15 +37,14 @@ locale=request.state.language)}}
<thead> <thead>
<tr> <tr>
<td></td> <td></td>
<td></td> <td style="width:100%"></td>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for value in response.values %} {% for value in response.values %}
<tr class="{{'table-success' if value.live else ''}}"> <tr class="{{'d-none' if (live and not value.live) else 'table-success' if (not live and value.live) else ''}}">
<td> <td>
<span>{{value.start.strftime('%H:%M')}}</span> <span>{{value.start.strftime('%H:%M')}}</span>&nbsp;<span class="small ms-1">({{(value.end - value.start) | timedelta_format}})</span>
<span class="small ms-1">({{(value.end - value.start) | timedelta_format}})</span>
</td> </td>
<td>{{value.label}}</td> <td>{{value.label}}</td>
</tr> </tr>
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% set live = request.query_params.live == '1' %}
{% set date = responses[0].date %}
{% block title %}
{{_("TV program")}}
{% endblock %}
{% block header %}
<app-link href="/schedule"
icon="tv">{{_("TV program")}}</app-link>
{% endblock %}
{% block content %}
<h4 class="mb-4">
<a class="icon-link {{'disabled' if date == datetime.date.today() else ''}}"
href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
<i class="bi bi-arrow-left-square"></i>
</a>
<a class="icon-link"
href="{{build_url(request, up=3)}}">
<i class="bi bi-arrow-up-square"></i>
</a>
<a class="icon-link {{'text-secondary' if not live else ''}}"
href="{{build_url(request, query={'live': 0 if live else 1})}}">
<i class="bi bi-cast"></i>
</a>
<span>{{format_date(date, DATE_FORMAT, locale=request.state.language)}}</span>
<a class="icon-link"
href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
<i class="bi bi-arrow-right-square"></i>
</a>
</h4>
{% for response in responses %}
<h5>
<span class="badge rounded-pill text-bg-secondary fs-6">{{response.channel.provider}}</span>
<span>{{response.channel.name}}</span>
</h5>
<table class="table">
<thead>
<tr>
<td></td>
<td style="width:100%"></td>
</tr>
</thead>
<tbody>
{% for value in response.values %}
<tr class="{{'d-none' if (live and not value.live) else 'table-success' if (not live and value.live) else ''}}">
<td>
<span>{{value.start.strftime('%H:%M')}}</span>&nbsp;<span class="small ms-1">({{(value.end - value.start) |
timedelta_format}})</span>
</td>
<td>{{value.label}}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% endblock %}
@@ -35,17 +35,26 @@
</div> </div>
</form> </form>
{% if channels %} {% if channels %}
<ul id="channels" <div class="mb-4">
class="list-group mb-5"> <schedule-channel-list value="{{channels | tojson | forceescape}}"
{% for channel in channels %} onclick="scheduleChannelManager.store(element)"
<schedule-channel channel="{{channel.model_dump() | tojson | forceescape}}"></schedule-channel> onadd="scheduleChannelManager.add(element)"></schedule-channel-list>
<hr>
</div>
{% endif %}
<div class="mb-4">
<h3>{{_("Stored channels")}}</h3>
<schedule-channel-list id="storedChannels"
onremove="scheduleChannelManager.remove(element)"></schedule-channel-list>
</div>
<div class="mb-4">
<h3>{{_("Channel groups")}}</h3>
<ul class="list-group">
{% for group in groups %}
<schedule-channel-group value="{{group | tojson | forceescape}}"></schedule-channel-group>
{% endfor %} {% endfor %}
</ul> </ul>
<hr> </div>
{% endif %}
<ul id="storedChannels"
class="list-group mb-5">
</ul>
<script> <script>
(function () { (function () {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -53,9 +62,8 @@
if (searchQuery) { if (searchQuery) {
document.querySelector('#query').value = searchQuery; document.querySelector('#query').value = searchQuery;
} }
document.addEventListener("DOMContentLoaded", (event) => { document.addEventListener("DOMContentLoaded", (event) => {
scheduleChannelManager.loadChannels('#storedChannels'); scheduleChannelManager.load();
}); });
})(); })();
</script> </script>
+1 -102
View File
@@ -1,102 +1 @@
import datetime from .route import router
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.easel.depends.api import api_resolver
from gallery.easel.depends.weather import WeatherApiDepends
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse
from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import cloudness_icon, weather_icon_svg, wind_direction_icon
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(WeatherApi),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
"wind_direction_icon": wind_direction_icon,
"cloudness_icon": cloudness_icon,
"weather_icon_svg": weather_icon_svg,
},
context_procesor,
)
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
context={
"response": response,
},
)
router = APIRouter(prefix="/weather")
@router.get("/", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, provider: str | None = None, query: str | None = None):
if query and provider:
weather_api = api_resolver(WeatherApi)(request, provider)
locations = await weather_api.find_locations(query)
else:
locations = []
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"locations": locations,
},
)
@router.get("/{provider}/{location}", response_class=RedirectResponse)
async def get_weather(location: str):
return RedirectResponse(f"{location}/tag/today")
@router.get("/{provider}/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(
request: AppRequest,
weather_api: WeatherApiDepends,
location: str,
date: datetime.date,
):
response = await weather_api.get_day(location, date)
return build_weather_response(request, response)
@router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
response = await weather_api.get_days(location, days)
return build_weather_response(request, response)
@router.get("/{provider}/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, location: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
if tag_value.type == TagType.DAY:
response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS:
response = await weather_api.get_days(location, tag_value.days)
else:
raise ValueError(tag)
return build_weather_response(request, response)
@router.get("/zmiyevka-184640/tag/{tag}", response_class=RedirectResponse)
async def get_zmiyevka_weather_compat(request: AppRequest):
path_parts = request.url.path.split("/")
path_parts.insert(2, "gismeteo")
return RedirectResponse("/".join(path_parts))
+81
View File
@@ -0,0 +1,81 @@
from pathlib import Path
from typing import Literal
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.easel.depends.api import api_resolver
from gallery.easel.depends.date import DateDepends
from gallery.easel.depends.weather import WeatherApiDepends
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse
from ..common.utils.template import build_templates
from .filters import cloudness_icon, weather_icon_svg, wind_direction_icon
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(WeatherApi),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
"wind_direction_icon": wind_direction_icon,
"cloudness_icon": cloudness_icon,
"weather_icon_svg": weather_icon_svg,
},
context_procesor,
)
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
context={"response": response},
)
router = APIRouter(prefix="/weather")
@router.get("/", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, provider: str | None = None, query: str | None = None):
if query and provider:
weather_api = api_resolver(WeatherApi)(request, provider)
locations = await weather_api.find_locations(query)
else:
locations = []
return templates.TemplateResponse(
request=request,
name="index.html",
context={"locations": [item.model_dump() for item in locations]},
)
@router.get("/{provider}/{location}", response_class=RedirectResponse)
async def get_weather(location: str):
return RedirectResponse(f"{location}/day/today")
@router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
response = await weather_api.get_days(location, days)
return build_weather_response(request, response)
# TODO: tag mode route deprecated, used in tizen app
@router.get("/{provider}/{location}/{mode:str}/{date}", response_class=HTMLResponse)
async def get_weather_day(
request: AppRequest,
weather_api: WeatherApiDepends,
location: str,
date: DateDepends,
mode: Literal["day", "tag"], # pylint:disable=unused-argument
):
response = await weather_api.get_day(location, date)
return build_weather_response(request, response)
@@ -35,17 +35,18 @@
</div> </div>
</form> </form>
{% if locations %} {% if locations %}
<ul id="locations" <div class="mb-4">
class="list-group mb-5"> <weather-location-list value="{{locations | tojson | forceescape}}"
{% for location in locations %} onclick="weatherLocationManager.store(element)"
<weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location> onadd="weatherLocationManager.add(element)"></weather-location-list>
{% endfor %}
</ul>
<hr> <hr>
</div>
{% endif %} {% endif %}
<ul id="storedLocations" <div class="mb-4">
class="list-group mb-5"> <h3>{{_("Stored locations")}}</h3>
</ul> <weather-location-list id="storedLocations"
onremove="weatherLocationManager.remove(element)"></weather-location-list>
</div>
<script> <script>
(function () { (function () {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -55,7 +56,7 @@
} }
document.addEventListener("DOMContentLoaded", (event) => { document.addEventListener("DOMContentLoaded", (event) => {
weatherLocationManager.loadLocations('#storedLocations'); weatherLocationManager.load();
}); });
})(); })();
</script> </script>
@@ -1,5 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %} {% set date = response.date %}
{% block title %}{{_("Weather")}} | {{response.location}} | {{format_date(response.date, DATE_FORMAT,
locale=request.state.language)}}{% endblock %}
{% block header %} {% block header %}
<app-link href="/weather" <app-link href="/weather"
@@ -9,18 +12,18 @@
{% block content %} {% block content %}
<h4> <h4>
{% if response.period == 'day' %} {% if response.period == 'day' %}
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}" <a class="icon-link {{'disabled' if date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}"> href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
<i class="bi bi-arrow-left-square"></i> <i class="bi bi-arrow-left-square"></i>
</a> </a>
<a class="icon-link" <a class="icon-link"
href="../tag/days-10"> href="{{build_url(request, path=['..', '..', 'days', '10'])}}">
<i class="bi bi-arrow-up-square"></i> <i class="bi bi-arrow-up-square"></i>
</a> </a>
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span> <span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span> <span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<a class="icon-link" <a class="icon-link"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}"> href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
<i class="bi bi-arrow-right-square"></i> <i class="bi bi-arrow-right-square"></i>
</a> </a>
{% endif %} {% endif %}
@@ -47,7 +50,7 @@
{% if response.period == 'days' %} {% if response.period == 'days' %}
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}"> <td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
<span class="value {{'text-danger' if value.date.weekday() in [5,6] else ''}}"> <span class="value {{'text-danger' if value.date.weekday() in [5,6] else ''}}">
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}"> <a href="{{build_url(request, path=['..', '..', 'day', value.date.date()])}}">
{{format_date(value.date, 'E d', locale=request.state.language)}} {{format_date(value.date, 'E d', locale=request.state.language)}}
</a> </a>
</span> </span>
+4 -21
View File
@@ -14,36 +14,17 @@ from gallery.sketch.weather.model import (
from gallery.sketch.weather.util import merge_weather_values from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit from gallery.util import TimeUnit
from ...sketch.headers import build_headers
from .parser import parse_page from .parser import parse_page
logger = logging.getLogger("rp5") logger = logging.getLogger("rp5")
HEADERS: dict[str, str] = {
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,uk;q=0.6",
"Connection": "keep-alive",
"Host": "rp5.ru",
"Referer": "https://rp5.ru/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": ApiSource.DEFAULT_USER_AGENT,
"X-Requested-With": "XMLHttpRequest",
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
}
class RP5WeatherApi(WeatherApi): class RP5WeatherApi(WeatherApi):
PROVIDER = "rp5" PROVIDER = "rp5"
def _build_source(self) -> ApiSource: def _build_source(self) -> ApiSource:
return ApiSource( return ApiSource("https://rp5.ru", headers=build_headers("rp5.ru"))
"https://rp5.ru",
headers=HEADERS,
)
async def find_locations(self, query: str) -> list[Location]: async def find_locations(self, query: str) -> list[Location]:
endpoint = "docs/search/ru" endpoint = "docs/search/ru"
@@ -90,6 +71,8 @@ class RP5WeatherApi(WeatherApi):
for response in forecast: for response in forecast:
if response.date == date: if response.date == date:
return response return response
if date == datetime.date.today():
return WeatherResponse(location=location_id, date=date, period="day", values=[])
raise ValueError(f"Unsupported date: {date}") raise ValueError(f"Unsupported date: {date}")
async def get_days(self, location_id: str, days: int) -> WeatherResponse: async def get_days(self, location_id: str, days: int) -> WeatherResponse:
+1 -2
View File
@@ -48,7 +48,6 @@ def parse_page(page: BeautifulSoup) -> list[WeatherResponse]:
cloudness_row, cloudness_row,
precipitation_row, precipitation_row,
temperature_row, temperature_row,
_,
pressure_row, pressure_row,
wind_speed_row, wind_speed_row,
wind_gust_row, wind_gust_row,
@@ -100,7 +99,7 @@ def parse_page(page: BeautifulSoup) -> list[WeatherResponse]:
for index, cell in enumerate(wind_speed_row.select("td")[1:]): for index, cell in enumerate(wind_speed_row.select("td")[1:]):
values[index].wind_speed = _parse_wind_speed(cell) values[index].wind_speed = _parse_wind_speed(cell)
for index, cell in enumerate(wind_gust_row.select("td")[1:]): for index, cell in enumerate(wind_gust_row.select("td")[1:]):
values[index].wind_speed = _parse_wind_speed(cell) values[index].wind_gust = _parse_wind_speed(cell)
for index, cell in enumerate(wind_direction_row.select("td")[1:]): for index, cell in enumerate(wind_direction_row.select("td")[1:]):
values[index].wind_direction = parse_wind_direction(cell.text.strip()) values[index].wind_direction = parse_wind_direction(cell.text.strip())
for index, cell in enumerate(humidity_row.select("td")[1:]): for index, cell in enumerate(humidity_row.select("td")[1:]):
+3 -31
View File
@@ -8,44 +8,16 @@ from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource from gallery.sketch.source import ApiSource
from ...sketch.headers import build_headers
logger = logging.getLogger("matchtv") logger = logging.getLogger("matchtv")
HEADERS: dict[str, str] = {
"Accept": (
"text/html,"
"application/xhtml+xml,"
"application/xml;q=0.9,"
"image/avif,image/webp,"
"image/apng,*/*;q=0.8,"
"application/signed-exchange;v=b3;q=0.9"
),
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Connection": "keep-alive",
"Host": "tv.yandex.ru",
"sec-ch-ua": '"Chromium";v="100", " Not A;Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/100.0.4896.133 "
"Safari/537.36"
),
}
class YandexTvApi(ScheduleApi): class YandexTvApi(ScheduleApi):
PROVIDER = "yandextv" PROVIDER = "yandextv"
def _build_source(self) -> ApiSource: def _build_source(self) -> ApiSource:
return ApiSource("https://tv.yandex.ru", headers=HEADERS) return ApiSource("https://tv.yandex.ru", headers=build_headers("tv.yandex.ru"))
async def find_channels(self, query: str) -> list[Channel]: async def find_channels(self, query: str) -> list[Channel]:
url = ( url = (
+42
View File
@@ -0,0 +1,42 @@
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
)
DEFAULT_ACCEPT = (
"text/html,"
"application/xhtml+xml,"
"application/xml;q=0.9,"
"image/avif,"
"image/webp,"
"image/apng,"
"*/*;q=0.8,"
"application/signed-exchange;v=b3;q=0.7"
)
DEFAULT_HEADERS = {
"Accept": DEFAULT_ACCEPT,
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,uk;q=0.6",
"Cache-Control": "max-age=0",
"Connection": "keep-alive",
"Host": None,
"Referer": None,
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": DEFAULT_USER_AGENT,
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
}
def build_headers(host: str, *, user_agent: str | None = None) -> dict[str, str]:
result = DEFAULT_HEADERS.copy()
result["Host"] = host
result["Referer"] = f"https://{host}/"
if user_agent is not None:
result["User-Agent"] = user_agent
return result
+3 -8
View File
@@ -10,25 +10,20 @@ logger = logging.getLogger("source")
RequestMode = Literal["base", "browser"] RequestMode = Literal["base", "browser"]
DEFAULT_REQUEST_MODE: RequestMode = "base" DEFAULT_REQUEST_MODE: RequestMode = "base"
DEFAULT_METHOD = "GET" DEFAULT_METHOD: str = "GET"
class ApiSource: class ApiSource:
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)
DEFAULT_TIMEOUT = 30.0 DEFAULT_TIMEOUT = 30.0
def __init__( def __init__(
self, self,
base_url, base_url,
user_agent: str = DEFAULT_USER_AGENT,
timeout: float = DEFAULT_TIMEOUT, timeout: float = DEFAULT_TIMEOUT,
cookies: dict[str, str] | None = None, cookies: dict[str, str] | None = None,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
): ):
self._base_url = base_url self._base_url = base_url
self._user_agent = user_agent
self._timeout = timeout self._timeout = timeout
self._cookies = cookies self._cookies = cookies
self._headers = headers self._headers = headers
@@ -39,15 +34,15 @@ class ApiSource:
method: str = DEFAULT_METHOD, method: str = DEFAULT_METHOD,
data: Any | None = None, data: Any | None = None,
) -> str: ) -> str:
headers = {"User-Agent": self._user_agent, **(self._headers or {})}
async with aiohttp.ClientSession( async with aiohttp.ClientSession(
headers=headers, headers=self._headers,
cookies=self._cookies, cookies=self._cookies,
raise_for_status=True, raise_for_status=True,
) as session: ) as session:
async with session.request(method, url, timeout=self._timeout, data=data) as response: async with session.request(method, url, timeout=self._timeout, data=data) as response:
return await response.text() return await response.text()
# pylint:disable=unused-argument
async def _browser_request( async def _browser_request(
self, self,
url: str, url: str,
+9
View File
@@ -69,9 +69,18 @@ msgstr "Давление, мм рт. ст."
msgid "Humidity, %" msgid "Humidity, %"
msgstr "Влажность, %" msgstr "Влажность, %"
msgid "Stored locations"
msgstr "Сохранённые локации"
# tv # tv
msgid "TV program" msgid "TV program"
msgstr "Телепрограмма" msgstr "Телепрограмма"
msgid "Live broadcasts" msgid "Live broadcasts"
msgstr "Прямые трансляции" msgstr "Прямые трансляции"
msgid "Stored channels"
msgstr "Сохранённые каналы"
msgid "Channel groups"
msgstr "Группы каналов"
+196
View File
@@ -0,0 +1,196 @@
export interface Type<T> extends Function {
new (...args: any[]): T;
}
export abstract class ValueElement<T, O> extends HTMLElement {
static observedAttributes = ["value"];
private _value: T | null = null;
get value(): T | null {
return this._value;
}
set value(value: T | null) {
if (value) {
this.setAttribute("value", JSON.stringify(value));
} else if (this.hasAttribute("value")) {
this.removeAttribute("value");
}
}
options: O | null;
constructor(value: T | null = null, options: O | null = null) {
super();
if (value) {
this.value = value;
}
this.options = options;
this.onInit();
}
onInit() {}
beforeRefresh() {}
onRefresh() {}
abstract buildHTML(): string;
refresh() {
const rawValue = this.getAttribute("value");
if (rawValue) {
this._value = JSON.parse(rawValue);
} else {
this._value = null;
}
if (this.className) {
this.classList = this.className;
}
this.beforeRefresh();
this.innerHTML = this.buildHTML();
this.onRefresh();
}
connectedCallback() {
this.refresh();
}
attributeChangedCallback(name: string, oldValue: any, newValue: any) {
setTimeout(() => this.refresh());
}
}
export interface ListOptions<T> {
onClick?: (element: ListItemElement<T>) => void;
onAdd?: (element: ListItemElement<T>) => void;
onRemove?: (element: ListItemElement<T>) => void;
}
export abstract class ListItemElement<T> extends ValueElement<T, ListOptions<T>> {
abstract getHref(value: T): string;
abstract getContent(value: T): string;
className = "list-group-item list-group-item-action";
onInit(): void {
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
this.handleAddClick = this.handleAddClick.bind(this);
}
buildHTML(): string {
if (!this.value) {
return "";
}
const href = this.getHref(this.value);
const content = this.getContent(this.value);
return `
<a href="${href}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-success me-2 ${this.options?.onAdd ? "" : "d-none"}" style="color: green;" data-add>
&plus;
</span>
${content}
</span>
<span>
<span class="text-danger ms-2 ${this.options?.onRemove ? "" : "d-none"}" data-remove>
&#x2715;
</span>
</span>
</a>
`;
}
onRefresh(): void {
this.addEventListener("click", this.handleClick);
if (this.options?.onAdd) {
this.querySelector<HTMLElement>("[data-add]")?.addEventListener("click", this.handleAddClick);
} else {
this.querySelector("[data-add]")?.remove();
}
if (this.options?.onRemove) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
handleClick(event: MouseEvent) {
if (this.value && this.options?.onClick) {
this.options.onClick(this);
}
}
handleAddClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.value && this.options?.onAdd) {
this.options.onAdd(this);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.value && this.options?.onRemove) {
this.options.onRemove(this);
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-add]")?.removeEventListener("click", this.handleAddClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
}
export abstract class ListElement<T, C extends ListItemElement<T> = ListItemElement<T>> extends ValueElement<
T[],
ListOptions<T>
> {
static observedAttributes = ["value", "onadd", "onremove", "onclick"];
abstract childType: Type<C>;
className = "list-group";
buildHTML(): string {
return "";
}
beforeRefresh() {
if (this.hasAttribute("onclick")) {
const onclick = new Function("element", this.getAttribute("onclick")!) as ListOptions<T>["onClick"];
if (!this.options) {
this.options = {};
}
this.options.onClick = onclick;
}
if (this.hasAttribute("onadd")) {
const onadd = new Function("element", this.getAttribute("onadd")!) as ListOptions<T>["onAdd"];
if (!this.options) {
this.options = {};
}
this.options.onAdd = onadd;
}
if (this.hasAttribute("onremove")) {
const onremove = new Function("element", this.getAttribute("onremove")!) as ListOptions<T>["onRemove"];
if (!this.options) {
this.options = {};
}
this.options.onRemove = onremove;
}
}
onRefresh() {
if (this.value) {
for (const item of this.value) {
this.appendChild(new this.childType(item, this.options));
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import { ListElement, ListItemElement } from "./component";
import { DataStorage, StorageValue } from "./storage";
export class DataManager<T extends StorageValue> {
private storage: DataStorage<T>;
private get dataList(): ListElement<T> {
return document.querySelector(this.dataListSelector)!;
}
constructor(
listComponentType: typeof ListElement<T>, //prevent removing as unusing
storageKey: string,
private dataListSelector: string,
) {
this.storage = new DataStorage(storageKey);
this.load = this.load.bind(this);
this.store = this.store.bind(this);
this.add = this.add.bind(this);
this.remove = this.remove.bind(this);
}
load() {
this.dataList.value = this.storage.getAll();
}
store(element: ListItemElement<T>) {
if (element.value) {
this.storage.add(element.value);
}
}
add(element: ListItemElement<T>) {
const value = element.value;
value;
if (value && this.storage.add(value)) {
const clone: ListItemElement<T> = element.cloneNode(true) as ListItemElement<T>;
clone.options = { onRemove: this.remove };
this.dataList.appendChild(clone);
}
}
remove(element: ListItemElement<T>) {
const value = element.value;
if (value) {
this.storage.remove(value.id);
element.remove();
}
}
}
+46
View File
@@ -0,0 +1,46 @@
export interface StorageValue {
id: string;
}
export class DataStorage<T extends StorageValue> {
constructor(private storageKey: string) {}
private readValues(): T[] {
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "[]");
if (!Array.isArray(objects)) {
return Object.values(objects);
}
return objects;
}
private writeValues(values: T[]) {
window.localStorage.setItem(this.storageKey, JSON.stringify(values));
}
getAll(): T[] {
return this.readValues();
}
add(value: T): boolean {
const values = this.readValues();
for (const v of values) {
if (v.id == value.id) {
return false;
}
}
values.push(value);
this.writeValues(values);
return true;
}
remove(id: string) {
const values = this.readValues();
for (let i = 0; i < values.length; i++) {
if (values[i].id == id) {
values.splice(i, 1);
this.writeValues(values);
return;
}
}
}
}
-110
View File
@@ -1,110 +0,0 @@
export interface Channel {
id: string;
name: string;
provider: string;
}
export class ScheduleChannelStorage {
private storageKey: string = "schedule:channels";
getAll(): Channel[] {
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
return Object.values(objects);
}
add(object: Channel) {
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
objects[object.id] = object;
window.localStorage.setItem(this.storageKey, JSON.stringify(objects));
}
remove(id: string) {
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
delete objects[id];
window.localStorage.setItem(this.storageKey, JSON.stringify(objects));
}
}
export class ScheduleChannelManager {
loadChannels(selector: string) {
const channels = window.scheduleChannelStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
for (const channel of channels) {
const element = new ScheduleChannelElement();
element.setAttribute("channel", JSON.stringify(channel));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
class ScheduleChannelElement extends HTMLElement {
static observedAttributes = ["channel", "removable"];
channel: Channel | null = null;
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
}
connectedCallback() {
this.channel = JSON.parse(this.getAttribute("channel") || "{}");
this.innerHTML = `
<a href="${this.channel?.provider}/${this.channel?.id}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-primary">${this.channel?.name}</span>
</span>
<span>
<span class="badge text-bg-secondary">${this.channel?.provider}</span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
</span>
</a>`;
this.classList = "list-group-item list-group-item-action";
this.addEventListener("click", this.handleClick);
if (this.hasAttribute("removable")) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
handleClick(event: MouseEvent) {
if (this.channel) {
window.scheduleChannelStorage.add(this.channel);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.channel) {
window.scheduleChannelStorage.remove(this.channel.id);
}
this.remove();
}
}
customElements.define("schedule-channel", ScheduleChannelElement);
declare global {
interface Window {
scheduleChannelStorage: ScheduleChannelStorage;
scheduleChannelManager: ScheduleChannelManager;
}
}
window.scheduleChannelStorage = new ScheduleChannelStorage();
window.scheduleChannelManager = new ScheduleChannelManager();
+52
View File
@@ -0,0 +1,52 @@
import { ListElement, ListItemElement, ValueElement } from "../base/component";
import { Channel } from "./model";
export class ScheduleChannelGroup extends ValueElement<string | Channel[], {}> {
className: string = "list-group-item list-group-item-action";
buildHTML(): string {
const groupName = Array.isArray(this.value) ? "Open All" : this.value;
const groupId = Array.isArray(this.value)
? this.value.map((item) => `${item.provider}:${item.id}`).join(",")
: this.value || "";
return `
<a href="group/${encodeURIComponent(groupId)}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-primary" style="text-transform: capitalize">${groupName}</span>
</span>
</a>`;
}
}
customElements.define("schedule-channel-group", ScheduleChannelGroup);
export class ScheduleChannelItem extends ListItemElement<Channel> {
getHref(value: Channel): string {
return `${value.provider}/${value.id}`;
}
getContent(value: Channel): string {
return `
<span class="badge me-1 text-bg-secondary">${value.provider}</span>
<span class="text-primary">${value.name}</span>
`;
}
}
export class ScheduleChannelList extends ListElement<Channel, ScheduleChannelItem> {
childType = ScheduleChannelItem;
public onRefresh(): void {
super.onRefresh();
if (this.value?.length) {
const groupElement = new ScheduleChannelGroup(this.value);
this.prepend(groupElement);
} else {
this.prepend("Empty");
}
}
}
customElements.define("schedule-channel-item", ScheduleChannelItem);
customElements.define("schedule-channel-list", ScheduleChannelList);
+11
View File
@@ -0,0 +1,11 @@
import { DataManager } from "../base/manager";
import { ScheduleChannelList } from "./component";
import { Channel } from "./model";
declare global {
interface Window {
scheduleChannelManager: DataManager<Channel>;
}
}
window.scheduleChannelManager = new DataManager(ScheduleChannelList, "schedule:channels", "#storedChannels");
+5
View File
@@ -0,0 +1,5 @@
export interface Channel {
id: string;
name: string;
provider: string;
}
-118
View File
@@ -1,118 +0,0 @@
export interface Location {
id: string;
name: string;
provider: string;
country_code: string;
country?: string;
district?: string;
subdistrict?: string;
}
export class WeatherLocationStorage {
private storageKey: string = "weather:locations";
getAll(): Location[] {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
return Object.values(locations);
}
add(location: Location) {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
locations[location.id] = location;
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
}
remove(id: string) {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
delete locations[id];
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
}
}
export class WeatherLocationManager {
loadLocations(selector: string) {
const locations = window.weatherLocationStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
for (const location of locations) {
const element = new WeatherLocationElement();
element.setAttribute("location", JSON.stringify(location));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
class WeatherLocationElement extends HTMLElement {
static observedAttributes = ["location", "removable"];
location: Location | null = null;
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
}
connectedCallback() {
this.location = JSON.parse(this.getAttribute("location") || "{}");
this.innerHTML = `
<a href="${this.location?.provider}/${this.location?.id}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="badge me-1 text-bg-secondary">${this.location?.provider}</span>
<span class="fi fi-${this.location?.country_code} me-1"></span>
<span class="text-primary">${this.location?.name}</span>
<span class="small ms-1 text-secondary">
${this.location?.country}, ${this.location?.district}, ${this.location?.subdistrict}
</span>
</span>
<span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
</span>
</a>`;
this.classList = 'list-group-item list-group-item-action';
this.addEventListener("click", this.handleClick);
if (this.hasAttribute("removable")) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
handleClick(event: MouseEvent) {
if (this.location) {
window.weatherLocationStorage.add(this.location);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.location) {
window.weatherLocationStorage.remove(this.location.id);
}
this.remove();
}
}
customElements.define("weather-location", WeatherLocationElement);
declare global {
interface Window {
weatherLocationStorage: WeatherLocationStorage;
weatherLocationManager: WeatherLocationManager;
}
}
window.weatherLocationStorage = new WeatherLocationStorage();
window.weatherLocationManager = new WeatherLocationManager();
+33
View File
@@ -0,0 +1,33 @@
import { ListElement, ListItemElement } from "../base/component";
import { Location } from "./model";
export class WheatherLocationItem extends ListItemElement<Location> {
getHref(value: Location): string {
return `${value.provider}/${value.id}`;
}
getContent(value: Location): string {
return `
<span class="badge me-1 text-bg-secondary">${value.provider}</span>
<span class="fi fi-${value.country_code} me-1"></span>
<span class="text-primary">${value.name}</span>
<span class="small ms-1 text-secondary">
${value.country}, ${value.district}, ${value.subdistrict}
</span>
`;
}
}
export class WeatherLocationList extends ListElement<Location, WheatherLocationItem> {
childType = WheatherLocationItem;
public onRefresh(): void {
super.onRefresh();
if (!this.value?.length) {
this.prepend("Empty");
}
}
}
customElements.define("weather-location-item", WheatherLocationItem);
customElements.define("weather-location-list", WeatherLocationList);
+11
View File
@@ -0,0 +1,11 @@
import { DataManager } from "../base/manager";
import { WeatherLocationList } from "./component";
import { Location } from "./model";
declare global {
interface Window {
weatherLocationManager: DataManager<Location>;
}
}
window.weatherLocationManager = new DataManager(WeatherLocationList, "weather:locations", "#storedLocations");
+9
View File
@@ -0,0 +1,9 @@
export interface Location {
id: string;
name: string;
provider: string;
country_code: string;
country?: string;
district?: string;
subdistrict?: string;
}
+3
View File
@@ -45,4 +45,7 @@
&.bi-file-earmark-text { &.bi-file-earmark-text {
mask-image: url(bootstrap-icons/icons/file-earmark-text.svg); mask-image: url(bootstrap-icons/icons/file-earmark-text.svg);
} }
&.bi-cast {
mask-image: url(bootstrap-icons/icons/cast.svg);
}
} }
+1
View File
@@ -12,6 +12,7 @@ class MockSource(ApiSource):
self._path = path self._path = path
self._mapping = mapping self._mapping = mapping
# pylint:disable=arguments-differ,unused-argument
async def request(self, endpoint: str, /, **kwargs) -> str: async def request(self, endpoint: str, /, **kwargs) -> str:
for pattern, filename in self._mapping.items(): for pattern, filename in self._mapping.items():
if pattern in endpoint: if pattern in endpoint: