Просмотр исходного кода

Sort by pp. Display pp. Display star rating.
Data is fetched (respecting etag cache) from DuoVRs scrape.

Stephen Damm 6 лет назад
Родитель
Сommit
e47c6ddcc8

+ 204 - 0
SongBrowserPlugin/DataAccess/Network/CacheableDownloadHandler.cs

@@ -0,0 +1,204 @@
+using UnityEngine.Networking;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using UnityEngine;
+using System;
+
+// Modified Version of:
+// https://github.com/mob-sakai/AssetSystem/blob/master/Assets/Mobcast/Coffee/AssetSystem/CacheableDownloadHandler.cs
+// MIT-LICENSE - https://github.com/mob-sakai/AssetSystem/blob/master/LICENSE
+// Modified to use custom logging only.
+namespace Mobcast.Coffee.AssetSystem
+{
+
+	public static class UnityWebRequestCachingExtensions
+	{
+		/// <summary>
+		/// Set UnityWebRequest to be cacheable(Etag).
+		/// </summary>
+		public static void SetCacheable(this UnityWebRequest www, CacheableDownloadHandler handler)
+		{
+			var etag = CacheableDownloadHandler.GetCacheEtag(www.url);
+			if (etag != null)
+				www.SetRequestHeader("If-None-Match", etag);
+			www.downloadHandler = handler;
+		}
+	}
+
+	/// <summary>
+	/// Cacheable download handler texture.
+	/// </summary>
+	public class CacheableDownloadHandlerTexture : CacheableDownloadHandler
+	{
+		Texture2D m_Texture;
+
+		public CacheableDownloadHandlerTexture(UnityWebRequest www, byte[] preallocateBuffer)
+			: base(www, preallocateBuffer)
+		{
+		}
+
+		/// <summary>
+		/// Returns the downloaded Texture, or null.
+		/// </summary>
+		public Texture2D texture
+		{
+			get
+			{
+				if (m_Texture == null)
+				{
+					m_Texture = new Texture2D(1, 1);
+					m_Texture.LoadImage(GetData(), true);
+				}
+				return m_Texture;
+			}
+		}
+	}
+
+	/// <summary>
+	/// Cacheable download handler.
+	/// </summary>
+	public abstract class CacheableDownloadHandler : DownloadHandlerScript
+	{
+        private static SongBrowserPlugin.Logger _log = new SongBrowserPlugin.Logger("CacheableDownloadHandler");
+
+        const string kLog = "[WebRequestCaching] ";
+		const string kDataSufix = "_d";
+		const string kEtagSufix = "_e";
+
+		static string s_WebCachePath; 
+		static SHA1CryptoServiceProvider s_SHA1 = new SHA1CryptoServiceProvider();
+
+		/// <summary>
+		/// Is the download already finished?
+		/// </summary>
+		public new bool isDone { get; private set; }
+
+
+		UnityWebRequest m_WebRequest;
+		MemoryStream m_Stream;
+		protected byte[] m_Buffer;
+
+		internal CacheableDownloadHandler(UnityWebRequest www, byte[] preallocateBuffer)
+			: base(preallocateBuffer)
+		{
+			this.m_WebRequest = www;
+			m_Stream = new MemoryStream(preallocateBuffer.Length);
+		}
+
+		/// <summary>
+		/// Get path for web-caching.
+		/// </summary>
+		public static string GetCachePath(string url)
+		{
+			if (s_WebCachePath == null)
+			{
+				s_WebCachePath = Application.temporaryCachePath + "/WebCache/";
+				_log.Debug("{0}WebCachePath : {1}", kLog, s_WebCachePath);
+
+			}
+
+			if (!Directory.Exists(s_WebCachePath))
+				Directory.CreateDirectory(s_WebCachePath);
+
+			return s_WebCachePath + Convert.ToBase64String(s_SHA1.ComputeHash(UTF8Encoding.Default.GetBytes(url))).Replace('/', '_');
+		}
+
+		/// <summary>
+		/// Get cached Etag for url.
+		/// </summary>
+		public static string GetCacheEtag(string url)
+		{
+			var path = GetCachePath(url);
+			var infoPath = path + kEtagSufix;
+			var dataPath = path + kDataSufix;
+			return File.Exists(infoPath) && File.Exists(dataPath)
+					? File.ReadAllText(infoPath)
+					: null;
+		}
+
+		/// <summary>
+		/// Load cached data for url.
+		/// </summary>
+		public static byte[] LoadCache(string url)
+		{
+			return File.ReadAllBytes(GetCachePath(url) + kDataSufix);
+		}
+
+		/// <summary>
+		/// Save cache data for url.
+		/// </summary>
+		public static void SaveCache(string url, string etag, byte[] datas)
+		{
+			var path = GetCachePath(url);
+			File.WriteAllText(path + kEtagSufix, etag);
+			File.WriteAllBytes(path + kDataSufix, datas);
+		}
+
+		/// <summary>
+		/// Callback, invoked when the data property is accessed.
+		/// </summary>
+		protected override byte[] GetData()
+		{
+			if (!isDone)
+			{
+				_log.Error("{0}Downloading is not completed : {1}", kLog, m_WebRequest.url);
+				throw new InvalidOperationException("Downloading is not completed. " + m_WebRequest.url);
+			}
+			else if (m_Buffer == null)
+			{
+				// Etag cache hit!
+				if (m_WebRequest.responseCode == 304)
+				{
+					_log.Debug("<color=green>{0}Etag cache hit : {1}</color>", kLog, m_WebRequest.url);
+					m_Buffer = LoadCache(m_WebRequest.url);
+				}
+				// Download is completed successfully.
+				else if (m_WebRequest.responseCode == 200)
+				{
+					_log.Debug("<color=green>{0}Download is completed successfully : {1}</color>", kLog, m_WebRequest.url);
+					m_Buffer = m_Stream.GetBuffer();
+					SaveCache(m_WebRequest.url, m_WebRequest.GetResponseHeader("Etag"), m_Buffer);
+				}
+			}
+
+			if (m_Stream != null)
+			{
+				m_Stream.Dispose();
+				m_Stream = null;
+			}
+			return m_Buffer;
+		}
+
+		/// <summary>
+		/// Callback, invoked as data is received from the remote server.
+		/// </summary>
+		protected override bool ReceiveData(byte[] data, int dataLength)
+		{
+			m_Stream.Write(data, 0, dataLength);
+			return true;
+		}
+
+		/// <summary>
+		/// Callback, invoked when all data has been received from the remote server.
+		/// </summary>
+		protected override void CompleteContent()
+		{
+			base.CompleteContent();
+			isDone = true;
+		}
+
+		/// <summary>
+		/// Signals that this [DownloadHandler] is no longer being used, and should clean up any resources it is using.
+		/// </summary>
+		public new void Dispose()
+		{
+			base.Dispose();
+			if (m_Stream != null)
+			{
+				m_Stream.Dispose();
+				m_Stream = null;
+			}
+		}
+	}
+}

