Allow Spotify history uploads

It works B) Just need to fix date filtering, and I should be able to start writing views for this kind of stuff. After that, editing the data in the browser.
This commit is contained in:
mitteneer 2025-02-24 19:19:37 -05:00
parent e2ff66ea50
commit 16f5b8bdba
5 changed files with 106 additions and 86 deletions

3
.gitignore vendored
View file

@ -5,4 +5,5 @@ static/
.jetzig .jetzig
src/app/database/data.db-journal src/app/database/data.db-journal
src/app/database/old_migrations/ src/app/database/old_migrations/
src/lib/ src/lib/time.h

View file

@ -23,41 +23,94 @@ pub fn post(request: *jetzig.Request) !jetzig.View {
if (try request.file("upload")) |file| { if (try request.file("upload")) |file| {
const params = try request.params(); const params = try request.params();
const source = try std.fmt.parseInt(u8, params.get("t").?.string.value, 10); // This param is required in HTML
var content: ScrobbleTypes.UploadData = undefined; // Date limiting is broken atm
const before_limiter: bool = if (params.get("bbool")) |_| true else false;
const source = std.fmt.parseInt(u8, params.get("t").string.value, 10).?; const after_limiter: bool = if (params.get("abool")) |_| true else false;
//if (params.get("t")) |param| {
// const source = std.fmt.parseInt(u8, param.string.value, 10);
switch (source) {
0 => content = try std.json.parseFromSliceLeaky(ScrobbleTypes.LastFM, request.allocator, file.content, .{}),
1 => content = try std.json.parseFromSliceLeaky(ScrobbleTypes.Spotify, request.allocator, file.content, .{}),
else => unreachable,
}
//const content = try std.json.parseFromSliceLeaky(lastfm, request.allocator, file.content, .{ .ignore_unknown_fields = true });
var scrobbles_view = try root.put("scrobbles", .array); var scrobbles_view = try root.put("scrobbles", .array);
var job = try request.job("process_scrobbles"); var job = try request.job("process_scrobbles");
var scrobbles_data = try job.params.put("scrobbles", .array); var scrobbles_data = try job.params.put("scrobbles", .array);
// This is seconds from Unix epoch var skipped_tracks: u64 = 0;
const limiting_date = if (params.get("l")) |param| (try zeit.instant(.{ .source = .{ .iso8601 = param.string.value } })).unixTimestamp() else 9_223_372_036_854_775_807; var limited_tracks: u64 = 0;
// The only difference between a LastFM scrobble and a Spotify scrobble is the format.
// I've made a branches for each, because doing it all in one made the readability terrible,
// and formatting the date in particular was challenging. I could probably pull out the
// actual appending at some point, since that's the same process for each, but I'm not
// sure how to do that yet.
switch (source) {
0 => {
const content: ScrobbleTypes.LastFM = try std.json.parseFromSliceLeaky(ScrobbleTypes.LastFM, request.allocator, file.content, .{});
const before_limiting_date = if (before_limiter and params.get("b") != null) (try zeit.instant(.{ .source = .{ .iso8601 = params.get("b").?.string.value } })).unixTimestamp() * 1000 else 0;
const after_limiting_date = if (after_limiter and params.get("a") != null) (try zeit.instant(.{ .source = .{ .iso8601 = params.get("a").?.string.value } })).unixTimestamp() * 1000 else 9_223_372_036_854_775_807;
appends: for (content.scrobbles) |scrobble| { appends: for (content.scrobbles) |scrobble| {
if (std.mem.eql(u8, @typeName(scrobble), "SpotifyScrobble")) scrobble = scrobble.scrobblize(); // We can short-circuit on the limiter bools
// Scrobble.date is in milliseconds from Unix epoch if ((before_limiter or after_limiter) and (scrobble.date > before_limiting_date or scrobble.date < after_limiting_date)) continue :appends;
if (scrobble.date < limiting_date * 1000) continue :appends;
var value = try scrobbles_data.append(.object); var value = try scrobbles_data.append(.object);
// This is so unnecessary, probably useful once I start doing Spotify integration though // This is so unnecessary, probably useful once I start doing Spotify integration though
inline for (std.meta.fields(ScrobbleTypes.Scrobble)) |f| { inline for (std.meta.fields(ScrobbleTypes.LastFMScrobble)) |f| {
try value.put(f.name, @as(f.type, @field(scrobble, f.name))); try value.put(f.name, @as(f.type, @field(scrobble, f.name)));
} }
// Note sure why this works for ZMPL, but not for jobs. // Note sure why this works for ZMPL, but not for jobs.
try scrobbles_view.append(scrobble); try scrobbles_view.append(scrobble);
} }
},
1 => {
const content: []ScrobbleTypes.SpotifyScrobble = try std.json.parseFromSliceLeaky([]ScrobbleTypes.SpotifyScrobble, request.allocator, file.content, .{});
const before_limiting_date: ?zeit.Time = if (before_limiter) (try zeit.Time.fromISO8601(params.get("b").?.string.value)) else null;
const after_limiting_date: ?zeit.Time = if (after_limiter) (try zeit.Time.fromISO8601(params.get("a").?.string.value)) else null;
appends: for (content) |scrobble| {
// A LastFM Scrobble occurs when half a song has been played
// or the song plays for 4 minutes, whichever happens first.
// Spotify considers a song played if it plays for 30 seconds.
// Ideally, I would go with the LastFM convention, but Spotify
// history data only gives us so much information. I'm okay
// with the 30 second convention, but eventually I would prefer
// to get the song length from MusicBrainz and check if it meets
// the requirement. Until then, if it goes 30 seconds, or the
// reason_end field reads "trackdone", then it counts as a Scrobble.
// May consider giving user control to the minimum millisecond requirement.
if (scrobble.reason_end != null and (scrobble.ms_played < 30_000 and !std.mem.eql(u8, scrobble.reason_end.?, "trackdone"))) {
skipped_tracks += 1;
continue :appends;
}
// In the case where the artist is null, but there's other metadata, I could
// probably let the user edit it in themselves, although I'm not sure if that
// situation happens.
if (scrobble.master_metadata_album_artist_name == null or scrobble.master_metadata_track_name == null) {
skipped_tracks += 1;
continue :appends;
}
// I'm separating these on account of the above comment, as well as
// this part being kinda complicated
const iso_ts = try zeit.Time.fromISO8601(scrobble.ts);
if ((before_limiter or after_limiter) and (iso_ts.after(before_limiting_date.?) or iso_ts.before(after_limiting_date.?))) {
limited_tracks += 1;
continue :appends;
}
// Turn SpotifyScrobble into a LastFM scrobble
const formatted_scrobble: ScrobbleTypes.LastFMScrobble = .{ .track = scrobble.master_metadata_track_name.?, .album = scrobble.master_metadata_album_album_name.?, .artist = scrobble.master_metadata_album_artist_name.?, .date = (try zeit.instant(.{ .source = .{ .iso8601 = scrobble.ts } })).unixTimestamp() * 1000 };
var value = try scrobbles_data.append(.object);
// This is so unnecessary, probably useful once I start doing Spotify integration though
inline for (std.meta.fields(ScrobbleTypes.LastFMScrobble)) |f| {
try value.put(f.name, @as(f.type, @field(formatted_scrobble, f.name)));
}
// Note sure why this works for ZMPL, but not for jobs.
try scrobbles_view.append(formatted_scrobble);
}
},
else => unreachable,
}
try job.schedule(); try job.schedule();
std.log.debug("Skipped {} tracks", .{skipped_tracks});
std.log.debug("Filtered {} tracks", .{limited_tracks});
} }
var upload_table = try root.put("upload_table", .array); var upload_table = try root.put("upload_table", .array);

View file

@ -14,9 +14,10 @@
<input type="file" name="upload" /> <input type="file" name="upload" />
<input type="submit" value="Submit" /> <input type="submit" value="Submit" />
<fieldset> <fieldset>
<input type="radio" name="t" label="Last.fm" value="0">Last.fm</input> <input type="radio" name="t" label="Last.fm" value="0" required>Last.fm</input>
<input type="radio" name="t" label="Spotify" value="1">Spotify</input> <input type="radio" name="t" label="Spotify" value="1" required>Spotify</input>
Upload Scrobbles after: <input type="datetime-local" name="l" label="date"></input> <input type="checkbox" name="bbool" id="bbool" value="false" onclick="document.getElementById('abool').setAttribute('required', 'required')"></input>Limit to Scrobbles before: <input type="datetime-local" name="b" label="date-before"></input>
<input type="checkbox" name="abool" id="abool" value="false" onclick="document.getElementById('bbool').setAttribute('required', 'required')"></input>Limit to Scrobbles after: <input type="datetime-local" name="a" label="date-after"></input>
</fieldset> </fieldset>
</form> </form>
</body> </body>

View file

@ -86,36 +86,6 @@ pub const jetzig_options = struct {
}; };
pub fn main() !void { pub fn main() !void {
//var db = try sqlite.Db.init(.{
// .mode = sqlite.Db.Mode{ .File = "/home/swebb/Source/zuletzt/src/app/database/data.db" },
// .open_flags = .{
// .write = true,
// .create = true,
// },
// .threading_mode = .MultiThread,
//});
//const create =
// \\CREATE TABLE artists ('artist', 'plays')
//;
//const query =
// \\INSERT INTO artists ('artist', 'plays') VALUES (?,?)
//;
//var build = try db.prepare(create);
//defer build.deinit();
//try build.exec(.{},.{});
//var stmt = try db.prepare(query);
//defer stmt.deinit();
//try stmt.exec(.{}, .{
// .artist = "Wilco",
// .plays = 2500,
//});
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok); defer std.debug.assert(gpa.deinit() == .ok);
const allocator = gpa.allocator(); const allocator = gpa.allocator();

View file

@ -10,34 +10,29 @@ pub const LastFMScrobble = struct {
// From lastfmstats.com // From lastfmstats.com
pub const LastFM = struct { username: []const u8, scrobbles: []LastFMScrobble }; pub const LastFM = struct { username: []const u8, scrobbles: []LastFMScrobble };
// I derived whether or not these values were optional from searching
// the respective fields for null in Vim, so there may be some fields
// that can be optional that I haven't run into yet
pub const SpotifyScrobble = struct { pub const SpotifyScrobble = struct {
ts: []u8, ts: []const u8,
username: []u8, username: []const u8,
platform: []u8, platform: []const u8,
ms_played: u64, ms_played: u64,
conn_country: []u8, conn_country: []const u8,
ip_addr_decrypted: []u8, ip_addr_decrypted: ?[]const u8,
user_agent_decrypted: []u8, user_agent_decrypted: ?[]const u8,
master_metadata_track_name: []u8, master_metadata_track_name: ?[]const u8,
master_metadata_artist_name: []u8, master_metadata_album_artist_name: ?[]const u8,
master_metadata_album_name: []u8, master_metadata_album_album_name: ?[]const u8,
spotify_track_uri: []u8, spotify_track_uri: ?[]const u8,
episode_name: []u8, episode_name: ?[]const u8,
reason_start: []u8, episode_show_name: ?[]const u8,
reason_end: []u8, spotify_episode_uri: ?[]const u8,
reason_start: []const u8,
reason_end: ?[]const u8,
shuffle: bool, shuffle: bool,
skipped: bool, skipped: ?bool,
offline: bool, offline: bool,
offline_timestamp: u64, offline_timestamp: u64,
incognito_mode: bool, incognito_mode: ?bool,
pub fn scrobblize(self: *SpotifyScrobble) LastFMScrobble {
return LastFMScrobble{ .track = self.master_metadata_track_name, .artist = self.master_metadata_artist_name, .album = self.master_metadata_album_name, .date = try zeit.instant(.{ .source = .{ .iso8601 = self.ts } }).unixTimestamp() };
}
}; };
pub const Spotify = struct { scrobbles: []SpotifyScrobble };
const UploadDataTag = enum { spotify, lastfm };
pub const UploadData = union(UploadDataTag) { spotify: Spotify, lastfm: LastFM };