Browse Source

Initial commit

coder 3 years ago
commit
90b1866ba2

+ 329 - 0
.gitignore

@@ -0,0 +1,329 @@
+# ---> VisualStudio
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+
+# User-specific files
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+build/
+bld/
+[Bb]in/
+[Oo]bj/
+
+# Visual Studio 2015 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUNIT
+*.VisualState.xml
+TestResult.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# DNX
+project.lock.json
+artifacts/
+
+*_i.c
+*_p.c
+*_i.h
+*.ilk
+*.meta
+*.obj
+*.pch
+*.pdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opensdf
+*.sdf
+*.cachefile
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# JustCode is a .NET coding add-in
+.JustCode
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# 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
+# TODO: 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
+
+# NuGet Packages
+*.nupkg
+# The packages folder can be ignored because of Package Restore
+**/packages/*
+# except build/, which is used as an MSBuild target.
+!**/packages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/packages/repositories.config
+
+# Windows Azure Build Output
+csx/
+*.build.csdef
+
+# Windows Store app package directory
+AppPackages/
+
+# 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/
+[Ss]tyle[Cc]op.*
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.pfx
+*.publishsettings
+node_modules/
+orleans.codegen.cs
+
+# 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
+
+# SQL Server files
+*.mdf
+*.ldf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# ---> C Sharp
+# Build Folders (you can keep bin if you'd like, to store dlls and pdbs)
+[Bb]in/
+[Oo]bj/
+
+# mstest test results
+TestResults
+
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+
+# User-specific files
+*.suo
+*.user
+*.sln.docstates
+
+# Build results
+[Dd]ebug/
+[Rr]elease/
+x64/
+*_i.c
+*_p.c
+*.ilk
+*.meta
+*.obj
+*.pch
+*.pdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.log
+*.vspscc
+*.vssscc
+.builds
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opensdf
+*.sdf
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*
+
+# NCrunch
+*.ncrunch*
+.*crunch*.local.xml
+
+# 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
+*.Publish.xml
+
+# NuGet Packages Directory
+packages
+
+# Windows Azure Build Output
+csx
+*.build.csdef
+
+# Windows Store app package directory
+AppPackages/
+
+# Others
+[Bb]in
+[Oo]bj
+sql
+TestResults
+[Tt]est[Rr]esult*
+*.Cache
+ClientBin
+[Ss]tyle[Cc]op.*
+~$*
+*.dbmdl
+Generated_Code #added for RIA/Silverlight projects
+
+# 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
+

+ 13 - 0
BanTur.Core/BanTur.Core.csproj

@@ -0,0 +1,13 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netcoreapp3.1</TargetFramework>
+    <OutputType>Exe</OutputType>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="CodeHollow.FeedReader" Version="1.2.1" />
+    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.3" />
+  </ItemGroup>
+
+</Project>

+ 180 - 0
BanTur.Core/BanTurProgram.cs

