├── .gitattributes ├── .gitignore ├── .gitmodules ├── FeedReader ├── BeastSaberReader.cs ├── BeatSaverReader.cs ├── FeedReader.csproj ├── FeedReader.ruleset ├── IFeedReader.cs ├── Logging │ ├── FeedReaderLogger.cs │ ├── FeedReaderLoggerBase.cs │ └── LoggingController.cs ├── ScoreSaberReader.cs ├── ScrapedSong.cs ├── Util.cs └── WebUtils.cs ├── FeedReaderTests ├── AssertAsync.cs ├── BeastSaberReaderTests.cs ├── BeatSaverReaderTests.cs ├── Data │ ├── BeastSaber │ │ ├── ErrorResponses │ │ │ └── 522-Timeout.txt │ │ ├── bookmarked_by_curator1.json │ │ ├── bookmarked_by_curator2.json │ │ ├── bookmarked_by_curator3.json │ │ ├── bookmarked_by_curator4_partial.json │ │ ├── bookmarked_by_curator5_empty.json │ │ ├── bookmarked_by_zingabopp1.json │ │ ├── bookmarked_by_zingabopp2.json │ │ ├── bookmarked_by_zingabopp3_empty.json │ │ ├── followings1.xml │ │ ├── followings2.xml │ │ ├── followings3.xml │ │ ├── followings4.xml │ │ ├── followings5.xml │ │ ├── followings6.xml │ │ ├── followings7.xml │ │ ├── followings8_partial.xml │ │ └── followings9_empty.xml │ ├── BeastSaberJsonPage.json │ ├── BeastSaberXMLPage.xml │ ├── BeatSaver │ │ ├── author_5cff0b7398cc5a672c84f1d8_0.json │ │ ├── downloads0.json │ │ ├── hot0.json │ │ ├── latest0.json │ │ ├── latest1.json │ │ ├── latest2.json │ │ ├── latest3.json │ │ ├── latest4.json │ │ ├── latest5_partial - Copy.json │ │ ├── latest6_empty.json │ │ ├── plays0.json │ │ └── search_believer_0.json │ ├── BeatSaverListPage.json │ ├── BeatSaverListPageWithConverted.json │ ├── BeatSaverSingleSong.json │ ├── ScoreSaber │ │ ├── latest0.json │ │ ├── search_believer_0.json │ │ ├── topplayed0.json │ │ ├── topranked0.json │ │ └── trending0.json │ └── ScoreSaberPage.json ├── FeedReaderTests.csproj ├── FeedReaderTests.ruleset ├── MockClasses │ ├── IMockResponseTemplate.cs │ ├── MockHttpContent.cs │ ├── MockHttpResponse.cs │ ├── MockResponseTemplates │ │ └── BeastSaberResponseTemplate.cs │ ├── MockTests │ │ ├── MockHttpContentTests.cs │ │ ├── MockHttpResponseTests.cs │ │ ├── MockStaticTests.cs │ │ └── MockWebClientTests.cs │ └── MockWebClient.cs └── ScoreSaberReaderTests.cs ├── LICENSE ├── README.md ├── Status ├── SyncSaberConsole ├── App.config ├── Program.cs ├── Properties │ └── AssemblyInfo.cs ├── ScrapedData │ ├── BeatSaverScrape.json │ └── ScoreSaberScrape.json └── SyncSaberConsole.csproj ├── SyncSaberLib ├── Config.cs ├── Config │ ├── CustomSetting.cs │ ├── IFeedConfig.cs │ ├── IReaderConfig.cs │ └── ISyncSaberLibConfig.cs ├── Data │ ├── BeatSaverScrape.cs │ ├── BeatSaverSong.cs │ ├── IScrapedDataModel.cs │ ├── JsonConverters.cs │ ├── ScoreSaberScrape.cs │ ├── ScoreSaberSong.cs │ ├── ScrapedDataProvider.cs │ ├── SongInfo.cs │ ├── SongInfoProvider.cs │ └── SyncSaberScrape.cs ├── Logger.cs ├── Playlist.cs ├── PlaylistIO.cs ├── PlaylistSong.cs ├── Properties │ └── AssemblyInfo.cs ├── SyncSaber.cs ├── SyncSaberLib.csproj ├── Utilities.cs └── Web │ ├── BeastSaberReader.cs │ ├── BeatSaverReader.cs │ ├── DownloadBatch.cs │ ├── DownloadJob.cs │ ├── IFeedReader.cs │ ├── ScoreSaberReader.cs │ └── WebUtils.cs ├── SyncSaberService.sln ├── SyncSaberServiceTests ├── Data │ └── BeatSaverSongTests.cs ├── Properties │ └── AssemblyInfo.cs ├── SongInfoTests.cs ├── SyncSaberServiceTests.csproj ├── packages.config ├── test_detail_page.json └── test_multiplesongs_page.json ├── WebUtilities ├── HttpClientWrapper.cs ├── HttpContentWrapper.cs ├── HttpResponseWrapper.cs ├── IWebClient.cs ├── IWebResponse.cs └── WebUtilities.csproj └── update_submodules.bat /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | project.fragment.lock.json 46 | artifacts/ 47 | 48 | *_i.c 49 | *_p.c 50 | *_i.h 51 | *.ilk 52 | *.meta 53 | *.obj 54 | *.pch 55 | *.pdb 56 | *.pgc 57 | *.pgd 58 | *.rsp 59 | *.sbr 60 | *.tlb 61 | *.tli 62 | *.tlh 63 | *.tmp 64 | *.tmp_proj 65 | *.log 66 | *.vspscc 67 | *.vssscc 68 | .builds 69 | *.pidb 70 | *.svclog 71 | *.scc 72 | 73 | # Chutzpah Test files 74 | _Chutzpah* 75 | 76 | # Visual C++ cache files 77 | ipch/ 78 | *.aps 79 | *.ncb 80 | *.opendb 81 | *.opensdf 82 | *.sdf 83 | *.cachefile 84 | *.VC.db 85 | *.VC.VC.opendb 86 | 87 | # Visual Studio profiler 88 | *.psess 89 | *.vsp 90 | *.vspx 91 | *.sap 92 | 93 | # TFS 2012 Local Workspace 94 | $tf/ 95 | 96 | # Guidance Automation Toolkit 97 | *.gpState 98 | 99 | # ReSharper is a .NET coding add-in 100 | _ReSharper*/ 101 | *.[Rr]e[Ss]harper 102 | *.DotSettings.user 103 | 104 | # JustCode is a .NET coding add-in 105 | .JustCode 106 | 107 | # TeamCity is a build add-in 108 | _TeamCity* 109 | 110 | # DotCover is a Code Coverage Tool 111 | *.dotCover 112 | 113 | # NCrunch 114 | _NCrunch_* 115 | .*crunch*.local.xml 116 | nCrunchTemp_* 117 | 118 | # MightyMoose 119 | *.mm.* 120 | AutoTest.Net/ 121 | 122 | # Web workbench (sass) 123 | .sass-cache/ 124 | 125 | # Installshield output folder 126 | [Ee]xpress/ 127 | 128 | # DocProject is a documentation generator add-in 129 | DocProject/buildhelp/ 130 | DocProject/Help/*.HxT 131 | DocProject/Help/*.HxC 132 | DocProject/Help/*.hhc 133 | DocProject/Help/*.hhk 134 | DocProject/Help/*.hhp 135 | DocProject/Help/Html2 136 | DocProject/Help/html 137 | 138 | # Click-Once directory 139 | publish/ 140 | 141 | # Publish Web Output 142 | *.[Pp]ublish.xml 143 | *.azurePubxml 144 | # TODO: Comment the next line if you want to checkin your web deploy settings 145 | # but database connection strings (with potential passwords) will be unencrypted 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 150 | # checkin your Azure Web App publish settings, but sensitive information contained 151 | # in these scripts will be unencrypted 152 | PublishScripts/ 153 | 154 | # NuGet Packages 155 | *.nupkg 156 | # The packages folder can be ignored because of Package Restore 157 | **/packages/* 158 | # except build/, which is used as an MSBuild target. 159 | !**/packages/build/ 160 | # Uncomment if necessary however generally it will be regenerated when needed 161 | #!**/packages/repositories.config 162 | # NuGet v3's project.json files produces more ignoreable files 163 | *.nuget.props 164 | *.nuget.targets 165 | 166 | # Microsoft Azure Build Output 167 | csx/ 168 | *.build.csdef 169 | 170 | # Microsoft Azure Emulator 171 | ecf/ 172 | rcf/ 173 | 174 | # Windows Store app package directories and files 175 | AppPackages/ 176 | BundleArtifacts/ 177 | Package.StoreAssociation.xml 178 | _pkginfo.txt 179 | 180 | # Visual Studio cache files 181 | # files ending in .cache can be ignored 182 | *.[Cc]ache 183 | # but keep track of directories ending in .cache 184 | !*.[Cc]ache/ 185 | 186 | # Others 187 | ClientBin/ 188 | ~$* 189 | *~ 190 | *.dbmdl 191 | *.dbproj.schemaview 192 | *.jfm 193 | *.pfx 194 | *.publishsettings 195 | node_modules/ 196 | orleans.codegen.cs 197 | 198 | # Since there are multiple workflows, uncomment next line to ignore bower_components 199 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 200 | #bower_components/ 201 | 202 | # RIA/Silverlight projects 203 | Generated_Code/ 204 | 205 | # Backup & report files from converting an old project file 206 | # to a newer Visual Studio version. Backup files are not needed, 207 | # because we have git ;-) 208 | _UpgradeReport_Files/ 209 | Backup*/ 210 | UpgradeLog*.XML 211 | UpgradeLog*.htm 212 | 213 | # SQL Server files 214 | *.mdf 215 | *.ldf 216 | 217 | # Business Intelligence projects 218 | *.rdl.data 219 | *.bim.layout 220 | *.bim_*.settings 221 | 222 | # Microsoft Fakes 223 | FakesAssemblies/ 224 | 225 | # GhostDoc plugin setting file 226 | *.GhostDoc.xml 227 | 228 | # Node.js Tools for Visual Studio 229 | .ntvs_analysis.dat 230 | 231 | # Visual Studio 6 build log 232 | *.plg 233 | 234 | # Visual Studio 6 workspace options file 235 | *.opt 236 | 237 | # Visual Studio LightSwitch build output 238 | **/*.HTMLClient/GeneratedArtifacts 239 | **/*.DesktopClient/GeneratedArtifacts 240 | **/*.DesktopClient/ModelManifest.xml 241 | **/*.Server/GeneratedArtifacts 242 | **/*.Server/ModelManifest.xml 243 | _Pvt_Extensions 244 | 245 | # Paket dependency manager 246 | .paket/paket.exe 247 | paket-files/ 248 | 249 | # FAKE - F# Make 250 | .fake/ 251 | 252 | # JetBrains Rider 253 | .idea/ 254 | *.sln.iml 255 | 256 | # CodeRush 257 | .cr/ 258 | 259 | # Python Tools for Visual Studio (PTVS) 260 | __pycache__/ 261 | *.pyc -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "SyncSaberLib/libs/BeatSaber-PlayerDataReader"] 2 | path = SyncSaberLib/libs/BeatSaber-PlayerDataReader 3 | url = https://github.com/Zingabopp/BeatSaber-PlayerDataReader.git 4 | -------------------------------------------------------------------------------- /FeedReader/FeedReader.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | Zingabopp 6 | Copyright © Zingabopp 2019 7 | 8 | 9 | 10 | FeedReader.ruleset 11 | 12 | 13 | 14 | FeedReader.ruleset 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /FeedReader/FeedReader.ruleset: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /FeedReader/IFeedReader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace FeedReader 8 | { 9 | public interface IFeedReader 10 | { 11 | string Name { get; } // Name of the reader 12 | string Source { get; } // Name of the site 13 | Uri RootUri { get; } 14 | bool Ready { get; } // Reader is ready 15 | bool StoreRawData { get; set; } // Save the raw data in ScrapedSong 16 | 17 | /// 18 | /// Anything that needs to happen before the Reader is ready. 19 | /// 20 | void PrepareReader(); 21 | 22 | /// 23 | /// Retrieves the songs from a feed and returns them as a Dictionary. Key is the song hash. 24 | /// 25 | /// 26 | /// 27 | Dictionary GetSongsFromFeed(IFeedSettings settings); 28 | 29 | Task> GetSongsFromFeedAsync(IFeedSettings settings); 30 | Task> GetSongsFromFeedAsync(IFeedSettings settings, CancellationToken cancellationToken); 31 | } 32 | 33 | public interface IFeedSettings 34 | { 35 | string FeedName { get; } // Name of the feed 36 | int FeedIndex { get; } // Index of the feed 37 | 38 | /// 39 | /// Max number of songs to retrieve, 0 for unlimited. 40 | /// 41 | int MaxSongs { get; set; } 42 | 43 | /// 44 | /// Page of the feed to start on, default is 1. For all feeds, setting '1' here is the same as starting on the first page. 45 | /// 46 | int StartingPage { get; set; } 47 | } 48 | 49 | /// 50 | /// Data for a feed. 51 | /// 52 | public struct FeedInfo : IEquatable 53 | { 54 | #pragma warning disable CA1054 // Uri parameters should not be strings 55 | public FeedInfo(string name, string baseUrl) 56 | #pragma warning restore CA1054 // Uri parameters should not be strings 57 | { 58 | Name = name; 59 | BaseUrl = baseUrl; 60 | } 61 | #pragma warning disable CA1056 // Uri properties should not be strings 62 | public string BaseUrl { get; set; } // Base URL for the feed, has string keys to replace with things like page number/bsaber username 63 | #pragma warning restore CA1056 // Uri properties should not be strings 64 | public string Name { get; set; } // Name of the feed 65 | 66 | #region EqualsOperators 67 | public override bool Equals(object obj) 68 | { 69 | if (!(obj is FeedInfo)) 70 | return false; 71 | return Equals((FeedInfo)obj); 72 | } 73 | public bool Equals(FeedInfo other) 74 | { 75 | if (Name != other.Name) 76 | return false; 77 | return BaseUrl == other.BaseUrl; 78 | } 79 | 80 | public static bool operator ==(FeedInfo feedInfo1, FeedInfo feedInfo2) 81 | { 82 | return feedInfo1.Equals(feedInfo2); 83 | } 84 | public static bool operator !=(FeedInfo feedInfo1, FeedInfo feedInfo2) 85 | { 86 | return !feedInfo1.Equals(feedInfo2); 87 | } 88 | 89 | public override int GetHashCode() => (Name, BaseUrl).GetHashCode(); 90 | #endregion 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /FeedReader/Logging/FeedReaderLogger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Runtime.CompilerServices; 4 | using System.IO; 5 | using System.Text; 6 | 7 | namespace FeedReader.Logging 8 | { 9 | public class FeedReaderLogger 10 | : FeedReaderLoggerBase 11 | { 12 | public FeedReaderLogger() 13 | { 14 | LoggerName = "FeedReader"; 15 | } 16 | 17 | public FeedReaderLogger(LoggingController controller) 18 | { 19 | LogController = controller; 20 | } 21 | 22 | public override void Trace(string message, [CallerFilePath] string file = "", [CallerMemberName] string member = "", [CallerLineNumber] int line = 0) 23 | { 24 | if (LogLevel > LogLevel.Trace) 25 | { 26 | return; 27 | } 28 | string sourcePart, timePart = ""; 29 | if (!ShortSource) 30 | sourcePart = $"[{Path.GetFileName(file)}_{member}({line})"; 31 | else 32 | sourcePart = $"[{LoggerName}"; 33 | if (EnableTimestamp) 34 | timePart = $" @ {DateTime.Now.ToString("HH:mm")}"; 35 | Console.WriteLine($"{sourcePart}{timePart} - Trace] {message}"); 36 | } 37 | 38 | public override void Debug(string message, [CallerFilePath] string file = "", [CallerMemberName] string member = "", [CallerLineNumber] int line = 0) 39 | { 40 | if (LogLevel > LogLevel.Debug) 41 | { 42 | return; 43 | } 44 | string sourcePart, timePart = ""; 45 | if (!ShortSource) 46 | sourcePart = $"[{Path.GetFileName(file)}_{member}({line})"; 47 | else 48 | sourcePart = $"[{LoggerName}"; 49 | if (EnableTimestamp) 50 | timePart = $" @ {DateTime.Now.ToString("HH:mm")}"; 51 | Console.WriteLine($"{sourcePart}{timePart} - Debug] {message}"); 52 | } 53 | 54 | public override void Info(string message, [CallerFilePath] string file = "", [CallerMemberName] string member = "", [CallerLineNumber] int line = 0) 55 | { 56 | if (LogLevel > LogLevel.Info) 57 | { 58 | return; 59 | } 60 | string sourcePart, timePart = ""; 61 | if (!ShortSource) 62 | sourcePart = $"[{Path.GetFileName(file)}_{member}({line})"; 63 | else 64 | sourcePart = $"[{LoggerName}"; 65 | if (EnableTimestamp) 66 | timePart = $" @ {DateTime.Now.ToString("HH:mm")}"; 67 | Console.WriteLine($"{sourcePart}{timePart} - Info] {message}"); 68 | } 69 | 70 | public override void Warning(string message, [CallerFilePath] string file = "", [CallerMemberName] string member = "", [CallerLineNumber] int line = 0) 71 | { 72 | if (LogLevel > LogLevel.Warning) 73 | { 74 | return; 75 | } 76 | string sourcePart, timePart = ""; 77 | if (!ShortSource) 78 | sourcePart = $"[{Path.GetFileName(file)}_{member}({line})"; 79 | else 80 | sourcePart = $"[{LoggerName}"; 81 | if (EnableTimestamp) 82 | timePart = $" @ {DateTime.Now.ToString("HH:mm")}"; 83 | Console.WriteLine($"{sourcePart}{timePart} - Warning] {message}"); 84 | } 85 | 86 | public override void Error(string message, [CallerFilePath] string file = "", [CallerMemberName] string member = "", [CallerLineNumber] int line = 0) 87 | { 88 | if (LogLevel > LogLevel.Error) 89 | { 90 | return; 91 | } 92 | string sourcePart, timePart = ""; 93 | if (!ShortSource) 94 | sourcePart = $"[{Path.GetFileName(file)}_{member}({line})"; 95 | else 96 | sourcePart = $"[{LoggerName}"; 97 | if (EnableTimestamp) 98 | timePart = $" @ {DateTime.Now.ToString("HH:mm")}"; 99 | Console.WriteLine($"{sourcePart}{timePart} - Error] {message}"); 100 | } 101 | 102 | public override void Exception(string message, Exception e, [CallerFilePath] string file = "", [CallerMemberName] string member = "", [CallerLineNumber] int line = 0) 103 | { 104 | if (LogLevel > LogLevel.Exception) 105 | { 106 | return; 107 | } 108 | Console.WriteLine($"[{Path.GetFileName(file)}_{member}({line}) @ {DateTime.Now.ToString("HH:mm")} - Exception] {message} - {e?.GetType().FullName}-{e?.Message}\n{e?.StackTrace}"); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /FeedReader/Logging/FeedReaderLoggerBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Runtime.CompilerServices; 5 | 6 | namespace FeedReader.Logging 7 | { 8 | public abstract class FeedReaderLoggerBase 9 | { 10 | public string LoggerName { get; set; } 11 | public LogLevel LogLevel { get; set; } 12 | public bool ShortSource { get; set; } 13 | public bool EnableTimestamp { get; set; } 14 | private LoggingController _loggingController; 15 | public LoggingController LogController 16 | { 17 | get { return _loggingController; } 18 | set 19 | { 20 | if (_loggingController == value) 21 | return; 22 | if(_loggingController != null) 23 | _loggingController.PropertyChanged -= Controller_PropertyChanged; 24 | if (value == null) 25 | { 26 | _loggingController = null; 27 | return; 28 | } 29 | _loggingController = value; 30 | LoggerName = _loggingController.LoggerName; 31 | LogLevel = _loggingController.LogLevel; 32 | ShortSource = _loggingController.ShortSource; 33 | EnableTimestamp = _loggingController.EnableTimestamp; 34 | _loggingController.PropertyChanged -= Controller_PropertyChanged; 35 | _loggingController.PropertyChanged += Controller_PropertyChanged; 36 | } 37 | } 38 | 39 | #pragma warning disable CA1707 // Identifiers should not contain underscores 40 | protected virtual void Controller_PropertyChanged(string propertyName, object propertyValue) 41 | #pragma warning restore CA1707 // Identifiers should not contain underscores 42 | { 43 | switch (propertyName) 44 | { 45 | case "LoggerName": 46 | LoggerName = propertyValue?.ToString(); 47 | break; 48 | case "LogLevel": 49 | LogLevel = (LogLevel)propertyValue; 50 | break; 51 | case "ShortSource": 52 | ShortSource = (bool)propertyValue; 53 | break; 54 | case "EnableTimeStamp": 55 | EnableTimestamp = (bool)propertyValue; 56 | break; 57 | default: 58 | break; 59 | } 60 | } 61 | 62 | public abstract void Trace(string message, 63 | [CallerFilePath] string file = "", 64 | [CallerMemberName] string member = "", 65 | [CallerLineNumber] int line = 0); 66 | public abstract void Debug(string message, 67 | [CallerFilePath] string file = "", 68 | [CallerMemberName] string member = "", 69 | [CallerLineNumber] int line = 0); 70 | public abstract void Info(string message, 71 | [CallerFilePath] string file = "", 72 | [CallerMemberName] string member = "", 73 | [CallerLineNumber] int line = 0); 74 | public abstract void Warning(string message, 75 | [CallerFilePath] string file = "", 76 | [CallerMemberName] string member = "", 77 | [CallerLineNumber] int line = 0); 78 | #pragma warning disable CA1716 // Identifiers should not match keywords 79 | public abstract void Error(string message, 80 | #pragma warning restore CA1716 // Identifiers should not match keywords 81 | [CallerFilePath] string file = "", 82 | [CallerMemberName] string member = "", 83 | [CallerLineNumber] int line = 0); 84 | public abstract void Exception(string message, Exception e, 85 | [CallerFilePath] string file = "", 86 | [CallerMemberName] string member = "", 87 | [CallerLineNumber] int line = 0); 88 | 89 | } 90 | public enum LogLevel 91 | { 92 | Trace = 0, 93 | Debug = 1, 94 | Info = 2, 95 | Warning = 3, 96 | Error = 4, 97 | Exception = 5, 98 | Disabled = 6 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /FeedReader/Logging/LoggingController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace FeedReader.Logging 6 | { 7 | public class LoggingController 8 | { 9 | public static LoggingController DefaultLogController { get; protected set; } 10 | static LoggingController() 11 | { 12 | DefaultLogController = new LoggingController() 13 | { 14 | LoggerName = "FeedReader", 15 | ShortSource = true, 16 | LogLevel = LogLevel.Debug, 17 | EnableTimestamp = false 18 | }; 19 | } 20 | private string _loggerName; 21 | private LogLevel _logLevel; 22 | private bool _shortSource; 23 | private bool _enableTimeStamp; 24 | 25 | public string LoggerName 26 | { 27 | get { return _loggerName; } 28 | set 29 | { 30 | if (_loggerName != value) 31 | { 32 | _loggerName = value; 33 | PropertyChanged?.Invoke("LoggerName", value); 34 | } 35 | } 36 | } 37 | public LogLevel LogLevel 38 | { 39 | get { return _logLevel; } 40 | set 41 | { 42 | if (_logLevel != value) 43 | { 44 | _logLevel = value; 45 | PropertyChanged?.Invoke("LogLevel", value); 46 | } 47 | } 48 | } 49 | public bool ShortSource 50 | { 51 | get { return _shortSource; } 52 | set 53 | { 54 | if (_shortSource != value) 55 | { 56 | _shortSource = value; 57 | PropertyChanged?.Invoke("ShortSource", value); 58 | } 59 | } 60 | } 61 | public bool EnableTimestamp 62 | { 63 | get { return _enableTimeStamp; } 64 | set 65 | { 66 | if (_enableTimeStamp != value) 67 | { 68 | _enableTimeStamp = value; 69 | PropertyChanged?.Invoke("EnableTimestamp", value); 70 | } 71 | } 72 | } 73 | 74 | public event Action PropertyChanged; 75 | 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /FeedReader/ScrapedSong.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace FeedReader 6 | { 7 | public class ScrapedSong 8 | { 9 | private string _hash; 10 | public string Hash 11 | { 12 | get { return _hash; } 13 | set { _hash = value?.ToUpper(); } 14 | } 15 | /// 16 | /// Full URL to download song. 17 | /// 18 | public Uri DownloadUri { get; set; } 19 | /// 20 | /// What web page this song was scraped from. 21 | /// 22 | public Uri SourceUri { get; set; } 23 | public string SongName { get; set; } 24 | public string MapperName { get; set; } 25 | /// 26 | /// Data this song was scraped from in JSON form. 27 | /// 28 | public string RawData { get; set; } 29 | 30 | public ScrapedSong() { } 31 | public ScrapedSong(string hash) 32 | { 33 | Hash = hash; 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /FeedReader/Util.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using FeedReader.Logging; 5 | 6 | namespace FeedReader 7 | { 8 | static class Util 9 | { 10 | public static FeedReaderLoggerBase Logger = new FeedReaderLogger(LoggingController.DefaultLogController); 11 | public static int MaxAggregateExceptionDepth = 10; 12 | 13 | public static void WriteExceptions(this AggregateException ae, string message) 14 | { 15 | Logger.Exception(message, ae); 16 | for (int i = 0; i < ae.InnerExceptions.Count; i++) 17 | { 18 | Logger.Exception($"Exception {i}:\n", ae.InnerExceptions[i]); 19 | if (ae.InnerExceptions[i] is AggregateException ex) 20 | WriteExceptions(ex, 0); // TODO: This could get very long 21 | } 22 | } 23 | public static void WriteExceptions(this AggregateException ae, int depth = 0) 24 | { 25 | for (int i = 0; i < ae.InnerExceptions.Count; i++) 26 | { 27 | Logger.Exception($"Exception {i}:\n", ae.InnerExceptions[i]); 28 | if (ae.InnerExceptions[i] is AggregateException ex) 29 | { 30 | if (depth < MaxAggregateExceptionDepth) 31 | { 32 | WriteExceptions(ex, depth + 1); 33 | } 34 | } 35 | } 36 | } 37 | 38 | public static Uri GetUriFromString(string uriString) 39 | { 40 | Uri retVal = null; 41 | if(!string.IsNullOrEmpty(uriString)) 42 | { 43 | retVal = new Uri(uriString); 44 | } 45 | return retVal; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /FeedReader/WebUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.IO; 7 | using System.Net.Http; 8 | using System.Net; 9 | using FeedReader.Logging; 10 | using WebUtilities; 11 | 12 | namespace FeedReader 13 | { 14 | public static class WebUtils 15 | { 16 | private static FeedReaderLoggerBase _logger = new FeedReaderLogger(LoggingController.DefaultLogController); 17 | public static FeedReaderLoggerBase Logger { get { return _logger; } set { _logger = value; } } 18 | public static bool IsInitialized { get; private set; } 19 | //private static readonly object lockObject = new object(); 20 | //private static HttpClientHandler _httpClientHandler; 21 | //public static HttpClientHandler HttpClientHandler 22 | //{ 23 | // get 24 | // { 25 | // if (_httpClientHandler == null) 26 | // { 27 | // _httpClientHandler = new HttpClientHandler(); 28 | // HttpClientHandler.MaxConnectionsPerServer = 10; 29 | // HttpClientHandler.UseCookies = true; 30 | // HttpClientHandler.AllowAutoRedirect = true; // Needs to be false to detect Beat Saver song download rate limit 31 | // } 32 | // return _httpClientHandler; 33 | // } 34 | //} 35 | 36 | private static IWebClient _webClient; 37 | public static IWebClient WebClient 38 | { 39 | get 40 | { 41 | if (_webClient == null) 42 | _webClient = new HttpClientWrapper(); 43 | return _webClient; 44 | } 45 | 46 | } 47 | 48 | public static DateTime UnixTimeStampToDateTime(double unixTimeStamp) 49 | { 50 | // Unix timestamp is seconds past epoch 51 | System.DateTime dtDateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc); 52 | dtDateTime = dtDateTime.AddSeconds(unixTimeStamp).ToLocalTime(); 53 | return dtDateTime; 54 | } 55 | private const string RATE_LIMIT_REMAINING_KEY = "Rate-Limit-Remaining"; 56 | private const string RATE_LIMIT_RESET_KEY = "Rate-Limit-Reset"; 57 | private const string RATE_LIMIT_TOTAL_KEY = "Rate-Limit-Total"; 58 | #pragma warning disable IDE0051 // Remove unused private members 59 | #pragma warning disable CA1823 // Remove unused private members 60 | private const string RATE_LIMIT_PREFIX = "Rate-Limit"; 61 | #pragma warning restore CA1823 // Remove unused private members 62 | #pragma warning restore IDE0051 // Remove unused private members 63 | private static readonly string[] RateLimitKeys = new string[] { RATE_LIMIT_REMAINING_KEY, RATE_LIMIT_RESET_KEY, RATE_LIMIT_TOTAL_KEY }; 64 | public static RateLimit ParseRateLimit(Dictionary headers) 65 | { 66 | if(headers == null) 67 | throw new ArgumentNullException(nameof(headers), "headers cannot be null for WebUtils.ParseRateLimit"); 68 | if (RateLimitKeys.All(k => headers.Keys.Contains(k))) 69 | return new RateLimit() 70 | { 71 | CallsRemaining = int.Parse(headers[RATE_LIMIT_REMAINING_KEY]), 72 | TimeToReset = UnixTimeStampToDateTime(double.Parse(headers[RATE_LIMIT_RESET_KEY])) - DateTime.Now, 73 | CallsPerReset = int.Parse(headers[RATE_LIMIT_TOTAL_KEY]) 74 | }; 75 | else 76 | return null; 77 | } 78 | 79 | public static void Initialize() 80 | { 81 | if (!IsInitialized) 82 | { 83 | _webClient = new HttpClientWrapper(); 84 | IsInitialized = true; 85 | } 86 | } 87 | 88 | public static void Initialize(HttpClient client) 89 | { 90 | if (!IsInitialized) 91 | { 92 | if (client == null) 93 | { 94 | _webClient = new HttpClientWrapper(); 95 | } 96 | else 97 | { 98 | _webClient = new HttpClientWrapper(client); 99 | } 100 | IsInitialized = true; 101 | } 102 | } 103 | public static void Initialize(IWebClient client) 104 | { 105 | if (!IsInitialized) 106 | { 107 | if (client == null) 108 | { 109 | _webClient = new HttpClientWrapper(); 110 | } 111 | else 112 | { 113 | _webClient = client; 114 | } 115 | IsInitialized = true; 116 | } 117 | } 118 | } 119 | 120 | public class RateLimit 121 | { 122 | public int CallsRemaining { get; set; } 123 | public TimeSpan TimeToReset { get; set; } 124 | public int CallsPerReset { get; set; } 125 | } 126 | 127 | //public class HttpGetException : Exception 128 | //{ 129 | // public HttpStatusCode HttpStatusCode { get; private set; } 130 | // public string Url { get; private set; } 131 | 132 | // public HttpGetException() 133 | // : base() 134 | // { 135 | // base.Data.Add("StatusCode", HttpStatusCode.BadRequest); 136 | // base.Data.Add("Url", string.Empty); 137 | // } 138 | 139 | // public HttpGetException(string message) 140 | // : base(message) 141 | // { 142 | 143 | // base.Data.Add("StatusCode", HttpStatusCode.BadRequest); 144 | // base.Data.Add("Url", string.Empty); 145 | // } 146 | 147 | // public HttpGetException(string message, Exception inner) 148 | // : base(message, inner) 149 | // { 150 | // base.Data.Add("StatusCode", HttpStatusCode.BadRequest); 151 | // base.Data.Add("Url", string.Empty); 152 | // } 153 | 154 | // public HttpGetException(HttpStatusCode code, string url) 155 | // : base() 156 | // { 157 | // base.Data.Add("StatusCode", code); 158 | // base.Data.Add("Url", url); 159 | // HttpStatusCode = code; 160 | // Url = url; 161 | // } 162 | 163 | // public HttpGetException(HttpStatusCode code, string url, string message) 164 | // : base(message) 165 | // { 166 | // base.Data.Add("StatusCode", code); 167 | // base.Data.Add("Url", url); 168 | // HttpStatusCode = code; 169 | // Url = url; 170 | // } 171 | //} 172 | 173 | // From https://stackoverflow.com/questions/45711428/download-file-with-webclient-or-httpclient 174 | public static class HttpContentExtensions 175 | { 176 | /// 177 | /// Downloads the provided HttpContent to the specified file. 178 | /// 179 | /// 180 | /// 181 | /// 182 | /// Thrown when content or the filename are null or empty. 183 | /// Thrown when overwrite is false and a file at the provided path already exists. 184 | /// 185 | public static async Task ReadAsFileAsync(this HttpContent content, string filename, bool overwrite) 186 | { 187 | if (content == null) 188 | throw new ArgumentNullException(nameof(content), "content cannot be null for HttpContent.ReadAsFileAsync"); 189 | if (string.IsNullOrEmpty(filename?.Trim())) 190 | throw new ArgumentNullException(nameof(filename), "filename cannot be null or empty for HttpContent.ReadAsFileAsync"); 191 | string pathname = Path.GetFullPath(filename); 192 | if (!overwrite && File.Exists(filename)) 193 | { 194 | throw new InvalidOperationException(string.Format("File {0} already exists.", pathname)); 195 | } 196 | 197 | using (Stream contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false)) 198 | { 199 | using (Stream writeStream = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.None)) 200 | { 201 | await contentStream.CopyToAsync(writeStream).ConfigureAwait(false); 202 | } 203 | } 204 | } 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /FeedReaderTests/AssertAsync.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | [assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)] 8 | 9 | namespace FeedReaderTests 10 | { 11 | public static class AssertAsync 12 | { 13 | public static async Task ThrowsExceptionAsync(Func> action) 14 | { 15 | if (action == null) 16 | return; 17 | try 18 | { 19 | await action().ConfigureAwait(false); 20 | } 21 | catch (Exception ex) 22 | { 23 | if (ex.GetType() == typeof(TException)) 24 | return; 25 | throw new AssertFailedException($"Threw exception {ex.GetType().Name}, but exception {typeof(TException).Name} was expected."); 26 | } 27 | 28 | throw new AssertFailedException("Action did not throw an exception."); 29 | } 30 | 31 | public static async Task ThrowsExceptionAsync(Func action) 32 | { 33 | if (action == null) 34 | return; 35 | try 36 | { 37 | await action().ConfigureAwait(false); 38 | } 39 | catch (Exception ex) 40 | { 41 | if (ex.GetType() == typeof(TException)) 42 | return; 43 | throw new AssertFailedException($"Threw exception {ex.GetType().Name}, but exception {typeof(TException).Name} was expected."); 44 | } 45 | 46 | throw new AssertFailedException("Action did not throw an exception."); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /FeedReaderTests/BeastSaberReaderTests.cs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/BeastSaberReaderTests.cs -------------------------------------------------------------------------------- /FeedReaderTests/BeatSaverReaderTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using System.Collections; 3 | using System.Linq; 4 | using System.IO; 5 | using FeedReader; 6 | using Newtonsoft.Json.Linq; 7 | using System; 8 | 9 | namespace FeedReaderTests 10 | { 11 | [TestClass] 12 | public class BeatSaverReaderTests 13 | { 14 | static BeatSaverReaderTests() 15 | { 16 | if (!WebUtils.IsInitialized) 17 | WebUtils.Initialize(); 18 | } 19 | #region Web 20 | [TestMethod] 21 | public void GetSongsFromFeed_Authors_Test() 22 | { 23 | var reader = new BeatSaverReader() { StoreRawData = true }; 24 | var authorList = new string[] { "BlackBlazon", "greatyazer" }; 25 | var settings = new BeatSaverFeedSettings((int)BeatSaverFeed.Author) { Authors = authorList, MaxSongs = 59 }; 26 | var songsByAuthor = reader.GetSongsFromFeed(settings); 27 | var detectedAuthors = songsByAuthor.Values.Select(s => s.MapperName.ToLower()).Distinct(); 28 | foreach (var song in songsByAuthor) 29 | { 30 | Assert.IsTrue(song.Value.DownloadUri != null); 31 | Assert.IsTrue(authorList.Any(a => a.ToLower() == song.Value.MapperName.ToLower())); 32 | } 33 | foreach (var author in authorList) 34 | { 35 | Assert.IsTrue(songsByAuthor.Any(s => s.Value.MapperName.ToLower() == author.ToLower())); 36 | } 37 | 38 | // BlackBlazon check 39 | var blazonHash = "58de2d709a45b68fdb1dbbfefb187f59f629bfc5".ToUpper(); 40 | var blazonSong = songsByAuthor[blazonHash]; 41 | Assert.IsTrue(blazonSong != null); 42 | Assert.IsTrue(blazonSong.DownloadUri != null); 43 | // GreatYazer check 44 | var songHash = "bf8c016dc6b9832ece3030f05277bbbe67db790d".ToUpper(); 45 | var yazerSong = songsByAuthor[songHash]; 46 | Assert.IsTrue(yazerSong != null); 47 | Assert.IsTrue(yazerSong.DownloadUri != null); 48 | } 49 | 50 | [TestMethod] 51 | public void GetSongsFromFeed_Newest_Test() 52 | { 53 | var reader = new BeatSaverReader() { StoreRawData = true }; 54 | var settings = new BeatSaverFeedSettings((int)BeatSaverFeed.Latest) { MaxSongs = 50 }; 55 | var songList = reader.GetSongsFromFeed(settings); 56 | Assert.IsTrue(songList.Count == settings.MaxSongs); 57 | foreach (var song in songList.Values) 58 | { 59 | Console.WriteLine($"{song.SongName} by {song.MapperName}, {song.Hash}"); 60 | } 61 | } 62 | 63 | [TestMethod] 64 | public void GetSongsFromFeed_Hot_Test() 65 | { 66 | var reader = new BeatSaverReader() { StoreRawData = true }; 67 | var settings = new BeatSaverFeedSettings((int)BeatSaverFeed.Hot) { MaxSongs = 50 }; 68 | var songList = reader.GetSongsFromFeed(settings); 69 | Assert.IsTrue(songList.Count == settings.MaxSongs); 70 | foreach (var song in songList.Values) 71 | { 72 | Console.WriteLine($"{song.SongName} by {song.MapperName}, {song.Hash}"); 73 | } 74 | } 75 | [TestMethod] 76 | public void GetSongsFromFeed_Plays_Test() 77 | { 78 | var reader = new BeatSaverReader() { StoreRawData = true }; 79 | var settings = new BeatSaverFeedSettings((int)BeatSaverFeed.Plays) { MaxSongs = 50 }; 80 | var songList = reader.GetSongsFromFeed(settings); 81 | Assert.IsTrue(songList.Count == settings.MaxSongs); 82 | foreach (var song in songList.Values) 83 | { 84 | Console.WriteLine($"{song.SongName} by {song.MapperName}, {song.Hash}"); 85 | } 86 | } 87 | [TestMethod] 88 | public void GetSongsFromFeed_Downloads_Test() 89 | { 90 | var reader = new BeatSaverReader() { StoreRawData = true }; 91 | var settings = new BeatSaverFeedSettings((int)BeatSaverFeed.Downloads) { MaxSongs = 50 }; 92 | var songList = reader.GetSongsFromFeed(settings); 93 | Assert.IsTrue(songList.Count == settings.MaxSongs); 94 | foreach (var song in songList.Values) 95 | { 96 | Console.WriteLine($"{song.SongName} by {song.MapperName}, {song.Hash}"); 97 | } 98 | } 99 | 100 | [TestMethod] 101 | public void GetSongsFromFeed_Search_Test() 102 | { 103 | var reader = new BeatSaverReader() { StoreRawData = true }; 104 | var settings = new BeatSaverFeedSettings((int)BeatSaverFeed.Search) { MaxSongs = 50, SearchCriteria = "Believer" }; 105 | var songList = reader.GetSongsFromFeed(settings); 106 | Assert.IsTrue(songList.Count > 0); 107 | foreach (var song in songList.Values) 108 | { 109 | Console.WriteLine($"{song.SongName} by {song.MapperName}, {song.Hash}"); 110 | } 111 | } 112 | #endregion 113 | 114 | [TestMethod] 115 | public void ParseSongsFromPage_Test() 116 | { 117 | string pageText = File.ReadAllText(@"Data\BeatSaverListPage.json"); 118 | Uri uri = null; 119 | var songs = BeatSaverReader.ParseSongsFromPage(pageText, uri); 120 | Assert.IsTrue(songs.Count == 10); 121 | foreach (var song in songs) 122 | { 123 | Assert.IsFalse(song.DownloadUri == null); 124 | Assert.IsFalse(string.IsNullOrEmpty(song.Hash)); 125 | Assert.IsFalse(string.IsNullOrEmpty(song.MapperName)); 126 | Assert.IsFalse(string.IsNullOrEmpty(song.RawData)); 127 | Assert.IsFalse(string.IsNullOrEmpty(song.SongName)); 128 | } 129 | var firstSong = JObject.Parse(songs.First().RawData); 130 | string firstHash = firstSong["hash"]?.Value(); 131 | Assert.IsTrue( firstHash == "27639680f92a9588b7cce843fc7aaa0f5dc720f8"); 132 | string firstUploader = firstSong["uploader"]?["username"]?.Value(); 133 | Assert.IsTrue(firstUploader == "latte"); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeastSaber/ErrorResponses/522-Timeout.txt: -------------------------------------------------------------------------------- 1 | {StatusCode: 522, ReasonPhrase: 'Origin Connection Time-out', Version: 1.1, Content: System.Net.Http.HttpConnection+HttpConnectionResponseContent, Headers: 2 | { 3 | Date: Thu, 25 Jul 2019 04:17:43 GMT 4 | Transfer-Encoding: chunked 5 | Connection: keep-alive 6 | Set-Cookie: __cfduid=; expires=Fri, 24-Jul-20 04:17:12 GMT; path=/; domain=.bsaber.com; HttpOnly 7 | Set-Cookie: cf_use_ob=0; path=/; expires=Thu, 25-Jul-19 04:18:13 GMT 8 | Expect-CT: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" 9 | Cache-Control: no-store, must-revalidate, no-cache, post-check=0, pre-check=0 10 | Pragma: no-cache 11 | Server: cloudflare 12 | CF-RAY: 4fbb52a56ab5c530-ORD 13 | Content-Type: text/html; charset=UTF-8 14 | Expires: Thu, 01 Jan 1970 00:00:01 GMT 15 | }} -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeastSaber/bookmarked_by_curator4_partial.json: -------------------------------------------------------------------------------- 1 | { 2 | "songs": [ 3 | { 4 | "title": "Muse - Uprising", 5 | "song_key": "4c6", 6 | "hash": "00e5671e594a6fe621c3605fcc5a0e4466ba6478", 7 | "level_author_name": "rustic" 8 | }, 9 | { 10 | "title": "Prototyperaptor - Awe", 11 | "song_key": "65", 12 | "hash": "f16443de4216fc5f9435f103d77a6919423edb8a", 13 | "level_author_name": "rustic" 14 | }, 15 | { 16 | "title": "Royals (Yinyues Remix) - Lorde", 17 | "song_key": "459", 18 | "hash": "aee01374c29983b6e7488ed7f649c9f5d7bad019", 19 | "level_author_name": "awfulnaut" 20 | }, 21 | { 22 | "title": "CAN'T STOP THE FEELING -Justin Timberlake", 23 | "song_key": "45e", 24 | "hash": "91ba25c089d50e93154e2c7920c46d4730f22569", 25 | "level_author_name": "bennydabeast" 26 | }, 27 | { 28 | "title": "Tetris Effect Trailer \"I'm yours forever\"", 29 | "song_key": "4d5", 30 | "hash": "851daab7074f65fcb2ed2ac686f6a0cfb460ba61", 31 | "level_author_name": "rustic" 32 | }, 33 | { 34 | "title": "Tetris (Trap Remix)", 35 | "song_key": "4af", 36 | "hash": "207a911d2ab94b833310e94ea0dd036a3b9fb74d", 37 | "level_author_name": "crankor" 38 | }, 39 | { 40 | "title": "Believer - Imagine Dragons", 41 | "song_key": "b", 42 | "hash": "19f2879d11a91b51a5c090d63471c3e8d9b7aee3", 43 | "level_author_name": "rustic" 44 | }, 45 | { 46 | "title": "Every Time We Touch - Cascada", 47 | "song_key": "e4", 48 | "hash": "bc6c7ef1385db4c11c59736d2b32eacf48c95bd9", 49 | "level_author_name": "purphoros" 50 | }, 51 | { 52 | "title": "Ride - Twenty One Pilots", 53 | "song_key": "475", 54 | "hash": "05c4b1fc955756d2672ce322417ad2fadb416af6", 55 | "level_author_name": "downycat" 56 | }, 57 | { 58 | "title": "Taylor Swift - Shake It Off", 59 | "song_key": "348", 60 | "hash": "483c7bc03133c6e215f3018e5033b0913821126f", 61 | "level_author_name": "jovian" 62 | }, 63 | { 64 | "title": "Shia LaBeouf (Rob Cantor)", 65 | "song_key": "1c1", 66 | "hash": "1279a3fcaff31e767e5dd7d7c016ec08733cf566", 67 | "level_author_name": "kleid" 68 | }, 69 | { 70 | "title": "The Island Part 2 Dusk - Pendulum", 71 | "song_key": "13b", 72 | "hash": "b564c43a1d52e8b51b91f882732e39c65ac9f8c2", 73 | "level_author_name": "purphoros" 74 | } 75 | ], 76 | "next_page": null 77 | } -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeastSaber/bookmarked_by_curator5_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "songs": [], 3 | "next_page": null 4 | } -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeastSaber/bookmarked_by_zingabopp1.json: -------------------------------------------------------------------------------- 1 | { 2 | "songs": [ 3 | { 4 | "title": "Happier Sunflower - Marshmello, Post Malone, Swae Lee, Bastille [Mashup by Dj Pyromania]", 5 | "song_key": "3519", 6 | "hash": "ec8dc26331e1eff4d4fabeed45b927c49a61f8ca", 7 | "level_author_name": "ruckus" 8 | }, 9 | { 10 | "title": "PUPPE - Rammstein", 11 | "song_key": "583a", 12 | "hash": "e33bb284ce5af33ad2341c31c8be466c2b384381", 13 | "level_author_name": "Heisenberg" 14 | }, 15 | { 16 | "title": "MEIN TEIL - Rammstein", 17 | "song_key": "57d3", 18 | "hash": "6240baee13632d6c0a2991d87dec49bf32184354", 19 | "level_author_name": "Heisenberg" 20 | }, 21 | { 22 | "title": "Harder Better Faster Stronger - Far Out Remix - Daft Punk", 23 | "song_key": "57b8", 24 | "hash": "48f5b7252a06be1b4d4651ed10d74556ab79373b", 25 | "level_author_name": "Heisenberg (Lights by Rexxz)" 26 | }, 27 | { 28 | "title": "Pokemon Theme Song (Metal Cover)", 29 | "song_key": "5768", 30 | "hash": "f2c7de64e9539cf983b073944c268653467e0cdf", 31 | "level_author_name": "Nuketime" 32 | }, 33 | { 34 | "title": "Walking On The Moon - Infected Mushroom", 35 | "song_key": "56a8", 36 | "hash": "97df7b3a2cccedd9ce5473aff669c693e46a1778", 37 | "level_author_name": "KuritsaDBS" 38 | }, 39 | { 40 | "title": "(6 Lane) Perfect Situation - Weezer", 41 | "song_key": "55d6", 42 | "hash": "18fc2b140f04041bf67c6cde01137634f814d841", 43 | "level_author_name": "BennyDaBeast" 44 | }, 45 | { 46 | "title": "The Middle - Jimmy Eat World", 47 | "song_key": "55a5", 48 | "hash": "a01f855fbd0a927b48e07464f09a2172c47ce3cd", 49 | "level_author_name": "Joetastic" 50 | }, 51 | { 52 | "title": "Snow Drive - Araki", 53 | "song_key": "5596", 54 | "hash": "546aaabee304b7d8a128b7bc1cb6894dcb35f986", 55 | "level_author_name": "Nuketime" 56 | }, 57 | { 58 | "title": "We Will Rock You (2011 Remaster) - Queen", 59 | "song_key": "54c0", 60 | "hash": "c47b2f9e75ba677d9516304b3c8fbbc945973de0", 61 | "level_author_name": "Joetastic" 62 | }, 63 | { 64 | "title": "Krab Borg Remix Compilation", 65 | "song_key": "54af", 66 | "hash": "a76618f7ba9739ad938f14edb2f5f79b5a6fc55d", 67 | "level_author_name": "Ruckus" 68 | }, 69 | { 70 | "title": "Champion - Fall Out Boy", 71 | "song_key": "5472", 72 | "hash": "5f05ad258e287410a6f04fdeb567dc4550b38ddd", 73 | "level_author_name": "ConnorJC" 74 | }, 75 | { 76 | "title": "Gun n' Bass pt. 2 - Boom Kitty", 77 | "song_key": "5470", 78 | "hash": "605ddd3b4bd0fdf43e5b8f6dae78c3aff6b43d03", 79 | "level_author_name": "ConnorJC" 80 | }, 81 | { 82 | "title": "Stuck In Your Radio - Young Hearted Kids", 83 | "song_key": "542b", 84 | "hash": "d9ef64164b49772cff7d3e4f16a1ba84f68f66a6", 85 | "level_author_name": "FEFELAND" 86 | }, 87 | { 88 | "title": "Stuck In Your Radio - Today Is The Day", 89 | "song_key": "542a", 90 | "hash": "8aff1b29f26778af4bce01229851bcb61380118a", 91 | "level_author_name": "FEFELAND" 92 | }, 93 | { 94 | "title": "Stuck In Your Radio - Our Own People", 95 | "song_key": "5429", 96 | "hash": "39a423e0cdeacb76e6f11c17c608a5f8354857cb", 97 | "level_author_name": "FEFELAND (Lights by Rexxz)" 98 | }, 99 | { 100 | "title": "Stuck In Your Radio - My Last Mistake", 101 | "song_key": "5428", 102 | "hash": "abe35a3c0b2d250c5a55f31157b5bb1cf9da2d84", 103 | "level_author_name": "FEFELAND" 104 | }, 105 | { 106 | "title": "KDA\/POPSTARS - League of Legends", 107 | "song_key": "538a", 108 | "hash": "6b7c5baf85b9e4402b3461eb137908d4522a9a9c", 109 | "level_author_name": "BennyDaBeast" 110 | }, 111 | { 112 | "title": "On Top of the World - Imagine Dragons", 113 | "song_key": "5389", 114 | "hash": "88314981432a8002f62e464562c0c41f06393ab5", 115 | "level_author_name": "BennyDaBeast" 116 | }, 117 | { 118 | "title": "Memes (Maul Mode)", 119 | "song_key": "5339", 120 | "hash": "58de2d709a45b68fdb1dbbfefb187f59f629bfc5", 121 | "level_author_name": "BlackBlazon" 122 | } 123 | ], 124 | "next_page": 2 125 | } -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeastSaber/bookmarked_by_zingabopp2.json: -------------------------------------------------------------------------------- 1 | { 2 | "songs": [ 3 | { 4 | "title": "A Lot Like You", 5 | "song_key": "5336", 6 | "hash": "e87a9b749fac7898706eb3b09e6ea6943525e151", 7 | "level_author_name": "BlackBlazon" 8 | }, 9 | { 10 | "title": "The Cuss Word Song - Rusty Cage", 11 | "song_key": "52c7", 12 | "hash": "baf7123e80731b1d7df4b3dfbc49b15ac853fc15", 13 | "level_author_name": "Rexxz" 14 | }, 15 | { 16 | "title": "Thaehan - Final Boss II", 17 | "song_key": "529e", 18 | "hash": "b13a744166e330baf0ab4d576bae6c5b9945390f", 19 | "level_author_name": "ruckus" 20 | }, 21 | { 22 | "title": "You're Gonna Go Far, Kid (Eurobeat Remix) - TurboAutism", 23 | "song_key": "528c", 24 | "hash": "616a0fd32df4116e66f46d5c3c34ffa543c6253a", 25 | "level_author_name": "Joetastic" 26 | }, 27 | { 28 | "title": "Basilisk", 29 | "song_key": "526d", 30 | "hash": "7992424e7869710432858ac76e804719adf5ed91", 31 | "level_author_name": "blackblazon" 32 | }, 33 | { 34 | "title": "Memes", 35 | "song_key": "5229", 36 | "hash": "2641a091a49d041d3f1115e1c232678c21d526ec", 37 | "level_author_name": "blackblazon" 38 | }, 39 | { 40 | "title": "Shockwave - Teminite", 41 | "song_key": "521c", 42 | "hash": "2c38699201ebd6d7c1ef3a7193dad9e2d73a9a64", 43 | "level_author_name": "heisenbergirl" 44 | } 45 | ], 46 | "next_page": null 47 | } -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeastSaber/bookmarked_by_zingabopp3_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "songs": [], 3 | "next_page": null 4 | } -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeastSaber/followings9_empty.xml: -------------------------------------------------------------------------------- 1 |  2 | 8 | 9 | 10 | BeastSaber | Zingabopp | Following Activity 11 | https://bsaber.com/members/zingabopp/wall/followings/ 12 | 13 | Activity feed for people that Zingabopp is following. 14 | Mon, 22 Jul 2019 04:10:15 +0000 15 | https://buddypress.org/?v=4.3.0 16 | en-US 17 | 30 18 | hourly 19 | 2 20 | 21 | 22 | -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeastSaberJsonPage.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/BeastSaberJsonPage.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeatSaver/author_5cff0b7398cc5a672c84f1d8_0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/BeatSaver/author_5cff0b7398cc5a672c84f1d8_0.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeatSaver/downloads0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/BeatSaver/downloads0.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeatSaver/hot0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/BeatSaver/hot0.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeatSaver/latest6_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": [], 3 | "totalDocs": 57, 4 | "lastPage": 5, 5 | "prevPage": 5, 6 | "nextPage": null 7 | } -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeatSaver/plays0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/BeatSaver/plays0.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeatSaver/search_believer_0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/BeatSaver/search_believer_0.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/BeatSaverSingleSong.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | 4 | "metadata": { 5 | "difficulties": { 6 | "easy": true, 7 | "expert": true, 8 | "expertPlus": true, 9 | "hard": true, 10 | "normal": true 11 | }, 12 | "characteristics": [ 13 | { 14 | "name": "Standard", 15 | "difficulties": { 16 | "easy": { 17 | "duration": 511, 18 | "length": 180, 19 | "bombs": 0, 20 | "notes": 198, 21 | "obstacles": 23, 22 | "njs": 11 23 | }, 24 | "normal": { 25 | "duration": 511, 26 | "length": 180, 27 | "bombs": 0, 28 | "notes": 280, 29 | "obstacles": 25, 30 | "njs": 11 31 | }, 32 | "hard": { 33 | "duration": 511, 34 | "length": 180, 35 | "bombs": 0, 36 | "notes": 382, 37 | "obstacles": 20, 38 | "njs": 12 39 | }, 40 | "expert": { 41 | "duration": 511, 42 | "length": 180, 43 | "bombs": 0, 44 | "notes": 533, 45 | "obstacles": 20, 46 | "njs": 14 47 | }, 48 | "expertPlus": { 49 | "duration": 511, 50 | "length": 180, 51 | "bombs": 2, 52 | "notes": 704, 53 | "obstacles": 25, 54 | "njs": 15 55 | } 56 | } 57 | }, 58 | { 59 | "name": "OneSaber", 60 | "difficulties": { 61 | "easy": null, 62 | "normal": null, 63 | "hard": null, 64 | "expert": { 65 | "duration": 511, 66 | "length": 180, 67 | "bombs": 0, 68 | "notes": 450, 69 | "obstacles": 44, 70 | "njs": 14 71 | }, 72 | "expertPlus": null 73 | } 74 | } 75 | ], 76 | "levelAuthorName": "Skyler Wallace", 77 | "songAuthorName": "Urban Cone", 78 | "songName": "Come Back To Me", 79 | "songSubName": "ft. Tove Lo", 80 | "bpm": 170 81 | }, 82 | "stats": { 83 | "downloads": 3379, 84 | "plays": 0, 85 | "downVotes": 1, 86 | "upVotes": 57, 87 | "heat": 790.2998286, 88 | "rating": 0.8412931449578613 89 | }, 90 | "description": "170 BPM / 3:06 Runtime\nEasy - 198 Notes\nNormal - 280 Notes\nHard - 382 Notes\nExpert - 533 Notes\nExpert (Single Saber) / 450 Notes\nExpert+ / 704 Notes", 91 | "deletedAt": null, 92 | "_id": "5d0522979e94c50006de797b", 93 | "key": "5317", 94 | "name": "Come Back To Me (ft. Tove Lo) - Urban Cone (All Difficulties & Single Saber)", 95 | "uploader": { 96 | "_id": "5cff0b7298cc5a672c84ea67", 97 | "username": "skylerwallace" 98 | }, 99 | "hash": "aaa14fb7dcaeda7a688db77617045a247baa151d", 100 | "uploaded": "2019-06-15T16:53:43.826Z", 101 | "converted": true, 102 | "downloadURL": "/api/download/key/5317", 103 | "coverURL": "/cdn/5317/aaa14fb7dcaeda7a688db77617045a247baa151d.jpg" 104 | 105 | } 106 | -------------------------------------------------------------------------------- /FeedReaderTests/Data/ScoreSaber/latest0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/ScoreSaber/latest0.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/ScoreSaber/search_believer_0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/ScoreSaber/search_believer_0.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/ScoreSaber/topplayed0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/ScoreSaber/topplayed0.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/ScoreSaber/topranked0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/ScoreSaber/topranked0.json -------------------------------------------------------------------------------- /FeedReaderTests/Data/ScoreSaber/trending0.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zingabopp/SyncSaberService/6613a5d9c41296dd8368922f7f5d1cb6d132801f/FeedReaderTests/Data/ScoreSaber/trending0.json -------------------------------------------------------------------------------- /FeedReaderTests/FeedReaderTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp2.2 5 | 6 | false 7 | 8 | 9 | 10 | FeedReaderTests.ruleset 11 | 12 | 13 | 14 | FeedReaderTests.ruleset 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Always 31 | 32 | 33 | Always 34 | 35 | 36 | Always 37 | 38 | 39 | Always 40 | 41 | 42 | Always 43 | 44 | 45 | Always 46 | 47 | 48 | Always 49 | 50 | 51 | Always 52 | 53 | 54 | Always 55 | 56 | 57 | Always 58 | 59 | 60 | Always 61 | 62 | 63 | Always 64 | 65 | 66 | Always 67 | 68 | 69 | Always 70 | 71 | 72 | Always 73 | 74 | 75 | Always 76 | 77 | 78 | Always 79 | 80 | 81 | Always 82 | 83 | 84 | Always 85 | 86 | 87 | Always 88 | 89 | 90 | Always 91 | 92 | 93 | Always 94 | 95 | 96 | Always 97 | 98 | 99 | Always 100 | 101 | 102 | Always 103 | 104 | 105 | Always 106 | 107 | 108 | Always 109 | 110 | 111 | Always 112 | 113 | 114 | Always 115 | 116 | 117 | Always 118 | 119 | 120 | Always 121 | 122 | 123 | Always 124 | 125 | 126 | Always 127 | 128 | 129 | Always 130 | 131 | 132 | Always 133 | 134 | 135 | Always 136 | 137 | 138 | Always 139 | 140 | 141 | Always 142 | 143 | 144 | Always 145 | 146 | 147 | 148 | 149 | -------------------------------------------------------------------------------- /FeedReaderTests/FeedReaderTests.ruleset: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /FeedReaderTests/MockClasses/IMockResponseTemplate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace FeedReaderTests.MockClasses 6 | { 7 | public interface IMockResponseTemplate 8 | { 9 | void LoadResponse(ref MockHttpResponse response, ref MockHttpContent content, ResponseType responseType); 10 | 11 | } 12 | 13 | 14 | public enum ResponseType 15 | { 16 | Normal, 17 | NotFound, 18 | RateLimitExceeded, 19 | BadGateway 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FeedReaderTests/MockClasses/MockHttpContent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Collections.ObjectModel; 6 | using System.IO; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using WebUtilities; 10 | 11 | namespace FeedReaderTests.MockClasses 12 | { 13 | public class MockHttpContent : IWebResponseContent 14 | { 15 | public string FileSourcePath { get; private set; } 16 | private readonly string _contentType; 17 | 18 | private Dictionary> _headers; 19 | public MockHttpContent(string filePath, Dictionary> headers = null) 20 | { 21 | if (headers == null) 22 | _headers = new Dictionary>(); 23 | FileSourcePath = filePath; 24 | if (!string.IsNullOrEmpty(filePath) && File.Exists(FileSourcePath)) 25 | { 26 | if (FileSourcePath.EndsWith("json")) 27 | _contentType = @"application/json"; 28 | else if (FileSourcePath.EndsWith("xml")) 29 | _contentType = @"text/xml"; 30 | else 31 | _contentType = @"text/html"; 32 | } 33 | else 34 | { 35 | _contentType = @"text/html"; 36 | } 37 | Headers = new ReadOnlyDictionary>(_headers); 38 | } 39 | 40 | 41 | #region IWebResponseContent 42 | public string ContentType { get { return _contentType; } } 43 | 44 | public ReadOnlyDictionary> Headers { get; private set; } 45 | 46 | public async Task ReadAsByteArrayAsync() 47 | { 48 | using (FileStream stream = new FileStream(FileSourcePath, FileMode.Open, FileAccess.Read)) 49 | { 50 | using (MemoryStream memStream = new MemoryStream()) 51 | { 52 | await Task.Yield(); 53 | await stream.CopyToAsync(memStream).ConfigureAwait(false); 54 | return memStream.ToArray(); 55 | } 56 | } 57 | } 58 | 59 | public async Task ReadAsFileAsync(string filePath, bool overwrite) 60 | { 61 | Directory.CreateDirectory(Path.GetDirectoryName(filePath)); 62 | if (!overwrite && File.Exists(filePath)) 63 | { 64 | throw new InvalidOperationException(string.Format("File {0} already exists.", filePath)); 65 | } 66 | using (var stream = File.OpenRead(FileSourcePath)) 67 | using (var writeStream = File.OpenWrite(filePath)) 68 | { 69 | await Task.Yield(); 70 | await stream.CopyToAsync(writeStream).ConfigureAwait(false); 71 | } 72 | 73 | } 74 | 75 | public async Task ReadAsStreamAsync() 76 | { 77 | FileStream stream = new FileStream(FileSourcePath, FileMode.Open, FileAccess.Read); 78 | await Task.Yield(); 79 | return stream; 80 | } 81 | 82 | public async Task ReadAsStringAsync() 83 | { 84 | using (FileStream stream = new FileStream(FileSourcePath, FileMode.Open, FileAccess.Read)) 85 | using (var sr = new StreamReader(stream)) 86 | { 87 | await Task.Yield(); 88 | return await sr.ReadToEndAsync().ConfigureAwait(false); 89 | } 90 | } 91 | 92 | #region IDisposable Support 93 | private bool disposedValue = false; // To detect redundant calls 94 | 95 | protected virtual void Dispose(bool disposing) 96 | { 97 | if (!disposedValue) 98 | { 99 | if (disposing) 100 | { 101 | _headers = null; 102 | Headers = null; 103 | } 104 | disposedValue = true; 105 | } 106 | } 107 | 108 | public void Dispose() 109 | { 110 | Dispose(true); 111 | GC.SuppressFinalize(this); 112 | } 113 | #endregion 114 | 115 | #endregion 116 | 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /FeedReaderTests/MockClasses/MockHttpResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Net; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | using WebUtilities; 10 | 11 | namespace FeedReaderTests.MockClasses 12 | { 13 | public class MockHttpResponse : IWebResponseMessage 14 | { 15 | 16 | #region Regex 17 | private static Regex BeatSaverRegex = 18 | new Regex(@"\/api\/.*?\/(?:(?:uploader\/)(.+)(?:\/))?(\d+)(?:\?q=)?(.*$)?", RegexOptions.Compiled); 19 | private static Regex BeastSaberRegex = 20 | new Regex(@"(?:(?:(?:members\/)|(?:bookmarked_by\=))(.+?)(?:(?:\/wall\/.*)|(?:\&).*))?page=(\d+).*?(?:(?:&count=)(\d+))?", RegexOptions.Compiled); 21 | private static Regex ScoreSaberRegex = 22 | new Regex(@"(?:(?:&cat=)(\d+)).*?page=(\d+).*?(?:(?:&ranked=)(\d))?(?:(?:&search=)(.*))?", RegexOptions.Compiled); 23 | 24 | enum BeatSaverGroup 25 | { 26 | Uploader = 1, 27 | Page = 2, 28 | Query = 3 29 | } 30 | 31 | enum BeastSaberGroup 32 | { 33 | Username = 1, 34 | Page = 2, 35 | SongsPerPage = 3 36 | } 37 | 38 | enum ScoreSaberGroup 39 | { 40 | Catalog = 1, 41 | Page = 2, 42 | Ranked = 3, 43 | Query = 4 44 | } 45 | #endregion 46 | #pragma warning disable CA1055 // Uri return values should not be strings 47 | public static string GetFileForUrl(string url) 48 | #pragma warning restore CA1055 // Uri return values should not be strings 49 | { 50 | Uri urlAsUri = string.IsNullOrEmpty(url) ? null : new Uri(url); 51 | return GetFileForUrl(urlAsUri); 52 | } 53 | 54 | public static string GetFileForUrl(Uri url) 55 | { 56 | if (url == null) 57 | throw new ArgumentNullException(nameof(url), "url cannot be null for MockHttpContent.GetFileForUrl"); 58 | var urlStr = url.ToString().ToLower(); 59 | string directory = "Data"; 60 | string path = string.Empty; 61 | IEnumerable files = null; 62 | if (urlStr.Contains("beatsaver.com")) 63 | { 64 | directory = Path.Combine(directory, "BeatSaver"); 65 | var match = BeatSaverRegex.Match(urlStr); 66 | var uploader = match.Groups[(int)BeatSaverGroup.Uploader]?.Value; 67 | var pageStr = match.Groups[(int)BeatSaverGroup.Page].Value; 68 | var page = string.IsNullOrEmpty(pageStr) ? 0 : int.Parse(pageStr); 69 | var query = match.Groups[(int)BeatSaverGroup.Query]?.Value; 70 | throw new NotImplementedException(); 71 | //path = files.Single().FullName; 72 | } 73 | else if (urlStr.Contains("bsaber.com")) 74 | { 75 | var match = BeastSaberRegex.Match(urlStr); 76 | var username = match.Groups[(int)BeastSaberGroup.Username].Value; 77 | var pageStr = match.Groups[(int)BeastSaberGroup.Page].Value; 78 | var page = string.IsNullOrEmpty(pageStr) ? 0 : int.Parse(pageStr); 79 | var songsPerPageStr = match.Groups[(int)BeastSaberGroup.SongsPerPage]?.Value; 80 | var SongsPerPage = string.IsNullOrEmpty(songsPerPageStr) ? 20 : int.Parse(songsPerPageStr); 81 | 82 | directory = Path.Combine(directory, "BeastSaber"); 83 | //var dInfo = new DirectoryInfo(directory); 84 | files = new DirectoryInfo(directory).GetFiles(); 85 | if (urlStr.Contains("followings")) 86 | files = files.Where(f => f.Name.Contains("followings") && f.Name.Contains(page.ToString())); 87 | else if (urlStr.Contains("bookmarked")) 88 | { 89 | files = files.Where(f => f.Name.Contains("bookmarked")); 90 | if (urlStr.Contains("curator")) 91 | files = files.Where(f => f.Name.Contains("curator") && f.Name.Contains(page.ToString())); 92 | else 93 | files = files.Where(f => !f.Name.Contains("curator") && f.Name.Contains(page.ToString())); 94 | } 95 | else 96 | files = null; 97 | path = files?.FirstOrDefault()?.FullName ?? string.Empty; 98 | } 99 | else if (urlStr.Contains("scoresaber.com")) 100 | { 101 | var match = ScoreSaberRegex.Match(urlStr); 102 | var catStr = match.Groups[(int)ScoreSaberGroup.Catalog].Value; 103 | var pageStr = match.Groups[(int)ScoreSaberGroup.Page].Value; 104 | var rankedStr = match.Groups[(int)ScoreSaberGroup.Ranked].Value; 105 | 106 | var catalog = string.IsNullOrEmpty(catStr) ? 0 : int.Parse(catStr); 107 | var page = string.IsNullOrEmpty(pageStr) ? 0 : int.Parse(pageStr); 108 | var ranked = string.IsNullOrEmpty(rankedStr) ? 0 : int.Parse(rankedStr); 109 | var query = match.Groups[(int)ScoreSaberGroup.Query].Value; 110 | 111 | directory = Path.Combine(directory, "ScoreSaber"); 112 | files = new DirectoryInfo(directory).GetFiles(); 113 | throw new NotImplementedException(); 114 | //path = files.Single().FullName; 115 | } 116 | 117 | return path; 118 | } 119 | 120 | public MockHttpResponse(Uri url, Dictionary> headers) 121 | : this(url) 122 | { 123 | _headers = headers; 124 | } 125 | public MockHttpResponse(Uri url) 126 | { 127 | if (_headers == null) 128 | _headers = new Dictionary>(); 129 | OriginalUri = url; 130 | FileSourcePath = GetFileForUrl(url); 131 | Content = new MockHttpContent(FileSourcePath); 132 | 133 | if (!File.Exists(FileSourcePath)) 134 | { 135 | StatusCode = HttpStatusCode.NotFound; 136 | ReasonPhrase = "Not Found"; 137 | } 138 | } 139 | 140 | 141 | public string FileSourcePath { get; set; } 142 | 143 | public Uri OriginalUri { get; set; } 144 | 145 | public HttpStatusCode StatusCode { get; set; } 146 | 147 | public string ReasonPhrase { get; set; } 148 | 149 | public bool IsSuccessStatusCode { get; set; } 150 | 151 | private IWebResponseContent _content; 152 | public IWebResponseContent Content 153 | { 154 | get 155 | { 156 | return _content; 157 | } 158 | set 159 | { 160 | _content = value; 161 | } 162 | } 163 | 164 | private Dictionary> _headers; 165 | public ReadOnlyDictionary> Headers 166 | { 167 | get { return new ReadOnlyDictionary>(_headers); } 168 | } 169 | 170 | #region IDisposable Support 171 | private bool disposedValue = false; // To detect redundant calls 172 | 173 | protected virtual void Dispose(bool disposing) 174 | { 175 | if (!disposedValue) 176 | { 177 | if (disposing) 178 | { 179 | //if (httpClient != null) 180 | //{ 181 | // httpClient.Dispose(); 182 | // httpClient = null; 183 | //} 184 | } 185 | disposedValue = true; 186 | } 187 | } 188 | 189 | public void Dispose() 190 | { 191 | Dispose(true); 192 | GC.SuppressFinalize(this); 193 | } 194 | #endregion 195 | 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /FeedReaderTests/MockClasses/MockResponseTemplates/BeastSaberResponseTemplate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Text; 5 | 6 | namespace FeedReaderTests.MockClasses.MockResponseTemplates 7 | { 8 | public class BeastSaberResponseTemplate : IMockResponseTemplate 9 | { 10 | public BeastSaberResponseTemplate() 11 | { 12 | BuildResponsesDictionary(); 13 | } 14 | 15 | public void LoadResponse(ref MockHttpResponse response, ref MockHttpContent content, ResponseType responseType) 16 | { 17 | if (response == null) 18 | throw new ArgumentNullException(nameof(response), "response cannot be null in BeastSaberResponseTemplate.LoadResponse."); 19 | if (content == null) 20 | throw new ArgumentNullException(nameof(content), "content cannot be null in BeastSaberResponseTemplate.LoadResponse."); 21 | 22 | 23 | 24 | } 25 | 26 | public Dictionary> Responses { get; private set; } 27 | public Dictionary> Contents { get; private set; } 28 | 29 | 30 | #region Responses 31 | public void BuildResponsesDictionary() 32 | { 33 | if (Responses != null) 34 | return; 35 | Responses = new Dictionary>() 36 | { 37 | { ResponseType.Normal, GetNormalResponse() }, 38 | { ResponseType.NotFound, GetNotFoundResponse() }, 39 | { ResponseType.BadGateway, GetBadGatewayResponse() }, 40 | { ResponseType.RateLimitExceeded, GetRateLimitExceededResponse() } 41 | }; 42 | 43 | } 44 | 45 | private static Dictionary GetNormalResponse() 46 | { 47 | return new Dictionary() 48 | { 49 | {"StatusCode", HttpStatusCode.OK }, 50 | {"ReasonPhrase", "OK" }, 51 | {"IsSuccessStatusCode", true } 52 | }; 53 | } 54 | 55 | private static Dictionary GetNotFoundResponse() 56 | { 57 | return new Dictionary() 58 | { 59 | {"StatusCode", HttpStatusCode.NotFound }, 60 | {"ReasonPhrase", "NotFound" }, 61 | {"IsSuccessStatusCode", false } 62 | }; 63 | } 64 | 65 | private static Dictionary GetBadGatewayResponse() 66 | { 67 | return new Dictionary() 68 | { 69 | {"StatusCode", HttpStatusCode.BadGateway }, 70 | {"ReasonPhrase", "BadGateway" }, // Probably wrong 71 | {"IsSuccessStatusCode", false } 72 | }; 73 | } 74 | 75 | private static Dictionary GetRateLimitExceededResponse() 76 | { 77 | return new Dictionary() 78 | { 79 | {"StatusCode", HttpStatusCode.TooManyRequests }, 80 | {"ReasonPhrase", "Rate limit exceeded" }, // Probably wrong, maybe doesn't exist 81 | {"IsSuccessStatusCode", false } 82 | }; 83 | } 84 | #endregion 85 | 86 | #region Contents 87 | public void BuildContentsDictionary() 88 | { 89 | if (Contents != null) 90 | return; 91 | Contents = new Dictionary>() 92 | { 93 | { ResponseType.Normal, GetNormalResponse() }, 94 | { ResponseType.NotFound, GetNotFoundResponse() }, 95 | { ResponseType.BadGateway, GetBadGatewayResponse() }, 96 | { ResponseType.RateLimitExceeded, GetRateLimitExceededResponse() } 97 | }; 98 | 99 | } 100 | 101 | private Dictionary GetNormalContent() 102 | { 103 | return new Dictionary() 104 | { 105 | {"StatusCode", HttpStatusCode.OK }, 106 | {"ReasonPhrase", "OK" }, 107 | {"IsSuccessStatusCode", true } 108 | }; 109 | } 110 | #endregion 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /FeedReaderTests/MockClasses/MockTests/MockHttpContentTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using System.Collections; 3 | using System.Linq; 4 | using System.IO; 5 | using FeedReader; 6 | using Newtonsoft.Json.Linq; 7 | using System; 8 | using System.Threading.Tasks; 9 | 10 | namespace FeedReaderTests.MockClasses.MockTests 11 | { 12 | [TestClass] 13 | public class MockHttpContentTests 14 | { 15 | public static readonly string DownloadPath = "MockDownloads"; 16 | 17 | [TestMethod] 18 | public void ReadAsStringAsync_Test() 19 | { 20 | var dataDirectory = @"Data\BeastSaber"; 21 | var jsonFile = Path.Combine(dataDirectory, "bookmarked_by_zingabopp1.json"); 22 | using (var mockContent = new MockHttpContent(jsonFile)) 23 | { 24 | var expectedString = File.ReadAllText(mockContent.FileSourcePath); 25 | var actualString = mockContent.ReadAsStringAsync().Result; 26 | Assert.AreEqual(expectedString, actualString); 27 | } 28 | } 29 | 30 | [TestMethod] 31 | public void ReadAsStreamAsync_Test() 32 | { 33 | var dataDirectory = @"Data\BeastSaber"; 34 | var jsonFile = Path.Combine(dataDirectory, "bookmarked_by_zingabopp1.json"); 35 | using (var mockContent = new MockHttpContent(jsonFile)) 36 | using (var actualStream = mockContent.ReadAsStreamAsync().Result) 37 | using (var expectedStream = File.OpenRead(jsonFile)) 38 | { 39 | Assert.AreEqual(expectedStream.Length, actualStream.Length); 40 | } 41 | } 42 | 43 | [TestMethod] 44 | public void ReadAsByteArrayAsync_Test() 45 | { 46 | var dataDirectory = @"Data\BeastSaber"; 47 | var jsonFile = Path.Combine(dataDirectory, "bookmarked_by_zingabopp1.json"); 48 | using (var mockContent = new MockHttpContent(jsonFile)) 49 | { 50 | var expectedArray = File.ReadAllBytes(mockContent.FileSourcePath); 51 | var actualArray = mockContent.ReadAsByteArrayAsync().Result; 52 | bool notEqual = false; 53 | Assert.AreEqual(expectedArray.LongLength, actualArray.LongLength); 54 | Assert.IsTrue(actualArray.LongLength > 0); 55 | for (long i = 0; i < expectedArray.LongLength; i++) 56 | { 57 | if (expectedArray[i] != actualArray[i]) 58 | notEqual = true; 59 | } 60 | Assert.IsFalse(notEqual); 61 | } 62 | } 63 | 64 | [TestMethod] 65 | public void ReadAsFile_Test() 66 | { 67 | var dataDirectory = @"Data\BeastSaber"; 68 | var jsonFile = Path.Combine(dataDirectory, "bookmarked_by_zingabopp1.json"); 69 | using (var mockContent = new MockHttpContent(jsonFile)) 70 | { 71 | var expectedString = File.ReadAllText(mockContent.FileSourcePath); 72 | var dirPath = new DirectoryInfo(DownloadPath); 73 | var destPath = Path.Combine(dirPath.FullName, Path.GetFileName(mockContent.FileSourcePath)); 74 | mockContent.ReadAsFileAsync(destPath, true).Wait(); 75 | var actualString = mockContent.ReadAsStringAsync().Result; 76 | Assert.AreEqual(expectedString, actualString); 77 | AssertAsync.ThrowsExceptionAsync(async () => await mockContent.ReadAsFileAsync(destPath, false).ConfigureAwait(false)).Wait(); 78 | } 79 | } 80 | 81 | [TestMethod] 82 | public void ContentType_Test() 83 | { 84 | var dataDirectory = @"Data\BeastSaber"; 85 | var jsonFile = Path.Combine(dataDirectory, "bookmarked_by_zingabopp1.json"); 86 | var xmlFile = Path.Combine(dataDirectory, "followings1.xml"); 87 | using (var mockContent = new MockHttpContent(jsonFile)) 88 | { 89 | var expectedContentType = @"application/json"; 90 | Assert.AreEqual(expectedContentType, mockContent.ContentType); 91 | } 92 | using (var mockContent = new MockHttpContent(xmlFile)) 93 | { 94 | var expectedContentType = @"text/xml"; 95 | Assert.AreEqual(expectedContentType, mockContent.ContentType); 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /FeedReaderTests/MockClasses/MockTests/MockHttpResponseTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using System.Collections; 3 | using System.Linq; 4 | using System.IO; 5 | using FeedReader; 6 | using Newtonsoft.Json.Linq; 7 | 8 | namespace FeedReaderTests.MockClasses.MockTests 9 | { 10 | [TestClass] 11 | public class MockHttpResponseTests 12 | { 13 | 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /FeedReaderTests/MockClasses/MockTests/MockStaticTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using System.Collections; 3 | using System.Linq; 4 | using System.IO; 5 | using FeedReader; 6 | using Newtonsoft.Json.Linq; 7 | using System; 8 | 9 | namespace FeedReaderTests.MockClasses.MockTests 10 | { 11 | [TestClass] 12 | public class MockStaticTests 13 | { 14 | [TestMethod] 15 | public void GetFileForUrl_BeastSaber_Bookmarked() 16 | { 17 | var testUrl = new Uri(@"https://bsaber.com/wp-json/bsaber-api/songs/?bookmarked_by=Zingabopp&page=2&count=15"); 18 | string fileMatch = "bookmarked_by_zingabopp2.json"; 19 | var file = new FileInfo(MockHttpResponse.GetFileForUrl(testUrl)); 20 | Assert.AreEqual(file.Name, fileMatch); 21 | 22 | testUrl = new Uri(@"https://bsaber.com/wp-json/bsaber-api/songs/?bookmarked_by=Zingabopp&page=3&count=15"); 23 | fileMatch = "bookmarked_by_zingabopp3_empty.json"; 24 | file = new FileInfo(MockHttpResponse.GetFileForUrl(testUrl)); 25 | Assert.AreEqual(file.Name, fileMatch); 26 | } 27 | 28 | [TestMethod] 29 | public void GetFileForUrl_BeastSaber_Followings() 30 | { 31 | var testUrl = new Uri(@"https://bsaber.com/members/zingabopp/wall/followings/feed/?acpage=1&count=20"); 32 | string fileMatch = "followings1.xml"; 33 | var file = new FileInfo(MockHttpResponse.GetFileForUrl(testUrl)); 34 | Assert.AreEqual(file.Name, fileMatch); 35 | 36 | testUrl = new Uri(@"https://bsaber.com/members/zingabopp/wall/followings/feed/?acpage=8"); 37 | fileMatch = "followings8_partial.xml"; 38 | file = new FileInfo(MockHttpResponse.GetFileForUrl(testUrl)); 39 | Assert.AreEqual(file.Name, fileMatch); 40 | } 41 | 42 | [TestMethod] 43 | public void GetFileForUrl_BeastSaber_Curator() 44 | { 45 | var testUrl = new Uri(@"https://bsaber.com/wp-json/bsaber-api/songs/?bookmarked_by=curatorrecommended&page=1&count=50"); 46 | string fileMatch = "bookmarked_by_curator1.json"; 47 | var file = new FileInfo(MockHttpResponse.GetFileForUrl(testUrl)); 48 | Assert.AreEqual(file.Name, fileMatch); 49 | 50 | testUrl = new Uri(@"https://bsaber.com/wp-json/bsaber-api/songs/?bookmarked_by=curatorrecommended&page=4"); 51 | fileMatch = "bookmarked_by_curator4_partial.json"; 52 | file = new FileInfo(MockHttpResponse.GetFileForUrl(testUrl)); 53 | Assert.AreEqual(file.Name, fileMatch); 54 | } 55 | 56 | [TestMethod] 57 | public void GetFileForUrl_EmptyString() 58 | { 59 | var testUrl = string.Empty; 60 | #pragma warning disable CA2234 // Pass system uri objects instead of strings 61 | Assert.ThrowsException(() => MockHttpResponse.GetFileForUrl(testUrl)); 62 | #pragma warning restore CA2234 // Pass system uri objects instead of strings 63 | } 64 | 65 | [TestMethod] 66 | public void GetFileForUrl_Null_Uri() 67 | { 68 | Uri testUri = null; 69 | Assert.ThrowsException(() => MockHttpResponse.GetFileForUrl(testUri)); 70 | } 71 | 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /FeedReaderTests/MockClasses/MockTests/MockWebClientTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using System.Collections; 3 | using System.Linq; 4 | using System.IO; 5 | using FeedReader; 6 | using WebUtilities; 7 | using Newtonsoft.Json.Linq; 8 | using System; 9 | 10 | namespace FeedReaderTests.MockClasses.MockTests 11 | { 12 | [TestClass] 13 | public class MockWebClientTests 14 | { 15 | [TestMethod] 16 | public void GetAsync_PageNotFound() 17 | { 18 | using (var mockClient = new MockWebClient()) 19 | using (var realClient = new HttpClientWrapper()) 20 | { 21 | var testUrl = new Uri("https://bsaber.com/wp-jsoasdfn/bsabasdfer-api/songs/"); 22 | WebUtils.Initialize(); 23 | using (var realResponse = realClient.GetAsync(testUrl).Result) 24 | using (var mockResponse = mockClient.GetAsync(testUrl).Result) 25 | { 26 | var test = realResponse.Content.ReadAsStringAsync().Result; 27 | Assert.AreEqual(realResponse.IsSuccessStatusCode, mockResponse.IsSuccessStatusCode); 28 | Assert.AreEqual(realResponse.StatusCode, mockResponse.StatusCode); 29 | Assert.AreEqual(realResponse.Content.ContentType, mockResponse.Content.ContentType); 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /FeedReaderTests/MockClasses/MockWebClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using WebUtilities; 7 | 8 | namespace FeedReaderTests.MockClasses 9 | { 10 | public class MockWebClient : IWebClient 11 | { 12 | public int Timeout { get; set; } 13 | public ErrorHandling ErrorHandling { get; set; } 14 | 15 | public Task GetAsync(Uri uri, bool completeOnHeaders, CancellationToken cancellationToken) 16 | { 17 | //var content = new MockHttpContent(url); 18 | #pragma warning disable CA2000 // Dispose objects before losing scope 19 | var response = new MockHttpResponse(uri); 20 | #pragma warning restore CA2000 // Dispose objects before losing scope 21 | return Task.Run(() => { return (IWebResponseMessage)response; }); 22 | } 23 | 24 | #region GetAsyncOverloads 25 | public Task GetAsync(string url, bool completeOnHeaders, CancellationToken cancellationToken) 26 | { 27 | var urlAsUri = string.IsNullOrEmpty(url) ? null : new Uri(url); 28 | return GetAsync(urlAsUri, completeOnHeaders, cancellationToken); 29 | } 30 | public Task GetAsync(string url) 31 | { 32 | var urlAsUri = string.IsNullOrEmpty(url) ? null : new Uri(url); 33 | return GetAsync(urlAsUri, false, CancellationToken.None); 34 | } 35 | public Task GetAsync(string url, bool completeOnHeaders) 36 | { 37 | var urlAsUri = string.IsNullOrEmpty(url) ? null : new Uri(url); 38 | return GetAsync(urlAsUri, completeOnHeaders, CancellationToken.None); 39 | } 40 | public Task GetAsync(string url, CancellationToken cancellationToken) 41 | { 42 | var urlAsUri = string.IsNullOrEmpty(url) ? null : new Uri(url); 43 | return GetAsync(urlAsUri, false, cancellationToken); 44 | } 45 | 46 | public Task GetAsync(Uri uri) 47 | { 48 | return GetAsync(uri, false, CancellationToken.None); 49 | } 50 | public Task GetAsync(Uri uri, bool completeOnHeaders) 51 | { 52 | return GetAsync(uri, completeOnHeaders, CancellationToken.None); 53 | } 54 | public Task GetAsync(Uri uri, CancellationToken cancellationToken) 55 | { 56 | return GetAsync(uri, false, cancellationToken); 57 | } 58 | #endregion 59 | 60 | #region IDisposable Support 61 | private bool disposedValue = false; // To detect redundant calls 62 | 63 | protected virtual void Dispose(bool disposing) 64 | { 65 | if (!disposedValue) 66 | { 67 | if (disposing) 68 | { 69 | //if (httpClient != null) 70 | //{ 71 | // httpClient.Dispose(); 72 | // httpClient = null; 73 | //} 74 | } 75 | disposedValue = true; 76 | } 77 | } 78 | 79 | public void Dispose() 80 | { 81 | Dispose(true); 82 | GC.SuppressFinalize(this); 83 | } 84 | #endregion 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /FeedReaderTests/ScoreSaberReaderTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using System.Collections; 3 | using System.Linq; 4 | using System.IO; 5 | using FeedReader; 6 | using Newtonsoft.Json.Linq; 7 | using System; 8 | 9 | namespace FeedReaderTests 10 | { 11 | [TestClass] 12 | public class ScoreSaberReaderTests 13 | { 14 | static ScoreSaberReaderTests() 15 | { 16 | if (!WebUtils.IsInitialized) 17 | WebUtils.Initialize(); 18 | } 19 | 20 | [TestMethod] 21 | public void GetSongsFromFeed_Trending() 22 | { 23 | var reader = new ScoreSaberReader(); 24 | int maxSongs = 100; 25 | var settings = new ScoreSaberFeedSettings((int)ScoreSaberFeed.Trending) { MaxSongs = maxSongs, SongsPerPage = 40, RankedOnly = true }; 26 | var songList = reader.GetSongsFromFeed(settings); 27 | Assert.IsTrue(songList.Count == maxSongs); 28 | Assert.IsFalse(songList.Keys.Any(k => string.IsNullOrEmpty(k))); 29 | } 30 | 31 | [TestMethod] 32 | public void GetSongsFromPageText() 33 | { 34 | var reader = new ScoreSaberReader() { StoreRawData = true }; 35 | var pageText = File.ReadAllText("Data\\ScoreSaberPage.json"); 36 | Uri sourceUri = null; 37 | var songList = reader.GetSongsFromPageText(pageText, sourceUri); 38 | Assert.IsTrue(songList.Count == 50); 39 | var firstHash = "0597F8F7D8E396EBFEF511DC9EC98B69635CE532"; 40 | Assert.IsTrue(songList.First().Hash == firstHash); 41 | var firstRawData = JToken.Parse(songList.First().RawData); 42 | Assert.IsTrue(firstRawData["uid"]?.Value() == 143199); 43 | var lastHash = "F369747C6B54914DEAA163AAE85816BA5A8C1845"; 44 | Assert.IsTrue(songList.Last().Hash == lastHash); 45 | } 46 | 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 brian91292 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Replacement Released: 2 | [BeatSyncConsole](https://github.com/Zingabopp/BeatSync/releases) is replacing SyncSaberService, and it's finally usable. 3 | 4 | # SyncSaberService 5 | Automatically downloads Beat Saber maps 6 | 7 | Based on SyncSaber by brian, https://github.com/brian91292/SyncSaber/. 8 | 9 | This is currently a standalone application you can run to automatically download maps like the original SyncSaber did. 10 | 11 | # Usage 12 |