+ 34 - 0
SongBrowserPlugin/DataAccess/Network/CacheableDownloadHandlerScoreSaberData.cs

@@ -0,0 +1,34 @@
+using Mobcast.Coffee.AssetSystem;
+using UnityEngine.Networking;
+
+namespace SongBrowserPlugin.DataAccess.Network
+{
+    /// <summary>
+	/// Cacheable download handler for score saber tsv file.
+	/// </summary>
+	public class CacheableDownloadHandlerScoreSaberData : CacheableDownloadHandler
+    {
+        ScoreSaberDataFile _scoreSaberDataFile;
+
+        public CacheableDownloadHandlerScoreSaberData(UnityWebRequest www, byte[] preallocateBuffer)
+            : base(www, preallocateBuffer)
+        {
+        }
+
+        /// <summary>
+        /// Returns the downloaded score saber data file, or null.
+        /// </summary>
+        public ScoreSaberDataFile ScoreSaberDataFile
+        {
+            get
+            {
+                if (_scoreSaberDataFile == null)
+                {
+                    _scoreSaberDataFile = new ScoreSaberDataFile(GetData());
+
+                }
+                return _scoreSaberDataFile;
+            }
+        }
+    }
+}

+ 115 - 0
SongBrowserPlugin/DataAccess/ScoreSaberDatabase.cs