@@ -0,0 +1,180 @@
+using BanTur.Core.Entity;
+using BanTur.Core.Rss;
+using BanTur.Core.Utility;
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BanTur.Core
+{
+    internal static class BanTurProgram
+    {
+        internal const string CmdComplete = "complete";
+
+        private static void PrintLine(string line)
+        {
+            Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.ff} {line}");
+        }
+
+        // Fetch
+        //    Fetch all feed source, add news
+        // HandleNew
+        //    Get All New Entry,Generate notice executable AND start download
+        // Complete <id>
+        //    Call by download complete executable, Update entry state and Send email, delete notice executable
+        private static void Main(string[] args)
+        {
+            switch (args.FirstOrDefault()?.ToLower())
+            {
+                case "daemon":
+                    if (args.Length < 2 || !int.TryParse(args[1], out var intervalMin)) throw new ArgumentException("Missing interval(Min)");
+                    Daemon(intervalMin);
+                    break;
+
+                default:
+                case "fetch":
+                    Fetch();
+                    break;
+
+                case CmdComplete:
+                    if (args.Length < 2 || !int.TryParse(args[1], out var id)) throw new ArgumentException("Missing id");
+                    if (args.Length < 3) throw new ArgumentException("Missing FilePath");
+
+                    Complete(id, args[2]);
+                    break;
+            }
+        }
+
+        private static void Daemon(int intervalMin)
+        {
+            PrintLine($"Run Bangumi Turret as Daemon, Interval {intervalMin} min. To exit Press Enter");
+
+            var intervalSeconds = intervalMin * 60;
+            var isRunning = true;
+
+            void Loop()
+            {
+                // ReSharper disable once AccessToModifiedClosure
+                while (isRunning)
+                {
+                    Fetch();
+                    for (int i = 0; i < intervalSeconds; i++)
+                    {
+                        // ReSharper disable once AccessToModifiedClosure
+                        if (false == isRunning) break;
+
+                        Console.Write($"Sleeping... {i}/{intervalSeconds}");
+                        Thread.Sleep(1000);
+
+                        //clear line and reset cursor
+                        var left = Console.CursorLeft;
+                        Console.CursorLeft = 0;
+                        Console.Write("".PadLeft(left));
+                        Console.CursorLeft = 0;
+                    }
+                }
+            }
+
+            var task = Task.Run(Loop);
+
+            Console.ReadLine();
+            isRunning = false;
+
+            Console.WriteLine("Stopping...");
+            task.Wait();
+            Console.WriteLine("Bye.");
+        }
+
+        private static void Fetch()
+        {
+            //Call by scheduler
+            var sources = DbAccess.GetFeedSources();
+            if (sources.Length == 0)
+            {
+                PrintLine("[Fetch] Nothing to do, Please add source entry into database");
+                return;
+            }
+
+            var totalNewItemCount = 0;
+            foreach (var feed in sources)
+            {
+                PrintLine($"[Fetch] Download rss of 「{feed.Name}」...");
+                try
+                {
+                    var xml = HttpAccess.Get(feed.Url);
+                    var items = RssParser.Parse(xml);
+
+                    var newItemCount = 0;
+                    foreach (var item in items)
+                    {
+                        if (DbAccess.CheckExist(item)) continue;
+                        item.Status = item.PubDate > feed.CreationTime
+                            ? BangumiEntryStatus.New
+                            : BangumiEntryStatus.Skipped;
+                        DbAccess.AddEntry(item);
+                        ++newItemCount;
+                    }
+
+                    totalNewItemCount += newItemCount;
+
+                    PrintLine(newItemCount > 0
+                        ? $"[Fetch] Got {newItemCount} new item(s)."
+                        : $"[Fetch] No new items of 「{feed.Name}」.");
+                }
+                catch (Exception e)
+                {
+                    Console.WriteLine(e);
+                }
+            }
+
+            if (totalNewItemCount > 0) HandleNew();
+        }
+
+        private static void HandleNew()
+        {
+            //Call by scheduler
+            PrintLine("[HandleNew] Loading new entries...");
+            var entries = DbAccess.GetNewEntries();
+
+            if (entries.Length == 0)
+            {
+                PrintLine("[HandleNew] Nothing to do.");
+                return;
+            }
+
+            PrintLine($"[HandleNew] Get {entries.Length} items to handle");
+
+            foreach (var item in entries)
+            {
+                PrintLine($"[HandleNew] Starting download 「{item.Title}」");
+
+                //Prep executable for complete notice
+                var onBtDownloadComplete = Aria2Helper.CreateNoticeFile(item.Id);
+                var logFilePath = Aria2Helper.GetLogFilePath(item.Id);
+                //start download with notice executable
+                Aria2Helper.StartBitTorrentDownload(item.Magnet, DbAccess.Config.DownloadDirectory, onBtDownloadComplete, logFilePath);
+                //set status to downloading
+                DbAccess.UpdateStatus(item.Id, BangumiEntryStatus.Downloading);
+                //send email tell download started
+                if (DbAccess.Config.EnableMail) new MailSender().SendMail($"Download Start #{item.Id}", $"Id: {item.Id}{Environment.NewLine}Title: {item.Title}");
+            }
+
+            PrintLine("[HandleNew] Finished");
+        }
+
+        private static void Complete(int id, string filePath)
+        {
+            //Call by download completed notice executable
+            var ce = DbAccess.GetEntry(id);
+            if (ce == null) throw new ArgumentException("Entry not found", nameof(id));
+            //Update entry state and Send email
+            if (DbAccess.UpdateStatus(id, BangumiEntryStatus.DownloadComplete, filePath))
+            {
+                if (DbAccess.Config.EnableMail) new MailSender().SendMail($"Download Completed #{id}", $"Id: {id}{Environment.NewLine}Title: {ce.Title}{Environment.NewLine}Saved to {filePath}");
+                //delete notice executable
+                Aria2Helper.DeleteNoticeFile(id);
+            }
+        }
+    }
+}

