Add docs to footer

This commit is contained in:
arabcoders 2025-09-14 19:43:52 +03:00
parent 82cd085ed5
commit a96be4c0fe
10 changed files with 432 additions and 12 deletions

View file

@ -141,6 +141,7 @@
"sstr", "sstr",
"startswith", "startswith",
"SUPPRESSHELP", "SUPPRESSHELP",
"tablehead",
"tebibytes", "tebibytes",
"testpaths", "testpaths",
"threadsafe", "threadsafe",

View file

@ -1,4 +1,4 @@
Copyright (c) 2024 ArabCoders Copyright (c) 2025 ArabCoders
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

119
app/routes/api/docs.py Normal file
View file

@ -0,0 +1,119 @@
import logging
import time
from datetime import UTC, datetime
from typing import Any
import httpx
from aiohttp import web
from aiohttp.web import Request, Response
from yt_dlp.utils.networking import random_user_agent
from app.library.cache import Cache
from app.library.config import Config
from app.library.router import add_route, route
from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__)
STATIC_FILES = ["README.md", "FAQ.md", "API.md", "sc_short.png"]
EXT_TO_MIME: dict = {
".md": "text/markdown",
".png": "image/png",
}
@route("GET", "api/docs/{file}", name="get_doc")
async def get_doc(request: Request, config: Config, cache: Cache) -> Response:
"""
Get the thumbnail.
Args:
request (Request): The request object.
config (Config): The configuration object.
cache (Cache): The cache object.
Returns:
Response: The response object.
"""
if not (file := request.path):
return web.json_response(
data={
"error": "Doc file is is required.",
"matcher": request.match_info,
},
status=web.HTTPForbidden.status_code,
)
file = file.removeprefix("/api/docs/") if file.startswith("/api/docs/") else file.removeprefix("/")
if file not in STATIC_FILES:
return web.json_response(
data={
"error": "Doc file not found.",
"file": file,
"st": STATIC_FILES,
},
status=web.HTTPNotFound.status_code,
)
cache_key = f"doc:{file}"
if dct := cache.get(cache_key):
LOG.debug(f"Serving doc '{file}' from cache.")
return web.Response(**dct)
url = f"https://raw.githubusercontent.com/arabcoders/ytptube/refs/heads/dev/{file}"
try:
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
opts: dict[str, Any] = {
"headers": {
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())),
},
}
if proxy := ytdlp_args.get("proxy"):
opts["proxy"] = proxy
try:
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
opts["transport"] = AsyncCurlTransport(
impersonate="chrome",
default_headers=True,
curl_options={CurlOpt.FRESH_CONNECT: True},
)
opts.pop("headers", None)
except Exception:
pass
async with httpx.AsyncClient(**opts) as client:
LOG.debug(f"Fetching doc from '{url}'.")
response = await client.request(method="GET", url=url, follow_redirects=True)
dct = {
"body": response.content,
"headers": {
"Content-Type": EXT_TO_MIME.get(file[file.rfind(".") :], "text/plain"),
"Pragma": "public",
"Access-Control-Allow-Origin": "*",
"Cache-Control": f"public, max-age={time.time() + 3600}",
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT",
datetime.fromtimestamp(time.time() + 3600, tz=UTC).timetuple(),
),
},
}
cache.set(cache_key, dct, ttl=3600)
return web.Response(**dct)
except Exception as e:
LOG.error(f"Failed to request doc from '{url}'.'. '{e!s}'.")
return web.json_response(data={"error": "Failed to get doc."}, status=web.HTTPInternalServerError.status_code)
for file in STATIC_FILES:
add_route(
method="GET",
path=f"{file}",
handler=get_doc,
name=f"get_{file.replace('.', '_')}",
)

View file

@ -164,7 +164,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineEmits, ref } from 'vue' import { ref } from 'vue'
import InputAutocomplete from '~/components/InputAutocomplete.vue' import InputAutocomplete from '~/components/InputAutocomplete.vue'
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils'

View file

