Compare commits

..

No commits in common. "a8a4ed27c46baeb213b687f89fbb8fb71cdc04f6" and "3ef17fcd468d710186d0448424d15ea42786b395" have entirely different histories.

15 changed files with 106 additions and 184 deletions

View file

@ -3,6 +3,7 @@
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.
@ -11,35 +12,6 @@ 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"
@ -88,7 +60,6 @@ If two artists have the same name, they are necessarily listed as the same artis
- [ ] 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).

View file

@ -4,7 +4,7 @@ pub const Album = jetquery.Model(
@This(),
"albums",
struct {
id: i64,
id: i32,
name: []const u8,
length: ?f32,
created_at: jetquery.DateTime,
@ -22,9 +22,9 @@ pub const Albumsong = jetquery.Model(
@This(),
"albumsongs",
struct {
id: i64,
song_id: i64,
album_id: i64,
id: i32,
song_id: i32,
album_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
@ -32,7 +32,9 @@ 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, .{}),
},
},
@ -42,9 +44,9 @@ pub const Albumsongsartist = jetquery.Model(
@This(),
"albumsongsartists",
struct {
id: i64,
albumsong_id: i64,
artist_id: i64,
id: i32,
albumsong_id: i32,
artist_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
@ -60,9 +62,9 @@ pub const Artistalbum = jetquery.Model(
@This(),
"artistalbums",
struct {
id: i64,
album_id: i64,
artist_id: i64,
id: i32,
album_id: i32,
artist_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
@ -78,7 +80,7 @@ pub const Artist = jetquery.Model(
@This(),
"artists",
struct {
id: i64,
id: i32,
name: []const u8,
disambiguation: ?[]const u8,
created_at: jetquery.DateTime,
@ -88,7 +90,6 @@ pub const Artist = jetquery.Model(
.relations = .{
.albumsongsartists = jetquery.hasMany(.Albumsongsartist, .{}),
.artistalbums = jetquery.hasMany(.Artistalbum, .{}),
.artistsongs = jetquery.hasMany(.Artistsong, .{}),
},
},
);
@ -97,15 +98,17 @@ pub const Scrobble = jetquery.Model(
@This(),
"scrobbles",
struct {
id: i64,
albumsong: i64,
id: i32,
albumsong: i32,
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",
}),
},
},
);
@ -114,7 +117,7 @@ pub const Song = jetquery.Model(
@This(),
"songs",
struct {
id: i64,
id: i32,
name: []const u8,
length: ?f32,
hidden: bool,
@ -124,25 +127,6 @@ 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, .{}),
},
},
);

View file

@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void {
try repo.createTable(
"songs",
&.{
t.primaryKey("id", .{ .type = .bigint }),
t.primaryKey("id", .{}),
t.column("name", .string, .{}),
t.column("length", .float, .{ .optional = true }),
t.column("hidden", .boolean, .{}),

View file

@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void {
try repo.createTable(
"albums",
&.{
t.primaryKey("id", .{ .type = .bigint }),
t.primaryKey("id", .{}),
t.column("name", .string, .{}),
t.column("length", .float, .{ .optional = true }),
t.timestamps(.{}),

View file

@ -6,9 +6,9 @@ pub fn up(repo: anytype) !void {
try repo.createTable(
"albumsongs",
&.{
t.primaryKey("id", .{ .type = .bigint }),
t.column("song_id", .bigint, .{ .reference = .{ "songs", "id" } }),
t.column("album_id", .bigint, .{ .reference = .{ "albums", "id" } }),
t.primaryKey("id", .{}),
t.column("song_id", .integer, .{ .reference = .{ "songs", "id" } }),
t.column("album_id", .integer, .{ .reference = .{ "albums", "id" } }),
t.timestamps(.{}),
},
.{},

View file

@ -6,8 +6,8 @@ pub fn up(repo: anytype) !void {
try repo.createTable(
"scrobbles",
&.{
t.primaryKey("id", .{ .type = .bigint }),
t.column("albumsong", .bigint, .{ .reference = .{ "albumsongs", "id" } }),
t.primaryKey("id", .{}),
t.column("albumsong", .integer, .{ .reference = .{ "albumsongs", "id" } }),
t.column("datetime", .datetime, .{}),
t.timestamps(.{}),
},

View file

@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void {
try repo.createTable(
"artists",
&.{
t.primaryKey("id", .{ .type = .bigint }),
t.primaryKey("id", .{}),
t.column("name", .string, .{}),
t.column("disambiguation", .string, .{ .optional = true }),
t.timestamps(.{}),

View file

@ -6,9 +6,9 @@ pub fn up(repo: anytype) !void {
try repo.createTable(
"albumsongsartists",
&.{
t.primaryKey("id", .{ .type = .bigint }),
t.column("albumsong_id", .bigint, .{ .reference = .{ "albumsongs", "id" } }),
t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }),
t.primaryKey("id", .{}),
t.column("albumsong_id", .integer, .{ .reference = .{ "albumsongs", "id" } }),
t.column("artist_id", .integer, .{ .reference = .{ "artists", "id" } }),
t.timestamps(.{}),
},
.{},

View file

@ -6,9 +6,9 @@ pub fn up(repo: anytype) !void {
try repo.createTable(
"artistalbums",
&.{
t.primaryKey("id", .{ .type = .bigint }),
t.column("album_id", .bigint, .{ .reference = .{ "albums", "id" } }),
t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }),
t.primaryKey("id", .{}),
t.column("album_id", .integer, .{ .reference = .{ "albums", "id" } }),
t.column("artist_id", .integer, .{ .reference = .{ "artists", "id" } }),
t.timestamps(.{}),
},
.{},

View file

@ -1,20 +0,0 @@
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", .{});
}

View file

@ -20,13 +20,12 @@ 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_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 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 scrobble: Data.Scrobble = .{
.track = item.getT(.string, "track").?,
@ -36,99 +35,95 @@ pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig
.date = @as(i64, @truncate(item.getT(.integer, "date").?)),
};
var album_hash_string = std.ArrayList(u8).init(allocator);
var track_hash_string = std.ArrayList(u8).init(allocator);
var id_prehash = std.ArrayList(u8).init(allocator);
// I theoretically don't need this for loop
for (track_artists, 0..track_artists.len) |artist, i| {
for (item.getT(.array, "artists_track").?.items(), 0..track_artist_count) |artist, i| {
const artist_name = try artist.coerce([]const u8);
track_artist_name_buffer[i] = artist_name;
track_artist_id_buffer[i] = @as(i64, @bitCast(std.hash.Fnv1a_64.hash(artist_name)));
track_artist_id_buffer[i] = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(artist_name)));
}
for (album_artists, 0..album_artists.len) |artist, i| {
for (item.getT(.array, "artists_album").?.items(), 0..album_artist_count) |artist, i| {
const artist_name = try artist.coerce([]const u8);
album_artist_name_buffer[i] = artist_name;
album_artist_id_buffer[i] = @as(i64, @bitCast(std.hash.Fnv1a_32.hash(artist_name)));
try album_hash_string.appendSlice(artist_name);
album_artist_id_buffer[i] = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(artist_name)));
try id_prehash.appendSlice(artist_name);
}
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)));
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)));
var albumsong_id = try jetzig.database.Query(.Albumsong)
.find(album_hash ^ track_hash)
var albumsong = try jetzig.database.Query(.Albumsong)
.findBy(.{ .album_id = album_id, .song_id = song_id })
.select(.{.id}).execute(env.repo);
var album_id = try jetzig.database.Query(.Album)
.find(album_hash)
var ins_album = try jetzig.database.Query(.Album)
.find(album_id)
.select(.{.id}).execute(env.repo);
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)
for (track_artist_name_buffer, track_artist_id_buffer) |artist_name, artist_id| {
var ins_artist = try jetzig.database.Query(.Artist)
.find(artist_id)
.select(.{.id}).execute(env.repo);
if (artist_id == null)
artist_id = try jetzig.database.Query(.Artist)
.insert(.{ .id = track_artist_hash, .name = scrobble_track_artist, .disambiguation = null })
if (ins_artist == null)
ins_artist = try jetzig.database.Query(.Artist)
.insert(.{ .id = artist_id, .name = artist_name, .disambiguation = null })
.returning(.{.id}).execute(env.repo);
if (albumsong_id == null) {
var track_id = try jetzig.database.Query(.Song)
.find(track_hash)
if (albumsong == null) {
var ins_song = try jetzig.database.Query(.Song)
.find(song_id)
.select(.{.id}).execute(env.repo);
if (track_id == null)
track_id = try jetzig.database.Query(.Song)
.insert(.{ .id = track_hash, .name = scrobble.track, .length = null, .hidden = false })
if (ins_song == null)
ins_song = try jetzig.database.Query(.Song)
.insert(.{ .id = song_id, .name = scrobble.track, .length = null, .hidden = false })
.returning(.{.id}).execute(env.repo);
if (album_id == null)
album_id = try jetzig.database.Query(.Album)
.insert(.{ .id = album_hash, .name = scrobble.album, .length = null })
if (ins_album == null)
ins_album = try jetzig.database.Query(.Album)
.insert(.{ .id = album_id, .name = scrobble.album, .length = null })
.returning(.{.id}).execute(env.repo);
albumsong_id = try jetzig.database.Query(.Albumsong)
.insert(.{ .song_id = track_id.?.id, .album_id = album_id.?.id })
albumsong = try jetzig.database.Query(.Albumsong)
.insert(.{ .song_id = ins_song.?.id, .album_id = ins_album.?.id })
.returning(.{.id}).execute(env.repo);
try jetzig.database.Query(.Albumsongsartist)
.insert(.{ .albumsong_id = albumsong_id.?.id, .artist_id = artist_id.?.id }).execute(env.repo);
.insert(.{ .albumsong_id = albumsong.?.id, .artist_id = ins_artist.?.id }).execute(env.repo);
} else {
const ins_albumsongartist = try jetzig.database.Query(.Albumsongsartist)
.findBy(.{ .albumsong_id = albumsong_id.?.id, .artist_id = artist_id.?.id })
.findBy(.{ .albumsong_id = albumsong.?.id, .artist_id = ins_artist.?.id })
.select(.{.id}).execute(env.repo);
if (ins_albumsongartist == null)
try jetzig.database.Query(.Albumsongsartist)
.insert(.{ .albumsong_id = albumsong_id.?.id, .artist_id = artist_id.?.id }).execute(env.repo);
.insert(.{ .albumsong_id = albumsong.?.id, .artist_id = ins_artist.?.id }).execute(env.repo);
}
}
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 })
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 })
.select(.{.id}).execute(env.repo);
if (artistalbum_id == null) {
var artist_id = try jetzig.database.Query(.Artist)
.find(album_artist_hash)
if (ins_artistalbum == null) {
var ins_artist = try jetzig.database.Query(.Artist)
.find(artist_id)
.select(.{.id}).execute(env.repo);
if (artist_id == null)
artist_id = try jetzig.database.Query(.Artist)
.insert(.{ .id = artist_id, .name = scrobble_album_artist, .disambiguation = null })
if (ins_artist == null)
ins_artist = try jetzig.database.Query(.Artist)
.insert(.{ .id = artist_id, .name = artist_name, .disambiguation = null })
.returning(.{.id}).execute(env.repo);
try jetzig.database.Query(.Artistalbum)
.insert(.{ .album_id = album_id.?.id, .artist_id = artist_id.?.id }).execute(env.repo);
.insert(.{ .album_id = ins_album.?.id, .artist_id = ins_artist.?.id }).execute(env.repo);
}
}
try jetzig.database.Query(.Scrobble)
.insert(.{ .albumsong = albumsong_id.?.id, .datetime = scrobble.date }).execute(env.repo);
.insert(.{ .albumsong = albumsong.?.id, .datetime = scrobble.date }).execute(env.repo);
}
}
}