+ 28 - 0
BanTur.Core/Entity/BangumiEntry.cs

@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore;
+using System;
+
+namespace BanTur.Core.Entity
+{
+    [Index(nameof(Magnet))]
+    public class BangumiEntry
+    {
+        public int Id { get; set; }
+        public string Magnet { get; set; }
+        public string Url { get; set; }
+        public string Title { get; set; }
+
+        public DateTime? PubDate { get; set; }
+        public DateTime FetchDate { get; set; }
+        public BangumiEntryStatus Status { get; set; }
+        public string FilePath { get; set; }
+    }
+
+    public enum BangumiEntryStatus
+    {
+        Skipped = -1,
+        New = 0,
+        Downloading = 1,
+        DownloadComplete = 2,
+        DownloadError = 3,
+    }
+}

+ 18 - 0
BanTur.Core/Entity/ConfigEntry.cs

@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Text;
+using Microsoft.EntityFrameworkCore;
+
+namespace BanTur.Core.Entity
+{
+    [Index(nameof(Key), IsUnique = true)]
+    public class ConfigEntry
+    {
+        [Key]
+        public string Key { get; set; }
+        public string Value { get; set; }
+
+        public string Type { get; set; }
+    }
+}

+ 15 - 0
BanTur.Core/Entity/FeedSource.cs

@@ -0,0 +1,15 @@
+using System;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace BanTur.Core.Entity
+{
+    public class FeedSource
+    {
+        public int Id { get; set; }
+        public bool Enable { get; set; }
+        public string Name { get; set; }
+        public string Url { get; set; }
+        
+        public DateTime CreationTime { get; set; }
+    }
+}

+ 18 - 0
BanTur.Core/EntityFramework/BanTurContext.cs

@@ -0,0 +1,18 @@
+using BanTur.Core.Entity;
+using BanTur.Core.Utility;
+using Microsoft.EntityFrameworkCore;
+
+namespace BanTur.Core.EntityFramework
+{
+    internal class BanTurContext : DbContext
+    {
+        public DbSet<FeedSource> FeedSources { get; set; }
+        public DbSet<BangumiEntry> BangumiEntries { get; set; }
+        public DbSet<ConfigEntry> ConfigEntries { get; set; }
+
+        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+        {
+            optionsBuilder.UseSqlite("Data Source=" + DbAccess.DbFileName);
+        }
+    }
+}

+ 8 - 0
BanTur.Core/Properties/launchSettings.json

@@ -0,0 +1,8 @@
+{
+  "profiles": {
+    "BanTur.Core": {
+      "commandName": "Project",
+      "commandLineArgs": "daemon 30"
+    }
+  }
+}

+ 46 - 0
BanTur.Core/Rss/RssParser.cs

