In-Depth Filtering (#850)

* Laying the foundation for the filter rework

* Filtering by Genre is now possible.

* Cleaned up code and preparing for People filtering

* People filtering is hooked up for the frontend

* Filtering now works. On Deck does not work with filtering currently due to a unique implementation.

* More cleanup

* Implemented the ability to reset the filters

* Added a mobile drawer for filtering

* Added some additional cases for NaturalSortComparer. Filter now uses a drawer on smaller screens.

* Fixed a bug where backup service was not pointing to the correct directory.

* Undid the fix, it's working as expected
This commit is contained in:
Joseph Milazzo 2021-12-15 10:23:10 -06:00 committed by GitHub
parent ca893930d3
commit 28688ada8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 2354 additions and 187 deletions

View file

@ -47,29 +47,29 @@
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.38" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
<PackageReference Include="NetVips" Version="2.0.1" />
<PackageReference Include="NetVips.Native" Version="8.11.4" />
<PackageReference Include="NetVips" Version="2.1.0" />
<PackageReference Include="NetVips.Native" Version="8.12.1" />
<PackageReference Include="NReco.Logging.File" Version="1.1.2" />
<PackageReference Include="SharpCompress" Version="0.30.1" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.32.0.39516">
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.33.0.40503">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.14.1" />
<PackageReference Include="System.IO.Abstractions" Version="14.0.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.15.0" />
<PackageReference Include="System.IO.Abstractions" Version="14.0.13" />
<PackageReference Include="VersOne.Epub" Version="3.0.3.1" />
</ItemGroup>

View file

@ -27,6 +27,10 @@ namespace API.Comparators
{
if (x == y) return 0;
if (x != null && y == null) return -1;
if (x == null) return 1;
if (!_table.TryGetValue(x ?? Empty, out var x1))
{
x1 = Regex.Split(x ?? Empty, "([0-9]+)");

View file

@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.Metadata;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
public class MetadataController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
public MetadataController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
[HttpGet("genres")]
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres()
{
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync());
}
[HttpGet("people")]
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople()
{
return Ok(await _unitOfWork.PersonRepository.GetAllPeople());
}
}

View file

@ -334,7 +334,7 @@ namespace API.Controllers
var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
if (existingTag != null)
{
if (!series.Metadata.CollectionTags.Any(t => t.Title == tag.Title))
if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title))
{
newTags.Add(existingTag);
}

View file

@ -1,5 +1,7 @@
using System.Collections;
using System.Collections.Generic;
using API.Data.Migrations;
using API.Entities;
using API.Entities.Enums;
namespace API.DTOs.Filtering
@ -11,5 +13,64 @@ namespace API.DTOs.Filtering
/// </summary>
public IList<MangaFormat> Formats { get; init; } = new List<MangaFormat>();
/// <summary>
/// The progress you want to be returned. This can be bitwise manipulated. Defaults to all applicable states.
/// </summary>
public ReadStatus ReadStatus { get; init; } = new ReadStatus();
/// <summary>
/// A list of library ids to restrict search to. Defaults to all libraries by passing empty list
/// </summary>
public IList<int> Libraries { get; init; } = new List<int>();
/// <summary>
/// A list of Genre ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> Genres { get; init; } = new List<int>();
/// <summary>
/// A list of Writers to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> Writers { get; init; } = new List<int>();
/// <summary>
/// A list of Penciller ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> Penciller { get; init; } = new List<int>();
/// <summary>
/// A list of Inker ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> Inker { get; init; } = new List<int>();
/// <summary>
/// A list of Colorist ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> Colorist { get; init; } = new List<int>();
/// <summary>
/// A list of Letterer ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> Letterer { get; init; } = new List<int>();
/// <summary>
/// A list of CoverArtist ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> CoverArtist { get; init; } = new List<int>();
/// <summary>
/// A list of Editor ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> Editor { get; init; } = new List<int>();
/// <summary>
/// A list of Publisher ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> Publisher { get; init; } = new List<int>();
/// <summary>
/// A list of Character ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> Character { get; init; } = new List<int>();
/// <summary>
/// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list
/// </summary>
public IList<int> CollectionTags { get; init; } = new List<int>();
/// <summary>
/// Will return back everything with the rating and above
/// <see cref="AppUserRating.Rating"/>
/// </summary>
public int Rating { get; init; }
}
}

View file

@ -0,0 +1,13 @@
using System;
namespace API.DTOs.Filtering;
/// <summary>
/// Represents the Reading Status. This is a flag and allows multiple statues
/// </summary>
public class ReadStatus
{
public bool NotRead { get; set; } = false;
public bool InProgress { get; set; } = false;
public bool Read { get; set; } = false;
}

View file

