Improvements to CoverDB and cover loading (#893)
* Cover handling improvement * Cover handling improvement * Cover handling improvement * Remove CoverDB flag (not need anymore) * Remove CoverDB flag (not needed anymore) * Cover handling improvement * Cover handling improvement --------- Co-authored-by: Florian Ostertag <flokuep@users.noreply.github.com>
This commit is contained in:
1 parent
b822281e04
commit
ba5a361d42
5 files changed
+323
-114
No files matched your search
+148
-23
@@ -1,4 +1,4 @@
|
||||
#region license
|
||||
#region license
|
||||
// This file is part of Vocaluxe.
|
||||
//
|
||||
// Vocaluxe is free software: you can redistribute it and/or modify
|
||||
@@ -18,8 +18,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using VocaluxeLib;
|
||||
@@ -34,6 +38,8 @@ namespace Vocaluxe.Base
|
||||
{
|
||||
private const string _NoCoverName = "No Cover";
|
||||
private const string _NoCoverNameAlt = "NoCover";
|
||||
private const string _CoverCacheFolderName = "CoverCache";
|
||||
private const int _CoverCacheVersion = 1;
|
||||
private static readonly Dictionary<string, CTextureRef> _Covers = new Dictionary<string, CTextureRef>();
|
||||
private static readonly Dictionary<ECoverGeneratorType, CCoverGenerator> _CoverGenerators = new Dictionary<ECoverGeneratorType, CCoverGenerator>();
|
||||
private static readonly List<SThemeCover> _CoverThemes = new List<SThemeCover>();
|
||||
@@ -97,35 +103,74 @@ namespace Vocaluxe.Base
|
||||
|
||||
public static CTextureRef GenerateCover(string text, ECoverGeneratorType type, CSong firstSong)
|
||||
{
|
||||
var texture = Cover(text);
|
||||
if (texture != NoCover)
|
||||
lock (_Covers)
|
||||
{
|
||||
if (_CoverExists(text))
|
||||
return _Covers[text];
|
||||
}
|
||||
|
||||
CTextureRef texture;
|
||||
if (_TryLoadCachedCover(text, type, firstSong, out texture))
|
||||
{
|
||||
lock (_Covers)
|
||||
{
|
||||
if (!_CoverExists(text))
|
||||
_Covers.Add(text, texture);
|
||||
else
|
||||
{
|
||||
var existingTexture = _Covers[text];
|
||||
CDraw.RemoveTexture(ref texture);
|
||||
texture = existingTexture;
|
||||
}
|
||||
}
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
texture = CDraw.CopyTexture(NoCover);
|
||||
Task.Factory.StartNew(() =>
|
||||
{
|
||||
_CancelToken.Token.ThrowIfCancellationRequested();
|
||||
var coverBmp = !_CoverGenerators.ContainsKey(type)
|
||||
? null : _CoverGenerators[type].GetCover(text, firstSong != null ? Path.Combine(firstSong.Folder, firstSong.Cover) : null);
|
||||
_CancelToken.Token.ThrowIfCancellationRequested();
|
||||
if (coverBmp == null && _CoverGenerators.ContainsKey(ECoverGeneratorType.Default))
|
||||
{
|
||||
coverBmp = _CoverGenerators[ECoverGeneratorType.Default].GetCover(text, firstSong != null ? Path.Combine(firstSong.Folder, firstSong.Cover) : null);
|
||||
}
|
||||
var startGeneration = false;
|
||||
|
||||
_CancelToken.Token.ThrowIfCancellationRequested();
|
||||
if (coverBmp != null)
|
||||
{
|
||||
CDraw.EnqueueTextureUpdate(texture, coverBmp);
|
||||
}
|
||||
|
||||
_CancelToken.Token.ThrowIfCancellationRequested();
|
||||
}, _CancelToken.Token);
|
||||
lock (_Covers)
|
||||
{
|
||||
_Covers.Add(text, texture);
|
||||
if (!_CoverExists(text))
|
||||
{
|
||||
_Covers.Add(text, texture);
|
||||
startGeneration = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var existingTexture = _Covers[text];
|
||||
CDraw.RemoveTexture(ref texture);
|
||||
texture = existingTexture;
|
||||
}
|
||||
}
|
||||
|
||||
if (startGeneration)
|
||||
{
|
||||
Task.Factory.StartNew(() =>
|
||||
{
|
||||
_CancelToken.Token.ThrowIfCancellationRequested();
|
||||
|
||||
var firstCoverPath = firstSong != null ? Path.Combine(firstSong.Folder, firstSong.Cover) : null;
|
||||
var coverBmp = !_CoverGenerators.ContainsKey(type)
|
||||
? null
|
||||
: _CoverGenerators[type].GetCover(text, firstCoverPath);
|
||||
|
||||
_CancelToken.Token.ThrowIfCancellationRequested();
|
||||
|
||||
if (coverBmp == null && _CoverGenerators.ContainsKey(ECoverGeneratorType.Default))
|
||||
coverBmp = _CoverGenerators[ECoverGeneratorType.Default].GetCover(text, firstCoverPath);
|
||||
|
||||
_CancelToken.Token.ThrowIfCancellationRequested();
|
||||
|
||||
if (coverBmp != null)
|
||||
{
|
||||
_TrySaveCachedCover(text, type, firstSong, coverBmp);
|
||||
CDraw.EnqueueTextureUpdate(texture, coverBmp);
|
||||
}
|
||||
|
||||
_CancelToken.Token.ThrowIfCancellationRequested();
|
||||
}, _CancelToken.Token);
|
||||
}
|
||||
|
||||
return texture;
|
||||
@@ -325,5 +370,85 @@ namespace Vocaluxe.Base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool _TryLoadCachedCover(string text, ECoverGeneratorType type, CSong firstSong, out CTextureRef texture)
|
||||
{
|
||||
texture = null;
|
||||
try
|
||||
{
|
||||
var coverTheme = _GetCoverTheme();
|
||||
var cachePath = _GetCoverCacheFilePath(text, type, firstSong, coverTheme);
|
||||
if (!File.Exists(cachePath))
|
||||
return false;
|
||||
|
||||
texture = CDraw.AddTexture(cachePath);
|
||||
return texture != null;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CLog.Error("Error loading generated cover cache: " + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void _TrySaveCachedCover(string text, ECoverGeneratorType type, CSong firstSong, Bitmap bmp)
|
||||
{
|
||||
try
|
||||
{
|
||||
var coverTheme = _GetCoverTheme();
|
||||
var cachePath = _GetCoverCacheFilePath(text, type, firstSong, coverTheme);
|
||||
var cacheFolder = Path.GetDirectoryName(cachePath);
|
||||
if (string.IsNullOrEmpty(cacheFolder))
|
||||
return;
|
||||
|
||||
Directory.CreateDirectory(cacheFolder);
|
||||
|
||||
var tmpPath = cachePath + ".tmp";
|
||||
bmp.Save(tmpPath, ImageFormat.Png);
|
||||
|
||||
if (File.Exists(cachePath))
|
||||
File.Delete(cachePath);
|
||||
|
||||
File.Move(tmpPath, cachePath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CLog.Error("Error writing generated cover cache: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
private static string _GetCoverCacheFolder(SThemeCover coverTheme)
|
||||
{
|
||||
return Path.Combine(CSettings.ProgramFolder, _CoverCacheFolderName, coverTheme.Info.Name);
|
||||
}
|
||||
|
||||
private static string _GetCoverCacheFilePath(string text, ECoverGeneratorType type, CSong firstSong, SThemeCover coverTheme)
|
||||
{
|
||||
return Path.Combine(_GetCoverCacheFolder(coverTheme), _GetCoverCacheKey(text, type, firstSong, coverTheme) + ".png");
|
||||
}
|
||||
|
||||
private static string _GetCoverCacheKey(string text, ECoverGeneratorType type, CSong firstSong, SThemeCover coverTheme)
|
||||
{
|
||||
var firstCoverPath = firstSong != null ? Path.Combine(firstSong.Folder, firstSong.Cover) : "";
|
||||
var firstCoverStamp = "";
|
||||
|
||||
if (!string.IsNullOrEmpty(firstCoverPath) && File.Exists(firstCoverPath))
|
||||
firstCoverStamp = File.GetLastWriteTimeUtc(firstCoverPath).Ticks.ToString();
|
||||
|
||||
var rawKey = string.Join("|",
|
||||
_CoverCacheVersion,
|
||||
coverTheme.Info.Name,
|
||||
type,
|
||||
text,
|
||||
firstCoverPath,
|
||||
firstCoverStamp
|
||||
);
|
||||
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(rawKey));
|
||||
return BitConverter.ToString(hash).Replace("-", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#region license
|
||||
#region license
|
||||
// This file is part of Vocaluxe.
|
||||
//
|
||||
// Vocaluxe is free software: you can redistribute it and/or modify
|
||||
@@ -70,9 +70,7 @@ namespace Vocaluxe.Lib.Database
|
||||
public bool GetCover(string coverPath, ref CTextureRef tex, int maxSize)
|
||||
{
|
||||
if (_Connection == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(coverPath))
|
||||
{
|
||||
@@ -84,18 +82,14 @@ namespace Vocaluxe.Lib.Database
|
||||
{
|
||||
//Double check here because we may have just closed our connection
|
||||
if (_Connection == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using (var command = new SqliteCommand())
|
||||
{
|
||||
command.Connection = _Connection;
|
||||
// If we have an open transaction on this connection, all commands must use it.
|
||||
if (_TransactionCover != null)
|
||||
{
|
||||
command.Transaction = _TransactionCover;
|
||||
}
|
||||
|
||||
command.CommandText = "SELECT id, width, height FROM Cover WHERE [Path] = @path";
|
||||
command.Parameters.Clear();
|
||||
@@ -121,7 +115,40 @@ namespace Vocaluxe.Lib.Database
|
||||
reader.Read();
|
||||
var data2 = _GetBytes(reader);
|
||||
reader.Dispose();
|
||||
tex = CDraw.EnqueueTexture(w, h, data2);
|
||||
|
||||
try
|
||||
{
|
||||
using (var stream = new MemoryStream(data2))
|
||||
using (var source = new System.Drawing.Bitmap(stream))
|
||||
{
|
||||
var sourceSize = source.GetSize();
|
||||
|
||||
System.Drawing.Bitmap bitmap;
|
||||
|
||||
if (sourceSize.Width > maxSize || sourceSize.Height > maxSize)
|
||||
{
|
||||
var targetSize = CHelper.FitInBounds(
|
||||
new SRectF(0, 0, maxSize, maxSize, 0),
|
||||
(float)sourceSize.Width / sourceSize.Height,
|
||||
EAspect.LetterBox
|
||||
).SizeI;
|
||||
|
||||
bitmap = source.Resize(targetSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
bitmap = new System.Drawing.Bitmap(source);
|
||||
}
|
||||
|
||||
tex = CDraw.EnqueueTexture(bitmap);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CLog.Error("Can't decode cover from database: " + e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -129,94 +156,104 @@ namespace Vocaluxe.Lib.Database
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("@id", id);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (reader != null)
|
||||
{
|
||||
reader.Close();
|
||||
if (reader != null)
|
||||
reader.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At this point we do not have a mathing entry in the CoverDB (either no Data found and deleted or nothing at all)
|
||||
// We break out of the lock to do the bitmap loading and resizing here to allow multithreaded loading
|
||||
// At this point we do not have a matching entry in the CoverDB
|
||||
// (either no Data found and deleted or nothing at all)
|
||||
// We break out of the lock to do the bitmap loading here to allow multithreaded loading
|
||||
|
||||
var origin = CHelper.LoadBitmap(coverPath);
|
||||
if (origin == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
byte[] data;
|
||||
int width;
|
||||
int height;
|
||||
|
||||
var size = origin.GetSize();
|
||||
if (size.Width > maxSize || size.Height > maxSize)
|
||||
{
|
||||
size = CHelper.FitInBounds(new SRectF(0, 0, maxSize, maxSize, 0), (float)size.Width / size.Height, EAspect.LetterBox).SizeI;
|
||||
var tmp = origin.Resize(size);
|
||||
origin.Dispose();
|
||||
origin = tmp;
|
||||
}
|
||||
|
||||
byte[] data;
|
||||
|
||||
try
|
||||
{
|
||||
data = new byte[size.Width * size.Height * 4];
|
||||
var bmpData = origin.LockBits(origin.GetRect(), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
Marshal.Copy(bmpData.Scan0, data, 0, data.Length);
|
||||
origin.UnlockBits(bmpData);
|
||||
}
|
||||
finally
|
||||
{
|
||||
origin.Dispose();
|
||||
}
|
||||
|
||||
tex = CDraw.EnqueueTexture(size.Width, size.Height, data);
|
||||
|
||||
lock (_Mutex)
|
||||
{
|
||||
//Double check here because we may have just closed our connection
|
||||
if (_Connection == null)
|
||||
try
|
||||
{
|
||||
data = File.ReadAllBytes(coverPath);
|
||||
|
||||
using (var source = CHelper.LoadBitmap(coverPath))
|
||||
{
|
||||
if (source == null)
|
||||
return false;
|
||||
|
||||
var sourceSize = source.GetSize();
|
||||
width = sourceSize.Width;
|
||||
height = sourceSize.Height;
|
||||
|
||||
System.Drawing.Bitmap bitmap;
|
||||
|
||||
if (sourceSize.Width > maxSize || sourceSize.Height > maxSize)
|
||||
{
|
||||
var targetSize = CHelper.FitInBounds(
|
||||
new SRectF(0, 0, maxSize, maxSize, 0),
|
||||
(float)sourceSize.Width / sourceSize.Height,
|
||||
EAspect.LetterBox
|
||||
).SizeI;
|
||||
|
||||
bitmap = source.Resize(targetSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
bitmap = new System.Drawing.Bitmap(source);
|
||||
}
|
||||
|
||||
tex = CDraw.EnqueueTexture(bitmap);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CLog.Error("Error loading cover: " + coverPath + " - " + e);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_TransactionCover == null)
|
||||
lock (_Mutex)
|
||||
{
|
||||
_TransactionCover = _Connection.BeginTransaction();
|
||||
}
|
||||
// Double check here because we may have just closed our connection
|
||||
if (_Connection == null)
|
||||
return false;
|
||||
|
||||
using (var command = new SqliteCommand())
|
||||
{
|
||||
command.Connection = _Connection;
|
||||
command.Transaction = _TransactionCover;
|
||||
command.CommandText = "INSERT INTO Cover (Path, width, height) VALUES (@path, @w, @h)";
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("@w", size.Width);
|
||||
command.Parameters.AddWithValue("@h", size.Height);
|
||||
command.Parameters.AddWithValue("@path", coverPath);
|
||||
command.ExecuteNonQuery();
|
||||
if (_TransactionCover == null)
|
||||
_TransactionCover = _Connection.BeginTransaction();
|
||||
|
||||
command.CommandText = "SELECT id FROM Cover WHERE [Path] = @path";
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("@path", coverPath);
|
||||
var reader = command.ExecuteReader();
|
||||
|
||||
if (reader != null)
|
||||
using (var command = new SqliteCommand())
|
||||
{
|
||||
reader.Read();
|
||||
command.Connection = _Connection;
|
||||
command.Transaction = _TransactionCover;
|
||||
command.CommandText = "INSERT INTO Cover (Path, width, height) VALUES (@path, @w, @h)";
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("@w", width);
|
||||
command.Parameters.AddWithValue("@h", height);
|
||||
command.Parameters.AddWithValue("@path", coverPath);
|
||||
command.ExecuteNonQuery();
|
||||
|
||||
command.CommandText = "SELECT id FROM Cover WHERE [Path] = @path";
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("@path", coverPath);
|
||||
|
||||
var reader = command.ExecuteReader();
|
||||
|
||||
if (reader == null || !reader.Read())
|
||||
{
|
||||
reader?.Dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
var id = reader.GetInt32(0);
|
||||
reader.Dispose();
|
||||
|
||||
command.CommandText = "INSERT INTO CoverData (CoverId, Data) VALUES (@id, @data)";
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("@id", id);
|
||||
command.Parameters.AddWithValue("@data", data);
|
||||
command.ExecuteNonQuery();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void CommitCovers()
|
||||
@@ -275,4 +312,4 @@ namespace Vocaluxe.Lib.Database
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-15
@@ -104,20 +104,6 @@ namespace Vocaluxe
|
||||
// Create data folder
|
||||
Directory.CreateDirectory(CSettings.DataFolder);
|
||||
|
||||
// Delete CoverDB.sqlite if marker exists (cover size changed)
|
||||
var flagPath = Path.Combine(CSettings.DataFolder, "DeleteCoverDB.flag");
|
||||
var coverDbPath = Path.Combine(CSettings.DataFolder, CSettings.FileNameCoverDB);
|
||||
|
||||
if (File.Exists(flagPath))
|
||||
{
|
||||
if (File.Exists(coverDbPath))
|
||||
{
|
||||
File.Delete(coverDbPath); // Delete the database
|
||||
}
|
||||
|
||||
File.Delete(flagPath); // Remove the marker so it doesn't repeat
|
||||
}
|
||||
|
||||
// Init Log
|
||||
CLog.Init(CSettings.FolderNameLogs,
|
||||
CSettings.FileNameMainLog,
|
||||
@@ -585,4 +571,4 @@ namespace Vocaluxe
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,8 +181,6 @@ namespace Vocaluxe.Screens
|
||||
var newCoverSize = int.Parse(selectedValue);
|
||||
if (currentCoverSize != newCoverSize)
|
||||
{
|
||||
var flagPath = Path.Combine(CSettings.DataFolder, "DeleteCoverDB.flag");
|
||||
File.Create(flagPath).Dispose();
|
||||
_Texts[_TextWarningRestart].Visible = true;
|
||||
_Statics[_StaticWarningRestart].Visible = true;
|
||||
CConfig.Config.Graphics.CoverSize = newCoverSize;
|
||||
@@ -207,4 +205,4 @@ namespace Vocaluxe.Screens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ using VocaluxeLib;
|
||||
using VocaluxeLib.Menu;
|
||||
using VocaluxeLib.Menu.SongMenu;
|
||||
using VocaluxeLib.PartyModes;
|
||||
using VocaluxeLib.Songs;
|
||||
|
||||
namespace Vocaluxe.Screens
|
||||
{
|
||||
@@ -970,6 +971,7 @@ namespace Vocaluxe.Screens
|
||||
|
||||
_Statics[_StaticShortInfoTop].Visible = false;
|
||||
_Texts[_TextShortInfoTop].Visible = false;
|
||||
_PreloadVisibleCovers();
|
||||
|
||||
UpdateGame();
|
||||
}
|
||||
@@ -1045,6 +1047,67 @@ namespace Vocaluxe.Screens
|
||||
|
||||
return true;
|
||||
}
|
||||
private void _PreloadVisibleCovers()
|
||||
{
|
||||
if (CSongs.IsInCategory)
|
||||
{
|
||||
var coverType = CCover._SongSortingToType(_Sso.Sorting.SongSorting);
|
||||
foreach (var song in CSongs.VisibleSongs)
|
||||
{
|
||||
if (song == null)
|
||||
continue;
|
||||
|
||||
if (string.IsNullOrEmpty(song.Cover))
|
||||
{
|
||||
var text = _GetGeneratedCoverText(song, coverType);
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
CCover.GenerateCover(text, coverType, song);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var category in CSongs.Categories)
|
||||
{
|
||||
if (category == null || string.IsNullOrEmpty(category.Name))
|
||||
continue;
|
||||
|
||||
var coverType = CCover._SongSortingToType(_Sso.Sorting.SongSorting);
|
||||
CCover.GenerateCover(category.Name, coverType, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string _GetGeneratedCoverText(CSong song, ECoverGeneratorType coverType)
|
||||
{
|
||||
switch (coverType)
|
||||
{
|
||||
case ECoverGeneratorType.Artist:
|
||||
return song.Artist;
|
||||
case ECoverGeneratorType.Folder:
|
||||
return song.FolderName;
|
||||
case ECoverGeneratorType.Edition:
|
||||
return song.Editions.Count > 0 ? song.Editions[0] : "";
|
||||
case ECoverGeneratorType.Genre:
|
||||
return song.Genres.Count > 0 ? song.Genres[0] : "";
|
||||
case ECoverGeneratorType.Language:
|
||||
return song.Languages.Count > 0 ? song.Languages[0] : "";
|
||||
case ECoverGeneratorType.Year:
|
||||
return song.Year;
|
||||
case ECoverGeneratorType.Decade:
|
||||
return !string.IsNullOrEmpty(song.Year) && song.Year.Length >= 3
|
||||
? song.Year.Substring(0, 3) + "0s"
|
||||
: "";
|
||||
case ECoverGeneratorType.Tags:
|
||||
return song.Tags.Count > 0 ? song.Tags[0] : "";
|
||||
case ECoverGeneratorType.Date:
|
||||
return song.DateAdded.ToString("yyyy-MM-dd");
|
||||
case ECoverGeneratorType.Letter:
|
||||
return !string.IsNullOrEmpty(song.Artist) ? song.Artist.Substring(0, 1) : "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnClose()
|
||||
{
|
||||
@@ -2011,4 +2074,4 @@ namespace Vocaluxe.Screens
|
||||
}
|
||||
#endregion Playlist Actions
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user