First commit

This commit is contained in:
2026-03-15 22:15:34 -05:00
commit 0858a8a751
79 changed files with 6010 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
bin
data
obj
+3
View File
@@ -0,0 +1,3 @@
bin
data
obj
+27
View File
@@ -0,0 +1,27 @@
using Tesses.WebServer;
using Scriban;
using Newtonsoft.Json;
namespace Tesses.YouTubeDownloader2025Companion
{
public class AuthConfig {
[JsonProperty("companion_url")]
public string CompanionUrl {get;set;}="";
[JsonProperty("tytd_url")]
public string TYTDUrl {get;set;}="";
[JsonProperty("service_name")]
public string ServiceName {get;set;}="";
[JsonProperty("sso_app_key")]
public string SSOAppKey {get;set;}="";
[JsonProperty("tytd_session")]
public string Session {get;set;}="";
[JsonProperty("thumbnails_through_tytd")]
public bool ThumbnailsThroughTYTD {get;set;}=false;
}
}
+30
View File
@@ -0,0 +1,30 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
namespace Tesses.YouTubeDownloader2025Companion
{
public class PageLink(string title, string icon, string url,string badge="") {
public string Title => HttpUtility.HtmlEncode(title);
public string Icon => HttpUtility.HtmlEncode(icon);
public string Url => HttpUtility.HtmlAttributeEncode(url);
public string Badge => HttpUtility.HtmlEncode(badge);
}
public static partial class Components {
private static ReloadingScript shell = new ReloadingScript("wwwroot/components/shell.sbnhtml");
public static string Shell(string title, string body, IEnumerable<PageLink> pages)
{
return shell.Render(new {
Title = System.Web.HttpUtility.HtmlEncode(title),
Body = body,
Pages = pages
});
}
internal static string Shell(string v1, string v2, object defaultLinks)
{
throw new NotImplementedException();
}
}
}
+306
View File
@@ -0,0 +1,306 @@
using Tesses.WebServer;
using Scriban;
using Newtonsoft.Json;
using System.Data.SQLite;
using Dapper;
using System.Security.Cryptography;
//using YouTubeMusicAPI.Models.Info;
using YoutubeExplode;
using YoutubeExplode.Videos;
//using YouTubeMusicAPI.Models.Search;
namespace Tesses.YouTubeDownloader2025Companion
{
public class Database : IDisposable, IAsyncDisposable
{
public const long Expires = 86400*7;
SQLiteConnection conn;
public Database()
{
conn=new SQLiteConnection("Data Source=data/Config/database.db");
}
public void Init()
{
conn.Execute("CREATE TABLE IF NOT EXISTS sessions (Id INTEGER PRIMARY KEY AUTOINCREMENT, AccountName TEXT, Key TEXT UNIQUE, Expires INTEGER);");
conn.Execute("CREATE TABLE IF NOT EXISTS song_videos (Id INTEGER PRIMARY KEY AUTOINCREMENT, SongId TEXT UNIQUE, VideoId TEXT UNIQUE, VideoTitle TEXT);");
conn.Execute("CREATE TABLE IF NOT EXISTS songs (Id INTEGER PRIMARY KEY AUTOINCREMENT, SongId TEXT UNIQUE, SongName TEXT);");
conn.Execute("CREATE TABLE IF NOT EXISTS artists (Id INTEGER PRIMARY KEY AUTOINCREMENT, ArtistId TEXT UNIQUE, ArtistName TEXT, ArtistDisambiguation TEXT);");
conn.Execute("CREATE TABLE IF NOT EXISTS albums (Id INTEGER PRIMARY KEY AUTOINCREMENT, AlbumId TEXT UNIQUE, AlbumName TEXT, AlbumReleaseDate TEXT);");
conn.Execute("CREATE TABLE IF NOT EXISTS album_tracks (Id INTEGER PRIMARY KEY AUTOINCREMENT, AlbumId TEXT, SongId TEXT, Track INTEGER, Length INTEGER, UNIQUE(AlbumId, SongId, Track));");
conn.Execute("CREATE TABLE IF NOT EXISTS album_artists (Id INTEGER PRIMARY KEY AUTOINCREMENT, AlbumId TEXT, ArtistId TEXT, UNIQUE(AlbumId, ArtistId));");
conn.Execute("CREATE TABLE IF NOT EXISTS song_artists (Id INTEGER PRIMARY KEY AUTOINCREMENT, SongId TEXT, ArtistId TEXT, UNIQUE(SongId, ArtistId));");
conn.Execute("CREATE TABLE IF NOT EXISTS tytd_events (Id INTEGER PRIMARY KEY AUTOINCREMENT, Time INTEGER, Type TEXT, VideoTitle TEXT, VideoId TEXT, ChannelTitle TEXT, ChannelId TEXT);");
}
public async Task<IEnumerable<LogEntry>> GetLogEntriesAsync(int offset=0, int count=20)
{
return await conn.QueryAsync<LogEntry>("SELECT * FROM tytd_events ORDER BY Time DESC LIMIT @Count OFFSET @Offset",new DynamicParameters(new{
Offset = offset,
Count = count
}));
}
public async Task<MediaEntry?> GetMediaEntryAsync(string albumId, string songId)
{
var song = await GetSongAsync(songId);
if(song is null) return null;
var album = await GetAlbumAsync(albumId);
if(album is null) return null;
var track = (await conn.QueryAsync<AlbumTrack>("SELECT * FROM album_tracks WHERE SongId = @SongId AND AlbumId = @AlbumId;" ,new DynamicParameters(new{SongId = songId, AlbumId = albumId}))).FirstOrDefault();
if(track is null) return null;
MediaEntry ent = new MediaEntry();
ent.TrackIndex = track.Track;
ent.AlbumId = album.AlbumId;
ent.SongName = song.SongName;
ent.AlbumArtist = (await GetAlbumArtistsAsync(track.AlbumId)).Select(e=>e.ArtistName).FirstOrDefault() ?? "Unknown Artist";
ent.AlbumName = album.AlbumName;
ent.ContributingArtists.AddRange((await GetContributingArtistsAsync(songId)).Select(e=>e.ArtistName));
return ent;
}
public async IAsyncEnumerable<MediaEntry> GetMediaEntriesAsync(string songId)
{
var song = await GetSongAsync(songId);
if(song is null) yield break;
List<string> contributingArtists = new List<string>();
contributingArtists.AddRange((await GetContributingArtistsAsync(songId)).Select(e=>e.ArtistName));
foreach(var track_info in await conn.QueryAsync<AlbumTrack>("SELECT * FROM album_tracks WHERE SongId = @SongId;" ,new DynamicParameters(new{SongId = songId})))
{
MediaEntry ent=new MediaEntry();
ent.TrackIndex = track_info.Track;
ent.AlbumId = track_info.AlbumId;
ent.SongName = song.SongName;
ent.ContributingArtists.AddRange(contributingArtists);
ent.AlbumArtist = (await GetAlbumArtistsAsync(track_info.AlbumId)).Select(e=>e.ArtistName).FirstOrDefault() ?? "Unknown Artist";
var album = await GetAlbumAsync(track_info.AlbumId);
if(album is not null)
{
ent.AlbumName = album.AlbumName;
}
else ent.AlbumName = "Unknown Album";
yield return ent;
}
}
public async Task<Session> CreateSessionTokenAsync(string username)
{
Session session=new Session();
session.AccountName = username;
session.Expires = Program.NowEpoch + Expires;
session.Key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
await conn.ExecuteAsync("INSERT INTO sessions (AccountName,Key,Expires) VALUES (@AccountName,@Key,@Expires);",session);
return session;
}
public async Task<VideoId?> GetVideoIdAsync(string id)
{
var video = await GetSongVideoFromSongIdAsync(id);
if(video is not null)
{
return VideoId.TryParse(video.VideoId);
}
return null;
}
public async Task<string?> GetSongIdFromVideoId(VideoId id)
{
var video = await GetSongVideoFromVideoIdAsync(id);
if(video is not null)
{
return video.SongId;
}
return null;
}
public async Task AddVideoIdAsync(string songId, VideoId id, string title)
{
try {
await conn.ExecuteAsync($"INSERT INTO song_videos (SongId, VideoId, VideoTitle) VALUES (@SongId,@VideoId,@VideoTitle);", new DynamicParameters(new{SongId=songId,VideoId = id.Value, VideoTitle = title}));
}catch(Exception){}
}
public async Task UpdateVideoIdAsync(string songId, VideoId id, string title)
{
try {
await conn.ExecuteAsync($"INSERT OR REPLACE INTO song_videos (SongId, VideoId, VideoTitle) VALUES (@SongId,@VideoId,@VideoTitle);", new DynamicParameters(new{SongId=songId,VideoId = id.Value, VideoTitle = title}));
}catch(Exception){}
}
public async Task AddAlbumArtistAsync(string albumId, string artistId)
{
try {
await conn.ExecuteAsync($"INSERT INTO album_artists (AlbumId, ArtistId) VALUES (@AlbumId,@ArtistId);", new DynamicParameters(new{AlbumId=albumId,ArtistId = artistId}));
}catch(Exception){}
}
public async Task AddContributingArtistAsync(string songId, string artistId)
{
try {
await conn.ExecuteAsync($"INSERT INTO song_artists (SongId, ArtistId) VALUES (@SongId,@ArtistId);", new DynamicParameters(new{SongId=songId,ArtistId = artistId}));
}catch(Exception){}
}
public async Task<SongVideo?> GetSongVideoFromVideoIdAsync(VideoId id)
{
return (await conn.QueryAsync<SongVideo>($"SELECT * FROM song_videos WHERE VideoId = @VideoId;", new DynamicParameters(new{VideoId = id.Value}))).FirstOrDefault();
}
public async Task<SongVideo?> GetSongVideoFromSongIdAsync(string id)
{
return (await conn.QueryAsync<SongVideo>($"SELECT * FROM song_videos WHERE SongId = @SongId;", new DynamicParameters(new{SongId = id}))).FirstOrDefault();
}
public async Task AddArtistAsync(Artist artist)
{
try {
await conn.ExecuteAsync("INSERT INTO artists (ArtistId,ArtistName,ArtistDisambiguation) VALUES (@ArtistId,@ArtistName,@ArtistDisambiguation);",artist);
}catch(Exception) {}
}
public async Task AddAlbumAsync(Album album)
{
try {
await conn.ExecuteAsync("INSERT INTO albums (AlbumId,AlbumName,AlbumReleaseDate) VALUES (@AlbumId,@AlbumName,@AlbumReleaseDate);",album);
}catch(Exception) {}
}
public async Task AddSongAsync(Song song)
{
try {
await conn.ExecuteAsync("INSERT INTO songs (SongId,SongName) VALUES (@SongId,@SongName);",song);
}catch(Exception) {}
}
public async Task AddTrackAsync(AlbumTrack track)
{
try {
await conn.ExecuteAsync("INSERT INTO album_tracks (AlbumId,SongId,Track,Length) VALUES (@AlbumId,@SongId,@Track,@Length);",track);
}catch(Exception) {}
}
public async Task<Song?> GetSongAsync(string id)
{
return (await conn.QueryAsync<Song>($"SELECT * FROM songs WHERE SongId = @SongId;", new DynamicParameters(new{SongId = id}))).FirstOrDefault();
}
public async Task<IEnumerable<Artist>> GetContributingArtistsAsync(string songId)
{
return await conn.QueryAsync<Artist>($"SELECT * FROM song_artists s INNER JOIN artists a ON s.ArtistId = a.ArtistId WHERE s.SongId = @SongId ORDER BY s.Id ASC;", new DynamicParameters(new{SongId = songId}));
}
public async Task<IEnumerable<Artist>> GetArtistsAsync(string q="", long offset=0, long count=20)
{
return await conn.QueryAsync<Artist>("SELECT * FROM artists WHERE ArtistName LIKE @Query LIMIT @Count OFFSET @Offset;",new DynamicParameters(new{
Query = $"%{q}%",
Offset = offset,
Count = count
}));
}
public async Task<IEnumerable<Song>> GetSongsAsync(string q="", long offset=0, long count=20)
{
return await conn.QueryAsync<Song>("SELECT * FROM songs WHERE SongName LIKE @Query LIMIT @Count OFFSET @Offset;",new DynamicParameters(new{
Query = $"%{q}%",
Offset = offset,
Count = count
}));
}
public async Task<IEnumerable<Album>> GetAlbumsFromArtistAsync(string artistId, string q = "",long offset=0, long count=20)
{
return await conn.QueryAsync<Album>("SELECT * FROM albums al INNER JOIN album_artists ar ON al.AlbumId = ar.AlbumId WHERE AlbumName LIKE @Query AND ar.ArtistId = @ArtistId ORDER BY al.AlbumReleaseDate, al.Id ASC LIMIT @Count OFFSET @Offset;",new DynamicParameters(new{
Query = $"%{q}%",
Offset = offset,
Count = count,
ArtistId = artistId
}));
}
public async Task<IEnumerable<Album>> GetAlbumsAsync(string q="", long offset=0, long count=20)
{
return await conn.QueryAsync<Album>("SELECT * FROM albums WHERE AlbumName LIKE @Query ORDER BY AlbumReleaseDate, Id ASC LIMIT @Count OFFSET @Offset;",new DynamicParameters(new{
Query = $"%{q}%",
Offset = offset,
Count = count
}));
}
public async Task<IEnumerable<SongVideo>> GetSongVideosAsync()
{
return await conn.QueryAsync<SongVideo>($"SELECT * FROM song_videos;");
}
public async Task<IEnumerable<Artist>> GetAlbumArtistsAsync(string albumId)
{
return await conn.QueryAsync<Artist>($"SELECT * FROM album_artists s INNER JOIN artists a ON s.ArtistId = a.ArtistId WHERE s.AlbumId = @AlbumId;", new DynamicParameters(new{AlbumId = albumId}));
}
public async Task<Artist?> GetArtistAsync(string id)
{
return (await conn.QueryAsync<Artist>($"SELECT * FROM artists WHERE ArtistId = @ArtistId;", new DynamicParameters(new{ArtistId = id}))).FirstOrDefault();
}
public async Task<Album?> GetAlbumAsync(string id)
{
return (await conn.QueryAsync<Album>("SELECT * FROM albums WHERE AlbumId = @AlbumId;", new DynamicParameters(new{AlbumId = id}))).FirstOrDefault();
}
public async Task<AlbumTrack?> GetTrackAsync(string songId, string albumId)
{
return (await conn.QueryAsync<AlbumTrack>("SELECT * FROM album_tracks WHERE AlbumId = @AlbumId AND SongId = @SongId", new DynamicParameters(new{
SongId = songId,
AlbumId = albumId}))).FirstOrDefault();
}
public async Task<IEnumerable<AlbumSongTrack>> GetTracksAsync(string albumId)
{
return await conn.QueryAsync<AlbumSongTrack>("SELECT * FROM songs s INNER JOIN album_tracks t ON s.SongId = t.SongId WHERE t.AlbumId = @AlbumId ORDER BY t.Track ASC;",new DynamicParameters(new{AlbumId = albumId}));
}
public async Task<Session?> GetSessionAsync(ServerContext ctx,string sessionToken)
{
var time = Program.NowEpoch;
var res = (await conn.QueryAsync<Session>("SELECT * FROM sessions WHERE Key = @Key;", new DynamicParameters(new{Key=sessionToken}))).FirstOrDefault();
if(res is not null)
{
var expires = res.Expires;
if(expires != 0 && time < expires && (expires - time) < (Expires - 3600))
{
long expiry = time + Expires;
res.Expires = expiry;
await conn.ExecuteAsync("UPDATE sessions SET Expires = @Expires WHERE Key = @Key;",res);
ctx.ResponseHeaders["Set-Cookie"]=[$"Session={res.Key}; SameSite=Lax; Expires={DateTimeOffset.FromUnixTimeSeconds(expiry).ToString("R")}; HttpOnly"];
}
else if(expires != 0 && time >= expires)
{
await conn.ExecuteAsync("DELETE FROM sessions WHERE Key = @Key;", res);
return null;
}
}
return res;
}
public void Dispose()
{
conn.Dispose();
}
public async ValueTask DisposeAsync()
{
await conn.DisposeAsync();
}
public async Task AddEventAsync(long epoch, Webhook webhook)
{
//conn.Execute("CREATE TABLE IF NOT EXISTS tytd_events (Id INTEGER PRIMARY KEY AUTOINCREMENT, Time INTEGER, Type TEXT, VideoTitle TEXT, VideoId TEXT, ChannelTitle TEXT, ChannelId TEXT);");
await conn.ExecuteAsync("INSERT INTO tytd_events (Time,Type,VideoTitle,VideoId,ChannelTitle,ChannelId) VALUES (@Time,@Type,@VideoTitle,@VideoId,@ChannelTitle,@ChannelId);", new DynamicParameters(new {
Time = epoch,
Type = webhook.Type,
VideoTitle = webhook.Video.Title,
VideoId = webhook.Video.VideoId,
ChannelTitle = webhook.Video.Channel,
ChannelId = webhook.Video.ChannelId
}));
}
public class Session
{
public long Id {get;set;}
public string AccountName {get;set;}="";
public string Key {get;set;}="";
public long Expires {get;set;}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ./ /src
RUN dotnet restore
RUN dotnet publish -c Release -o out
FROM mcr.microsoft.com/dotnet/runtime:10.0
WORKDIR /
EXPOSE 4255
COPY --from=build /src/out /app
COPY --from=build /src/wwwroot /wwwroot
ENTRYPOINT ["/app/TYTD2025Companion"]
+27
View File
@@ -0,0 +1,27 @@
using System.Collections.Concurrent;
namespace Tesses.YouTubeDownloader2025Companion;
public interface IEventable {
Task EventAsync();
}
public class TaskList : IEventable {
ConcurrentQueue<Func<Task>> tasks = new ConcurrentQueue<Func<Task>>();
public void Enqueue(Func<Task> task)
{
tasks.Enqueue(task);
}
public async Task EventAsync()
{
try {
if(tasks.TryDequeue(out var res))
await res();
} catch(Exception ex)
{
Console.WriteLine(ex);
}
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class Album {
public long Id {get;set;} = 0;
public string AlbumId {get;set;}="";
public string AlbumName {get;set;}="";
public string AlbumReleaseDate {get;set;}="";
}
+19
View File
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class AlbumQuery {
[JsonPropertyName("releases")]
public List<AlbumQueryItem> Albums {get;set;}=new List<AlbumQueryItem>();
public class AlbumQueryItem
{
[JsonPropertyName("id")]
public Guid Id {get;set;}
[JsonPropertyName("title")]
public string Name {get;set;}="";
}
}
+67
View File
@@ -0,0 +1,67 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class AlbumSongQuery {
[JsonPropertyName("artist-credit")]
public List<AlbumSongQueryMedia.AlbumSongQueryMediaTrack.AlbumSongQueryMediaTrackRecording.AlbumSongQueryMediaTrackRecordingArtists> Artists {get;set;}=new List<AlbumSongQueryMedia.AlbumSongQueryMediaTrack.AlbumSongQueryMediaTrackRecording.AlbumSongQueryMediaTrackRecordingArtists>();
[JsonPropertyName("title")]
public string Title {get;set;}="";
[JsonPropertyName("date")]
public string Date {get;set;}="";
[JsonPropertyName("media")]
public List<AlbumSongQueryMedia> Media {get;set;}=new List<AlbumSongQueryMedia>();
public class AlbumSongQueryMedia {
[JsonPropertyName("tracks")]
public List<AlbumSongQueryMediaTrack> Tracks {get;set;}=new List<AlbumSongQueryMediaTrack>();
public class AlbumSongQueryMediaTrack
{
[JsonPropertyName("position")]
public int Track {get;set;} = 1;
[JsonPropertyName("recording")]
public AlbumSongQueryMediaTrackRecording Recording {get;set;}=new AlbumSongQueryMediaTrackRecording();
[JsonPropertyName("length")]
public long Length {get;set;} = 0;
public class AlbumSongQueryMediaTrackRecording
{
[JsonPropertyName("title")]
public string Name {get;set;}="";
[JsonPropertyName("id")]
public Guid Id {get;set;}
[JsonPropertyName("artist-credit")]
public List<AlbumSongQueryMediaTrackRecordingArtists> ContributingArtists {get;set;}=new List<AlbumSongQueryMediaTrackRecordingArtists>();
public class AlbumSongQueryMediaTrackRecordingArtists
{
[JsonPropertyName("joinphrase")]
public string JoinPhrase {get;set;}="";
[JsonPropertyName("artist")]
public AlbumSongQueryMediaTrackRecordingArtistsArtist Artist {get;set;}=new AlbumSongQueryMediaTrackRecordingArtistsArtist();
public class AlbumSongQueryMediaTrackRecordingArtistsArtist
{
[JsonPropertyName("name")]
public string Name {get;set;}="";
[JsonPropertyName("id")]
public Guid Id {get;set;}
[JsonPropertyName("disambiguation")]
public string Disambiguation {get;set;}="";
}
}
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class AlbumTrack {
public long Id {get;set;} = 0;
public string AlbumId {get;set;}="";
public string SongId {get;set;}="";
public int Track {get;set;}=0;
public long Length {get;set;}=0;
}
public class AlbumSongTrack {
public string AlbumId {get;set;}="";
public string SongId {get;set;} = "";
public int Track {get;set;}
public string SongName {get;set;}="";
public long Length {get;set;}=0;
}
+14
View File
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class Artist {
public long Id {get;set;}=0;
public string ArtistId {get;set;}="";
public string ArtistDisambiguation {get;set;}="";
public string ArtistName {get;set;}="";
}
+19
View File
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class ArtistQuery {
[JsonPropertyName("artists")]
public List<ArtistQueryItem> Artists {get;set;}=new List<ArtistQueryItem>();
public class ArtistQueryItem
{
[JsonPropertyName("id")]
public Guid Id {get;set;}
[JsonPropertyName("name")]
public string Name {get;set;}="";
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class AudioDBArtistJsonResponse {
[JsonPropertyName("artists")]
public List<AudioDBArtistJsonResponseArtist> Artists {get;set;}=new List<AudioDBArtistJsonResponseArtist>();
public class AudioDBArtistJsonResponseArtist
{
[JsonPropertyName("strArtistThumb")]
public string Thumbnail {get;set;}="";
}
}
+83
View File
@@ -0,0 +1,83 @@
using System.Diagnostics;
using System.Text.Json.Serialization;
using Tesses.WebServer;
using YoutubeExplode.Videos;
namespace Tesses.YouTubeDownloader2025Companion;
public class MediaEntry {
public string AlbumArtist {get;set;}="";
public string AlbumName {get;set;}="";
public string SongName {get;set;} = "";
public string AlbumId {get;set;}="";
public List<string> ContributingArtists {get;set;}=new List<string>();
public string ContributingArtistsString => string.Join("; ", ContributingArtists.Select(e=>e.Replace(";","")));
public int TrackIndex {get;set;}
public override string ToString()
{
var trackIndex = TrackIndex.ToString("D2");
return Path.Combine("data","Music",ServerContext.FixFileName(AlbumArtist),ServerContext.FixFileName(AlbumName),$"{trackIndex} {ServerContext.FixFileName(SongName)}.mp3");
}
public async Task<bool> ImportAsync(string srcPath)
{
string dest = ToString();
if(File.Exists(dest)) return true;
using Process p = new Process();
p.StartInfo.FileName = "ffmpeg";
p.StartInfo.UseShellExecute = false;
p.StartInfo.CreateNoWindow=true;
p.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
p.StartInfo.ArgumentList.Add("-y");
p.StartInfo.ArgumentList.Add("-i");
p.StartInfo.ArgumentList.Add(srcPath);
p.StartInfo.ArgumentList.Add("-i");
p.StartInfo.ArgumentList.Add($"data/Thumbnails/Albums/{AlbumId}.jpg");
p.StartInfo.ArgumentList.Add("-metadata");
p.StartInfo.ArgumentList.Add($"artist={ContributingArtistsString}");
p.StartInfo.ArgumentList.Add("-metadata");
p.StartInfo.ArgumentList.Add($"album={AlbumName}");
p.StartInfo.ArgumentList.Add("-metadata");
p.StartInfo.ArgumentList.Add($"album_artist={AlbumArtist}");
p.StartInfo.ArgumentList.Add("-metadata");
p.StartInfo.ArgumentList.Add($"title={SongName}");
p.StartInfo.ArgumentList.Add("-metadata");
p.StartInfo.ArgumentList.Add($"track={TrackIndex}");
p.StartInfo.ArgumentList.Add("-c:v");
p.StartInfo.ArgumentList.Add("copy");
p.StartInfo.ArgumentList.Add("-map");
p.StartInfo.ArgumentList.Add("0:a");
p.StartInfo.ArgumentList.Add("-map");
p.StartInfo.ArgumentList.Add("1:v");
p.StartInfo.ArgumentList.Add("-id3v2_version");
p.StartInfo.ArgumentList.Add("3");
p.StartInfo.ArgumentList.Add("-metadata:s:v");
p.StartInfo.ArgumentList.Add("title=Album cover");
p.StartInfo.ArgumentList.Add("-metadata:s:v");
p.StartInfo.ArgumentList.Add("comment=Cover (Front)");
p.StartInfo.ArgumentList.Add("data/Temp/conv.mp3");
if(p.Start())
{
await p.WaitForExitAsync();
if(p.ExitCode == 0)
{
string? dir=Path.GetDirectoryName(dest);
if(!string.IsNullOrWhiteSpace(dir))
Directory.CreateDirectory(dir);
File.Move("data/Temp/conv.mp3",dest);
return true;
}
}
return false;
}
}
+13
View File
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class Song {
public long Id {get;set;}=0;
public string SongId {get;set;}="";
public string SongName {get;set;}="";
}
+14
View File
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class SongVideo {
public long Id {get;set;}=0;
public string SongId {get;set;}="";
public string VideoId {get;set;}="";
public string VideoTitle {get;set;}="";
}
+45
View File
@@ -0,0 +1,45 @@
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
public enum LogEntryType {
VideoStarted,
VideoFinished,
Bell,
Started,
Unknown
}
public class LogEntry {
public long Time {get;set;}
[JsonIgnore]
public DateTimeOffset DateTime => DateTimeOffset.FromUnixTimeSeconds(Time);
public string Type {get;set;}="";
[JsonIgnore]
public LogEntryType EventType {
get {
if(Enum.TryParse<LogEntryType>(Type,out var type))
{
return type;
}
return LogEntryType.Unknown;
}
set => Type = value.ToString();
}
public string? VideoTitle {get;set;}
public string? VideoId {get;set;}
public string? ChannelTitle {get;set;}
public string? ChannelId {get;set;}
[JsonIgnore]
[MemberNotNullWhen(true,nameof(VideoTitle),nameof(VideoId),nameof(ChannelTitle),nameof(ChannelId))]
public bool HasStrings => !string.IsNullOrEmpty(VideoTitle) && !string.IsNullOrEmpty(VideoId) && !string.IsNullOrEmpty(ChannelTitle) && !string.IsNullOrEmpty(ChannelId);
}
+21
View File
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Tesses.YouTubeDownloader2025Companion;
public class Webhook {
public string Type {get;set;}="";
public WebhookVideo Video {get;set;}=new WebhookVideo();
public class WebhookVideo
{
public string Channel {get;set;}="";
public string ChannelId {get;set;}="";
public string Title {get;set;}="";
public string VideoId {get;set;}="";
}
}
+62
View File
@@ -0,0 +1,62 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
using YoutubeExplode.Search;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using Newtonsoft.Json;
namespace Tesses.YouTubeDownloader2025Companion;
public static partial class Pages {
private static ReloadingScript log_page = new ReloadingScript("wwwroot/pages/log.sbnhtml");
public static async Task<string> LogAsync(ServerContext ctx)
{
var cfg = Program.Config ?? new AuthConfig();
int page=1;
if(!ctx.QueryParams.TryGetFirstInt32("page",out page))
page = 1;
List<object> entries=new List<object>();
using(var db = new Database())
{
foreach(var vid in await db.GetLogEntriesAsync((page-1) * 20))
{
var time = vid.DateTime.DateTime;
if(vid.HasStrings)
{
entries.Add(new{
Time = $"{time.ToShortDateString()} {time.ToShortTimeString()}",
vid.Type,
Videotitle = HttpUtility.HtmlEncode(vid.VideoTitle),
Videourl = HttpUtility.HtmlAttributeEncode($"{cfg.TYTDUrl.TrimEnd('/')}/watch?v={HttpUtility.UrlEncode(vid.VideoId)}"),
Channeltitle = HttpUtility.HtmlEncode(vid.ChannelTitle),
Channelurl = HttpUtility.HtmlAttributeEncode($"{cfg.TYTDUrl.TrimEnd('/')}/channel?id={HttpUtility.UrlEncode(vid.ChannelId)}"),
});
}
else {
entries.Add(new {
Time = $"{time.ToShortDateString()} {time.ToShortTimeString()}",
vid.Type
});
}
}
}
return Components.Shell("Logs",log_page.Render(new{
Entries=entries,
Prev = HttpUtility.HtmlAttributeEncode($"/log?page={(page == 1 ? 1 : page - 1)}"),
Current = page,
Next = HttpUtility.HtmlAttributeEncode($"/log?page={page + 1}")
}),Program.DefaultLinks());
}
}
+30
View File
@@ -0,0 +1,30 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
//using YouTubeMusicAPI.Models.Info;
//using Acornima.Ast;
//using YouTubeMusicAPI.Models;
//using YouTubeMusicAPI.Models.Search;
using Newtonsoft.Json;
namespace Tesses.YouTubeDownloader2025Companion
{
public class AlbumSongLink(int? songNumber,string name,string albumId, string songId, TimeSpan dur) {
public string Songnumber => songNumber.HasValue ? songNumber.Value.ToString() : "-";
public string Duration => dur.ToString("m\\:ss");
public string Songname => HttpUtility.HtmlEncode(name);
public string Songurl => HttpUtility.HtmlAttributeEncode($"/music/song?albumId={HttpUtility.UrlEncode(albumId)}&songId={HttpUtility.UrlEncode(songId)}");
}
public static partial class Pages {
private static ReloadingScript album_info = new ReloadingScript("wwwroot/pages/music/albums/album-info.sbnhtml");
public static string AlbumInfo(string name, string id, List<AlbumSongLink> songLinks)
{
return Components.Shell($"Album info", album_info.Render(new {
Name = HttpUtility.HtmlEncode(name),
Songs = songLinks,
Addvals = HttpUtility.HtmlAttributeEncode(JsonConvert.SerializeObject(new{id}))
}),Program.DefaultLinks());
}
}
}
+27
View File
@@ -0,0 +1,27 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
namespace Tesses.YouTubeDownloader2025Companion
{
public class AlbumLink(string name, string id, string thumbnail) {
public string Albumname => HttpUtility.HtmlEncode(name);
public string Albumurl => HttpUtility.HtmlAttributeEncode($"/music/album?id={HttpUtility.UrlEncode(id)}");
public string Imgurl => HttpUtility.HtmlAttributeEncode(thumbnail);
}
public static partial class Pages {
private static ReloadingScript album_listing = new ReloadingScript("wwwroot/pages/music/albums/albums.sbnhtml");
public static string AlbumListing(string query, List<AlbumLink> links,int page)
{
return Components.Shell($"Album search {query}", album_listing.Render(new {
Albums = links,
Q = HttpUtility.HtmlAttributeEncode(query),
Prev = HttpUtility.HtmlAttributeEncode($"/music/albums?q={HttpUtility.UrlEncode(query)}&page={(page == 1 ? 1 : page - 1)}"),
Current = page,
Next = HttpUtility.HtmlAttributeEncode($"/music/albums?q={HttpUtility.UrlEncode(query)}&page={page + 1}")
}),Program.DefaultLinks());
}
}
}
+25
View File
@@ -0,0 +1,25 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
//using YouTubeMusicAPI.Models.Info;
namespace Tesses.YouTubeDownloader2025Companion
{
public class ArtistAlbumLink(string name, string id, string thumbnail) {
public string Albumname => HttpUtility.HtmlEncode(name);
public string Albumurl => HttpUtility.HtmlAttributeEncode($"/music/album?id={HttpUtility.UrlEncode(id)}");
public string Imgurl => HttpUtility.HtmlAttributeEncode(thumbnail);
}
public static partial class Pages {
private static ReloadingScript artist_album_listing = new ReloadingScript("wwwroot/pages/music/artists/artist-album-list.sbnhtml");
public static string ArtistAlbumListing(Guid id, List<AlbumLink> albums, int page)
{
return Components.Shell("Albums", artist_album_listing.Render(new {
Albums = albums,
Prev = HttpUtility.HtmlAttributeEncode($"/music/artist?id={HttpUtility.UrlEncode(id.ToString())}&page={(page == 1 ? 1 : page - 1)}"),
Current = page,
Next = HttpUtility.HtmlAttributeEncode($"/music/artist?id={HttpUtility.UrlEncode(id.ToString())}&page={page + 1}")
}),Program.DefaultLinks());
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
namespace Tesses.YouTubeDownloader2025Companion
{
public class ArtistLink(string name, string id, string thumbnail) {
public string Artistname => HttpUtility.HtmlEncode(name);
public string Artisturl => HttpUtility.HtmlAttributeEncode($"/music/artist?id={HttpUtility.UrlEncode(id)}");
public string Imgurl => HttpUtility.HtmlAttributeEncode(thumbnail);
}
public static partial class Pages {
private static ReloadingScript artist_listing = new ReloadingScript("wwwroot/pages/music/artists/artists.sbnhtml");
public static string ArtistListing(string query, List<ArtistLink> links, int page)
{
return Components.Shell($"Artist search {query}", artist_listing.Render(new {
Artists = links,
Q = HttpUtility.HtmlAttributeEncode(query),
Prev = HttpUtility.HtmlAttributeEncode($"/music/artists?q={HttpUtility.UrlEncode(query)}&page={(page == 1 ? 1 : page - 1)}"),
Current = page,
Next = HttpUtility.HtmlAttributeEncode($"/music/artists?q={HttpUtility.UrlEncode(query)}&page={page + 1}")
}),Program.DefaultLinks());
}
}
}
+37
View File
@@ -0,0 +1,37 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
//using YouTubeMusicAPI.Models.Info;
//using Acornima.Ast;
//using YouTubeMusicAPI.Models;
//using YouTubeMusicAPI.Models.Search;
using Newtonsoft.Json;
namespace Tesses.YouTubeDownloader2025Companion
{
public class MyAlbumSongLink(int? songNumber,string name, string albumId, string songId, TimeSpan dur, string btn) {
public string Songnumber => songNumber.HasValue ? songNumber.Value.ToString() : "-";
public string Duration => dur.ToString(
"m\\:ss"
);
public string Songname => HttpUtility.HtmlEncode(name);
public string Songurl => HttpUtility.HtmlAttributeEncode($"/music/mysong?albumId={HttpUtility.UrlEncode(albumId)}&songId={HttpUtility.UrlEncode(songId)}");
public string Btn => btn;
}
public static partial class Pages {
private static ReloadingScript myalbum_info = new ReloadingScript("wwwroot/pages/music/myalbums/album-info.sbnhtml");
public static string MyAlbumInfo(string name, string id, List<MyAlbumSongLink> songLinks, bool hasAny, string artists)
{
return Components.Shell($"Album info", myalbum_info.Render(new {
Name = HttpUtility.HtmlEncode(name),
Songs = songLinks,
Addvals = HttpUtility.HtmlAttributeEncode(JsonConvert.SerializeObject(new{id})),
Hasanysongs = hasAny,
Zipurl = HttpUtility.HtmlAttributeEncode($"/api/v1/album.zip?albumId={HttpUtility.UrlEncode(id)}"),
Artists = artists
}),Program.DefaultLinks());
}
}
}
+23
View File
@@ -0,0 +1,23 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
namespace Tesses.YouTubeDownloader2025Companion
{
public static partial class Pages {
private static ReloadingScript myalbum_listing = new ReloadingScript("wwwroot/pages/music/myalbums/albums.sbnhtml");
public static string MyAlbumListing(string query, List<MyAlbumLink> links,int page)
{
return Components.Shell($"Album search {query}", myalbum_listing.Render(new {
Albums = links,
Q = HttpUtility.HtmlAttributeEncode(query),
Prev = HttpUtility.HtmlAttributeEncode($"/music/myalbums?q={HttpUtility.UrlEncode(query)}&page={(page == 1 ? 1 : page - 1)}"),
Current = page,
Next = HttpUtility.HtmlAttributeEncode($"/music/myalbums?q={HttpUtility.UrlEncode(query)}&page={page + 1}")
}),Program.DefaultLinks());
}
}
}
@@ -0,0 +1,27 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
//using YouTubeMusicAPI.Models.Info;
namespace Tesses.YouTubeDownloader2025Companion
{
public class MyAlbumLink(string name, string id, string thumbnail) {
public string Albumname => HttpUtility.HtmlEncode(name);
public string Albumurl => HttpUtility.HtmlAttributeEncode($"/music/myalbum?id={HttpUtility.UrlEncode(id)}");
public string Imgurl => HttpUtility.HtmlAttributeEncode(thumbnail);
}
public static partial class Pages {
private static ReloadingScript myartist_album_listing = new ReloadingScript("wwwroot/pages/music/myartists/artist-album-list.sbnhtml");
public static string MyArtistAlbumListing(Guid id,string query, List<MyAlbumLink> albums, int page)
{
return Components.Shell("Albums", myartist_album_listing.Render(new {
Searchurl = $"/music/myartist?id={id.ToString()}",
Q = HttpUtility.HtmlAttributeEncode(query),
Albums = albums,
Prev = HttpUtility.HtmlAttributeEncode($"/music/myartist?q={HttpUtility.UrlEncode(query)}&id={HttpUtility.UrlEncode(id.ToString())}&page={(page == 1 ? 1 : page - 1)}"),
Current = page,
Next = HttpUtility.HtmlAttributeEncode($"/music/myartist?q={HttpUtility.UrlEncode(query)}&id={HttpUtility.UrlEncode(id.ToString())}&page={page + 1}")
}),Program.DefaultLinks());
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
namespace Tesses.YouTubeDownloader2025Companion
{
public class MyArtistLink(string name, string id, string thumbnail) {
public string Artistname => HttpUtility.HtmlEncode(name);
public string Artisturl => HttpUtility.HtmlAttributeEncode($"/music/myartist?id={HttpUtility.UrlEncode(id)}");
public string Imgurl => HttpUtility.HtmlAttributeEncode(thumbnail);
}
public static partial class Pages {
private static ReloadingScript myartist_listing = new ReloadingScript("wwwroot/pages/music/myartists/artists.sbnhtml");
public static string MyArtistListing(string query, List<MyArtistLink> links, int page)
{
return Components.Shell($"Artist search {query}", myartist_listing.Render(new {
Artists = links,
Q = HttpUtility.HtmlAttributeEncode(query),
Prev = HttpUtility.HtmlAttributeEncode($"/music/myartists?q={HttpUtility.UrlEncode(query)}&page={(page == 1 ? 1 : page - 1)}"),
Current = page,
Next = HttpUtility.HtmlAttributeEncode($"/music/myartists?q={HttpUtility.UrlEncode(query)}&page={page + 1}")
}),Program.DefaultLinks());
}
}
}
+55
View File
@@ -0,0 +1,55 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
using System.Text;
namespace Tesses.YouTubeDownloader2025Companion
{
public static partial class Pages {
private static ReloadingScript mysong = new ReloadingScript("wwwroot/pages/music/mysongs/song.sbnhtml");
private static ReloadingScript isnt_correct = new ReloadingScript("wwwroot/components/music/mysongs/isntcorrect.sbnhtml");
public static string IsntCorrect(string songId, string albumId, string query)
{
return isnt_correct.Render(new {
Cancelurl = HttpUtility.HtmlAttributeEncode($"/music/mysong?songId={HttpUtility.UrlEncode(songId)}&albumId={HttpUtility.UrlEncode(albumId)}"),
Albumid = HttpUtility.HtmlAttributeEncode(albumId),
Songid = HttpUtility.HtmlAttributeEncode(songId),
Searchurl = query == "" ? "" : HttpUtility.HtmlAttributeEncode($"/search_dialog?q={HttpUtility.UrlEncode(query)}")
});
}
public static string MySong(Song song, Album album, SongVideo video, string tytdEndpoint,bool downloaded, int trackIndex, TimeSpan ts, List<Artist> contributingArtists)
{
StringBuilder builder=new StringBuilder();
for(int i = 0; i < contributingArtists.Count; i++)
{
string link = $"<a hx-target=\"body\" hx-push-url=\"true\" hx-get=\"{HttpUtility.HtmlAttributeEncode($"/music/myartist?id={HttpUtility.UrlEncode(contributingArtists[i].ArtistId)}")}\" href=\"{HttpUtility.HtmlAttributeEncode($"/music/myartist?id={HttpUtility.UrlEncode(contributingArtists[i].ArtistId)}")}\">{HttpUtility.HtmlEncode(contributingArtists[i].ArtistName)}</a>";
if(i == 0)
builder.Append(link);
else if(i == 1)
builder.Append($" feat {link}");
else
builder.Append($", {link}");
}
return Components.Shell("", mysong.Render(new {
Songname = HttpUtility.HtmlEncode(song.SongName),
Albumurl = HttpUtility.HtmlAttributeEncode($"/music/myalbum?id={HttpUtility.UrlEncode(album.AlbumId)}"),
Albumname = HttpUtility.HtmlEncode(album.AlbumName),
Trackindex = trackIndex.ToString("D2"),
Duration = ts.ToString("m\\:ss"),
Albumart = HttpUtility.HtmlAttributeEncode($"/api/v1/album-thumbnail?id={HttpUtility.UrlEncode(album.AlbumId)}"),
Artists = builder.ToString(),
Videothumbnail = HttpUtility.HtmlAttributeEncode($"/api/v1/video-thumbnail?v={HttpUtility.UrlEncode(video.VideoId)}"),
Videotitle = HttpUtility.HtmlEncode($"{video.VideoTitle}-{video.VideoId}"),
Incorrectsongurl = HttpUtility.HtmlAttributeEncode($"/music/fix-song?songId={HttpUtility.UrlEncode(song.SongId)}&albumId={HttpUtility.UrlEncode(album.AlbumId)}"),
Tytdurl = HttpUtility.HtmlAttributeEncode($"{tytdEndpoint.TrimEnd('/')}/watch?v={HttpUtility.UrlEncode(video.VideoId)}"),
Dlbtn = downloaded ? $"<a class=\"button\" href=\"/api/v1/song?albumId={HttpUtility.UrlEncode(album.AlbumId)}&songId={HttpUtility.UrlEncode(song.SongId)}\"><i>download</i> Download</a>" : string.IsNullOrEmpty(video.VideoId) ? "<span><i>file_download_off</i> Unavailable</span>" : "<span><i>downloading</i> Downloading</span>",
Hasvideo = !string.IsNullOrEmpty(video.VideoId)
}),Program.DefaultLinks());
}
}
}
+28
View File
@@ -0,0 +1,28 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
namespace Tesses.YouTubeDownloader2025Companion
{
public class MySongLink(string name, string id, string btn) {
public string Name => HttpUtility.HtmlEncode(name);
public string Songname => HttpUtility.HtmlEncode(name);
public string Songurl => HttpUtility.HtmlAttributeEncode($"/music/mysong?songId={HttpUtility.UrlEncode(id)}");
public string Btn => btn;
}
public static partial class Pages {
private static ReloadingScript mysongs_listing = new ReloadingScript("wwwroot/pages/music/mysongs/songs.sbnhtml");
public static string MySongListing(string query, List<MySongLink> links, int page)
{
return Components.Shell($"Artist search {query}", mysongs_listing.Render(new {
Songs = links,
Q = HttpUtility.HtmlAttributeEncode(query),
Prev = HttpUtility.HtmlAttributeEncode($"/music/mysongs?q={HttpUtility.UrlEncode(query)}&page={(page == 1 ? 1 : page - 1)}"),
Current = page,
Next = HttpUtility.HtmlAttributeEncode($"/music/mysongs?q={HttpUtility.UrlEncode(query)}&page={page + 1}")
}),Program.DefaultLinks());
}
}
}
+149
View File
@@ -0,0 +1,149 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
using YoutubeExplode.Search;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using Newtonsoft.Json;
namespace Tesses.YouTubeDownloader2025Companion;
public class SearchResultHandler : IEventable {
private class SearchResultObj(IAsyncEnumerator<YoutubeExplode.Common.Batch<ISearchResult>> e,bool isDialog) {
public long expires = Program.NowEpoch + (3600*6);
public IAsyncEnumerator<YoutubeExplode.Common.Batch<ISearchResult>> enumerator = e;
public bool isDialog = isDialog;
}
private ConcurrentDictionary<string,SearchResultObj> ents=new ConcurrentDictionary<string, SearchResultObj>();
public SearchResultHandler()
{
}
private class SearchResult(string tytdUrl, string title, string id)
{
public string Url => HttpUtility.HtmlAttributeEncode($"{tytdUrl.TrimEnd('/')}/watch?v={id}");
public string Imgurl => HttpUtility.HtmlAttributeEncode($"/api/v1/video-thumbnail?v={id}");
public string Title => HttpUtility.HtmlEncode(title);
public string Dialogok => HttpUtility.HtmlAttributeEncode($"searchDialogSubmit('{HttpUtility.JavaScriptStringEncode(id)}')");
public string Addbtnjson => HttpUtility.HtmlAttributeEncode(JsonConvert.SerializeObject(new{type="add",value=id}));
}
public async Task<string> SearchAsync(string query,bool isDialog)
{
var obj = Program.YouTubeClient.Search.GetResultBatchesAsync(query, YoutubeExplode.Search.SearchFilter.Video);
SearchResultObj ro = new SearchResultObj(obj.GetAsyncEnumerator(),isDialog);
string id = RandomNumberGenerator.GetHexString(32);
while(!ents.TryAdd(id,ro))
{
id = RandomNumberGenerator.GetHexString(32);
}
return await MoreAsync(id,ro);
}
private static ReloadingScript results_component = new ReloadingScript("wwwroot/components/search-results.sbnhtml");
private static ReloadingScript results_dialog_component = new ReloadingScript("wwwroot/components/search-results-dialog.sbnhtml");
private async Task<string> MoreAsync(string id,SearchResultObj o)
{
if(o.expires <= Program.NowEpoch)
{
return """<div class="row"><div class="max"></div><div class="min"><h1>The search has expired</h1></div><div class="max"></div></div>""";
}
if(await o.enumerator.MoveNextAsync())
{
var tytd = Program.Config ?? new AuthConfig();
List<SearchResult> results = new List<SearchResult>();
foreach(var ent in o.enumerator.Current.Items)
if(ent is VideoSearchResult item)
results.Add(new SearchResult(tytd.TYTDUrl, item.Title, item.Id));
return o.isDialog ? results_dialog_component.Render(new{results, moreurl = HttpUtility.HtmlAttributeEncode($"/search_more?id={id}")}) : results_component.Render(new {
results, moreurl = HttpUtility.HtmlAttributeEncode($"/search_more?id={id}")
});
}
return """<div class="row"><div class="max"></div><div class="min"><h1>There are no more results</h1></div><div class="max"></div></div>""";
}
public async Task<string> MoreAsync(string id)
{
if(ents.TryGetValue(id, out var v))
{
return await MoreAsync(id,v);
}
return """<div class="row"><div class="max"></div><div class="min"><h1>There are no more results</h1></div><div class="max"></div></div>""";
}
public async Task EventAsync()
{
try {
List<string> expired = new List<string>();
var time = Program.NowEpoch;
foreach(var item in ents)
{
if(item.Value.expires <= time)
expired.Add(item.Key);
}
foreach(var expire in expired)
ents.Remove(expire,out var e);
}catch{
}
await Task.CompletedTask;
}
}
public static partial class Components {
public static async Task<string> SearchMoreAsync(ServerContext ctx)
{
if(ctx.QueryParams.TryGetFirst("id",out var id))
{
return await Program.SearchResultHandler.MoreAsync(id);
}
return """<div class="row"><div class="max"></div><div class="min"><h1>No continuation id provided</h1></div><div class="max"></div></div>""";
}
}
public static partial class Pages {
private static ReloadingScript search_page = new ReloadingScript("wwwroot/pages/search.sbnhtml");
public static async Task<string> SearchAsync(ServerContext ctx)
{
string q="";
string body = "";
if(ctx.QueryParams.TryGetFirst("q",out q))
{
body = await Program.SearchResultHandler.SearchAsync(q,false);
}
return Components.Shell($"Search {q}",search_page.Render(new {body,q = HttpUtility.HtmlAttributeEncode(q)}),Program.DefaultLinks());
}
public static async Task<string> SearchDialogAsync(ServerContext ctx)
{
string q="";
string body = "";
if(ctx.QueryParams.TryGetFirst("q",out q))
{
body = await Program.SearchResultHandler.SearchAsync(q,true);
}
return body;
}
}
+22
View File
@@ -0,0 +1,22 @@
using Tesses.WebServer;
using Scriban;
using System.Web;
using YoutubeExplode.Search;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using Newtonsoft.Json;
namespace Tesses.YouTubeDownloader2025Companion;
public static partial class Pages {
private static ReloadingScript settings_page = new ReloadingScript("wwwroot/pages/settings.sbnhtml");
public static string Settings(ServerContext ctx)
{
return Components.Shell("Settings",settings_page.Render(new{}),Program.DefaultLinks());
}
}
+1501
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
using Tesses.WebServer;
using Scriban;
namespace Tesses.YouTubeDownloader2025Companion
{
public class ReloadingScript(string path)
{
DateTime created=new DateTime(1970,1,1);
Template? template;
private string path = path;
public string Render(object val)
{
Template? template;
lock(this)
{
template = this.template;
var cd = File.GetLastWriteTime(path);
if(cd > created || this.template is null)
{
template = Template.Parse(File.ReadAllText(path));
this.template = template;
created = cd;
}
}
if(template is not null)
{
return template.Render(val);
}
throw new FileNotFoundException("Not found");
}
}
}
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Scriban" Version="6.5.3" />
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
<PackageReference Include="Tesses.WebServer" Version="1.0.4.3" />
<PackageReference Include="Tesses.WebServer.EasyServer" Version="1.0.1" />
<PackageReference Include="YoutubeExplode" Version="6.5.7" />
<PackageReference Include="YouTubeMusicAPI" Version="3.0.6" />
</ItemGroup>
</Project>
+30
View File
@@ -0,0 +1,30 @@
using Tesses.WebServer;
using Scriban;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
namespace Tesses.YouTubeDownloader2025Companion
{
public class TYTDLoginResponse {
[JsonPropertyName("token")]
public string Token {get;set;}="";
[JsonPropertyName("success")]
public bool Success {get;set;}
}
public class SSOResp
{
[JsonProperty("success")]
public bool Success {get;set;}
[JsonProperty("type")]
public string Type {get;set;}="";
[JsonProperty("reason")]
public string Reason {get;set;}="";
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace Tesses.YouTubeDownloader2025Companion;
public enum TYTDResolution {
NoDownload,
LowVideo,
VideoOnly,
AudioOnly,
MP3,
FLAC,
MP4,
MKV,
DontConvert
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https:\/\/crosslang.tesseslanguage.com\/\/schema\/cross-json-schema.json",
"dependencies": [
],
"icon": "icon.png",
"info": {
"license": "GPLv3-or-later",
"plugin_host": "tytd2025",
"short_name": "tytdcompanion",
"short_name_pretty": "TYTD Companion",
"type": "lib"
},
"name": "Tesses.YouTubeDownloader.Plugins.TYTDCompanion",
"version": "1.0.0.0-dev"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
func PluginInit() new Plugin();
+97
View File
@@ -0,0 +1,97 @@
/^
%PROJECT_NAME Class
^/
class Plugin {
public Plugin()
{
/*
You can access these anywhere in the plugin
TYTD.GetVideoId(id): get the youtube video id from url or id
TYTD.GetPlaylistId(id): get the youtube playlist id from url or id
TYTD.GetChannelId(id): get the youtube channel id from url or id
TYTD.Config[key]: get a string setting for this plugin
TYTD.Config[key] = value: set a string setting for this plugin (is presistant)
TYTD.Config.Directory: a crosslang SubdirFilesystem for the Files directory in plugin directory
TYTD.Config.DirectoryPath: same directory but the actual path of it for FS.Local
Resolution, SubscriptionBell, TYTD.Downloader (the instance, you can't create your own instance of it): See https://cpkg.tesseslanguage.com/package_docs?name=Tesses.YoutubeDownloader&version=1.0.0.0-prod
*/
TYTD.Config.Directory.CreateDirectory(/"AlbumThumbnails");
TYTD.Config.Directory.CreateDirectory(/"ArtistThumbnails");
TYTD.Downloader.Bell += this.OnBell;
TYTD.Downloader.VideoStarted += this.OnVideoStarted;
TYTD.Downloader.VideoEnded += this.OnVideoFinished;
this.Server = new PluginServer(this);
this.SendStart();
}
private SendStart()
{
this.SendMessage({
Type = "Started"
});
}
private OnVideoStarted(sender, e)
{
this.SendMessage({
Type = "VideoStarted",
Video = e.Video
});
}
private OnVideoFinished(sender, e)
{
this.SendMessage({
Type = "VideoFinished",
Video = e.Video
});
}
private OnBell(sender, e)
{
this.SendMessage({
Type = "Bell",
Video = {
Channel = e.author,
ChannelId = e.channelId,
Title = e.title,
VideoId = e.videoId
}
});
}
private SendMessage(msg)
{
const conf = TYTD.Config["conf"];
const jo = Json.Decode(conf);
if(TypeIsString(jo.url))
{
const request = {
Method = "POST",
Body = new JsonHttpRequestBody(msg)
};
if(TypeIsString(jo.token))
{
request.RequestHeaders = [
{
Key = "Authorization",
Value = $"TYTD {jo.token}"
}
];
}
Net.Http.MakeRequest($"{jo.url.TrimEnd('/')}/api/v1/webhook", request).Close();
}
}
public Server;
public Close()
{
TYTD.Downloader.Bell -= this.OnBell;
TYTD.Downloader.VideoStarted -= this.OnVideoStarted;
TYTD.Downloader.VideoEnded -= this.OnVideoFinished;
}
}
+119
View File
@@ -0,0 +1,119 @@
class PluginServer
{
public Server;
public PluginServer(svr)
{
this.Server = svr;
}
public Handle(ctx)
{
if(ctx.Path == "/")
{
const conf = TYTD.Config["conf"];
if(TypeIsString(conf))
{
const jo = Json.Decode(conf);
if(TypeIsString(jo.url))
{
ctx.SendRedirect($"{jo.url.TrimEnd('/')}/",303);
return true;
}
}
ctx.WithMimeType("text/html").SendText(<h1>Not configured</h1>);
return true;
}
else if(ctx.Path == "/artist-thumbnail")
{
const id = ctx.QueryParams.TryGetFirst("id");
if(TypeIsString(id))
{
if(!TYTD.Config.Directory.FileExists(/"ArtistThumbnails"/$"{id}.jpg"))
{
var resp = Net.Http.MakeRequest($"https://www.theaudiodb.com/api/v1/json/123/artist-mb.php?i={Net.Http.UrlEncode(id)}",{FollowRedirects=true});
if(resp.StatusCode >= 200 && resp.StatusCode <= 299)
{
const url = resp.ReadAsJson().artists[0].strArtistThumb;
if(TypeIsString(url))
{
var resp2 = Net.Http.MakeRequest(url,{FollowRedirects=true});
if(resp.StatusCode >= 200 && resp.StatusCode <= 299)
{
var strm=TYTD.Config.Directory.OpenFile(/"ArtistThumbnails"/$"{id}.jpg","wb");
resp2.CopyToStream(strm);
strm.Close();
}
}
}
}
if(TYTD.Config.Directory.FileExists(/"ArtistThumbnails"/$"{id}.jpg"))
{
var strm=TYTD.Config.Directory.OpenFile(/"ArtistThumbnails"/$"{id}.jpg","rb");
ctx.WithMimeType("image/jpg").SendStream(strm);
strm.Close();
return true;
}
return false;
}
}
else if(ctx.Path == "/artist-thumbnail")
{
const id = ctx.QueryParams.TryGetFirst("id");
if(TypeIsString(id))
{
if(!TYTD.Config.Directory.FileExists(/"AlbumThumbnails"/$"{id}.jpg"))
{
var resp2 = Net.Http.MakeRequest($"https://coverartarchive.org/release/{Net.Http.UrlEncode(id)}/front",{FollowRedirects=true});
if(resp.StatusCode >= 200 && resp.StatusCode <= 299)
{
var strm=TYTD.Config.Directory.OpenFile(/"AlbumThumbnails"/$"{id}.jpg","wb");
resp2.CopyToStream(strm);
strm.Close();
}
}
if(TYTD.Config.Directory.FileExists(/"AlbumThumbnails"/$"{id}.jpg"))
{
var strm=TYTD.Config.Directory.OpenFile(/"AlbumThumbnails"/$"{id}.jpg","rb");
ctx.WithMimeType("image/jpg").SendStream(strm);
strm.Close();
return true;
}
return false;
}
}
else if(ctx.Path == "/config")
{
if(ctx.Method == "GET")
{
const conf = TYTD.Config["conf"];
if(TypeIsString(conf))
{
const jo = Json.Decode(conf);
if(TypeIsString(jo.url))
{
ctx.WithMimeType("application/json").SendJson({url = jo.url});
return true;
}
}
ctx.WithMimeType("application/json").SendText("null");
return true;
}
else if(ctx.Method == "PUT")
{
TYTD.Config["conf"]=Json.Encode(ctx.ReadJson());
ctx.StatusCode = 204;
ctx.WriteHeaders();
this.SendStart();
return true;
}
}
return true;
}
}
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
<form hx-post="/music/fix-song" hx-target="body">
<input type="hidden" name="albumId" value="{{albumid}}">
<input type="hidden" name="songId" value="{{songid}}">
<div class="row">
<div class="max">
<div class="field label border">
<input type="text" name="url" id='url'>
<label>Video Url</label>
</div>
</div>
<div class="min">
{{if !string.empty(searchurl) }}
<button hx-get="{{searchurl}}" hx-swap="innerHTML" hx-target="#search-dialog-results" type="button" hx-on::after-request="ui('#search-dialog')">Browse</button>
{{end}}
</div>
</div>
<nav>
<button>OK</button>
<button hx-get="{{cancelurl}}" hx-target="body">Cancel</button>
</nav>
</form>
@@ -0,0 +1,21 @@
{{for result in results}}
<div class="row">
<div class="min">
<img src="{{result.imgurl}}" alt="video thumbnail">
</div>
<div class="max">
<a hx-on:click="{{result.dialogok}}">{{result.title}}</a>
</div>
</div>
{{end}}
<div class="row" id="more-pane" hx-get="{{moreurl}}" hx-target="#more-pane" hx-swap="outerHTML">
<div class="max">
</div>
<div class="min">
<button hx-get="{{moreurl}}" hx-target="#more-pane" hx-swap="outerHTML">More</button>
</div>
<div class="max">
</div>
</div>
+22
View File
@@ -0,0 +1,22 @@
{{for result in results}}
<div class="row">
<div class="min">
<img src="{{result.imgurl}}" alt="video thumbnail">
</div>
<div class="max">
<a hx-target="body" hx-push-url="true" hx-get="{{result.url}}" href="{{result.url}}">{{result.title}}</a>
</div>
<div class="min">
<div>
<button class="circle transparent">
<i>more_vert</i>
</button>
<menu class="left no-wrap">
<li hx-post="/search_button" hx-vals="{{result.addbtnjson}}" hx-swap="outerHTML">Add</li>
</menu>
</div>
</div>
</div>
{{end}}
<div hx-trigger="revealed" id="more-pane" hx-get="{{moreurl}}" hx-target="#more-pane" hx-swap="outerHTML"></div>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html><html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TYTD Companion - {{title}}</title>
<link rel="stylesheet" href="/beer.min.css">
<link rel="stylesheet" href="/theme.css">
<script src="/htmx.min.js" defer></script>
<script type="module" src="/beer.min.js" defer></script>
</head>
<body hx-indicator="#loading-indicator">
<dialog class="left" id="leftpane">
<header>
<nav>
<img class="circle large" src="/tytd-128.png">
<h6 class="max">TYTD Companion</h6>
<button hx-on:click="ui('#leftpane')" class="transparent circle large">
<i>close</i>
</button>
</nav>
</header>
<div class="space"></div>
<ul class="list">
{{for item in pages}}
<li class="wave round" hx-target="body" hx-push-url="true" hx-get="{{item.url}}">
<i>{{item.icon}}</i>
<span class="max">{{item.title}}</span>
<b>{{item.badge}}</b>
</li>
{{end}}
</ul>
</dialog>
<header class="fixed primary-container">
<nav>
<button hx-on:click="ui('#leftpane')" class="circle transparent">
<i>menu</i>
</button>
<div class="max"></div>
<div>
<button class="circle transparent">
<i>more_vert</i>
</button>
<menu class="left no-wrap">
<li hx-target="body" hx-push-url="true" hx-get="/settings">Settings</li>
<li hx-target="body" hx-push-url="true" hx-get="/log">Log</li>
</menu>
</div>
</nav>
<div class="small-padding">
<h5>TYTD Companion</h5>
</div>
</header>
<main class="responsive">
{{body}}
</main>
<div class="htmx-indicator shape loading-indicator extra" id="loading-indicator">
<img class="responsive" src="/tytd.svg">
</div>
<div class="overlay blur"></div>
<dialog style="width: 80vw;" id="search-dialog">
<h5>Search</h5>
<div id="search-dialog-results">
</div>
<nav class="right-align no-space">
<button hx-on:click="ui('#search-dialog')" class="transparent link">Cancel</button>
</nav>
</dialog>
<script>
function searchDialogSubmit(id)
{
document.getElementById('url').value = `https://www.youtube.com/watch?v=${id}`;
ui('#search-dialog');
}
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+1
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
<div class="row">
<div class="min">
<button hx-post="/rescan">
<i>sync</i>
Rescan TYTD for Music
</button>
</div>
<div class="min">
<a class="button" href="@TYTD_URL@"><i>exit_to_app</i> TYTD2025 Downloader</a>
</div>
</div>
+19
View File
@@ -0,0 +1,19 @@
<svg width="100%" height="100%" viewBox="4 4 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="#D0BCFF">
<animate attributeName="d" dur="5s" repeatCount="indefinite" calcMode="spline" keySplines="0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8" keyTimes="0; 0.14; 0.14; 0.29; 0.29; 0.43; 0.43; 0.57; 0.57; 0.71; 0.71; 0.86; 0.86; 1" values="M20.9 10.4 21.4 9.5 21.9 8.7 22.5 7.8 23.2 7.2 24.2 7 25.1 7.4 25.7 8.1 26.2 9 26.8 9.8 27.3 10.6 28.1 11.2 29 11.3 30 11 30.9 10.6 31.8 10.3 32.8 9.9 33.7 10 34.5 10.6 34.9 11.5 34.8 12.5 34.8 13.5 34.7 14.5 34.7 15.5 35.2 16.3 36 16.8 37 17.1 37.9 17.3 38.9 17.5 39.8 17.9 40.4 18.7 40.5 19.7 40 20.5 39.4 21.3 38.7 22 38.1 22.8 37.6 23.7 37.7 24.6 38.3 25.5 38.9 26.2 39.6 27 40.2 27.7 40.5 28.7 40.3 29.6 39.5 30.3 38.6 30.6 37.6 30.8 36.7 31 35.7 31.3 35 31.9 34.6 32.8 34.7 33.8 34.8 34.8 34.9 35.8 34.8 36.8 34.3 37.6 33.4 38.1 32.4 38 31.5 37.6 30.6 37.2 29.7 36.9 28.7 36.6 27.8 36.9 27.1 37.6 26.6 38.5 26.1 39.3 25.5 40.2 24.8 40.8 23.8 41 22.9 40.6 22.3 39.9 21.8 39 21.2 38.2 20.7 37.4 19.9 36.8 19 36.7 18 37 17.1 37.4 16.2 37.7 15.2 38.1 14.3 38 13.5 37.4 13.1 36.5 13.2 35.5 13.2 34.5 13.3 33.5 13.3 32.5 12.8 31.7 12 31.2 11 31 10.1 30.7 9.1 30.5 8.2 30.1 7.6 29.3 7.5 28.3 8 27.5 8.7 26.7 9.3 26 9.9 25.2 10.4 24.3 10.3 23.4 9.7 22.5 9.1 21.8 8.4 21 7.8 20.3 7.5 19.3 7.7 18.4 8.5 17.7 9.4 17.4 10.4 17.2 11.3 17 12.3 16.7 13 16.1 13.4 15.2 13.3 14.2 13.2 13.2 13.1 12.2 13.2 11.2 13.7 10.4 14.6 9.9 15.6 10 16.5 10.4 17.4 10.8 18.3 11.1 19.3 11.4 20.2 11.1Z;
M20.3 8.6 21.1 8 22 7.6 23 7.3 23 7.3 24 7.2 25 7.3 25.9 7.5 26.8 8 27.6 8.6 28.4 9.1 28.4 9.1 29.3 9.6 30.3 9.8 31.3 9.9 32.3 10 33.3 10.2 34.2 10.6 34.2 10.6 35 11.2 35.7 11.9 36.3 12.7 36.7 13.6 36.9 14.6 37.2 15.5 37.2 15.5 37.6 16.5 38.2 17.3 38.9 18 39.6 18.7 40.2 19.5 40.6 20.4 40.6 20.4 40.9 21.3 41 22.3 40.9 23.3 40.6 24.3 40.2 25.2 39.8 26.1 39.8 26.1 39.5 27 39.4 28 39.5 29 39.6 30 39.5 31 39.3 32 39.3 32 38.9 32.9 38.3 33.7 37.6 34.4 36.8 35 35.9 35.4 35 35.8 35 35.8 34.1 36.3 33.4 37 32.9 37.9 32.3 38.7 31.6 39.4 30.8 40 30.8 40 29.9 40.4 28.9 40.7 27.9 40.8 27 40.7 26 40.4 25 40.1 25 40.1 24 40 23 40.1 22.1 40.4 21.1 40.7 20.1 40.8 19.1 40.7 19.1 40.7 18.2 40.5 17.3 40 16.4 39.5 15.7 38.8 15.2 37.9 14.6 37.1 14.6 37.1 13.9 36.4 13.1 35.8 12.2 35.4 11.3 35 10.5 34.4 9.7 33.8 9.7 33.8 9.1 32.9 8.7 32 8.5 31.1 8.4 30.1 8.5 29.1 8.6 28.1 8.6 28.1 8.5 27.1 8.3 26.1 7.8 25.2 7.4 24.3 7.1 23.4 7 22.4 7 22.4 7.1 21.4 7.3 20.4 7.8 19.5 8.3 18.7 9.1 18 9.8 17.3 9.8 17.3 10.4 16.5 10.8 15.6 11 14.6 11.3 13.7 11.7 12.8 12.2 11.9 12.2 11.9 12.9 11.2 13.8 10.7 14.7 10.2 15.6 10 16.6 9.9 17.6 9.8 17.6 9.8 18.6 9.6 19.5 9.2Z;
M18.6 9.6 19.5 9.2 20.3 8.6 21.1 8 22 7.6 23 7.3 24 7.2 25 7.3 25.9 7.5 26.8 8 27.6 8.6 28.4 9.1 29.3 9.6 30.3 9.8 31.3 9.9 32.3 10 33.3 10.2 34.2 10.6 35 11.2 35.7 11.9 36.3 12.7 36.7 13.6 36.9 14.6 37.2 15.5 37.6 16.4 38.2 17.3 38.9 18 39.6 18.7 40.2 19.5 40.6 20.4 40.9 21.3 41 22.3 40.9 23.3 40.6 24.3 40.2 25.2 39.8 26.1 39.5 27 39.4 28 39.5 29 39.6 30 39.5 31 39.3 32 38.9 32.9 38.3 33.7 37.6 34.4 36.8 35 35.9 35.4 35 35.8 34.1 36.3 33.4 37 32.9 37.9 32.3 38.7 31.6 39.4 30.8 40 29.9 40.4 28.9 40.7 27.9 40.8 27 40.7 26 40.4 25 40.1 24 40 23 40.1 22.1 40.4 21.1 40.7 20.1 40.8 19.1 40.7 18.2 40.5 17.3 40 16.4 39.5 15.7 38.8 15.2 37.9 14.6 37.1 13.9 36.4 13.1 35.8 12.2 35.4 11.3 35 10.5 34.4 9.7 33.8 9.1 32.9 8.7 32 8.5 31.1 8.4 30.1 8.5 29.1 8.6 28.1 8.5 27.1 8.3 26.1 7.8 25.2 7.4 24.3 7.1 23.4 7 22.4 7.1 21.4 7.3 20.4 7.8 19.5 8.3 18.7 9.1 18 9.8 17.3 10.4 16.5 10.8 15.6 11 14.6 11.3 13.7 11.7 12.8 12.2 11.9 12.9 11.2 13.8 10.7 14.7 10.2 15.6 10 16.6 9.9 17.6 9.8Z;
M18.6 9.9 19.5 9.4 20.3 8.8 21.1 8.2 22 7.8 23 7.6 23.9 7.5 24.9 7.6 25.9 7.8 26.8 8.2 27.6 8.7 28.5 9.3 29.3 9.9 30.1 10.5 30.9 11 31.7 11.6 32.5 12.2 33.3 12.8 33.7 13.1 34.1 13.3 34.9 13.9 35.7 14.5 36.6 15 37.4 15.6 38.2 16.2 39 16.8 39.7 17.5 40.2 18.3 40.7 19.2 40.9 20.1 41 21.1 40.9 22.1 40.7 23.1 40.3 24 40 24.9 39.7 25.9 39.4 26.8 39 27.8 38.7 28.7 38.4 29.6 38.1 30.6 37.8 31.5 37.5 32.5 37.2 33.4 36.9 34.4 36.6 35.3 36.2 36.2 35.7 37.1 35 37.8 34.3 38.4 33.4 38.9 32.5 39.3 31.5 39.5 30.5 39.5 30 39.5 29.5 39.5 28.5 39.5 27.5 39.5 26.5 39.5 25.5 39.4 24.5 39.4 23.6 39.4 22.6 39.4 21.6 39.5 20.6 39.5 19.6 39.5 18.6 39.5 17.6 39.5 16.6 39.5 15.6 39.3 14.7 39 13.8 38.5 13.1 37.9 12.4 37.2 11.9 36.3 11.5 35.4 11.2 34.5 10.9 33.5 10.6 32.6 10.3 31.6 10 30.7 9.7 29.7 9.3 28.8 9 27.9 8.7 26.9 8.4 26 8 25 7.7 24.1 7.4 23.2 7.1 22.2 7.1 21.7 7 21.2 7.1 20.2 7.3 19.3 7.7 18.4 8.3 17.5 8.9 16.8 9.7 16.2 10.5 15.6 11.4 15.1 12.2 14.5 13 14 13.8 13.4 14.6 12.8 15.4 12.3 16.2 11.7 17 11.1 17.8 10.5Z;
M15.4 12.3 16.2 11.7 17 11.1 17.8 10.5 18.6 9.9 19.5 9.4 20.3 8.8 21.1 8.3 22 7.8 23 7.6 23.9 7.5 24.9 7.6 25.9 7.8 26.8 8.2 27.6 8.7 28.5 9.3 29.3 9.9 30.1 10.5 30.9 11 31.7 11.6 32.5 12.2 33.3 12.8 34.1 13.3 34.9 13.9 35.7 14.5 36.6 15 37.4 15.6 38.2 16.2 39 16.8 39.7 17.5 40.2 18.3 40.7 19.2 40.9 20.1 41 21.1 40.9 22.1 40.7 23.1 40.3 24 40 24.9 39.7 25.9 39.4 26.8 39 27.8 38.7 28.7 38.4 29.6 38.1 30.6 37.8 31.5 37.5 32.5 37.2 33.4 36.9 34.4 36.6 35.3 36.2 36.2 35.7 37.1 35 37.8 34.3 38.4 33.4 38.9 32.5 39.3 31.5 39.4 30.5 39.5 29.5 39.5 28.5 39.5 27.5 39.5 26.5 39.5 25.5 39.4 24.5 39.4 23.6 39.4 22.6 39.4 21.6 39.5 20.6 39.5 19.6 39.5 18.6 39.5 17.6 39.5 16.6 39.5 15.6 39.3 14.7 39 13.8 38.5 13.1 37.9 12.4 37.2 11.9 36.3 11.5 35.4 11.2 34.5 10.9 33.5 10.6 32.6 10.3 31.6 10 30.7 9.7 29.7 9.3 28.8 9 27.9 8.7 26.9 8.4 26 8 25 7.7 24.1 7.4 23.2 7.1 22.2 7 21.2 7.1 20.2 7.3 19.3 7.7 18.4 8.3 17.5 8.9 16.8 9.7 16.2 10.5 15.6 11.4 15.1 12.2 14.5 13 14 13.8 13.4 14.6 12.8Z;
M17 12.8 17.7 12.1 18.5 11.5 19.3 10.9 20.1 10.5 20.2 10.4 21.1 10 22 9.7 23 9.4 24 9.2 25 9 26 9 27 9 27.6 9.1 28 9.1 28.9 9.3 29.9 9.6 30.9 9.9 31.8 10.3 32.6 10.8 33.5 11.3 34.3 11.9 34.6 12.2 35 12.6 35.7 13.3 36.4 14.1 36.9 14.9 37.4 15.8 37.9 16.6 38.3 17.6 38.6 18.5 38.6 18.7 38.8 19.5 38.9 20.5 39 21.5 39 22.5 38.9 23.5 38.7 24.5 38.5 25.4 38.2 26.3 38.2 26.4 37.8 27.3 37.4 28.2 36.8 29.1 36.2 29.9 35.6 30.6 34.9 31.3 34.2 32 33.8 32.5 33.5 32.8 32.8 33.5 32.1 34.2 31.4 34.9 30.7 35.6 29.9 36.2 29.1 36.8 28.2 37.3 27.9 37.5 27.4 37.8 26.4 38.2 25.5 38.5 24.5 38.7 23.5 38.9 22.5 39 21.5 39 20.5 38.9 20.4 38.9 19.5 38.8 18.6 38.6 17.6 38.3 16.7 37.9 15.8 37.5 14.9 37 14.1 36.4 13.4 35.8 13.3 35.8 12.6 35.1 12 34.3 11.3 33.5 10.8 32.7 10.3 31.8 9.9 30.9 9.6 30 9.4 29.3 9.3 29 9.1 28 9 27 9 26 9 25 9.2 24 9.4 23 9.6 22.1 9.8 21.7 10 21.1 10.4 20.2 10.9 19.4 11.4 18.5 12.1 17.8 12.7 17 13.4 16.3 14.2 15.6 14.2 15.5 14.9 14.9 15.6 14.2 16.3 13.5Z;
M33.5 11.3 34.3 11.9 35 12.6 35.3 12.8 35.7 13.3 36.4 14.1 36.9 14.9 37.4 15.8 37.9 16.6 38.3 17.6 38.3 17.7 38.6 18.5 38.8 19.5 38.9 20.5 39 21.5 39 22.5 38.9 23.5 38.9 23.5 38.7 24.5 38.5 25.4 38.2 26.4 37.8 27.3 37.4 28.2 36.9 28.9 36.8 29.1 36.2 29.9 35.6 30.6 34.9 31.3 34.2 32 33.5 32.8 33.1 33.2 32.8 33.5 32.1 34.2 31.4 34.9 30.7 35.6 29.9 36.2 29.1 36.8 28.7 37 28.2 37.3 27.4 37.8 26.4 38.2 25.5 38.5 24.5 38.7 23.5 38.9 23.3 38.9 22.5 39 21.5 39 20.5 38.9 19.5 38.8 18.6 38.6 17.6 38.3 17.6 38.3 16.7 37.9 15.8 37.5 14.9 37 14.1 36.4 13.3 35.8 12.7 35.2 12.6 35.1 12 34.3 11.3 33.5 10.8 32.7 10.3 31.8 9.9 30.9 9.7 30.3 9.6 29.9 9.3 29 9.1 28 9 27 9 26 9 25 9.1 24.5 9.2 24 9.4 23 9.6 22.1 10 21.1 10.4 20.2 10.9 19.4 11.1 19.1 11.5 18.5 12.1 17.7 12.7 17 13.5 16.3 14.2 15.6 14.9 14.9 14.9 14.8 15.6 14.2 16.3 13.5 17 12.8 17.7 12.1 18.5 11.5 19.3 11 19.3 10.9 20.2 10.4 21.1 10 22 9.7 23 9.4 24 9.2 24.7 9.1 25 9 26 9 27 9 28 9.1 28.9 9.3 29.9 9.6 30.4 9.7 30.9 9.9 31.8 10.3 32.6 10.8Z;
M33.2 11.1 34.2 11.2 35.1 11.4 36 11.9 36.6 12.7 36.8 13.7 36.9 14.7 36.9 15.7 37 16.7 37.1 17.7 37.3 18.6 37.9 19.4 38.5 20.2 39.2 20.9 39.8 21.7 40.5 22.4 40.9 23.3 41 24.3 40.7 25.2 40.1 26 39.4 26.8 38.8 27.5 38.1 28.3 37.5 29.1 37.1 30 37 31 37 31.9 36.9 32.9 36.8 33.9 36.7 34.9 36.2 35.8 35.5 36.4 34.5 36.8 33.6 36.8 32.6 36.9 31.6 37 30.6 37.1 29.6 37.2 28.8 37.7 28 38.4 27.3 39 26.5 39.7 25.8 40.3 24.9 40.8 23.9 41 23 40.8 22.1 40.2 21.4 39.6 20.6 38.9 19.9 38.3 19.1 37.6 18.3 37.2 17.3 37 16.3 37 15.3 36.9 14.3 36.8 13.3 36.7 12.4 36.4 11.7 35.7 11.3 34.8 11.2 33.8 11.1 32.8 11 31.8 11 30.8 10.9 29.9 10.4 29 9.8 28.2 9.1 27.5 8.5 26.7 7.8 26 7.3 25.1 7 24.2 7.1 23.2 7.6 22.3 8.2 21.6 8.9 20.8 9.5 20.1 10.2 19.3 10.7 18.5 10.9 17.5 11 16.6 11.1 15.6 11.1 14.6 11.2 13.6 11.5 12.6 12.1 11.9 13 11.4 13.9 11.2 14.9 11.1 15.9 11 16.9 11 17.9 10.9 18.8 10.6 19.6 10 20.4 9.3 21.1 8.6 21.9 8 22.6 7.4 23.6 7 24.6 7.1 25.5 7.5 26.2 8.1 27 8.7 27.7 9.4 28.5 10 29.3 10.6 30.2 10.9 31.2 11 32.2 11.1Z;
M27.7 9.4 28.5 10 29.3 10.6 30.2 10.9 31.2 11 32.2 11.1 33.2 11.1 34.2 11.2 35.1 11.4 36 11.9 36.6 12.7 36.8 13.7 36.9 14.7 36.9 15.7 37 16.7 37.1 17.7 37.3 18.6 37.9 19.4 38.5 20.2 39.2 20.9 39.8 21.7 40.5 22.4 40.9 23.3 41 24.3 40.7 25.2 40.1 26 39.4 26.8 38.8 27.5 38.1 28.3 37.5 29.1 37.1 30 37 31 37 31.9 36.9 32.9 36.8 33.9 36.7 34.9 36.2 35.8 35.5 36.4 34.5 36.8 33.6 36.9 32.6 36.9 31.6 37 30.6 37.1 29.6 37.2 28.8 37.7 28 38.4 27.3 39 26.5 39.7 25.8 40.3 24.9 40.8 23.9 41 23 40.8 22.1 40.2 21.4 39.6 20.6 38.9 19.9 38.3 19.1 37.6 18.3 37.2 17.3 37 16.3 37 15.3 36.9 14.3 36.8 13.3 36.7 12.4 36.4 11.7 35.7 11.3 34.8 11.2 33.8 11.1 32.8 11 31.8 11 30.8 10.9 29.9 10.4 29 9.8 28.2 9.1 27.5 8.5 26.7 7.8 26 7.3 25.1 7 24.2 7.1 23.2 7.6 22.3 8.2 21.6 8.9 20.8 9.5 20.1 10.2 19.3 10.7 18.5 10.9 17.5 11 16.5 11.1 15.6 11.1 14.6 11.2 13.6 11.5 12.6 12.1 11.9 13 11.4 13.9 11.2 14.9 11.1 15.9 11 16.9 11 17.9 10.9 18.8 10.6 19.6 10 20.4 9.3 21.1 8.6 21.9 8 22.6 7.4 23.6 7 24.6 7.1 25.5 7.5 26.2 8.1 27 8.7Z;
M27.9 10.6 28.8 10.3 29.8 10.1 30.8 10 31.8 10.1 32.7 10.3 33.7 10.6 34.6 11.1 34.8 11.3 35.4 11.7 36.1 12.4 36.7 13.1 37.2 14 37.6 14.9 37.9 15.9 38 16.9 38 17.9 37.8 18.8 37.5 19.8 37.1 20.7 36.8 21.6 36.5 22.6 36.4 23.6 36.4 24.4 36.4 24.6 36.5 25.5 36.8 26.5 37.2 27.4 37.6 28.3 37.8 29.3 38 30.3 38 31.3 37.8 32.3 37.6 33.2 37.2 34.1 36.6 35 36 35.7 35.3 36.4 34.4 37 34.1 37.2 33.6 37.4 32.6 37.8 31.6 37.9 30.6 38 29.7 37.9 28.7 37.7 27.8 37.3 26.8 36.9 25.9 36.6 24.9 36.4 23.9 36.4 22.9 36.4 22 36.6 21 37 20.1 37.4 20.1 37.4 19.2 37.7 18.2 37.9 17.2 38 16.2 37.9 15.3 37.7 14.3 37.4 13.4 36.9 12.6 36.3 11.9 35.6 11.3 34.9 10.8 34 10.4 33.1 10.1 32.1 10 31.1 10 30.6 10 30.1 10.2 29.2 10.5 28.2 10.9 27.3 11.2 26.4 11.5 25.4 11.6 24.4 11.6 23.4 11.5 22.5 11.2 21.5 10.8 20.6 10.4 19.7 10.2 18.7 10 17.7 10 16.7 10 16.6 10.2 15.7 10.4 14.8 10.8 13.9 11.4 13 12 12.3 12.7 11.6 13.6 11 14.4 10.6 15.4 10.2 16.4 10.1 17.4 10 18.3 10.1 19.3 10.3 20.2 10.7 20.9 11 21.2 11.1 22.1 11.4 23.1 11.6 24.1 11.6 25.1 11.6 26 11.4 27 11Z;
M36 35.7 35.3 36.4 34.4 37 33.6 37.4 32.6 37.8 31.6 37.9 30.6 38 29.7 37.9 28.7 37.7 27.8 37.3 26.8 36.9 25.9 36.6 24.9 36.4 23.9 36.4 22.9 36.4 22 36.6 21 37 20.1 37.4 19.2 37.7 18.2 37.9 17.2 38 16.2 37.9 15.3 37.7 14.3 37.4 13.4 36.9 12.6 36.3 11.9 35.6 11.3 34.9 10.8 34 10.4 33.1 10.1 32.1 10 31.1 10 30.2 10.2 29.2 10.5 28.2 10.9 27.3 11.2 26.4 11.5 25.4 11.6 24.4 11.6 23.4 11.5 22.5 11.2 21.5 10.8 20.6 10.4 19.7 10.2 18.7 10 17.7 10 16.7 10.2 15.7 10.4 14.8 10.8 13.9 11.4 13 12 12.3 12.7 11.6 13.6 11 14.4 10.6 15.4 10.2 16.4 10.1 17.4 10 18.3 10.1 19.3 10.3 20.2 10.7 21.2 11.1 22.1 11.4 23.1 11.6 24.1 11.6 25.1 11.6 26 11.4 27 11 27.9 10.6 28.8 10.3 29.8 10.1 30.8 10 31.8 10.1 32.7 10.3 33.7 10.6 34.6 11.1 35.4 11.7 36.1 12.4 36.7 13.1 37.2 14 37.6 14.9 37.9 15.9 38 16.9 38 17.8 37.8 18.8 37.5 19.8 37.1 20.7 36.8 21.6 36.5 22.6 36.4 23.6 36.4 24.6 36.5 25.5 36.8 26.5 37.2 27.4 37.6 28.3 37.8 29.3 38 30.3 38 31.3 37.8 32.3 37.6 33.2 37.2 34.1 36.6 35Z;
M32.1 32.1 31.4 32.8 30.7 33.5 29.9 34.1 29.1 34.7 28.3 35.3 27.6 35.8 27.5 35.8 26.6 36.4 25.8 36.8 24.9 37.3 24 37.7 23.1 38 22.1 38.3 21.2 38.6 20.2 38.8 19.2 38.9 18.2 39 17.2 39 16.6 38.9 16.3 38.9 15.3 38.7 14.3 38.4 13.4 38 12.5 37.5 11.7 36.9 11.1 36.3 10.5 35.5 10 34.6 9.6 33.7 9.3 32.7 9.1 31.7 9.1 31.4 9 30.8 9 29.8 9.1 28.8 9.2 27.8 9.4 26.8 9.7 25.9 10 24.9 10.3 24 10.7 23.1 11.2 22.2 11.6 21.4 12.2 20.5 12.2 20.4 12.7 19.7 13.3 18.9 13.9 18.1 14.5 17.3 15.2 16.6 15.9 15.9 16.6 15.2 17.3 14.5 18.1 13.9 18.9 13.3 19.7 12.7 20.4 12.2 20.5 12.2 21.4 11.6 22.2 11.2 23.1 10.7 24 10.3 24.9 10 25.9 9.7 26.8 9.4 27.8 9.2 28.8 9.1 29.8 9 30.8 9 31.4 9.1 31.7 9.1 32.7 9.3 33.7 9.6 34.6 10 35.5 10.5 36.3 11.1 36.9 11.7 37.5 12.5 38 13.4 38.4 14.3 38.7 15.3 38.9 16.3 38.9 16.6 39 17.2 39 18.2 38.9 19.2 38.8 20.2 38.6 21.2 38.3 22.1 38 23.1 37.7 24 37.3 24.9 36.8 25.8 36.4 26.6 35.8 27.5 35.8 27.6 35.3 28.3 34.7 29.1 34.1 29.9 33.5 30.7 32.8 31.4Z;
M24.3 10.2 24.9 10 25.9 9.7 26.8 9.4 27.1 9.4 27.8 9.2 28.8 9.1 29.8 9 29.9 9 30.8 9 31.7 9.1 32.7 9.3 32.8 9.3 33.7 9.6 34.6 10 35.5 10.5 35.5 10.5 36.3 11.1 36.9 11.7 37.5 12.5 37.5 12.5 38 13.4 38.4 14.3 38.7 15.2 38.7 15.3 38.9 16.3 39 17.2 39 18.1 39 18.2 38.9 19.2 38.8 20.2 38.6 20.9 38.6 21.2 38.3 22.1 38 23.1 37.8 23.7 37.7 24 37.3 24.9 36.8 25.8 36.5 26.4 36.4 26.6 35.8 27.5 35.3 28.3 35 28.8 34.7 29.1 34.1 29.9 33.5 30.7 33.1 31.1 32.8 31.4 32.1 32.1 31.4 32.8 31.1 33.1 30.7 33.5 29.9 34.1 29.1 34.7 28.8 35 28.3 35.3 27.5 35.8 26.6 36.4 26.4 36.5 25.8 36.8 24.9 37.3 24 37.7 23.7 37.8 23.1 38 22.1 38.3 21.2 38.6 20.9 38.6 20.2 38.8 19.2 38.9 18.2 39 18.1 39 17.2 39 16.3 38.9 15.3 38.7 15.2 38.7 14.3 38.4 13.4 38 12.5 37.5 12.5 37.5 11.7 36.9 11.1 36.3 10.5 35.5 10.5 35.5 10 34.6 9.6 33.7 9.3 32.8 9.3 32.7 9.1 31.7 9 30.8 9 29.9 9 29.8 9.1 28.8 9.2 27.8 9.4 27.1 9.4 26.8 9.7 25.9 10 24.9 10.2 24.3 10.3 24 10.7 23.1 11.2 22.2 11.5 21.6 11.6 21.4 12.2 20.5 12.7 19.7 13 19.2 13.3 18.9 13.9 18.1 14.5 17.3 14.9 16.9 15.2 16.6 15.9 15.9 16.6 15.2 16.9 14.9 17.3 14.5 18.1 13.9 18.9 13.3 19.2 13 19.7 12.7 20.5 12.2 21.4 11.6 21.6 11.5 22.2 11.2 23.1 10.7 24 10.3Z;
M22.5 7.8 23.2 7.2 24.2 7 25.1 7.4 25.7 8.1 26.2 9 26.8 9.8 27.3 10.6 28.1 11.2 29 11.3 30 11 30.9 10.6 31.8 10.3 32.8 9.9 33.7 10 34.5 10.6 34.9 11.5 34.8 12.5 34.8 13.5 34.7 14.5 34.7 15.5 35.2 16.3 36 16.8 37 17 37.9 17.3 38.9 17.5 39.8 17.9 40.4 18.7 40.5 19.7 40 20.5 39.3 21.3 38.7 22 38.1 22.8 37.6 23.7 37.7 24.6 38.3 25.4 38.9 26.2 39.6 27 40.2 27.7 40.5 28.6 40.3 29.6 39.5 30.3 38.6 30.6 37.6 30.8 36.7 31 35.7 31.3 35 31.9 34.6 32.8 34.7 33.8 34.8 34.8 34.8 35.8 34.8 36.8 34.3 37.6 33.4 38.1 32.4 38 31.5 37.6 30.6 37.2 29.7 36.9 28.7 36.6 27.8 36.9 27.1 37.6 26.6 38.5 26.1 39.3 25.5 40.2 24.8 40.8 23.8 41 22.9 40.6 22.3 39.9 21.8 39 21.2 38.2 20.7 37.4 19.9 36.8 19 36.7 18 37 17.1 37.4 16.2 37.7 15.2 38.1 14.3 38 13.5 37.4 13.1 36.5 13.2 35.5 13.2 34.5 13.3 33.5 13.3 32.5 12.8 31.7 12 31.2 11 31 10.1 30.7 9.1 30.5 8.2 30.1 7.6 29.3 7.5 28.3 8 27.5 8.7 26.7 9.3 26 9.9 25.2 10.4 24.3 10.3 23.4 9.7 22.6 9.1 21.8 8.4 21 7.8 20.3 7.5 19.4 7.7 18.4 8.5 17.7 9.4 17.4 10.4 17.2 11.3 17 12.3 16.7 13 16.1 13.4 15.2 13.3 14.2 13.2 13.2 13.2 12.2 13.2 11.2 13.7 10.4 14.6 9.9 15.6 10 16.5 10.4 17.4 10.8 18.3 11.1 19.3 11.4 20.2 11.1 20.9 10.4 21.4 9.5 21.9 8.7Z"></animate>
<animateTransform attributeName="transform" attributeType="XML" type="rotate" dur="5s" repeatCount="indefinite" calcMode="spline" keySplines="0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8; 0.5 0.2 0 0.8" keyTimes="0; 0.14; 0.29; 0.43; 0.57; 0.71; 0.86; 1" values="0 24 24; 154 24 24; 309 24 24; 463 24 24; 617 24 24; 771 24 24; 926 24 24; 1080 24 24"></animateTransform>
</path>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
<h1>Music</h1>
<ul class="list border">
<li>My Music</li>
<li>
<ul class="list border">
<li>
<i>artist</i>
<div class="max">
<a href="/music/myartists">Artists</a>
</div>
</li>
<li>
<i>album</i>
<div class="max">
<a href="/music/myalbums">Albums</a>
</div>
</li>
<li>
<i>music_note</i>
<div class="max">
<a href="/music/mysongs">Songs</a>
</div>
</li>
</ul>
</li>
<li>Find New</li>
<li>
<ul class="list border">
<li>
<i>artist</i>
<div class="max">
<a href="/music/artists">Artists</a>
</div>
</li>
<li>
<i>album</i>
<div class="max">
<a href="/music/albums">Albums</a>
</div>
</li>
<li>
<i>music_note</i>
<div class="max">
<a href="/music/songs">Songs</a>
</div>
</li>
</ul>
</li>
</ul>
+31
View File
@@ -0,0 +1,31 @@
<table>
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Video</th>
<th>Channel</th>
</tr>
</thead>
<tbody>
{{for ent in entries}}
<tr>
<td>{{ent.time}}</td>
<td>{{ent.type}}</td>
<td><a href="{{ent.videourl}}">{{ent.videotitle}}</a></td>
<td><a href="{{ent.channelurl}}">{{ent.channeltitle}}</a></td>
</tr>
{{end}}
</tbody>
</table>
<div class="row">
<div class="max">
</div>
<div class="min">
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{prev}}"><i>arrow_back</i></button>
<span>{{current}}</span>
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{next}}"><i>arrow_forward</i></button>
</div>
<div class="max">
</div>
</div>
@@ -0,0 +1,23 @@
<div class="row">
<div class="max">
<h1>{{name}}</h1>
</div>
<div class="min">
<button hx-post="/music/album/add" hx-vals="{{addvals}}" hx-swap="outerHTML"><i>add</i> Add to TYTD</button>
</div>
</div>
{{for song in songs}}
<div class="row">
<div class="min">
<span>{{song.songnumber}}</span>
</div>
<div class="max">
<a href="{{song.songurl}}">{{song.songname}}</a>
</div>
<div class="min">
<span>{{song.duration}}</span>
</div>
</div>
{{end}}
+34
View File
@@ -0,0 +1,34 @@
<form hx-target="body" hx-push-url="true" hx-get="/music/albums">
<div class="row">
<div class="max">
<div class="field label border">
<input type="text" name="q" value="{{q}}">
<label>Search for albums</label>
</div>
</div>
<div class="min">
<button>Search</button>
</div>
</div>
</form>
{{for album in albums}}
<div class="row">
<div class="min">
<img width="120" height="120" src="{{album.imgurl}}" alt="album thumbnail">
</div>
<div class="max">
<a href="{{album.albumurl}}">{{album.albumname}}</a>
</div>
</div>
{{end}}
<div class="row">
<div class="max">
</div>
<div class="min">
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{prev}}"><i>arrow_back</i></button>
<span>{{current}}</span>
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{next}}"><i>arrow_forward</i></button>
</div>
<div class="max">
</div>
</div>
@@ -0,0 +1,24 @@
<h1>{{name}}</h1>
{{for album in albums}}
<div class="row">
<div class="min">
<img width="120" height="120" src="{{album.imgurl}}" alt="album thumbnail">
</div>
<div class="max">
<a href="{{album.albumurl}}">{{album.albumname}}</a>
</div>
</div>
{{end}}
</div>
<div class="row">
<div class="max">
</div>
<div class="min">
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{prev}}"><i>arrow_back</i></button>
<span>{{current}}</span>
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{next}}"><i>arrow_forward</i></button>
</div>
<div class="max">
</div>
</div>
@@ -0,0 +1,34 @@
<form hx-target="body" hx-push-url="true" hx-get="/music/artists">
<div class="row">
<div class="max">
<div class="field label border">
<input type="text" name="q" value="{{q}}">
<label>Search for artists</label>
</div>
</div>
<div class="min">
<button>Search</button>
</div>
</div>
</form>
{{for artist in artists}}
<div class="row">
<div class="min">
<img width="120" height="120" src="{{artist.imgurl}}" alt="artist thumbnail">
</div>
<div class="max">
<a href="{{artist.artisturl}}">{{artist.artistname}}</a>
</div>
</div>
{{end}}
<div class="row">
<div class="max">
</div>
<div class="min">
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{prev}}"><i>arrow_back</i></button>
<span>{{current}}</span>
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{next}}"><i>arrow_forward</i></button>
</div>
<div class="max">
</div>
</div>
@@ -0,0 +1,52 @@
<div class="row">
<div class="max">
<h1>{{name}}</h1>
</div>
<div class="min">
{{if hasanysongs}}
<nav class="group split">
<a class="button left-round" href="{{zipurl}}">
<i>folder_zip</i>
<span>Download Zip</span>
</a>
<nav class="no-margin">
<button class="right-round square">
<i>keyboard_arrow_down</i>
</button>
<menu class="left no-wrap secondary-container">
<li hx-post="/music/album/add" hx-vals="{{addvals}}" hx-swap="outerHTML">Add To TYTD</li>
</menu>
</nav>
</nav>
{{else}}
<button hx-post="/music/album/add" hx-vals="{{addvals}}" hx-swap="outerHTML"><i>add</i> Add to TYTD</button>
{{end}}
</div>
</div>
<div>
{{artists}}
</div>
<ul class="list border">
{{for song in songs}}
<li>
<div class="min">
<span>{{song.songnumber}}</span>
</div>
<div class="max">
<a href="{{song.songurl}}">{{song.songname}}</a>
</div>
<div class="min">
<span>{{song.duration}}</span>
</div>
<div class="min">
{{song.btn}}
</div>
</li>
{{end}}
</ul>
@@ -0,0 +1,34 @@
<form hx-target="body" hx-push-url="true" hx-get="/music/myalbums">
<div class="row">
<div class="max">
<div class="field label border">
<input type="text" name="q" value="{{q}}">
<label>Search for albums</label>
</div>
</div>
<div class="min">
<button>Search</button>
</div>
</div>
</form>
{{for album in albums}}
<div class="row">
<div class="min">
<img width="120" height="120" src="{{album.imgurl}}" alt="album thumbnail">
</div>
<div class="max">
<a href="{{album.albumurl}}">{{album.albumname}}</a>
</div>
</div>
{{end}}
<div class="row">
<div class="max">
</div>
<div class="min">
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{prev}}"><i>arrow_back</i></button>
<span>{{current}}</span>
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{next}}"><i>arrow_forward</i></button>
</div>
<div class="max">
</div>
</div>
@@ -0,0 +1,36 @@
<h1>{{name}}</h1>
<form hx-target="body" hx-push-url="true" hx-get="{{searchurl}}">
<div class="row">
<div class="max">
<div class="field label border">
<input type="text" name="q" value="{{q}}">
<label>Search for albums</label>
</div>
</div>
<div class="min">
<button>Search</button>
</div>
</div>
</form>
{{for album in albums}}
<div class="row">
<div class="min">
<img width="120" height="120" src="{{album.imgurl}}" alt="album thumbnail">
</div>
<div class="max">
<a href="{{album.albumurl}}">{{album.albumname}}</a>
</div>
</div>
{{end}}
</div>
<div class="row">
<div class="max">
</div>
<div class="min">
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{prev}}"><i>arrow_back</i></button>
<span>{{current}}</span>
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{next}}"><i>arrow_forward</i></button>
</div>
<div class="max">
</div>
</div>
@@ -0,0 +1,34 @@
<form hx-target="body" hx-push-url="true" hx-get="/music/myartists">
<div class="row">
<div class="max">
<div class="field label border">
<input type="text" name="q" value="{{q}}">
<label>Search for artists</label>
</div>
</div>
<div class="min">
<button>Search</button>
</div>
</div>
</form>
{{for artist in artists}}
<div class="row">
<div class="min">
<img width="120" height="120" src="{{artist.imgurl}}" alt="artist thumbnail">
</div>
<div class="max">
<a href="{{artist.artisturl}}">{{artist.artistname}}</a>
</div>
</div>
{{end}}
<div class="row">
<div class="max">
</div>
<div class="min">
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{prev}}"><i>arrow_back</i></button>
<span>{{current}}</span>
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{next}}"><i>arrow_forward</i></button>
</div>
<div class="max">
</div>
</div>
+47
View File
@@ -0,0 +1,47 @@
<div class="row">
<div class="min">
<img width="240" height="240" src="{{albumart}}" alt="album thumbnail">
</div>
<div class="max" style="min-height: 240px;">
<div class="col top">
<div class="min">
<i>music_note</i> {{trackindex}}. {{songname}}
</div>
<div class="min">
<i>album</i> <a hx-target="body" hx-push-url="true" hx-get="{{albumurl}}" href="{{albumurl}}">{{albumname}}</a>
</div>
<div class="min">
<i>artist</i> {{artists}}
</div>
<div class="min">
{{dlbtn}}
</div>
</div>
</div>
</div>
<article class="primary-container">
{{if hasvideo }}
<div class="row no-space" id="isnt_correct">
<div class="min">
<img src="{{videothumbnail}}" height="120" alt="video thumbnail">
</div>
<div class="max">
<div class="padding">
<h5>{{videotitle}}</h5>
<nav>
<button hx-swap="outerHTML" hx-target="#isnt_correct" hx-get="{{incorrectsongurl}}" class="border round secondary">This isn&apos;t correct</button>
<a href="{{tytdurl}}" target="_blank" class="button border round secondary">View on TYTD</a>
</nav>
</div>
</div>
</div>
{{else}}
<div class="row no-space" id="isnt_correct">
<button hx-swap="outerHTML" hx-target="#isnt_correct" hx-get="{{incorrectsongurl}}" class="border round secondary">Find video</button>
</div>
{{end}}
</article>
+34
View File
@@ -0,0 +1,34 @@
<form hx-target="body" hx-push-url="true" hx-get="/music/mysongs">
<div class="row">
<div class="max">
<div class="field label border">
<input type="text" name="q" value="{{q}}">
<label>Search for songs</label>
</div>
</div>
<div class="min">
<button>Search</button>
</div>
</div>
</form>
{{for song in songs}}
<div class="row">
<div class="max">
<a href="{{song.songurl}}">{{song.songname}}</a>
</div>
<div class="min">
{{song.btn}}
</div>
</div>
{{end}}
<div class="row">
<div class="max">
</div>
<div class="min">
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{prev}}"><i>arrow_back</i></button>
<span>{{current}}</span>
<button class="round" hx-target="body" hx-push-url="true" hx-get="{{next}}"><i>arrow_forward</i></button>
</div>
<div class="max">
</div>
</div>
+22
View File
@@ -0,0 +1,22 @@
<h1>We need some information</h1>
<form action="/oobe" method="POST">
<div class="field label border">
<input type="url" name="companion">
<label>Companion Url</label>
</div>
<div class="field label border">
<input type="url" name="tytd">
<label>TYTD Url</label>
</div>
<div class="field label border">
<input type="text" name="user">
<label>TYTD Username</label>
</div>
<div class="field label border">
<input type="password" name="pass">
<label>TYTD Password</label>
</div>
<br>
<button>Finish</button>
</form>
+14
View File
@@ -0,0 +1,14 @@
<form hx-target="body" hx-push-url="true" hx-get="/search">
<div class="row">
<div class="max">
<div class="field label border">
<input type="text" name="q" value="{{q}}">
<label>Search for videos</label>
</div>
</div>
<div class="min">
<button>Search</button>
</div>
</div>
</form>
{{body}}
View File
+86
View File
@@ -0,0 +1,86 @@
:root,
body.light {
--primary:#bb1614;
--on-primary:#ffffff;
--primary-container:#ffdad5;
--on-primary-container:#410001;
--secondary:#775652;
--on-secondary:#ffffff;
--secondary-container:#ffdad5;
--on-secondary-container:#2c1512;
--tertiary:#705c2e;
--on-tertiary:#ffffff;
--tertiary-container:#fcdfa6;
--on-tertiary-container:#261a00;
--error:#ba1a1a;
--on-error:#ffffff;
--error-container:#ffdad6;
--on-error-container:#410002;
--background:#fffbff;
--on-background:#201a19;
--surface:#fff8f7;
--on-surface:#201a19;
--surface-variant:#f5ddda;
--on-surface-variant:#534341;
--outline:#857370;
--outline-variant:#d8c2be;
--shadow:#000000;
--scrim:#000000;
--inverse-surface:#362f2e;
--inverse-on-surface:#fbeeec;
--inverse-primary:#ffb4a9;
--surface-dim:#e4d7d5;
--surface-bright:#fff8f7;
--surface-container-lowest:#ffffff;
--surface-container-low:#fef1ef;
--surface-container:#f8ebe9;
--surface-container-high:#f3e5e3;
--surface-container-highest:#ede0de;
}
body.dark {
--primary:#ffb4a9;
--on-primary:#690002;
--primary-container:#930005;
--on-primary-container:#ffdad5;
--secondary:#e7bdb7;
--on-secondary:#442926;
--secondary-container:#5d3f3b;
--on-secondary-container:#ffdad5;
--tertiary:#dfc38c;
--on-tertiary:#3e2e04;
--tertiary-container:#574419;
--on-tertiary-container:#fcdfa6;
--error:#ffb4ab;
--on-error:#690005;
--error-container:#93000a;
--on-error-container:#ffb4ab;
--background:#201a19;
--on-background:#ede0de;
--surface:#181211;
--on-surface:#ede0de;
--surface-variant:#534341;
--on-surface-variant:#d8c2be;
--outline:#a08c89;
--outline-variant:#534341;
--shadow:#000000;
--scrim:#000000;
--inverse-surface:#ede0de;
--inverse-on-surface:#362f2e;
--inverse-primary:#bb1614;
--surface-dim:#181211;
--surface-bright:#3f3736;
--surface-container-lowest:#120d0c;
--surface-container-low:#201a19;
--surface-container:#251e1d;
--surface-container-high:#2f2827;
--surface-container-highest:#3b3332;
}
.loading-indicator
{
position: absolute;
top: 50vh;
left: 50vw;
transform: translate(-50%, -50%);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="tytd.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="1.1438552"
inkscape:cx="396.90338"
inkscape:cy="561.25985"
inkscape:window-width="2560"
inkscape:window-height="1528"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#ff0000;stroke-width:0.224175"
id="rect113"
width="120"
height="84"
x="40"
y="58"
ry="18.792341" />
<path
style="display:inline;fill:#00AA00;fill-rule:evenodd;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M
100 140
130 106
105 106
105 60
95 60
95 106
70 106
100 140
"
id="path510"
inkscape:connector-type="polyline"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+2018
View File
File diff suppressed because one or more lines are too long
+53
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="610 1101 1220 75">
<path fill="#D0BCFF" d="M1870.425 1173.746c-29.448 0-58.665-7.477-84.493-21.622-.03-.016-.061-.032-.09-.049-43.555-23.82-95.546-23.809-139.091.039a4.079 4.079 0 0 1-.113.062c-25.803 14.109-54.984 21.567-84.394 21.568-29.449 0-58.667-7.478-84.495-21.623-43.554-23.852-95.539-23.865-139.089-.052-.03.018-.062.034-.092.051-25.83 14.146-55.048 21.623-84.497 21.623-29.423 0-58.613-7.463-84.425-21.584l-.077-.042c-43.572-23.863-95.596-23.862-139.164-.001l-.085.046c-25.809 14.117-55.001 21.581-84.418 21.581h-.001c-29.45-.001-58.669-7.479-84.498-21.625-43.534-23.845-95.513-23.863-139.063-.058l-.108.06c-25.829 14.146-55.047 21.623-84.496 21.623-29.398 0-58.566-7.451-84.362-21.551-.048-.024-.095-.05-.142-.075-43.568-23.863-95.593-23.863-139.163 0l-.081.043c-25.811 14.12-55.001 21.583-84.421 21.583-29.45 0-58.668-7.478-84.497-21.624-7.521-4.118-10.278-13.554-6.159-21.074s13.554-10.277 21.074-6.158c43.57 23.862 95.593 23.864 139.163 0l.098-.053c25.807-14.114 54.991-21.573 84.406-21.573 29.398 0 58.567 7.451 84.364 21.55l.14.076c43.534 23.843 95.508 23.864 139.056.059l.113-.063c25.828-14.145 55.049-21.622 84.496-21.622h.002c29.449 0 58.668 7.478 84.497 21.625 43.57 23.863 95.595 23.862 139.165.001.021-.013.043-.024.065-.036 25.813-14.124 55.011-21.591 84.438-21.591 29.42 0 58.61 7.463 84.421 21.582l.082.045c43.539 23.847 95.521 23.862 139.074.049l.096-.053c25.829-14.146 55.047-21.623 84.496-21.623s58.667 7.477 84.496 21.623c43.58 23.867 95.604 23.866 139.172.005l.118-.064c25.803-14.108 54.98-21.564 84.389-21.564 29.448-.001 58.666 7.476 84.494 21.62l.114.063c43.549 23.807 95.528 23.791 139.063-.051 7.52-4.121 16.955-1.361 21.073 6.159 4.119 7.521 1.361 16.955-6.159 21.073-25.828 14.146-55.045 21.622-84.492 21.622z">
<animateTransform attributeName="transform" calcMode="linear" dur="1s" from="0 0" repeatCount="indefinite" to="312.5 0" type="translate"/>
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB