Compare commits

...

16 Commits

Author SHA1 Message Date
f73c45bb48 Target crosslang v0.0.10, add /api/v1/progress_ex.json
All checks were successful
Build and Deploy on Tag / 🔨 Build (push) Successful in 22s
2026-09-10 03:29:30 -05:00
dc270e6785 Target crosslang v0.0.10, add /api/v1/progress_ex.json
All checks were successful
Build and Deploy on Tag / 🔨 Build (push) Successful in 20s
2026-09-10 03:15:05 -05:00
456577f2f7 Fix for crosslang v0.0.10
Some checks failed
Build and Deploy on Tag / 🔨 Build (push) Has been cancelled
2026-09-02 12:32:59 -05:00
027f601887 Fix for crosslang v0.0.10 2026-09-02 12:31:31 -05:00
d2f855881c Make it a little faster, queue is in sqlite now, fix db bug and more
All checks were successful
Build and Deploy on Tag / 🔨 Build (push) Successful in 10s
2026-06-25 15:43:00 -05:00
454a259e37 Make it a little faster, queue is in sqlite now, fix db bug and more
Some checks failed
Build and Deploy on Tag / 🔨 Build (push) Failing after 8s
2026-06-25 15:41:26 -05:00
c82957cc61 Make it a little faster, queue is in sqlite now, fix db bug and more
Some checks failed
Build and Deploy on Tag / 🔨 Build for everything else (push) Failing after 11s
Build and Deploy on Tag / 🔨 Build for PowerPC (push) Failing after 20s
2026-06-25 15:35:39 -05:00
ede1f92981 Fix tytd config 2026-03-05 11:10:21 -06:00
90f33d455f Fix youtube breakage hopefully 2026-03-03 08:53:55 -06:00
ecb1496a54 Get diagnostics 2026-03-03 08:41:59 -06:00
39709662af Fix the captions downloading 2026-03-02 02:46:51 -06:00
29fdf6a2f3 Fix the captions downloading 2026-03-02 02:39:33 -06:00
44c87e1e50 Make the downloader more resiliant 2026-03-02 02:23:46 -06:00
8851f710fb Make requests slower 2026-03-01 10:22:07 -06:00
28b7138547 Add pwa support 2026-02-28 06:32:39 -06:00
02b10131f9 Fix bugs and make the readme better 2026-01-29 16:40:33 -06:00
72 changed files with 1788 additions and 1015 deletions

59
.gitea/workflows/work.yml Normal file
View File