@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+
+
+namespace SongBrowserPlugin.DataAccess
+{
+    public class ScoreSaberDifficulty
+    {
+        public string name;
+        public float pp;
+        public float star;
+    }
+
+    public class ScoreSaberData
+    {
+        public string name;        
+        public Dictionary<String, ScoreSaberDifficulty> difficultyToSaberDifficulty = new Dictionary<string, ScoreSaberDifficulty>();
+        public float maxStar = 0;
+        public float maxPp = 0;
+        public string version;
+
+        public void AddDifficultyRating(string name, float pp, float star)
+        {
+            // assume list is newest->oldest, so always take first result.
+            if (difficultyToSaberDifficulty.ContainsKey(name))
+            {
+                return;
+            }
+            ScoreSaberDifficulty ppDifficulty = new ScoreSaberDifficulty();
+            ppDifficulty.name = name;
+            ppDifficulty.star = star;
+            ppDifficulty.pp = pp;
+            difficultyToSaberDifficulty.Add(ppDifficulty.name, ppDifficulty);
+
+            if (pp > maxPp)
+                maxPp = pp;
+
+            if (star > maxStar)
+                maxStar = star;
+        }
+    }
+
+    public class ScoreSaberDataFile
+    {
+        private Logger _log = new Logger("ScoreSaberDataFile");
+
+        public Dictionary<String, ScoreSaberData> SongNameToPp;
+        public Dictionary<String, ScoreSaberData> SongVersionToPp;
+
+        public ScoreSaberDataFile(byte[] data)
+        {
+            SongNameToPp = new Dictionary<string, ScoreSaberData>();
+            SongVersionToPp = new Dictionary<string, ScoreSaberData>();
+
+            string result = System.Text.Encoding.UTF8.GetString(data);
+            string[] lines = result.Split('\n');
+
+            Regex versionRegex = new Regex(@".*/(?<version>.*)\.(?<extension>jpg|JPG|png|PNG)");
+            foreach (string s in lines)
+            {
+                // Example: Freedom Dive - v2	367.03	Expert+	9.19★	[src='https://beatsaver.com/storage/songs/3037/3037-2154.jpg']                
+                //_log.Trace(s);
+
+                string[] split = s.Split('\t');                
+                float pp = float.Parse(split[1]);
+
+                int lastDashIndex = split[0].LastIndexOf('-');
+                string name = split[0].Substring(0, lastDashIndex).Trim();
+                string author = split[0].Substring(lastDashIndex+1, split[0].Length - (lastDashIndex+1)).Trim();
+                //_log.Debug("name={0}", name);
+                //_log.Debug("author={0}", author);
+                
+                string difficultyName = split[2];
+                if (difficultyName == "Expert+")
+                {
+                    difficultyName = "ExpertPlus";
+                }
+
+                float starDifficulty = 0;
+                string fixedStarDifficultyString = split[3].Remove(split[3].Length -1);
+                
+                if (fixedStarDifficultyString.Length >= 1 && Char.IsNumber(fixedStarDifficultyString[0]))
+                {
+                    starDifficulty = float.Parse(fixedStarDifficultyString);
+                }
+                
+                Match m = versionRegex.Match(split[4]);
+                string version = m.Groups["version"].Value;
+
+                ScoreSaberData ppData = null;
+                if (!SongVersionToPp.ContainsKey(version))
+                {
+                    ppData = new ScoreSaberData();
+                    ppData.version = version;
+                    ppData.name = name;
+
+                    SongVersionToPp.Add(version, ppData);
+                }
+                else
+                {
+                    ppData = SongVersionToPp[version];
+                }
+
+                if (!SongNameToPp.ContainsKey(name))
+                {
+                    SongNameToPp.Add(name, ppData);
+                }
+
+                // add difficulty  
+                ppData.AddDifficultyRating(difficultyName, pp, starDifficulty);
+            }
+        }
+    }
+}

+ 76 - 9
SongBrowserPlugin/DataAccess/SongBrowserModel.cs

@@ -89,9 +89,10 @@ namespace SongBrowserPlugin
         private List<StandardLevelSO> _sortedSongs;
         private List<StandardLevelSO> _originalSongs;
         private Dictionary<String, SongLoaderPlugin.OverrideClasses.CustomLevel> _levelIdToCustomLevel;
