Configure git to always use LF (#1013)

This commit is contained in:
Craig Alexander 2025-07-31 10:29:58 -04:00 committed by GitHub
parent b0c435caf3
commit 6b859eb436
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 359 additions and 359 deletions

2
.gitattributes vendored
View File

@ -1 +1 @@
docker_assets\run.sh eol=lf * text=auto eol=lf

View File

@ -1,352 +1,352 @@
""" """
Loose collection of helper functions Loose collection of helper functions
- don't import AppConfig class here to avoid circular imports - don't import AppConfig class here to avoid circular imports
""" """
import json import json
import os import os
import random import random
import string import string
import subprocess import subprocess
from datetime import datetime, timezone from datetime import datetime, timezone
from time import sleep from time import sleep
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
from common.src.es_connect import IndexPaginate from common.src.es_connect import IndexPaginate
def ignore_filelist(filelist: list[str]) -> list[str]: def ignore_filelist(filelist: list[str]) -> list[str]:
"""ignore temp files for os.listdir sanitizer""" """ignore temp files for os.listdir sanitizer"""
to_ignore = [ to_ignore = [
"@eaDir", "@eaDir",
"Icon\r\r", "Icon\r\r",
"Network Trash Folder", "Network Trash Folder",
"Temporary Items", "Temporary Items",
] ]
cleaned: list[str] = [] cleaned: list[str] = []
for file_name in filelist: for file_name in filelist:
if file_name.startswith(".") or file_name in to_ignore: if file_name.startswith(".") or file_name in to_ignore:
continue continue
cleaned.append(file_name) cleaned.append(file_name)
return cleaned return cleaned
def randomizor(length: int) -> str: def randomizor(length: int) -> str:
"""generate random alpha numeric string""" """generate random alpha numeric string"""
pool: str = string.digits + string.ascii_letters pool: str = string.digits + string.ascii_letters
return "".join(random.choice(pool) for i in range(length)) return "".join(random.choice(pool) for i in range(length))
def rand_sleep(config) -> None: def rand_sleep(config) -> None:
"""randomized sleep based on config""" """randomized sleep based on config"""
sleep_config = config["downloads"].get("sleep_interval") sleep_config = config["downloads"].get("sleep_interval")
if not sleep_config: if not sleep_config:
return return
secs = random.randrange(int(sleep_config * 0.5), int(sleep_config * 1.5)) secs = random.randrange(int(sleep_config * 0.5), int(sleep_config * 1.5))
sleep(secs) sleep(secs)
def requests_headers() -> dict[str, str]: def requests_headers() -> dict[str, str]:
"""build header with random user agent for requests outside of yt-dlp""" """build header with random user agent for requests outside of yt-dlp"""
chrome_versions = ( chrome_versions = (
"90.0.4430.212", "90.0.4430.212",
"90.0.4430.24", "90.0.4430.24",
"90.0.4430.70", "90.0.4430.70",
"90.0.4430.72", "90.0.4430.72",
"90.0.4430.85", "90.0.4430.85",
"90.0.4430.93", "90.0.4430.93",
"91.0.4472.101", "91.0.4472.101",
"91.0.4472.106", "91.0.4472.106",
"91.0.4472.114", "91.0.4472.114",
"91.0.4472.124", "91.0.4472.124",
"91.0.4472.164", "91.0.4472.164",
"91.0.4472.19", "91.0.4472.19",
"91.0.4472.77", "91.0.4472.77",
"92.0.4515.107", "92.0.4515.107",
"92.0.4515.115", "92.0.4515.115",
"92.0.4515.131", "92.0.4515.131",
"92.0.4515.159", "92.0.4515.159",
"92.0.4515.43", "92.0.4515.43",
"93.0.4556.0", "93.0.4556.0",
"93.0.4577.15", "93.0.4577.15",
"93.0.4577.63", "93.0.4577.63",
"93.0.4577.82", "93.0.4577.82",
"94.0.4606.41", "94.0.4606.41",
"94.0.4606.54", "94.0.4606.54",
"94.0.4606.61", "94.0.4606.61",
"94.0.4606.71", "94.0.4606.71",
"94.0.4606.81", "94.0.4606.81",
"94.0.4606.85", "94.0.4606.85",
"95.0.4638.17", "95.0.4638.17",
"95.0.4638.50", "95.0.4638.50",
"95.0.4638.54", "95.0.4638.54",
"95.0.4638.69", "95.0.4638.69",
"95.0.4638.74", "95.0.4638.74",
"96.0.4664.18", "96.0.4664.18",
"96.0.4664.45", "96.0.4664.45",
"96.0.4664.55", "96.0.4664.55",
"96.0.4664.93", "96.0.4664.93",
"97.0.4692.20", "97.0.4692.20",
) )
template = ( template = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) " + "AppleWebKit/537.36 (KHTML, like Gecko) "
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36" + f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
) )
return {"User-Agent": template} return {"User-Agent": template}
def date_parser(timestamp: int | str | None) -> str | None: def date_parser(timestamp: int | str | None) -> str | None:
"""return formatted date string""" """return formatted date string"""
if timestamp is None: if timestamp is None:
return None return None
if isinstance(timestamp, int): if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc) date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc)
elif isinstance(timestamp, str): elif isinstance(timestamp, str):
date_obj = datetime.strptime(timestamp, "%Y-%m-%d") date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
date_obj = date_obj.replace(tzinfo=timezone.utc) date_obj = date_obj.replace(tzinfo=timezone.utc)
else: else:
raise TypeError(f"invalid timestamp: {timestamp}") raise TypeError(f"invalid timestamp: {timestamp}")
return date_obj.isoformat() return date_obj.isoformat()
def time_parser(timestamp: str) -> float: def time_parser(timestamp: str) -> float:
"""return seconds from timestamp, false on empty""" """return seconds from timestamp, false on empty"""
if not timestamp: if not timestamp:
return False return False
if timestamp.isnumeric(): if timestamp.isnumeric():
return int(timestamp) return int(timestamp)
hours, minutes, seconds = timestamp.split(":", maxsplit=3) hours, minutes, seconds = timestamp.split(":", maxsplit=3)
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds) return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
def clear_dl_cache(cache_dir: str) -> int: def clear_dl_cache(cache_dir: str) -> int:
"""clear leftover files from dl cache""" """clear leftover files from dl cache"""
print("clear download cache") print("clear download cache")
download_cache_dir = os.path.join(cache_dir, "download") download_cache_dir = os.path.join(cache_dir, "download")
leftover_files = ignore_filelist(os.listdir(download_cache_dir)) leftover_files = ignore_filelist(os.listdir(download_cache_dir))
for cached in leftover_files: for cached in leftover_files:
to_delete = os.path.join(download_cache_dir, cached) to_delete = os.path.join(download_cache_dir, cached)
os.remove(to_delete) os.remove(to_delete)
return len(leftover_files) return len(leftover_files)
def get_mapping() -> dict: def get_mapping() -> dict:
"""read index_mapping.json and get expected mapping and settings""" """read index_mapping.json and get expected mapping and settings"""
with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f: with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
index_config: dict = json.load(f).get("index_config") index_config: dict = json.load(f).get("index_config")
return index_config return index_config
def is_shorts(youtube_id: str) -> bool: def is_shorts(youtube_id: str) -> bool:
"""check if youtube_id is a shorts video, bot not it it's not a shorts""" """check if youtube_id is a shorts video, bot not it it's not a shorts"""
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}" shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
cookies = {"SOCS": "CAI"} cookies = {"SOCS": "CAI"}
try: try:
response = requests.head( response = requests.head(
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10 shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
) )
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
# assume video on error # assume video on error
return False return False
return response.status_code == 200 return response.status_code == 200
def get_duration_sec(file_path: str) -> int: def get_duration_sec(file_path: str) -> int:
"""get duration of media file from file path""" """get duration of media file from file path"""
duration = subprocess.run( duration = subprocess.run(
[ [
"ffprobe", "ffprobe",
"-v", "-v",
"error", "error",
"-show_entries", "-show_entries",
"format=duration", "format=duration",
"-of", "-of",
"default=noprint_wrappers=1:nokey=1", "default=noprint_wrappers=1:nokey=1",
file_path, file_path,
], ],
capture_output=True, capture_output=True,
check=True, check=True,
) )
duration_raw = duration.stdout.decode().strip() duration_raw = duration.stdout.decode().strip()
if duration_raw == "N/A": if duration_raw == "N/A":
return 0 return 0
duration_sec = int(float(duration_raw)) duration_sec = int(float(duration_raw))
return duration_sec return duration_sec
def get_duration_str(seconds: int | float) -> str: def get_duration_str(seconds: int | float) -> str:
"""Return a human-readable duration string from seconds.""" """Return a human-readable duration string from seconds."""
if not seconds: if not seconds:
return "NA" return "NA"
seconds = int(seconds) seconds = int(seconds)
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)] units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
duration_parts = [] duration_parts = []
for unit_label, unit_seconds in units: for unit_label, unit_seconds in units:
if seconds >= unit_seconds: if seconds >= unit_seconds:
unit_count, seconds = divmod(seconds, unit_seconds) unit_count, seconds = divmod(seconds, unit_seconds)
duration_parts.append(f"{unit_count:02}{unit_label}") duration_parts.append(f"{unit_count:02}{unit_label}")
duration_parts[0] = duration_parts[0].lstrip("0") duration_parts[0] = duration_parts[0].lstrip("0")
return " ".join(duration_parts) return " ".join(duration_parts)
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]: def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS""" """parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
allowed_hosts: list[str] = [ allowed_hosts: list[str] = [
"localhost", "localhost",
"tubearchivist", "tubearchivist",
] ]
csrf_trusted_origins: list[str] = [ csrf_trusted_origins: list[str] = [
"http://localhost", "http://localhost",
"http://tubearchivist", "http://tubearchivist",
] ]
for host in ta_host.split(): for host in ta_host.split():
host_clean = host.strip() host_clean = host.strip()
if not host_clean.startswith("http"): if not host_clean.startswith("http"):
host_clean = f"http://{host_clean}" host_clean = f"http://{host_clean}"
parsed = urlparse(host_clean) parsed = urlparse(host_clean)
allowed_hosts.append(f"{parsed.hostname}") allowed_hosts.append(f"{parsed.hostname}")
cors_url = f"{parsed.scheme}://{parsed.hostname}" cors_url = f"{parsed.scheme}://{parsed.hostname}"
if parsed.port: if parsed.port:
cors_url = f"{cors_url}:{parsed.port}" cors_url = f"{cors_url}:{parsed.port}"
csrf_trusted_origins.append(cors_url) csrf_trusted_origins.append(cors_url)
return allowed_hosts, csrf_trusted_origins return allowed_hosts, csrf_trusted_origins
def get_stylesheets() -> list: def get_stylesheets() -> list:
"""Get all valid stylesheets from /static/css""" """Get all valid stylesheets from /static/css"""
stylesheets = [ stylesheets = [
"dark.css", "dark.css",
"light.css", "light.css",
"matrix.css", "matrix.css",
"midnight.css", "midnight.css",
"custom.css", "custom.css",
] ]
return stylesheets return stylesheets
def check_stylesheet(stylesheet: str): def check_stylesheet(stylesheet: str):
"""Check if a stylesheet exists. Return dark.css as a fallback""" """Check if a stylesheet exists. Return dark.css as a fallback"""
if stylesheet in get_stylesheets(): if stylesheet in get_stylesheets():
return stylesheet return stylesheet
return "dark.css" return "dark.css"
def is_missing( def is_missing(
to_check: str | list[str], to_check: str | list[str],
index_name: str = "ta_video,ta_download", index_name: str = "ta_video,ta_download",
on_key: str = "youtube_id", on_key: str = "youtube_id",
) -> list[str]: ) -> list[str]:
"""id or list of ids that are missing from index_name""" """id or list of ids that are missing from index_name"""
if isinstance(to_check, str): if isinstance(to_check, str):
to_check = [to_check] to_check = [to_check]
data = { data = {
"query": {"terms": {on_key: to_check}}, "query": {"terms": {on_key: to_check}},
"_source": [on_key], "_source": [on_key],
} }
result = IndexPaginate(index_name, data=data).get_results() result = IndexPaginate(index_name, data=data).get_results()
existing_ids = [i[on_key] for i in result] existing_ids = [i[on_key] for i in result]
dl = [i for i in to_check if i not in existing_ids] dl = [i for i in to_check if i not in existing_ids]
return dl return dl
def get_channel_overwrites() -> dict[str, dict[str, Any]]: def get_channel_overwrites() -> dict[str, dict[str, Any]]:
"""get overwrites indexed my channel_id""" """get overwrites indexed my channel_id"""
data = { data = {
"query": { "query": {
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]} "bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
}, },
"_source": ["channel_id", "channel_overwrites"], "_source": ["channel_id", "channel_overwrites"],
} }
result = IndexPaginate("ta_channel", data).get_results() result = IndexPaginate("ta_channel", data).get_results()
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result} overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
return overwrites return overwrites
def get_channels( def get_channels(
subscribed_only: bool, source: list[str] | None = None subscribed_only: bool, source: list[str] | None = None
) -> list[dict]: ) -> list[dict]:
"""get a list of all channels""" """get a list of all channels"""
data = { data = {
"sort": [{"channel_name.keyword": {"order": "asc"}}], "sort": [{"channel_name.keyword": {"order": "asc"}}],
} }
if subscribed_only: if subscribed_only:
query = {"term": {"channel_subscribed": {"value": True}}} query = {"term": {"channel_subscribed": {"value": True}}}
else: else:
query = {"match_all": {}} query = {"match_all": {}}
data["query"] = query # type: ignore data["query"] = query # type: ignore
if source: if source:
data["_source"] = source # type: ignore data["_source"] = source # type: ignore
all_channels = IndexPaginate("ta_channel", data).get_results() all_channels = IndexPaginate("ta_channel", data).get_results()
return all_channels return all_channels
def get_playlists( def get_playlists(
subscribed_only: bool, source: list[str] | None = None subscribed_only: bool, source: list[str] | None = None
) -> list[dict]: ) -> list[dict]:
"""get list of playlists""" """get list of playlists"""
data = { data = {
"sort": [{"playlist_channel.keyword": {"order": "desc"}}], "sort": [{"playlist_channel.keyword": {"order": "desc"}}],
} }
must_list = [{"term": {"playlist_active": {"value": True}}}] must_list = [{"term": {"playlist_active": {"value": True}}}]
if subscribed_only: if subscribed_only:
must_list.append({"term": {"playlist_subscribed": {"value": True}}}) must_list.append({"term": {"playlist_subscribed": {"value": True}}})
data = {"query": {"bool": {"must": must_list}}} # type: ignore data = {"query": {"bool": {"must": must_list}}} # type: ignore
if source: if source:
data["_source"] = source # type: ignore data["_source"] = source # type: ignore
all_playlists = IndexPaginate("ta_playlist", data).get_results() all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists return all_playlists
def calc_is_watched(duration: float, position: float) -> bool: def calc_is_watched(duration: float, position: float) -> bool:
"""considered watched based on duration position""" """considered watched based on duration position"""
if not duration or duration <= 0: if not duration or duration <= 0:
return False return False
if duration < 60: if duration < 60:
threshold = 0.5 threshold = 0.5
elif duration > 900: elif duration > 900:
threshold = 1 - (180 / duration) threshold = 1 - (180 / duration)
else: else:
threshold = 0.9 threshold = 0.9
return position >= duration * threshold return position >= duration * threshold

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" width="960pt" height="960pt" viewBox="0 0 960 960" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg"> <svg version="1.0" width="960pt" height="960pt" viewBox="0 0 960 960" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(0,960)scale(.075,.075)"> <g transform="translate(0,960)scale(.075,.075)">
<path id="path1" d="M 740 -10481 c -77 24 -152 94 -170 160 -6 23 -10 376 -10 946 l 0 910 24 50 c 28 59 66 97 121 122 38 17 268 18 5620 21 3923 1 5595 -1 5632 -8 70 -15 124 -56 157 -121 l 27 -54 0 -920 c 0 -637 -4 -932 -11 -958 -17 -58 -84 -122 -150 -141 -49 -15 -544 -16 -5635 -15 -3069 0 -5591 4 -5605 8 z M 9615 -7400 c -11 4 -31 20 -45 35 l -25 27 -3 889 c -3 978 -5 935 57 972 27 16 117 17 1253 17 1321 0 1252 3 1293 -54 20 -27 20 -43 20 -919 0 -860 -1 -893 -19 -920 -41 -60 37 -57 -1293 -56 -670 0 -1227 4 -1238 9 z M 3658 -7384 c -61 32 -58 -10 -58 959 0 973 -3 928 60 960 25 13 178 15 1250 15 1072 0 1225 -2 1250 -15 63 -32 60 13 60 -960 0 -973 3 -928 -60 -960 -25 -13 -178 -15 -1252 -15 -1062 1 -1227 3 -1250 16 z M 6551 -7374 c -17 14 -35 42 -41 62 -6 24 -10 339 -10 892 0 826 1 856 20 898 35 78 -60 73 1315 70 l 1225 -3 32 -33 33 -32 3 -895 c 2 -788 0 -899 -13 -925 -33 -64 48 -60 -1304 -60 l -1229 0 -31 26 z M 616 -7369 c -59 46 -56 0 -56 951 0 976 -4 924 70 960 33 17 113 18 1250 18 1187 0 1216 -1 1247 -20 66 -40 63 12 63 -955 0 -791 -2 -880 -16 -911 -33 -68 54 -64 -1302 -64 l -1229 0 -27 21 z M 9615 -4530 c -11 4 -31 20 -45 35 l -25 27 -3 901 c -2 891 -2 902 18 935 40 65 -32 62 1296 62 1335 0 1255 4 1292 -64 16 -29 17 -101 17 -921 0 -832 -1 -892 -18 -922 -36 -67 50 -63 -1294 -62 -670 0 -1227 4 -1238 9 z M 3664 -4514 c -68 33 -64 -26 -64 973 l 0 901 34 38 34 37 1242 0 1242 0 34 -37 34 -38 0 -900 c 0 -989 3 -942 -60 -975 -25 -13 -178 -15 -1247 -15 -1064 0 -1222 2 -1249 16 z M 6551 -4504 c -17 14 -35 42 -41 62 -14 51 -14 1733 0 1784 6 20 24 48 41 62 l 31 26 1237 0 c 1360 0 1263 5 1296 -60 23 -44 23 -1796 0 -1840 -33 -64 47 -60 -1304 -60 l -1229 0 -31 26 z M 615 -4498 c -57 44 -55 13 -55 963 0 844 1 877 19 913 11 21 31 43 46 50 20 9 323 12 1260 12 l 1233 0 31 -30 c 17 -17 33 -45 36 -63 3 -18 4 -429 3 -915 l -3 -884 -37 -34 -38 -34 -1234 0 -1233 0 -28 22 z " /> <path id="path1" d="M 740 -10481 c -77 24 -152 94 -170 160 -6 23 -10 376 -10 946 l 0 910 24 50 c 28 59 66 97 121 122 38 17 268 18 5620 21 3923 1 5595 -1 5632 -8 70 -15 124 -56 157 -121 l 27 -54 0 -920 c 0 -637 -4 -932 -11 -958 -17 -58 -84 -122 -150 -141 -49 -15 -544 -16 -5635 -15 -3069 0 -5591 4 -5605 8 z M 9615 -7400 c -11 4 -31 20 -45 35 l -25 27 -3 889 c -3 978 -5 935 57 972 27 16 117 17 1253 17 1321 0 1252 3 1293 -54 20 -27 20 -43 20 -919 0 -860 -1 -893 -19 -920 -41 -60 37 -57 -1293 -56 -670 0 -1227 4 -1238 9 z M 3658 -7384 c -61 32 -58 -10 -58 959 0 973 -3 928 60 960 25 13 178 15 1250 15 1072 0 1225 -2 1250 -15 63 -32 60 13 60 -960 0 -973 3 -928 -60 -960 -25 -13 -178 -15 -1252 -15 -1062 1 -1227 3 -1250 16 z M 6551 -7374 c -17 14 -35 42 -41 62 -6 24 -10 339 -10 892 0 826 1 856 20 898 35 78 -60 73 1315 70 l 1225 -3 32 -33 33 -32 3 -895 c 2 -788 0 -899 -13 -925 -33 -64 48 -60 -1304 -60 l -1229 0 -31 26 z M 616 -7369 c -59 46 -56 0 -56 951 0 976 -4 924 70 960 33 17 113 18 1250 18 1187 0 1216 -1 1247 -20 66 -40 63 12 63 -955 0 -791 -2 -880 -16 -911 -33 -68 54 -64 -1302 -64 l -1229 0 -27 21 z M 9615 -4530 c -11 4 -31 20 -45 35 l -25 27 -3 901 c -2 891 -2 902 18 935 40 65 -32 62 1296 62 1335 0 1255 4 1292 -64 16 -29 17 -101 17 -921 0 -832 -1 -892 -18 -922 -36 -67 50 -63 -1294 -62 -670 0 -1227 4 -1238 9 z M 3664 -4514 c -68 33 -64 -26 -64 973 l 0 901 34 38 34 37 1242 0 1242 0 34 -37 34 -38 0 -900 c 0 -989 3 -942 -60 -975 -25 -13 -178 -15 -1247 -15 -1064 0 -1222 2 -1249 16 z M 6551 -4504 c -17 14 -35 42 -41 62 -14 51 -14 1733 0 1784 6 20 24 48 41 62 l 31 26 1237 0 c 1360 0 1263 5 1296 -60 23 -44 23 -1796 0 -1840 -33 -64 47 -60 -1304 -60 l -1229 0 -31 26 z M 615 -4498 c -57 44 -55 13 -55 963 0 844 1 877 19 913 11 21 31 43 46 50 20 9 323 12 1260 12 l 1233 0 31 -30 c 17 -17 33 -45 36 -63 3 -18 4 -429 3 -915 l -3 -884 -37 -34 -38 -34 -1234 0 -1233 0 -28 22 z " />
</g> </g>
</svg> </svg>