@@ -0,0 +1,59 @@
name: Build and Deploy on Tag
on:
push:
tags:
- "v*"
env:
PACKAGE_AND_BREW: ${{ secrets.PACKAGE_AND_BREW }}
VERSION: ${{ gitea.ref_name }}
GITEA_DOMAIN: git.tesses.org
GITEA_REGISTRY_USER: tesses50
RESULT_IMAGE_NAME: tesses50/tytd2025
jobs:
build-packages:
name: "🔨 Build"
runs-on: fw13-ubuntu-latest
container:
image: "git.tesses.org/tesses50/linux-x64:latest"
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_DOMAIN }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.PACKAGE_AND_BREW }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:latest
- name: Install CrossLang
uses: https://git.tesses.org/tesses50/setup-crosslang@v1
- run: |
bash build-for-system-pkg.sh
cd Tesses.YouTubeDownloader
crosslang upload-package --token="${{ secrets.CPKG_KEY }}" --host="https://cpkg.tesseslanguage.com/"
cd ../Tesses.YouTubeDownloader.Server
crosslang upload-package --token="${{ secrets.CPKG_KEY }}" --host="https://cpkg.tesseslanguage.com/"
cd ../Tesses.YouTubeDownloader.PluginTemplate
crosslang upload-package --token="${{ secrets.CPKG_KEY }}" --host="https://cpkg.tesseslanguage.com/"
- uses: akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with:
prerelease: true
files: |-
artifacts/**

6
.gitignore vendored
View File

@@ -1,4 +1,8 @@
bin
obj
publish
TYTD
TYTD
artifacts
*.pkg.tar.zst
*.deb
*.rpm

View File

@@ -1,9 +1,14 @@
FROM onedev.site.tesses.net/crosslang/crosslang:latest
RUN mkdir /app
COPY Tesses.YouTubeDownloader.Server/bin/ /app
FROM git.tesses.org/tesses50/crosslang-withshell:latest AS build
COPY ./ /src/
RUN cd /src/Tesses.YouTubeDownloader.Server && crosslang build && cd bin && mv Tesses.YouTubeDownloader.Server-*.crvm tytd2025.crvm
FROM git.tesses.org/tesses50/crosslang:latest
COPY --from=build /src/Tesses.YouTubeDownloader.Server/bin/ /app
WORKDIR /data
ENV TYTDDIR=/data
EXPOSE 3255
ENTRYPOINT ["crossvm","/app/Tesses.YouTubeDownloader.Server-1.0.0.0-prod.crvm","--port=3255"]
ENTRYPOINT ["crosslang","vm","/app/tytd2025.crvm","--port=3255"]

View File

@@ -1,39 +1,71 @@
# TYTD 2025
A YouTube downloader writen in [CrossLang](https://crosslang.tesseslanguage.com/)
My web based YouTube downloader that I created in 2025 written in [CrossLang](https://crosslang.tesseslanguage.com/), my own language
![The Image](https://crosslang.tesseslanguage.com/images/tytdscreenshot.jpg)
[Website](https://crosslang.tesseslanguage.com/software/webapps/tytd2025/)
# Features
- PWA with the ability to add videos when server is down
- Can download videos, playlists and channels (you need a channel url like this [https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A](https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A))
- Can subscribe to channels
- Can create playlists (that are stored on the server)
- Search and browse your downloaded videos, playlists, or channels (the search is very basic though)
- User accounts
- Videos can be tagged based on your downloader's TYTD tag (to determine which instance downloaded it)
- Plugins from [CPKG](https://cpkg.tesseslanguage.com/) or any CPKG compliant server
- Can download YouTube videos either Low quality (but doesn't) require [ffmpeg](https://ffmpeg.org/), MP3, FLAC or AMV (these do need [ffmpeg](https://ffmpeg.org/) in your PATH however)
- Runs on the Wii using the [Wii Linux Continuation Project](https://wiibrew.org/wiki/Wii-Linux#Wii_Linux_Continuation_Project) (albeit extremely slowly, despite this that's where I run it)
# What this project uses (attribution)
I don't feel like storing their licenses in my project, so I link to their projects instead
- [SQLite3](https://www.sqlite.org/) for it's database (embedded into TessesFramework)
- [BeerCSS](https://www.beercss.com/) for its webui, licensed under MIT
- [HTMX](https://htmx.org/) for the SPA experience
- [FFmpeg](https://ffmpeg.org/) if you convert the videos, uses the cli
- [YouTubeExplode](https://github.com/Tyrrrz/YoutubeExplode) for some of its json payloads
- [NewPipe](https://newpipe.net/) for some of its json payloads
- [Material Symbols Font](https://fonts.google.com/icons) (wget from beercss css files)
- [VideoJS](https://videojs.org/) for video player
- [CrossLang](https://crosslang.tesseslanguage.com/) My programming language
## To Install
Install [crosslang](https://crosslang.tesseslanguage.com/downloads/index.html)
from source:
```bash
cd Tesses.YouTubeDownloader.Server
git clone https://git.tesses.org/tesses50/tytd2025
cd tytd2025/Tesses.YouTubeDownloader.Server
crosslang install-webapp
# Save to Videos
crosslang webapp tytd2025 --port=3255
# Save anywhere (unix, replace /path/to/tytd with the folder you want)
export TYTDDIR=/path/to/tytd
crosslang webapp tytd2025 --port=3255
# Save anywhere (windows, replace C:\path\to\tytd with the folder you want)
SET TYTDDIR=/path/to/tytd
crosslang webapp tytd2025 --port=3255
```
from package manager:
```bash
crosslang install-webapp Tesses.YouTubeDownloader.Server
```
# Save to Videos
## To Run
Save to videos:
```bash
crosslang webapp tytd2025 --port=3255
```
# Save anywhere (unix, replace /path/to/tytd with the folder you want)
Save to a folder (unix):
```bash
# Replace /path/to/tytd with the folder you want
export TYTDDIR=/path/to/tytd
crosslang webapp tytd2025 --port=3255
```
# Save anywhere (windows, replace C:\path\to\tytd with the folder you want)
SET TYTDDIR=/path/to/tytd
Save to a folder (windows):
```batch
REM Replace C:\path\to\tytd with the folder you want
SET TYTDDIR=C:\path\to\tytd
crosslang webapp tytd2025 --port=3255
```

View File

@@ -28,6 +28,6 @@
"template_icon": "icon.png"
},
"name": "Tesses.YouTubeDownloader.PluginTemplate",
"version": "1.0.0.0-prod",
"version": "1.0.1.0-dev",
"icon": "icon.png"
}

View File

@@ -1,5 +1,5 @@
{
"icon": "icon.png",
"icon": "tytd-128.png",
"info": {
"description": "Download YouTube Videos (using CrossLang, via web interface, great for homelabs)",
"maintainer": "Mike Nolan",
@@ -14,5 +14,6 @@
"project_dependencies": [
"..\/Tesses.YouTubeDownloader"
],
"version": "1.0.0.0-dev"
"version": "1.0.1.0-dev",
"compTime": "secure"
}

View File

@@ -0,0 +1,4 @@
<div>
<h1>Cannot connect to the TYTD2025 server.</h1>
Refresh page to add to offline queue
</div>

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TYTD2025 is Offline</title>
<link rel="stylesheet" href="/beer.min.css">
<link rel="stylesheet" href="/theme.css">
<script src="/offline.js" defer></script>
<script type="module" src="/beer.min.js" defer></script>
</head>
<body>
<main>
<a href="./" class="button extend circle">
<i>home</i>
<span>Home</span>
</a>
<div class="row">
<div class="max"></div>
<div class="min">
<img src="tytd-128.png" alt="tytd-logo">
</div>
<div class="max"></div>
</div>
<div class="row">
<div class="max"></div>
<div class="min">
<h3>Cannot connect to the TYTD2025 server.</h3>
</div>
<div class="max"></div>
</div>
<div class="row">
<div class="max"></div>
<div class="min">
<form>
<div class="field label border small fill">
<input id="url" type="text">
<label>Url or id</label>
</div>
<div class="row">
<div class="max">
<div class="field label suffix border fill">
<select id="res">
<option value="NoDownload">Don't Download</option>
<option value="LowVideo" selected="">Low (muxed by YouTube)</option>
<option value="VideoOnly">Highest video (no audio)</option>
<option value="AudioOnly">Highest audio (no video)</option>
<option value="MP3">Convert to MP3</option>
<option value="FLAC">Convert to FLAC</option>
<option value="MP4">Convert to MP4</option>
<option value="MKV">Mux to MKV (no transcoding)</option>
<option value="DontConvert">Don't convert or Mux</option>
</select>
<label>Resolution</label>
<i>arrow_drop_down</i>
</div>
</div>
<div class="min">
<button id="btn">
<i>add</i>
Add when online
</button>
</div>
</div>
</form>
</div>
<div class="max"></div>
</div>
</main>
<script>
btn.onclick = (evt)=>{
evt.preventDefault();
addOffline(url.value, res.value);
};
</script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
function addOffline(url,res)
{
var videos = JSON.parse(localStorage.getItem("videos") ?? "[]");
videos.push({
url: url,
res: res
});
localStorage.setItem('videos',JSON.stringify(videos));
}
async function syncOffline()
{
const json = localStorage.getItem("videos") ?? "[]";
if(json !== "[]")
{
const resp = await fetch('/api/v1/add',{
body: json,
headers: {
'Content-Type': 'application/json'
},
method: "POST"
});
if(resp.ok)
{
localStorage.removeItem('videos');
}
}
}
if(navigator.online)
{
syncOffline();
}
window.addEventListener('online',()=>{
syncOffline();
});
async function getPersonalTempLink()
{
const searchParams = new URLSearchParams(window.location.search);
const ent = searchParams.get("name");
if(ent)
{
const resp=await fetch(`./api/v1/personal_tmp_link?name=${encodeURIComponent(ent)}`);
if(resp.ok)
{
const text = await resp.text();
the_playlist_url.innerText = text;
the_playlist_url.href = text;
ui("#dialog");
}
}
}

View File

@@ -0,0 +1,54 @@
const assets = ["<@ASSETS@>"];
const staticCacheName = "tytd-static-<@BUILD_TIME@>";
async function ThrowOnBadGateway(req)
{
const resp = await fetch(req);
if(resp.status === 502) throw new Error("Bad Gateway");
return resp;
}
self.addEventListener('install',evt => {
evt.waitUntil(
caches.open(staticCacheName).then(cache =>{
cache.addAll(assets);
})
);
});
self.addEventListener('activate',(evt)=>{
evt.waitUntil(
caches.keys().then(keys => {
return Promise.all(keys.filter(key => key !== staticCacheName).map(key => caches.delete(key)));
})
);
});
self.addEventListener('fetch', evt => {
evt.respondWith(
caches.match(evt.request).then(cacheRes=>{
console.warn('SW cacheRes:', cacheRes);
return cacheRes || ThrowOnBadGateway(evt.request);
}).catch((err)=>{
console.warn('SW Fallback:', err.message);
if(evt.request.headers.has('HX-Request'))
{
return new Response("",{
status: 200,
headers: {
'HX-Redirect': "/offline.html"
}
});
}
else
{
return caches.match('/offline.html');
}
})
);
});

View File

@@ -0,0 +1,73 @@
{
"short_name": "TYTD2025",
"name": "Tesses YouTubeDownloader 2025",
"start_url": "/",
"display": "standalone",
"theme_color": "#ffb2be",
"background_color": "#241e1f",
"description": "A web based YouTube archiver to preserve videos that might be removed",
"icons": [
{
"src": "/tytd.svg",
"sizes": "192x192 256x256 384x384 512x512",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/tytd-128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/tytd-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/tytd-256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "/tytd-384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/tytd-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/tytd-1024.png",
"sizes": "1024x1024",
"type": "image/png"
}
],
"shortcuts": [
{
"name": "Open Downloads",
"short_name": "Downloads",
"description": "Open Downloads page",
"url": "/downloads"
},
{
"name": "Open Installed Plugins",
"short_name": "Installed plugins",
"description": "Open the installed plugins page",
"url": "/plugins"
},
{
"name": "Open Download Plugins",
"short_name": "Download plugins",
"description": "Open the download plugins page",
"url": "/plugins-download"
},
{
"name": "Open Settings",
"short_name": "Settings",
"description": "Open settings page",
"url": "/settings"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -1,62 +0,0 @@
func Components.DiscoverEntry(item)
{
switch(item.type)
{
case "video":
return
<div class="row">
<div class="min">
<img src={$"./video-thumbnail?v={Net.Http.UrlEncode(item.id)}"} width="144" width="120">
</div>
<div class="max">
<div class="col">
<div class="min">
<a target="_blank" href={$"./watch?v={Net.Http.UrlEncode(item.id)}"}>{item.title}</a>
</div>
<div class="min">
<span>{item.views} • {item.uploaded}</span>
</div>
<div class="min">
<each(var part : item.author)>
<if(TypeOf(part.navigationEndpoint) == "Dictionary")>
<true>
<a target="_blank" href={$"./channel?id={Net.Http.UrlEncode(part.navigationEndpoint.browseEndpoint.browseId)}"}>{part.text}</a>
</true>
<false>
<span>{part.text}</span>
</false>
</if>
</each>
</div>
</div>
</div>
</div>;
break;
case "channel":
return
<div class="row">
<div class="min">
<img src={$"./channel-thumbnail?id={Net.Http.UrlEncode(item.id)}"} width="144" width="144">
</div>
<div class="max">
<div class="col">
<div class="min">
<a target="_blank" href={$"./channel?id={Net.Http.UrlEncode(item.id)}"}>{item.title}</a>
</div>
<div class="min">
<span>{item.subs}</span>
</div>
<div class ="min">
<p class="min">{item.description}</p>
</div>
</div>
</div>
</div>;
break;
}
return "";
}

View File

@@ -1,11 +0,0 @@
func Components.MusicAlbum(item)
{
return <div class="row">
<div class="min">
<img src={$"./album-art?id={Net.Http.UrlEncode(item.id)}"} width="200" height="200">
</div>
<div class="min">
<a href={$"./music-album?id={Net.Http.UrlEncode(item.id)}"}>{item.title}</a>
</div>
</div>;
}

View File

@@ -1,4 +0,0 @@
func Components.MusicArtist(item)
{
return <a href={$"./music-artist?id={Net.Http.UrlEncode(item.id)}"}>{item.name}</a>;
}

View File

@@ -20,11 +20,19 @@ func Components.PersonalListDescription(tytd,name,editing)
</form>
</true>
<false>
<dialog id="dialog">
<h5>The url</h5>
<div><a id="the_playlist_url" href=""></a></div>
<nav class="right-align no-space">
<button data-ui="#dialog" class="transparent link">OK</button>
</nav>
</dialog>
<div class="row" id="description">
<div class="max">
<plink(description)>
</div>
<div class="min">
<button onclick="getPersonalTempLink()"><i>link</i></button>
<button hx-get={$"./edit-personal-description?name={Net.Http.UrlEncode(name)}"} hx-target="#description" hx-swap="outerHTML"><i>edit</i></button>
</div>
</div>

View File

@@ -1,13 +1,12 @@
var progress=0;
func Components.Progress(tytd)
{
var vid = tytd.CurrentVideo;
var progress = tytd.GetProgress();
var html = <div hx-trigger="every 1500ms" hx-indicator="none" hx-get="./progress" hx-swap="outerHTML">
<h2><a hx-target="body" hx-push-url="true" hx-get={$"./watch?v={Net.Http.UrlEncode(vid.VideoId)}"} href={$"./watch?v={Net.Http.UrlEncode(vid.VideoId)}"}>{vid.Title}</a></h2>
<h4><a hx-target="body" hx-push-url="true" hx-get={$"./channel?id={Net.Http.UrlEncode(vid.ChannelId)}"} href={$"./channel?id={Net.Http.UrlEncode(vid.ChannelId)}"}>{vid.Channel}</a></h4>
<progress class="wavy light-green-text" value={tytd.CurrentVideoProgress * 100.0} max="100" min="0"></progress>
var html = <div hx-trigger="every 4500ms" hx-indicator="none" hx-get="./progress" hx-swap="outerHTML">
<h2><a hx-target="body" hx-push-url="true" hx-get={$"./watch?v={Net.Http.UrlEncode(progress.CurrentVideo.VideoId)}"} href={$"./watch?v={Net.Http.UrlEncode(progress.CurrentVideo.VideoId)}"}>{progress.CurrentVideo.Title}</a></h2>
<h4><a hx-target="body" hx-push-url="true" hx-get={$"./channel?id={Net.Http.UrlEncode(progress.CurrentVideo.ChannelId)}"} href={$"./channel?id={Net.Http.UrlEncode(progress.CurrentVideo.ChannelId)}"}>{progress.CurrentVideo.Channel}</a></h4>
<progress class="wavy light-green-text" value={progress.CurrentVideoProgress * 100.0} max="100" min="0"></progress>
</div>;
progress++;
return html;
}

View File

@@ -0,0 +1,12 @@
func Components.QueueSZ(tytd)
{
var vid = tytd.CurrentVideo;
tytd.Mutex.Lock();
var html = <b hx-trigger="every 10000ms" hx-target="this" hx-push-url="false" hx-indicator="none" hx-get="./queue-size" hx-swap="outerHTML">{tytd.VideoQueueCount}</b>;
tytd.Mutex.Unlock();
return html;
}

View File

@@ -1,44 +1,68 @@
func Components.Shell(title, html, page, $mypage)
{
if(TypeOf(mypage) != "Path")
mypage = /;
var index = (/).MakeRelative(mypage).ToString();
if(index == "") index = "./";
const service_worker_script_path = (/"service_worker.js").MakeRelative(mypage).ToString();
const service_worker_script = $"<script>
if(\"serviceWorker\" in navigator) {123.ToChar()}
navigator.serviceWorker.register({Json.Encode(service_worker_script_path)})
.then(()=>console.log('Service worker registered'))
.catch(()=>console.log('service work not registered'));
{125.ToChar()}
</script>";
var pages = [
{
text="Home",
icon="home",
href=index,
classStr=""
classStr="",
badge = ""
},
{
text="Queue",
icon="downloading",
href=(/"queue").MakeRelative(mypage).ToString(),,
classStr="",
badge=<b hx-trigger="every 1000ms" hx-target="this" hx-push-url="false" hx-indicator="none" hx-get="./queue-size" hx-swap="outerHTML">
?
</b>
},
{
text = "Downloads",
icon = "download",
href=(/"downloads").MakeRelative(mypage).ToString(),
classStr=""
classStr="",
badge=""
},
{
text = "Plugins",
icon = "extension",
href= (/"plugins").MakeRelative(mypage).ToString(),
classStr=""
classStr="",
badge=""
},
{
text = "Settings",
icon = "settings",
href= (/"settings").MakeRelative(mypage).ToString(),
classStr=""
classStr="",
badge=""
},
{
text = "Api",
icon = "api",
href = (/"api").MakeRelative(mypage).ToString(),
classStr=""
classStr="",
badge=""
}
];
pages[page].classStr = "active";
pages[page].classStr = " primary-container";
return <!DOCTYPE html>
<html lang="en">
<head>
@@ -47,19 +71,66 @@ func Components.Shell(title, html, page, $mypage)
<title>TYTD - {title}</title>
<link rel="stylesheet" href={(/"beer.min.css").MakeRelative(mypage).ToString()}>
<link rel="stylesheet" href={(/"theme.css").MakeRelative(mypage).ToString()}>
<link rel="manifest" href={(/"site.webmanifest").MakeRelative(mypage).ToString()}>
<script src={(/"htmx.min.js").MakeRelative(mypage).ToString()} defer></script>
<script src={(/"offline.js").MakeRelative(mypage).ToString()} defer></script>
<script type="module" src={(/"beer.min.js").MakeRelative(mypage).ToString()} defer></script>
</head>
<body hx-indicator="#loading-indicator">
<nav class="bottom">
<dialog class="left" id="leftpane">
<header>
<nav>
<img class="circle large" src="/tytd-128.png">
<h6 class="max">TYTD2025</h6>
<button hx-on:click="ui('#leftpane')" class="transparent circle large">
<i>close</i>
</button>
</nav>
</header>
<div class="space"></div>
<ul class="list">
<each(var page : pages)>
<li class={$"wave round{page.classStr}"} hx-target="body" hx-push-url="true" hx-get={page.href}>
<i>{page.icon}</i>
<span class="max">{page.text}</span>
<raw(page.badge)>
</li>
</each>
</ul>
</dialog>
<header class="fixed primary-container">
<nav>
<button hx-on:click="ui('#leftpane')" class="circle transparent">
<i>menu</i>
</button>
<h6 class="max">TYTD2025</h6>
</nav>
</header>
/*<nav class="bottom">
<each(var page : pages)>
<a hx-get={page.href} hx-target="body" hx-push-url="true" class={page.classStr}>
<i>{page.icon}</i>
<if(page.text == "Home")>
<true>
<span hx-trigger="every 1000ms" hx-target="this" hx-push-url="false" hx-indicator="none" hx-get="./queue-size" hx-swap="outerHTML" class="badge">
?
</span>
</true>
</if>
<div>{page.text}</div>
</a>
</each>
</nav>
</nav>*/
<main class="responsive">
<raw(html)>
@@ -69,7 +140,7 @@ func Components.Shell(title, html, page, $mypage)
<div class="htmx-indicator shape loading-indicator extra" id="loading-indicator">
<img class="responsive" src="./tytd.svg">
</div>
<raw(service_worker_script)>
</body>
</html>;
}