-        private SongLoaderPlugin.OverrideClasses.CustomLevelCollectionSO _gameplayModeCollection;
+        //private SongLoaderPlugin.OverrideClasses.CustomLevelCollectionSO _gameplayModeCollection;
         private Dictionary<String, double> _cachedLastWriteTimes;
         private Dictionary<string, int> _weights;
+        private Dictionary<string, ScoreSaberData> _ppMapping = null;
         private Dictionary<String, DirectoryNode> _directoryTree;
         private Stack<DirectoryNode> _directoryStack = new Stack<DirectoryNode>();
 
@@ -135,6 +136,14 @@ namespace SongBrowserPlugin
             }
         }
 
+        public Dictionary<string, ScoreSaberData> PpMapping
+        {
+            get
+            {
+                return _ppMapping;
+            }
+        }
+
         /// <summary>
         /// How deep is the directory stack.
         /// </summary>
@@ -278,8 +287,9 @@ namespace SongBrowserPlugin
 
             // Update song Infos, directory tree, and sort
             this.UpdateSongInfos(_currentGamePlayMode);
+            this.UpdatePpMappings();
             this.UpdateDirectoryTree(customSongsPath);
-            this.ProcessSongList();                       
+            this.ProcessSongList();
         }
 
         /// <summary>
@@ -290,7 +300,7 @@ namespace SongBrowserPlugin
             _log.Trace("UpdateSongInfos for Gameplay Mode {0}", gameplayMode);
 
             // Get the level collection from song loader
-            LevelCollectionsForGameplayModes levelCollections = Resources.FindObjectsOfTypeAll<LevelCollectionsForGameplayModes>().FirstOrDefault();            
+            LevelCollectionsForGameplayModes levelCollections = Resources.FindObjectsOfTypeAll<LevelCollectionsForGameplayModes>().FirstOrDefault();
             List<LevelCollectionsForGameplayModes.LevelCollectionForGameplayMode> levelCollectionsForGameModes = ReflectionUtil.GetPrivateField<LevelCollectionsForGameplayModes.LevelCollectionForGameplayMode[]>(levelCollections, "_collections").ToList();
 
             _originalSongs = levelCollections.GetLevels(gameplayMode).ToList();
@@ -307,6 +317,55 @@ namespace SongBrowserPlugin
         }
 
         /// <summary>
+        /// Parse the current pp data file.
+        /// </summary>
+        private void UpdatePpMappings()
+        {
+            _log.Trace("UpdatePpMappings()");
+
+            ScoreSaberDataFile ppDataFile = ScoreSaberDatabaseDownloader.Instance.ScoreSaberDataFile;
+
+            _ppMapping = new Dictionary<string, ScoreSaberData>();
+
+            // bail
+            if (ppDataFile == null)
+            {
+                _log.Warning("Cannot fetch song difficulty data tsv file from DuoVR");
+                return;
+            }
+
+            foreach (var level in SongLoader.CustomLevels)
+            {
+                ScoreSaberData ppData = null;
+
+                Regex versionRegex = new Regex(@".*/(?<version>[0-9]*-[0-9]*)/");
+                Match m = versionRegex.Match(level.customSongInfo.path);
+                if (m.Success)
+                {
+                    String version = m.Groups["version"].Value;
+                    if (ppDataFile.SongVersionToPp.ContainsKey(version))
+                    {
+                        ppData = ppDataFile.SongVersionToPp[version];
+                    }
+                }
+
+                if (ppData == null)
+                {
+                    if (ppDataFile.SongVersionToPp.ContainsKey(level.songName))
+                    {
+                        ppData = ppDataFile.SongVersionToPp[level.songName];
+                    }
+                }
+
+                if (ppData != null)
+                {
+                    //_log.Debug("{0} = {1}pp", level.songName, pp);
+                    _ppMapping.Add(level.levelID, ppData);
+                }
+            }            
+        }
+
+        /// <summary>
         /// Make the directory tree.
         /// </summary>
         /// <param name="customSongsPath"></param>