Extract the zip to a folder of your choosing (avoid the Program Files folders unless you know how to deal with NTFS file permissions). It is recommended to adjust the default configuration in SyncSaberService.ini (to avoid downloading songs you don't want) then run SyncSaberConsole.exe

13 | 14 | # Configuration 15 |

The app's settings are in the SyncSaberService.ini file in the same folder as the executable

16 |

You must add your BeastSaber username if you want to download your bookmarks and following feeds.

17 |

SyncSaberService should automatically find your Beat Saber folder. If it doesn't, you can either manually enter your Beat Saber game's folder or drag and drop the folder onto SyncSaberService.exe to provide your game directory.

18 |

SyncSaberService uses the same FavoriteMappers.ini in Beat Saber's UserData folder as the original does. Format is a single mapper's name on each line.

19 | 20 | # Frequenty Asked Question(s) 21 |

Why is SyncSaberService skipping songs and not downloading them? When songs are skipped, it's either because SyncSaberService found the songs in your CustomSongs folder or the songs were listed in the SyncSaberHistory.txt located in your Beat Saber UserData folder. The history exists so that SyncSaberService doesn't redownload a song you've previously deleted.

22 | -------------------------------------------------------------------------------- /Status: -------------------------------------------------------------------------------- 1 | 0.0.1,Broken 2 | 0.0.2,Broken 3 | 0.0.3,Broken 4 | 0.1.0,Broken 5 | 0.1.1,Broken 6 | 0.2.0,Outdated 7 | 0.2.1,Outdated 8 | 0.2.2,Latest 9 | -------------------------------------------------------------------------------- /SyncSaberConsole/App.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /SyncSaberConsole/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("SyncSaberConsole")] 9 | [assembly: AssemblyDescription("Automatically download Beat Saber maps.")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct("SyncSaberConsole")] 13 | [assembly: AssemblyCopyright("Copyright © Zingabopp 2019")] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("9f38d628-2649-4890-86d6-419cc225592c")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion("0.2.3")] 36 | [assembly: AssemblyFileVersion("0.2.3")] 37 | -------------------------------------------------------------------------------- /SyncSaberConsole/SyncSaberConsole.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {9F38D628-2649-4890-86D6-419CC225592C} 8 | Exe 9 | SyncSaberConsole 10 | SyncSaberConsole 11 | v4.7.2 12 | 512 13 | true 14 | true 15 | 16 | 17 | AnyCPU 18 | true 19 | full 20 | false 21 | bin\Debug\ 22 | DEBUG;TRACE 23 | prompt 24 | 4 25 | 26 | 27 | AnyCPU 28 | pdbonly 29 | true 30 | bin\Release\ 31 | TRACE 32 | prompt 33 | 4 34 | 35 | 36 | true 37 | bin\x64\Debug\ 38 | DEBUG;TRACE 39 | full 40 | x64 41 | prompt 42 | MinimumRecommendedRules.ruleset 43 | true 44 | 45 | 46 | bin\x64\Release\ 47 | TRACE 48 | true 49 | pdbonly 50 | x64 51 | prompt 52 | MinimumRecommendedRules.ruleset 53 | true 54 | 55 | 56 | true 57 | bin\x86\Debug\ 58 | DEBUG;TRACE 59 | full 60 | x86 61 | prompt 62 | MinimumRecommendedRules.ruleset 63 | true 64 | 65 | 66 | bin\x86\Release\ 67 | TRACE 68 | true 69 | pdbonly 70 | x86 71 | prompt 72 | MinimumRecommendedRules.ruleset 73 | true 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 12.0.2 95 | 96 | 97 | 98 | 99 | {47e9e695-c638-4e4f-ad3d-6e8f6abd983f} 100 | SyncSaberLib 101 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /SyncSaberLib/Config/CustomSetting.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace SyncSaberLib.Config 8 | { 9 | public abstract class CustomSetting 10 | { 11 | private string _name; 12 | public string Name 13 | { 14 | get { return _name; } 15 | set 16 | { 17 | if (string.IsNullOrEmpty(value?.Trim())) 18 | throw new ArgumentException("CustomSetting Name cannot be null or empty."); 19 | _name = value; 20 | } 21 | } 22 | public string Description { get; set; } 23 | public bool Required { get; set; } 24 | public bool Recommended { get; set; } 25 | public abstract object GetValue(); 26 | public abstract void SetValue(object value); 27 | } 28 | 29 | public class CustomSetting 30 | : CustomSetting 31 | { 32 | public T Value { get; set; } 33 | public override object GetValue() 34 | { 35 | return Value; 36 | } 37 | public override void SetValue(object value) 38 | { 39 | if (!typeof(T).IsAssignableFrom(value.GetType())) 40 | throw new InvalidCastException($"Cannot convert {value.GetType()} to type {typeof(T).ToString()}."); 41 | Value = (T)value; 42 | } 43 | } 44 | 45 | public static class CustomSettingExtensions 46 | { 47 | public static T Value(this CustomSetting setting) 48 | { 49 | if (!typeof(T).IsAssignableFrom(setting.GetValue().GetType())) 50 | throw new InvalidCastException($"Cannot convert {setting.GetValue().GetType()} to type {typeof(T).ToString()}."); 51 | return (T)setting.GetValue(); 52 | } 53 | 54 | public static void AddOrUpdate(this Dictionary dict, CustomSetting setting) 55 | { 56 | if (dict.ContainsKey(setting.Name)) 57 | dict[setting.Name].SetValue(setting.GetValue()); 58 | else 59 | dict.Add(setting.Name, setting); 60 | } 61 | } 62 | 63 | 64 | } 65 | -------------------------------------------------------------------------------- /SyncSaberLib/Config/IFeedConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace SyncSaberLib.Config 8 | { 9 | interface IFeedConfig 10 | { 11 | string Name { get; set; } 12 | int FeedIndex { get; set; } 13 | string Description { get; set; } 14 | bool Enabled { get; set; } 15 | int MaxPages { get; set; } 16 | int MaxSongs { get; set; } 17 | int StartingPage { get; set; } 18 | Dictionary CustomSettings { get; set; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /SyncSaberLib/Config/IReaderConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using FeedReader; 7 | 8 | namespace SyncSaberLib.Config 9 | { 10 | /// 11 | /// Interface for a specific FeedReader configuration. 12 | /// 13 | interface IReaderConfig 14 | { 15 | string ReaderName { get; set; } 16 | string ReaderDescription { get; set; } 17 | bool Enabled { get; set; } 18 | Dictionary AvailableFeeds { get; set; } 19 | Dictionary CustomSettings { get; set; } 20 | 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /SyncSaberLib/Config/ISyncSaberLibConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | 8 | /// Songs for SideQuest: 9 | /// AppData\Roaming\SideQuest\bsaber 10 | /// Quest Folder: 11 | /// This PC > Quest > Internal Shared Storage > BeatOnData > CustomSongs 12 | namespace SyncSaberLib.Config 13 | { 14 | /// 15 | /// Overall configuration for SyncSaberLib. 16 | /// 17 | interface ISyncSaberLibConfig 18 | { 19 | IEnumerable FeedConfigs { get; set; } 20 | Dictionary CustomSettings { get; set; } 21 | string ConfigPath { get; } 22 | string ScrapedDataDirectory { get; set; } 23 | string BeatSaberDirectory { get; set; } 24 | string SongDirectoryPath { get; set; } 25 | string LoggingLevel { get; set; } 26 | bool DeleteOldVersions { get; set; } 27 | int DownloadTimeout { get; set; } 28 | int MaxConcurrentDownloads { get; set; } 29 | int MaxConcurrentPageChecks { get; set; } 30 | 31 | 32 | bool SaveChanges(); 33 | 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /SyncSaberLib/Data/BeatSaverScrape.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.IO; 7 | using System.Reflection; 8 | using Newtonsoft.Json; 9 | using System.Text.RegularExpressions; 10 | using SyncSaberLib.Web; 11 | 12 | namespace SyncSaberLib.Data 13 | { 14 | public class BeatSaverScrape : IScrapedDataModel, BeatSaverSong> 15 | { 16 | private readonly object dataLock = new object(); 17 | //[JsonProperty("Data")] 18 | //public List Data { get; private set; } 19 | 20 | public BeatSaverScrape() 21 | { 22 | DefaultPath = Path.Combine(DATA_DIRECTORY.FullName, "BeatSaverScrape.json"); 23 | Initialized = false; 24 | Data = new List(); 25 | 26 | } 27 | 28 | public override void Initialize(string filePath = "") 29 | { 30 | if (string.IsNullOrEmpty(filePath)) 31 | filePath = DefaultPath; 32 | 33 | //(filePath).Populate(this); 34 | if (File.Exists(filePath)) 35 | ReadScrapedFile(filePath).Populate(Data); 36 | //JsonSerializer serializer = new JsonSerializer(); 37 | //if (test.Type == Newtonsoft.Json.Linq.JTokenType.Array) 38 | // Data = test.ToObject>(); 39 | Initialized = true; 40 | CurrentFile = new FileInfo(filePath); 41 | } 42 | /* 43 | public void AddOrUpdate(BeatSaverSong newSong) 44 | { 45 | IEnumerable existing = null; 46 | lock (dataLock) 47 | { 48 | existing = Data.Where(s => s.Equals(newSong)); 49 | } 50 | if (existing.Count() > 1) 51 | { 52 | Logger.Warning("Duplicate hash in BeatSaverScrape, this shouldn't happen"); 53 | } 54 | if (existing.SingleOrDefault() != null) 55 | { 56 | if (existing.Single().ScrapedAt < newSong.ScrapedAt) 57 | { 58 | lock (dataLock) 59 | { 60 | Data.Remove(existing.Single()); 61 | Data.Add(newSong); 62 | } 63 | } 64 | } 65 | } 66 | */ 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SyncSaberLib/Data/IScrapedDataModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.IO; 7 | using Newtonsoft.Json; 8 | using Newtonsoft.Json.Linq; 9 | using System.Reflection; 10 | 11 | namespace SyncSaberLib.Data 12 | { 13 | public abstract class IScrapedDataModel 14 | where T : List, new() where DataType : IEquatable 15 | { 16 | private static readonly string ASSEMBLY_PATH = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); 17 | public static readonly DirectoryInfo DATA_DIRECTORY = new DirectoryInfo(Path.Combine(ASSEMBLY_PATH, "ScrapedData")); 18 | 19 | [JsonIgnore] 20 | public bool Initialized { get; protected set; } 21 | public bool HasData { get { return Data != null && Data.Count > 0; } } 22 | public virtual T Data { get; protected set; } 23 | [JsonIgnore] 24 | public bool ReadOnly { get; protected set; } 25 | [JsonIgnore] 26 | public string DefaultPath { get; protected set; } 27 | [JsonIgnore] 28 | public FileInfo CurrentFile { get; protected set; } 29 | 30 | public virtual JToken ReadScrapedFile(string filePath) 31 | { 32 | JToken results = null; 33 | string backupPath = filePath + ".bak"; 34 | if(File.Exists(backupPath)) // Last write was unsuccessful, delete corrupted file and use the backup 35 | { 36 | if (File.Exists(filePath)) 37 | File.Delete(filePath); 38 | File.Move(backupPath, filePath); 39 | } 40 | if (File.Exists(filePath)) 41 | using (StreamReader file = File.OpenText(filePath)) 42 | { 43 | JsonSerializer serializer = new JsonSerializer(); 44 | //results = (JObject)serializer.Deserialize(file, typeof(JObject)); 45 | results = JToken.Parse(file.ReadToEnd()); 46 | } 47 | return results; 48 | } 49 | 50 | public virtual void WriteFile(string filePath = "") 51 | { 52 | if (string.IsNullOrEmpty(filePath)) 53 | filePath = CurrentFile.FullName; 54 | string backupPath = ""; 55 | //Backup file before overwriting 56 | if (File.Exists(filePath)) 57 | { 58 | backupPath = filePath + ".bak"; 59 | File.Move(filePath, backupPath); 60 | } 61 | using (StreamWriter file = File.CreateText(filePath)) 62 | { 63 | JsonSerializer serializer = new JsonSerializer(); 64 | serializer.Serialize(file, Data); 65 | } 66 | //Write was successful, delete backup 67 | if(!string.IsNullOrEmpty(backupPath) && File.Exists(backupPath)) 68 | { 69 | File.Delete(backupPath); 70 | } 71 | 72 | } 73 | public abstract void Initialize(string filePath = ""); 74 | 75 | 76 | public virtual bool AddOrUpdate(DataType item, bool exceptionOnNull = false) 77 | { 78 | if (Equals(item, default(DataType))) 79 | if (exceptionOnNull) 80 | throw new ArgumentNullException("Item cannot be null."); 81 | else 82 | return false; 83 | DataType added = default; 84 | DataType removed = default; 85 | bool successful = false; 86 | lock (Data) 87 | { 88 | var match = Data.Where(s => s.Equals(item)); 89 | if (match.Count() == 0) 90 | { 91 | //Logger.Debug($"Adding song {song.key} - {song.songName} by {song.authorName} to ScrapedData"); 92 | Data.Add(item); 93 | added = item; 94 | successful = true; 95 | } 96 | else 97 | { 98 | removed = match.First(); 99 | Data.Remove(removed); 100 | Data.Add(item); 101 | added = item; 102 | successful = true; 103 | } 104 | } 105 | if (!(Equals(added, default(DataType)) || Equals(removed, default(DataType)))) 106 | { 107 | CollectionChanged?.Invoke(this, new CollectionChangedEventArgs(added, removed)); 108 | } 109 | return successful; 110 | } 111 | 112 | public event EventHandler> CollectionChanged; 113 | } 114 | 115 | public class CollectionChangedEventArgs 116 | where T : IEquatable 117 | { 118 | public T ItemAdded; 119 | public T ItemRemoved; 120 | public CollectionChangedEventArgs(T Added, T Removed) 121 | { 122 | ItemAdded = Added; 123 | ItemRemoved = Removed; 124 | } 125 | } 126 | 127 | public static class JsonExtensions 128 | { 129 | public static void Populate(this JToken value, T target) where T : class 130 | { 131 | using (var sr = value.CreateReader()) 132 | { 133 | JsonSerializer.CreateDefault().Populate(sr, target); // Uses the system default JsonSerializerSettings 134 | } 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /SyncSaberLib/Data/JsonConverters.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Newtonsoft.Json; 7 | using Newtonsoft.Json.Linq; 8 | using System.Globalization; 9 | 10 | namespace SyncSaberLib.Data 11 | { 12 | // From: https://stackoverflow.com/a/55768479 13 | public class IntegerWithCommasConverter : JsonConverter 14 | { 15 | public override int ReadJson(JsonReader reader, Type objectType, int existingValue, bool hasExistingValue, JsonSerializer serializer) 16 | { 17 | if (reader.TokenType == JsonToken.Null) 18 | { 19 | throw new JsonSerializationException("Cannot unmarshal int"); 20 | } 21 | if (reader.TokenType == JsonToken.Integer) 22 | return Convert.ToInt32(reader.Value); 23 | var value = (string) reader.Value; 24 | const NumberStyles style = NumberStyles.AllowThousands; 25 | var result = int.Parse(value, style, CultureInfo.InvariantCulture); 26 | return result; 27 | } 28 | 29 | public override void WriteJson(JsonWriter writer, int value, JsonSerializer serializer) 30 | { 31 | writer.WriteValue(value); 32 | } 33 | } 34 | 35 | public class EmptyArrayOrDictionaryConverter : JsonConverter 36 | { 37 | public override bool CanConvert(Type objectType) 38 | { 39 | return objectType.IsAssignableFrom(typeof(Dictionary)); 40 | } 41 | 42 | public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) 43 | { 44 | JToken token = JToken.Load(reader); 45 | if (token.Type == JTokenType.Object) 46 | { 47 | return token.ToObject(objectType); 48 | } 49 | else if (token.Type == JTokenType.Array) 50 | { 51 | if (!token.HasValues) 52 | { 53 | // create empty dictionary 54 | return Activator.CreateInstance(objectType); 55 | } 56 | // Handles case where Beat Saver gives the slashstat in the form of an array. 57 | if (objectType == typeof(Dictionary)) 58 | { 59 | var retDict = new Dictionary(); 60 | for (int i = 0; i < token.Count(); i++) 61 | { 62 | retDict.Add(i.ToString(), (int) token.ElementAt(i)); 63 | } 64 | return retDict; 65 | } 66 | } 67 | //throw new JsonSerializationException($"{objectType.ToString()} or empty array expected, received a {token.Type.ToString()}"); 68 | Logger.Warning($"{objectType.ToString()} or empty array expected, received a {token.Type.ToString()}"); 69 | return Activator.CreateInstance(objectType); 70 | } 71 | 72 | public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) 73 | { 74 | serializer.Serialize(writer, value); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /SyncSaberLib/Data/ScoreSaberScrape.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.IO; 7 | using System.Reflection; 8 | using Newtonsoft.Json; 9 | using System.Text.RegularExpressions; 10 | using SyncSaberLib.Web; 11 | 12 | namespace SyncSaberLib.Data 13 | { 14 | public class ScoreSaberScrape : IScrapedDataModel, ScoreSaberSong> 15 | { 16 | private readonly object dataLock = new object(); 17 | public ScoreSaberScrape() 18 | { 19 | Initialized = false; 20 | DefaultPath = Path.Combine(DATA_DIRECTORY.FullName, "ScoreSaberScrape.json"); 21 | } 22 | 23 | public override void Initialize(string filePath = "") 24 | { 25 | if (string.IsNullOrEmpty(filePath)) 26 | filePath = DefaultPath; 27 | Data = new List(); 28 | //(filePath).Populate(this); 29 | if (File.Exists(filePath)) 30 | ReadScrapedFile(filePath).Populate(Data); 31 | //JsonSerializer serializer = new JsonSerializer(); 32 | //if (test.Type == Newtonsoft.Json.Linq.JTokenType.Array) 33 | // Data = test.ToObject>(); 34 | Initialized = true; 35 | CurrentFile = new FileInfo(filePath); 36 | } 37 | /* 38 | public void AddOrUpdate(ScoreSaberSong newSong) 39 | { 40 | IEnumerable existing = null; 41 | lock (dataLock) 42 | { 43 | existing = Data.Where(s => s.Equals(newSong)); 44 | } 45 | if (existing.Count() > 1) 46 | { 47 | Logger.Warning("Duplicate hash in BeatSaverScrape, this shouldn't happen"); 48 | } 49 | if (existing.SingleOrDefault() != null) 50 | { 51 | if (existing.Single().ScrapedAt < newSong.ScrapedAt) 52 | { 53 | lock (dataLock) 54 | { 55 | Data.Remove(existing.Single()); 56 | Data.Add(newSong); 57 | } 58 | } 59 | } 60 | } 61 | */ 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /SyncSaberLib/Data/ScoreSaberSong.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Linq; 3 | using System; 4 | using System.Runtime.Serialization; 5 | 6 | namespace SyncSaberLib.Data 7 | { 8 | public class ScoreSaberSong : IEquatable 9 | { 10 | [JsonIgnore] 11 | public bool Populated { get; private set; } 12 | 13 | public ScoreSaberSong() 14 | { 15 | 16 | } 17 | 18 | public static bool TryParseScoreSaberSong(JToken token, ref ScoreSaberSong song) 19 | { 20 | string songName = token["name"]?.Value(); 21 | if (songName == null) 22 | songName = ""; 23 | bool successful = true; 24 | try 25 | { 26 | song = token.ToObject(new JsonSerializer() { 27 | NullValueHandling = NullValueHandling.Ignore, 28 | MissingMemberHandling = MissingMemberHandling.Ignore 29 | }); 30 | //Logger.Debug(song.ToString()); 31 | } 32 | catch (Exception ex) 33 | { 34 | Logger.Exception($"Unable to create a ScoreSaberSong from the JSON for {songName}\n", ex); 35 | successful = false; 36 | song = null; 37 | } 38 | return successful; 39 | } 40 | [JsonProperty("uid")] 41 | public int uid { get; set; } 42 | [JsonIgnore] 43 | private string _hash; 44 | /// 45 | /// Hash is always uppercase. 46 | /// 47 | [JsonProperty("id")] 48 | public string hash { get { return _hash; } set { _hash = value.ToUpper(); } } 49 | [JsonProperty("name")] 50 | public string name { get; set; } 51 | [JsonProperty("songSubName")] 52 | public string songSubName { get; set; } 53 | [JsonProperty("songAuthorName")] 54 | public string songAuthorName { get; set; } 55 | [JsonProperty("levelAuthorName")] 56 | public string levelAuthorName { get; set; } 57 | 58 | [JsonProperty("bpm")] 59 | public float bpm { get; set; } 60 | 61 | [JsonProperty("diff")] 62 | private string diff { get; set; } 63 | [JsonIgnore] 64 | public string difficulty 65 | { 66 | get { return ConvertDiff(diff); } 67 | } 68 | [JsonProperty("scores")] 69 | [JsonConverter(typeof(IntegerWithCommasConverter))] 70 | public int scores { get; set; } 71 | [JsonProperty("scores_day")] 72 | public int scores_day { get; set; } 73 | [JsonProperty("ranked")] 74 | public bool ranked { get; set; } 75 | [JsonProperty("stars")] 76 | public float stars { get; set; } 77 | [JsonProperty("image")] 78 | public string image { get; set; } 79 | [JsonProperty("ScrapedAt")] 80 | public DateTime ScrapedAt { get; set; } 81 | 82 | [OnDeserialized] 83 | protected void OnDeserialized(StreamingContext context) 84 | { 85 | //if (!(this is ScoreSaberSong)) 86 | //if (!this.GetType().IsSubclassOf(typeof(SongInfo))) 87 | //{ 88 | //Logger.Warning("SongInfo OnDeserialized"); 89 | Populated = true; 90 | //var song = ScrapedDataProvider.SyncSaberScrape.Where(s => s.hash == md5Hash).FirstOrDefault(); 91 | //if (song != null) 92 | // if (song.ScoreSaberInfo.AddOrUpdate(uid, this)) 93 | // Logger.Warning($"Adding the same ScoreSaberInfo {uid}-{difficulty} to song {name}"); 94 | } 95 | 96 | public SongInfo GenerateSongInfo() 97 | { 98 | var newSong = new SongInfo(hash); 99 | /* 100 | var newSong = new SongInfo() { 101 | songName = name, 102 | songSubName = songSubName, 103 | authorName = levelAuthorName, 104 | bpm = bpm 105 | }; 106 | */ 107 | //newSong.ScoreSaberInfo.Add(uid, this); 108 | return newSong; 109 | } 110 | 111 | private const string EASYKEY = "_easy_solostandard"; 112 | private const string NORMALKEY = "_normal_solostandard"; 113 | private const string HARDKEY = "_hard_solostandard"; 114 | private const string EXPERTKEY = "_expert_solostandard"; 115 | private const string EXPERTPLUSKEY = "_expertplus_solostandard"; 116 | public static string ConvertDiff(string diffString) 117 | { 118 | diffString = diffString.ToLower(); 119 | if (!diffString.Contains("solostandard")) 120 | return diffString; 121 | switch (diffString) 122 | { 123 | case EXPERTPLUSKEY: 124 | return "ExpertPlus"; 125 | case EXPERTKEY: 126 | return "Expert"; 127 | case HARDKEY: 128 | return "Hard"; 129 | case NORMALKEY: 130 | return "Normal"; 131 | case EASYKEY: 132 | return "Easy"; 133 | default: 134 | return diffString; 135 | } 136 | } 137 | 138 | public bool Equals(ScoreSaberSong other) 139 | { 140 | return uid == other.uid; 141 | } 142 | } 143 | } 144 | 145 | 146 | //uid 8497 147 | //id "44C9544A577E5B8DC3876F9F696A7F92" 148 | //name "Redo" 149 | //songSubName "Suzuki Konomi" 150 | //author "Splake" 151 | //bpm 190 152 | //diff "_Expert_SoloStandard" 153 | //scores "1,702" 154 | //24hr 8 155 | //ranked 1 156 | //stars 3.03 157 | //image "/imports/images/songs/44C9544A577E5B8DC3876F9F696A7F92.png" 158 | -------------------------------------------------------------------------------- /SyncSaberLib/Data/SongInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | using System.Collections; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using Newtonsoft.Json; 9 | using Newtonsoft.Json.Linq; 10 | using System.Net.Http; 11 | using System.Net; 12 | using System.Reflection; 13 | using System.Runtime.Serialization; 14 | 15 | namespace SyncSaberLib.Data 16 | { 17 | public class SongInfo : IEquatable 18 | { 19 | // Link: https://raw.githubusercontent.com/andruzzzhka/BeatSaberScrappedData/master/combinedScrappedData.json 20 | private static readonly Regex _digitRegex = new Regex("^[0-9]+$", RegexOptions.Compiled); 21 | private static readonly Regex _beatSaverRegex = new Regex("^[0-9]+-[0-9]+$", RegexOptions.Compiled); 22 | public const char IDENTIFIER_DELIMITER = (char) 0x220E; 23 | private const string DOWNLOAD_URL_BASE = "http://beatsaver.com/download/"; 24 | 25 | 26 | [JsonIgnore] 27 | private Dictionary _rankedDiffs; 28 | [JsonIgnore] 29 | public Dictionary RankedDifficulties 30 | { 31 | get 32 | { 33 | if (_rankedDiffs == null) 34 | _rankedDiffs = new Dictionary(); 35 | if (ScoreSaberInfo.Count != _rankedDiffs.Count) // If they don't have the same number of difficulties, remake 36 | { 37 | _rankedDiffs = new Dictionary(); 38 | foreach (var key in ScoreSaberInfo.Keys) 39 | { 40 | if (ScoreSaberInfo[key].ranked) 41 | { 42 | if (hash == ScoreSaberInfo[key].hash) 43 | _rankedDiffs.AddOrUpdate(ScoreSaberInfo[key].difficulty, ScoreSaberInfo[key].stars); 44 | else 45 | Logger.Debug($"Ranked version of {key} is outdated.\n" + 46 | $" {hash} != {ScoreSaberInfo[key].hash}"); 47 | } 48 | } 49 | } 50 | return _rankedDiffs; 51 | } 52 | } 53 | 54 | public BeatSaverSong BeatSaverInfo { get; set; } 55 | 56 | private Dictionary _scoreSaberInfo; 57 | public Dictionary ScoreSaberInfo 58 | { 59 | get 60 | { 61 | if (_scoreSaberInfo == null) 62 | _scoreSaberInfo = new Dictionary(); 63 | return _scoreSaberInfo; 64 | } 65 | set { _scoreSaberInfo = value; } 66 | } 67 | 68 | private string _hash; 69 | /// 70 | /// Hash is always uppercase (or empty). 71 | /// 72 | public string hash 73 | { 74 | get 75 | { 76 | if (string.IsNullOrEmpty(_hash)) 77 | { 78 | if (BeatSaverInfo != null) 79 | _hash = BeatSaverInfo.hash.ToUpper(); 80 | else 81 | { 82 | var ssSong = ScoreSaberInfo.Values.FirstOrDefault(); 83 | if (ssSong != null) 84 | _hash = ssSong.hash.ToUpper(); 85 | } 86 | } 87 | return _hash; 88 | } 89 | } 90 | 91 | public int keyAsInt 92 | { 93 | get 94 | { 95 | if (BeatSaverInfo != null) 96 | return BeatSaverInfo.KeyAsInt; 97 | return 0; 98 | } 99 | } 100 | 101 | public string key 102 | { 103 | get 104 | { 105 | if (BeatSaverInfo != null) 106 | return BeatSaverInfo.key; 107 | return string.Empty; 108 | } 109 | } 110 | 111 | public string songName 112 | { 113 | get 114 | { 115 | if (BeatSaverInfo != null) 116 | return BeatSaverInfo.metadata.songName; 117 | var ssSong = ScoreSaberInfo.Values.FirstOrDefault(); 118 | if (ssSong != null) 119 | return ssSong.name; 120 | return string.Empty; 121 | } 122 | } 123 | 124 | public string authorName 125 | { 126 | get 127 | { 128 | if (BeatSaverInfo != null) 129 | return BeatSaverInfo.uploader.username; 130 | var ssSong = ScoreSaberInfo.Values.FirstOrDefault(); 131 | if (ssSong != null) 132 | return ssSong.levelAuthorName; 133 | return string.Empty; 134 | } 135 | } 136 | 137 | public float bpm 138 | { 139 | get 140 | { 141 | if (BeatSaverInfo != null) 142 | return BeatSaverInfo.metadata.bpm; 143 | var ssSong = ScoreSaberInfo.Values.FirstOrDefault(); 144 | if (ssSong != null) 145 | return ssSong.bpm; 146 | return 0; 147 | } 148 | } 149 | 150 | 151 | /* 152 | [JsonIgnore] 153 | private string _identifier; 154 | [JsonIgnore] 155 | public string Identifier 156 | { 157 | get 158 | { 159 | if (string.IsNullOrEmpty(_identifier)) 160 | { 161 | if (string.IsNullOrEmpty(hash)) 162 | return string.Empty; 163 | if (string.IsNullOrEmpty(songName)) 164 | return string.Empty; 165 | if (string.IsNullOrEmpty(songSubName)) 166 | return string.Empty; 167 | if (string.IsNullOrEmpty(authorName)) 168 | return string.Empty; 169 | if (bpm <= 0) 170 | return string.Empty; 171 | _identifier = string.Join(IDENTIFIER_DELIMITER.ToString(), new string[] { 172 | hash, 173 | songName, 174 | songSubName, 175 | authorName, 176 | bpm.ToString() 177 | }); 178 | } 179 | return _identifier; 180 | } 181 | } 182 | */ 183 | //public SongInfo() { } 184 | public SongInfo(string hash) 185 | { 186 | _hash = hash.ToUpper(); 187 | } 188 | 189 | public override string ToString() 190 | { 191 | return hash; 192 | } 193 | 194 | public object this[string propertyName] 195 | { 196 | get 197 | { 198 | Type myType = typeof(SongInfo); 199 | object retVal; 200 | FieldInfo field = myType.GetField(propertyName); 201 | if (field != null) 202 | { 203 | retVal = field.GetValue(this); 204 | } 205 | else 206 | { 207 | PropertyInfo myPropInfo = myType.GetProperty(propertyName); 208 | retVal = myPropInfo.GetValue(this); 209 | } 210 | 211 | Type whatType = retVal.GetType(); 212 | return retVal; 213 | } 214 | set 215 | { 216 | Type myType = typeof(SongInfo); 217 | PropertyInfo myPropInfo = myType.GetProperty(propertyName); 218 | myPropInfo.SetValue(this, value, null); 219 | } 220 | } 221 | 222 | public bool Equals(SongInfo other) 223 | { 224 | return hash.ToUpper() == other.hash.ToUpper(); 225 | } 226 | } 227 | 228 | 229 | 230 | } 231 | -------------------------------------------------------------------------------- /SyncSaberLib/Data/SongInfoProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace SyncSaberLib.Data 8 | { 9 | public static class SongInfoProvider 10 | { 11 | public static BeatSaverScrape BeatSaverSongs { get; set; } 12 | public static ScoreSaberScrape ScoreSaberSongs { get; set; } 13 | 14 | public static void Initialize() 15 | { 16 | 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /SyncSaberLib/Data/SyncSaberScrape.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.IO; 7 | using System.Reflection; 8 | using Newtonsoft.Json; 9 | using System.Text.RegularExpressions; 10 | using SyncSaberLib.Web; 11 | 12 | namespace SyncSaberLib.Data 13 | { 14 | [Obsolete("Replacing with separate classes for Beat Saver and ScoreSaber data")] 15 | public class SyncSaberScrape : IScrapedDataModel, SongInfo> 16 | { 17 | private static readonly string ASSEMBLY_PATH = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); 18 | 19 | public SyncSaberScrape() 20 | { 21 | Data = new List(); 22 | DefaultPath = Path.Combine(DATA_DIRECTORY.FullName, "SyncSaberScrapedData.json"); 23 | } 24 | 25 | public override void Initialize(string filePath = "") 26 | { 27 | if (string.IsNullOrEmpty(filePath)) 28 | filePath = DefaultPath; 29 | Data = new List(); 30 | //(filePath).Populate(this); 31 | if(File.Exists(filePath)) 32 | ReadScrapedFile(filePath).Populate(this); 33 | //JsonSerializer serializer = new JsonSerializer(); 34 | //if (test.Type == Newtonsoft.Json.Linq.JTokenType.Array) 35 | // Data = test.ToObject>(); 36 | Initialized = true; 37 | CurrentFile = new FileInfo(filePath); 38 | } 39 | 40 | 41 | public override void WriteFile(string filePath = "") 42 | { 43 | if (string.IsNullOrEmpty(filePath)) 44 | filePath = CurrentFile.FullName; 45 | using (StreamWriter file = File.CreateText(filePath)) 46 | { 47 | JsonSerializer serializer = new JsonSerializer(); 48 | serializer.Serialize(file, this); 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /SyncSaberLib/Playlist.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.IO; 7 | using Newtonsoft.Json; 8 | using Newtonsoft.Json.Linq; 9 | 10 | namespace SyncSaberLib 11 | { 12 | 13 | [Serializable] 14 | public class Playlist 15 | { 16 | public Playlist(string playlistFileName, string playlistTitle, string playlistAuthor, string image) 17 | { 18 | fileName = playlistFileName; 19 | Title = playlistTitle; 20 | Author = playlistAuthor; 21 | Image = image; 22 | Songs = new List(); 23 | fileLoc = ""; 24 | ReadPlaylist(); 25 | } 26 | 27 | public void TryAdd(string songHash, string songIndex, string songName) 28 | { 29 | if (!Songs.Exists(s => !string.IsNullOrEmpty(s.hash) && s.hash.ToUpper() == songHash.ToUpper())) 30 | { 31 | Songs.Add(new PlaylistSong(songHash, songIndex, songName)); 32 | // Remove any duplicate song that doesn't have a hash 33 | var oldSongs = Songs.Where(s => string.IsNullOrEmpty(s.hash) && !string.IsNullOrEmpty(s.key) && s.key.ToLower() == songIndex.ToLower()).ToArray(); 34 | foreach (var song in oldSongs) 35 | { 36 | Songs.Remove(song); 37 | } 38 | } 39 | } 40 | 41 | public void WritePlaylist() 42 | { 43 | PlaylistIO.WritePlaylist(this); 44 | } 45 | 46 | public bool ReadPlaylist() 47 | { 48 | string oldFormatPath = Path.Combine(OldConfig.BeatSaberPath, "Playlists", fileName + ".json"); 49 | string newFormatPath = Path.Combine(OldConfig.BeatSaberPath, "Playlists", fileName + ".bplist"); 50 | oldFormat = !File.Exists(newFormatPath); 51 | Logger.Info($"Playlist {Title} found in {(oldFormat ? "old" : "new")} playlist format."); 52 | if (File.Exists(oldFormat ? oldFormatPath : newFormatPath)) 53 | { 54 | 55 | PlaylistIO.ReadPlaylistSongs(this); 56 | /* 57 | if (playlist != null) 58 | { 59 | Title = playlist.Title; 60 | Author = playlist.Author; 61 | Image = playlist.Image; 62 | Songs = playlist.Songs; 63 | fileLoc = playlist.fileLoc; 64 | Logger.Info("Success loading playlist!"); 65 | return true; 66 | }*/ 67 | } 68 | return false; 69 | } 70 | 71 | [JsonProperty("playlistTitle")] 72 | public string Title; 73 | [JsonProperty("playlistAuthor")] 74 | public string Author; 75 | 76 | [JsonProperty("image")] 77 | public string Image; 78 | 79 | [JsonProperty("songs")] 80 | public List Songs; 81 | 82 | [JsonProperty("fileLoc")] 83 | public string fileLoc; 84 | [JsonIgnore] 85 | public string fileName; 86 | [JsonIgnore] 87 | public bool oldFormat = true; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /SyncSaberLib/PlaylistIO.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | //using SimpleJSON; 7 | using System.IO; 8 | using Newtonsoft.Json; 9 | using Newtonsoft.Json.Linq; 10 | 11 | 12 | namespace SyncSaberLib 13 | { 14 | class PlaylistIO 15 | { 16 | public static Playlist ReadPlaylistSongs(Playlist playlist) 17 | { 18 | try 19 | { 20 | string filePath = Path.Combine(OldConfig.BeatSaberPath, "Playlists", playlist.fileName + (playlist.oldFormat ? ".json" : ".bplist")); 21 | //var playListJson = JObject.Parse(File.ReadAllText(filePath)); 22 | JsonConvert.PopulateObject(File.ReadAllText(filePath), playlist); 23 | playlist.fileLoc = null; 24 | 25 | return playlist; 26 | } 27 | catch (Exception ex) 28 | { 29 | Logger.Exception("Exception parsing playlist:", ex); 30 | } 31 | return null; 32 | } 33 | 34 | public static void WritePlaylist(Playlist playlist) 35 | { 36 | 37 | if (!Directory.Exists(Path.Combine(OldConfig.BeatSaberPath, "Playlists"))) 38 | { 39 | Directory.CreateDirectory(Path.Combine(OldConfig.BeatSaberPath, "Playlists")); 40 | } 41 | var jsonString = JsonConvert.SerializeObject(playlist); 42 | File.WriteAllText(Path.Combine(OldConfig.BeatSaberPath, "Playlists", playlist.fileName + (playlist.oldFormat ? ".json" : ".bplist")), jsonString); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /SyncSaberLib/PlaylistSong.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Newtonsoft.Json; 7 | using Newtonsoft.Json.Linq; 8 | 9 | namespace SyncSaberLib 10 | { 11 | [Serializable] 12 | public class PlaylistSong 13 | { 14 | public PlaylistSong(string _hash, string _songIndex, string _songName) 15 | { 16 | hash = _hash; 17 | key = _songIndex; 18 | songName = _songName; 19 | } 20 | 21 | [JsonProperty("key")] 22 | public string key; 23 | 24 | [JsonProperty("hash")] 25 | public string hash; 26 | 27 | [JsonProperty("songName")] 28 | public string songName; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /SyncSaberLib/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("SyncSaberLib")] 9 | [assembly: AssemblyDescription("")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct("SyncSaberLib")] 13 | [assembly: AssemblyCopyright("Copyright © 2019")] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("47e9e695-c638-4e4f-ad3d-6e8f6abd983f")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion("0.2.2")] 36 | [assembly: AssemblyFileVersion("0.2.2")] 37 | -------------------------------------------------------------------------------- /SyncSaberLib/SyncSaberLib.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F} 8 | Library 9 | SyncSaberLib 10 | SyncSaberLib 11 | v4.7.2 12 | 512 13 | true 14 | true 15 | 16 | 17 | AnyCPU 18 | true 19 | full 20 | false 21 | bin\Debug\ 22 | DEBUG;TRACE 23 | prompt 24 | 4 25 | 26 | 27 | AnyCPU 28 | none 29 | true 30 | bin\Release\ 31 | 32 | 33 | prompt 34 | 4 35 | true 36 | 37 | 38 | 39 | 40 | 41 | 42 | true 43 | bin\x64\Debug\ 44 | DEBUG;TRACE 45 | full 46 | x64 47 | prompt 48 | MinimumRecommendedRules.ruleset 49 | 50 | 51 | bin\x64\Release\ 52 | true 53 | x64 54 | prompt 55 | MinimumRecommendedRules.ruleset 56 | true 57 | 58 | 59 | true 60 | bin\x86\Debug\ 61 | DEBUG;TRACE 62 | full 63 | x86 64 | prompt 65 | MinimumRecommendedRules.ruleset 66 | 67 | 68 | bin\x86\Release\ 69 | true 70 | x86 71 | prompt 72 | MinimumRecommendedRules.ruleset 73 | true 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 2.5.2 121 | 122 | 123 | 12.0.2 124 | 125 | 126 | 4.9.0 127 | 128 | 129 | 130 | 131 | {ada8c603-a103-4324-a308-6d4236270c13} 132 | FeedReader 133 | 134 | 135 | {76dea3a1-3558-4dfe-834b-1fbd597a4dd6} 136 | BeatSaber-PlayerDataReader 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /SyncSaberLib/Utilities.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Concurrent; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using IniParser; 9 | using IniParser.Model; 10 | using System.IO; 11 | using System.IO.Compression; 12 | using System.Net; 13 | using System.ComponentModel; 14 | using System.Diagnostics; 15 | 16 | namespace SyncSaberLib 17 | { 18 | public static class Utilities 19 | { 20 | public static string MakeSafeFilename(string str) 21 | { 22 | StringBuilder retStr = new StringBuilder(str); 23 | foreach (var character in Path.GetInvalidFileNameChars()) 24 | { 25 | retStr.Replace(character.ToString(), string.Empty); 26 | } 27 | return retStr.ToString(); 28 | } 29 | 30 | 31 | 32 | /// 33 | /// Tries to parse a string as a bool, returns false if it fails. 34 | /// 35 | /// 36 | /// 37 | /// 38 | /// Successful 39 | public static bool StrToBool(string str, out bool result, bool defaultVal = false) 40 | { 41 | bool successful = true; 42 | switch (str.ToLower()) 43 | { 44 | case "0": 45 | result = false; 46 | break; 47 | case "false": 48 | result = false; 49 | break; 50 | case "1": 51 | result = true; 52 | break; 53 | case "true": 54 | result = true; 55 | break; 56 | default: 57 | successful = false; 58 | result = defaultVal; 59 | break; 60 | } 61 | return successful; 62 | } 63 | 64 | 65 | 66 | public static void EmptyDirectory(string directory, bool delete = true) 67 | { 68 | DirectoryInfo directoryInfo = new DirectoryInfo(directory); 69 | if (directoryInfo.Exists) 70 | { 71 | foreach (FileInfo file in directoryInfo.GetFiles()) 72 | { 73 | file.Delete(); 74 | } 75 | foreach (DirectoryInfo dir in directoryInfo.GetDirectories()) 76 | { 77 | dir.Delete(true); 78 | } 79 | if (delete) 80 | { 81 | directoryInfo.Delete(true); 82 | } 83 | } 84 | } 85 | 86 | public static void MoveFilesRecursively(DirectoryInfo source, DirectoryInfo target) 87 | { 88 | foreach (DirectoryInfo directoryInfo in source.GetDirectories()) 89 | { 90 | Utilities.MoveFilesRecursively(directoryInfo, target.CreateSubdirectory(directoryInfo.Name)); 91 | } 92 | foreach (FileInfo fileInfo in source.GetFiles()) 93 | { 94 | string newPath = Path.Combine(target.FullName, fileInfo.Name); 95 | if (File.Exists(newPath)) 96 | { 97 | try 98 | { 99 | File.Delete(newPath); 100 | } 101 | catch (Exception) 102 | { 103 | try 104 | { 105 | string oldFilePath = Path.Combine(target.FullName, "FilesToDelete"); 106 | if (!Directory.Exists(oldFilePath)) 107 | { 108 | Directory.CreateDirectory(oldFilePath); 109 | } 110 | File.Move(newPath, Path.Combine(oldFilePath, fileInfo.Name)); 111 | } 112 | catch (Exception) { } // TODO: This is dirty code 113 | } 114 | } 115 | // Check for file lock 116 | var time = Stopwatch.StartNew(); 117 | bool waitTimeout = false; 118 | while (IsFileLocked(fileInfo.FullName) && !waitTimeout) 119 | waitTimeout = time.ElapsedMilliseconds < 1000; 120 | if (waitTimeout) 121 | Logger.Warning($"Timeout waiting for {fileInfo.FullName} to be released to move."); 122 | fileInfo.MoveTo(newPath); 123 | } 124 | } 125 | //async static Task 126 | public static bool IsFileLocked(string filename) 127 | { 128 | // If the file can be opened for exclusive access it means that the file 129 | // is no longer locked by another process. 130 | try 131 | { 132 | using (FileStream inputStream = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.None)) 133 | return !(inputStream.Length > 0); 134 | } 135 | catch (Exception) // TODO: Catch only IOException? so the caller doesn't wait for a Timeout if there are other errors 136 | { 137 | return true; 138 | } 139 | } 140 | 141 | public static void WriteStringListSafe(string path, List data, bool sort = true) 142 | { 143 | if (File.Exists(path)) 144 | { 145 | File.Copy(path, path + ".bak", true); 146 | } 147 | if (sort) 148 | { 149 | data.Sort(); 150 | } 151 | File.WriteAllLines(path, data); 152 | File.Delete(path + ".bak"); 153 | } 154 | 155 | public static string FormatTimeSpan(TimeSpan timeElapsed) 156 | { 157 | string timeElapsedStr = ""; 158 | if (timeElapsed.TotalMinutes >= 1) 159 | { 160 | timeElapsedStr = $"{(int) timeElapsed.TotalMinutes}m "; 161 | } 162 | timeElapsedStr = $"{timeElapsedStr}{timeElapsed.Seconds}s"; 163 | return timeElapsedStr; 164 | } 165 | } 166 | 167 | internal static class ConcurrentQueueExtensions 168 | { 169 | public static void Clear(this ConcurrentQueue queue) 170 | { 171 | while (queue.TryDequeue(out T item)) 172 | { 173 | // do nothing 174 | } 175 | } 176 | } 177 | 178 | public static class AggregateExceptionExtensions 179 | { 180 | public static void WriteExceptions(this AggregateException ae, string message) 181 | { 182 | for(int i = 0; i < ae.InnerExceptions.Count; i++) 183 | { 184 | Logger.Exception($"Exception {i}:\n", ae.InnerExceptions[i]); 185 | if (ae.InnerExceptions[i] is AggregateException ex) 186 | WriteExceptions(ex, ""); // TODO: This could get very long 187 | } 188 | } 189 | } 190 | 191 | internal static class DictionaryExtensions 192 | { 193 | /// 194 | /// Adds the given key and value to the dictionary. If they key already exists, updates the value. 195 | /// Returns true if the key already exists. 196 | /// 197 | /// 198 | /// 199 | /// 200 | /// 201 | /// 202 | /// True if the key already exists, false otherwise. 203 | public static bool AddOrUpdate(this Dictionary dict, TKey key, TValue value) 204 | { 205 | if (dict.ContainsKey(key)) 206 | { 207 | dict[key] = value; 208 | return true; 209 | } 210 | dict.Add(key, value); 211 | return false; 212 | } 213 | } 214 | 215 | } 216 | -------------------------------------------------------------------------------- /SyncSaberLib/Web/DownloadBatch.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | using System.Threading.Tasks.Dataflow; 8 | using static SyncSaberLib.Utilities; 9 | using SyncSaberLib.Data; 10 | 11 | namespace SyncSaberLib.Web 12 | { 13 | class DownloadBatch 14 | { 15 | public async Task WorkDownloadQueueAsync() 16 | { 17 | BatchComplete = false; 18 | int maxConcurrentDownloads = OldConfig.MaxConcurrentDownloads; // Set it here so it doesn't error 19 | var actionBlock = new ActionBlock(job => { 20 | Logger.Debug($"Running job {job.Song.key} in ActionBlock"); 21 | Task newTask = job.RunJobAsync(); 22 | try 23 | { 24 | newTask.Wait(); 25 | } 26 | catch (AggregateException ae) 27 | { 28 | foreach (var ex in ae.InnerExceptions) 29 | { 30 | Logger.Exception($"Error while running job {job.Song.key}-{job.Song.songName} by {job.Song.authorName}\n", ex); 31 | } 32 | } 33 | finally 34 | { 35 | TaskComplete(job.Song, job); 36 | } 37 | }, new ExecutionDataflowBlockOptions { 38 | BoundedCapacity = 500, 39 | MaxDegreeOfParallelism = maxConcurrentDownloads 40 | }); 41 | while (_songDownloadQueue.Count > 0) 42 | { 43 | var job = _songDownloadQueue.Pop(); 44 | Logger.Trace($"Adding job for {job.Song.key}"); 45 | await actionBlock.SendAsync(job).ConfigureAwait(false); 46 | } 47 | 48 | actionBlock.Complete(); 49 | await actionBlock.Completion.ConfigureAwait(false); 50 | Logger.Trace($"Actionblock complete"); 51 | BatchComplete = true; 52 | } 53 | 54 | public void TaskComplete(SongInfo song, DownloadJob job) 55 | { 56 | bool successful = job.Result == DownloadJob.JobResult.SUCCESS; 57 | //string failReason; 58 | switch (job.Result) 59 | { 60 | case DownloadJob.JobResult.SUCCESS: 61 | Logger.Info($"Finished job {song.key}-{song.songName} by {song.authorName} successfully."); 62 | Logger.Info($"Song {song.key} downloaded to {job.SongDirectory.FullName}"); 63 | break; 64 | case DownloadJob.JobResult.TIMEOUT: 65 | Logger.Warning($"Job {song.key} failed due to download timeout."); 66 | break; 67 | case DownloadJob.JobResult.NOTFOUND: 68 | Logger.Warning($"Job failed, {song.key} could not be found on Beat Saver."); // TODO: Put song in history so we don't try to download it again. 69 | break; 70 | case DownloadJob.JobResult.UNZIPFAILED: 71 | Logger.Warning($"Job failed, {song.key} failed during unzipping."); 72 | break; 73 | case DownloadJob.JobResult.OTHERERROR: 74 | Logger.Warning($"Job {song.key} failed for...reasons."); 75 | break; 76 | default: 77 | break; 78 | } 79 | 80 | 81 | JobCompleted(job); 82 | } 83 | 84 | public async Task RunJobs() 85 | { 86 | await WorkDownloadQueueAsync().ConfigureAwait(false); 87 | } 88 | 89 | public void AddJob(DownloadJob job) 90 | { 91 | if (_songDownloadQueue.Where(j => j.Song.key == job.Song.key).Count() == 0) 92 | _songDownloadQueue.Push(job); 93 | else 94 | Logger.Warning($"{job.Song.key} is already in the queue"); 95 | } 96 | 97 | public event Action JobCompleted; 98 | 99 | private Stack _songDownloadQueue = new Stack(); 100 | public bool BatchComplete { get; private set; } = false; 101 | 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /SyncSaberLib/Web/IFeedReader.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using System.Collections.Generic; 3 | using SyncSaberLib.Data; 4 | using System.Linq; 5 | 6 | namespace SyncSaberLib.Web 7 | { 8 | public interface IFeedReader 9 | { 10 | string Name { get; } // Name of the reader 11 | string Source { get; } // Name of the site 12 | bool Ready { get; } // Reader is ready 13 | 14 | /// 15 | /// Anything that needs to happen before the Reader is ready. 16 | /// 17 | void PrepareReader(); 18 | 19 | /// 20 | /// Retrieves the songs from a feed and returns them as a Dictionary. Key is the Beat Saver key as an integer. 21 | /// 22 | /// 23 | /// 24 | Dictionary GetSongsFromFeed(IFeedSettings settings); 25 | 26 | /// 27 | /// Gets the playlists associated with the feed. 28 | /// 29 | /// 30 | /// 31 | Playlist[] PlaylistsForFeed(int feedIndex); 32 | } 33 | 34 | public interface IFeedSettings 35 | { 36 | string FeedName { get; } // Name of the feed 37 | int FeedIndex { get; } // Index of the feed 38 | int MaxSongs { get; set; } // Max number of songs to retrieve 39 | bool searchOnline { get; set; } // Search online instead of using local scrapes 40 | bool UseSongKeyAsOutputFolder { get; set; } // Use the song key as the output folder name instead of the default 41 | } 42 | 43 | /// 44 | /// Data for a feed. 45 | /// 46 | public struct FeedInfo 47 | { 48 | public FeedInfo(string _name, string _baseUrl) 49 | { 50 | Name = _name; 51 | BaseUrl = _baseUrl; 52 | } 53 | public string BaseUrl; // Base URL for the feed, has string keys to replace with things like page number/bsaber username 54 | public string Name; // Name of the feed 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /SyncSaberService.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29102.190 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SyncSaberLib", "SyncSaberLib\SyncSaberLib.csproj", "{47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SyncSaberServiceTests", "SyncSaberServiceTests\SyncSaberServiceTests.csproj", "{15C81339-6560-4DB8-AEC1-9E6C32678935}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SyncSaberConsole", "SyncSaberConsole\SyncSaberConsole.csproj", "{9F38D628-2649-4890-86D6-419CC225592C}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeatSaber-PlayerDataReader", "SyncSaberLib\libs\BeatSaber-PlayerDataReader\BeatSaber-PlayerDataReader\BeatSaber-PlayerDataReader.csproj", "{76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeedReader", "FeedReader\FeedReader.csproj", "{ADA8C603-A103-4324-A308-6D4236270C13}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeedReaderTests", "FeedReaderTests\FeedReaderTests.csproj", "{A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebUtilities", "WebUtilities\WebUtilities.csproj", "{1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}" 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Debug|x64 = Debug|x64 24 | Debug|x86 = Debug|x86 25 | Release|Any CPU = Release|Any CPU 26 | Release|x64 = Release|x64 27 | Release|x86 = Release|x86 28 | EndGlobalSection 29 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 30 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Debug|x64.ActiveCfg = Debug|x64 33 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Debug|x64.Build.0 = Debug|x64 34 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Debug|x86.ActiveCfg = Debug|x86 35 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Debug|x86.Build.0 = Debug|x86 36 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Release|x64.ActiveCfg = Release|x64 39 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Release|x64.Build.0 = Release|x64 40 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Release|x86.ActiveCfg = Release|x86 41 | {47E9E695-C638-4E4F-AD3D-6E8F6ABD983F}.Release|x86.Build.0 = Release|x86 42 | {15C81339-6560-4DB8-AEC1-9E6C32678935}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {15C81339-6560-4DB8-AEC1-9E6C32678935}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {15C81339-6560-4DB8-AEC1-9E6C32678935}.Debug|x64.ActiveCfg = Debug|Any CPU 45 | {15C81339-6560-4DB8-AEC1-9E6C32678935}.Debug|x86.ActiveCfg = Debug|Any CPU 46 | {15C81339-6560-4DB8-AEC1-9E6C32678935}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {15C81339-6560-4DB8-AEC1-9E6C32678935}.Release|Any CPU.Build.0 = Release|Any CPU 48 | {15C81339-6560-4DB8-AEC1-9E6C32678935}.Release|x64.ActiveCfg = Release|Any CPU 49 | {15C81339-6560-4DB8-AEC1-9E6C32678935}.Release|x86.ActiveCfg = Release|Any CPU 50 | {9F38D628-2649-4890-86D6-419CC225592C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 51 | {9F38D628-2649-4890-86D6-419CC225592C}.Debug|Any CPU.Build.0 = Debug|Any CPU 52 | {9F38D628-2649-4890-86D6-419CC225592C}.Debug|x64.ActiveCfg = Debug|x64 53 | {9F38D628-2649-4890-86D6-419CC225592C}.Debug|x64.Build.0 = Debug|x64 54 | {9F38D628-2649-4890-86D6-419CC225592C}.Debug|x86.ActiveCfg = Debug|x86 55 | {9F38D628-2649-4890-86D6-419CC225592C}.Debug|x86.Build.0 = Debug|x86 56 | {9F38D628-2649-4890-86D6-419CC225592C}.Release|Any CPU.ActiveCfg = Release|Any CPU 57 | {9F38D628-2649-4890-86D6-419CC225592C}.Release|Any CPU.Build.0 = Release|Any CPU 58 | {9F38D628-2649-4890-86D6-419CC225592C}.Release|x64.ActiveCfg = Release|x64 59 | {9F38D628-2649-4890-86D6-419CC225592C}.Release|x64.Build.0 = Release|x64 60 | {9F38D628-2649-4890-86D6-419CC225592C}.Release|x86.ActiveCfg = Release|x86 61 | {9F38D628-2649-4890-86D6-419CC225592C}.Release|x86.Build.0 = Release|x86 62 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 63 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Debug|Any CPU.Build.0 = Debug|Any CPU 64 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Debug|x64.ActiveCfg = Debug|Any CPU 65 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Debug|x64.Build.0 = Debug|Any CPU 66 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Debug|x86.ActiveCfg = Debug|Any CPU 67 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Debug|x86.Build.0 = Debug|Any CPU 68 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Release|Any CPU.ActiveCfg = Release|Any CPU 69 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Release|Any CPU.Build.0 = Release|Any CPU 70 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Release|x64.ActiveCfg = Release|Any CPU 71 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Release|x64.Build.0 = Release|Any CPU 72 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Release|x86.ActiveCfg = Release|Any CPU 73 | {76DEA3A1-3558-4DFE-834B-1FBD597A4DD6}.Release|x86.Build.0 = Release|Any CPU 74 | {ADA8C603-A103-4324-A308-6D4236270C13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 75 | {ADA8C603-A103-4324-A308-6D4236270C13}.Debug|Any CPU.Build.0 = Debug|Any CPU 76 | {ADA8C603-A103-4324-A308-6D4236270C13}.Debug|x64.ActiveCfg = Debug|Any CPU 77 | {ADA8C603-A103-4324-A308-6D4236270C13}.Debug|x64.Build.0 = Debug|Any CPU 78 | {ADA8C603-A103-4324-A308-6D4236270C13}.Debug|x86.ActiveCfg = Debug|Any CPU 79 | {ADA8C603-A103-4324-A308-6D4236270C13}.Debug|x86.Build.0 = Debug|Any CPU 80 | {ADA8C603-A103-4324-A308-6D4236270C13}.Release|Any CPU.ActiveCfg = Release|Any CPU 81 | {ADA8C603-A103-4324-A308-6D4236270C13}.Release|Any CPU.Build.0 = Release|Any CPU 82 | {ADA8C603-A103-4324-A308-6D4236270C13}.Release|x64.ActiveCfg = Release|Any CPU 83 | {ADA8C603-A103-4324-A308-6D4236270C13}.Release|x64.Build.0 = Release|Any CPU 84 | {ADA8C603-A103-4324-A308-6D4236270C13}.Release|x86.ActiveCfg = Release|Any CPU 85 | {ADA8C603-A103-4324-A308-6D4236270C13}.Release|x86.Build.0 = Release|Any CPU 86 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 87 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Debug|Any CPU.Build.0 = Debug|Any CPU 88 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Debug|x64.ActiveCfg = Debug|Any CPU 89 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Debug|x64.Build.0 = Debug|Any CPU 90 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Debug|x86.ActiveCfg = Debug|Any CPU 91 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Debug|x86.Build.0 = Debug|Any CPU 92 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Release|Any CPU.ActiveCfg = Release|Any CPU 93 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Release|Any CPU.Build.0 = Release|Any CPU 94 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Release|x64.ActiveCfg = Release|Any CPU 95 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Release|x64.Build.0 = Release|Any CPU 96 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Release|x86.ActiveCfg = Release|Any CPU 97 | {A1BE9AFD-24A9-42B4-81E4-83433E1E1C65}.Release|x86.Build.0 = Release|Any CPU 98 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 99 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Debug|Any CPU.Build.0 = Debug|Any CPU 100 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Debug|x64.ActiveCfg = Debug|Any CPU 101 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Debug|x64.Build.0 = Debug|Any CPU 102 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Debug|x86.ActiveCfg = Debug|Any CPU 103 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Debug|x86.Build.0 = Debug|Any CPU 104 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Release|Any CPU.ActiveCfg = Release|Any CPU 105 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Release|Any CPU.Build.0 = Release|Any CPU 106 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Release|x64.ActiveCfg = Release|Any CPU 107 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Release|x64.Build.0 = Release|Any CPU 108 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Release|x86.ActiveCfg = Release|Any CPU 109 | {1578AEC5-0D16-45F8-8987-AE23FFFE1FFA}.Release|x86.Build.0 = Release|Any CPU 110 | EndGlobalSection 111 | GlobalSection(SolutionProperties) = preSolution 112 | HideSolutionNode = FALSE 113 | EndGlobalSection 114 | GlobalSection(ExtensibilityGlobals) = postSolution 115 | SolutionGuid = {A40DEEC2-9B61-4EA2-A034-FB7539DFD704} 116 | EndGlobalSection 117 | EndGlobal 118 | -------------------------------------------------------------------------------- /SyncSaberServiceTests/Data/BeatSaverSongTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using Newtonsoft.Json.Linq; 3 | using SyncSaberLib.Data; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace SyncSaberLib.Data.Tests 12 | { 13 | [TestClass()] 14 | public class BeatSaverSongTests 15 | { 16 | [TestMethod()] 17 | public void TryParseBeatSaverTest() 18 | { 19 | string jsonStr = File.ReadAllText("test_detail_page.json"); 20 | JToken singleSong = JToken.Parse(jsonStr); 21 | bool successful = BeatSaverSong.TryParseBeatSaver(singleSong, out BeatSaverSong song); 22 | Console.WriteLine($"{song.name}"); 23 | Assert.IsTrue(successful); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /SyncSaberServiceTests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("SyncSaberServiceTests")] 9 | [assembly: AssemblyDescription("")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct("SyncSaberServiceTests")] 13 | [assembly: AssemblyCopyright("Copyright © 2019")] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("15c81339-6560-4db8-aec1-9e6c32678935")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion("1.0.0.0")] 36 | [assembly: AssemblyFileVersion("1.0.0.0")] 37 | -------------------------------------------------------------------------------- /SyncSaberServiceTests/SongInfoTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.VisualStudio.TestTools.UnitTesting; 2 | using SyncSaberLib; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using SyncSaberLib.Data; 9 | 10 | namespace SyncSaberLib.Tests 11 | { 12 | [TestClass()] 13 | public class SongInfoTests 14 | { 15 | [TestMethod()] 16 | public void SongInfoTest_ValidID() 17 | { 18 | //SongInfo testSong = new SongInfo("1234-23", "Test Song", "http://testurl.com", "TestAuthor"); 19 | //Assert.IsTrue(testSong.keyAsInt == 1234); 20 | //Assert.IsTrue(testSong.SongVersion == 23); 21 | } 22 | 23 | [TestMethod()] 24 | public void SongInfoTest_InvalidID_SingleNum() 25 | { 26 | //SongInfo testSong = new SongInfo("1234", "Test Song", "http://testurl.com", "TestAuthor"); 27 | //Assert.IsTrue(testSong.keyAsInt == 0); 28 | //Assert.IsTrue(testSong.SongVersion == 0); 29 | } 30 | 31 | [TestMethod()] 32 | public void SongInfoTest_InvalidID_HasLetters() 33 | { 34 | //SongInfo testSong = new SongInfo("1234s-23", "Test Song", "http://testurl.com", "TestAuthor"); 35 | //Assert.IsTrue(testSong.keyAsInt == 0); 36 | //Assert.IsTrue(testSong.SongVersion == 0); 37 | } 38 | 39 | [TestMethod()] 40 | public void SongInfoTest_InvalidID_EmptyString() 41 | { 42 | //SongInfo testSong = new SongInfo("", "Test Song", "http://testurl.com", "TestAuthor"); 43 | //Assert.IsTrue(testSong.keyAsInt == 0); 44 | //Assert.IsTrue(testSong.SongVersion == 0); 45 | } 46 | 47 | [TestMethod()] 48 | public void SongInfoTest_InvalidID_NoID() 49 | { 50 | //SongInfo testSong = new SongInfo("-123", "Test Song", "http://testurl.com", "TestAuthor"); 51 | //Assert.IsTrue(testSong.keyAsInt == 0); 52 | //Assert.IsTrue(testSong.SongVersion == 0); 53 | } 54 | 55 | [TestMethod()] 56 | public void SongInfoTest_InvalidID_NoVersion() 57 | { 58 | //SongInfo testSong = new SongInfo("1234-", "Test Song", "http://testurl.com", "TestAuthor"); 59 | //Assert.IsTrue(testSong.keyAsInt == 0); 60 | //Assert.IsTrue(testSong.SongVersion == 0); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /SyncSaberServiceTests/SyncSaberServiceTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {15C81339-6560-4DB8-AEC1-9E6C32678935} 8 | Library 9 | Properties 10 | SyncSaberServiceTests 11 | SyncSaberServiceTests 12 | v4.7.2 13 | 512 14 | {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 15 | 10.0 16 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 17 | $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages 18 | False 19 | UnitTest 20 | 21 | 22 | 23 | 24 | 25 | true 26 | full 27 | false 28 | bin\Debug\ 29 | DEBUG;TRACE 30 | prompt 31 | 4 32 | 33 | 34 | pdbonly 35 | true 36 | bin\Release\ 37 | TRACE 38 | prompt 39 | 4 40 | 41 | 42 | 43 | ..\packages\MSTest.TestFramework.1.3.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll 44 | 45 | 46 | ..\packages\MSTest.TestFramework.1.3.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll 47 | 48 | 49 | ..\packages\Newtonsoft.Json.12.0.2\lib\net45\Newtonsoft.Json.dll 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | {47e9e695-c638-4e4f-ad3d-6e8f6abd983f} 72 | SyncSaberLib 73 | 74 | 75 | 76 | 77 | Always 78 | 79 | 80 | Always 81 | 82 | 83 | 84 | 85 | 86 | 87 | False 88 | 89 | 90 | False 91 | 92 | 93 | False 94 | 95 | 96 | False 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. 106 | 107 | 108 | 109 | 110 | 111 | 118 | -------------------------------------------------------------------------------- /SyncSaberServiceTests/packages.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /SyncSaberServiceTests/test_detail_page.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | 4 | "metadata": { 5 | "difficulties": { 6 | "easy": true, 7 | "expert": true, 8 | "expertPlus": true, 9 | "hard": true, 10 | "normal": true 11 | }, 12 | "characteristics": [ 13 | { 14 | "name": "Standard", 15 | "difficulties": { 16 | "easy": { 17 | "duration": 511, 18 | "length": 180, 19 | "bombs": 0, 20 | "notes": 198, 21 | "obstacles": 23, 22 | "njs": 11 23 | }, 24 | "normal": { 25 | "duration": 511, 26 | "length": 180, 27 | "bombs": 0, 28 | "notes": 280, 29 | "obstacles": 25, 30 | "njs": 11 31 | }, 32 | "hard": { 33 | "duration": 511, 34 | "length": 180, 35 | "bombs": 0, 36 | "notes": 382, 37 | "obstacles": 20, 38 | "njs": 12 39 | }, 40 | "expert": { 41 | "duration": 511, 42 | "length": 180, 43 | "bombs": 0, 44 | "notes": 533, 45 | "obstacles": 20, 46 | "njs": 14 47 | }, 48 | "expertPlus": { 49 | "duration": 511, 50 | "length": 180, 51 | "bombs": 2, 52 | "notes": 704, 53 | "obstacles": 25, 54 | "njs": 15 55 | } 56 | } 57 | }, 58 | { 59 | "name": "OneSaber", 60 | "difficulties": { 61 | "easy": null, 62 | "normal": null, 63 | "hard": null, 64 | "expert": { 65 | "duration": 511, 66 | "length": 180, 67 | "bombs": 0, 68 | "notes": 450, 69 | "obstacles": 44, 70 | "njs": 14 71 | }, 72 | "expertPlus": null 73 | } 74 | } 75 | ], 76 | "levelAuthorName": "Skyler Wallace", 77 | "songAuthorName": "Urban Cone", 78 | "songName": "Come Back To Me", 79 | "songSubName": "ft. Tove Lo", 80 | "bpm": 170 81 | }, 82 | "stats": { 83 | "downloads": 3379, 84 | "plays": 0, 85 | "downVotes": 1, 86 | "upVotes": 57, 87 | "heat": 790.2998286, 88 | "rating": 0.8412931449578613 89 | }, 90 | "description": "170 BPM / 3:06 Runtime\nEasy - 198 Notes\nNormal - 280 Notes\nHard - 382 Notes\nExpert - 533 Notes\nExpert (Single Saber) / 450 Notes\nExpert+ / 704 Notes", 91 | "deletedAt": null, 92 | "_id": "5d0522979e94c50006de797b", 93 | "key": "5317", 94 | "name": "Come Back To Me (ft. Tove Lo) - Urban Cone (All Difficulties & Single Saber)", 95 | "uploader": { 96 | "_id": "5cff0b7298cc5a672c84ea67", 97 | "username": "skylerwallace" 98 | }, 99 | "hash": "aaa14fb7dcaeda7a688db77617045a247baa151d", 100 | "uploaded": "2019-06-15T16:53:43.826Z", 101 | "converted": true, 102 | "downloadURL": "/api/download/key/5317", 103 | "coverURL": "/cdn/5317/aaa14fb7dcaeda7a688db77617045a247baa151d.jpg" 104 | 105 | } 106 | -------------------------------------------------------------------------------- /WebUtilities/HttpClientWrapper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace WebUtilities 8 | { 9 | public class HttpClientWrapper : IWebClient 10 | { 11 | private HttpClient httpClient; 12 | public ILogger Logger; 13 | 14 | public HttpClientWrapper(HttpClient client = null) 15 | { 16 | if (client == null) 17 | httpClient = new HttpClient(); 18 | else 19 | httpClient = client; 20 | ErrorHandling = ErrorHandling.ThrowOnException; 21 | } 22 | 23 | public int Timeout { get; set; } 24 | public ErrorHandling ErrorHandling { get; set; } 25 | 26 | public async Task GetAsync(Uri uri, bool completeOnHeaders, CancellationToken cancellationToken) 27 | { 28 | HttpCompletionOption completionOption = 29 | completeOnHeaders ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead; 30 | try 31 | { 32 | //TODO: Need testing for cancellation token 33 | return new HttpResponseWrapper(await httpClient.GetAsync(uri, completionOption, cancellationToken).ConfigureAwait(false)); 34 | } 35 | catch(ArgumentException ex) 36 | { 37 | if (ErrorHandling != ErrorHandling.ReturnEmptyContent) 38 | throw; 39 | else 40 | { 41 | Logger?.Log(LogLevel.Error, $"Invalid URL, {uri?.ToString()}, passed to GetAsync()\n{ex.Message}\n{ex.StackTrace}"); 42 | return new HttpResponseWrapper(null); 43 | } 44 | } 45 | catch(HttpRequestException ex) 46 | { 47 | if (ErrorHandling == ErrorHandling.ThrowOnException) 48 | throw; 49 | else 50 | { 51 | Logger?.Log(LogLevel.Error, $"Exception getting {uri?.ToString()}\n{ex.Message}\n{ex.StackTrace}"); 52 | return new HttpResponseWrapper(null); 53 | } 54 | } 55 | 56 | } 57 | 58 | #region GetAsyncOverloads 59 | 60 | public Task GetAsync(string url, bool completeOnHeaders, CancellationToken cancellationToken) 61 | { 62 | var urlAsUri = string.IsNullOrEmpty(url) ? null : new Uri(url); 63 | return GetAsync(urlAsUri, completeOnHeaders, cancellationToken); 64 | } 65 | public Task GetAsync(string url) 66 | { 67 | return GetAsync(url, false, CancellationToken.None); 68 | } 69 | public Task GetAsync(string url, bool completeOnHeaders) 70 | { 71 | return GetAsync(url, completeOnHeaders, CancellationToken.None); 72 | } 73 | public Task GetAsync(string url, CancellationToken cancellationToken) 74 | { 75 | return GetAsync(url, false, cancellationToken); 76 | } 77 | 78 | public Task GetAsync(Uri uri) 79 | { 80 | return GetAsync(uri, false, CancellationToken.None); 81 | } 82 | public Task GetAsync(Uri uri, bool completeOnHeaders) 83 | { 84 | return GetAsync(uri, completeOnHeaders, CancellationToken.None); 85 | } 86 | public Task GetAsync(Uri uri, CancellationToken cancellationToken) 87 | { 88 | return GetAsync(uri, false, cancellationToken); 89 | } 90 | #endregion 91 | 92 | #region IDisposable Support 93 | private bool disposedValue = false; // To detect redundant calls 94 | 95 | protected virtual void Dispose(bool disposing) 96 | { 97 | if (!disposedValue) 98 | { 99 | if (disposing) 100 | { 101 | if (httpClient != null) 102 | { 103 | httpClient.Dispose(); 104 | httpClient = null; 105 | } 106 | } 107 | disposedValue = true; 108 | } 109 | } 110 | 111 | public void Dispose() 112 | { 113 | Dispose(true); 114 | } 115 | #endregion 116 | 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /WebUtilities/HttpContentWrapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using System.Net; 7 | using System.Net.Http; 8 | using System.Collections.ObjectModel; 9 | 10 | namespace WebUtilities 11 | { 12 | public class HttpContentWrapper : IWebResponseContent 13 | { 14 | private HttpContent _content; 15 | public HttpContentWrapper(HttpContent content) 16 | { 17 | _content = content; 18 | _headers = new Dictionary>(); 19 | if (_content?.Headers != null) 20 | { 21 | foreach (var header in _content.Headers) 22 | { 23 | _headers.Add(header.Key, header.Value); 24 | } 25 | } 26 | } 27 | 28 | protected Dictionary> _headers; 29 | public ReadOnlyDictionary> Headers 30 | { 31 | get { return new ReadOnlyDictionary>(_headers); } 32 | } 33 | 34 | public string ContentType { get { return _content?.Headers?.ContentType?.MediaType; } } 35 | 36 | public Task ReadAsByteArrayAsync() 37 | { 38 | return _content?.ReadAsByteArrayAsync(); 39 | } 40 | 41 | public Task ReadAsStreamAsync() 42 | { 43 | return _content?.ReadAsStreamAsync(); 44 | } 45 | 46 | public Task ReadAsStringAsync() 47 | { 48 | return _content?.ReadAsStringAsync(); 49 | } 50 | 51 | public Task ReadAsFileAsync(string filePath, bool overwrite) 52 | { 53 | if (_content == null) 54 | return null; 55 | string pathname = Path.GetFullPath(filePath); 56 | if (!overwrite && File.Exists(filePath)) 57 | { 58 | throw new InvalidOperationException(string.Format("File {0} already exists.", pathname)); 59 | } 60 | 61 | FileStream fileStream = null; 62 | try 63 | { 64 | fileStream = new FileStream(pathname, FileMode.Create, FileAccess.Write, FileShare.None); 65 | return _content.CopyToAsync(fileStream).ContinueWith( 66 | (copyTask) => 67 | { 68 | fileStream.Close(); 69 | }); 70 | } 71 | catch 72 | { 73 | if (fileStream != null) 74 | { 75 | fileStream.Close(); 76 | } 77 | 78 | throw; 79 | } 80 | } 81 | 82 | #region IDisposable Support 83 | private bool disposedValue = false; // To detect redundant calls 84 | 85 | protected virtual void Dispose(bool disposing) 86 | { 87 | if (!disposedValue) 88 | { 89 | if (disposing) 90 | { 91 | if (_content != null) 92 | { 93 | _content.Dispose(); 94 | _content = null; 95 | } 96 | } 97 | disposedValue = true; 98 | } 99 | } 100 | 101 | public void Dispose() 102 | { 103 | Dispose(true); 104 | } 105 | #endregion 106 | 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /WebUtilities/HttpResponseWrapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Text; 7 | 8 | namespace WebUtilities 9 | { 10 | public class HttpResponseWrapper : IWebResponseMessage 11 | { 12 | private HttpResponseMessage _response; 13 | public HttpStatusCode StatusCode { get { return _response.StatusCode; } } 14 | 15 | public bool IsSuccessStatusCode { get { return _response.IsSuccessStatusCode; } } 16 | 17 | public IWebResponseContent Content { get; protected set; } 18 | 19 | private Dictionary> _headers; 20 | public ReadOnlyDictionary> Headers 21 | { 22 | get { return new ReadOnlyDictionary>(_headers); } 23 | } 24 | 25 | public string ReasonPhrase { get { return _response.ReasonPhrase; } } 26 | 27 | public HttpResponseWrapper(HttpResponseMessage response) 28 | { 29 | _response = response; 30 | Content = new HttpContentWrapper(response?.Content); 31 | _headers = new Dictionary>(); 32 | if (_response?.Headers != null) 33 | { 34 | foreach (var header in _response.Headers) 35 | { 36 | _headers.Add(header.Key, header.Value); 37 | } 38 | } 39 | } 40 | 41 | 42 | #region IDisposable Support 43 | private bool disposedValue = false; // To detect redundant calls 44 | 45 | protected virtual void Dispose(bool disposing) 46 | { 47 | if (!disposedValue) 48 | { 49 | if (disposing) 50 | { 51 | if (Content != null) 52 | { 53 | Content.Dispose(); 54 | Content = null; 55 | } 56 | } 57 | disposedValue = true; 58 | } 59 | } 60 | 61 | public void Dispose() 62 | { 63 | Dispose(true); 64 | } 65 | #endregion 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /WebUtilities/IWebClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace WebUtilities 8 | { 9 | public interface IWebClient : IDisposable 10 | { 11 | int Timeout { get; set; } 12 | ErrorHandling ErrorHandling { get; set; } 13 | Task GetAsync(Uri uri); 14 | Task GetAsync(Uri uri, bool completeOnHeaders); 15 | Task GetAsync(Uri uri, CancellationToken cancellationToken); 16 | Task GetAsync(Uri uri, bool completeOnHeaders, CancellationToken cancellationToken); 17 | Task GetAsync(string url); 18 | Task GetAsync(string url, bool completeOnHeaders); 19 | Task GetAsync(string url, CancellationToken cancellationToken); 20 | Task GetAsync(string url, bool completeOnHeaders, CancellationToken cancellationToken); 21 | 22 | } 23 | 24 | public enum ErrorHandling 25 | { 26 | ThrowOnException, 27 | ThrowOnWebFault, 28 | ReturnEmptyContent 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /WebUtilities/IWebResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Net; 5 | using System.Threading.Tasks; 6 | using System.Threading; 7 | using System.Net.Http; 8 | using System.IO; 9 | using System.Collections.ObjectModel; 10 | 11 | namespace WebUtilities 12 | { 13 | public interface IWebResponseMessage : IDisposable 14 | { 15 | HttpStatusCode StatusCode { get; } 16 | string ReasonPhrase { get; } 17 | bool IsSuccessStatusCode { get; } 18 | IWebResponseContent Content { get; } 19 | 20 | ReadOnlyDictionary> Headers { get; } 21 | } 22 | 23 | public interface IWebResponseContent : IDisposable 24 | { 25 | 26 | Task ReadAsStringAsync(); 27 | Task ReadAsStreamAsync(); 28 | Task ReadAsByteArrayAsync(); 29 | Task ReadAsFileAsync(string filePath, bool overwrite); 30 | 31 | string ContentType { get; } 32 | ReadOnlyDictionary> Headers { get; } 33 | 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /WebUtilities/WebUtilities.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /update_submodules.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | git submodule foreach git pull origin master 3 | echo Finshed... 4 | pause --------------------------------------------------------------------------------