View file

@ -1,6 +1,5 @@
@args T: type, table_data: *ZmplValue, columns: T
<div>
<table>
<thead>
<tr>
@ -71,5 +70,4 @@
}
}
</tbody>
</table>
</div>
</table>

View file

@ -9,24 +9,12 @@
</head>
<body>
@partial partials/header
<div style="text-align:center">
<h1>{{.song.song_name}}</h1>
</div>
<div style="display:flex;flex-direction:row;justify-content:space-evenly">
<div style="display:flex;flex-direction:column;align-self:left">
<div>{{.song.scrobbles}} scrobbles ({{.song.rank}} place)</div>
@partial partials/firstlast_listens(firstlast: .firstlast)
<h3>Yearly Performance</h3>
@partial partials/timescale(range: .yearly)
<h2>Scrobbles</h2>
@partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns)
</div>
<div style="display:flex;flex-direction:column;align-self:right">
<h2>Rating</h2>
<input type="text">
<input type="button">
</div>
</div>
<div>{{.song.scrobbles}} scrobbles ({{.song.rank}} place)</div>
@partial partials/firstlast_listens(firstlast: .firstlast)
<h3>Yearly Performance</h3>
@partial partials/timescale(range: .yearly)
<h2>Scrobbles</h2>
@partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns)
</body>
</html>

View file

@ -23,6 +23,12 @@ 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 });
@ -59,22 +65,22 @@ const GeneratedQuery = struct {
const UnifiedResult = struct {
album_name: ?[]const u8 = null,
album_id: ?i64 = null,
album_id: ?i32 = null,
song_name: ?[]const u8 = null,
song_id: ?i64 = null,
song_id: ?i32 = null,
artist_name: ?[]const u8 = null,
artist_id: ?i64 = null,
artist_id: ?i32 = null,
scrobbles: ?i64 = null,
date: ?[]const u8 = null,
};
const EntityInfoResult = struct {
album_name: ?[]const u8 = null,
album_id: ?i64 = null,
album_id: ?i32 = null,
song_name: ?[]const u8 = null,
song_id: ?i64 = null,
song_id: ?i32 = null,
artist_name: ?[]const u8 = null,
artist_id: ?i64 = null,
artist_id: ?i32 = null,
scrobbles: ?i64 = null,
date: ?[]const u8 = null,
rank: []const u8,

View file

@ -133,5 +133,5 @@ pub const TableRow = struct {
pub const HyperlinkData = struct {
name: []const u8,
id: i64,
id: i32,
};