@ -4,7 +4,8 @@ namespace API.DTOs
{
public class PersonDto
{
public int Id { get; set; }
public string Name { get; set; }
public PersonRole Role { get; set; }
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class SeriesIncludes : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_AppUserRating_SeriesId",
table: "AppUserRating",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_AppUserProgresses_SeriesId",
table: "AppUserProgresses",
column: "SeriesId");
migrationBuilder.AddForeignKey(
name: "FK_AppUserProgresses_Series_SeriesId",
table: "AppUserProgresses",
column: "SeriesId",
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_AppUserRating_Series_SeriesId",
table: "AppUserRating",
column: "SeriesId",
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AppUserProgresses_Series_SeriesId",
table: "AppUserProgresses");
migrationBuilder.DropForeignKey(
name: "FK_AppUserRating_Series_SeriesId",
table: "AppUserRating");
migrationBuilder.DropIndex(
name: "IX_AppUserRating_SeriesId",
table: "AppUserRating");
migrationBuilder.DropIndex(
name: "IX_AppUserProgresses_SeriesId",
table: "AppUserProgresses");
}
}
}

View file

@ -240,6 +240,8 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.HasIndex("SeriesId");
b.ToTable("AppUserProgresses");
});
@ -265,6 +267,8 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.HasIndex("SeriesId");
b.ToTable("AppUserRating");
});
@ -887,6 +891,12 @@ namespace API.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", null)
.WithMany("Progress")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
@ -898,6 +908,12 @@ namespace API.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", null)
.WithMany("Ratings")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
@ -1193,6 +1209,10 @@ namespace API.Data.Migrations
{
b.Navigation("Metadata");
b.Navigation("Progress");
b.Navigation("Ratings");
b.Navigation("Volumes");
});

View file

@ -1,8 +1,10 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Metadata;
using API.Entities;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
@ -12,7 +14,8 @@ public interface IGenreRepository
void Attach(Genre genre);
void Remove(Genre genre);
Task<Genre> FindByNameAsync(string genreName);
Task<IList<Genre>> GetAllGenres();
Task<IList<Genre>> GetAllGenresAsync();
Task<IList<GenreTagDto>> GetAllGenreDtosAsync();
Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false);
}
@ -57,8 +60,15 @@ public class GenreRepository : IGenreRepository
await _context.SaveChangesAsync();
}
public async Task<IList<Genre>> GetAllGenres()
public async Task<IList<Genre>> GetAllGenresAsync()
{
return await _context.Genre.ToListAsync();
}
public async Task<IList<GenreTagDto>> GetAllGenreDtosAsync()
{
return await _context.Genre
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
}

View file

