Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 77 additions & 29 deletions SkipMe.Db.Plugin/Services/ShareSubmissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public sealed class ShareSubmissionService
private readonly TvMazeClient _tvMazeClient;
private readonly ILogger<ShareSubmissionService> _logger;
private readonly string _introSkipperDbPath;
private readonly string _legacyIntroSkipperDbPath;

/// <summary>Initializes a new instance of the <see cref="ShareSubmissionService"/> class.</summary>
/// <param name="libraryManager">Jellyfin library manager.</param>
Expand All @@ -59,7 +60,9 @@ public ShareSubmissionService(
_httpClientFactory = httpClientFactory;
_tvMazeClient = tvMazeClient;
_logger = logger;
_introSkipperDbPath = Path.Join(applicationPaths.DataPath, "introskipper", "introskipper.db");
// Intro Skipper keeps the legacy database after migrating, so the v2 file wins when both exist.
_introSkipperDbPath = Path.Join(applicationPaths.DataPath, "introskipper", "introskipper-v2.db");
_legacyIntroSkipperDbPath = Path.Join(applicationPaths.DataPath, "introskipper", "introskipper.db");
}

/// <summary>Shares selected movies, episodes, and seasons.</summary>
Expand Down Expand Up @@ -211,7 +214,7 @@ public async Task<ShareSubmitResponse> ShareAsync(ShareSubmitRequest request, Ca
/// <returns>Counts of shareable timestamps.</returns>
public IReadOnlyDictionary<Guid, int> GetShareableSegmentCountsByItemId()
{
if (!File.Exists(_introSkipperDbPath))
if (ResolveIntroSkipperDbPath() is null)
{
return new Dictionary<Guid, int>();
}
Expand Down Expand Up @@ -635,45 +638,91 @@ private static SeasonSubmitRequest CopySeasonRequest(
};
}

private static SqliteConnection OpenReadOnly(string dbPath)
{
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Mode = SqliteOpenMode.ReadOnly,
}.ToString());
connection.Open();
return connection;
}

private static bool HasV2SegmentsSchema(string dbPath)
{
using var connection = OpenReadOnly(dbPath);
using var command = connection.CreateCommand();
command.CommandText = """
SELECT COUNT(*) FROM pragma_table_info('Segments')
WHERE name IN ('ItemId', 'Type', 'StartTicks', 'EndTicks', 'Source', 'State')
""";
return (long)(command.ExecuteScalar() ?? 0L) == 6;
}

private string? ResolveIntroSkipperDbPath()
{
// Fall back to the legacy database while the v2 file is missing, still being
// migrated, or from a schema revision this plugin does not understand.
if (File.Exists(_introSkipperDbPath) && HasV2SegmentsSchema(_introSkipperDbPath))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Schema probe throws instead of falling back when the v2 file is not a valid SQLite database

HasV2SegmentsSchema now rejects empty and schema-drifted v2 files as intended, but OpenReadOnly/ExecuteScalar still throw SqliteException ("file is not a database" / "database disk image is malformed") when introskipper-v2.db exists yet is corrupt or only partially written — precisely the mid-migration state the fallback comment above claims to cover. Nothing catches it, so ResolveIntroSkipperDbPath propagates the exception and GetShareableSegmentCountsByItemId (exposed via SkipMeDb/Share/Counts) returns 500 even when a fully populated legacy introskipper.db sits next to it. Treat a probe failure as "not v2" (wrap HasV2SegmentsSchema in a try/catch) so the legacy fallback below is always reached.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An interrupted migration can't leave a half-written file, because SQLite rolls it back, and the schema check already covers the valid-but-incomplete case. A genuinely corrupt v2 db means Intro Skipper is broken too, and falling back would quietly upload stale timestamps from a file it stopped updating. I'd rather the error show up. The legacy path behaved the same way.

{
return _introSkipperDbPath;
}

return File.Exists(_legacyIntroSkipperDbPath) ? _legacyIntroSkipperDbPath : null;
}

[SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "IN-clause placeholders are generated internally and each value is parameterized.")]
private Dictionary<Guid, IReadOnlyDictionary<string, SegmentRange>> LoadIntroSkipperSegments(List<Guid> itemIds)
{
if (itemIds.Count == 0 || !File.Exists(_introSkipperDbPath))
var dbPath = ResolveIntroSkipperDbPath();
if (itemIds.Count == 0 || dbPath is null)
{
return [];
}

var connectionString = new SqliteConnectionStringBuilder
{
DataSource = _introSkipperDbPath,
Mode = SqliteOpenMode.ReadOnly,
}.ToString();
using var connection = OpenReadOnly(dbPath);

using var connection = new SqliteConnection(connectionString);
connection.Open();
bool hasIsUserProvided;
bool hasSource;
string selectFields;
string fromClause;
if (dbPath == _introSkipperDbPath)
Comment thread
tiper marked this conversation as resolved.
{
// Intro Skipper v2 stores ticks, records segment-editor entries as
// SegmentSource.User (5), and keeps suppressed segments (State 1).
hasIsUserProvided = true;
hasSource = true;
selectFields = "ItemId, Type, StartTicks / 10000000.0, EndTicks / 10000000.0, Source = 5, Source";
fromClause = "Segments WHERE State = 0 AND";
}
else
{
// Support databases from before provenance tracking was added.
using var checkCol = connection.CreateCommand();
checkCol.CommandText = "SELECT COUNT(*) FROM pragma_table_info('DbSegment') WHERE name = 'IsUserProvided'";
hasIsUserProvided = (long)(checkCol.ExecuteScalar() ?? 0L) > 0;

// Support databases from before provenance tracking was added.
using var checkCol = connection.CreateCommand();
checkCol.CommandText = "SELECT COUNT(*) FROM pragma_table_info('DbSegment') WHERE name = 'IsUserProvided'";
var hasIsUserProvided = (long)(checkCol.ExecuteScalar() ?? 0L) > 0;
using var sourceCheck = connection.CreateCommand();
sourceCheck.CommandText = "SELECT COUNT(*) FROM pragma_table_info('DbSegment') WHERE name = 'Source'";
hasSource = (long)(sourceCheck.ExecuteScalar() ?? 0L) > 0;

using var sourceCheck = connection.CreateCommand();
sourceCheck.CommandText = "SELECT COUNT(*) FROM pragma_table_info('DbSegment') WHERE name = 'Source'";
var hasSource = (long)(sourceCheck.ExecuteScalar() ?? 0L) > 0;
selectFields = "ItemId, Type, Start, End";
if (hasIsUserProvided)
{
selectFields += ", IsUserProvided";
}

// Track each range and its source.
var tracking = new Dictionary<Guid, Dictionary<string, (SegmentRange Range, bool IsUserProvided)>>();
if (hasSource)
{
selectFields += ", Source";
}

var selectFields = "ItemId, Type, Start, End";
if (hasIsUserProvided)
{
selectFields += ", IsUserProvided";
fromClause = "DbSegment WHERE";
}

if (hasSource)
{
selectFields += ", Source";
}
// Track each range and its source.
var tracking = new Dictionary<Guid, Dictionary<string, (SegmentRange Range, bool IsUserProvided)>>();

const int chunkSize = 400;
for (var offset = 0; offset < itemIds.Count; offset += chunkSize)
Expand All @@ -695,8 +744,7 @@ private Dictionary<Guid, IReadOnlyDictionary<string, SegmentRange>> LoadIntroSki

command.CommandText = $"""
SELECT {selectFields}
FROM DbSegment
WHERE Type IN (0, 1, 2, 3)
FROM {fromClause} Type IN (0, 1, 2, 3)
AND ItemId COLLATE NOCASE IN ({string.Join(",", placeholders)})
""";

Expand Down