@ -272,7 +272,7 @@
<figure class="image is-3by1"> <figure class="image is-3by1">
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay"> <span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
<div class="play-icon"></div> <div class="play-icon"></div>
<img @load="e => pImg(e)" <img @load="(e: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
@ -280,13 +280,13 @@
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string" <span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay"> class="play-overlay">
<div class="play-icon embed-icon"></div> <div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" <img @load="(e: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</span> </span>
<template v-else> <template v-else>
<img @load="e => pImg(e)" v-if="item.extras?.thumbnail" <img @load="(e: Event) => pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" /> :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</template> </template>

View file

@ -0,0 +1,238 @@
<style>
.markdown-alert {
padding: 0 1em;
margin-bottom: 16px;
color: inherit;
border-left: 0.25em solid #444c56;
}
.markdown-alert-title {
display: inline-flex;
align-items: center;
font-weight: 500;
text-transform: uppercase;
user-select: none;
}
.markdown-alert-note {
border-left-color: #539bf5;
}
.markdown-alert-tip {
border-left-color: #57ab5a;
}
.markdown-alert-important {
border-left-color: #986ee2;
}
.markdown-alert-warning {
border-left-color: #c69026;
}
.markdown-alert-caution {
border-left-color: #e5534b;
}
.markdown-alert-note>.markdown-alert-title {
color: #539bf5;
}
.markdown-alert-tip>.markdown-alert-title {
color: #57ab5a;
}
.markdown-alert-important>.markdown-alert-title {
color: #986ee2;
}
.markdown-alert-warning>.markdown-alert-title {
color: #c69026;
}
.markdown-alert-caution>.markdown-alert-title {
color: #e5534b;
}
code {
word-break: break-word !important
}
</style>
<template>
<div>
<div class="modal is-active">
<div class="modal-background" @click="emitter('closeModel')"></div>
<div class="modal-content modal-content-max">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin" />
</div>
<div style="position: relative" v-if="!isLoading">
<Message v-if="error" message_class="has-background-warning-90 has-text-dark" title="Error"
icon="fas fa-exclamation" :message="error" />
<div class="card" v-else>
<div class="card-body p-4">
<div class="content" v-html="content" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onUpdated } from 'vue'
import { marked } from 'marked'
import { baseUrl } from 'marked-base-url'
import markedAlert from 'marked-alert'
import { gfmHeadingId } from 'marked-gfm-heading-id'
import Message from '~/components/Message.vue'
const props = defineProps<{ file: string }>()
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.png']
const content = ref<string>('')
const error = ref<string>('')
const isLoading = ref<boolean>(true)
const handleClick = async (e: MouseEvent) => {
const target = (e.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null
if (!target) {
return
}
const href = target.getAttribute('data-url')
if (!href) {
return
}
e.preventDefault()
await loader(href)
}
const addListeners = (): void => {
removeListeners()
document.querySelectorAll('.content a').forEach((l: Element): void => {
const href = l.getAttribute('data-url')
if (!href) {
return
}
(l as HTMLElement).addEventListener('click', handleClick)
})
}
const removeListeners = (): void => {
document.querySelectorAll('.content a').forEach((l: Element): void => {
const href = l.getAttribute('data-url')
if (!href) {
return
}
(l as HTMLElement).removeEventListener('click', handleClick)
})
}
const loader = async (file: string) => {
try {
isLoading.value = true
const response = await fetch(`${file}?_=${Date.now()}`)
if (true !== response.ok) {
const err = await response.json()
error.value = err.error.message
return
}
const text = await response.text()
marked.use(gfmHeadingId())
marked.use(baseUrl(window.origin))
marked.use(markedAlert())
marked.use({
gfm: true,
hooks: {
postprocess: (text: string) => text.replace(
/<!--\s*?i:([\w.-]+)\s*?-->/gi,
(_, list) => `<span class="icon"><i class="fas ${list.split('.').map((n: string) => n.trim()).join(' ')}"></i></span>`
)
},
walkTokens: (token: any) => {
if (token.type !== 'link') {
return
}
if (token.href.startsWith('#')) {
return
}
if (urls.some(l => token.href.includes(l))) {
const name = urls.find(l => token.href.includes(l)) || ''
token._external = false
token.href = `/${name}`
} else {
token._external = true
}
},
renderer: {
link(token: any) {
const text = this.parser.parseInline(token.tokens)
const title = token.title ? ` title="${token.title}"` : ''
const attrs = token._external ? ' target="_blank" rel="noopener noreferrer"' : ''
let local = ''
const name = urls.find(l => token.href.includes(l)) || ''
if (name) {
local = ` data-url="/api/docs/${name}"`
}
return `<a href="${token.href}"${local}${title}${attrs}>${text}</a>`
},
table(token: any) {
// `token.header` and `token.rows` are available
// Use default table output from built-in renderer to get header + body markup
// Then wrap with classed <table>
// We need to generate the inner HTML parts first
const headerHtml = `<thead><tr>${token.header.map((cell: any) => `<th>${this.parser.parseInline(cell.tokens)}</th>`).join('')}</tr></thead>`
const bodyHtml = (token.rows || []).map((row: any[]) => {
const tr = row.map((cell: any) => {
return `<td>${this.parser.parseInline(cell.tokens)}</td>`
}).join('')
return `<tr>${tr}</tr>`
}).join('')
return `<div class="table-container"><table class="table is-striped is-hoverable is-fullwidth is-bordered">\n${headerHtml}\n<tbody>\n${bodyHtml}\n</tbody>\n</table></div>`
},
image(token: any) {
const alt = token.text ? ` alt="${token.text}"` : ' alt=""'
const title = token.title ? ` title="${token.title}"` : ''
const refPolicy = ' referrerpolicy="no-referrer"'
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : ''
const loading = ' loading="lazy"'
return `<img src="${token.href}"${alt}${title}${refPolicy}${crossorigin}${loading} />`
},
},
})
content.value = String(marked.parse(text))
} catch (e: any) {
console.error(e)
error.value = e.message
} finally {
isLoading.value = false
}
}
onMounted(async () => loader(props.file))
onUpdated(() => addListeners())
onBeforeUnmount(() => removeListeners())
</script>

View file

@ -203,13 +203,13 @@
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string" <span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay"> class="play-overlay">
<div class="play-icon embed-icon"></div> <div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" <img @load="(e: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</span> </span>
<template v-else> <template v-else>
<img @load="e => pImg(e)" v-if="item.extras?.thumbnail" <img @load="(e: Event) => pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" /> :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</template> </template>

View file

@ -121,24 +121,28 @@
connection is functional. connection is functional.
</p> </p>
</Message> </Message>
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
<ClientOnly> <ClientOnly>
<Dialog /> <Dialog />
</ClientOnly> </ClientOnly>
</div> </div>
<div class="columns mt-3 is-mobile"> <div class="columns mt-3 is-mobile">
<div class="column is-8-mobile"> <div class="column">
<div class="has-text-left" v-if="config.app?.app_version"> <div class="has-text-left" v-if="config.app?.app_version">
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink> © {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
<span class="has-tooltip" v-if="!isMobile" <span class="has-tooltip"
v-tooltip="`Build Date: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, commit: ${config.app?.app_commit_sha}`"> v-tooltip="`Build Date: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, commit: ${config.app?.app_commit_sha}`">
&nbsp;({{ config?.app?.app_version || 'unknown' }})</span> &nbsp;({{ config?.app?.app_version || 'unknown' }})</span>
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink> - <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
<span v-if="!isMobile">&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span> <span>&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span>
- <NuxtLink to="/changelog">CHANGELOG</NuxtLink> - <NuxtLink to="/changelog">CHANGELOG</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/FAQ.md'">FAQ</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/README.md'">README</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/API.md'">API</NuxtLink>
</div> </div>
</div> </div>
<div class="column is-4-mobile" v-if="config.app?.started"> <div class="column is-narrow" v-if="config.app?.started">
<div class="has-text-right"> <div class="has-text-right">
<span class="user-hint" <span class="user-hint"
v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')"> v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')">
@ -161,6 +165,7 @@ import type { YTDLPOption } from '~/types/ytdlp'
import { useDialog } from '~/composables/useDialog' import { useDialog } from '~/composables/useDialog'
import Dialog from '~/components/Dialog.vue' import Dialog from '~/components/Dialog.vue'
import Shutdown from '~/components/shutdown.vue' import Shutdown from '~/components/shutdown.vue'
import Markdown from '~/components/Markdown.vue'
const Year = new Date().getFullYear() const Year = new Date().getFullYear()
const selectedTheme = useStorage('theme', 'auto') const selectedTheme = useStorage('theme', 'auto')
@ -174,6 +179,7 @@ const bg_opacity = useStorage('random_bg_opacity', 0.95)
const showMenu = ref(false) const showMenu = ref(false)
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 })
const app_shutdown = ref<boolean>(false) const app_shutdown = ref<boolean>(false)
const doc = ref<{ file: string }>({ file: '' })
const applyPreferredColorScheme = (scheme: string) => { const applyPreferredColorScheme = (scheme: string) => {
if (!scheme || scheme === 'auto') { if (!scheme || scheme === 'auto') {

View file

@ -33,7 +33,11 @@
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vue": "^3.5.21", "vue": "^3.5.21",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-toastification": "2.0.0-rc.5" "vue-toastification": "2.0.0-rc.5",
"marked": "^16.2.1",
"marked-alert": "^2.1.2",
"marked-base-url": "^1.1.7",
"marked-gfm-heading-id": "^4.1.2"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [

View file

@ -35,6 +35,18 @@ importers:
hls.js: hls.js:
specifier: ^1.6.12 specifier: ^1.6.12
version: 1.6.12 version: 1.6.12
marked:
specifier: ^16.2.1
version: 16.2.1
marked-alert:
specifier: ^2.1.2
version: 2.1.2(marked@16.2.1)
marked-base-url:
specifier: ^1.1.7
version: 1.1.7(marked@16.2.1)
marked-gfm-heading-id:
specifier: ^4.1.2
version: 4.1.2(marked@16.2.1)
moment: moment:
specifier: ^2.30.1 specifier: ^2.30.1
version: 2.30.1 version: 2.30.1
@ -2402,6 +2414,9 @@ packages:
git-url-parse@16.1.0: git-url-parse@16.1.0:
resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==} resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
glob-parent@5.1.2: glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -2762,6 +2777,26 @@ packages:
magicast@0.3.5: magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
marked-alert@2.1.2:
resolution: {integrity: sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w==}
peerDependencies:
marked: '>=7.0.0'
marked-base-url@1.1.7:
resolution: {integrity: sha512-CJOfpG2/XOEp8UuI5H0tbELxuS1v8Ud705jamEIpWBQDdkda1i+LrafxLn41rlxhGEeJqo27b/hBFVYHWOYccw==}
peerDependencies:
marked: '>= 4 < 17'
marked-gfm-heading-id@4.1.2:
resolution: {integrity: sha512-EQ1WiEGHJh0C8viU+hbXbhHyWTDgEia2i96fiSemm2wdYER6YBw/9QI5TB6YFTqFfmMOxBFXPcPJtlgD0fVV2w==}
peerDependencies:
marked: '>=13 <17'
marked@16.2.1:
resolution: {integrity: sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==}
engines: {node: '>= 20'}
hasBin: true
mdn-data@2.0.28: mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
@ -6638,6 +6673,8 @@ snapshots:
dependencies: dependencies:
git-up: 8.1.1 git-up: 8.1.1
github-slugger@2.0.0: {}
glob-parent@5.1.2: glob-parent@5.1.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
@ -6985,6 +7022,21 @@ snapshots:
'@babel/types': 7.28.4 '@babel/types': 7.28.4
source-map-js: 1.2.1 source-map-js: 1.2.1
marked-alert@2.1.2(marked@16.2.1):
dependencies:
marked: 16.2.1
marked-base-url@1.1.7(marked@16.2.1):
dependencies:
marked: 16.2.1
marked-gfm-heading-id@4.1.2(marked@16.2.1):
dependencies:
github-slugger: 2.0.0
marked: 16.2.1
marked@16.2.1: {}
mdn-data@2.0.28: {} mdn-data@2.0.28: {}
mdn-data@2.12.2: {} mdn-data@2.12.2: {}