@@ -475,7 +534,7 @@ namespace SongBrowserPlugin
         /// <summary>
         /// Sort the song list based on the settings.
         /// </summary>
-        private void ProcessSongList()
+        public void ProcessSongList()
         {
             _log.Trace("ProcessSongList()");
 
@@ -546,6 +605,9 @@ namespace SongBrowserPlugin
                 case SongSortMode.PlayCount:
                     SortPlayCount(_filteredSongs, _currentGamePlayMode);
                     break;
+                case SongSortMode.PP:
+                    SortPerformancePoints(_filteredSongs);
+                    break;
                 case SongSortMode.Difficulty:
                     SortDifficulty(_filteredSongs);
                     break;
@@ -628,11 +690,7 @@ namespace SongBrowserPlugin
         private void SortOriginal(List<StandardLevelSO> levels)
         {
             _log.Info("Sorting song list as original");
-            _sortedSongs = levels;/*levels
-                .AsQueryable()
-                .OrderByDescending(x => _weights.ContainsKey(x.levelID) ? _weights[x.levelID] : 0)
-                .ThenBy(x => x.songName)
-                .ToList();*/
+            _sortedSongs = levels;
         }
 
         private void SortNewest(List<StandardLevelSO> levels)
@@ -689,6 +747,15 @@ namespace SongBrowserPlugin
                 .ToList();
         }
 
+        private void SortPerformancePoints(List<StandardLevelSO> levels)
+        {
+            _log.Info("Sorting song list by performance points...");
+
+            _sortedSongs = levels
+                .OrderByDescending(x => _ppMapping.ContainsKey(x.levelID) ? _ppMapping[x.levelID].maxPp : 0)
+                .ToList();
+        }
+
         private void SortDifficulty(List<StandardLevelSO> levels)
         {
             _log.Info("Sorting song list by random");

+ 1 - 0
SongBrowserPlugin/DataAccess/SongBrowserSettings.cs

@@ -17,6 +17,7 @@ namespace SongBrowserPlugin.DataAccess
         PlayCount,
         Difficulty,
         Random,
+        PP,
 
         // Deprecated
         Favorites,

+ 1 - 1
SongBrowserPlugin/Logger.cs

@@ -86,7 +86,7 @@ namespace SongBrowserPlugin
         public void Exception(string message, Exception e)
         {
             Console.ForegroundColor = ConsoleColor.Red;
-            Console.WriteLine("[" + loggerName + " @ " + DateTime.Now.ToString("HH:mm") + "] " + String.Format("{0}-{1}\n{2}", message, e.Message, e.StackTrace));
+            Console.WriteLine("[" + loggerName + " @ " + DateTime.Now.ToString("HH:mm") + "] " + String.Format("{0}-{1}-{2}\n{3}", message, e.GetType().FullName, e.Message, e.StackTrace));
             ResetForegroundColor();
         }
 

+ 1 - 1
SongBrowserPlugin/Plugin.cs

@@ -13,7 +13,7 @@ namespace SongBrowserPlugin
 
         public string Version
         {
-            get { return "v2.2.8"; }
+            get { return "v2.3.0-RC1"; }
         }
 
         public void OnApplicationStart()

+ 4 - 1
SongBrowserPlugin/SongBrowserApplication.cs

@@ -18,6 +18,8 @@ namespace SongBrowserPlugin
 
         // Song Browser UI Elements
         private SongBrowserUI _songBrowserUI;
+        private ScoreSaberDatabaseDownloader _ppDownloader;
+
         public Dictionary<String, Sprite> CachedIcons;
 
         /// <summary>
@@ -29,7 +31,7 @@ namespace SongBrowserPlugin
             {
                 return;
             }
-            new GameObject("BeatSaber SongBrowser Mod").AddComponent<SongBrowserApplication>();
+            new GameObject("BeatSaber SongBrowser Mod").AddComponent<SongBrowserApplication>();            
         }
 
         /// <summary>
@@ -42,6 +44,7 @@ namespace SongBrowserPlugin
             Instance = this;
 
             _songBrowserUI = gameObject.AddComponent<SongBrowserUI>();
+            _ppDownloader = gameObject.AddComponent<ScoreSaberDatabaseDownloader>();
         }
 
         /// <summary>