@ -177,17 +177,15 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var formats = filter.GetSqlFilter();
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter);
var userLibraries = await GetUserLibraries(libraryId, userId);
var query = _context.Series
.Where(s => userLibraries.Contains(s.LibraryId) && formats.Contains(s.Format))
.OrderBy(s => s.SortName)
var retSeries = query
.OrderByDescending(s => s.SortName)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
private async Task<List<int>> GetUserLibraries(int libraryId, int userId)
@ -247,7 +245,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<bool> DeleteSeriesAsync(int seriesId)
{
var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync();
_context.Series.Remove(series);
if (series != null) _context.Series.Remove(series);
return await _context.SaveChangesAsync() > 0;
}
@ -376,18 +374,84 @@ public class SeriesRepository : ISeriesRepository
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var formats = filter.GetSqlFilter();
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter);
var userLibraries = await GetUserLibraries(libraryId, userId);
var query = _context.Series
.Where(s => userLibraries.Contains(s.LibraryId) && formats.Contains(s.Format))
var retSeries = query
.OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds)
{
var formats = filter.GetSqlFilter();
if (filter.Libraries.Count > 0)
{
userLibraries = userLibraries.Where(l => filter.Libraries.Contains(l)).ToList();
}
allPeopleIds = new List<int>();
allPeopleIds.AddRange(filter.Writers);
allPeopleIds.AddRange(filter.Character);
allPeopleIds.AddRange(filter.Colorist);
allPeopleIds.AddRange(filter.Editor);
allPeopleIds.AddRange(filter.Inker);
allPeopleIds.AddRange(filter.Letterer);
allPeopleIds.AddRange(filter.Penciller);
allPeopleIds.AddRange(filter.Publisher);
allPeopleIds.AddRange(filter.CoverArtist);
hasPeopleFilter = allPeopleIds.Count > 0;
hasGenresFilter = filter.Genres.Count > 0;
hasCollectionTagFilter = filter.CollectionTags.Count > 0;
hasRatingFilter = filter.Rating > 0;
hasProgressFilter = !filter.ReadStatus.Read || !filter.ReadStatus.InProgress || !filter.ReadStatus.NotRead;
bool ProgressComparison(int pagesRead, int totalPages)
{
var result = false;
if (filter.ReadStatus.NotRead)
{
result = (pagesRead == 0);
}
if (filter.ReadStatus.Read)
{
result = result || (pagesRead == totalPages);
}
if (filter.ReadStatus.InProgress)
{
result = result || (pagesRead > 0 && pagesRead < totalPages);
}
return result;
}
seriesIds = new List<int>();
if (hasProgressFilter)
{
seriesIds = _context.Series
.Include(s => s.Progress)
.Select(s => new
{
Series = s,
PagesRead = s.Progress.Where(p => p.AppUserId == userId).Sum(p => p.PagesRead),
})
.ToList()
.Where(s => ProgressComparison(s.PagesRead, s.Series.Pages))
.Select(s => s.Series.Id)
.ToList();
}
return formats;
}
/// <summary>
@ -401,24 +465,23 @@ public class SeriesRepository : ISeriesRepository
/// <returns></returns>
public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
{
var formats = filter.GetSqlFilter();
var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter))
.Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) =>
new
{
Series = s,
PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId)
.Sum(s1 => s1.PagesRead),
progress.AppUserId,
LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId)
.Max(p => p.LastModified)
});
var userLibraries = await GetUserLibraries(libraryId, userId);
var series = _context.Series
.Where(s => formats.Contains(s.Format) && userLibraries.Contains(s.LibraryId))
.Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new
{
Series = s,
PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId).Sum(s1 => s1.PagesRead),
progress.AppUserId,
LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId).Max(p => p.LastModified)
})
.AsNoTracking();
var retSeries = series.Where(s => s.AppUserId == userId
&& s.PagesRead > 0
&& s.PagesRead < s.Series.Pages)
var retSeries = query.Where(s => s.AppUserId == userId
&& s.PagesRead > 0
&& s.PagesRead < s.Series.Pages)
.OrderByDescending(s => s.LastModified)
.ThenByDescending(s => s.Series.LastModified)
.Select(s => s.Series)
@ -430,6 +493,63 @@ public class SeriesRepository : ISeriesRepository
return await retSeries.ToListAsync();
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter)
{
var userLibraries = await GetUserLibraries(libraryId, userId);
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
out var seriesIds);
var query = _context.Series
.Where(s => userLibraries.Contains(s.LibraryId)
&& formats.Contains(s.Format)
&& (!hasGenresFilter || s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id)))
&& (!hasPeopleFilter || s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id)))
&& (!hasCollectionTagFilter ||
s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
&& (!hasRatingFilter || s.Ratings.Any(r => r.Rating >= filter.Rating))
&& (!hasProgressFilter || seriesIds.Contains(s.Id))
)
.AsNoTracking();
// IQueryable<FilterableQuery> newFilter = null;
// if (hasProgressFilter)
// {
// newFilter = query
// .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) =>
// new
// {
// Series = s,
// PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId)
// .Sum(s1 => s1.PagesRead),
// progress.AppUserId,
// LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId)
// .Max(p => p.LastModified)
// })
// .Select(d => new FilterableQuery()
// {
// Series = d.Series,
// AppUserId = d.AppUserId,
// LastModified = d.LastModified,
// PagesRead = d.PagesRead
// })
// .Where(d => seriesIds.Contains(d.Series.Id));
// }
// else
// {
// newFilter = query.Select(s => new FilterableQuery()
// {
// Series = s,
// LastModified = DateTime.Now, // TODO: Figure this out
// AppUserId = userId,
// PagesRead = 0
// });
// }
return query;
}
public async Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId)
{
var metadataDto = await _context.SeriesMetadata

View file

@ -53,6 +53,8 @@ namespace API.Entities
public MangaFormat Format { get; set; } = MangaFormat.Unknown;
public SeriesMetadata Metadata { get; set; }
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
public ICollection<AppUserProgress> Progress { get; set; } = new List<AppUserProgress>();
// Relationships
public List<Volume> Volumes { get; set; }

View file

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Data;
using System.Data.Common;
using API.DTOs;
using Microsoft.EntityFrameworkCore;
namespace API.Helpers

View file

@ -412,7 +412,7 @@ namespace API.Parser
MatchOptions, RegexTimeout),
// Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz
new Regex(
@"^(?!Vol)(?<Series>.+?)(?<!Vol)\.?\s(\d\s)?(?<Chapter>\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)",
@"^(?!Vol)(?<Series>.+?)(?<!Vol)(?<!Vol.)\s(\d\s)?(?<Chapter>\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)",
MatchOptions, RegexTimeout),
// Tower Of God S01 014 (CBT) (digital).cbz
new Regex(

View file

@ -369,7 +369,7 @@ public class MetadataService : IMetadataService
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
var allGenres = await _unitOfWork.GenreRepository.GetAllGenres();
var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
var seriesIndex = 0;
@ -489,7 +489,7 @@ public class MetadataService : IMetadataService
MessageFactory.RefreshMetadataProgressEvent(libraryId, 0F));
var allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
var allGenres = await _unitOfWork.GenreRepository.GetAllGenres();
var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
ProcessSeriesMetadataUpdate(series, allPeople, allGenres, forceUpdate);

View file

@ -128,8 +128,6 @@ namespace API.Services.Tasks.Scanner
{
info.Chapters = info.ComicInfo.Number;
}
_logger.LogDebug("ComicInfo read added {Time} ms to processing", sw.ElapsedMilliseconds);
}
TrackSeries(info);