From 3ef17fcd468d710186d0448424d15ea42786b395 Mon Sep 17 00:00:00 2001 From: mitteneer Date: Fri, 6 Jun 2025 14:28:15 -0400 Subject: [PATCH 1/6] Split entity_items and appears query into more granular queries We can be a bit more specific about the information we get this way --- src/app/views/albums.zig | 2 +- src/app/views/artists.zig | 2 +- src/app/views/songs.zig | 6 +-- src/queries.zig | 91 +++++++++++++++++++++++++++------------ 4 files changed, 69 insertions(+), 32 deletions(-) diff --git a/src/app/views/albums.zig b/src/app/views/albums.zig index 713e6d6..03046b3 100644 --- a/src/app/views/albums.zig +++ b/src/app/views/albums.zig @@ -20,7 +20,7 @@ pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { const album = try queries.entityQueryResult(request, queries.loadQuery(.album, .entity_info), .{id}); try root.put("album", album); - const songs = try queries.entityQueryResult(request, queries.loadQuery(.album, .entity_items), .{id}); + const songs = try queries.entityQueryResult(request, queries.loadQuery(.album, .get_songs), .{id}); try root.put("songs", songs); const firstlast = try queries.entityQueryResult(request, queries.loadQuery(.album, .firstlast), .{id}); diff --git a/src/app/views/artists.zig b/src/app/views/artists.zig index 1f7ff85..3ec8eba 100644 --- a/src/app/views/artists.zig +++ b/src/app/views/artists.zig @@ -21,7 +21,7 @@ pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { const artist = try queries.entityQueryResult(request, queries.loadQuery(.artist, .entity_info), .{id}); try root.put("artist", artist); - const albums = try queries.entityQueryResult(request, queries.loadQuery(.artist, .entity_items), .{id}); + const albums = try queries.entityQueryResult(request, queries.loadQuery(.artist, .get_albums), .{id}); try root.put("albums", albums); const appears = try queries.entityQueryResult(request, queries.loadQuery(.artist, .appears), .{id}); diff --git a/src/app/views/songs.zig b/src/app/views/songs.zig index b24dbf5..164f928 100644 --- a/src/app/views/songs.zig +++ b/src/app/views/songs.zig @@ -17,11 +17,11 @@ pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { const song = try queries.entityQueryResult(request, queries.loadQuery(.song, .entity_info), .{id}); try root.put("song", song); - const scrobbles = try queries.entityQueryResult(request, queries.loadQuery(.song, .entity_items), .{id}); + const scrobbles = try queries.entityQueryResult(request, queries.loadQuery(.song, .get_scrobbles), .{id}); try root.put("scrobbles", scrobbles); - const appears = try queries.entityQueryResult(request, queries.loadQuery(.song, .appears), .{id}); - try root.put("appears", appears); + const albums = try queries.entityQueryResult(request, queries.loadQuery(.song, .get_albums), .{id}); + try root.put("albums", albums); const firstlast = try queries.entityQueryResult(request, queries.loadQuery(.song, .firstlast), .{id}); try root.put("firstlast", firstlast); diff --git a/src/queries.zig b/src/queries.zig index 8db7809..5d92869 100644 --- a/src/queries.zig +++ b/src/queries.zig @@ -55,7 +55,7 @@ pub fn entityQueryResult(request: *jetzig.Request, query: GeneratedQuery, args: } const EntityType = enum { scrobble, song, album, artist }; -const QueryTypeEnum = enum { firstlast, timescale, entities, entity_items, appears, entity_info, datestreak }; +const QueryTypeEnum = enum { firstlast, timescale, entities, get_songs, get_albums, get_scrobbles, appears, entity_info, datestreak }; const GeneratedQuery = struct { entity: EntityType, @@ -246,20 +246,9 @@ pub fn loadQuery(entity: EntityType, query_type: QueryTypeEnum) GeneratedQuery { }, .appears => - //.ResultType = AppearsResult, + // Not sure how I feel about this one switch (entity) { - .scrobble, .album => unreachable, - .song => - \\SELECT albums.name AS album_name, albums.id AS album_id, COUNT(scrobbles) AS scrobbles - \\FROM artistalbums - \\INNER JOIN albums ON albums.id = artistalbums.album_id - \\INNER JOIN albumsongs ON albumsongs.album_id = albums.id - \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id - \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id - \\WHERE albumsongs.song_id = $1 - \\GROUP BY albums.id - \\ORDER BY scrobbles DESC; - , + .scrobble, .song, .album => unreachable, .artist => \\SELECT albums.name AS album_name, albums.id AS album_id, COUNT(scrobbles) AS scrobbles \\FROM artistalbums @@ -273,19 +262,8 @@ pub fn loadQuery(entity: EntityType, query_type: QueryTypeEnum) GeneratedQuery { , }, - .entity_items => - //.ResultType = EntityItemsResult, - switch (entity) { - .scrobble => unreachable, - .song => - \\SELECT songs.name AS song_name, songs.id AS song_id, albums.name AS album_name, albums.id AS album_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date - \\FROM albumsongs - \\INNER JOIN albums ON albums.id = albumsongs.album_id - \\INNER JOIN songs ON songs.id = albumsongs.song_id - \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id - \\WHERE songs.id = $1 - \\ORDER BY date ASC - , + .get_songs => switch (entity) { + .scrobble, .song => unreachable, // Might be able to use this with SongGroups? .album => \\SELECT songs.name AS song_name, songs.id AS song_id, COUNT(scrobbles) AS scrobbles \\FROM albumsongs @@ -296,6 +274,34 @@ pub fn loadQuery(entity: EntityType, query_type: QueryTypeEnum) GeneratedQuery { \\ORDER BY scrobbles DESC , .artist => + \\SELECT songs.name AS song_name, songs.id AS song_id, albums.name AS album_name, albums.id AS album_id, COUNT(scrobbles) AS scrobbles + \\FROM albumsongs + \\INNER JOIN songs ON songs.id = albumsongs.song_id + \\INNER JOIN albums ON albums.id = albumsongs.album_id + \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id + \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id + \\WHERE albumsongsartists.artist_id = $1 + \\GROUP BY songs.id, albums.id + \\ORDER BY scrobbles DESC + , + }, + + .get_albums => + //.ResultType = EntityItemsResult, + switch (entity) { + .scrobble, .album => unreachable, // Might be able to use this with ReleaseGroups? + .song => + \\SELECT albums.name AS album_name, albums.id AS album_id, COUNT(scrobbles) AS scrobbles + \\FROM artistalbums + \\INNER JOIN albums ON albums.id = artistalbums.album_id + \\INNER JOIN albumsongs ON albumsongs.album_id = albums.id + \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id + \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id + \\WHERE albumsongs.song_id = $1 + \\GROUP BY albums.id + \\ORDER BY scrobbles DESC + , + .artist => \\SELECT albums.name AS album_name, albums.id AS album_id, COUNT(scrobbles) AS scrobbles \\FROM artistalbums \\INNER JOIN albums ON albums.id = artistalbums.album_id @@ -307,6 +313,37 @@ pub fn loadQuery(entity: EntityType, query_type: QueryTypeEnum) GeneratedQuery { , }, + .get_scrobbles => switch (entity) { + .scrobble => unreachable, + .song => + \\SELECT songs.name AS song_name, songs.id AS song_id, albums.name AS album_name, albums.id AS album_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date + \\FROM albumsongs + \\INNER JOIN albums ON albums.id = albumsongs.album_id + \\INNER JOIN songs ON songs.id = albumsongs.song_id + \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id + \\WHERE songs.id = $1 + \\ORDER BY date ASC + , + .album => + \\SELECT songs.name AS song_name, songs.id AS song_id, albums.name AS album_name, albums.id AS album_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date + \\FROM albumsongs + \\INNER JOIN albums ON albums.id = albumsongs.album_id + \\INNER JOIN songs ON songs.id = albumsongs.song_id + \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id + \\WHERE albums.id = $1 + \\ORDER BY date ASC + , + .artist => + \\SELECT songs.name AS song_name, songs.id AS song_id, albums.name AS album_name, albums.id AS album_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date + \\FROM albumsongs + \\INNER JOIN albums ON albums.id = albumsongs.album_id + \\INNER JOIN songs ON songs.id = albumsongs.song_id + \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id + \\WHERE artists.id = $1 + \\ORDER BY date ASC + , + }, + .entity_info => switch (entity) { .scrobble => unreachable, .song => From c8f2ef57c8f1f8932f5faa1612be30ed4792d592 Mon Sep 17 00:00:00 2001 From: mitteneer Date: Fri, 6 Jun 2025 15:55:20 -0400 Subject: [PATCH 2/6] Add some tyling to songs view This can (will) be easily replicated for the other views, I just first tested it on songs. I think this looks much nicer, and I'll probably roll with a layout similar to this for the other views, with some minor adjustments for each particular view. --- src/app/views/partials/_newtable.zmpl | 4 +++- src/app/views/songs/get.zmpl | 24 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/app/views/partials/_newtable.zmpl b/src/app/views/partials/_newtable.zmpl index 6746a2a..f9dc618 100644 --- a/src/app/views/partials/_newtable.zmpl +++ b/src/app/views/partials/_newtable.zmpl @@ -1,5 +1,6 @@ @args T: type, table_data: *ZmplValue, columns: T +
@@ -70,4 +71,5 @@ } } -
\ No newline at end of file + +
\ No newline at end of file diff --git a/src/app/views/songs/get.zmpl b/src/app/views/songs/get.zmpl index e912930..1ceddef 100644 --- a/src/app/views/songs/get.zmpl +++ b/src/app/views/songs/get.zmpl @@ -9,12 +9,24 @@ @partial partials/header +

{{.song.song_name}}

-
{{.song.scrobbles}} scrobbles ({{.song.rank}} place)
-@partial partials/firstlast_listens(firstlast: .firstlast) -

Yearly Performance

-@partial partials/timescale(range: .yearly) -

Scrobbles

-@partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns) +
+ +
+
+
{{.song.scrobbles}} scrobbles ({{.song.rank}} place)
+ @partial partials/firstlast_listens(firstlast: .firstlast) +

Yearly Performance

+ @partial partials/timescale(range: .yearly) +

Scrobbles

+ @partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns) +
+
+