+ 5 - 0
SongBrowserPlugin/SongBrowserPlugin.csproj

@@ -48,6 +48,7 @@
     </Reference>
     <Reference Include="System" />
     <Reference Include="System.Data" />
+    <Reference Include="System.Security" />
     <Reference Include="System.Xml" />
     <Reference Include="TextMeshPro-1.0.55.2017.1.0b12, Version=0.0.0.0, Culture=neutral, processorArchitecture=MSIL">
       <SpecificVersion>False</SpecificVersion>
@@ -99,10 +100,14 @@
     </Reference>
   </ItemGroup>
   <ItemGroup>
+    <Compile Include="DataAccess\Network\CacheableDownloadHandler.cs" />
+    <Compile Include="DataAccess\Network\CacheableDownloadHandlerScoreSaberData.cs" />
     <Compile Include="DataAccess\Playlist.cs" />
     <Compile Include="DataAccess\PlaylistReader.cs" />
     <Compile Include="DataAccess\PlaylistSong.cs" />
+    <Compile Include="DataAccess\ScoreSaberDatabase.cs" />
     <Compile Include="Logger.cs" />
+    <Compile Include="UI\ScoreSaberDatabaseDownloader.cs" />
     <Compile Include="SongBrowserApplication.cs" />
     <Compile Include="Plugin.cs" />
     <Compile Include="Properties\AssemblyInfo.cs" />

+ 63 - 3
SongBrowserPlugin/UI/Browser/SongBrowserUI.cs

@@ -11,6 +11,7 @@ using SongLoaderPlugin;
 using System.Security.Cryptography;
 using System.Text;
 using System.Text.RegularExpressions;
+using TMPro;
 
 namespace SongBrowserPlugin.UI
 {
@@ -52,6 +53,8 @@ namespace SongBrowserPlugin.UI
         private Button _upFolderButton;
         private SearchKeyboardViewController _searchViewController;
         private PlaylistFlowCoordinator _playListFlowCoordinator;
+        private TextMeshProUGUI _ppText;
+        private TextMeshProUGUI _starText;
 
         // Cached items
         private Sprite _addFavoriteSprite;
@@ -181,12 +184,12 @@ namespace SongBrowserPlugin.UI
 
                 string[] sortButtonNames = new string[]
                 {
-                    "Song", "Author", "Original", "Newest", "Plays", "Difficult", "Random"
+                    "Song", "Author", "Original", "Newest", "Plays", "PP", "Difficult", "Random"
                 };
 
                 SongSortMode[] sortModes = new SongSortMode[]
                 {
-                    SongSortMode.Default, SongSortMode.Author, SongSortMode.Original, SongSortMode.Newest, SongSortMode.PlayCount, SongSortMode.Difficulty, SongSortMode.Random
+                    SongSortMode.Default, SongSortMode.Author, SongSortMode.Original, SongSortMode.Newest, SongSortMode.PlayCount, SongSortMode.PP, SongSortMode.Difficulty, SongSortMode.Random
                 };
 
                 _sortButtonGroup = new List<SongSortButton>();
@@ -314,7 +317,7 @@ namespace SongBrowserPlugin.UI
                         this.RefreshDirectoryButtons();
                     });
                 }
-
+                
                 RefreshSortButtonUI();
                 RefreshDirectoryButtons();
             }
@@ -340,6 +343,7 @@ namespace SongBrowserPlugin.UI
             _model.Settings.sortMode = sortMode;
             _model.Settings.Save();
 
+            //this._model.ProcessSongList();
             UpdateSongList();
             RefreshSongList();
 