@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using BanTur.Core.Entity;
+using CodeHollow.FeedReader;
+using CodeHollow.FeedReader.Feeds;
+
+namespace BanTur.Core.Rss
+{
+    public static class RssParser
+    {
+        public static BangumiEntry[] Parse(string xml)
+        {
+            var doc = FeedReader.ReadFromString(xml);
+
+            var isFeed20 = doc.Type == FeedType.Rss_2_0;
+
+            var list = new List<BangumiEntry>();
+            foreach (var item in doc.Items)
+            {
+                string magnet = null;
+                if (isFeed20)
+                {
+                    var rss20 = (Rss20FeedItem)item.SpecificItem;
+                    if (rss20.Enclosure?.MediaType == "application/x-bittorrent") magnet = rss20.Enclosure.Url;
+                }
+
+                if (magnet == null) continue;
+
+                var entry = new BangumiEntry
+                {
+                    FetchDate = DateTime.Now,
+                    PubDate = item.PublishingDate,
+                    Magnet = magnet,
+                    Title = item.Title,
+                    Url = item.Link,
+                };
+
+                list.Add(entry);
+            }
+
+            return list.ToArray();
+        }
+    }
+}

+ 61 - 0
BanTur.Core/Utility/Aria2Helper.cs

@@ -0,0 +1,61 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+
+namespace BanTur.Core.Utility
+{
+    public static class Aria2Helper
+    {
+        private static string GetNoticeFilePath(int id)
+        {
+            return Path.Combine(DbAccess.Config.WorkingDirectory, id + ".bat");
+        }
+
+        public static string GetLogFilePath(int id)
+        {
+            return Path.Combine(DbAccess.Config.WorkingDirectory, id + ".log");
+        }
+
+        public static string CreateNoticeFile(int id)
+        {
+            var self = Process.GetCurrentProcess().MainModule.FileName;
+            var selfDir = Path.GetDirectoryName(self);
+            var path = GetNoticeFilePath(id);
+            var content = $"cd /d {selfDir}{Environment.NewLine}{self} {BanTurProgram.CmdComplete} {id} %3";
+            if (Directory.Exists(DbAccess.Config.WorkingDirectory) == false) Directory.CreateDirectory(DbAccess.Config.WorkingDirectory);
+            File.WriteAllText(path, content, Encoding.Default);
+            return path;
+        }
+
+        public static void DeleteNoticeFile(int id)
+        {
+            var path = GetNoticeFilePath(id);
+            File.Delete(path);
+        }
+
+        public static void StartBitTorrentDownload(string magnet, string downloadDirectory, string onBtDownloadComplete, string logFilePath)
+        {
+            if (Directory.Exists(downloadDirectory) == false) Directory.CreateDirectory(downloadDirectory);
+
+            var p = new Process
+            {
+                StartInfo =
+                {
+                    FileName = "aria2c",
+                    WorkingDirectory = downloadDirectory,
+                    ArgumentList =
+                    {
+                        "--seed-time=30",
+                        "--on-bt-download-complete=" + onBtDownloadComplete,
+                        magnet
+                    },
+                    UseShellExecute = true,
+                    WindowStyle = ProcessWindowStyle.Minimized,
+                }
+            };
+
+            p.Start();
+        }
+    }
+}

+ 111 - 0
BanTur.Core/Utility/DbAccess.cs