View File

@@ -1,3 +1,5 @@
const BUILD_TIME = comptime DateTime.NowEpoch;
var TYTDResources = [
{path="/beer.min.css",value=embed("beer.min.css")},
{path="/beer.min.js",value=embed("beer.min.js")},
@@ -8,12 +10,31 @@ var TYTDResources = [
{path="/htmx.min.js",value=embed("htmx.min.js")},
{path="/favicon.ico",value=embed("favicon.ico")},
{path="/tytd.svg",value=embed("tytd.svg")},
{path="/tytd-128.png",value=embed("tytd-128.png")},
{path="/tytd-192.png",value=embed("tytd-192.png")},
{path="/tytd-256.png",value=embed("tytd-256.png")},
{path="/tytd-384.png",value=embed("tytd-384.png")},
{path="/tytd-512.png",value=embed("tytd-512.png")},
{path="/tytd-1024.png",value=embed("tytd-1024.png")},
{path="/loading-indicator.svg",value=embed("loading-indicator.svg")},
{path="/theme.css",value=embed("theme.css")},
{path="/video.min.js",value=embed("video.min.js")},
{path="/video-js.css",value=embed("video-js.css")},
{path="/wavy.svg",value=embed("wavy.svg")}
{path="/wavy.svg",value=embed("wavy.svg")},
{path="/site.webmanifest",value=embed("site.webmanifest")},
{path="/offline-progress.html",value=embed("offline-progress.html")},
{path="/offline.html",value=embed("offline.html")},
{path="/offline.js", value=embed("offline.js")},
];
const fileNames = [];
each(var item : TYTDResources)
{
fileNames.Add(item.path);
}
const service_worker_str = embed("service_worker.js").ToString().Replace("[\"<@ASSETS@>\"]",Json.Encode(fileNames)).Replace("<@BUILD_TIME@>",BUILD_TIME.ToString());
var times=1;
class TYTDApp {
@@ -29,21 +50,71 @@ class TYTDApp {
public TYTDApp()
{
Console.WriteLine($"Built at {new DateTime(BUILD_TIME).ToString()}");
var tytdfs = new SubdirFilesystem(FS.Local, GetTYTDDir());
this.TYTD = new TYTD.Downloader(tytdfs,FS.MakeFull(GetTYTDDir()));
this.TYTD.Start();
}
public Handle(ctx)
{
if(ctx.Path == "/api/v1/resolutions.json")
{
ctx.SendJson(Resolution.Resolutions);
return true;
}
if(ctx.Path == "/service_worker.js" || fileNames.Contains(ctx.Path))
{
const inm=ctx.RequestHeaders.TryGetFirst("If-None-Match");
if(TypeIsString(inm))
{
const strs = inm.Split(", ");
each(var item : strs)
{
if(item == $"W/\"{BUILD_TIME}\"" || item == $"\"{BUILD_TIME}\"")
{
ctx.StatusCode = 304;
ctx.WriteHeaders();
return true;
}
}
}
ctx.WithHeader("ETag",$"W/\"{BUILD_TIME}\"");
}
if(ctx.Path == "/service_worker.js")
{
ctx.WithMimeType(Net.Http.MimeType("service_worker.js")).SendText(service_worker_str);
return true;
}
if(ctx.Path == "/api/v1/auth")
{
if(ctx.Method == "POST")
{
const req=ctx.ReadJson();
const result = this.TYTD.Auth(req.username, req.password);
if(result)
{
ctx.SendJson({
success = true,
flags = result.flags
});
}
else {
ctx.SendJson({
success=false
});
}
return true;
}
return false;
}
if(ctx.Path == "/api/v1/login")
{
if(ctx.Method == "POST")
{
const req=ctx.ReadJson();
const result = this.TYTD.Login(req.username, req.password);
const result = this.TYTD.Login(req.username, req.password, false);
if(result)
{
ctx.SendJson({
@@ -68,16 +139,19 @@ class TYTDApp {
{
var incorrect=false;
const redirect = ctx.QueryParams.TryGetFirst("redirect") ?? "/";
const username = ctx.QueryParams.TryGetFirst("username") ?? "";
const password = ctx.QueryParams.TryGetFirst("password") ?? "";
const username = ctx.BodyParams.TryGetFirst("username") ?? "";
const password = ctx.BodyParams.TryGetFirst("password") ?? "";
if(ctx.Method == "POST")
{
const result = this.TYTD.Login(username, password);
const result = this.TYTD.Login(username, password,true);
if(result)
{
ctx.StatusCode = 303;
ctx.WithHeader("Set-Cookie",$"Session={result}; SameSite=Strict").SendRedirect("/");
var date = new DateTime(DateTime.NowEpoch + UserFlags.Expires);
ctx.WithHeader("Set-Cookie",$"Session={result}; SameSite=Lax; Expires={date.ToHttpDate()}; HttpOnly").SendRedirect(redirect);
return true;
}
else incorrect=true;
@@ -97,6 +171,29 @@ class TYTDApp {
return true;
}
}
if(ctx.Path == "/progress")
{
ctx.WithMimeType("text/html").SendText(
<div>
<h1>You have been logged out</h1>
<a href="./login">Login</a>
</div>
);
return true;
}
if(ctx.Path == "/queue-size")
{
return false;
}
if(ctx.Path == "/sso")
{
const app = ctx.QueryParams.TryGetFirst("app") ?? "";
const token = ctx.QueryParams.TryGetFirst("token") ?? "";
const path = $"/sso?app={Net.Http.UrlEncode(app)}&token={Net.Http.UrlEncode(token)}";
ctx.StatusCode = 307;
ctx.SendRedirect($"/login?redirect={Net.Http.UrlEncode(path)}");
return true;
}
ctx.StatusCode = 307;
ctx.SendRedirect($"/login?redirect={Net.Http.UrlEncode(ctx.Path)}");
return true;
@@ -121,16 +218,16 @@ class TYTDApp {
{
if(ctx.Method == "POST")
{
const from = ctx.QueryParams.TryGetFirstInt("from");
const from = ctx.BodyParams.TryGetFirstInt("from");
switch(from)
{
case 1:
{
const enablePlugins = ctx.QueryParams.GetFirstBoolean("enablePlugins");
const tag = ctx.QueryParams.TryGetFirst("tag") ?? "UnknownPC";
const pollHours = ctx.QueryParams.TryGetFirstInt("pollHours") ?? 3;
const pollMinutes = ctx.QueryParams.TryGetFirstInt("pollMinutes") ?? 0;
const pollSeconds = ctx.QueryParams.TryGetFirstInt("pollSeconds") ?? 0;
const enablePlugins = ctx.BodyParams.GetFirstBoolean("enablePlugins");
const tag = ctx.BodyParams.TryGetFirst("tag") ?? "UnknownPC";
const pollHours = ctx.BodyParams.TryGetFirstInt("pollHours") ?? 3;
const pollMinutes = ctx.BodyParams.TryGetFirstInt("pollMinutes") ?? 0;
const pollSeconds = ctx.BodyParams.TryGetFirstInt("pollSeconds") ?? 0;
this.OOBE_STATE.tag = tag;
@@ -151,7 +248,7 @@ class TYTDApp {
case 3:
{
const createUser = ctx.QueryParams.GetFirstBoolean("user");
const createUser = (ctx.BodyParams.TryGetFirst("user") ?? "no")=="yes";
var seconds = this.OOBE_STATE.pollSeconds;
seconds += this.OOBE_STATE.pollMinutes * 60;
@@ -208,7 +305,6 @@ class TYTDApp {
return true;
}
}
if(ctx.Path == "/welcome")
{
const redirect = ctx.QueryParams.TryGetFirst("redirect") ?? "/settings";
@@ -245,39 +341,98 @@ class TYTDApp {
return true;
}
}
if(ctx.Path == "/")
{
ctx.WithMimeType("text/html").SendText(Pages.Index(this.TYTD));
return true;
}
else if(ctx.Path == "/sso")
{
const app = ctx.QueryParams.TryGetFirst("app");
const token = ctx.QueryParams.TryGetFirst("token");
if(TypeIsString(app) && TypeIsString(token))
{
const sso = this.TYTD.GetSSO(app);
if(TypeIsDictionary(sso))
{
const user = this.TYTD.WhoAmI(ctx);
if(TypeIsDictionary(user) && user.flags != 0)
{
const postJson = {
username = user.username,
flags = user.flags,
token = token,
sso_app_key = sso.sso_app_key
};
const resp = Net.Http.MakeRequest(sso.service_auth_post, {
Method = "POST",
Body = Net.Http.TextHttpRequestBody(postJson.ToString(),"application/json")
});
if(resp.StatusCode >= 200 && resp.StatusCode <= 299)
{
ctx.StatusCode = 307;
ctx.SendRedirect($"{sso.service_auth_redirect}{Net.Http.UrlEncode(token)}");
return true;
}
}
}
}
ctx.StatusCode = 401;
return false;
}
else if(ctx.Path == "/passwd")
{
var incorrect=false;
var result = {success=true};
const password = ctx.QueryParams.TryGetFirst("password") ?? "";
const newpassword = ctx.QueryParams.TryGetFirst("newpassword") ?? "";
const confirm = ctx.QueryParams.TryGetFirst("newpassword") ?? "";
const redirect = ctx.BodyParams.TryGetFirst("redirect") ?? "/login";
const password = ctx.BodyParams.TryGetFirst("password") ?? "";
const newpassword = ctx.BodyParams.TryGetFirst("newpassword") ?? "";
const confirm = ctx.BodyParams.TryGetFirst("confirm") ?? "";
const logout = ctx.BodyParams.GetFirstBoolean("logout");
if(ctx.Method == "POST")
{
this.TYTD.Passwd();
//public Passwd(ctx, oldPassword, newPassword, logout)
if(password != newpassword)
{
result = {success = false, reason = "Passwords do not match"};
}
else
result = this.TYTD.Passwd(ctx, password, newpassword, logout);
if(result.success)
{
ctx.StatusCode = 307;
ctx.SendRedirect(redirect);
return true;
}
}
ctx.WithMimeType("text/html").SendText(Pages.ChangePassword(redirect, incorrect));
ctx.WithMimeType("text/html").SendText(Pages.ChangePassword(redirect, result.success ? null : result.reason));
return true;
}
else if(ctx.Path == "/newuser")
{
var error = null;
if(ctx.Method == "POST")
{
const username = ctx.QueryParams.TryGetFirst("username");
const password = ctx.QueryParams.TryGetFirst("password");
const confirm = ctx.QueryParams.TryGetFirst("confirm");
const isAdmin = ctx.QueryParams.GetFirstBoolean("isAdmin");
const canUsePlugins = ctx.QueryParams.GetFirstBoolean("canUsePlugins");
const canManagePlugins = ctx.QueryParams.GetFirstBoolean("canManagePlugins");
const canDownloadDB = ctx.QueryParams.GetFirstBoolean("canDownloadDB");
const redirect = ctx.QueryParams.TryGetFirst("redirect") ?? "/";
const username = ctx.BodyParams.TryGetFirst("username");
const password = ctx.BodyParams.TryGetFirst("password");
const confirm = ctx.BodyParams.TryGetFirst("confirm");
const isAdmin = ctx.BodyParams.GetFirstBoolean("isAdmin");
const canUsePlugins = ctx.BodyParams.GetFirstBoolean("canUsePlugins");
const canManagePlugins = ctx.BodyParams.GetFirstBoolean("canManagePlugins");
const canDownloadDB = ctx.BodyParams.GetFirstBoolean("canDownloadDB");
var flags = 0;
if(isAdmin) flags |= UserFlags.AdminFlag;
if(canUsePlugins) flags |= UserFlags.PluginFlag;
@@ -299,12 +454,6 @@ class TYTDApp {
else {
error = this.TYTD.CreateAccount(ctx, username, password, flags);
}
if(!TypeIsString(error))
{
ctx.StatusCode=303;
ctx.SendRedirect(redirect);
}
}
ctx.WithMimeType("text/html").SendText(Pages.NewUser(error));
return true;
@@ -340,6 +489,36 @@ class TYTDApp {
}
return false;
}
else if(ctx.Path == "/api/v1/register_sso")
{
if(ctx.Method!="POST")
{
ctx.WithMimeType("application/json").SendJson({
success=false,
reason = "Method must be post",
type = "method"
});
return true;
}
if(!UserFlags.IsAdmin(this.TYTD.IsLoggedIn(ctx)))
{
ctx.WithMimeType("application/json").SendJson({
success=false,
reason = "You are either not logged in or not admin",
type ="auth"
});
return true;
}
const json = ctx.ReadJson();
const resp = this.TYTD.RegisterSSO(json);
ctx.WithMimeType("application/json").SendJson(
resp
);
return true;
}
else if(ctx.Path == "/api/v1/download")
{
var v = ctx.QueryParams.TryGetFirst("v");
@@ -361,16 +540,30 @@ class TYTDApp {
}
return false;
}
else if(ctx.Path == "/api/v1/progress_ex.json")
{
ctx.WithMimeType("application/json").SendJson(this.TYTD.GetProgressEx());
return true;
}
else if(ctx.Path == "/api/v1/progress.json")
{
var jo = {
CurrentVideoProgress = this.TYTD.CurrentVideoProgress,
CurrentVideo = this.TYTD.CurrentVideo
};
ctx.WithMimeType("application/json").SendJson(jo);
ctx.WithMimeType("application/json").SendJson(this.TYTD.GetProgress());
return true;
}
else if(ctx.Path == "/api/v1/add")
{
if(ctx.Method=="POST")
{
const json = ctx.ReadJson();
each(var item : json)
{
this.TYTD.DownloadItem(item.url,item.res);
}
ctx.StatusCode=204;
ctx.WriteHeaders();
return true;
}
}
else if(ctx.Path == "/api/v1/video.json")
{
var id = ctx.QueryParams.TryGetFirst("v");
@@ -400,6 +593,21 @@ class TYTDApp {
ctx.WithMimeType("application/json").SendJson(this.TYTD.GetChannelContents(id,page-1,count));
return true;
}
else if(ctx.Path == "/api/v1/queue.json")
{
if(ctx.Method == "GET")
{
var page = ctx.QueryParams.TryGetFirstInt("page");
var count = ctx.QueryParams.TryGetFirstInt("count");
if(TypeOf(page)!="Long") page = 1;
if(TypeOf(count)!="Long") count = 20;
var result = { entries = TYTD.GetQueueItems(page-1,count)};
ctx.WithMimeType("application/json").SendJson(result);
return true;
}
}
else if(ctx.Path == "/api/v1/downloads.json")
{
var q = ctx.QueryParams.TryGetFirst("q");
@@ -435,10 +643,34 @@ class TYTDApp {
if(!this.TYTD.SendDatabase(ctx))
{
ctx.WithMimeType("text/html").SendText(Components.Shell("Unauthorized","<h1>You are not authorized to download the database</h1>",3,/"api/v1/database.db"));
ctx.WithMimeType("text/html").SendText(Components.Shell("Unauthorized","<h1>You are not authorized to download the database</h1>",4,/"api/v1/database.db"));
}
return true;
}
else if(ctx.Path == "/api/v1/personal_tmp_link")
{
const name = ctx.QueryParams.TryGetFirst("name");
if(TypeIsString(name))
{
const ents = this.TYTD.GetPersonalListTempUrl(name);
if(TypeIsString(ents))
{
ctx.WithMimeType("text/plain").SendText(ents);
return true;
}
}
}
else if(ctx.Path == "/api/v1/manifest.json")
{
const v = ctx.QueryParams.TryGetFirst("v");
if(TypeIsString(v))
{
const resp=this.TYTD.ManifestRequest(v);
ctx.WithMimeType("application/json").SendJson(resp);
return true;
}
}
else if(ctx.Path == "/api/v1/personal")
{
/*
@@ -572,13 +804,31 @@ class TYTDApp {
}
else if(ctx.Method == "POST")
{
var description =ctx.QueryParams.TryGetFirst("description");
var description =ctx.BodyParams.TryGetFirst("description");
this.TYTD.SetPersonalListDescription(name,description);
ctx.WithMimeType("text/html").SendText(Components.PersonalListDescription(this.TYTD,name,false));
return true;
}
}
else if(ctx.Path == "/queue")
{
if(ctx.Method == "DELETE")
{
const id = ctx.QueryParams.TryGetFirstInt("id");
if(TypeIsLong(id))
{
this.TYTD.QueueRemoveItem(id);
}
else
{
this.TYTD.QueueClear();
}
}
ctx.WithMimeType("text/html").SendText(Pages.Queue(this.TYTD,ctx));
return true;
}
else if(ctx.Path == "/downloads")
{
ctx.WithMimeType("text/html").SendText(Pages.Downloads(this.TYTD,ctx));
@@ -599,7 +849,7 @@ class TYTDApp {
{
if(!UserFlags.IsAdmin(this.TYTD.IsLoggedIn(ctx)))
{
ctx.WithMimeType("text/html").SendText(Components.Shell("Unauthorized",<h1>You can{"'"}t modify admin settings as you are not an admin</h1>,3));
ctx.WithMimeType("text/html").SendText(Components.Shell("Unauthorized",<h1>You can{"'"}t modify admin settings as you are not an admin</h1>,4));
return true;
}
const user = ctx.QueryParams.TryGetFirst("user");
@@ -612,10 +862,10 @@ class TYTDApp {
{
if(whoami.username != user)
{
const isAdmin = ctx.QueryParams.GetFirstBoolean("isAdmin");
const canUsePlugins = ctx.QueryParams.GetFirstBoolean("canUsePlugins");
const canManagePlugins = ctx.QueryParams.GetFirstBoolean("canManagePlugins");
const canDownloadDB = ctx.QueryParams.GetFirstBoolean("canDownloadDB");
const isAdmin = ctx.BodyParams.GetFirstBoolean("isAdmin");
const canUsePlugins = ctx.BodyParams.GetFirstBoolean("canUsePlugins");
const canManagePlugins = ctx.BodyParams.GetFirstBoolean("canManagePlugins");
const canDownloadDB = ctx.BodyParams.GetFirstBoolean("canDownloadDB");
var flags = 0;
if(isAdmin) flags |= UserFlags.AdminFlag;
if(canUsePlugins) flags |= UserFlags.PluginFlag;
@@ -677,7 +927,7 @@ class TYTDApp {
{
if(!UserFlags.IsAdmin(this.TYTD.IsLoggedIn(ctx)))
{
ctx.WithMimeType("text/html").SendText(Components.Shell("Unauthorized",<h1>You can{"'"}t modify admin settings as you are not an admin</h1>,3));
ctx.WithMimeType("text/html").SendText(Components.Shell("Unauthorized",<h1>You can{"'"}t modify admin settings as you are not an admin</h1>,4));
return true;
}
if(ctx.Method == "POST")
@@ -690,11 +940,11 @@ class TYTDApp {
tag
enablePlugins
*/
var enablePlugins = ctx.QueryParams.GetFirstBoolean("enablePlugins");
var tag = ctx.QueryParams.TryGetFirst("tag");
var hours = ctx.QueryParams.TryGetFirstInt("hours");
var minutes = ctx.QueryParams.TryGetFirstInt("minutes");
var seconds = ctx.QueryParams.TryGetFirstInt("seconds");
var enablePlugins = ctx.BodyParams.GetFirstBoolean("enablePlugins");
var tag = ctx.BodyParams.TryGetFirst("tag");
var hours = ctx.BodyParams.TryGetFirstInt("hours");
var minutes = ctx.BodyParams.TryGetFirstInt("minutes");
var seconds = ctx.BodyParams.TryGetFirstInt("seconds");
if(TypeOf(tag) == "String" && TypeOf(hours) == "Long" && TypeOf(minutes) == "Long" && TypeOf(seconds) == "Long")
{
@@ -714,6 +964,44 @@ class TYTDApp {
ctx.WithMimeType("text/html").SendText(Pages.VideoInfo(this.TYTD,ctx));
return true;
}
else if(ctx.Path == "/watch_videos")
{
if(ctx.Method=="GET")
{
const video_ids = ctx.QueryParams.TryGetFirst("video_ids");
if(TypeIsString(video_ids))
{
ctx.WithMimeType("text/html").SendText(Pages.YouTubeAnonyPlaylist(video_ids));
return true;
}
}
if(ctx.Method=="POST")
{
const video_ids = ctx.BodyParams.TryGetFirst("video_ids");
if(TypeIsString(video_ids))
{
const name = ctx.BodyParams.TryGetFirst("name");
if(TypeIsString(name))
{
const nameParts=video_ids.Split(",");
//Console.WriteLine(nameParts);
each(var item : nameParts)
{
this.TYTD.AddToPersonalList(name,item);
}
//Console.WriteLine(name);
ctx.SendRedirect($"/list?name={Net.Http.UrlEncode(name)}",303);
return true;
}
}
}
}
else if(ctx.Path == "/playlist")
{
ctx.WithMimeType("text/html").SendText(Pages.PlaylistInfo(this.TYTD,ctx));
@@ -726,9 +1014,9 @@ class TYTDApp {
}
else if(ctx.Path == "/add")
{
var url = ctx.QueryParams.TryGetFirst("url");
var action =ctx.QueryParams.TryGetFirst("action");
var res = ctx.QueryParams.TryGetFirst("res");
var url = ctx.BodyParams.TryGetFirst("url");
var action =ctx.BodyParams.TryGetFirst("action");
var res = ctx.BodyParams.TryGetFirst("res");
if(action == "add")
{
this.TYTD.DownloadItem(url,res);
@@ -756,9 +1044,9 @@ class TYTDApp {
}
else if(ctx.Path == "/add-video")
{
var url = ctx.QueryParams.TryGetFirst("url");
var action =ctx.QueryParams.TryGetFirst("action");
var res = ctx.QueryParams.TryGetFirst("res");
var url = ctx.BodyParams.TryGetFirst("url");
var action =ctx.BodyParams.TryGetFirst("action");
var res = ctx.BodyParams.TryGetFirst("res");
if(action == "add")
{
this.TYTD.DownloadItem(url,res);
@@ -779,10 +1067,10 @@ class TYTDApp {
}
else if(ctx.Path == "/add-to-list")
{
var id = ctx.QueryParams.TryGetFirst("id");
var id = ctx.BodyParams.TryGetFirst("id");
if(TypeOf(id) != "String") id = "";
var name = ctx.QueryParams.TryGetFirst("name");
var name = ctx.BodyParams.TryGetFirst("name");
if(TypeOf(name) != "String") name = "";
if(name == "")
@@ -802,6 +1090,12 @@ class TYTDApp {
ctx.WithMimeType("text/html").SendText(Components.Progress(this.TYTD));
return true;
}
else if(ctx.Path == "/queue-size")
{
ctx.WithMimeType("text/html").SendText(Components.QueueSZ(this.TYTD));
return true;
}
else if(ctx.Path == "/plugins")
{
ctx.WithMimeType("text/html").SendText(Pages.Plugins(this.TYTD,ctx));
@@ -817,9 +1111,9 @@ class TYTDApp {
{
if(!UserFlags.CanManagePlugins(this.TYTD.IsLoggedIn(ctx))) return false;
var data = {
name = ctx.QueryParams.TryGetFirst("name"),
version = ctx.QueryParams.TryGetFirst("version"),
action = ctx.QueryParams.TryGetFirst("action")
name = ctx.BodyParams.TryGetFirst("name"),
version = ctx.BodyParams.TryGetFirst("version"),
action = ctx.BodyParams.TryGetFirst("action")
};
var mustConfirm = false;

View File

@@ -72,5 +72,5 @@ func Pages.Admin(tytd,ctx)
</form>;
return Components.Shell("Admin Settings",html ,3);
return Components.Shell("Admin Settings",html ,4);
}

View File

@@ -8,5 +8,5 @@ func Pages.Api()
</a>
</ul>
</null>;
return Components.Shell("Api",html,4);
return Components.Shell("Api",html,5);
}

View File

@@ -21,7 +21,7 @@ func Pages.ApiV1()
</fieldset>
</each>
</null>;
return Components.Shell("Api V1",html,4);
return Components.Shell("Api V1",html,5);
}
var apiv1_routes = Json.Decode(embed("apiv1_routes.json").ToString());

View File

@@ -1,14 +1,14 @@
func Pages.ChangePassword(redirect,incorrect)
func Pages.ChangePassword(redirect,error)
{
const html = <null>
<if(incorrect)>
<if(TypeIsString(error))>
<true>
<blockquote>
<h5>The password could not be changed</h5>
<h5>{error}</h5>
</blockquote>
</true>
</if>
@@ -45,5 +45,5 @@ const html = <null>
</form>
</null>;
return Components.Shell("Change your password",html,3);
return Components.Shell("Change your password",html,4);
}

View File

@@ -35,5 +35,5 @@ func Pages.ChannelInfo(tytd,ctx)
title = res.authorName;
}
return Components.Shell($"Channel {title}",html,1);
return Components.Shell($"Channel {title}",html,2);
}

View File

@@ -1,34 +0,0 @@
func Pages.Discover(tytd,ctx)
{
var q = ctx.QueryParams.TryGetFirst("q");
var res = null;
var q2 = "";
if(TypeOf(q) == "String")
{
q2 = q;
res = tytd.Discover(q);
}
var html = <null>
<form hx-get="./discover" hx-target="body" hx-push-url="true">
<div class="field large prefix round fill active">
<i class="front">search</i>
<input name="q" value={q2}>
</div>
</form>
<if(res != null)>
<true>
<ul>
<each(var item : res.items)>
<li><raw(Components.DiscoverEntry(item))></li>
</each>
</ul>
<button class="responsive">More</button>
</true></null>;
return Components.Shell($"Discover {q2}",html,1);
}

View File

@@ -289,5 +289,5 @@ func Pages.Downloads(tytd,ctx)
return Components.Shell($"Downloads {q}",html,1);
return Components.Shell($"Downloads {q}",html,2);
}

View File

@@ -38,7 +38,7 @@ func Pages.EditUserList(tytd, ctx)
</fieldset>
</null>;
return Components.Shell("Edit users",html, 3);
return Components.Shell("Edit users",html, 4);
}
func Pages.EditUser(user)
@@ -141,5 +141,5 @@ func Pages.EditUser(user)
</if>;
return Components.Shell("Edit user",html, 3);
return Components.Shell("Edit user",html, 4);
}

View File

@@ -35,5 +35,5 @@ func Pages.List(tytd,ctx)
</null>;
return Components.Shell($"Personal List {name}",html,1);
return Components.Shell($"Personal List {name}",html,2);
}

View File

@@ -1,25 +0,0 @@
func Pages.Artist(music,ctx)
{
var id = ctx.QueryParams.TryGetFirst("id");
var res = music.GetArtistsAlbums(id);
if(TypeOf(id) != "String")
{
return Components.Shell("Error",<h1>Artist id not specified</h1>,pages);
}
var html = <null>
<h1>{res.name}</h1>
<ul>
<each(var item : res.items)>
<li><raw(Components.MusicAlbum(item))></li>
</each>
</ul>
</null>;
return Components.Shell($"Artist {res.name}",html,2);
}

View File

@@ -1,33 +0,0 @@
func Pages.Artists(music,ctx)
{
var q = ctx.QueryParams.TryGetFirst("q");
var res = null;
var q2 = "";
if(TypeOf(q) == "String")
{
q2 = q;
res = music.GetArtists(q);
}
var html = <null>
<form hx-get="./music-artists" hx-target="body" hx-push-url="true">
<div class="field large prefix round fill active">
<i class="front">search</i>
<input name="q" value={q2}>
</div>
</form>
<if(res != null)>
<true>
<ul>
<each(var item : res)>
<li><raw(Components.MusicArtist(item))></li>
</each>
</ul>
</true></null>;
return Components.Shell($"Search Artists {q2}",html,2);
}

View File

@@ -1,8 +0,0 @@
func Pages.Music(ctx)
{
var html = <ul>
<div><a href="./music-artists">Artists</a></div>
<div><a href="./music-downloaded">My Music</a></div>
</ul>;
return Components.Shell("Music",html,2);
}

View File

@@ -81,5 +81,5 @@ func Pages.NewUser(error)
</form>
</null>;
return Components.Shell("New User",html ,3);
return Components.Shell("New User",html ,4);
}

View File

@@ -51,7 +51,7 @@ func Pages.OobePage2(tytd,ctx)
<div class="small-margin">Security</div>
</div>
</nav>
<form hx-get="./oobe" hx-target="body" hx-push-url="true">
<form hx-post="./oobe" hx-target="body" hx-push-url="true">
<input type="hidden" name="page" value="2">
<div class="field suffix border round">
<select name="server">

View File

@@ -12,7 +12,9 @@ func Pages.OobePage3()
</div>
<hr class="max">
<div class="center-align">
<a href="./oobe?page=2" class="button circle small">2</a>
<a href="./oobe?page=2" class="button circle small">
<i>done</i>
</a>
<div class="small-margin">Plugins</div>
</div>
<hr class="max">

View File

@@ -0,0 +1,26 @@
func Pages.YouTubeAnonyPlaylist(video_ids)
{
const html = <null>
<div class="row">
<div class="max"></div>
<div class="min">
<form method="POST" action="./watch_videos">
<input type="hidden" name="video_ids" value={video_ids}>
<div class="row">
<div class="max">
<div class="field label border">
<input type="text" name="name">
<label>Playlist Name</label>
</div>
</div>
<div class="min">
<button>Add</button>
</div>
</div>
</form>
</div>
<div class="max"></div>
</div>
</null>;
return Components.Shell("Create playlist",html ,2);
}

View File

@@ -35,5 +35,5 @@ func Pages.PlaylistInfo(tytd,ctx)
title = res.authorName;
}
return Components.Shell($"Playlist {title}",html,1);
return Components.Shell($"Playlist {title}",html,2);
}

View File

@@ -75,5 +75,5 @@ func Pages.DownloadPlugins(tytd,ctx)
</null>;
return Components.Shell("Download plugins",html ,2);
return Components.Shell("Download plugins",html ,3);
}

View File

@@ -20,5 +20,5 @@ func Pages.Plugins(tytd,ctx)
</each>
</null>;
return Components.Shell("Installed plugins", UserFlags.CanUsePlugins(userFlags) ? html : <h1>You can{"'"}t use plugins</h1> ,2);
return Components.Shell("Installed plugins", UserFlags.CanUsePlugins(userFlags) ? html : <h1>You can{"'"}t use plugins</h1> ,3);
}

View File

@@ -0,0 +1,71 @@
func Pages.Queue(tytd,ctx)
{
var page = ctx.QueryParams.TryGetFirstInt("page");
if(TypeOf(page) != "Long") page = 1;
page--;
enumerable func getitems()
{
each(var item : tytd.GetQueueItems(page,10))
{
tytd.PutVideoInfoIfNotExists(item.videoId);
const info = tytd.GetVideo(item.videoId);
yield {
Title = $"{info.title}-{item.videoId}-{item.resolution}",
VideoId = item.videoId,
Id = item.id,
ChannelId = info.channelId,
Channel = info.author
};
}
}
const html = <null>
<div class="row">
<div class="max">
</div>
<div class="min">
<button hx-delete="./queue" hx-target="body" hx-push-url="true" hx-confirm="Are you sure you want to clear the queue?">Clear All</button>
</div>
</div>
<each(var item : getitems())>
<div class="row">
<div class="min">
<img src={$"./api/v1/video-thumbnail?v={Net.Http.UrlEncode(item.VideoId)}"} width="144" width="120">
</div>
<div class="max">
<div class="col">
<div class="min">
{item.Title}
</div>
<div class="min">
<a hx-get={$"./channel?id={Net.Http.UrlEncode(item.ChannelId)}"} href={$"./channel?id={Net.Http.UrlEncode(item.ChannelId)}"} hx-target="body" hx-push-url="true">{item.Channel}</a>
</div>
</div>
</div>
<div class="min">
<button class="circle" hx-delete={$"./queue?page={page+1}"} hx-vals={{id = item.Id}} hx-target="body" hx-push-url="true" hx-confirm="Are you sure you want to delete this?"><i>delete</i></button>
</div>
</div>
</each>
<footer class="row center-align">
<button hx-get={$"./queue?page={page}"} hx-target="body" hx-push-url="true" >Prev</button>{page+1}<button hx-get={$"./queue?page={page+2}"} hx-target="body" hx-push-url="true" >Next</button>
</footer>
</null>;
return Components.Shell("Queue",html,1);
}

View File

@@ -15,5 +15,5 @@ func Pages.Settings(tytd,ctx)
</fieldset>;
return Components.Shell("Settings",html ,3);
return Components.Shell("Settings",html ,4);
}

View File

@@ -27,5 +27,5 @@ func Pages.VideoInfo(tytd,ctx)
</div>;
}
return Components.Shell(vi != null ? vi.title : "Could not find video", html, 1);
return Components.Shell(vi != null ? vi.title : "Could not find video", html, 2);
}

View File

@@ -3,7 +3,7 @@ func Pages.Welcome(redirect)
const html = <null>
<h1>You are all set</h1>
<p>
This is Tesses YouTube Downloader 2025, a YouTube Downloader created in <a href="https://crosslang.tesseslanguage.com/">CrossLang</a>
This is Tesses YouTube Downloader 2025, a YouTube Downloader created in <a target="_blank" href="https://crosslang.tesseslanguage.com/">CrossLang</a>
<h4><i>home</i> Home</h4>
Once you click the <b>{"I'm Done"}</b> button, you will be able to add YouTube videos to the downloader by using <i>add</i>, you can download the video from the server using <i>download</i> or view metadata/watch the video using <i>info</i> (this also will allow you to view playlist/channel contents)

View File

@@ -11,5 +11,5 @@ func Pages.WhoAmI(row)
<li>Can manage plugins: {UserFlags.CanManagePlugins(row.flags)}</li>
</ul>
</null>;
return Components.Shell("Settings",html ,3);
return Components.Shell("Settings",html ,4);
}

View File

@@ -14,6 +14,6 @@
}
],
"name": "Tesses.YouTubeDownloader",
"version": "1.0.0.0-dev",
"version": "1.0.1.0-dev",
"icon": "icon.png"
}

View File

@@ -2,12 +2,12 @@
"context": {
"client": {
"clientName": "ANDROID",
"clientVersion": "19.28.35",
"clientVersion": "21.03.36",
"clientScreen": "WATCH",
"platform": "MOBILE",
"osName": "Android",
"osVersion": "15",
"androidSdkVersion": 35,
"osVersion": "16",
"androidSdkVersion": 36,
"hl": "en-GB",
"gl": "US",
"utcOffsetMinutes": 0

View File

@@ -2,13 +2,13 @@
"context": {
"client": {
"clientName": "ANDROID",
"clientVersion": "19.28.35",
"clientVersion": "21.03.36",
"clientScreen": "WATCH",
"platform": "MOBILE",
"visitorData": "VISITOR_DATA",
"osName": "Android",
"osVersion": "15",
"androidSdkVersion": 35,
"osVersion": "16",
"androidSdkVersion": 36,
"hl": "en-GB",
"gl": "US",
"utcOffsetMinutes": 0

View File

@@ -1,35 +0,0 @@
class TYTD.Queue
{
private ls = [];
private mtx = new Muxex();
public getCount()
{
this.mtx.Lock();
var c = this.ls.Count;
this.mtx.Unlock();
return c;
}
public Pop()
{
var item = null;
this.mtx.Lock();
if(this.ls.Count > 0)
{
item = ls[ls.Count-1];
this.ls.RemoveAt(ls.Count-1);
}
this.mtx.Unlock();
return item;
}
public Push(val)
{
this.mtx.Lock();
this.ls.Add(val);
this.mtx.Unlock();
}
}

View File

@@ -4,33 +4,21 @@ class Resolution
static getNoDownload() "NoDownload";
/^ Get the video/audio mp4 (muxed by YouTube) ^/
static getLowVideo() "LowVideo";
/^ Get the highest video stream ^/
static getVideoOnly() "VideoOnly";
/^ Get the highest audio stream ^/
static getAudioOnly() "AudioOnly";
/^ Get the highest audio stream (and convert to mp3) ^/
/^ Get the LowVideo stream (and convert to mp3) ^/
static getMP3() "MP3";
/^ Get the highest audio stream (and convert to flac) ^/
/^ Get the LowVideo stream (and convert to flac) ^/
static getFLAC() "FLAC";
/^ Get the highest video and then audio stream (and convert to mp4) ^/
static getMP4() "MP4";
/^ Get the highest video and then audio stream (and mux to a mkv file) ^/
static getMKV() "MKV";
/^ Get the highest video and then audio stream (dont convert or mux) ^/
static getDontConvert() "DontConvert";
/^ Get the LowVideo stream (and convert to AMV for mp4 players like: https://www.ebay.com/sch/i.html?_nkw=AMV+mp3+player) ^/
static getAMV() "AMV";
static getResolutions()
{
return [
{name="Don't Download", value=Resolution.NoDownload},
{name="Low (muxed by YouTube)", value=Resolution.LowVideo,default=true},
{name="Highest video (no audio)", value=Resolution.VideoOnly},
{name="Highest audio (no video)", value=Resolution.AudioOnly},
{name="Convert to MP3", value=Resolution.MP3},
{name="Convert to FLAC", value=Resolution.FLAC},
{name="Convert to MP4", value=Resolution.MP4},
{name="Mux to MKV (no transcoding)",value=Resolution.MKV},
{name="Don't convert or Mux",value=Resolution.DontConvert}
{name="Don't Download", value=Resolution.NoDownload,filename=null},
{name="Low (muxed by YouTube)", value=Resolution.LowVideo,default=true,filename="ytmux.mp4"},
{name="Convert to MP3", value=Resolution.MP3,filename="conv.mp3"},
{name="Convert to FLAC", value=Resolution.FLAC,filename="conv.flac"},
{name="Convert To AMV (for generic mp4 players)", value=Resolution.AMV,filename="conv.amv"}
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
class VideoDownloadError {
public VideoDownloadError(id, error)
{
Id = id;
Error = error;
}
public Id;
public Error;
public ToString()
{
return $"Download error {Error} with Id: {Id}";
}
}

View File

@@ -27,6 +27,7 @@ func TYTD.GetVideoId(v)
return null;
}
func TYTD.GetPlaylistId(pid)
{
func IsValidId(v)
@@ -84,4 +85,49 @@ func TYTD.GetChannelId(cid)
}
return null;
}
func TYTD.CreateYouTubeTempPlaylist(ids)
{
var url = "https://www.youtube.com/watch_videos?video_ids=";
var first=true;
each(var item : ids)
{
if(!first) url += $"{url},{Net.Http.UrlEncode(item)}";
else
url += Net.Http.UrlEncode(item);
first=false;
}
return url;
}
func TYTD.GetYouTubeTempPlaylistRedirect(url)
{
if(url.Contains("/watch_videos?") && url.Contains("video_ids="))
{
var queryPart = url.Split("?",true,2);
return $"/watch_videos?{queryPart[1]}";
}
return null;
}
func TYTD.GetYouTubeTempPlaylist(url)
{
if(url.Contains("/watch_videos?") && url.Contains("video_ids="))
{
var queryPart = url.Split("?",true,2);
if(queryPart.Length == 2)
{
var queryParms =queryPart[1].Split("&");
each(var item : queryParms)
{
const vals = item.Split("=",true,2);
if(vals.Length == 2 && vals[0] == "video_ids")
{
return Net.Http.UrlDecode(vals[1]).Split(",");
}
}
}
}
return null;
}

View File

@@ -1,122 +0,0 @@
class TYTD.AOVideoDownload : IVideoDownload {
private info;
private tytd;
private progress;
private video_stream_url;
private done=false;
public AOVideoDownload(id)
{
this.info = {
Title = "",
Channel = "",
VideoId = TYTD.GetVideoId(id),
ChannelId = ""
};
}
public setTYTD(tytd)
{
this.tytd = tytd;
var id = this.info.VideoId;
var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"ao.bin";
if(this.tytd.Storage.FileExists(path)) {
this.done = true;
return tytd;
}
var req = this.tytd.ManifestRequest(id).playerResponse;
this.info.Title = req.videoDetails.title;
tytd.LOG($"Downloading: {this.info.Title} with id: {id} Highest Audio");
Console.WriteLine($"Downloading: {this.info.Title} with id: {id} Highest Audio");
this.info.Channel = req.videoDetails.author;
this.info.ChannelId = req.videoDetails.channelId;
this.tytd.PutVideoInfo(req.videoDetails);
var sampleRate = 0;
var bitrate = 0;
var url = "";
each(var item : req.streamingData.adaptiveFormats)
{
if(TypeOf(item.height) != "Long")
{
item.audioSampleRate = ParseLong(item.audioSampleRate);
if(item.audioSampleRate >= sampleRate && item.bitrate >= bitrate)
{
url = item.url;
sampleRate = item.audioSampleRate;
bitrate = item.bitrate;
}
}
}
this.video_stream_url = url;
return tytd;
}
public setProgress(p)
{
this.progress = p;
}
public getVideo()
{
return this.info;
}
public Start()
{
if(this.done) return;
for(var i = 0; i < 5; i++)
{
var req = {
FollowRedirects = true,
RequestHeaders = [
{
Key = "User-Agent",
Value = "com.google.android.youtube/19.28.35 (Linux; U; Android 15; GB) gzip"
}
]
};
var resp = Net.Http.MakeRequest(this.video_stream_url,req);
if(resp.StatusCode >= 200 && resp.StatusCode <= 299)
{
var id = this.info.VideoId;
var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"ao.bin";
this.tytd.Storage.CreateDirectory(path.GetParent());
var strm = this.tytd.Storage.OpenFile(path+".part","wb");
var src = resp.ReadAsStream();
Helpers.CopyToProgress(src,strm,this.progress,100.0);
strm.Close();
src.Close();
this.tytd.Storage.MoveFile(path+".part",path);
break;
}else {
var req = this.tytd.ManifestRequest(this.info.VideoId).playerResponse;
var sampleRate = 0;
var bitrate = 0;
var url = "";
each(var item : req.streamingData.adaptiveFormats)
{
if(TypeOf(item.height) != "Long")
{
item.audioSampleRate = ParseLong(item.audioSampleRate);
if(item.audioSampleRate >= sampleRate && item.bitrate >= bitrate)
{
url = item.url;
sampleRate = item.audioSampleRate;
bitrate = item.bitrate;
}
}
}
this.video_stream_url = url;
}
}
}
}

View File

@@ -1,228 +0,0 @@
class TYTD.NoConvertVideoDownload : IVideoDownload {
private info;
private tytd;
private progress;
private video_stream_url;
private audio_stream_url;
private done=false;
public NoConvertVideoDownload(id)
{
this.info = {
Title = "",
Channel = "",
VideoId = TYTD.GetVideoId(id),
ChannelId = ""
};
}
public getVideo()
{
return this.info;
}
public setTYTD(tytd)
{
this.tytd = tytd;
var id = this.info.VideoId;
var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"vo.bin";
var pathA = /"Streams"/id.Substring(0,4)/id.Substring(4)/"ao.bin";
if(this.tytd.Storage.FileExists(path) && this.tytd.Storage.FileExists(pathA)) {
this.done = true;
return tytd;
}
var req = this.tytd.ManifestRequest(id).playerResponse;
this.info.Title = req.videoDetails.title;
tytd.LOG($"Downloading: {this.info.Title} with id: {id} Highest Video/Audio");
Console.WriteLine($"Downloading: {this.info.Title} with id: {id} Highest Video/Audio");
this.info.Channel = req.videoDetails.author;
this.info.ChannelId = req.videoDetails.channelId;
this.tytd.PutVideoInfo(req.videoDetails);
var width = 0;
var height = 0;
var sampleRate = 0;
var bitrate = 0;
var vurl = "";
var aurl = "";
each(var item : req.streamingData.adaptiveFormats)
{
if(TypeOf(item.height) == "Long")
{
if(item.width >= width && item.height >= height)
{
vurl = item.url;
width = item.width;
height = item.height;
}
}
else {
item.audioSampleRate = ParseLong(item.audioSampleRate);
if(item.audioSampleRate >= sampleRate && item.bitrate >= bitrate)
{
aurl = item.url;
sampleRate = item.audioSampleRate;
bitrate = item.bitrate;
}
}
}
this.video_stream_url = vurl;
this.audio_stream_url = aurl;
return tytd;
}
public setProgress(p)
{
this.progress = p;
}
public Start()
{
if(this.done) return;
var id = this.info.VideoId;
var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"vo.bin";
var pathA = /"Streams"/id.Substring(0,4)/id.Substring(4)/"ao.bin";
if(!this.tytd.Storage.FileExists(path))
{
for(var i = 0; i < 5; i++)
{
var req = {
FollowRedirects = true,
RequestHeaders = [
{
Key = "User-Agent",
Value = "com.google.android.youtube/19.28.35 (Linux; U; Android 15; GB) gzip"
}
]
};
var resp = Net.Http.MakeRequest(this.video_stream_url,req);
if(resp.StatusCode >= 200 && resp.StatusCode <= 299)
{
this.tytd.Storage.CreateDirectory(path.GetParent());
var strm = this.tytd.Storage.OpenFile(path+".part","wb");
var src = resp.ReadAsStream();
Helpers.CopyToProgress(src,strm,(p)=>{
this.progress(p/2);
},100.0);
strm.Close();
src.Close();
this.tytd.Storage.MoveFile(path+".part",path);
break;
}
else {
var req = this.tytd.ManifestRequest(this.info.VideoId).playerResponse;
var width = 0;
var height = 0;
var sampleRate = 0;
var bitrate = 0;
var vurl = "";
var aurl = "";
each(var item : req.streamingData.adaptiveFormats)
{
if(TypeOf(item.height) == "Long")
{
if(item.width >= width && item.height >= height)
{
vurl = item.url;
width = item.width;
height = item.height;
}
}
else {
item.audioSampleRate = ParseLong(item.audioSampleRate);
if(item.audioSampleRate >= sampleRate && item.bitrate >= bitrate)
{
aurl = item.url;
sampleRate = item.audioSampleRate;
bitrate = item.bitrate;
}
}
}
this.video_stream_url = vurl;
this.audio_stream_url = aurl;
}
}
}
if(!this.tytd.Storage.FileExists(pathA))
{
for(var i = 0; i < 5; i++)
{
var req = {
FollowRedirects = true,
RequestHeaders = [
{
Key = "User-Agent",
Value = "com.google.android.youtube/19.28.35 (Linux; U; Android 15; GB) gzip"
}
]
};
var resp = Net.Http.MakeRequest(this.audio_stream_url,req);
if(resp.StatusCode >= 200 && resp.StatusCode <= 299)
{
this.tytd.Storage.CreateDirectory(pathA.GetParent());
var strm = this.tytd.Storage.OpenFile(pathA+".part","wb");
var src = resp.ReadAsStream();
Helpers.CopyToProgress(src,strm,(p)=>{
this.progress((p/2)+0.5);
},100.0);
strm.Close();
src.Close();
this.tytd.Storage.MoveFile(pathA+".part",pathA);
break;
}
else {
var req = this.tytd.ManifestRequest(this.info.VideoId).playerResponse;
var width = 0;
var height = 0;
var sampleRate = 0;
var bitrate = 0;
var vurl = "";
var aurl = "";
each(var item : req.streamingData.adaptiveFormats)
{
if(TypeOf(item.height) == "Long")
{
if(item.width >= width && item.height >= height)
{
vurl = item.url;
width = item.width;
height = item.height;
}
}
else {
item.audioSampleRate = ParseLong(item.audioSampleRate);
if(item.audioSampleRate >= sampleRate && item.bitrate >= bitrate)
{
aurl = item.url;
sampleRate = item.audioSampleRate;
bitrate = item.bitrate;
}
}
}
this.video_stream_url = vurl;
this.audio_stream_url = aurl;
}
}
}
this.progress(1.0);
return;
}
}

View File

@@ -20,9 +20,17 @@ class TYTD.SDVideoDownload : IVideoDownload {
this.tytd = tytd;
var id = this.info.VideoId;
var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"ytmux.mp4";
if(this.tytd.Storage.FileExists(path)) {
if(this.tytd.Storage.FileExists(path)) {
this.done = true;
return tytd;
const vinfo = this.tytd.GetVideo(id);
this.info = {
Title = vinfo.title,
VideoId = vinfo.videoId,
ChannelId = vinfo.channelId,
Channel = vinfo.author
};
return null;
}
var req = this.tytd.ManifestRequest(id).playerResponse;

View File

@@ -0,0 +1,84 @@
class TYTD.TranscodeAMV : IVideoDownload {
private id;
private ncv;
private tytd;
private done;
public TranscodeAMV(id)
{
this.id = id;
this.ncv = new TYTD.SDVideoDownload(id);
}
public setTYTD(tytd)
{
this.tytd = tytd;
this.ncv.TYTD = tytd;
if(TypeIsDefined(tytd))
{
var dir = this.tytd.DatabaseDirectory / "Streams"/this.id.Substring(0,4)/this.id.Substring(4);
var ao = dir / "ytmux.mp4";
var out = dir /"conv.amv";
if(FS.Local.FileExists(out)) return null;
return tytd;
}
return null;
}
public setProgress(p)
{
return this.ncv.Progress = p;
}
public getVideo()
{
return this.ncv.Video;
}
public Start()
{
var id = this.id;
this.ncv.Start();
var p = new Process();
p.FileName = Env.GetRealExecutablePath("ffmpeg").ToString();
var dir = this.tytd.DatabaseDirectory / "Streams"/this.id.Substring(0,4)/this.id.Substring(4);
var ao = dir / "ytmux.mp4";
var out = dir / $"conv.amv";
if(FS.Local.FileExists(out) || !FS.Local.FileExists(ao)) return;
var args=["-y","-i",ao.ToString()];
//-c:v amv -c:a adpcm_ima_amv -pix_fmt yuvj420p -vstrict -1 -s 160x120 -ac 1 -ar 22050 -r 16 -block_size 882
args.Add("-c:v");
args.Add("amv");
args.Add("-c:a");
args.Add("adpcm_ima_amv");
args.Add("-pix_fmt");
args.Add("yuvj420p");
args.Add("-vstrict");
args.Add("-1");
args.Add("-s");
args.Add("160x120");
args.Add("-ac");
args.Add("1");
args.Add("-ar");
args.Add("22050");
args.Add("-r");
args.Add("15");
args.Add("-block_size");
args.Add("1470");
args.Add("-preset");
args.Add("ultrafast");
args.Add(out.ToString());
p.Arguments = args;
if(p.Start())
p.Join();
}
}

View File

@@ -9,13 +9,24 @@ class TYTD.TranscodeAudio : IVideoDownload {
{
this.id = id;
this.ext = ext;
this.ncv = new TYTD.AOVideoDownload(id);
this.ncv = new TYTD.SDVideoDownload(id);
}
public setTYTD(tytd)
{
this.tytd = tytd;
return this.ncv.TYTD = tytd;
this.ncv.TYTD = tytd;
if(TypeIsDefined(tytd))
{
var dir = this.tytd.DatabaseDirectory / "Streams"/this.id.Substring(0,4)/this.id.Substring(4);
var out = dir /"conv.{this.ext}";
if(FS.Local.FileExists(out)) return null;
return tytd;
}
return null;
}
public setProgress(p)
@@ -31,15 +42,15 @@ class TYTD.TranscodeAudio : IVideoDownload {
public Start()
{
var id = this.id;
this.mcv.Start();
this.ncv.Start();
var p = new Process();
p.FileName = Env.GetRealExecutablePath("ffmpeg").ToString();
var dir = this.tytd.DatabaseDirectory / "Streams"/this.id.Substring(0,4)/this.id.Substring(4);
var ao = dir / "ao.bin";
var ao = dir / "ytmux.mp4";
var out = dir / $"conv{this.ext}";
if(FS.Local.FileExists(out)) return;
if(FS.Local.FileExists(out) && !FS.Local.FileExists(ao)) return;
var args=["-y","-i",ao.ToString()];

View File

@@ -1,63 +0,0 @@
class TYTD.TranscodeVideo : IVideoDownload {
private id;
private ncv;
private tytd;
private ext;
public TranscodeVideo(id,ext)
{
this.id = id;
this.ext = ext;
this.ncv = new TYTD.NoConvertVideoDownload(id);
}
public setTYTD(tytd)
{
this.tytd = tytd;
return this.ncv.TYTD = tytd;
}
public setProgress(p)
{
return this.ncv.Progress = p;
}
public getVideo()
{
return this.ncv.Video;
}
public Start()
{
var id = this.id;
this.mcv.Start();
var p = new Process();
p.FileName = Env.GetRealExecutablePath("ffmpeg").ToString();
var dir = this.tytd.DatabaseDirectory / "Streams"/this.id.Substring(0,4)/this.id.Substring(4);
var vo = dir / "vo.bin";
var ao = dir / "ao.bin";
var out = dir / $"conv{this.ext}";
if(FS.Local.FileExists(out)) return;
var args=["-y","-i",vo.ToString(),"-i",ao.ToString()];
if(this.ext == ".mkv")
{
args.Add("-c");
args.Add("copy");
}
args.Add("-map");
args.Add("0:v");
args.Add("-map");
args.Add("1:a");
args.Add("-preset");
args.Add("ultrafast");
args.Add(out.ToString());
p.Arguments = args;
if(p.Start())
p.Join();
}
}

View File

@@ -1,115 +0,0 @@
class TYTD.VOVideoDownload : IVideoDownload {
private info;
private tytd;
private progress;
private video_stream_url;
private done=false;
public VOVideoDownload(id)
{
this.info = {
Title = "",
Channel = "",
VideoId = TYTD.GetVideoId(id),
ChannelId = ""
};
}
public setTYTD(tytd)
{
this.tytd = tytd;
var id = this.info.VideoId;
var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"vo.bin";
if(this.tytd.Storage.FileExists(path)) {
this.done = true;
return tytd;
}
var req = this.tytd.ManifestRequest(id).playerResponse;
this.info.Title = req.videoDetails.title;
this.info.Channel = req.videoDetails.author;
this.info.ChannelId = req.videoDetails.channelId;
tytd.LOG($"Downloading: {this.info.Title} with id: {id} Highest Video");
Console.WriteLine($"Downloading: {this.info.Title} with id: {id} Highest Video");
this.tytd.PutVideoInfo(req.videoDetails);
var width = 0;
var height = 0;
var url = "";
each(var item : req.streamingData.adaptiveFormats)
{
if(TypeOf(item.height) == "Long")
{
if(item.width >= width && item.height >= height)
{
url = item.url;
width = item.width;
height = item.height;
}
}
}
this.video_stream_url = url;
return tytd;
}
public setProgress(p)
{
this.progress = p;
}
public getVideo()
{
return this.info;
}
public Start()
{
if(this.done) return;
var req = {
FollowRedirects = true,
RequestHeaders = [
{
Key = "User-Agent",
Value = "com.google.android.youtube/19.28.35 (Linux; U; Android 15; GB) gzip"
}
]
};
var resp = Net.Http.MakeRequest(this.video_stream_url,req);
if(resp.StatusCode >= 200 && resp.StatusCode <= 299)
{
var id = this.info.VideoId;
var path = /"Streams"/id.Substring(0,4)/id.Substring(4)/"vo.bin";
this.tytd.Storage.CreateDirectory(path.GetParent());
var strm = this.tytd.Storage.OpenFile(path+".part","wb");
var src = resp.ReadAsStream();
Helpers.CopyToProgress(src,strm,this.progress,100.0);
strm.Close();
src.Close();
this.tytd.Storage.MoveFile(path+".part",path);
}
else {
var req = this.tytd.ManifestRequest(this.info.VideoId).playerResponse;
var width = 0;
var height = 0;
var url = "";
each(var item : req.streamingData.adaptiveFormats)
{
if(TypeOf(item.height) == "Long")
{
if(item.width >= width && item.height >= height)
{
url = item.url;
width = item.width;
height = item.height;
}
}
}
this.video_stream_url = url;
}
}
}

18
build-for-system-pkg.sh Normal file
View File

@@ -0,0 +1,18 @@
cd Tesses.YouTubeDownloader.Server
crosslang build
cd bin
cp Tesses.YouTubeDownloader.Server-*.crvm tytd2025.crvm
cd ../..
mkdir -p packages-tmp
nfpm package -t packages-tmp -p archlinux
curl --user tesses50:$PACKAGE_AND_BREW \
--upload-file packages-tmp/*.pkg.tar.zst \
https://git.tesses.org/api/packages/tesses50/arch/core
nfpm package -t packages-tmp -p deb
curl --user tesses50:$PACKAGE_AND_BREW \
--upload-file packages-tmp/*.deb \
https://git.tesses.org/api/packages/tesses50/debian/pool/universal/main/upload
nfpm package -t packages-tmp -p rpm
curl --user tesses50:$PACKAGE_AND_BREW \
--upload-file packages-tmp/*.rpm \
https://git.tesses.org/api/packages/tesses50/rpm/upload

7
changeme.md Normal file
View File

@@ -0,0 +1,7 @@
# Changelog
## 1.0.1
Target crosslang v0.0.10, add /api/v1/progress_ex.json
## 1.0.0
Make it a little faster, queue is in sqlite now, fix db bug and more

View File

@@ -1,6 +1,6 @@
services:
tytd:
image: onedev.site.tesses.net/tytd2025/tytd2025:latest
image: git.tesses.org/tesses50/tytd2025:latest
volumes:
- tytd-data:/data
ports:

43
nfpm.yaml Normal file
View File

@@ -0,0 +1,43 @@
# This is an example nfpm configuration file.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://nfpm.goreleaser.com/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
name: "tytd2025"
arch: "any"
platform: "linux"
version: "1.0.1"
section: "default"
priority: "extra"
depends:
- crosslang
recommends:
- ffmpeg
maintainer: "Mike Nolan <tesses@tesses.net>"
description: Tesses YouTube Downloader
vendor: "Tesses"
homepage: "https://git.tesses.org/tesses50/tytd2025.git"
license: "GPLv3"
contents:
- src: "Tesses.YouTubeDownloader.Server/bin/tytd2025.crvm"
dst: "/opt/tytd2025/tytd2025.crvm"
- src: "Tesses.YouTubeDownloader.Server/bin/Tesses.YouTubeDownloader-*.crvm"
dst: "/opt/tytd2025/"
- src: "Tesses.YouTubeDownloader.Server/bin/Tesses.CrossLang.BuildEssentials-*.crvm"
dst: "/opt/tytd2025/"
- src: package/systemd/
dst: /etc/systemd/system/
type: tree
- src: package/usrbin/
dst: /usr/bin/
type: tree
file_info:
mode: 0755
scripts:
postinstall: ./package/postinstall.sh
preremove: ./package/preremove.sh

5
package/postinstall.sh Normal file
View File

@@ -0,0 +1,5 @@
useradd --system --create-home --shell /usr/sbin/nologin tytd2025 || true
if command -v deb-systemd-helper >/dev/null 2>&1; then
deb-systemd-helper enable tytd2025
fi

3
package/preremove.sh Normal file
View File

@@ -0,0 +1,3 @@
if command -v deb-systemd-helper >/dev/null 2>&1; then
deb-systemd-helper disable tytd2025
fi

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Tesses YouTube Downloader
Documentation=https://git.tesses.org/tesses50/tytd2025
[Service]
Type=simple
ExecStart=/usr/bin/tytd2025
Restart=on-failure
User=tytd2025
StandardOutput=journal
[Install]
WantedBy=multi-user.target

2
package/usrbin/tytd2025 Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env sh
exec crosslang vm "/opt/tytd2025/tytd2025.crvm" --port=3255