@@ -485,11 +489,67 @@ namespace SongBrowserPlugin.UI
         /// <param name="level"></param>
         private void HandleDidSelectLevelRow(IStandardLevel level)
         {
+            // deal with enter folder button
             if (_enterFolderButton != null)
             {
                 _enterFolderButton.gameObject.SetActive(false);
             }
             _playButton.gameObject.SetActive(true);
+
+            // display pp potentially
+            if (this._model.PpMapping != null && this._levelDifficultyViewController.selectedDifficultyLevel != null)
+            {
+                if (this._ppText == null)
+                {
+                    // Create the PP and Star rating labels
+                    //RectTransform bmpTextRect = Resources.FindObjectsOfTypeAll<RectTransform>().First(x => x.name == "BPMText");
+                    var text = UIBuilder.CreateText(this._levelDetailViewController.rectTransform, "PP", new Vector2(-5, -41), new Vector2(20f, 10f));
+                    text.fontSize = 3.5f;
+                    text.alignment = TextAlignmentOptions.Left;
+                    text = UIBuilder.CreateText(this._levelDetailViewController.rectTransform, "STAR", new Vector2(-5, -22), new Vector2(20f, 10f));
+                    text.fontSize = 3.5f;
+                    text.alignment = TextAlignmentOptions.Left;
+
+                    RectTransform bmpValueTextRect = Resources.FindObjectsOfTypeAll<RectTransform>().First(x => x.name == "BPMValueText");
+
+                    _ppText = UIBuilder.CreateText(this._levelDetailViewController.rectTransform, "?", new Vector2(bmpValueTextRect.anchoredPosition.x, -41), new Vector2(39f, 10f));
+                    _ppText.fontSize = 3.5f;
+                    _ppText.alignment = TextAlignmentOptions.Right;
+
+                    _starText = UIBuilder.CreateText(this._levelDetailViewController.rectTransform, "", new Vector2(bmpValueTextRect.anchoredPosition.x, -22), new Vector2(39f, 10f));
+                    _starText.fontSize = 3.5f;
+                    _starText.alignment = TextAlignmentOptions.Right;
+                }
+
+                LevelDifficulty difficulty = this._levelDifficultyViewController.selectedDifficultyLevel.difficulty;
+                string difficultyString = difficulty.ToString();
+
+                _log.Debug("Checking if have info for song {0}", level.songName);
+                if (this._model.PpMapping.ContainsKey(level.levelID))
+                {
+                    _log.Debug("Checking if have difficulty for song {0} difficulty {1}", level.songName, difficultyString);
+                    ScoreSaberData ppData = this._model.PpMapping[level.levelID];
+                    if (ppData.difficultyToSaberDifficulty.ContainsKey(difficultyString))
+                    {
+                        _log.Debug("Display pp for song.");
+                        float pp = ppData.difficultyToSaberDifficulty[difficultyString].pp;
+                        float star = ppData.difficultyToSaberDifficulty[difficultyString].star;
+
+                        _ppText.SetText(String.Format("{0:0.##}", pp));
+                        _starText.SetText(String.Format("{0:0.##}", star));
+                    }
+                    else
+                    {
+                        _ppText.SetText("?");
+                        _starText.SetText("?");
+                    }
+                }
+                else
+                {
+                    _ppText.SetText("?");
+                    _starText.SetText("?");
+                }
+            }            
         }
 
         /// <summary>

+ 1 - 1
SongBrowserPlugin/UI/Keyboard/SearchKeyboardViewController.cs

@@ -38,7 +38,7 @@ namespace SongBrowserPlugin.UI
 
             if (_inputText == null)
             {
-                _inputText = UIBuilder.CreateText(rectTransform, "Search...", new Vector2(0f, -11.5f));
+                _inputText = UIBuilder.CreateText(rectTransform, "Search...", new Vector2(0f, -11.5f), new Vector2(60f, 10f));
                 _inputText.alignment = TextAlignmentOptions.Center;
                 _inputText.fontSize = 6f;
             }

+ 6 - 3
SongBrowserPlugin/UI/Playlists/PlaylistDetailViewController.cs