@@ -0,0 +1,111 @@
+using BanTur.Core.Entity;
+using BanTur.Core.EntityFramework;
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+
+namespace BanTur.Core.Utility
+{
+    public static class DbAccess
+    {
+        internal const string DbFileName = "BanTur.db3";
+
+        static DbAccess()
+        {
+            var exeDir = Path.GetDirectoryName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName);
+#if DEBUG
+            if (exeDir?.ToLower().Contains("testrunner") == true)
+            {
+                exeDir = AppDomain.CurrentDomain.BaseDirectory;
+            }
+#endif
+            var dbPath = Path.Combine(exeDir, DbFileName);
+            if (File.Exists(dbPath) == false)
+            {
+                using var ctx = GetContext();
+                ctx.Database.EnsureCreated();
+
+                var configProps = typeof(Config).GetProperties(BindingFlags.Public | BindingFlags.Static).Select(p => new { p.Name, p.PropertyType }).ToArray();
+                foreach (var prop in configProps)
+                {
+                    ctx.ConfigEntries.Add(prop.PropertyType.IsValueType
+                        ? new ConfigEntry { Key = prop.Name, Type = prop.PropertyType.Name, Value = Activator.CreateInstance(prop.PropertyType)?.ToString() }
+                        : new ConfigEntry { Key = prop.Name, Type = prop.PropertyType.Name });
+                }
+
+                ctx.SaveChanges();
+            }
+        }
+
+        private static BanTurContext GetContext() => new BanTurContext();
+
+        public static FeedSource[] GetFeedSources()
+        {
+            using var ctx = GetContext();
+            return ctx.FeedSources.Where(p => p.Enable).ToArray();
+        }
+
+        public static bool CheckExist(BangumiEntry entry)
+        {
+            using var ctx = GetContext();
+            return ctx.BangumiEntries.Any(p => p.Magnet == entry.Magnet);
+        }
+
+        public static void AddEntry(BangumiEntry entry)
+        {
+            using var ctx = GetContext();
+            ctx.BangumiEntries.Add(entry);
+            ctx.SaveChanges();
+        }
+
+        public static BangumiEntry[] GetNewEntries()
+        {
+            using var ctx = GetContext();
+            return ctx.BangumiEntries.Where(p => p.Status == BangumiEntryStatus.New).ToArray();
+        }
+
+        public static BangumiEntry GetEntry(int id)
+        {
+            using var ctx = GetContext();
+            return ctx.BangumiEntries.FirstOrDefault(p => p.Id == id);
+        }
+
+        public static bool UpdateStatus(int id, BangumiEntryStatus status, string filePath = null)
+        {
+            using var ctx = GetContext();
+            var ce = ctx.BangumiEntries.FirstOrDefault(p => p.Id == id);
+            if (ce == null) return false;
+            ce.Status = status;
+            ce.FilePath = filePath;
+            return ctx.SaveChanges() == 1;
+        }
+
+        public static class Config
+        {
+            private static string GetConfig([CallerMemberName] string key = null)
+            {
+                using var ctx = GetContext();
+                return ctx.ConfigEntries.Where(p => p.Key == key).Select(p => p.Value).FirstOrDefault();
+            }
+
+            private static T GetConfig<T>([CallerMemberName] string key = null)
+            {
+                var value = GetConfig(key);
+                return (T)Convert.ChangeType(value, typeof(T));
+            }
+
+            public static bool EnableMail => GetConfig<bool>();
+            public static string SendMailHost => GetConfig();
+            public static int SendMailPort => GetConfig<int>();
+            public static string SendMailUser => GetConfig();
+            public static string SendMailPass => GetConfig();
+            public static string SendMailTarget => GetConfig();
+            public static string SendMailTargetName => GetConfig();
+
+            public static string WorkingDirectory => GetConfig();
+            public static string DownloadDirectory => GetConfig();
+        }
+    }
+}

+ 18 - 0
BanTur.Core/Utility/HttpAccess.cs

@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+
+namespace BanTur.Core.Utility
+{
+    public class HttpAccess
+    {
+        public static string Get(string url)
+        {
+            using var client = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All });
+            var content = client.GetAsync(url).Result.Content.ReadAsStringAsync().Result;
+            return content;
+        }
+    }
+}

+ 220 - 0
BanTur.Core/Utility/MailSender.cs

