diff --git a/.gitignore b/.gitignore
index d721b24..fc25db9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -482,4 +482,5 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
-help.txt
\ No newline at end of file
+help.txt
+data/
diff --git a/Dockerfile b/Dockerfile
index dfc6bd6..fcccaeb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,11 @@
-FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
WORKDIR /src/Tesses.CMS.Server
RUN dotnet publish -c Release -o /app
-FROM mcr.microsoft.com/dotnet/runtime:7.0 AS runner
+FROM mcr.microsoft.com/dotnet/runtime:8.0 AS runner
RUN apt update && apt install -y ffmpeg
WORKDIR /app
COPY --from=build /app .
diff --git a/Tesses.CMS.Avalonia/.gitignore b/Tesses.CMS.Avalonia/.gitignore
new file mode 100644
index 0000000..48cc008
--- /dev/null
+++ b/Tesses.CMS.Avalonia/.gitignore
@@ -0,0 +1,454 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# Mac bundle stuff
+*.dmg
+*.app
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# JetBrains Rider
+.idea/
+*.sln.iml
+
+##
+## Visual Studio Code
+##
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
diff --git a/Tesses.CMS.Avalonia/Directory.Build.props b/Tesses.CMS.Avalonia/Directory.Build.props
new file mode 100644
index 0000000..f39656c
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+ enable
+ 11.0.6
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Icon.png b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Icon.png
new file mode 100644
index 0000000..41a2a61
Binary files /dev/null and b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Icon.png differ
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/MainActivity.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/MainActivity.cs
new file mode 100644
index 0000000..f88735e
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/MainActivity.cs
@@ -0,0 +1,41 @@
+using Android.App;
+using Android.Content;
+using Android.Content.PM;
+using Android.Runtime;
+using Avalonia;
+using Avalonia.Android;
+using Avalonia.ReactiveUI;
+using Tesses.VirtualFilesystem.Filesystems;
+
+namespace Tesses.CMS.Avalonia.Android;
+
+[Activity(
+ Label = "Tesses CMS",
+ Theme = "@style/MyTheme.NoActionBar",
+ Icon = "@drawable/icon",
+ MainLauncher = true,
+ ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
+public class MainActivity : AvaloniaMainActivity
+{
+ protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
+ {
+ App.Platform = new MobilePlatform(this);
+ return base.CustomizeAppBuilder(builder)
+ .WithInterFont()
+ .UseReactiveUI();
+ }
+ protected override void OnActivityResult(int requestCode, [GeneratedEnum] Result resultCode, Intent? data)
+ {
+ if(requestCode == SAFFileSystem.RequestCode && resultCode == Result.Ok)
+ {
+ var res=SAFFileSystem.GetSAFFromResponsePresistant(this,data,MobilePlatform.MySAFKey);
+ if(res != null)
+ {
+ var appPlatform = App.Platform as MobilePlatform;
+ if(appPlatform != null)
+ appPlatform.virtualFilesystem = res;
+ App.Log("Openned dir");
+ }
+ }
+ }
+}
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/MobilePlatform.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/MobilePlatform.cs
new file mode 100644
index 0000000..fd98eb4
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/MobilePlatform.cs
@@ -0,0 +1,92 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia.Platform.Storage;
+using Newtonsoft.Json;
+using Tesses.VirtualFilesystem;
+using Tesses.VirtualFilesystem.Extensions;
+using Tesses.VirtualFilesystem.Filesystems;
+namespace Tesses.CMS.Avalonia.Android;
+
+internal class MobilePlatform : IPlatform
+{
+ string configpath;
+ MainActivity activity;
+ public const string MySAFKey = "MySafKey";
+ public MobilePlatform(MainActivity activity)
+ {
+ this.activity = activity;
+ configpath=activity.FilesDir?.AbsolutePath ?? "";
+ if(File.Exists(Path.Combine(configpath, "tesses_cms_app.json")))
+ {
+ var conf=JsonConvert.DeserializeObject(File.ReadAllText(Path.Combine(configpath, "tesses_cms_app.json")));
+ if(conf != null)
+ _conf = conf;
+
+
+
+ }
+ var res= SAFFileSystem.GetSAFFromSharedStorage(activity,MySAFKey);
+ if(res != null)
+ virtualFilesystem = res;
+ }
+
+ Configuration _conf = new Configuration();
+ public Configuration Configuration => _conf;
+
+ public bool PlatformUsesNormalPathsForDownload => false;
+
+ internal IVirtualFilesystem? virtualFilesystem;
+ public IVirtualFilesystem? DownloadFilesystem => virtualFilesystem;
+
+
+ public async Task BrowseForDownloadDirectoryAsync()
+ {
+ /*var sp=App.Window?.StorageProvider;
+ if(sp != null)
+ {
+ var res=await sp.OpenFolderPickerAsync(new global::Avalonia.Platform.Storage.FolderPickerOpenOptions{Title="Browse for a downloads folder"});
+
+ if(res.Count > 0)
+ {
+ var first=res.First();
+ string? path=first.TryGetLocalPath();
+ if(!string.IsNullOrWhiteSpace(path))
+ {
+ _conf.DownloadPath = path;
+ await WriteConfigurationAsync();
+ }
+ }
+ }*/
+ try{
+ if(virtualFilesystem != null)
+ SAFFileSystem.Revoke(activity,MySAFKey);
+ }catch(Exception ex)
+ {
+ _=ex;
+ }
+ virtualFilesystem=null;
+ SAFFileSystem.RequestDirectory(activity,true,true);
+ await Task.CompletedTask;
+
+ }
+
+ public async Task ReadConfigurationAsync()
+ {
+ if(File.Exists(Path.Combine(configpath, "tesses_cms_app.json")))
+ {
+ var conf=JsonConvert.DeserializeObject(await File.ReadAllTextAsync(Path.Combine(configpath, "tesses_cms_app.json")));
+ if(conf != null)
+ _conf = conf;
+ }
+ }
+
+ public async Task WriteConfigurationAsync()
+ {
+
+ await File.WriteAllTextAsync(Path.Combine(configpath, "tesses_cms_app.json"),JsonConvert.SerializeObject(_conf));
+
+
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Properties/AndroidManifest.xml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Properties/AndroidManifest.xml
new file mode 100644
index 0000000..6080754
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Properties/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/AboutResources.txt b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/AboutResources.txt
new file mode 100644
index 0000000..4cedede
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/AboutResources.txt
@@ -0,0 +1,44 @@
+Images, layout descriptions, binary blobs and string dictionaries can be included
+in your application as resource files. Various Android APIs are designed to
+operate on the resource IDs instead of dealing with images, strings or binary blobs
+directly.
+
+For example, a sample Android app that contains a user interface layout (main.axml),
+an internationalization string table (strings.xml) and some icons (drawable-XXX/icon.png)
+would keep its resources in the "Resources" directory of the application:
+
+Resources/
+ drawable/
+ icon.png
+
+ layout/
+ main.axml
+
+ values/
+ strings.xml
+
+In order to get the build system to recognize Android resources, set the build action to
+"AndroidResource". The native Android APIs do not operate directly with filenames, but
+instead operate on resource IDs. When you compile an Android application that uses resources,
+the build system will package the resources for distribution and generate a class called "R"
+(this is an Android convention) that contains the tokens for each one of the resources
+included. For example, for the above Resources layout, this is what the R class would expose:
+
+public class R {
+ public class drawable {
+ public const int icon = 0x123;
+ }
+
+ public class layout {
+ public const int main = 0x456;
+ }
+
+ public class strings {
+ public const int first_string = 0xabc;
+ public const int second_string = 0xbcd;
+ }
+}
+
+You would then use R.drawable.icon to reference the drawable/icon.png file, or R.layout.main
+to reference the layout/main.axml file, or R.strings.first_string to reference the first
+string in the dictionary file values/strings.xml.
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/drawable-night-v31/avalonia_anim.xml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/drawable-night-v31/avalonia_anim.xml
new file mode 100644
index 0000000..1fef3ac
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/drawable-night-v31/avalonia_anim.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/drawable-v31/avalonia_anim.xml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/drawable-v31/avalonia_anim.xml
new file mode 100644
index 0000000..4784f80
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/drawable-v31/avalonia_anim.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/drawable/splash_screen.xml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/drawable/splash_screen.xml
new file mode 100644
index 0000000..4cebfe2
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/drawable/splash_screen.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values-night/colors.xml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values-night/colors.xml
new file mode 100644
index 0000000..0f6f6c8
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values-night/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #212121
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values-v31/styles.xml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values-v31/styles.xml
new file mode 100644
index 0000000..8075ffa
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values-v31/styles.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values/colors.xml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values/colors.xml
new file mode 100644
index 0000000..6fbae25
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values/styles.xml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values/styles.xml
new file mode 100644
index 0000000..77ed2d7
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Resources/values/styles.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/SAFFileStream.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/SAFFileStream.cs
new file mode 100644
index 0000000..36869a5
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/SAFFileStream.cs
@@ -0,0 +1,531 @@
+using Android.Content;
+using Tesses.VirtualFilesystem;
+using Android.App;
+using Android.Content.PM;
+using AndroidX.DocumentFile.Provider;
+using Tesses.VirtualFilesystem.Extensions;
+using System.Collections.Generic;
+using System;
+using System.IO;
+using Tesses.CMS.Avalonia;
+namespace Tesses.VirtualFilesystem.Filesystems;
+
+
+public class SAFFileSystem : SyncFileSystem
+{
+ public const int RequestCode = 1447775022;
+ public const string SharedPreferencesFile = "tesess_vfs";
+ public static void RequestDirectory(Activity app,bool canwrite=true,bool presistant=false,int reqCode=RequestCode)
+ {
+ Intent intent = new Intent(Intent.ActionOpenDocumentTree);
+ intent.AddFlags(ActivityFlags.GrantReadUriPermission|(canwrite? ActivityFlags.GrantWriteUriPermission : 0)|(presistant?ActivityFlags.GrantPersistableUriPermission:0));
+ app.StartActivityForResult(intent,reqCode);
+ }
+
+ public static SAFFileSystem? GetSAFFromResponse(Context? app,Intent? intent)
+ {
+ if(app == null || intent == null) return null;
+ var uri=intent.Data;
+ if(uri != null)
+ {
+ return new SAFFileSystem(uri,app);
+ }
+ return null;
+ }
+ public static SAFFileSystem? GetSAFFromResponsePresistant(Context? app,Intent? intent,string key)
+ {
+ if(app == null || intent == null) return null;
+ var uri=intent.Data;
+ if(uri != null)
+ {
+ var r = app?.ContentResolver;
+ if(r == null) return null;
+ var rw = intent.Flags & (ActivityFlags.GrantReadUriPermission | ActivityFlags.GrantWriteUriPermission);
+ r.TakePersistableUriPermission(uri,rw);
+ var app2=app?.CreatePackageContext(app.PackageName,0);
+ var sharedPrefs=app2?.GetSharedPreferences(SharedPreferencesFile,FileCreationMode.Private);
+
+ sharedPrefs?.Edit()?.PutString(key,uri.ToString())?.Apply();
+
+
+ return new SAFFileSystem(uri,app);
+ }
+ return null;
+ }
+ public static void Revoke(Context? app,string key)
+ {
+ var app2=app?.CreatePackageContext(app.PackageName,0);
+ var sharedPrefs=app2?.GetSharedPreferences(SharedPreferencesFile,FileCreationMode.Private);
+ var res=sharedPrefs?.GetString(key,null);
+ var r = app?.ContentResolver;
+ if(r == null) return;
+
+ if(string.IsNullOrWhiteSpace(res)) return;
+ var uri = Android.Net.Uri.Parse(res);
+ if(uri == null) return;
+ r.ReleasePersistableUriPermission(uri,ActivityFlags.GrantReadUriPermission);
+ sharedPrefs?.Edit()?.Remove(key)?.Apply();
+ }
+
+ public static SAFFileSystem? GetSAFFromSharedStorage(Context? app,string key)
+ {
+ if(app == null) return null;
+ var app2=app?.CreatePackageContext(app.PackageName,0);
+ var sharedPrefs=app2?.GetSharedPreferences(SharedPreferencesFile,FileCreationMode.Private);
+ var res=sharedPrefs?.GetString(key,null);
+
+ if(string.IsNullOrWhiteSpace(res)) return null;
+ var uri = Android.Net.Uri.Parse(res);
+ if(uri == null) return null;
+
+ return new SAFFileSystem(uri,app);
+ }
+
+
+ public SAFFileSystem(global::Android.Net.Uri uri,Context? ctx)
+ {
+ Uri = uri;
+ Context = ctx;
+
+ }
+ public global::Android.Net.Uri Uri {get;set;}
+ public Context? Context {get;set;}
+
+
+ public override void CreateDirectory(UnixPath directory)
+ {
+ if(Context != null && Uri != null)
+ {
+
+ var dir = DocumentFile.FromTreeUri(Context,Uri);
+ if(dir == null) return;
+
+ foreach(var item in directory.Parts)
+ {
+ var dir2= dir?.FindFile(item);
+ if(dir2 != null)
+ {
+ if(!dir2.IsDirectory && !dir2.IsFile && !dir2.IsVirtual)
+ {
+ dir = dir?.CreateDirectory(item);
+ }
+ else if(dir2.IsDirectory)
+ {
+ dir = dir2;
+ }
+ }
+ else
+ {
+ dir = dir?.CreateDirectory(item);
+ }
+ }
+
+ }
+ }
+
+ public override void DeleteDirectory(UnixPath path)
+ {
+ if(Context != null && Uri != null)
+ {
+
+ var dir = DocumentFile.FromTreeUri(Context,Uri);
+ if(dir == null) return;
+
+ for(int i = 0;i EnumerateFileSystemEntries(UnixPath path)
+ {
+ if(Context != null && Uri != null)
+ {
+
+ var dir = DocumentFile.FromTreeUri(Context,Uri);
+ if(dir == null) yield break;
+
+ if(path.IsRoot)
+ {
+ foreach(var _item in dir.ListFiles())
+ {
+ yield return path / _item.Name;
+ }
+ yield break;
+ }
+
+ for(int i = 0;i "a"
+ //FileMode.Create -> "w"
+ //FileMode.CreateNew -> "w"
+ //FileMode.Open -> "r or w"
+ //FileMode.OpenOrCreate -> "r or w"
+ //FileMode.Truncate -> "t"
+ //"r", "w", "wt", "wa", "rw" or "rwt"
+
+ Stream? strm = null;
+
+ if(access == FileAccess.Read)
+ strm = Context?.ContentResolver?.OpenInputStream(uri) ?? null;
+
+ if(access == FileAccess.Write)
+ if(mode == FileMode.Truncate)
+ strm = Context?.ContentResolver?.OpenOutputStream(uri,"wt");
+ else if(mode == FileMode.Append)
+ strm = Context?.ContentResolver?.OpenOutputStream(uri,"wa");
+ else
+ strm = Context?.ContentResolver?.OpenOutputStream(uri,"w");
+
+ if(access == FileAccess.ReadWrite)
+ if(mode == FileMode.Truncate)
+ strm = Context?.ContentResolver?.OpenOutputStream(uri,"rwt");
+ else
+ strm = Context?.ContentResolver?.OpenOutputStream(uri,"rw");
+
+
+
+
+
+ if(strm == null) throw new IOException("Failed to open stream");
+ return strm;
+
+ }
+
+ public override void SetCreationTime(UnixPath path, DateTime time)
+ {
+
+ }
+
+ public override void SetLastAccessTime(UnixPath path, DateTime time)
+ {
+
+ }
+
+ public override void SetLastWriteTime(UnixPath path, DateTime time)
+ {
+
+ }
+}
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Tesses.CMS.Avalonia.Android.csproj b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Tesses.CMS.Avalonia.Android.csproj
new file mode 100644
index 0000000..d3a34d9
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Android/Tesses.CMS.Avalonia.Android.csproj
@@ -0,0 +1,29 @@
+
+
+ Exe
+ net8.0-android
+ 21
+ enable
+ com.CompanyName.Tesses.CMS.Avalonia
+ 1
+ 1.0
+ apk
+ False
+
+
+
+
+ Resources\drawable\Icon.png
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/DesktopPlatform.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/DesktopPlatform.cs
new file mode 100644
index 0000000..7088291
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/DesktopPlatform.cs
@@ -0,0 +1,95 @@
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using LibVLCSharp.Avalonia;
+using LibVLCSharp.Shared;
+using Newtonsoft.Json;
+using Tesses.VirtualFilesystem;
+using Tesses.VirtualFilesystem.Extensions;
+using Tesses.VirtualFilesystem.Filesystems;
+namespace Tesses.CMS.Avalonia.Desktop;
+
+internal class DesktopPlatform : IPlatform
+{
+ public DesktopPlatform()
+ {
+
+ if(rootfs.FileExists(Special.LocalAppData / "tesses_cms_app.json"))
+ {
+ var conf=JsonConvert.DeserializeObject(rootfs.ReadAllText(Special.LocalAppData/"tesses_cms_app.json"));
+ if(conf != null)
+ _conf = conf;
+
+ var path=UnixPath.FromLocal(_conf.DownloadPath);
+ virtualFilesystem = rootfs.GetSubdirFilesystem(path);
+ }
+ }
+ LocalFileSystem rootfs=new LocalFileSystem();
+ Configuration _conf = new Configuration();
+ public Configuration Configuration => _conf;
+
+ public bool PlatformUsesNormalPathsForDownload => true;
+
+ IVirtualFilesystem? virtualFilesystem;
+ public IVirtualFilesystem? DownloadFilesystem => virtualFilesystem;
+
+
+ public async Task BrowseForDownloadDirectoryAsync()
+ {
+ var sp=App.Window?.StorageProvider;
+ if(sp != null)
+ {
+ var res=await sp.OpenFolderPickerAsync(new global::Avalonia.Platform.Storage.FolderPickerOpenOptions{Title="Browse for a downloads folder"});
+
+ if(res.Count > 0)
+ {
+ var first=res.First();
+ string? path=first.TryGetLocalPath();
+ if(!string.IsNullOrWhiteSpace(path))
+ {
+ _conf.DownloadPath = path;
+ await WriteConfigurationAsync();
+ }
+ }
+ }
+ }
+
+ public async Task ReadConfigurationAsync()
+ {
+ if(await rootfs.FileExistsAsync(Special.LocalAppData / "tesses_cms_app.json"))
+ {
+ var conf=JsonConvert.DeserializeObject(await rootfs.ReadAllTextAsync(Special.LocalAppData/"tesses_cms_app.json"));
+ if(conf != null)
+ _conf = conf;
+ }
+ }
+
+ public async Task WriteConfigurationAsync()
+ {
+ await rootfs.WriteAllTextAsync(Special.LocalAppData / "tesses_cms_app.json",JsonConvert.SerializeObject(_conf));
+ var path=UnixPath.FromLocal(_conf.DownloadPath);
+ virtualFilesystem = rootfs.GetSubdirFilesystem(path);
+ }
+
+ public Control CreatePlayer()
+ {
+ return new VideoPlayer();
+ }
+
+ public void SetMediaPlayer(Control control, MediaPlayer? mediaPlayer)
+ {
+ var ctrl = control as VideoPlayer;
+ if(ctrl != null)
+ ctrl.MediaPlayer = mediaPlayer;
+ }
+
+ public MediaPlayer? GetMediaPlayer(Control control)
+ {
+ var ctrl = control as VideoPlayer;
+ if(ctrl != null)
+ return ctrl.MediaPlayer;
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/Program.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/Program.cs
new file mode 100644
index 0000000..d489fe8
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/Program.cs
@@ -0,0 +1,28 @@
+using System;
+using Avalonia;
+using Avalonia.ReactiveUI;
+
+namespace Tesses.CMS.Avalonia.Desktop;
+
+sealed class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) {
+ App.Platform = new DesktopPlatform();
+
+
+ BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+ }
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace()
+ .UseReactiveUI();
+}
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/Tesses.CMS.Avalonia.Desktop.csproj b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/Tesses.CMS.Avalonia.Desktop.csproj
new file mode 100644
index 0000000..44b97dc
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/Tesses.CMS.Avalonia.Desktop.csproj
@@ -0,0 +1,28 @@
+
+
+ WinExe
+
+ net8.0
+ enable
+ true
+
+
+
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/VideoPlayer.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/VideoPlayer.cs
new file mode 100644
index 0000000..5de7a5b
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/VideoPlayer.cs
@@ -0,0 +1,124 @@
+using System;
+using System.IO;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using LibVLCSharp.Avalonia;
+using LibVLCSharp.Shared;
+using Tesses.CMS.Avalonia;
+
+public class VideoPlayer : Grid
+{
+ public VideoPlayer()
+ {
+ view=new VideoView();
+
+
+
+ this.RowDefinitions.Add(new RowDefinition(GridLength.Star));
+ this.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ this.Children.Add(view);
+ SetRow(view,0);
+ Grid seekPanel = new Grid();
+ Button ss=new Button();
+ ss.Content = "SS";
+ ss.Click += (sender,e)=>{
+ //take screenshot
+ string realDir=App.Platform.DownloadFilesystem == null ? Environment.GetFolderPath(Environment.SpecialFolder.MyPictures) : Path.Combine(App.Platform.Configuration.DownloadPath,"Screenshots");
+ Directory.CreateDirectory(realDir);
+ if(MediaPlayer == null) return;
+ string name = Path.Combine(realDir,$"TessesCMS-Screenshot-{DateTime.Now.ToString("yyyyMMdd_HHmmss")}-{TimeSpan.FromMilliseconds(MediaPlayer.Position*MediaPlayer.Length).ToString().Replace(":","_")}.png");
+ MediaPlayer.TakeSnapshot(0,name,0,0);
+
+ };
+ slider = new Slider();
+ slider.Minimum = 0;
+ slider.Maximum = 1000000;
+
+ slider.ValueChanged+= (sender,e)=>{
+ if(MediaPlayer != null)
+ {
+ if(!theyareseeking)
+ MediaPlayer.Position = (float)(slider.Value/slider.Maximum);
+ }
+ };
+ playBtn=new Button();
+ playBtn.Content = "Play";
+ playBtn.Click += (sender,e)=>{
+ if(MediaPlayer != null)
+ {
+ if(MediaPlayer.IsPlaying)
+ {
+
+ MediaPlayer.Pause();
+ }
+ else
+ {
+ MediaPlayer.Play();
+ }
+ }
+ };
+ seekPanel.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
+ seekPanel.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ seekPanel.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
+ seekPanel.Children.Add(playBtn);
+ seekPanel.Children.Add(slider);
+ seekPanel.Children.Add(ss);
+ SetColumn(playBtn,0);
+ SetColumn(slider,1);
+ SetColumn(ss,2);
+ this.Children.Add(seekPanel);
+ SetRow(seekPanel,1);
+ }
+ Slider slider;
+ Button playBtn;
+ VideoView view;
+ public MediaPlayer? MediaPlayer
+ {
+ get=>view.MediaPlayer;
+ set{
+ if(view.MediaPlayer != null)
+ {
+ view.MediaPlayer.Paused -= Paused;
+ view.MediaPlayer.Playing -= Playing;
+ view.MediaPlayer.PositionChanged -= PositionChanged;
+ }
+ view.MediaPlayer=value;
+ if(view.MediaPlayer != null)
+ {
+ view.MediaPlayer.Paused += Paused;
+ view.MediaPlayer.Playing += Playing;
+ view.MediaPlayer.PositionChanged += PositionChanged;
+ }
+ }
+ }
+
+
+
+ bool theyareseeking=false;
+
+
+ private void PositionChanged(object? sender, MediaPlayerPositionChangedEventArgs e)
+ {
+Dispatcher.UIThread.Invoke(()=>{
+ theyareseeking=true;
+ slider.Value = (e.Position*slider.Maximum);
+ theyareseeking=false;
+});
+ }
+
+
+
+ private void Playing(object? sender, EventArgs e)
+ {
+ Dispatcher.UIThread.Invoke(()=>{
+ playBtn.Content="Pause";
+ });
+ }
+
+ private void Paused(object? sender, EventArgs e)
+ {
+ Dispatcher.UIThread.Invoke(()=>{
+ playBtn.Content="Play";
+ });
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/app.manifest b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/app.manifest
new file mode 100644
index 0000000..3487fae
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.Desktop/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.sln b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.sln
new file mode 100644
index 0000000..4690536
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.sln
@@ -0,0 +1,50 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.3.32811.315
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tesses.CMS.Avalonia", "Tesses.CMS.Avalonia\Tesses.CMS.Avalonia.csproj", "{EBFA8512-1EA5-4D8C-B4AC-AB5B48A6D568}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tesses.CMS.Avalonia.Desktop", "Tesses.CMS.Avalonia.Desktop\Tesses.CMS.Avalonia.Desktop.csproj", "{ABC31E74-02FF-46EB-B3B2-4E6AE43B456C}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tesses.CMS.Avalonia.Android", "Tesses.CMS.Avalonia.Android\Tesses.CMS.Avalonia.Android.csproj", "{7AD1DAC8-7FBE-49D5-8614-7321233DB82E}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3DA99C4E-89E3-4049-9C22-0A7EC60D83D8}"
+ ProjectSection(SolutionItems) = preProject
+ Directory.Build.props = Directory.Build.props
+ EndProjectSection
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {EBFA8512-1EA5-4D8C-B4AC-AB5B48A6D568}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EBFA8512-1EA5-4D8C-B4AC-AB5B48A6D568}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EBFA8512-1EA5-4D8C-B4AC-AB5B48A6D568}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EBFA8512-1EA5-4D8C-B4AC-AB5B48A6D568}.Release|Any CPU.Build.0 = Release|Any CPU
+ {ABC31E74-02FF-46EB-B3B2-4E6AE43B456C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {ABC31E74-02FF-46EB-B3B2-4E6AE43B456C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {ABC31E74-02FF-46EB-B3B2-4E6AE43B456C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {ABC31E74-02FF-46EB-B3B2-4E6AE43B456C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1C1A049E-235C-4CD0-B6FA-D53AC418F4DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1C1A049E-235C-4CD0-B6FA-D53AC418F4DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1C1A049E-235C-4CD0-B6FA-D53AC418F4DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1C1A049E-235C-4CD0-B6FA-D53AC418F4DA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EBD9022F-BC83-4846-9A11-6F7F3772DC64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EBD9022F-BC83-4846-9A11-6F7F3772DC64}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EBD9022F-BC83-4846-9A11-6F7F3772DC64}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EBD9022F-BC83-4846-9A11-6F7F3772DC64}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7AD1DAC8-7FBE-49D5-8614-7321233DB82E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7AD1DAC8-7FBE-49D5-8614-7321233DB82E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7AD1DAC8-7FBE-49D5-8614-7321233DB82E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7AD1DAC8-7FBE-49D5-8614-7321233DB82E}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {83CB65B8-011F-4ED7-BCD3-A6CFA935EF7E}
+ EndGlobalSection
+EndGlobal
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/App.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/App.axaml
new file mode 100644
index 0000000..649c230
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/App.axaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/App.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/App.axaml.cs
new file mode 100644
index 0000000..c551c12
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/App.axaml.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using Tesses.CMS.Avalonia.ViewModels;
+using Tesses.CMS.Avalonia.Views;
+using Tesses.CMS.Client;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Tesses.VirtualFilesystem;
+using Tesses.VirtualFilesystem.Extensions;
+using System.IO;
+namespace Tesses.CMS.Avalonia;
+
+public partial class App : Application
+{
+ public static IPlatform Platform {get;set;} = new NullPlatform();
+
+ public static MainWindow? Window {get;set;}
+
+ public static TessesCMSClient Client {get;} = new TessesCMSClient();
+
+ public override void Initialize()
+ {
+
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ Client.RootUrl = Platform.Configuration.ServerUrl;
+ string title = GetApplicationTitle();
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ Window = new MainWindow
+ {
+ DataContext = new MainViewModel(title),
+ Title = title
+ };
+ desktop.MainWindow = Window;
+ }
+ else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
+ {
+
+ singleViewPlatform.MainView = new MainView
+ {
+ DataContext = new MainViewModel(title)
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ internal static string GetApplicationTitle()
+ {
+ string title="Tesses CMS";
+ Task.Run(async()=>{
+ try {
+ var data = await Client.GetBrandingAsync();
+ title = data.Title;
+ }catch(Exception ex)
+ {
+ _=ex;
+ }
+ }).Wait();
+ return title;
+ }
+
+ internal static async Task GetMovieThumbnailAsync(string username, string name)
+ {
+ //we need to cache the resource
+ if(Platform.Configuration.CacheResources && Platform.DownloadFilesystem != null)
+ {
+ UnixPath cacheDir = Special.Root / "Metadata" / "Cache" / username / "Movies" / name;
+ Log($"About to create directory: {cacheDir}");
+
+ await Platform.DownloadFilesystem.CreateDirectoryAsync(cacheDir);
+ Log($"Created directory: {cacheDir}");
+ UnixPath thumbnailPath = cacheDir / "thumbnail.jpg";
+
+ if(await Platform.DownloadFilesystem.FileExistsAsync(thumbnailPath))
+ {
+ Log($"Image exists: {thumbnailPath}");
+ using(var sr = await Platform.DownloadFilesystem.OpenReadAsync(thumbnailPath))
+ {
+ MemoryStream ms = new MemoryStream();
+ sr.CopyTo(ms);
+ ms.Position=0;
+ Log($"Image read from file: {thumbnailPath}");
+ return new Bitmap(ms);
+ }
+
+ }
+ else
+ {
+ var metadata = await Client.Movies.GetMovieContentMetadataAsync(username,name);
+ if(metadata.HasThumbnail)
+ {
+ using(var strm = await Platform.DownloadFilesystem.OpenAsync(thumbnailPath,System.IO.FileMode.Create,System.IO.FileAccess.Write,System.IO.FileShare.None))
+ {
+ MemoryStream ms=new MemoryStream();
+ Log($"About to read from network and save: {thumbnailPath}");
+
+ await Client.Movies.DownloadThumbnailAsync(username,name,ms);
+ ms.Position=0;
+ ms.CopyTo(strm);
+ ms.Position=0;
+ Log($"Image read from network and saved: {thumbnailPath}");
+ return new Bitmap(ms);
+ }
+ }
+
+ }
+ }
+ else
+ {
+ var metadata = await Client.Movies.GetMovieContentMetadataAsync(username,name);
+ if(metadata.HasThumbnail)
+ {
+ MemoryStream ms=new MemoryStream();
+ Log($"About to read from network: {username} {name}");
+
+ await Client.Movies.DownloadThumbnailAsync(username,name,ms);
+ ms.Position = 0;
+ Log($"Image read from network: {username} {name}");
+
+ return new Bitmap(ms);
+
+ }
+
+ }
+ Log($"Image does not exist: {username} {name}");
+
+ return new Bitmap(AssetLoader.Open(new Uri("avares://Tesses.CMS.Avalonia/Assets/no-media-icon.png")));
+ }
+
+ public static void Log(string text)
+ {
+ lock(App.Platform)
+ {
+ var fs=App.Platform.DownloadFilesystem;
+ if(fs != null && App.Platform.Configuration.Log)
+ {
+
+ using(var strm = fs.Open(Special.Root/"Logs.txt",FileMode.Append,FileAccess.Write,FileShare.None))
+ {
+ using(var sw = new StreamWriter(strm))
+ {
+ sw.WriteLine(text);
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Assets/avalonia-logo.ico b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..da8d49f
Binary files /dev/null and b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Assets/avalonia-logo.ico differ
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Assets/no-media-icon.png b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Assets/no-media-icon.png
new file mode 100644
index 0000000..c9147ba
Binary files /dev/null and b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Assets/no-media-icon.png differ
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Configuration.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Configuration.cs
new file mode 100644
index 0000000..79d94a4
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Configuration.cs
@@ -0,0 +1,12 @@
+public class Configuration
+{
+ public string ServerUrl {get;set;}="https://tessesstudios.com/";
+
+ public string LoginToken {get;set;}="";
+
+ public string DownloadPath {get;set;}="";
+
+ public bool CacheResources {get;set;}=true;
+
+ public bool Log {get;set;}=true;
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/IPlatform.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/IPlatform.cs
new file mode 100644
index 0000000..fd8c8c5
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/IPlatform.cs
@@ -0,0 +1,70 @@
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using LibVLCSharp.Shared;
+using Tesses.VirtualFilesystem;
+
+public interface IPlatform
+{
+ Configuration Configuration {get;}
+ Task BrowseForDownloadDirectoryAsync();
+
+ bool PlatformUsesNormalPathsForDownload {get;}
+
+ IVirtualFilesystem? DownloadFilesystem {get;}
+
+ Task WriteConfigurationAsync();
+
+ Task ReadConfigurationAsync();
+
+ Control CreatePlayer();
+
+ void SetMediaPlayer(Control control, MediaPlayer? mediaPlayer);
+
+ MediaPlayer? GetMediaPlayer(Control control);
+
+}
+
+public class NullPlatform : IPlatform
+{
+ public bool PlatformUsesNormalPathsForDownload => false;
+
+ public IVirtualFilesystem? DownloadFilesystem
+ {
+ get => null;
+ set => _=value;
+ }
+
+ Configuration _conf=new Configuration();
+
+ public Configuration Configuration => _conf;
+
+ public async Task BrowseForDownloadDirectoryAsync()
+ {
+ await Task.CompletedTask;
+ }
+
+ public async Task ReadConfigurationAsync()
+ {
+ await Task.CompletedTask;
+ }
+
+ public async Task WriteConfigurationAsync()
+ {
+ await Task.CompletedTask;
+ }
+
+ public Control CreatePlayer()
+ {
+ return new TextBlock(){Text="Video player unavailable"};
+ }
+
+ public void SetMediaPlayer(Control control, MediaPlayer? mediaPlayer)
+ {
+
+ }
+
+ public MediaPlayer? GetMediaPlayer(Control control)
+ {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Models/MovieItem.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Models/MovieItem.cs
new file mode 100644
index 0000000..d0bdbd4
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Models/MovieItem.cs
@@ -0,0 +1,16 @@
+using Avalonia.Media;
+using Tesses.CMS.Client;
+
+namespace Tesses.CMS.Avalonia.Models;
+
+public class MovieItem
+{
+ public MovieItem(Movie movie,IImage image)
+ {
+ Movie = movie;
+ Image = image;
+ }
+ public Movie Movie {get;}
+ public IImage Image {get;}
+ public string Name => Movie.ProperName;
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Models/Page.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Models/Page.cs
new file mode 100644
index 0000000..ef0d268
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Models/Page.cs
@@ -0,0 +1,16 @@
+using System;
+using Tesses.CMS.Avalonia.ViewModels;
+
+namespace Tesses.CMS.Avalonia.Models;
+
+public class Page
+{
+ public Page(string name,Func model)
+ {
+ Name = name;
+ getViewModel=model;
+ }
+ public string Name {get;set;}
+ private Func getViewModel;
+ public ViewModelBase ViewModel => getViewModel();
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.csproj b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.csproj
new file mode 100644
index 0000000..2726913
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia.csproj
@@ -0,0 +1,29 @@
+
+
+ net8.0
+ enable
+ latest
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewLocator.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewLocator.cs
new file mode 100644
index 0000000..defc095
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewLocator.cs
@@ -0,0 +1,30 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Tesses.CMS.Avalonia.ViewModels;
+
+namespace Tesses.CMS.Avalonia;
+
+public class ViewLocator : IDataTemplate
+{
+ public Control? Build(object? data)
+ {
+ if (data is null)
+ return null;
+
+ var name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/DownloadsPageViewModel.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/DownloadsPageViewModel.cs
new file mode 100644
index 0000000..6fce003
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/DownloadsPageViewModel.cs
@@ -0,0 +1,19 @@
+namespace Tesses.CMS.Avalonia.ViewModels;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using LibVLCSharp.Shared;
+
+public partial class DownloadsPageViewModel : ViewModelBase
+{
+ public DownloadsPageViewModel()
+ {
+ LibVLC vlc=new LibVLC("--input-repeat=65535");
+
+ Player=new MediaPlayer(new Media(vlc,"https://tytdarchive.site.tesses.net/content/PreMuxed/PzUKeGZiEl0.mp4",FromType.FromLocation));
+
+
+ }
+ [ObservableProperty]
+ private MediaPlayer? _player;
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/FavoritesPageViewModel.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/FavoritesPageViewModel.cs
new file mode 100644
index 0000000..3a6f72e
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/FavoritesPageViewModel.cs
@@ -0,0 +1,8 @@
+namespace Tesses.CMS.Avalonia.ViewModels;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+public partial class FavoritesPageViewModel : ViewModelBase
+{
+
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePageViewModel.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePageViewModel.cs
new file mode 100644
index 0000000..21829da
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePageViewModel.cs
@@ -0,0 +1,29 @@
+namespace Tesses.CMS.Avalonia.ViewModels;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using Tesses.CMS.Avalonia.ViewModels.HomePages;
+
+public partial class HomePageViewModel : ViewModelBase
+{
+ public HomePageViewModel()
+ {
+ _currentPage = new HomeUserListPageViewModel(this);
+ }
+ [ObservableProperty]
+ private ViewModelBase _currentPage;
+
+ [RelayCommand]
+ private void Back()
+ {
+ var page = CurrentPage as IBackable;
+ if(page != null)
+ {
+ CurrentPage = page.Back();
+ }
+ else
+ {
+ //can't go back
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePages/HomeMovieListPageViewModel.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePages/HomeMovieListPageViewModel.cs
new file mode 100644
index 0000000..b610270
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePages/HomeMovieListPageViewModel.cs
@@ -0,0 +1,53 @@
+namespace Tesses.CMS.Avalonia.ViewModels.HomePages;
+
+using System;
+using System.Collections.ObjectModel;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using Tesses.CMS.Avalonia.Models;
+using Tesses.CMS.Avalonia.Views.HomePages;
+using Tesses.CMS.Client;
+
+public partial class HomeMovieListPageViewModel : ViewModelBase, IBackable
+{
+ HomeUserPageViewModel homePage;
+ public HomeMovieListPageViewModel(HomeUserPageViewModel homePage)
+ {
+ App.Log("In HomeMovieListPageViewModel::ctor block begin");
+ this.homePage = homePage;
+ Task.Run(async()=>{
+ App.Log("In HomeMovieListPageViewModel::ctor::async block begin");
+
+ await foreach(var item in App.Client.Movies.GetMoviesAsync(homePage.Username))
+ {
+ try{
+ _movies.Add(new MovieItem(item,await App.GetMovieThumbnailAsync(homePage.Username,item.Name)));
+ }catch(Exception ex)
+ {
+ App.Log(ex.ToString());
+ }
+ }
+ App.Log("In HomeMovieListPageViewModel::ctor::async block end");
+ }).Wait(0);
+ App.Log("In HomeMovieListPageViewModel::ctor block end");
+ }
+
+ [ObservableProperty]
+ private ObservableCollection _movies=new ObservableCollection();
+ [ObservableProperty]
+ private MovieItem? _selectedListItem;
+ partial void OnSelectedListItemChanged(MovieItem? value)
+ {
+ if (value is null) return;
+ SelectedListItem=null;
+ //homePage.CurrentPage = new HomeUserPageViewModel(homePage,this,value);
+ }
+
+ public ViewModelBase Back()
+ {
+ return homePage;
+ }
+
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePages/HomeUserListPageViewModel.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePages/HomeUserListPageViewModel.cs
new file mode 100644
index 0000000..6a0eb4f
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePages/HomeUserListPageViewModel.cs
@@ -0,0 +1,37 @@
+namespace Tesses.CMS.Avalonia.ViewModels.HomePages;
+
+using System.Collections.ObjectModel;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using Tesses.CMS.Avalonia.Views.HomePages;
+using Tesses.CMS.Client;
+
+public partial class HomeUserListPageViewModel : ViewModelBase
+{
+ HomePageViewModel homePage;
+ public HomeUserListPageViewModel(HomePageViewModel homePage)
+ {
+ this.homePage = homePage;
+ Task.Run(async()=>{
+ await foreach(var item in App.Client.Users.GetUsersAsync())
+ {
+ App.Log($"Got user {item}");
+ _users.Add(item);
+ }
+ }).Wait(0);
+ }
+
+ [ObservableProperty]
+ private ObservableCollection _users=new ObservableCollection();
+ [ObservableProperty]
+ private UserAccount? _selectedListItem;
+ partial void OnSelectedListItemChanged(UserAccount? value)
+ {
+ if (value is null) return;
+ App.Log($"Selected account {value.ProperName}");
+ SelectedListItem=null;
+ homePage.CurrentPage = new HomeUserPageViewModel(homePage,this,value);
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePages/HomeUserPageViewModel.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePages/HomeUserPageViewModel.cs
new file mode 100644
index 0000000..dd77db7
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/HomePages/HomeUserPageViewModel.cs
@@ -0,0 +1,55 @@
+namespace Tesses.CMS.Avalonia.ViewModels.HomePages;
+
+using System.Collections.ObjectModel;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using Tesses.CMS.Client;
+
+public class UserPageItem
+{
+ public UserPageItem(string name,ViewModelBase vmb)
+ {
+ Name = name;
+ Item = vmb;
+ }
+ public string Name {get;set;}
+
+ public ViewModelBase Item {get;set;}
+}
+
+public partial class HomeUserPageViewModel : ViewModelBase, IBackable
+{
+ HomePageViewModel homePage;
+
+ HomeUserListPageViewModel userList;
+ UserAccount account;
+
+ public string Username => account.Username;
+
+ [ObservableProperty]
+ private ObservableCollection _userItems=new ObservableCollection();
+
+ public HomeUserPageViewModel(HomePageViewModel homePage,HomeUserListPageViewModel userList, UserAccount account)
+ {
+ this.homePage = homePage;
+ this.userList = userList;
+ this.account = account;
+ UserItems.Add(new UserPageItem("Movies",new HomeMovieListPageViewModel(this)));
+ }
+ [ObservableProperty]
+ private UserPageItem? _selectedListItem;
+ partial void OnSelectedListItemChanged(UserPageItem? value)
+ {
+ if (value is null) return;
+ SelectedListItem=null;
+ homePage.CurrentPage = value.Item;
+ }
+
+ public ViewModelBase Back()
+ {
+ return userList;
+ }
+
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/IBackable.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/IBackable.cs
new file mode 100644
index 0000000..49ec33e
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/IBackable.cs
@@ -0,0 +1,6 @@
+namespace Tesses.CMS.Avalonia.ViewModels;
+
+public interface IBackable
+{
+ public ViewModelBase Back();
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/MainViewModel.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..0f8c82a
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/MainViewModel.cs
@@ -0,0 +1,60 @@
+namespace Tesses.CMS.Avalonia.ViewModels;
+
+using System.Collections.ObjectModel;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using Tesses.CMS.Avalonia.Models;
+
+public partial class MainViewModel : ViewModelBase
+{
+ public MainViewModel(string title)
+ {
+ _pages.Add(new Page("Settings",()=>new SettingsPageViewModel(this)));
+ SelectedListItem = Pages.First();
+ this.Title = title;
+ }
+ [ObservableProperty]
+ private bool _paneOpen=true;
+ [ObservableProperty]
+ private ViewModelBase _currentPage = new HomePageViewModel();
+
+ [ObservableProperty]
+ private ObservableCollection _pages=new ObservableCollection(){
+ new Page("Home",()=>new HomePageViewModel()),
+ new Page("Favorites",()=>new FavoritesPageViewModel()),
+ new Page("Notifications",()=>new NotificationsPageViewModel()),
+ new Page("Downloads",()=>new DownloadsPageViewModel()),
+
+ };
+
+ [ObservableProperty]
+ private Page? _selectedListItem;
+ [ObservableProperty]
+ private string _loginText="Login";
+
+ [ObservableProperty]
+ private string _title;
+
+ partial void OnSelectedListItemChanged(Page? value)
+ {
+ if (value is null) return;
+
+
+
+
+ CurrentPage = value.ViewModel;
+ }
+
+ [RelayCommand]
+ private void TogglePane()
+ {
+ PaneOpen = !PaneOpen;
+ }
+ [RelayCommand]
+ private void LoginAccount()
+ {
+ App.Log("Login button");
+ }
+}
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/NotificationsPageViewModel.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/NotificationsPageViewModel.cs
new file mode 100644
index 0000000..b46182d
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/NotificationsPageViewModel.cs
@@ -0,0 +1,8 @@
+namespace Tesses.CMS.Avalonia.ViewModels;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+public partial class NotificationsPageViewModel : ViewModelBase
+{
+
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/SettingsPageViewModel.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/SettingsPageViewModel.cs
new file mode 100644
index 0000000..b3b9bfe
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/SettingsPageViewModel.cs
@@ -0,0 +1,50 @@
+namespace Tesses.CMS.Avalonia.ViewModels;
+
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+public partial class SettingsPageViewModel : ViewModelBase
+{
+ MainViewModel mvm;
+ public SettingsPageViewModel(MainViewModel mvm)
+ {
+ this.mvm = mvm;
+ }
+
+ [RelayCommand]
+ private async Task Browse()
+ {
+ await App.Platform.BrowseForDownloadDirectoryAsync();
+ if(App.Platform.PlatformUsesNormalPathsForDownload)
+ Path = App.Platform.Configuration.DownloadPath;
+ }
+
+ [RelayCommand]
+ private async Task Save()
+ {
+ if(App.Platform.PlatformUsesNormalPathsForDownload)
+ App.Platform.Configuration.DownloadPath = Path;
+
+ App.Platform.Configuration.ServerUrl = Url;
+ App.Platform.Configuration.Log = Log;
+ App.Platform.Configuration.CacheResources = CacheResources;
+
+ await App.Platform.WriteConfigurationAsync();
+ App.Client.RootUrl = App.Platform.Configuration.ServerUrl;
+ string title=App.GetApplicationTitle();
+ mvm.Title = title;
+ if(App.Window != null)
+ App.Window.Title = title;
+ }
+
+ [ObservableProperty]
+ private string _path = App.Platform.PlatformUsesNormalPathsForDownload ? App.Platform.Configuration.DownloadPath : "";
+
+ [ObservableProperty]
+ private string _url = App.Platform.Configuration.ServerUrl;
+ [ObservableProperty]
+ private bool _log = App.Platform.Configuration.Log;
+ [ObservableProperty]
+ private bool _cacheResources = App.Platform.Configuration.CacheResources;
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/ViewModelBase.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..3b35673
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/ViewModels/ViewModelBase.cs
@@ -0,0 +1,8 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using ReactiveUI;
+
+namespace Tesses.CMS.Avalonia.ViewModels;
+
+public class ViewModelBase : ObservableObject
+{
+}
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/DownloadsPageView.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/DownloadsPageView.axaml
new file mode 100644
index 0000000..9390bdf
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/DownloadsPageView.axaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/DownloadsPageView.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/DownloadsPageView.axaml.cs
new file mode 100644
index 0000000..eb4392a
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/DownloadsPageView.axaml.cs
@@ -0,0 +1,16 @@
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace Tesses.CMS.Avalonia.Views;
+
+public partial class DownloadsPageView : UserControl
+{
+ public DownloadsPageView()
+ {
+ InitializeComponent();
+ }
+
+
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/FavoritesPageView.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/FavoritesPageView.axaml
new file mode 100644
index 0000000..a732ec1
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/FavoritesPageView.axaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/FavoritesPageView.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/FavoritesPageView.axaml.cs
new file mode 100644
index 0000000..363ab4f
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/FavoritesPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Tesses.CMS.Avalonia.Views;
+
+public partial class FavoritesPageView : UserControl
+{
+ public FavoritesPageView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeMovieListPageView.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeMovieListPageView.axaml
new file mode 100644
index 0000000..6ba60ba
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeMovieListPageView.axaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeMovieListPageView.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeMovieListPageView.axaml.cs
new file mode 100644
index 0000000..b96bd5f
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeMovieListPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Tesses.CMS.Avalonia.Views.HomePages;
+
+public partial class HomeMovieListPageView : UserControl
+{
+ public HomeMovieListPageView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomePageView.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomePageView.axaml
new file mode 100644
index 0000000..7152497
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomePageView.axaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomePageView.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomePageView.axaml.cs
new file mode 100644
index 0000000..9f2462b
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomePageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Tesses.CMS.Avalonia.Views;
+
+public partial class HomePageView : UserControl
+{
+ public HomePageView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserListPageView.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserListPageView.axaml
new file mode 100644
index 0000000..b70232d
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserListPageView.axaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserListPageView.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserListPageView.axaml.cs
new file mode 100644
index 0000000..5ee78fd
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserListPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Tesses.CMS.Avalonia.Views.HomePages;
+
+public partial class HomeUserListPageView : UserControl
+{
+ public HomeUserListPageView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserPageView.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserPageView.axaml
new file mode 100644
index 0000000..9c281d9
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserPageView.axaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserPageView.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserPageView.axaml.cs
new file mode 100644
index 0000000..2b2bbb7
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/HomeUserPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Tesses.CMS.Avalonia.Views.HomePages;
+
+public partial class HomeUserPageView : UserControl
+{
+ public HomeUserPageView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainView.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainView.axaml
new file mode 100644
index 0000000..11671dd
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainView.axaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainView.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainView.axaml.cs
new file mode 100644
index 0000000..cf80bf0
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Tesses.CMS.Avalonia.Views;
+
+public partial class MainView : UserControl
+{
+ public MainView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainWindow.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainWindow.axaml
new file mode 100644
index 0000000..d8c0736
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainWindow.axaml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainWindow.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..4b39126
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/MainWindow.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Tesses.CMS.Avalonia.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/NotificationsPageView.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/NotificationsPageView.axaml
new file mode 100644
index 0000000..414b8ce
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/NotificationsPageView.axaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/NotificationsPageView.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/NotificationsPageView.axaml.cs
new file mode 100644
index 0000000..771d3d9
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/NotificationsPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Tesses.CMS.Avalonia.Views;
+
+public partial class NotificationsPageView : UserControl
+{
+ public NotificationsPageView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/SettingsPageView.axaml b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/SettingsPageView.axaml
new file mode 100644
index 0000000..015febf
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/SettingsPageView.axaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cache Resources
+ Enable Logs
+
+
+
+
+
+
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/SettingsPageView.axaml.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/SettingsPageView.axaml.cs
new file mode 100644
index 0000000..c543184
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/SettingsPageView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Tesses.CMS.Avalonia.Views;
+
+public partial class SettingsPageView : UserControl
+{
+ public SettingsPageView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/VideoPlayerWrapper.cs b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/VideoPlayerWrapper.cs
new file mode 100644
index 0000000..2c61897
--- /dev/null
+++ b/Tesses.CMS.Avalonia/Tesses.CMS.Avalonia/Views/VideoPlayerWrapper.cs
@@ -0,0 +1,41 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using LibVLCSharp.Shared;
+
+namespace Tesses.CMS.Avalonia.Views;
+
+public class VideoPlayerWrapper : Grid
+{
+ public VideoPlayerWrapper()
+ {
+ RowDefinitions.Add(new RowDefinition(GridLength.Star));
+ ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ Player= App.Platform.CreatePlayer();
+ this.Children.Add(Player);
+ }
+
+ public Control Player {get;}
+
+ public MediaPlayer? MediaPlayer
+ {
+ get=>App.Platform.GetMediaPlayer(Player);
+ set=>App.Platform.SetMediaPlayer(Player,value);
+ }
+
+
+ ///
+ /// MediaPlayer Data Bound property
+ ///
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty MediaPlayerProperty =
+ AvaloniaProperty.RegisterDirect(
+ nameof(MediaPlayer),
+ o => o.MediaPlayer,
+ (o, v) => o.MediaPlayer = v,
+ defaultBindingMode: BindingMode.TwoWay);
+
+
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Cli/Program.cs b/Tesses.CMS.Cli/Program.cs
index c382861..4bb1a34 100644
--- a/Tesses.CMS.Cli/Program.cs
+++ b/Tesses.CMS.Cli/Program.cs
@@ -1,15 +1,222 @@
using Tesses.CMS.Client;
+using CommandLine;
+using System.Net;
+using Newtonsoft.Json;
-TessesCMSClient client = new TessesCMSClient("http://192.168.0.158:62444/");
+var prefs=Prefs.Create(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),"tcms.json"));
-await foreach(var item in client.Movies.GetMoviesAsync("tesses"))
+
+
+
+
+
+var res =Parser.Default.ParseArguments(args);
+res = await res.WithParsedAsync(MoviesCallback);
+res = await res.WithParsedAsync(ShowsCallback);
+
+res = await res.WithParsedAsync(UsersCallback);
+
+res = await res.WithParsedAsync(EndpointCallback);
+
+res = res.WithParsed(EventsCallback);
+
+void EventsCallback(EventsOptions options)
{
- var res=await client.Movies.GetMovieContentMetadataAsync("tesses",item.Name);
- Console.WriteLine(item.ProperName);
- Console.WriteLine($"\t{res.PosterUrl}");
+ Console.WriteLine("About to read events");
+ using(var cms = new TessesCMSClient(prefs.Url))
+ {
+ using(CancellationTokenSource src=new CancellationTokenSource()){
+ cms.StartEvents((evt)=>{
+ Console.WriteLine($"Type: {evt.Type}");
+ Console.WriteLine($"Username: {evt.Username}");
+ Console.WriteLine($"Userpropername: {evt.UserProperName}");
+ Console.WriteLine($"Name: {evt.Name}");
+ Console.WriteLine($"ProperName: {evt.ProperName}");
+ bool hasBody = !string.IsNullOrWhiteSpace(evt.Body);
+ if(!string.IsNullOrWhiteSpace(evt.Description))
+ {
+ Console.WriteLine("Description:");
+ Console.WriteLine(evt.Description);
+ if(hasBody)
+ Console.WriteLine();
+ }
+ if(hasBody)
+ {
+ Console.WriteLine("Body:");
+ Console.WriteLine(evt.Body);
+ }
+ Console.WriteLine();
+ },src.Token);
+ Console.ReadLine();
+ src.Cancel();
+ }
+ }
}
-//await client.Movies.CreateAsync("HolyLoop","Holy Loop");
-await client.Users.LogoutAsync();
\ No newline at end of file
+async Task ShowsCallback(ShowsOptions options)
+{
+ throw new NotImplementedException();
+}
+
+
+async Task UsersCallback(UsersOptions options)
+{
+ if(options.Login)
+ {
+ Console.Write("Email: ");
+ string? email=Console.ReadLine();
+ if(string.IsNullOrWhiteSpace(email))
+ {
+ Console.WriteLine("Email is empty");
+ return;
+ }
+ string password = ReadLine.ReadPassword($"Password for {email}: ");
+
+
+ using(var clt = new TessesCMSClient(prefs.Url))
+ {
+ var cookie = await clt.Users.GetCookieAsync(email,password);
+ if(cookie.Success)
+ {
+ if(!string.IsNullOrWhiteSpace(prefs.Session))
+ {
+ await clt.Users.SetCookieAsync(prefs.Session);
+ await clt.Users.LogoutAsync();
+ }
+ prefs.Session = cookie.Cookie;
+ prefs.Save();
+ }
+ }
+ }
+ else if(options.Logout)
+ {
+ if(string.IsNullOrWhiteSpace(prefs.Session))
+ {
+ Console.WriteLine("Not logged in");
+ return;
+ }
+ using(var clt = new TessesCMSClient(prefs.Url))
+ {
+ await clt.Users.SetCookieAsync(prefs.Session);
+ await clt.Users.LogoutAsync();
+ prefs.Session="";
+ prefs.Save();
+ }
+ Console.WriteLine("Logged out");
+ }
+ else {
+ using(var clt = new TessesCMSClient(prefs.Url))
+ {
+ await foreach(var user in clt.Users.GetUsersAsync())
+ {
+ Console.WriteLine($"Username: {user.Username}");
+ Console.WriteLine($"Name: {user.ProperName}");
+ Console.WriteLine("About:");
+ Console.WriteLine(user.AboutMe);
+ Console.WriteLine();
+ }
+ }
+ }
+}
+
+async Task EndpointCallback(EndpointOptions options)
+{
+ if(string.IsNullOrWhiteSpace(options.Url))
+ {
+ Console.WriteLine($"Current Endpoint: {prefs.Url}");
+ }
+ else
+ {
+ prefs.Url = options.Url;
+ prefs.Save();
+ Console.WriteLine($"Set Current Endpoint To: {options.Url}");
+ }
+}
+
+
+async Task MoviesCallback(MoviesOptions options)
+{
+ if(options.List)
+ {
+ using(var clt = new TessesCMSClient(prefs.Url))
+ {
+ await foreach(var movie in clt.Movies.GetMoviesAsync(options.Username))
+ {
+ Console.WriteLine($"Title (ProperName): {movie.ProperName}");
+ Console.WriteLine($"Name: {movie.Name}");
+ Console.WriteLine($"Created: {movie.CreationTime}");
+ Console.WriteLine($"Last Updated: {movie.LastUpdated}");
+ Console.WriteLine("Description: ");
+ Console.WriteLine(movie.Description);
+ Console.WriteLine();
+ }
+ }
+
+ }
+}
+[Verb("endpoint",false,new string[]{"ep"},HelpText ="Change endpoint")]
+internal class EndpointOptions
+{
+ [Value(0,Required =false,HelpText = "Main Page URL for placing request")]
+ public string Url {get;set;} = "";
+}
+[Verb("users",false,HelpText = "User accounts")]
+internal class UsersOptions
+{
+ [Option('l',"login",Required =false,HelpText = "Login")]
+ public bool Login {get;set;}=false;
+
+ [Option('o',"logout",Required = false,HelpText = "Logout")]
+ public bool Logout {get;set;}=false;
+}
+
+internal class ShowsOptions
+{
+}
+[Verb("movies",false,HelpText ="The movies")]
+internal class MoviesOptions
+{
+ [Option('l',"list",HelpText = "List movies")]
+ public bool List {get;set;}=false;
+
+ [Value(0,Required =true,HelpText = "Username")]
+ public string Username {get;set;}="";
+}
+
+
+internal class Prefs
+{
+ [JsonProperty("url")]
+ public string Url {get;set;}="https://tessesstudios.com/";
+ [JsonProperty("session")]
+ public string Session {get;set;}="";
+
+ [JsonIgnore]
+ string filename{get;set;}="";
+
+
+ public static Prefs Create(string path)
+ {
+ if(File.Exists(path))
+ {
+ var res=JsonConvert.DeserializeObject(File.ReadAllText(path)) ?? new Prefs();
+ res.filename = path;
+ return res;
+ }
+ else
+ {
+ return new Prefs(){filename=path};
+ }
+ }
+
+ public void Save()
+ {
+ File.WriteAllText(filename,JsonConvert.SerializeObject(this));
+ }
+}
+[Verb("events",false,new string[]{"evts"},HelpText ="Read server sent events (for debugging)")]
+internal class EventsOptions
+{
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Cli/Tesses.CMS.Cli.csproj b/Tesses.CMS.Cli/Tesses.CMS.Cli.csproj
index b03c821..90ea3ba 100644
--- a/Tesses.CMS.Cli/Tesses.CMS.Cli.csproj
+++ b/Tesses.CMS.Cli/Tesses.CMS.Cli.csproj
@@ -4,6 +4,11 @@
+
+
+
+
+
Exenet7.0
diff --git a/Tesses.CMS.Client/Class1.cs b/Tesses.CMS.Client/Class1.cs
index 3166003..8952a9d 100644
--- a/Tesses.CMS.Client/Class1.cs
+++ b/Tesses.CMS.Client/Class1.cs
@@ -2,17 +2,36 @@
using System.Collections;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
+using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Serialization;
namespace Tesses.CMS.Client
{
- public class TessesCMSClient
+ public enum TessesCMSContentType
{
+ Movie,
+
+ Show,
+
+ Album,
+
+ MusicVideo,
+
+ SoftwareProject,
+
+ Other
+ }
+ public class TessesCMSClient : IDisposable
+ {
+
private static HttpClient CreateHttpClient()
{
HttpClientHandler httpClientHandler=new HttpClientHandler();
@@ -24,28 +43,88 @@ namespace Tesses.CMS.Client
internal HttpClient client;
internal string rooturl;
public HttpClient Client => client;
- public string RootUrl => $"{rooturl}/";
- public TessesCMSClient(string url,HttpClient client)
+ public string RootUrl
{
+ get=>$"{rooturl}/";
+ set {
+ rooturl = value.TrimEnd('/');
+ }
+ }
+ bool ownsClient;
+ public TessesCMSClient(string url,HttpClient client,bool ownsClient)
+ {
+ this.ownsClient=ownsClient;
this.client = client;
rooturl = url.TrimEnd('/');
+
}
- public TessesCMSClient(HttpClient client) : this("https://tessesstudios.com/",client)
- {
-
- }
-
- public TessesCMSClient(string url) : this(url,CreateHttpClient())
- {
-
- }
- public TessesCMSClient() : this(CreateHttpClient())
+ public TessesCMSClient(HttpClient client,bool ownsClient) : this("https://tessesstudios.com/",client,ownsClient)
{
}
+
+
+ public TessesCMSClient(string url) : this(url,CreateHttpClient(),true)
+ {
+
+ }
+ public TessesCMSClient() : this(CreateHttpClient(),true)
+ {
+
+ }
+ public async Task GetBrandingAsync()
+ {
+ return JsonConvert.DeserializeObject(await client.GetStringAsync($"{rooturl}/api/v1/Branding"));
+ }
+ public void StartEvents(Action evt,CancellationToken token=default)
+ {
+ Task.Run(async()=>{
+ var clt=await client.GetSSEClientAsync($"{rooturl}/api/v1/Updates");
+ await foreach(var item in clt.ReadEventsAsync(token))
+ {
+ evt(item.ParseJson());
+ }
+ }).Wait(0);
+ }
+
+ public async Task CreateAsync(string urlname, string propername, string description, TessesCMSContentType type=TessesCMSContentType.Movie,CancellationToken token=default)
+ {
+
+
+ Dictionary kvp= new Dictionary
+ {
+ { "name", urlname },
+ { "proper_name", propername },
+ { "description", description },
+ { "type", type.ToString().ToLower() }
+ };
+
+
+ using(var res=await client.PostAsync($"{rooturl}/upload",new FormUrlEncodedContent(kvp),token))
+ {
+
+ return res.StatusCode != System.Net.HttpStatusCode.Unauthorized;
+ }
+ }
+ public async Task UploadFilePutAsync(string url,string file,CancellationToken token=default,IProgress progress=null)
+ {
+ using(var f = File.OpenRead(file))
+ return await UploadFilePutAsync(url,f,token,progress);
+ }
+ public async Task UploadFilePutAsync(string url, Stream src,CancellationToken token=default,IProgress progress=null)
+ {
+
+ var request = new HttpRequestMessage(HttpMethod.Put,url);
+ request.Content = new ProgressContent(src,progress);
+
+ using(var res=await client.SendAsync(request,token))
+ {
+ return res.StatusCode != System.Net.HttpStatusCode.Unauthorized;
+ }
+ }
public async Task DownloadFileAsync(string url,string dest,CancellationToken token=default,IProgress progress=null)
{
- using(var f = File.Create(dest))
+ using(var f = File.Open(dest,FileMode.OpenOrCreate,FileAccess.Write))
await DownloadFileAsync(url,f,token,progress);
}
public async Task DownloadFileAsync(string url,Stream dest,CancellationToken token=default,IProgress progress=null)
@@ -83,11 +162,88 @@ namespace Tesses.CMS.Client
} while(read>0);
resp.Dispose();
}
-
+ public ShowClient Shows => new ShowClient(this);
public MovieClient Movies => new MovieClient(this);
public UserClient Users => new UserClient(this);
+
+ public void Dispose()
+ {
+ if(this.ownsClient)
+ this.client.Dispose();
+ }
}
+
+ public class Branding
+ {
+ [JsonProperty("title")]
+ public string Title {get;set;}="";
+ }
+
+ [JsonConverter(typeof(StringEnumConverter),typeof(SnakeCaseNamingStrategy))]
+ public enum EventType
+ {
+ MovieCreate,
+ MovieUpdate,
+ ShowCreate,
+ ShowUpdate
+ }
+ public class CMSEvent
+ {
+ [JsonProperty("eventtype")]
+ public EventType Type {get;set;}
+
+ [JsonProperty("username")]
+ public string Username {get;set;}="";
+ [JsonProperty("userpropername")]
+ public string UserProperName {get;set;}="";
+
+ [JsonProperty("name")]
+ public string Name {get;set;}="";
+
+ [JsonProperty("propername")]
+ public string ProperName {get;set;}="";
+
+ [JsonProperty("description")]
+ public string Description {get;set;}="";
+
+ [JsonProperty("body")]
+ public string Body {get;set;}="";
+ }
+
+ internal class ProgressContent : HttpContent
+ {
+ private Stream src;
+ private IProgress progress;
+
+ public ProgressContent(Stream src, IProgress progress)
+ {
+ this.src = src;
+ this.progress = progress;
+ }
+
+ protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context)
+ {
+ int read = 0;
+ byte[] buffer=new byte[1024];
+ double offset=0;
+ double length = src.Length;
+ do {
+ read = await src.ReadAsync(buffer,0,buffer.Length);
+ offset += read;
+ await stream.WriteAsync(buffer,0,read);
+ if(length != 0)
+ progress?.Report(offset / length);
+ } while(read != 0);
+ }
+
+ protected override bool TryComputeLength(out long length)
+ {
+ length = src.Length;
+ return true;
+ }
+ }
+
public class UserClient
{
TessesCMSClient client;
@@ -95,21 +251,115 @@ namespace Tesses.CMS.Client
{
this.client = client;
}
+
public async Task LogoutAsync()
{
- await client.client.GetStringAsync($"{client.rooturl}/logout");
+ (await client.client.GetAsync("/logout")).Dispose();
}
- public async Task LoginAsync(string email,string password)
+
+
+ public async Task CreateTokenAsync(string email,string password)
{
Dictionary us=new Dictionary();
us.Add("email",email);
us.Add("password",password);
- using(var res=await client.client.PostAsync($"{client.rooturl}/login", new FormUrlEncodedContent(us)))
- return res.StatusCode != System.Net.HttpStatusCode.Unauthorized;
+ us.Add("type","json");
+ using(var res=await client.client.PostAsync($"{client.rooturl}/api/v1/Login", new FormUrlEncodedContent(us)))
+
+ return JsonConvert.DeserializeObject( await res.Content.ReadAsStringAsync());
+
+ }
+
+ public string LoginToken
+ {
+ get {
+ if(client.client.DefaultRequestHeaders.Contains("Authorization"))
+ return client.client.DefaultRequestHeaders.Authorization.Parameter;
+ return "";
+ }
+ set{
+ if(string.IsNullOrWhiteSpace(value))
+ if(client.client.DefaultRequestHeaders.Contains("Authorization"))
+ client.client.DefaultRequestHeaders.Remove("Authorization");
+ else
+ client.client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer",value);
+ }
+ }
+
+ public async IAsyncEnumerable GetUsersAsync()
+ {
+ foreach(var user in JsonConvert.DeserializeObject>(await client.client.GetStringAsync($"{client.rooturl}/api/v1/GetPublicUsers")))
+ {
+ yield return user;
+ }
}
}
- public class MovieClient
+ public class LoginToken
+ {
+ [JsonProperty("success")]
+ public bool Success {get;set;}=false;
+ [JsonProperty("cookie")]
+ public string Cookie {get;set;}="";
+ }
+
+ public class ShowClient
+ {
+ TessesCMSClient client;
+ internal ShowClient(TessesCMSClient client)
+ {
+ this.client = client;
+ }
+
+ public async IAsyncEnumerable GetShowsAsync(string user)
+ {
+ foreach(var item in JsonConvert.DeserializeObject>(await client.client.GetStringAsync($"{client.rooturl}/api/v1/GetShows?user={HttpUtility.UrlEncode(user)}")))
+ {
+ yield return item;
+ }
+ }
+ public async IAsyncEnumerable GetSeasonsAsync(string user,string show)
+ {
+ foreach(var item in JsonConvert.DeserializeObject>(await client.client.GetStringAsync($"{client.rooturl}/api/v1/GetSeasons?user={HttpUtility.UrlEncode(user)}&show={HttpUtility.UrlEncode(show)}")))
+ {
+ yield return item;
+ }
+ }
+ public async IAsyncEnumerable GetEpisodesAsync(string user,string show,int season)
+ {
+ foreach(var item in JsonConvert.DeserializeObject>(await client.client.GetStringAsync($"{client.rooturl}/api/v1/GetEpisodes?user={HttpUtility.UrlEncode(user)}&show={HttpUtility.UrlEncode(show)}&season={season}")))
+ {
+ yield return item;
+ }
+ }
+ public async IAsyncEnumerable GetEpisodesAsync(string user,string show)
+ {
+ await foreach(var season in GetSeasonsAsync(user,show))
+ {
+ List episodes=new List();
+ await foreach(var episode in GetEpisodesAsync(user,show,season.SeasonNumber))
+ {
+ episodes.Add(episode);
+ }
+ yield return new SeasonWithEpisodes(season,episodes);
+ }
+ }
+ public async IAsyncEnumerable GetEpisodesAsync(string user)
+ {
+ await foreach(var show in GetShowsAsync(user))
+ {
+ List seasons=new List();
+ await foreach(var episode in GetEpisodesAsync(user,show.Name))
+ {
+ seasons.Add(episode);
+ }
+ yield return new ShowWithSeasonsAndEpisodes(show,seasons);
+ }
+ }
+
+ }
+
+ public class MovieClient
{
TessesCMSClient client;
internal MovieClient(TessesCMSClient client)
@@ -118,7 +368,7 @@ namespace Tesses.CMS.Client
}
public async IAsyncEnumerable GetMoviesAsync(string user)
{
- foreach(var item in JsonConvert.DeserializeObject>(await client.client.GetStringAsync($"{client.rooturl}/api/v1/GetMovies?user={HttpUtility.UrlDecode(user)}")))
+ foreach(var item in JsonConvert.DeserializeObject>(await client.client.GetStringAsync($"{client.rooturl}/api/v1/GetMovies?user={HttpUtility.UrlEncode(user)}")))
{
yield return item;
}
@@ -127,14 +377,213 @@ namespace Tesses.CMS.Client
{
return JsonConvert.DeserializeObject(await client.client.GetStringAsync($"{client.rooturl.TrimEnd('/')}/api/v1/MovieFile?movie={movie}&user={user}&type=json"));
}
+ public async Task UploadExtrasAsync(string user, string movie, string dir, CancellationToken token=default,IProgress progress=null)
+ {
+ List<(string localPath,string removePath)> items=new List<(string localPath, string removePath)>();
+ async Task EnumerateDir(string local, string remote)
+ {
+ if(!string.IsNullOrWhiteSpace(remote))
+ await CreateExtraDirectoryAsync(user,movie,remote,token);
+
+ foreach(var dir in Directory.EnumerateDirectories(local))
+ {
+ await EnumerateDir(dir,$"{remote}/{Path.GetFileName(dir)}");
+ }
+ foreach(var file in Directory.EnumerateFiles(local))
+ {
+ string name = $"{remote}/{Path.GetFileName(file)}";
+ items.Add((file,name));
+ }
+ }
+
+ await EnumerateDir(dir,"");
+ for(int i = 0;i(e=>{
+ double j = i + e;
+ progress?.Report(j / items.Count);
+ }));
+
+ }
+
+ }
+ public async Task UploadExtraAsync(string user, string movie,string extra, string src,CancellationToken token=default,IProgress progress=null)
+ {
+ string url = $"{client.rooturl}/api/v1/MovieExtra?user={user}&movie={movie}&extra={HttpUtility.UrlEncode(extra.TrimStart('/'))}";
+
+
+ return await client.UploadFilePutAsync(url,src,token,progress);
+ }
+
+ public async Task UploadExtraAsync(string user, string movie,string extra, Stream src,CancellationToken token=default,IProgress progress=null)
+ {
+ string url = $"{client.rooturl}/api/v1/MovieExtra?user={user}&movie={movie}&extra={HttpUtility.UrlEncode(extra.TrimStart('/'))}";
+
+ return await client.UploadFilePutAsync(url,src,token,progress);
+ }
+
+ public async Task UploadMovieAsync(string user, string movie, string src,CancellationToken token=default,IProgress progress=null)
+ {
+ string url = $"{client.rooturl}/api/v1/MovieFile?user={user}&movie={movie}&type=movie";
+
+ return await client.UploadFilePutAsync(url,src,token,progress);
+ }
+
+ public async Task UploadMovieAsync(string user, string movie, Stream src,CancellationToken token=default,IProgress progress=null)
+ {
+ string url = $"{client.rooturl}/api/v1/MovieFile?user={user}&movie={movie}&type=movie";
+
+ return await client.UploadFilePutAsync(url,src,token,progress);
+ }
+
+ public async Task UploadPosterAsync(string user, string movie, string src,CancellationToken token=default,IProgress progress=null)
+ {
+ string url = $"{client.rooturl}/api/v1/MovieFile?user={user}&movie={movie}&type=poster";
+
+ return await client.UploadFilePutAsync(url,src,token,progress);
+ }
+
+ public async Task UploadPosterAsync(string user, string movie, Stream src,CancellationToken token=default,IProgress progress=null)
+ {
+ string url = $"{client.rooturl}/api/v1/MovieFile?user={user}&movie={movie}&type=poster";
+
+ return await client.UploadFilePutAsync(url,src,token,progress);
+ }
+ public async Task UploadThumbnailAsync(string user, string movie, string src,CancellationToken token=default,IProgress progress=null)
+ {
+ string url = $"{client.rooturl}/api/v1/MovieFile?user={user}&movie={movie}&type=thumbnail";
+
+ return await client.UploadFilePutAsync(url,src,token,progress);
+ }
+
+ public async Task UploadThumbnailAsync(string user, string movie, Stream src,CancellationToken token=default,IProgress progress=null)
+ {
+ string url = $"{client.rooturl}/api/v1/MovieFile?user={user}&movie={movie}&type=thumbnail";
+
+ return await client.UploadFilePutAsync(url,src,token,progress);
+ }
+
+ public async Task CreateExtraDirectoryAsync(string user,string movie, string extraDir,CancellationToken token=default)
+ {
+ string parent="";
+ string name=Path.GetFileName(extraDir);
+ try{
+ parent=Path.GetDirectoryName(extraDir.TrimStart('/'));
+ }
+ catch(Exception)
+ {
+ parent="";
+ }
+
+ Dictionary kvp= new Dictionary
+ {
+ { "name", name },
+ { "parent", parent },
+
+ };
+
+
+ using(var res=await client.client.PostAsync($"{client.rooturl}/user/{user}/movie/{movie}/mkdir",new FormUrlEncodedContent(kvp),token))
+ {
+
+ return res.StatusCode != System.Net.HttpStatusCode.Unauthorized;
+ }
+ }
+
public async Task DownloadMovieAsync(string user,string movie,string dest,CancellationToken token=default,IProgress progress=null)
{
- await client.DownloadFileAsync($"{client.rooturl.TrimEnd('/')}/content/{user}/movie/{movie}/{movie}.mp4",dest,token,progress);
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/{movie}.mp4",dest,token,progress);
}
public async Task DownloadMovieAsync(string user,string movie,Stream dest,CancellationToken token=default,IProgress progress=null)
{
- await client.DownloadFileAsync($"{client.rooturl.TrimEnd('/')}/content/{user}/movie/{movie}/{movie}.mp4",dest,token,progress);
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/{movie}.mp4",dest,token,progress);
+ }
+
+
+ public async Task DownloadThumbnailAsync(string user,string movie,string dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/thumbnail.jpg",dest,token,progress);
+ }
+ public async Task DownloadThumbnailAsync(string user,string movie,Stream dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/thumbnail.jpg",dest,token,progress);
+ }
+
+ public async Task DownloadPosterAsync(string user,string movie,string dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/poster.jpg",dest,token,progress);
+ }
+ public async Task DownloadPosterAsync(string user,string movie,Stream dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/poster.jpg",dest,token,progress);
+ }
+ public async Task DownloadExtraAsync(string user,string movie,string path,string dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/extras/{HttpUtility.UrlPathEncode(path.TrimStart('/'))}",dest,token,progress);
+ }
+ public async Task DownloadExtraAsync(string user,string movie,string path,Stream dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/extras/{HttpUtility.UrlPathEncode(path.TrimStart('/'))}",dest,token,progress);
+ }
+
+ public async Task DownloadTorrentAsync(string user,string movie,string dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/{movie}.torrent",dest,token,progress);
+ }
+ public async Task DownloadTorrentAsync(string user,string movie,Stream dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/{movie}.torrent",dest,token,progress);
+ }
+
+
+ public async Task DownloadTorrentWithExtrasAsync(string user,string movie,string dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/{movie}_withextras.torrent",dest,token,progress);
+ }
+ public async Task DownloadTorrentWithExtrasAsync(string user,string movie,Stream dest,CancellationToken token=default,IProgress progress=null)
+ {
+ await client.DownloadFileAsync($"{client.rooturl}/content/{user}/movie/{movie}/{movie}_withextras.torrent",dest,token,progress);
+ }
+
+ public async Task DownloadExtrasAsync(string user,string movie, string dir, CancellationToken token=default,IProgress progress=null)
+ {
+ var r = await GetMovieContentMetadataAsync(user,movie);
+ List<(string inDir,string outDir)> items=new List<(string inDir, string outDir)>();
+ void DownloadExtraDir(List files,string outDir,string inDir)
+ {
+ Directory.CreateDirectory(outDir);
+ foreach(var item in files)
+ {
+ if(item.IsDir)
+ {
+ DownloadExtraDir(item.Items,Path.Combine(outDir,item.Name),$"{inDir}/{item.Name}");
+ }
+ else
+ {
+ items.Add(($"{inDir}/{item.Name}",Path.Combine(outDir,item.Name)));
+ }
+ }
+ }
+
+
+
+ DownloadExtraDir(r.ExtraStreams,dir,"");
+ for(int i = 0;i(e=>{
+ double j = i + e;
+ progress?.Report(j / items.Count);
+ }));
+
+ }
+ }
+
+
+ public async Task CreateAsync(string urlname, string propername, string description)
+ {
+ return await client.CreateAsync(urlname,propername,description);
}
}
}
+
diff --git a/Tesses.CMS.Client/Episode.cs b/Tesses.CMS.Client/Episode.cs
new file mode 100644
index 0000000..f11e54a
--- /dev/null
+++ b/Tesses.CMS.Client/Episode.cs
@@ -0,0 +1,25 @@
+using System;
+using Newtonsoft.Json;
+
+namespace Tesses.CMS.Client
+{
+ public class Episode
+ {
+ [JsonProperty("proper_name")]
+ public string ProperName {get;set;}="";
+ [JsonProperty("name")]
+ public string Name {get;set;}="";
+ [JsonProperty("season_number")]
+ public int SeasonNumber {get;set;}
+ [JsonProperty("episode_number")]
+ public int EpisodeNumber {get;set;}
+ [JsonProperty("episode_name")]
+ public string EpisodeName {get;set;}="";
+ [JsonProperty("creation_time")]
+ public DateTime CreationTime {get;set;}
+ [JsonProperty("last_updated_time")]
+ public DateTime LastUpdated {get;set;}
+ [JsonProperty("description")]
+ public string Description {get;set;}="";
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Client/Movie.cs b/Tesses.CMS.Client/Movie.cs
index a9d8e1e..f3d94f4 100644
--- a/Tesses.CMS.Client/Movie.cs
+++ b/Tesses.CMS.Client/Movie.cs
@@ -6,20 +6,33 @@ namespace Tesses.CMS.Client
{
public class MovieContentMetaData
{
+ [JsonProperty("has_movie_torrent")]
+ public bool HasMovieTorrent{get;set;}
[JsonProperty("movie_torrent_url")]
public string MovieTorrentUrl {get;set;}
-
+ [JsonProperty("has_movie_with_extras_torrent")]
+ public bool HasMovieWithExtrasTorrent{get;set;}
[JsonProperty("movie_with_extras_torrent_url")]
public string MovieWithExtrasTorrentUrl {get;set;}
+ [JsonProperty("has_browser_stream")]
+ public bool HasBrowserStream {get;set;}
+ [JsonProperty("has_download_stream")]
+ public bool HasDownloadStream {get;set;}
[JsonProperty("browser_stream")]
public string BrowserStream {get;set;}
+
[JsonProperty("download_stream")]
public string DownloadStream {get;set;}
+ [JsonProperty("has_poster")]
+ public bool HasPoster {get;set;}
[JsonProperty("poster_url")]
public string PosterUrl {get;set;}
+
+ [JsonProperty("has_thumbnail")]
+ public bool HasThumbnail {get;set;}
[JsonProperty("thumbnail_url")]
@@ -56,14 +69,15 @@ namespace Tesses.CMS.Client
public class Movie
{
+ [JsonProperty("proper_name")]
public string ProperName {get;set;}
-
+ [JsonProperty("name")]
public string Name {get;set;}
-
+ [JsonProperty("creation_time")]
public DateTime CreationTime {get;set;}
-
+ [JsonProperty("last_updated_time")]
public DateTime LastUpdated {get;set;}
-
+ [JsonProperty("description")]
public string Description {get;set;}
}
}
\ No newline at end of file
diff --git a/Tesses.CMS.Client/SSEClient.cs b/Tesses.CMS.Client/SSEClient.cs
new file mode 100644
index 0000000..e6432cb
--- /dev/null
+++ b/Tesses.CMS.Client/SSEClient.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+namespace Tesses.CMS.Client
+{
+ public static class SSEExtensions
+{
+ public static async Task GetSSEClientAsync(this HttpClient clt,string url)
+ {
+ var strm=await clt.GetStreamAsync(url);
+ return new SSEClient(strm);
+ }
+}
+
+public class SSEEvent
+{
+ public SSEEvent(string data)
+ {
+ Data=data;
+ }
+ public string Data {get;set;}
+ public T ParseJson()
+ {
+ return JsonConvert.DeserializeObject(Data);
+ }
+}
+public class SSEClient
+{
+ Stream strm;
+
+ public SSEClient(Stream strm)
+ {
+ this.strm=strm;
+ }
+
+
+ public async IAsyncEnumerable ReadEventsAsync([EnumeratorCancellation]CancellationToken token=default(CancellationToken))
+ {
+
+ using(var sr = new StreamReader(strm)){
+ token.Register(()=>{
+ sr.Dispose();
+ });
+
+ while(!token.IsCancellationRequested)
+ {
+
+ string text="";
+ try{
+ text=await sr.ReadLineAsync();
+ }catch(Exception ex)
+ {
+ _=ex;
+ }
+ if(!string.IsNullOrWhiteSpace(text))
+ yield return new SSEEvent(text.Substring(6));
+ }
+ }
+
+ }
+
+}
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Client/Season.cs b/Tesses.CMS.Client/Season.cs
new file mode 100644
index 0000000..bbee93b
--- /dev/null
+++ b/Tesses.CMS.Client/Season.cs
@@ -0,0 +1,22 @@
+using System;
+using Newtonsoft.Json;
+
+namespace Tesses.CMS.Client
+{
+ public class Season
+ {
+ [JsonProperty("proper_name")]
+ public string ProperName {get;set;}="";
+ [JsonProperty("name")]
+ public string Name {get;set;}="";
+ [JsonProperty("season_number")]
+ public int SeasonNumber {get;set;}
+
+ [JsonProperty("creation_time")]
+ public DateTime CreationTime {get;set;}
+ [JsonProperty("last_updated_time")]
+ public DateTime LastUpdated {get;set;}
+ [JsonProperty("description")]
+ public string Description {get;set;}="";
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Client/SeasonWithEpisodes.cs b/Tesses.CMS.Client/SeasonWithEpisodes.cs
new file mode 100644
index 0000000..ca683fb
--- /dev/null
+++ b/Tesses.CMS.Client/SeasonWithEpisodes.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Generic;
+
+namespace Tesses.CMS.Client
+{
+ public class SeasonWithEpisodes
+ {
+ public SeasonWithEpisodes(Season season,List episodes)
+ {
+ ProperName = season.ProperName;
+ Name = season.Name;
+ SeasonNumber = season.SeasonNumber;
+ CreationTime = season.CreationTime;
+ LastUpdated = season.LastUpdated;
+ Description = season.Description;
+ Episodes = episodes;
+ }
+ public string ProperName {get;set;}="";
+
+ public string Name {get;set;}="";
+ public int SeasonNumber {get;set;}
+
+
+ public DateTime CreationTime {get;set;}
+ public DateTime LastUpdated {get;set;}
+
+ public string Description {get;set;}="";
+
+ public List Episodes {get;set;}
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Client/Show.cs b/Tesses.CMS.Client/Show.cs
new file mode 100644
index 0000000..d4a7a50
--- /dev/null
+++ b/Tesses.CMS.Client/Show.cs
@@ -0,0 +1,20 @@
+using System;
+using Newtonsoft.Json;
+
+namespace Tesses.CMS.Client
+{
+ public class Show
+ {
+ [JsonProperty("proper_name")]
+ public string ProperName {get;set;}="";
+ [JsonProperty("name")]
+ public string Name {get;set;}="";
+
+ [JsonProperty("creation_time")]
+ public DateTime CreationTime {get;set;}
+ [JsonProperty("last_updated_time")]
+ public DateTime LastUpdated {get;set;}
+ [JsonProperty("description")]
+ public string Description {get;set;}="";
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Client/ShowWithSeasonsAndEpisodes.cs b/Tesses.CMS.Client/ShowWithSeasonsAndEpisodes.cs
new file mode 100644
index 0000000..b87d5a7
--- /dev/null
+++ b/Tesses.CMS.Client/ShowWithSeasonsAndEpisodes.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+
+namespace Tesses.CMS.Client
+{
+ public class ShowWithSeasonsAndEpisodes
+ {
+ public ShowWithSeasonsAndEpisodes(Show show,List seasons)
+ {
+ ProperName = show.ProperName;
+ Name = show.Name;
+ CreationTime = show.CreationTime;
+ LastUpdated = show.LastUpdated;
+ Description = show.Description;
+ Seasons = seasons;
+ }
+
+ public string ProperName {get;set;}="";
+
+ public string Name {get;set;}="";
+
+
+ public DateTime CreationTime {get;set;}
+ public DateTime LastUpdated {get;set;}
+
+ public string Description {get;set;}="";
+ public List Seasons {get;set;}
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Client/UserAccount.cs b/Tesses.CMS.Client/UserAccount.cs
new file mode 100644
index 0000000..1b77896
--- /dev/null
+++ b/Tesses.CMS.Client/UserAccount.cs
@@ -0,0 +1,14 @@
+using Newtonsoft.Json;
+
+namespace Tesses.CMS.Client
+{
+ public class UserAccount
+ {
+ [JsonProperty("username")]
+ public string Username {get;set;}="";
+ [JsonProperty("proper_name")]
+ public string ProperName {get;set;}="";
+ [JsonProperty("about_me")]
+ public string AboutMe {get;set;}="";
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS.Providers.Dapper/Class1.cs b/Tesses.CMS.Providers.Dapper/Class1.cs
index 24883ca..e5b76cd 100644
--- a/Tesses.CMS.Providers.Dapper/Class1.cs
+++ b/Tesses.CMS.Providers.Dapper/Class1.cs
@@ -46,6 +46,11 @@ namespace Tesses.CMS.Providers
return false;
}
+ public Episode CreateEpisode(string user, string show, int season, int episode, string episodename, string properName, string description)
+ {
+ throw new NotImplementedException();
+ }
+
public Movie CreateMovie(string user, string movie, string properName, string description)
{
var userId=GetUserAccount(user).Id;
@@ -59,11 +64,21 @@ namespace Tesses.CMS.Providers
return GetMovie(user,movie);
}
+ public Season CreateSeason(string user, string show, int season, string properName, string description)
+ {
+ throw new NotImplementedException();
+ }
+
public void CreateSession(string session, long account)
{
con.Execute("INSERT INTO Sessions(Session,Account) values (@session,@account);",new{session,account});
}
+ public Show CreateShow(string user, string show, string properName, string description)
+ {
+ throw new NotImplementedException();
+ }
+
public void CreateUser(CMSConfiguration configuration, string user, string properName, string email, string password)
{
bool first=con.QueryFirstOrDefault("SELECT * FROM Users LIMIT 0, 1;") == null;
@@ -99,6 +114,16 @@ namespace Tesses.CMS.Providers
con.Execute("DELETE FROM VerificationCodes WHERE Session = @code;",new{code});
}
+ public int EpisodeCount(string user, string show, int season)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Episode GetEpisode(string user, string show, int season, int episode)
+ {
+ throw new NotImplementedException();
+ }
+
public UserAccount GetFirstUser()
{
return con.QueryFirstOrDefault("SELECT * FROM Users LIMIT 0, 1;")?.Account;
@@ -131,11 +156,26 @@ namespace Tesses.CMS.Providers
}
}
+ public Season GetSeason(string user, string show, int season)
+ {
+ throw new NotImplementedException();
+ }
+
public long? GetSession(string session)
{
return con.QueryFirstOrDefault("SELECT * FROM Sessions WHERE Session = @session;",new{session})?.Id;
}
+ public Show GetShow(string user, string show)
+ {
+ throw new NotImplementedException();
+ }
+
+ public IEnumerable GetShows(string user)
+ {
+ throw new NotImplementedException();
+ }
+
public UserAccount GetUserAccount(string user)
{
return con.QueryFirstOrDefault("SELECT * FROM Users WHERE Username=@user;",new{user})?.Account;
@@ -170,12 +210,32 @@ namespace Tesses.CMS.Providers
return null;
}
+ public int SeasonCount(string user, string show)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void UpdateEpisode(Episode episode)
+ {
+ throw new NotImplementedException();
+ }
+
public void UpdateMovie(Movie movie)
{
DapperMovie dapperMovie=new DapperMovie(movie);
con.Execute("UPDATE Users set movie = @movie WHERE Id = @id;",new{movie=dapperMovie,id=movie.Id});
}
+ public void UpdateSeason(Season season)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void UpdateShow(Show show)
+ {
+ throw new NotImplementedException();
+ }
+
public void UpdateUser(UserAccount account)
{
DapperUserAccount account1=new DapperUserAccount(account);
diff --git a/Tesses.CMS.Providers.LiteDb/Class1.cs b/Tesses.CMS.Providers.LiteDb/Class1.cs
index 96bc27e..aa54f97 100644
--- a/Tesses.CMS.Providers.LiteDb/Class1.cs
+++ b/Tesses.CMS.Providers.LiteDb/Class1.cs
@@ -18,17 +18,13 @@ public class LiteDBContentProvider : IContentProvider
{
var userId=GetUserAccount(user).Id;
- Movie _movie = new Movie(){UserId = userId,Name = movie,ProperName=properName,Description = description};
- _movie.Id=Movies.Insert(_movie);
- return _movie;
+ return CreateMovie(userId,movie,properName,description);
}
public Show CreateShow(string user, string show, string properName, string description)
{
var userId=GetUserAccount(user).Id;
- Show _show = new Show(){UserId = userId,Name = show,ProperName=properName,Description = description};
- _show.Id=Shows.Insert(_show);
- return _show;
+ return CreateShow(userId,show,properName,description);
}
private ILiteCollection UserAccounts => db.GetCollection("users");
private ILiteCollection Movies => db.GetCollection("movies");
@@ -39,7 +35,7 @@ public class LiteDBContentProvider : IContentProvider
private ILiteCollection Sessions => db.GetCollection("sessions");
private ILiteCollection VerificationCodes => db.GetCollection("verificationcodes");
-
+ private ILiteCollection Albums => db.GetCollection("albums");
public UserAccount GetFirstUser()
{
return GetUsers().First();
@@ -105,10 +101,12 @@ public class LiteDBContentProvider : IContentProvider
public void UpdateMovie(Movie movie)
{
+ movie.LastUpdated = DateTime.Now;
Movies.Update(movie);
}
public void UpdateShow(Show show)
{
+ show.LastUpdated = DateTime.Now;
Shows.Update(show);
}
@@ -210,13 +208,7 @@ public class LiteDBContentProvider : IContentProvider
var myShow = GetShow(user,show);
var userId = myShow.UserId;
var showId = myShow.Id;
- int seasonLargest=0;
- foreach(var item in Seasons.Find(e=>e.ShowId==showId && e.UserId == userId))
- {
- if(item.SeasonNumber > seasonLargest)
- seasonLargest = item.SeasonNumber;
- }
- return seasonLargest;
+ return SeasonCount(userId,showId);
}
public Season GetSeason(string user, string show, int season)
@@ -224,7 +216,7 @@ public class LiteDBContentProvider : IContentProvider
var myShow = GetShow(user,show);
var userId = myShow.UserId;
var showId = myShow.Id;
- return Seasons.FindOne(e=>e.ShowId == showId && e.UserId == userId && e.SeasonNumber == season);
+ return GetSeason(userId,showId,season);
}
public Season CreateSeason(string user, string show, int season, string properName, string description)
@@ -232,9 +224,7 @@ public class LiteDBContentProvider : IContentProvider
var myShow = GetShow(user,show);
var userId = myShow.UserId;
var showId = myShow.Id;
- Season _season = new Season(){UserId = userId,ShowId = showId,ProperName=properName,Description = description, SeasonNumber=season};
- _season.Id=Seasons.Insert(_season);
- return _season;
+ return CreateSeason(userId,showId,season,properName,description);
}
public int EpisodeCount(string user, string show, int season)
@@ -242,22 +232,16 @@ public class LiteDBContentProvider : IContentProvider
var myShow = GetShow(user,show);
var userId = myShow.UserId;
var showId = myShow.Id;
- int episodeLargest=0;
- foreach(var item in Episodes.Find(e=>e.ShowId==showId && e.UserId == userId && e.SeasonNumber == season))
- {
- if(item.EpisodeNumber > episodeLargest)
- episodeLargest = item.EpisodeNumber;
- }
- return episodeLargest;
+ return EpisodeCount(userId,showId,season);
}
public Episode GetEpisode(string user, string show, int season, int episode)
{
var myShow = GetShow(user,show);
+
var userId = myShow.UserId;
var showId = myShow.Id;
- return Episodes.FindOne(e=>e.ShowId == showId && e.UserId == userId && e.SeasonNumber == season && e.EpisodeNumber == episode);
-
+ return GetEpisode(userId,showId,season,episode);
}
public Episode CreateEpisode(string user, string show, int season, int episode, string episodename, string properName, string description)
@@ -265,19 +249,136 @@ public class LiteDBContentProvider : IContentProvider
var myShow = GetShow(user,show);
var userId = myShow.UserId;
var showId = myShow.Id;
- Episode _episode = new Episode(){UserId = userId,ShowId = showId,ProperName=properName,Description = description, SeasonNumber=season, EpisodeNumber = episode,EpisodeName=episodename};
- _episode.Id=Episodes.Insert(_episode);
- return _episode;
+ return CreateEpisode(userId,showId,season,episode,episodename,properName,description);
}
public void UpdateEpisode(Episode episode)
{
+ episode.LastUpdated = DateTime.Now;
Episodes.Update(episode);
}
public void UpdateSeason(Season season)
{
+ season.LastUpdated = DateTime.Now;
Seasons.Update(season);
}
+
+ public Album CreateAlbum(string user, string album, string properName, string description)
+ {
+ var userId=GetUserAccount(user).Id;
+
+ return CreateAlbum(userId,album,properName,description);
+ }
+
+ public Album GetAlbum(string user, string album)
+ {
+ var userId=GetUserAccount(user).Id;
+ return GetAlbum(userId,album);
+ }
+
+ public void UpdateAlbum(Album album)
+ {
+ album.LastUpdated = DateTime.Now;
+ Albums.Update(album);
+ }
+
+ public IEnumerable GetAlbums(string user)
+ {
+ return GetAlbums(GetUserAccount(user).Id);
+ }
+
+ public IEnumerable GetAlbums(long user)
+ {
+ return Albums.Find(e=>e.UserId == user);
+ }
+
+ public Movie CreateMovie(long user, string movie, string properName, string description)
+ {
+ Movie _movie = new Movie(){UserId = user,Name = movie,ProperName=properName,Description = description};
+ _movie.CreationTime = DateTime.Now;
+ _movie.LastUpdated = DateTime.Now;
+ _movie.Id=Movies.Insert(_movie);
+ return _movie;
+ }
+
+ public Album CreateAlbum(long user, string album, string properName, string description)
+ {
+ Album _album = new Album(){UserId = user,Name = album,ProperName=properName,Description = description};
+ _album.CreationTime = DateTime.Now;
+ _album.LastUpdated = DateTime.Now;
+ _album.Id=Albums.Insert(_album);
+ return _album;
+ }
+
+ public Show CreateShow(long user, string show, string properName, string description)
+ {
+ Show _show = new Show(){UserId = user,Name = show,ProperName=properName,Description = description};
+ _show.CreationTime = DateTime.Now;
+ _show.LastUpdated = DateTime.Now;
+ _show.Id=Shows.Insert(_show);
+ return _show;
+ }
+
+ public Show GetShow(long user, long show)
+ {
+ return Shows.FindOne(e=>e.Id == show && e.UserId == user);
+ }
+
+ public int SeasonCount(long user, long show)
+ {
+ int seasonLargest=0;
+ foreach(var item in Seasons.Find(e=>e.ShowId==show && e.UserId == user))
+ {
+ if(item.SeasonNumber > seasonLargest)
+ seasonLargest = item.SeasonNumber;
+ }
+ return seasonLargest;
+ }
+
+ public Season GetSeason(long user, long show, int season)
+ {
+ return Seasons.FindOne(e=>e.ShowId == show && e.UserId == user && e.SeasonNumber == season);
+ }
+
+ public Season CreateSeason(long user, long show, int season, string properName, string description)
+ {
+ Season _season = new Season(){UserId = user,ShowId = show,ProperName=properName,Description = description, SeasonNumber=season};
+ _season.CreationTime = DateTime.Now;
+ _season.LastUpdated = DateTime.Now;
+ _season.Id=Seasons.Insert(_season);
+ return _season;
+ }
+
+ public int EpisodeCount(long user, long show, int season)
+ {
+ int episodeLargest=0;
+ foreach(var item in Episodes.Find(e=>e.ShowId==show && e.UserId == user && e.SeasonNumber == season))
+ {
+ if(item.EpisodeNumber > episodeLargest)
+ episodeLargest = item.EpisodeNumber;
+ }
+ return episodeLargest;
+ }
+
+ public Episode GetEpisode(long user, long show, int season, int episode)
+ {
+ return Episodes.FindOne(e=>e.ShowId == show && e.UserId == user && e.SeasonNumber == season && e.EpisodeNumber == episode);
+
+ }
+
+ public Episode CreateEpisode(long user, long show, int season, int episode, string episodename, string properName, string description)
+ {
+ Episode _episode = new Episode(){UserId = user,ShowId = show,ProperName=properName,Description = description, SeasonNumber=season, EpisodeNumber = episode,EpisodeName=episodename};
+ _episode.CreationTime = DateTime.Now;
+ _episode.LastUpdated = DateTime.Now;
+ _episode.Id=Episodes.Insert(_episode);
+ return _episode;
+ }
+
+ public Album GetAlbum(long user, string album)
+ {
+ return Albums.FindOne(e=>e.Name == album && e.UserId == user);
+ }
}
}
diff --git a/Tesses.CMS.Server/Tesses.CMS.Server.csproj b/Tesses.CMS.Server/Tesses.CMS.Server.csproj
index 40a5032..2d6cffc 100644
--- a/Tesses.CMS.Server/Tesses.CMS.Server.csproj
+++ b/Tesses.CMS.Server/Tesses.CMS.Server.csproj
@@ -2,7 +2,7 @@
Exe
- net7.0
+ net8.0enableenable
diff --git a/Tesses.CMS/Album.cs b/Tesses.CMS/Album.cs
new file mode 100644
index 0000000..8f493fb
--- /dev/null
+++ b/Tesses.CMS/Album.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace Tesses.CMS
+{
+ public class Album
+ {
+ [JsonIgnore]
+ public long Id {get;set;}
+ [JsonProperty("proper_name")]
+
+ public string ProperName {get;set;}
+ [JsonProperty("name")]
+ public string Name {get;set;}
+ [JsonProperty("album_artist")]
+ public string AlbumArtist {get;set;} = "Unknown Artist";
+ [JsonProperty("tracks")]
+ public List Tracks {get;set;}=new List();
+ [JsonProperty("year")]
+ public int Year {get;set;}=DateTime.Now.Year;
+
+ [JsonIgnore]
+ public long UserId {get;set;}
+ [JsonProperty("creation_time")]
+ public DateTime CreationTime {get;set;}
+ [JsonProperty("last_updated_time")]
+ public DateTime LastUpdated {get;set;}
+ [JsonProperty("description")]
+ public string Description {get;set;}
+
+ public object Scriban(string thumbnail)
+ {
+ return new {
+ Proper = System.Web.HttpUtility.HtmlEncode( ProperName),
+ Name = System.Web.HttpUtility.HtmlEncode(Name),
+ Description = System.Web.HttpUtility.HtmlEncode(Description),
+ Thumbnail = thumbnail
+ };
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/Tesses.CMS/AssetProvier.cs b/Tesses.CMS/AssetProvier.cs
index 2021d7b..dd4d778 100644
--- a/Tesses.CMS/AssetProvier.cs
+++ b/Tesses.CMS/AssetProvier.cs
@@ -25,7 +25,7 @@ namespace Tesses.CMS
public override async Task GetAsync(ServerContext ctx)
{
try{
- await ctx.SendTextAsync(await ReadAllTextAsync(ctx.UrlPath),HeyRed.Mime.MimeTypesMap.GetMimeType(ctx.UrlPath));
+ await ctx.SendStreamAsync(OpenRead(ctx.UrlPath),HeyRed.Mime.MimeTypesMap.GetMimeType(ctx.UrlPath));
}catch(ArgumentNullException ex)
{
_=ex;
diff --git a/Tesses.CMS/Assets/AddMovie.html b/Tesses.CMS/Assets/AddMovie.html
deleted file mode 100644
index e69de29..0000000
diff --git a/Tesses.CMS/Assets/AlbumPage.html b/Tesses.CMS/Assets/AlbumPage.html
new file mode 100644
index 0000000..f362af1
--- /dev/null
+++ b/Tesses.CMS/Assets/AlbumPage.html
@@ -0,0 +1,50 @@
+
+
+
+
{{albumproper}}
+
{{userproper}}
+
+
+ Listen Online
+ {{if editable}}
+ Edit
+ {{end}}
+ {{if extrasexists}}
+ Extras
+ {{end}}
+ {{if torrentexists}}
+ Torrent
+ {{end}}
+ {{if torrentwextraexists}}
+ Torrent With Extras
+ {{end}}
+ {{if editable}}
+
+ {{end}}
+
+ Note to Touchscreen users: Touch and hold seekbar to set position in song
+
+
+
+
+
+
+
+
+
+
+
{{albumdescription}}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Tesses.CMS/Assets/AlbumsPage.html b/Tesses.CMS/Assets/AlbumsPage.html
new file mode 100644
index 0000000..a1a0a77
--- /dev/null
+++ b/Tesses.CMS/Assets/AlbumsPage.html
@@ -0,0 +1,24 @@
+
\ No newline at end of file
diff --git a/Tesses.CMS/Assets/Devcenter.html b/Tesses.CMS/Assets/Devcenter.html
index 65cccc3..cb4fd06 100644
--- a/Tesses.CMS/Assets/Devcenter.html
+++ b/Tesses.CMS/Assets/Devcenter.html
@@ -3,6 +3,5 @@
\ No newline at end of file
diff --git a/Tesses.CMS/Assets/EditAlbumDetails.html b/Tesses.CMS/Assets/EditAlbumDetails.html
new file mode 100644
index 0000000..8bfee6b
--- /dev/null
+++ b/Tesses.CMS/Assets/EditAlbumDetails.html
@@ -0,0 +1,35 @@
+
Change album metadata
+
+
Upload album art (uses JPEG)
+
+
+
+View/Edit extras
+Tracklist
\ No newline at end of file
diff --git a/Tesses.CMS/Assets/EditEpisodeDetails.html b/Tesses.CMS/Assets/EditEpisodeDetails.html
new file mode 100644
index 0000000..6f6d17f
--- /dev/null
+++ b/Tesses.CMS/Assets/EditEpisodeDetails.html
@@ -0,0 +1,28 @@
+