Rating

+ + +
+
\ No newline at end of file From 162341fb5f696196375c33f5ae3c795f8058c952 Mon Sep 17 00:00:00 2001 From: mitteneer Date: Mon, 9 Jun 2025 21:41:52 -0400 Subject: [PATCH 3/6] Convert ids to i64 The birthday paradox is a real problem with the size of our datasets. i64 is the largest numerical value we can use, and there's a 0.1% chance of collision with ~2,000,000 values, so I feel pretty comfortable with this --- .../2025-04-07_14-31-14_create_songs.zig | 2 +- .../2025-04-07_14-31-45_create_albums.zig | 2 +- .../2025-04-07_14-34-39_create_albumsongs.zig | 6 +++--- .../2025-04-07_14-35-53_create_scrobbles.zig | 4 ++-- .../2025-04-07_14-38-02_create_artists.zig | 2 +- ...04-07_14-39-09_create_albumsongsartists.zig | 6 +++--- ...2025-04-07_14-40-17_create_artistalbums.zig | 6 +++--- src/queries.zig | 18 ++++++------------ src/types.zig | 2 +- 9 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig b/src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig index 9a52b6b..e8ae1d6 100644 --- a/src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig +++ b/src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig @@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void { try repo.createTable( "songs", &.{ - t.primaryKey("id", .{}), + t.primaryKey("id", .{ .type = .bigint }), t.column("name", .string, .{}), t.column("length", .float, .{ .optional = true }), t.column("hidden", .boolean, .{}), diff --git a/src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig b/src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig index d706cfe..86b6184 100644 --- a/src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig +++ b/src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig @@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void { try repo.createTable( "albums", &.{ - t.primaryKey("id", .{}), + t.primaryKey("id", .{ .type = .bigint }), t.column("name", .string, .{}), t.column("length", .float, .{ .optional = true }), t.timestamps(.{}), diff --git a/src/app/database/migrations/2025-04-07_14-34-39_create_albumsongs.zig b/src/app/database/migrations/2025-04-07_14-34-39_create_albumsongs.zig index 4381a5b..96c3063 100644 --- a/src/app/database/migrations/2025-04-07_14-34-39_create_albumsongs.zig +++ b/src/app/database/migrations/2025-04-07_14-34-39_create_albumsongs.zig @@ -6,9 +6,9 @@ pub fn up(repo: anytype) !void { try repo.createTable( "albumsongs", &.{ - t.primaryKey("id", .{}), - t.column("song_id", .integer, .{ .reference = .{ "songs", "id" } }), - t.column("album_id", .integer, .{ .reference = .{ "albums", "id" } }), + t.primaryKey("id", .{ .type = .bigint }), + t.column("song_id", .bigint, .{ .reference = .{ "songs", "id" } }), + t.column("album_id", .bigint, .{ .reference = .{ "albums", "id" } }), t.timestamps(.{}), }, .{}, diff --git a/src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig b/src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig index 088a43c..c429e19 100644 --- a/src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig +++ b/src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig @@ -6,8 +6,8 @@ pub fn up(repo: anytype) !void { try repo.createTable( "scrobbles", &.{ - t.primaryKey("id", .{}), - t.column("albumsong", .integer, .{ .reference = .{ "albumsongs", "id" } }), + t.primaryKey("id", .{ .type = .bigint }), + t.column("albumsong", .bigint, .{ .reference = .{ "albumsongs", "id" } }), t.column("datetime", .datetime, .{}), t.timestamps(.{}), }, diff --git a/src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig b/src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig index ed0d3d4..97c5bfe 100644 --- a/src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig +++ b/src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig @@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void { try repo.createTable( "artists", &.{ - t.primaryKey("id", .{}), + t.primaryKey("id", .{ .type = .bigint }), t.column("name", .string, .{}), t.column("disambiguation", .string, .{ .optional = true }), t.timestamps(.{}), diff --git a/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.zig b/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.zig index 55c7d84..3355196 100644 --- a/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.zig +++ b/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.zig @@ -6,9 +6,9 @@ pub fn up(repo: anytype) !void { try repo.createTable( "albumsongsartists", &.{ - t.primaryKey("id", .{}), - t.column("albumsong_id", .integer, .{ .reference = .{ "albumsongs", "id" } }), - t.column("artist_id", .integer, .{ .reference = .{ "artists", "id" } }), + t.primaryKey("id", .{ .type = .bigint }), + t.column("albumsong_id", .bigint, .{ .reference = .{ "albumsongs", "id" } }), + t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }), t.timestamps(.{}), }, .{}, diff --git a/src/app/database/migrations/2025-04-07_14-40-17_create_artistalbums.zig b/src/app/database/migrations/2025-04-07_14-40-17_create_artistalbums.zig index 76bac1e..3c3ea7f 100644 --- a/src/app/database/migrations/2025-04-07_14-40-17_create_artistalbums.zig +++ b/src/app/database/migrations/2025-04-07_14-40-17_create_artistalbums.zig @@ -6,9 +6,9 @@ pub fn up(repo: anytype) !void { try repo.createTable( "artistalbums", &.{ - t.primaryKey("id", .{}), - t.column("album_id", .integer, .{ .reference = .{ "albums", "id" } }), - t.column("artist_id", .integer, .{ .reference = .{ "artists", "id" } }), + t.primaryKey("id", .{ .type = .bigint }), + t.column("album_id", .bigint, .{ .reference = .{ "albums", "id" } }), + t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }), t.timestamps(.{}), }, .{}, diff --git a/src/queries.zig b/src/queries.zig index 5d92869..ba2a734 100644 --- a/src/queries.zig +++ b/src/queries.zig @@ -23,12 +23,6 @@ pub fn entityQueryResult(request: *jetzig.Request, query: GeneratedQuery, args: return out.get("entity_info").?; } - //if (query.query_type == .datestreak) { - // var out: *jetzig.Data.Value = try Data.object(); - // const entity = try (try result.next()).?.to(struct { name: []const u8, id: i32, scrobbles: i64, rank: []const u8 }, .{ .dupe = true, .allocator = request.allocator }); - // - //} - var out: *jetzig.Data.Value = try Data.array(); var mapper = result.mapper(UnifiedResult, .{ .dupe = true, .allocator = request.allocator }); @@ -65,22 +59,22 @@ const GeneratedQuery = struct { const UnifiedResult = struct { album_name: ?[]const u8 = null, - album_id: ?i32 = null, + album_id: ?i64 = null, song_name: ?[]const u8 = null, - song_id: ?i32 = null, + song_id: ?i64 = null, artist_name: ?[]const u8 = null, - artist_id: ?i32 = null, + artist_id: ?i64 = null, scrobbles: ?i64 = null, date: ?[]const u8 = null, }; const EntityInfoResult = struct { album_name: ?[]const u8 = null, - album_id: ?i32 = null, + album_id: ?i64 = null, song_name: ?[]const u8 = null, - song_id: ?i32 = null, + song_id: ?i64 = null, artist_name: ?[]const u8 = null, - artist_id: ?i32 = null, + artist_id: ?i64 = null, scrobbles: ?i64 = null, date: ?[]const u8 = null, rank: []const u8, diff --git a/src/types.zig b/src/types.zig index 3b2e4ff..5f3e0e3 100644 --- a/src/types.zig +++ b/src/types.zig @@ -133,5 +133,5 @@ pub const TableRow = struct { pub const HyperlinkData = struct { name: []const u8, - id: i32, + id: i64, }; From 85552f39c1145acf18d9b74b1252a1b32ac12168 Mon Sep 17 00:00:00 2001 From: mitteneer Date: Mon, 9 Jun 2025 21:45:41 -0400 Subject: [PATCH 4/6] Add Artistsongs table Whether or not a song is covered, there was an original artist who originally performed the song. The only issue is that an Artistsongs table will almost be the exact same as the Albumsongsartists table, since most songs aren't covered. So, it may be better not to populate that table by default, and then if two albumsongs with different artists share the same song, then fill the Artistsongs table. --- src/app/database/Schema.zig | 56 ++++++++++++------- ...2025-06-06_19-46-32_create_artistsongs.zig | 20 +++++++ 2 files changed, 56 insertions(+), 20 deletions(-) create mode 100644 src/app/database/migrations/2025-06-06_19-46-32_create_artistsongs.zig diff --git a/src/app/database/Schema.zig b/src/app/database/Schema.zig index eeec984..0a30a19 100644 --- a/src/app/database/Schema.zig +++ b/src/app/database/Schema.zig @@ -4,7 +4,7 @@ pub const Album = jetquery.Model( @This(), "albums", struct { - id: i32, + id: i64, name: []const u8, length: ?f32, created_at: jetquery.DateTime, @@ -22,9 +22,9 @@ pub const Albumsong = jetquery.Model( @This(), "albumsongs", struct { - id: i32, - song_id: i32, - album_id: i32, + id: i64, + song_id: i64, + album_id: i64, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, @@ -32,9 +32,7 @@ pub const Albumsong = jetquery.Model( .relations = .{ .song = jetquery.belongsTo(.Song, .{}), .album = jetquery.belongsTo(.Album, .{}), - .scrobbles = jetquery.hasMany(.Scrobble, .{ - .foreign_key = "albumsong", - }), + .scrobbles = jetquery.hasMany(.Scrobble, .{ .foreign_key = "albumsong" }), .albumsongsartists = jetquery.hasMany(.Albumsongsartist, .{}), }, }, @@ -44,9 +42,9 @@ pub const Albumsongsartist = jetquery.Model( @This(), "albumsongsartists", struct { - id: i32, - albumsong_id: i32, - artist_id: i32, + id: i64, + albumsong_id: i64, + artist_id: i64, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, @@ -62,9 +60,9 @@ pub const Artistalbum = jetquery.Model( @This(), "artistalbums", struct { - id: i32, - album_id: i32, - artist_id: i32, + id: i64, + album_id: i64, + artist_id: i64, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, @@ -80,7 +78,7 @@ pub const Artist = jetquery.Model( @This(), "artists", struct { - id: i32, + id: i64, name: []const u8, disambiguation: ?[]const u8, created_at: jetquery.DateTime, @@ -90,6 +88,7 @@ pub const Artist = jetquery.Model( .relations = .{ .albumsongsartists = jetquery.hasMany(.Albumsongsartist, .{}), .artistalbums = jetquery.hasMany(.Artistalbum, .{}), + .artistsongs = jetquery.hasMany(.Artistsong, .{}), }, }, ); @@ -98,17 +97,15 @@ pub const Scrobble = jetquery.Model( @This(), "scrobbles", struct { - id: i32, - albumsong: i32, + id: i64, + albumsong: i64, datetime: jetquery.DateTime, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, .{ .relations = .{ - .albumsong = jetquery.belongsTo(.Albumsong, .{ - .foreign_key = "albumsong", - }), + .albumsong = jetquery.belongsTo(.Albumsong, .{ .foreign_key = "albumsong" }), }, }, ); @@ -117,7 +114,7 @@ pub const Song = jetquery.Model( @This(), "songs", struct { - id: i32, + id: i64, name: []const u8, length: ?f32, hidden: bool, @@ -127,6 +124,25 @@ pub const Song = jetquery.Model( .{ .relations = .{ .albumsongs = jetquery.hasMany(.Albumsong, .{}), + .artistsongs = jetquery.hasMany(.Artistsong, .{}), + }, + }, +); + +pub const Artistsong = jetquery.Model( + @This(), + "artistsongs", + struct { + id: i64, + artist_id: i64, + song_id: i64, + created_at: jetquery.DateTime, + updated_at: jetquery.DateTime, + }, + .{ + .relations = .{ + .artist = jetquery.belongsTo(.Artist, .{}), + .song = jetquery.belongsTo(.Song, .{}), }, }, ); diff --git a/src/app/database/migrations/2025-06-06_19-46-32_create_artistsongs.zig b/src/app/database/migrations/2025-06-06_19-46-32_create_artistsongs.zig new file mode 100644 index 0000000..7d0a6c1 --- /dev/null +++ b/src/app/database/migrations/2025-06-06_19-46-32_create_artistsongs.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const jetquery = @import("jetquery"); +const t = jetquery.schema.table; + +pub fn up(repo: anytype) !void { + try repo.createTable( + "artistsongs", + &.{ + t.primaryKey("id", .{ .type = .bigint }), + t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }), + t.column("song_id", .bigint, .{ .reference = .{ "songs", "id" } }), + t.timestamps(.{}), + }, + .{}, + ); +} + +pub fn down(repo: anytype) !void { + try repo.dropTable("artistsongs", .{}); +} From 1e4a271b8d7e78c37140e6e129d5292dda2cd117 Mon Sep 17 00:00:00 2001 From: mitteneer Date: Wed, 11 Jun 2025 09:22:06 -0400 Subject: [PATCH 5/6] Update README --- README.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8247b21..06f45c2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ Inspired by [Last.fm](https://last.fm), [Maloja](https://github.com/krateng/maloja), and [Lastfmstats.com](https://www.lastfmstats.com). - **Z**uletzt is written with [**Z**ig](https://github.com/ziglang/zig) and [Jetzig](https://github.com/jetzig-framework/jetzig) as a means of learning the language, reintroducing myself to programming, and combining the functionality of the aforementioned inspirations. @@ -12,6 +11,35 @@ Zuletzt means "last" in German. Licensed under MIT. +## Usage +Zuletzt allows uploads of Scrobbles at the `/upload` page, where you +can import Scrobbles from a Spotify data export, Last.FM data export (a `.json` +file from lastfmstats.com), or by providing a Last.FM username and connecting +to Last.FM directly. + +Zuletzt will not make any assumptions about the data, and only change metadata when asked to by a rule. Two albums will be considered the same if: +- They share the same title (case/diacritic sensitive) +- The album artist(s) are the same + +Zuletzt allows you to list multiple artists under an album using rules, but +does not try to automatically split artists along common delimiters. For +example, there's no way to know that "Mermaid Avenue" by "Billy Bragg, Wilco" +is performed by two artists, while "Ants From Up There" by "Black Country, New +Road" is performed by one artist. Thus, a rule needs to be made to tell Zuletzt +"Mermaid Avenue" is performed by "Billy Bragg" and "Wilco". + +Two songs will only be considered the same if: +- They share the same title (case/diacritic sensitive) +- They appear on the same album + +If two or more songs with the same spelling appear on an album, they are +necessarily grouped under the same name, as there is no way to differentiate +them (see "Once In Royal David's City" on Sufjan Stevens's "Songs For +Christmas", for example). Every artist that performs on those songs with +receive attribution for the combined song. + +If two artists have the same name, they are necessarily listed as the same artist, but can be separated with a rule, or after the fact, with a disambiguation string. + ## To-Do List: - [ ] Entity statistics - [x] See all artists under "/artists" @@ -60,6 +88,7 @@ Licensed under MIT. - [ ] Rank songs - [ ] Custom statistics[^7] - [ ] "Playlists"[^8] +- [ ] First launch setup [^1]: I do not intend to exactly replicate all the statistics Lastfmstats.com provides, but I would at least like to give the user the option to see those kinds of statistics, or generate them themselves (see 7). From a8a4ed27c46baeb213b687f89fbb8fb71cdc04f6 Mon Sep 17 00:00:00 2001 From: mitteneer Date: Wed, 11 Jun 2025 09:22:38 -0400 Subject: [PATCH 6/6] Make process_scrobbles vars more readable, and change hashing --- src/app/jobs/process_scrobbles.zig | 107 +++++++++++++++-------------- 1 file changed, 56 insertions(+), 51 deletions(-) diff --git a/src/app/jobs/process_scrobbles.zig b/src/app/jobs/process_scrobbles.zig index dcf7f1f..ea743b9 100644 --- a/src/app/jobs/process_scrobbles.zig +++ b/src/app/jobs/process_scrobbles.zig @@ -20,12 +20,13 @@ pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig // Probably want to include artist name here, but not sure how to yet - const track_artist_count = item.getT(.array, "artists_track").?.count(); - const album_artist_count = item.getT(.array, "artists_album").?.count(); - var track_artist_name_buffer = try allocator.alloc([]const u8, track_artist_count); - var album_artist_name_buffer = try allocator.alloc([]const u8, album_artist_count); - var track_artist_id_buffer = try allocator.alloc(i32, track_artist_count); - var album_artist_id_buffer = try allocator.alloc(i32, album_artist_count); + const track_artists = item.getT(.array, "artists_track").?.items(); + const album_artists = item.getT(.array, "artists_album").?.items(); + + var track_artist_name_buffer = try allocator.alloc([]const u8, track_artists.len); + var album_artist_name_buffer = try allocator.alloc([]const u8, album_artists.len); + var track_artist_id_buffer = try allocator.alloc(i64, track_artists.len); + var album_artist_id_buffer = try allocator.alloc(i64, album_artists.len); const scrobble: Data.Scrobble = .{ .track = item.getT(.string, "track").?, @@ -35,95 +36,99 @@ pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig .date = @as(i64, @truncate(item.getT(.integer, "date").?)), }; - var id_prehash = std.ArrayList(u8).init(allocator); + var album_hash_string = std.ArrayList(u8).init(allocator); + var track_hash_string = std.ArrayList(u8).init(allocator); - for (item.getT(.array, "artists_track").?.items(), 0..track_artist_count) |artist, i| { + // I theoretically don't need this for loop + for (track_artists, 0..track_artists.len) |artist, i| { const artist_name = try artist.coerce([]const u8); track_artist_name_buffer[i] = artist_name; - track_artist_id_buffer[i] = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(artist_name))); + track_artist_id_buffer[i] = @as(i64, @bitCast(std.hash.Fnv1a_64.hash(artist_name))); } - for (item.getT(.array, "artists_album").?.items(), 0..album_artist_count) |artist, i| { + for (album_artists, 0..album_artists.len) |artist, i| { const artist_name = try artist.coerce([]const u8); album_artist_name_buffer[i] = artist_name; - album_artist_id_buffer[i] = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(artist_name))); - try id_prehash.appendSlice(artist_name); + album_artist_id_buffer[i] = @as(i64, @bitCast(std.hash.Fnv1a_32.hash(artist_name))); + try album_hash_string.appendSlice(artist_name); } - try id_prehash.appendSlice(scrobble.album); - const album_id = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(id_prehash.items))); - try id_prehash.appendSlice(scrobble.track); - const song_id = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(id_prehash.items))); + try album_hash_string.appendSlice(scrobble.album); + try track_hash_string.appendSlice(scrobble.album); + const album_hash = @as(i64, @bitCast(std.hash.Fnv1a_32.hash(album_hash_string.items))); + try track_hash_string.appendSlice(scrobble.track); + const track_hash = @as(i64, @bitCast(std.hash.Fnv1a_32.hash(track_hash_string.items))); - var albumsong = try jetzig.database.Query(.Albumsong) - .findBy(.{ .album_id = album_id, .song_id = song_id }) + var albumsong_id = try jetzig.database.Query(.Albumsong) + .find(album_hash ^ track_hash) .select(.{.id}).execute(env.repo); - var ins_album = try jetzig.database.Query(.Album) - .find(album_id) + var album_id = try jetzig.database.Query(.Album) + .find(album_hash) .select(.{.id}).execute(env.repo); - for (track_artist_name_buffer, track_artist_id_buffer) |artist_name, artist_id| { - var ins_artist = try jetzig.database.Query(.Artist) - .find(artist_id) + for (track_artist_name_buffer, track_artist_id_buffer) |scrobble_track_artist, track_artist_hash| { + var artist_id = try jetzig.database.Query(.Artist) + .find(track_artist_hash) .select(.{.id}).execute(env.repo); - if (ins_artist == null) - ins_artist = try jetzig.database.Query(.Artist) - .insert(.{ .id = artist_id, .name = artist_name, .disambiguation = null }) + if (artist_id == null) + artist_id = try jetzig.database.Query(.Artist) + .insert(.{ .id = track_artist_hash, .name = scrobble_track_artist, .disambiguation = null }) .returning(.{.id}).execute(env.repo); - if (albumsong == null) { - var ins_song = try jetzig.database.Query(.Song) - .find(song_id) + if (albumsong_id == null) { + var track_id = try jetzig.database.Query(.Song) + .find(track_hash) .select(.{.id}).execute(env.repo); - if (ins_song == null) - ins_song = try jetzig.database.Query(.Song) - .insert(.{ .id = song_id, .name = scrobble.track, .length = null, .hidden = false }) + if (track_id == null) + track_id = try jetzig.database.Query(.Song) + .insert(.{ .id = track_hash, .name = scrobble.track, .length = null, .hidden = false }) .returning(.{.id}).execute(env.repo); - if (ins_album == null) - ins_album = try jetzig.database.Query(.Album) - .insert(.{ .id = album_id, .name = scrobble.album, .length = null }) + if (album_id == null) + album_id = try jetzig.database.Query(.Album) + .insert(.{ .id = album_hash, .name = scrobble.album, .length = null }) .returning(.{.id}).execute(env.repo); - albumsong = try jetzig.database.Query(.Albumsong) - .insert(.{ .song_id = ins_song.?.id, .album_id = ins_album.?.id }) + albumsong_id = try jetzig.database.Query(.Albumsong) + .insert(.{ .song_id = track_id.?.id, .album_id = album_id.?.id }) .returning(.{.id}).execute(env.repo); try jetzig.database.Query(.Albumsongsartist) - .insert(.{ .albumsong_id = albumsong.?.id, .artist_id = ins_artist.?.id }).execute(env.repo); + .insert(.{ .albumsong_id = albumsong_id.?.id, .artist_id = artist_id.?.id }).execute(env.repo); } else { const ins_albumsongartist = try jetzig.database.Query(.Albumsongsartist) - .findBy(.{ .albumsong_id = albumsong.?.id, .artist_id = ins_artist.?.id }) + .findBy(.{ .albumsong_id = albumsong_id.?.id, .artist_id = artist_id.?.id }) .select(.{.id}).execute(env.repo); if (ins_albumsongartist == null) try jetzig.database.Query(.Albumsongsartist) - .insert(.{ .albumsong_id = albumsong.?.id, .artist_id = ins_artist.?.id }).execute(env.repo); + .insert(.{ .albumsong_id = albumsong_id.?.id, .artist_id = artist_id.?.id }).execute(env.repo); } } - for (album_artist_name_buffer, album_artist_id_buffer) |artist_name, artist_id| { - const ins_artistalbum = try jetzig.database.Query(.Artistalbum) - .findBy(.{ .album_id = ins_album.?.id, .artist_id = artist_id }) + + for (album_artist_name_buffer, album_artist_id_buffer) |scrobble_album_artist, album_artist_hash| { + const artistalbum_id = try jetzig.database.Query(.Artistalbum) + .findBy(.{ .album_id = album_id.?.id, .artist_id = album_artist_hash }) .select(.{.id}).execute(env.repo); - if (ins_artistalbum == null) { - var ins_artist = try jetzig.database.Query(.Artist) - .find(artist_id) + if (artistalbum_id == null) { + var artist_id = try jetzig.database.Query(.Artist) + .find(album_artist_hash) .select(.{.id}).execute(env.repo); - if (ins_artist == null) - ins_artist = try jetzig.database.Query(.Artist) - .insert(.{ .id = artist_id, .name = artist_name, .disambiguation = null }) + if (artist_id == null) + artist_id = try jetzig.database.Query(.Artist) + .insert(.{ .id = artist_id, .name = scrobble_album_artist, .disambiguation = null }) .returning(.{.id}).execute(env.repo); try jetzig.database.Query(.Artistalbum) - .insert(.{ .album_id = ins_album.?.id, .artist_id = ins_artist.?.id }).execute(env.repo); + .insert(.{ .album_id = album_id.?.id, .artist_id = artist_id.?.id }).execute(env.repo); } } try jetzig.database.Query(.Scrobble) - .insert(.{ .albumsong = albumsong.?.id, .datetime = scrobble.date }).execute(env.repo); + .insert(.{ .albumsong = albumsong_id.?.id, .datetime = scrobble.date }).execute(env.repo); } } }