@@ -0,0 +1,220 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Mail;
+using System.Net.Sockets;
+using System.Text;
+
+namespace BanTur.Core.Utility
+{
+    public class MailSender
+    {
+        private readonly string _host;
+        private readonly int _smtpPort;
+        private readonly string _user;
+        private readonly string _pass;
+        private readonly string _senderName;
+
+        public MailSender(string host, int smtpPort, string user, string pass, string senderName)
+        {
+            _host = host;
+            _smtpPort = smtpPort;
+            _user = user;
+            _pass = pass;
+            _senderName = senderName;
+        }
+
+
+        public MailSender() : this(
+            DbAccess.Config.SendMailHost,
+            DbAccess.Config.SendMailPort,
+            DbAccess.Config.SendMailUser,
+            DbAccess.Config.SendMailPass,
+            "BanTur")
+        {
+
+        }
+
+        public void SendMail(string subject, string body = "")
+        {
+            SendMail(DbAccess.Config.SendMailTarget, subject, DbAccess.Config.SendMailTargetName, body);
+        }
+
+        public void SendMail(string to, string subject, string toName, string body)
+        {
+            var cl = new TcpClient();
+            cl.SmtpConnect(_host, _smtpPort);
+            cl.SmtpLogin(_user, _pass);
+            cl.SmtpSendMail(_user, to, subject, body, _senderName, toName);
+            cl.SmtpQuit();
+        }
+    }
+
+    internal static class MailExtensions
+    {
+        //-- internal func --
+
+        private static void SmtpCheckStatus(this TcpClient client, int status)
+        {
+            var line = client.ReadAsciiLine();
+            if (line.StartsWith(status + " ") == false)
+                throw new SmtpException(line);
+        }
+
+        //-- public func --
+
+        public static void SmtpConnect(this TcpClient client, string host, int port)
+        {
+            client.Connect(host, port);
+            client.SmtpCheckStatus(220);
+        }
+
+        public static void SmtpLogin(this TcpClient client, string user, string pass)
+        {
+            client.WriteLine("AUTH LOGIN");
+            client.SmtpCheckStatus(334);
+
+            client.WriteLine(Convert.ToBase64String(Encoding.ASCII.GetBytes(user)));
+            client.SmtpCheckStatus(334);
+
+            client.WriteLine(Convert.ToBase64String(Encoding.ASCII.GetBytes(pass)));
+            client.SmtpCheckStatus(235);
+        }
+
+        public static void SmtpSendMail(this TcpClient client, string from, string to, string subject, string body = "", string senderName = "", string toName = "")
+        {
+            var bodyB64 = Convert.ToBase64String(
+                Encoding.UTF8.GetBytes(body + Environment.NewLine + "Mail generate by LibSharpMail")
+                , Base64FormattingOptions.InsertLineBreaks);
+
+            client.WriteLine("MAIL FROM: <{0}>", from);
+            client.SmtpCheckStatus(250);
+
+            client.WriteLine("RCPT TO: <{0}>", to);
+            client.SmtpCheckStatus(250);
+
+            client.WriteLine("DATA");
+            client.SmtpCheckStatus(354);
+
+            client.WriteLine("Subject: {0}", subject.SubjectEncoding());
+            client.WriteLine("From: \"{1}\" <{0}>", from, senderName.SubjectEncoding());
+            client.WriteLine("TO: \"{1}\" <{0}>", to, toName);
+            client.WriteLine("MIME-Version: 1.0");
+            client.WriteLine("Content-Type: multipart/mixed; boundary=frontier");
+            client.WriteLine("");
+            client.WriteLine("This is a message with multiple parts in MIME format.");
+            client.WriteLine("--frontier");
+            client.WriteLine("Content-Type: text/plain; charset=utf-8");
+            client.WriteLine("Content-Transfer-Encoding: base64");
+            client.WriteLine("");
+            client.WriteLine(bodyB64);
+            client.WriteLine("--frontier--");
+            client.WriteLine(".");
+
+            client.SmtpCheckStatus(250);
+        }
+
+        public static void SmtpQuit(this TcpClient client)
+        {
+            client.WriteLine("QUIT");
+            client.SmtpCheckStatus(221);
+            client.Close();
+        }
+
+        ////////////////////////
+
+        private const byte Cr = (byte)'\r';
+        private const byte Lf = (byte)'\n';
+        private static readonly byte[] CrLf = { Cr, Lf };
+
+        public static string SubjectEncoding(this string subject, Encoding encoding = null)
+        {
+            var enc = encoding ?? Encoding.UTF8;
+            return $"=?{enc.BodyName}?B?{Convert.ToBase64String(enc.GetBytes(subject))}?=";
+        }
+
+
+        public static void WriteBytes(this Stream s, byte[] bytes)
+        {
+            s.Write(bytes, 0, bytes.Length);
+        }
+
+        public static void WriteCrlf(this Stream s)
+        {
+            s.WriteBytes(CrLf);
+        }
+
+        public static void WriteLine(this TcpClient client, string s)
+        {
+            client.WriteLine("{0}", s);
+        }
+
+        public static void WriteLine(this TcpClient client, string fmt, params object[] args)
+        {
+            var s = client.GetStream();
+            s.WriteBytes(Encoding.ASCII.GetBytes(String.Format(fmt, args)));
+            s.WriteCrlf();
+        }
+
+        public static byte[] ReadBinLine(this TcpClient client)
+        {
+            var s = client.GetStream();
+            var ms = new MemoryStream();
+            var len = 0;
+
+            do
+            {
+                var b = s.ReadByte();
+                if (b < 0)
+                    continue;
+
+                ms.WriteByte((byte)b);
+
+                len++;
+                if (len < 2)
+                    continue;
+
+                var msbuf = ms.GetBuffer();
+                if (msbuf[len - 2] == Cr
+                    && msbuf[len - 1] == Lf)
+                    break;
+            } while (true);
+
+            var buf = new byte[len - 2];
+            Array.Copy(ms.GetBuffer(), 0, buf, 0, len - 2);
+
+            return buf;
+        }
+
+        public static string ReadAsciiLine(this TcpClient client)
+        {
+            return Encoding.ASCII.GetString(client.ReadBinLine());
+        }
+
+        public static string[] ReadAsciiLinesUntilDot(this TcpClient client)
+        {
+            var ret = new List<string>();
+            do
+            {
+                var line = client.ReadAsciiLine();
+                if (line.Trim() == ".")
+                    break;
+                ret.Add(line);
+            } while (true);
+            return ret.ToArray();
+        }
+
+        public static byte[][] ReadBinLineUntilDot(this TcpClient client)
+        {
+            var ret = new List<byte[]>();
+            do
+            {
+                var line = client.ReadBinLine();
+                if (line.Length > 0 && line[0] == (byte)'.')
+                    break;
+                ret.Add(line);
+            } while (true);
+            return ret.ToArray();
+        }
+    }
+}