@@ -26,19 +26,22 @@ namespace SongBrowserPlugin.UI
         {
             _playlistTitleText = UIBuilder.CreateText(this.transform as RectTransform,
                 playlist.playlistTitle,
-                new Vector2(0, -20)                
+                new Vector2(0, -20),
+                new Vector2(60f, 10f)
             );
             _playlistTitleText.alignment = TextAlignmentOptions.Center;
 
             _playlistAuthorText = UIBuilder.CreateText(this.transform as RectTransform,
                 playlist.playlistAuthor,
-                new Vector2(0, -30)
+                new Vector2(0, -30),
+                new Vector2(60f, 10f)
             );
             _playlistAuthorText.alignment = TextAlignmentOptions.Center;
 
             _playlistNumberOfSongs = UIBuilder.CreateText(this.transform as RectTransform,
                 playlist.songs.Count.ToString(),
-                new Vector2(0, -40)
+                new Vector2(0, -40),
+                new Vector2(60f, 10f)
             );
             _playlistNumberOfSongs.alignment = TextAlignmentOptions.Center;
 

+ 72 - 0
SongBrowserPlugin/UI/ScoreSaberDatabaseDownloader.cs

@@ -0,0 +1,72 @@
+using Mobcast.Coffee.AssetSystem;
+using SongBrowserPlugin.DataAccess;
+using SongBrowserPlugin.DataAccess.Network;
+using System;
+using System.Collections;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace SongBrowserPlugin.UI
+{
+    public class ScoreSaberDatabaseDownloader : MonoBehaviour
+    {
+        public const String PP_DATA_URL = "https://raw.githubusercontent.com/DuoVR/PPFarming/master/js/songlist.tsv";
+
+        private Logger _log = new Logger("ScoreSaberDatabaseDownloader");
+
+        public static ScoreSaberDatabaseDownloader Instance;
+
+        public ScoreSaberDataFile ScoreSaberDataFile;
+
+        /// <summary>
+        /// Awake.
+        /// </summary>
+        private void Awake()
+        {
+            _log.Trace("Awake()");
+
+            Instance = this;
+        }
+
+        /// <summary>
+        /// Acquire any UI elements from Beat saber that we need.  Wait for the song list to be loaded.
+        /// </summary>
+        public void Start()
+        {
+            _log.Trace("Start()");
+
+            StartCoroutine(WaitForDownload());
+        }
+
+        /// <summary>
+        /// Wait for the tsv file from DuoVR to download.
+        /// </summary>
+        /// <returns></returns>
+        private IEnumerator WaitForDownload()
+        {
+            _log.Info("Attempting to download: {0}", ScoreSaberDatabaseDownloader.PP_DATA_URL);
+            using (UnityWebRequest www = UnityWebRequest.Get(ScoreSaberDatabaseDownloader.PP_DATA_URL))
+            {
+                // Use 4MB cache, large enough for this file to grow for awhile.
+                www.SetCacheable(new CacheableDownloadHandlerScoreSaberData(www, new byte[4 * 1048576]));
+                yield return www.SendWebRequest();
+
+                _log.Debug("Returned from web request!...");
+                
+                try
+                {
+                    this.ScoreSaberDataFile = (www.downloadHandler as CacheableDownloadHandlerScoreSaberData).ScoreSaberDataFile;
+                    _log.Info("Success!");
+                }
+                catch (System.InvalidOperationException)
+                {
+                    _log.Error("Failed to download DuoVR ScoreSaber data file...");                    
+                }
+                catch (Exception e)
+                {
+                    _log.Exception("Exception trying to download DuoVR ScoreSaber data file...", e);
+                }                
+            }            
+        }
+    }
+}

+ 2 - 2
SongBrowserPlugin/UI/UIBuilder.cs

@@ -204,7 +204,7 @@ namespace SongBrowserPlugin.UI
         /// <param name="text"></param>
         /// <param name="position"></param>
         /// <returns></returns>
-        static public TextMeshProUGUI CreateText(RectTransform parent, string text, Vector2 position)
+        static public TextMeshProUGUI CreateText(RectTransform parent, string text, Vector2 position, Vector2 width)
         {
             TextMeshProUGUI textMesh = new GameObject("TextMeshProUGUI_GO").AddComponent<TextMeshProUGUI>();
             textMesh.rectTransform.SetParent(parent, false);
@@ -215,7 +215,7 @@ namespace SongBrowserPlugin.UI
             textMesh.rectTransform.anchorMin = new Vector2(0.5f, 1f);
             textMesh.rectTransform.anchorMax = new Vector2(0.5f, 1f);
             //textMesh.rectTransform.sizeDelta = size;
-            textMesh.rectTransform.sizeDelta = new Vector2(60f, 10f);
+            textMesh.rectTransform.sizeDelta = width;
             textMesh.rectTransform.anchoredPosition = position;
 
             return textMesh;