+ 16 - 0
BanTur.Tests/BanTur.Tests.csproj

@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netcoreapp3.1</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" />
+    <PackageReference Include="xunit" Version="2.4.1" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\BanTur.Core\BanTur.Core.csproj" />
+  </ItemGroup>
+
+</Project>

+ 34 - 0
BanTur.Tests/DbAccessTests.cs

@@ -0,0 +1,34 @@
+using BanTur.Core.Entity;
+using BanTur.Core.Utility;
+using System;
+using Xunit;
+
+namespace BanTur.Tests
+{
+    public class DbAccessTests
+    {
+        [Fact]
+        public void EntryCreationTest()
+        {
+            var e = new BangumiEntry
+            {
+                FetchDate = DateTime.Now,
+                Magnet = "---test---",
+                PubDate = new DateTime(2021, 1, 2, 3, 4, 5),
+                Title = "Test",
+                Status = BangumiEntryStatus.New,
+            };
+
+            DbAccess.AddEntry(e);
+
+            Assert.True(DbAccess.CheckExist(e));
+        }
+
+        [Fact]
+        public void RealtimeConfigTest()
+        {
+            var value1 = DbAccess.Config.SendMailHost;
+            var value2 = DbAccess.Config.SendMailHost;
+        }
+    }
+}

+ 17 - 0
BanTur.Tests/MailingTests.cs

@@ -0,0 +1,17 @@
+using BanTur.Core.Utility;
+using Xunit;
+
+namespace BanTur.Tests
+{
+    public class MailingTests
+    {
+        [Fact]
+        public void SendMailTest()
+        {
+            var touch = DbAccess.Config.SendMailHost;
+
+            var client = new MailSender(DbAccess.Config.SendMailHost, DbAccess.Config.SendMailPort, DbAccess.Config.SendMailUser, DbAccess.Config.SendMailPass, "BanTurTests");
+            client.SendMail(DbAccess.Config.SendMailTarget, "Test", DbAccess.Config.SendMailTargetName, "Brrrrrrrrrrrr");
+        }
+    }
+}

+ 17 - 0
BanTur.Tests/RssTests.cs

@@ -0,0 +1,17 @@
+using System;
+using BanTur.Core.Rss;
+using BanTur.Core.Utility;
+using Xunit;
+
+namespace BanTur.Tests
+{
+    public class RssTests
+    {
+        [Fact]
+        public void DmhyTest()
+        {
+            var xml = HttpAccess.Get("https://dmhy.anoneko.com/topics/rss/rss.xml");
+            var entries = RssParser.Parse(xml);
+        }
+    }
+}

+ 36 - 0
BangumiTurret.sln

@@ -0,0 +1,36 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.31005.135
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BanTur.Core", "BanTur.Core\BanTur.Core.csproj", "{CFE064F9-62D7-49DA-889D-5B4E27CF0501}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BanTur.Tests", "BanTur.Tests\BanTur.Tests.csproj", "{6717C806-39CF-410A-8B8A-C1C94D4C8918}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{118717E7-A871-4084-9053-7CA22283D67F}"
+	ProjectSection(SolutionItems) = preProject
+		README.md = README.md
+	EndProjectSection
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{CFE064F9-62D7-49DA-889D-5B4E27CF0501}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CFE064F9-62D7-49DA-889D-5B4E27CF0501}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CFE064F9-62D7-49DA-889D-5B4E27CF0501}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{CFE064F9-62D7-49DA-889D-5B4E27CF0501}.Release|Any CPU.Build.0 = Release|Any CPU
+		{6717C806-39CF-410A-8B8A-C1C94D4C8918}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{6717C806-39CF-410A-8B8A-C1C94D4C8918}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{6717C806-39CF-410A-8B8A-C1C94D4C8918}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{6717C806-39CF-410A-8B8A-C1C94D4C8918}.Release|Any CPU.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {4C954995-4E70-4360-B9AF-E2F717292C5C}
+	EndGlobalSection
+EndGlobal

+ 4 - 0
BangumiTurret.sln.DotSettings

@@ -0,0 +1,4 @@
+<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
+	<s:Boolean x:Key="/Default/UserDictionary/Words/=Bangumi/@EntryIndexedValue">True</s:Boolean>
+	<s:Boolean x:Key="/Default/UserDictionary/Words/=bittorrent/@EntryIndexedValue">True</s:Boolean>
+	<s:Boolean x:Key="/Default/UserDictionary/Words/=Sqlite/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

+ 28 - 0
README.md

@@ -0,0 +1,28 @@
+# BangumiTurret
+
+Auto download Anime Bangumi by Aria2c from magnet RSS 
+
+# Usage
+
+0) Install `Aria2` and setup path env var.
+
+1) Create database: Run wighout any command-line argument to create SQLite database
+
+2) Config: Use SQLite management tool to edit Config and add entry to FeedSource
+
+|Key|ExampleValue|
+|---|---|
+|EnableMail|True|
+|SendMailHost|mail.example.com|
+|SendMailPort|25|Int32|
+|SendMailUser|mailbot|
+|SendMailPass|passofbot|
+|SendMailTarget|john.d@example.com|
+|SendMailTargetName|john doe|
+|WorkingDirectory|C:\BTWD|
+|DownloadDirectory|C:\BTDL|
+
+3) Run:
+
+- Empty command-line argument or `fetch` for external scheduler
+- Pass `daemon 30` for 30 min interval loop running