diff --git a/.gitignore b/.gitignore
index 70b0239..76f5b26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@ static/
src/app/database/data.db-journal
src/app/database/old_migrations/
src/lib
-src/app/scripts/
\ No newline at end of file
+src/app/scripts/
+rules.json
\ No newline at end of file
diff --git a/README.md b/README.md
index b62b576..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,17 +11,49 @@ 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"
- [ ] List all songs on artist page, with respective album
- [x] List all albums on artist page
- [x] Include number of plays for each
+ - [x] List albums features on
- [x] See all albums under "/albums"
- [x] See all songs from album
- [x] Include number of plays
+ - [x] Include name of artist(s)
+ - [ ] Include artists features on each song
- [x] See all songs under "/songs"
- - [ ] Include respective artist(s)
+ - [x] Include respective artist(s)
- [ ] Include respective album[^10]
- [x] Include number of plays
- [ ] Create disambiguation pages
@@ -38,7 +69,7 @@ Licensed under MIT.
- [ ] Import from Discogs[^2]
- [ ] Import listening history
- [x] From Lastfmstats.com (.json file)[^3]
- - [ ] From Last.fm (authentication)
+ - [x] From Last.fm (authentication)
- [x] From Spotify (.json file)
- [ ] From other streaming services[^4]
- [ ] "Unofficial scrobbles"[^9]
@@ -57,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).
diff --git a/build.zig.zon b/build.zig.zon
index 823b42c..5db05e3 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -17,12 +17,12 @@
// internet connectivity.
.dependencies = .{
.jetzig = .{
- .url = "https://github.com/jetzig-framework/jetzig/archive/2c52792217b9441ed5e91d67e7ec5a8959285307.tar.gz",
- .hash = "jetzig-0.0.0-IpAgLbMeDwDYRqWu0OJixGbcDUoUbfAN0xGe1xsYRHTj",
+ .url = "https://github.com/jetzig-framework/jetzig/archive/695664bc10e6e73389ee2fe7fbcaedc27fa8adc9.tar.gz",
+ .hash = "jetzig-0.0.0-IpAgLSFeDwBEhfEaDBo0JqhRbvQA3ScbWjssBqhJHFmb",
},
.zeit = .{
- .url = "https://github.com/rockorager/zeit/archive/refs/heads/main.tar.gz",
- .hash = "zeit-0.6.0-5I6bk5daAgC-P60TjxRqW0bYknfCGxJp-03eS9UjGrO7",
+ .url = "https://github.com/rockorager/zeit/archive/refs/tags/v0.6.0.tar.gz",
+ .hash = "zeit-0.0.0-5I6bk_pZAgB03N1p1GmVOZ--gOFwwQSRKj1UXb5tnaKS",
},
},
.paths = .{
diff --git a/common_queries.md b/common_queries.md
deleted file mode 100644
index 833d2c0..0000000
--- a/common_queries.md
+++ /dev/null
@@ -1,164 +0,0 @@
-Get all albums from specified artist:
-```sql
-SELECT artists.name, albums.name
-FROM "Albumartists"
-INNER JOIN artists
-ON "Albumartists".artist_id = artists.id
-INNER JOIN albums
-ON "Albumartists".album_id = albums.id
-WHERE artists.name = {ARTIST};
-```
-
-Get all songs from specified artist:
-```sql
-SELECT artists.name, songs.name
-FROM "Songartists"
-INNER JOIN artists
-ON "Songartists".artist_id = artists.id
-INNER JOIN songs
-ON "Songartists".song_id = songs.id
-WHERE artists.name = {ARTIST};
-```
-
-Get all songs from any album of the specified name:
-```sql
-SELECT songs.name
-FROM "Albumsongs"
-INNER JOIN albums
-ON "Albumsongs".album_id = albums.id
-INNER JOIN songs
-ON "Albumsongs".song_id = songs.id
-WHERE albums.name = {ALBUM};
-```
-
-Sort all songs by plays (does not list artist or album):
-```sql
-SELECT songs.name, COUNT(scrobbles.song_id) AS scount
-FROM songs, scrobbles
-WHERE songs.id = scrobbles.song_id
-GROUP BY songs.id
-ORDER BY scount DESC;
-```
-
-Sort all songs by plays, and include artist:
-```sql
-SELECT songs.name, artists.name, COUNT(scrobbles.song_id) AS scount
-FROM scrobbles, "Songartists"
-INNER JOIN artists
-ON "Songartists".artist_id = artists.id
-INNER JOIN songs
-ON "Songartists".song_id = songs.id
-WHERE songs.id = scrobbles.song_id
-GROUP BY songs.id, artists.id
-ORDER BY scount DESC;
-```
-
-Sort all songs by plays, and include artist and album:
-```sql
-SELECT songs.name, artists.name, albums.name, COUNT(scrobbles.song_id) AS scount
-FROM scrobbles CROSS JOIN "Songartists" CROSS JOIN "Albumsongs"
-INNER JOIN artists
-ON "Songartists".artist_id = artists.id
-INNER JOIN songs
-ON "Songartists".song_id = songs.id AND "Albumsongs".song_id = songs.id
-INNER JOIN albums
-ON "Albumsongs".album_id = albums.id
-WHERE songs.id = scrobbles.song_id
-GROUP BY songs.id, artists.id, albums.id
-ORDER BY scount DESC;
-```
-
-Sort all albums by plays, and include artist:
-```sql
-SELECT albums.name, artists.name, COUNT(scrobbles.album_id) AS scount
-FROM scrobbles, "Albumartists"
-INNER JOIN albums
-ON "Albumartists".album_id = albums.id
-INNER JOIN artists
-ON "Albumartists".artist_id = artists.id
-WHERE albums.id = scrobbles.album_id
-GROUP BY artists.id, albums.id
-ORDER BY scount DESC;
-```
-
-Sort all artists by plays:
-```sql
-SELECT artists.name, COUNT(scrobbles.id) AS scount
-FROM artists, "Scrobbleartists"
-INNER JOIN scrobbles
-ON scrobbles.id = "Scrobbleartists".scrobble_id
-WHERE "Scrobbleartists".artist_id = artists.id
-GROUP BY artists.id
-ORDER BY scount DESC;
-```
-
-Sort all artists by alphabetical order, and include the first time you listened to that artist:
-```sql
-SELECT artists.name, MIN(scrobbles.date)
-FROM "Scrobbleartists"
-INNER JOIN artists
-ON "Scrobbleartists".artist_id = artists.id
-INNER JOIN scrobbles
-ON "Scrobbleartists".scrobble_id = scrobbles.id
-GROUP BY artists.id
-ORDER BY artists.name ASC;
-```
-
-Sort all songs by alphabetical order, and include the first time you listened to that song:
-```sql
-SELECT songs.name, MIN(scrobbles.date)
-FROM scrobbles
-INNER JOIN songs
-ON scrobbles.song_id = songs.id
-GROUP BY songs.id
-ORDER BY songs.name ASC;
-```
-
-Sort all albums by alphabetical order, and include the first time you listened to that album:
-```sql
-SELECT albums.name, MIN(scrobbles.date)
-FROM scrobbles
-INNER JOIN albums
-ON scrobbles.album_id = albums.id
-GROUP BY albums.id
-ORDER BY albums.name ASC;
-```
-
-Select all songs by specified artists, include the number of plays of each song, and sort by plays:
-```sql
-SELECT songs.name, COUNT(scrobbles.song_id) as count
-FROM songs, "Scrobbleartists"
-INNER JOIN artists
-ON "Scrobbleartists".artist_id = artists.id
-INNER JOIN scrobbles
-ON "Scrobbleartists".scrobble_id = scrobbles.id
-WHERE songs.id = scrobbles.song_id AND artists.name = {ARTIST}
-GROUP BY songs.id
-ORDER BY count DESC;
-```
-
-Select all albums by specified artist, include the number of plays of each album, and sort by plays:
-```sql
-SELECT albums.name, COUNT(scrobbles.song_id) as count
-FROM albums, "Scrobbleartists"
-INNER JOIN artists
-ON "Scrobbleartists".artist_id = artists.id
-INNER JOIN scrobbles
-ON "Scrobbleartists".scrobble_id = scrobbles.id
-WHERE albums.id = scrobbles.album_id AND artists.name = {ARTIST}
-GROUP BY albums.id
-ORDER BY count DESC;
-```
-
-Select all songs from an album specified by an ID, and sort by plays
-```sql
-SELECT songs.name, COUNT(scrobbles.song_id) AS count
-FROM "Albumsongs"
-INNER JOIN songs
-ON songs.id = "Albumsongs".song_id
-INNER JOIN scrobbles
-ON scrobbles.song_id = "Albumsongs".song_id
-WHERE "Albumsongs".album_id = {ALBUM_ID}
-GROUP BY songs.id
-ORDER BY count DESC;
-```
\ No newline at end of file
diff --git a/config/database.zig b/config/database.zig
index 361129d..4042ff0 100644
--- a/config/database.zig
+++ b/config/database.zig
@@ -15,7 +15,7 @@ pub const database = .{
.port = 5432,
.username = "postgres",
.password = "postgres",
- .database = "zuletzt_dev",
+ .database = "zuletzt_rsql",
.pool_size = 16,
},
diff --git a/src/app/database/Schema.zig b/src/app/database/Schema.zig
index 231c283..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,
@@ -12,91 +12,19 @@ pub const Album = jetquery.Model(
},
.{
.relations = .{
- .masteralbum = jetquery.belongsTo(.Masteralbum, .{}),
- .scrobbles = jetquery.hasMany(.Scrobble, .{}),
- .ratings = jetquery.hasMany(.Rating, .{}),
- .aliases = jetquery.hasMany(.Alias, .{}),
.albumsongs = jetquery.hasMany(.Albumsong, .{}),
- .albumartists = jetquery.hasMany(.Albumartist, .{}),
+ .artistalbums = jetquery.hasMany(.Artistalbum, .{}),
},
},
);
-pub const Alias = jetquery.Model(
+pub const Albumsong = jetquery.Model(
@This(),
- "aliases",
+ "albumsongs",
struct {
- id: i32,
- reference_id: i32,
- alias: []const u8,
- created_at: jetquery.DateTime,
- updated_at: jetquery.DateTime,
- },
- .{},
-);
-
-pub const Artist = jetquery.Model(
- @This(),
- "artists",
- struct {
- id: i32,
- name: []const u8,
- descriptive_string: []const u8,
- created_at: jetquery.DateTime,
- updated_at: jetquery.DateTime,
- },
- .{
- .relations = .{
- .scrobbleartists = jetquery.hasMany(.Scrobbleartist, .{}),
- .aliases = jetquery.hasMany(.Alias, .{}),
- .artistsongs = jetquery.hasMany(.Songartist, .{}),
- .mastersongs = jetquery.hasMany(.Mastersong, .{}),
- .artistalbums = jetquery.hasMany(.Albumartist, .{}),
- .masteralbums = jetquery.hasMany(.Masteralbum, .{}),
- },
- },
-);
-
-pub const Masteralbum = jetquery.Model(
- @This(),
- "masteralbums",
- struct {
- id: i32,
- name: []const u8,
- created_at: jetquery.DateTime,
- updated_at: jetquery.DateTime,
- },
- .{
- .relations = .{
- .albums = jetquery.hasMany(.Album, .{}),
- },
- },
-);
-
-pub const Mastersong = jetquery.Model(
- @This(),
- "mastersongs",
- struct {
- id: i32,
- name: []const u8,
- created_at: jetquery.DateTime,
- updated_at: jetquery.DateTime,
- },
- .{
- .relations = .{
- .songs = jetquery.hasMany(.Song, .{}),
- },
- },
-);
-
-pub const Rating = jetquery.Model(
- @This(),
- "ratings",
- struct {
- id: i32,
- reference_id: i32,
- score: f32,
- date: jetquery.DateTime,
+ id: i64,
+ song_id: i64,
+ album_id: i64,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
@@ -104,27 +32,80 @@ pub const Rating = jetquery.Model(
.relations = .{
.song = jetquery.belongsTo(.Song, .{}),
.album = jetquery.belongsTo(.Album, .{}),
+ .scrobbles = jetquery.hasMany(.Scrobble, .{ .foreign_key = "albumsong" }),
+ .albumsongsartists = jetquery.hasMany(.Albumsongsartist, .{}),
+ },
+ },
+);
+
+pub const Albumsongsartist = jetquery.Model(
+ @This(),
+ "albumsongsartists",
+ struct {
+ id: i64,
+ albumsong_id: i64,
+ artist_id: i64,
+ created_at: jetquery.DateTime,
+ updated_at: jetquery.DateTime,
+ },
+ .{
+ .relations = .{
+ .albumsong = jetquery.belongsTo(.Albumsong, .{}),
.artist = jetquery.belongsTo(.Artist, .{}),
},
},
);
+pub const Artistalbum = jetquery.Model(
+ @This(),
+ "artistalbums",
+ struct {
+ id: i64,
+ album_id: i64,
+ artist_id: i64,
+ created_at: jetquery.DateTime,
+ updated_at: jetquery.DateTime,
+ },
+ .{
+ .relations = .{
+ .album = jetquery.belongsTo(.Album, .{}),
+ .artist = jetquery.belongsTo(.Artist, .{}),
+ },
+ },
+);
+
+pub const Artist = jetquery.Model(
+ @This(),
+ "artists",
+ struct {
+ id: i64,
+ name: []const u8,
+ disambiguation: ?[]const u8,
+ created_at: jetquery.DateTime,
+ updated_at: jetquery.DateTime,
+ },
+ .{
+ .relations = .{
+ .albumsongsartists = jetquery.hasMany(.Albumsongsartist, .{}),
+ .artistalbums = jetquery.hasMany(.Artistalbum, .{}),
+ .artistsongs = jetquery.hasMany(.Artistsong, .{}),
+ },
+ },
+);
+
pub const Scrobble = jetquery.Model(
@This(),
"scrobbles",
struct {
- id: i32,
- song_id: i32,
- album_id: i32,
- date: jetquery.DateTime,
+ id: i64,
+ albumsong: i64,
+ datetime: jetquery.DateTime,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
- .song = jetquery.belongsTo(.Song, .{}),
- .album = jetquery.belongsTo(.Album, .{}),
- .scrobbleartists = jetquery.hasMany(.Scrobbleartist, .{}),
+ .albumsong = jetquery.belongsTo(.Albumsong, .{ .foreign_key = "albumsong" }),
},
},
);
@@ -133,7 +114,7 @@ pub const Song = jetquery.Model(
@This(),
"songs",
struct {
- id: i32,
+ id: i64,
name: []const u8,
length: ?f32,
hidden: bool,
@@ -142,84 +123,26 @@ pub const Song = jetquery.Model(
},
.{
.relations = .{
- .mastersong = jetquery.belongsTo(.Mastersong, .{}),
- .scrobbles = jetquery.hasMany(.Scrobble, .{}),
- .ratings = jetquery.hasMany(.Rating, .{}),
- .aliases = jetquery.hasMany(.Alias, .{}),
- .songartists = jetquery.hasMany(.Songartist, .{}),
.albumsongs = jetquery.hasMany(.Albumsong, .{}),
+ .artistsongs = jetquery.hasMany(.Artistsong, .{}),
},
},
);
-pub const Albumartist = jetquery.Model(
+pub const Artistsong = jetquery.Model(
@This(),
- "Albumartists",
+ "artistsongs",
struct {
- id: i32,
- album_id: i32,
- artist_id: i32,
+ id: i64,
+ artist_id: i64,
+ song_id: i64,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
- .album = jetquery.belongsTo(.Album, .{}),
.artist = jetquery.belongsTo(.Artist, .{}),
- },
- },
-);
-
-pub const Songartist = jetquery.Model(
- @This(),
- "Songartists",
- struct {
- id: i32,
- song_id: i32,
- artist_id: i32,
- created_at: jetquery.DateTime,
- updated_at: jetquery.DateTime,
- },
- .{
- .relations = .{
- .song = jetquery.belongsTo(.Song, .{}),
- .artist = jetquery.belongsTo(.Artist, .{}),
- },
- },
-);
-
-pub const Albumsong = jetquery.Model(
- @This(),
- "Albumsongs",
- struct {
- id: i32,
- album_id: i32,
- song_id: i32,
- created_at: jetquery.DateTime,
- updated_at: jetquery.DateTime,
- },
- .{
- .relations = .{
- .album = jetquery.belongsTo(.Album, .{}),
.song = jetquery.belongsTo(.Song, .{}),
},
},
);
-
-pub const Scrobbleartist = jetquery.Model(
- @This(),
- "Scrobbleartists",
- struct {
- id: i32,
- scrobble_id: i32,
- artist_id: i32,
- created_at: jetquery.DateTime,
- updated_at: jetquery.DateTime,
- },
- .{
- .relations = .{
- .scrobble = jetquery.belongsTo(.Scrobble, .{}),
- .artist = jetquery.belongsTo(.Artist, .{}),
- },
- },
-);
diff --git a/src/app/database/migrations/2025-02-17_22-38-41_create_aliases.zig b/src/app/database/migrations/2025-02-17_22-38-41_create_aliases.zig
deleted file mode 100644
index 11cbb70..0000000
--- a/src/app/database/migrations/2025-02-17_22-38-41_create_aliases.zig
+++ /dev/null
@@ -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(
- "aliases",
- &.{
- t.primaryKey("id", .{}),
- t.column("reference_id", .integer, .{}),
- t.column("alias", .string, .{}),
- t.timestamps(.{}),
- },
- .{},
- );
-}
-
-pub fn down(repo: anytype) !void {
- try repo.dropTable("aliases", .{});
-}
diff --git a/src/app/database/migrations/2025-02-17_22-38-43_create_masteralbums.zig b/src/app/database/migrations/2025-02-17_22-38-43_create_masteralbums.zig
deleted file mode 100644
index 69e82de..0000000
--- a/src/app/database/migrations/2025-02-17_22-38-43_create_masteralbums.zig
+++ /dev/null
@@ -1,19 +0,0 @@
-const std = @import("std");
-const jetquery = @import("jetquery");
-const t = jetquery.schema.table;
-
-pub fn up(repo: anytype) !void {
- try repo.createTable(
- "masteralbums",
- &.{
- t.primaryKey("id", .{}),
- t.column("name", .string, .{}),
- t.timestamps(.{}),
- },
- .{},
- );
-}
-
-pub fn down(repo: anytype) !void {
- try repo.dropTable("masteralbums", .{});
-}
diff --git a/src/app/database/migrations/2025-02-17_22-38-44_create_mastersongs.zig b/src/app/database/migrations/2025-02-17_22-38-44_create_mastersongs.zig
deleted file mode 100644
index 050a467..0000000
--- a/src/app/database/migrations/2025-02-17_22-38-44_create_mastersongs.zig
+++ /dev/null
@@ -1,19 +0,0 @@
-const std = @import("std");
-const jetquery = @import("jetquery");
-const t = jetquery.schema.table;
-
-pub fn up(repo: anytype) !void {
- try repo.createTable(
- "mastersongs",
- &.{
- t.primaryKey("id", .{}),
- t.column("name", .string, .{}),
- t.timestamps(.{}),
- },
- .{},
- );
-}
-
-pub fn down(repo: anytype) !void {
- try repo.dropTable("mastersongs", .{});
-}
diff --git a/src/app/database/migrations/2025-02-17_22-38-45_create_ratings.zig b/src/app/database/migrations/2025-02-17_22-38-45_create_ratings.zig
deleted file mode 100644
index d7ac939..0000000
--- a/src/app/database/migrations/2025-02-17_22-38-45_create_ratings.zig
+++ /dev/null
@@ -1,21 +0,0 @@
-const std = @import("std");
-const jetquery = @import("jetquery");
-const t = jetquery.schema.table;
-
-pub fn up(repo: anytype) !void {
- try repo.createTable(
- "ratings",
- &.{
- t.primaryKey("id", .{}),
- t.column("reference_id", .integer, .{}),
- t.column("score", .float, .{}),
- t.column("date", .datetime, .{}),
- t.timestamps(.{}),
- },
- .{},
- );
-}
-
-pub fn down(repo: anytype) !void {
- try repo.dropTable("ratings", .{});
-}
diff --git a/src/app/database/migrations/2025-02-19_18-03-51_create_albumartists.zig b/src/app/database/migrations/2025-02-19_18-03-51_create_albumartists.zig
deleted file mode 100644
index b0e4f54..0000000
--- a/src/app/database/migrations/2025-02-19_18-03-51_create_albumartists.zig
+++ /dev/null
@@ -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(
- "Albumartists",
- &.{
- t.primaryKey("id", .{}),
- t.column("album_id", .integer, .{}),
- t.column("artist_id", .integer, .{}),
- t.timestamps(.{}),
- },
- .{},
- );
-}
-
-pub fn down(repo: anytype) !void {
- try repo.dropTable("Albumartists", .{});
-}
diff --git a/src/app/database/migrations/2025-02-19_18-04-22_create_songsartists.zig b/src/app/database/migrations/2025-02-19_18-04-22_create_songsartists.zig
deleted file mode 100644
index a509d7a..0000000
--- a/src/app/database/migrations/2025-02-19_18-04-22_create_songsartists.zig
+++ /dev/null
@@ -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(
- "Songartists",
- &.{
- t.primaryKey("id", .{}),
- t.column("song_id", .integer, .{}),
- t.column("artist_id", .integer, .{}),
- t.timestamps(.{}),
- },
- .{},
- );
-}
-
-pub fn down(repo: anytype) !void {
- try repo.dropTable("Songartists", .{});
-}
diff --git a/src/app/database/migrations/2025-02-19_18-46-48_create_albumsongs.zig b/src/app/database/migrations/2025-02-19_18-46-48_create_albumsongs.zig
deleted file mode 100644
index 1865c2e..0000000
--- a/src/app/database/migrations/2025-02-19_18-46-48_create_albumsongs.zig
+++ /dev/null
@@ -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(
- "Albumsongs",
- &.{
- t.primaryKey("id", .{}),
- t.column("album_id", .integer, .{}),
- t.column("song_id", .integer, .{}),
- t.timestamps(.{}),
- },
- .{},
- );
-}
-
-pub fn down(repo: anytype) !void {
- try repo.dropTable("Albumsongs", .{});
-}
diff --git a/src/app/database/migrations/2025-02-21_14-24-31_create_scrobbleartists.zig b/src/app/database/migrations/2025-02-21_14-24-31_create_scrobbleartists.zig
deleted file mode 100644
index 2125a87..0000000
--- a/src/app/database/migrations/2025-02-21_14-24-31_create_scrobbleartists.zig
+++ /dev/null
@@ -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(
- "Scrobbleartists",
- &.{
- t.primaryKey("id", .{}),
- t.column("scrobble_id", .integer, .{}),
- t.column("artist_id", .integer, .{}),
- t.timestamps(.{}),
- },
- .{},
- );
-}
-
-pub fn down(repo: anytype) !void {
- try repo.dropTable("Scrobbleartists", .{});
-}
diff --git a/src/app/database/migrations/2025-02-17_22-38-47_create_songs.zig b/src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig
similarity index 89%
rename from src/app/database/migrations/2025-02-17_22-38-47_create_songs.zig
rename to src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig
index 9a52b6b..e8ae1d6 100644
--- a/src/app/database/migrations/2025-02-17_22-38-47_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-02-17_22-38-40_create_albums.zig b/src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig
similarity index 89%
rename from src/app/database/migrations/2025-02-17_22-38-40_create_albums.zig
rename to src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig
index d706cfe..86b6184 100644
--- a/src/app/database/migrations/2025-02-17_22-38-40_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
new file mode 100644
index 0000000..96c3063
--- /dev/null
+++ b/src/app/database/migrations/2025-04-07_14-34-39_create_albumsongs.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(
+ "albumsongs",
+ &.{
+ t.primaryKey("id", .{ .type = .bigint }),
+ t.column("song_id", .bigint, .{ .reference = .{ "songs", "id" } }),
+ t.column("album_id", .bigint, .{ .reference = .{ "albums", "id" } }),
+ t.timestamps(.{}),
+ },
+ .{},
+ );
+}
+
+pub fn down(repo: anytype) !void {
+ try repo.dropTable("albumsongs", .{});
+}
diff --git a/src/app/database/migrations/2025-02-17_22-38-46_create_scrobbles.zig b/src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig
similarity index 72%
rename from src/app/database/migrations/2025-02-17_22-38-46_create_scrobbles.zig
rename to src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig
index 9764d99..c3bcd12 100644
--- a/src/app/database/migrations/2025-02-17_22-38-46_create_scrobbles.zig
+++ b/src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig
@@ -7,9 +7,8 @@ pub fn up(repo: anytype) !void {
"scrobbles",
&.{
t.primaryKey("id", .{}),
- t.column("song_id", .integer, .{}),
- t.column("album_id", .integer, .{}),
- t.column("date", .datetime, .{}),
+ t.column("albumsong", .bigint, .{ .reference = .{ "albumsongs", "id" } }),
+ t.column("datetime", .datetime, .{}),
t.timestamps(.{}),
},
.{},
diff --git a/src/app/database/migrations/2025-02-17_22-38-42_create_artists.zig b/src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig
similarity index 74%
rename from src/app/database/migrations/2025-02-17_22-38-42_create_artists.zig
rename to src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig
index 2c92de4..97c5bfe 100644
--- a/src/app/database/migrations/2025-02-17_22-38-42_create_artists.zig
+++ b/src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig
@@ -6,9 +6,9 @@ pub fn up(repo: anytype) !void {
try repo.createTable(
"artists",
&.{
- t.primaryKey("id", .{}),
+ t.primaryKey("id", .{ .type = .bigint }),
t.column("name", .string, .{}),
- t.column("descriptive_string", .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
new file mode 100644
index 0000000..3355196
--- /dev/null
+++ b/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.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(
+ "albumsongsartists",
+ &.{
+ t.primaryKey("id", .{ .type = .bigint }),
+ t.column("albumsong_id", .bigint, .{ .reference = .{ "albumsongs", "id" } }),
+ t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }),
+ t.timestamps(.{}),
+ },
+ .{},
+ );
+}
+
+pub fn down(repo: anytype) !void {
+ try repo.dropTable("albumsongsartists", .{});
+}
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
new file mode 100644
index 0000000..3c3ea7f
--- /dev/null
+++ b/src/app/database/migrations/2025-04-07_14-40-17_create_artistalbums.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(
+ "artistalbums",
+ &.{
+ t.primaryKey("id", .{ .type = .bigint }),
+ t.column("album_id", .bigint, .{ .reference = .{ "albums", "id" } }),
+ t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }),
+ t.timestamps(.{}),
+ },
+ .{},
+ );
+}
+
+pub fn down(repo: anytype) !void {
+ try repo.dropTable("artistalbums", .{});
+}
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", .{});
+}
diff --git a/src/app/jobs/process_rule.zig b/src/app/jobs/process_rule.zig
new file mode 100644
index 0000000..6beb880
--- /dev/null
+++ b/src/app/jobs/process_rule.zig
@@ -0,0 +1,53 @@
+const std = @import("std");
+const jetzig = @import("jetzig");
+const Data = @import("../../types.zig");
+
+pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
+ _ = env;
+
+ const rule = try std.json.parseFromSliceLeaky(Data.Rule, allocator, try params.toJson(), .{ .ignore_unknown_fields = true });
+
+ const file_read: std.fs.File = std.fs.cwd().openFile("rules.json", .{}) catch |read_err| switch (read_err) {
+ error.FileNotFound => {
+ const file = std.fs.cwd().createFile("rules.json", .{ .read = true, .exclusive = true }) catch |write_err| switch (write_err) {
+ error.PathAlreadyExists => unreachable,
+ else => {
+ std.log.debug("{any} while writing file", .{write_err});
+ return;
+ },
+ };
+ const out_rules = &[_]Data.Rule{rule};
+ const out = try std.json.stringifyAlloc(allocator, out_rules, .{});
+ try file.writeAll(out);
+ file.close();
+ return;
+ },
+ else => {
+ std.log.debug("{any} while reading file", .{read_err});
+ return;
+ },
+ };
+
+ var rules = std.ArrayList(Data.Rule).init(allocator);
+
+ const file_content = try file_read.readToEndAlloc(allocator, 16_000_000);
+ file_read.close();
+
+ const file_write: std.fs.File = try std.fs.cwd().openFile("rules.json", .{ .mode = .write_only });
+ if (file_content.len == 0) {
+ const out_rules = &[_]Data.Rule{rule};
+ const out = try std.json.stringifyAlloc(allocator, out_rules, .{});
+ try file_write.writeAll(out);
+ file_write.close();
+ return;
+ }
+ const content: []Data.Rule = try std.json.parseFromSliceLeaky([]Data.Rule, allocator, file_content, .{});
+ try rules.appendSlice(content);
+ try rules.append(rule);
+
+ const out = try std.json.stringifyAlloc(allocator, rules.items, .{});
+
+ try file_write.writeAll(out);
+ file_write.close();
+ return;
+}
diff --git a/src/app/jobs/process_scrobbles.zig b/src/app/jobs/process_scrobbles.zig
index 1314465..770bde7 100644
--- a/src/app/jobs/process_scrobbles.zig
+++ b/src/app/jobs/process_scrobbles.zig
@@ -1,8 +1,8 @@
const std = @import("std");
const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery;
-const Scrobble = @import("../../types.zig").LastFMScrobble;
-const lastfm = @import("../../types.zig").LastFM;
+const Data = @import("../../types.zig");
+const rules = @import("../../apply_rule.zig");
// The `run` function for a job is invoked every time the job is processed by a queue worker
// (or by the Jetzig server if the job is processed in-line).
@@ -14,118 +14,121 @@ const lastfm = @import("../../types.zig").LastFM;
// - logger: Logger attached to the same stream as the Jetzig server.
// - environment: Enum of `{ production, development }`.
pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
- _ = allocator;
//_ = env;
-
if (params.getT(.array, "scrobbles")) |scrobbles| {
for (scrobbles.items()) |item| {
- //const fixed_date: u32 = @as(u32, item.getT(.integer, "date").?);
- const scrobble: Scrobble = .{ .track = item.getT(.string, "track").?, .artist = item.getT(.string, "artist").?, .album = item.getT(.string, "album") orelse "", .date = @as(u64, @bitCast(@as(i64, @truncate(item.getT(.integer, "date").? * 1000)))) };
- // Make hashes
- //const album_hash = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(scrobble.album)));
- //const artist_hash = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(scrobble.artist)));
- //const song_hash = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(scrobble.track)));
+ // Probably want to include artist name here, but not sure how to yet
- // Create a buffer to hold the metadata to hash. Numbers based on the title of a
- // particularly long Sufjan Stevens song title, and we're gonna pray the metadata
- // does not exceed three times it's length.
- var buffer = [_]u8{undefined} ** (288 * 3);
- const artist_id = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(scrobble.artist)));
- const album_prehash = try std.fmt.bufPrint(&buffer, "{s}{s}", .{ scrobble.artist, scrobble.album });
- const album_id = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(album_prehash)));
- const song_prehash = try std.fmt.bufPrint(&buffer, "{s}{s}{s}", .{ scrobble.artist, scrobble.album, scrobble.track });
- const song_id = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(song_prehash)));
+ const track_artists = item.getT(.array, "artists_track").?.items();
+ const album_artists = item.getT(.array, "artists_album").?.items();
- // Make IDs
- // Song: Song hash XOR artist hash XOR album hash
- // This way, if two songs share a name, then
- // the IDs also depend on the hash of the album
- // they're on, as well as the artist name. As far
- // as I can tell, this is only as issue for Sufjan
- // Steven's `Songs for Christmas`. (In practice.
- // In reality, there are albums with several untitled
- // songs (Selected Ambient Works Vol. II by Aphex Twin,
- // ( ) by Sigur Ros, ...) that have working titles
- // in their place.)
+ 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);
- // Album: If the album is not self-titled, then
- // album hash XOR artist hash. This way, if two
- // artists have an album of the same name, then
- // the IDs also depend on the hash of the artist
- // name. As far as I can tell, this is only an
- // issue for Weezer.
+ const scrobble: Data.Scrobble = .{
+ .track = item.getT(.string, "track").?,
+ .artists_track = track_artist_name_buffer,
+ .album = item.getT(.string, "album") orelse "",
+ .artists_album = album_artist_name_buffer,
+ .date = @as(i64, @truncate(item.getT(.integer, "date").?)),
+ };
- // Artist: Artist hash. If two artists have the same name,
- // then a descriptive string can be provided to
- // differentiate after the fact, or in a rule.
+ var album_hash_string = std.ArrayList(u8).init(allocator);
+ var track_hash_string = std.ArrayList(u8).init(allocator);
- //var album_id: i32 = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(formed)));
- //const song_id = (song_hash ^ artist_hash ^ album_hash);
-
- // Inserts
- const album_insert = jetzig.database.Query(.Album).insert(.{ .id = album_id, .name = scrobble.album, .length = null });
- const artist_insert = jetzig.database.Query(.Artist).insert(.{ .id = artist_id, .name = scrobble.artist, .descriptive_string = "" });
- const song_insert = jetzig.database.Query(.Song).insert(.{ .id = song_id, .name = scrobble.track, .length = null, .hidden = false });
-
- // Checks
- const album_check = try jetzig.database.Query(.Album).find(album_id).execute(env.repo);
- const artist_check = try jetzig.database.Query(.Artist).find(artist_id).execute(env.repo);
- const song_check = try jetzig.database.Query(.Song).find(song_id).execute(env.repo);
-
- // I think there must be a better way to do this next part
- // There are very few situations where artist_check is null
- // but song_check/album is not. Also yes, the order of these
- // checks is weird, I didn't put a lot of thought into it
- var associative_table_flags: [3]bool = [3]bool{ true, true, true };
-
- if (album_check == null) {
- try env.repo.execute(album_insert);
- try jetzig.database.Query(.Albumartist).insert(.{ .album_id = album_id, .artist_id = artist_id }).execute(env.repo);
- associative_table_flags[0] = false;
- try jetzig.database.Query(.Albumsong).insert(.{ .album_id = album_id, .song_id = song_id }).execute(env.repo);
- associative_table_flags[1] = false;
+ // 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(i64, @bitCast(std.hash.Fnv1a_64.hash(artist_name)));
}
- if (artist_check == null) {
- try env.repo.execute(artist_insert);
- if (associative_table_flags[0]) try jetzig.database.Query(.Albumartist).insert(.{ .album_id = album_id, .artist_id = artist_id }).execute(env.repo);
- try jetzig.database.Query(.Songartist).insert(.{ .song_id = song_id, .artist_id = artist_id }).execute(env.repo);
- associative_table_flags[2] = false;
+ 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(i64, @bitCast(std.hash.Fnv1a_64.hash(artist_name)));
+ try album_hash_string.appendSlice(artist_name);
}
- if (song_check == null) {
- try env.repo.execute(song_insert);
- if (associative_table_flags[1]) try jetzig.database.Query(.Albumsong).insert(.{ .album_id = album_id, .song_id = song_id }).execute(env.repo);
- if (associative_table_flags[2]) try jetzig.database.Query(.Songartist).insert(.{ .song_id = song_id, .artist_id = artist_id }).execute(env.repo);
+ try album_hash_string.appendSlice(scrobble.album);
+ try track_hash_string.appendSlice(scrobble.album);
+ const album_hash = @as(i64, @bitCast(std.hash.Fnv1a_64.hash(album_hash_string.items)));
+ try track_hash_string.appendSlice(scrobble.track);
+ const track_hash = @as(i64, @bitCast(std.hash.Fnv1a_64.hash(track_hash_string.items)));
+
+ var albumsong_id = try jetzig.database.Query(.Albumsong)
+ .find(album_hash ^ track_hash)
+ .select(.{.id}).execute(env.repo);
+
+ 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) |scrobble_track_artist, track_artist_hash| {
+ var artist_id = try jetzig.database.Query(.Artist)
+ .find(track_artist_hash)
+ .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 })
+ .returning(.{.id}).execute(env.repo);
+
+ if (albumsong_id == null) {
+ var track_id = try jetzig.database.Query(.Song)
+ .find(track_hash)
+ .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 })
+ .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 })
+ .returning(.{.id}).execute(env.repo);
+
+ 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.?.id, .artist_id = artist_id.?.id }).execute(env.repo);
+ } else {
+ const ins_albumsongartist = try jetzig.database.Query(.Albumsongsartist)
+ .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.?.id, .artist_id = artist_id.?.id }).execute(env.repo);
+ }
}
- const scr_id = try jetzig.database.Query(.Scrobble).insert(.{ .song_id = song_id, .album_id = album_id, .date = scrobble.date }).returning(.{.id}).execute(env.repo);
- defer env.repo.free(scr_id);
- try jetzig.database.Query(.Scrobbleartist).insert(.{ .scrobble_id = scr_id.?.id, .artist_id = 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 })
+ .select(.{.id}).execute(env.repo);
+
+ if (artistalbum_id == null) {
+ var artist_id = try jetzig.database.Query(.Artist)
+ .find(album_artist_hash)
+ .select(.{.id}).execute(env.repo);
+ if (artist_id == null)
+ artist_id = try jetzig.database.Query(.Artist)
+ .insert(.{ .id = album_artist_hash, .name = scrobble_album_artist, .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);
+ }
+ }
+
+ try jetzig.database.Query(.Scrobble)
+ .insert(.{ .albumsong = albumsong_id.?.id, .datetime = scrobble.date }).execute(env.repo);
}
}
-
- // I would like to replicate this kind of functionality for several kinds of queries
- // This one gives me all albums by Dream Theater (it also returns Dream Theater for
- // each entry, but removing artists.name from the SELECT would remove that)
- //
- // SELECT
- // artists.name, albums.name
- // FROM
- // "Albumartists"
- // INNER JOIN artists
- // ON "Albumartists".artist_id = artists.id
- // INNER JOIN albums
- // ON "Albumartists".album_id = albums.id
- // WHERE artists.name = 'Dream Theater';
-
- //const query = jetzig.database.Query(.Artist).include(.artistalbums, .{});
- //const results = try env.repo.all(query);
- //defer env.repo.free(results);
- //for (results) |result| {
- // for (result.artistalbums) |artistalbum| {
- // std.log.debug("{s}: {any}", .{ result.name, artistalbum.album_id });
- // }
- //}
}
diff --git a/src/app/jobs/process_scrobbles2.zig b/src/app/jobs/process_scrobbles2.zig
new file mode 100644
index 0000000..8b3ae66
--- /dev/null
+++ b/src/app/jobs/process_scrobbles2.zig
@@ -0,0 +1,131 @@
+const std = @import("std");
+const jetzig = @import("jetzig");
+
+// The `run` function for a job is invoked every time the job is processed by a queue worker
+// (or by the Jetzig server if the job is processed in-line).
+//
+// Arguments:
+// * allocator: Arena allocator for use during the job execution process.
+// * params: Params assigned to a job (from a request, values added to response data).
+// * env: Provides the following fields:
+// - logger: Logger attached to the same stream as the Jetzig server.
+// - environment: Enum of `{ production, development }`.
+pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
+ _ = allocator;
+
+ for (params.getT(.object, "tracks").?.items()) |track| {
+ const id = try std.fmt.parseInt(i64, track.key, 10);
+
+ const track_query = try jetzig.database.Query(.Song)
+ .find(id).execute(env.repo);
+
+ if (track_query == null) {
+ const name = try track.value.coerce([]const u8);
+ try jetzig.database.Query(.Song)
+ .insert(.{ .id = id, .name = name, .length = null, .hidden = false })
+ .execute(env.repo);
+ }
+ }
+
+ for (params.getT(.object, "albums").?.items()) |album| {
+ const id = try std.fmt.parseInt(i64, album.key, 10);
+
+ const album_query = try jetzig.database.Query(.Album)
+ .find(id).execute(env.repo);
+
+ if (album_query == null) {
+ const name = try album.value.coerce([]const u8);
+ try jetzig.database.Query(.Album)
+ .insert(.{ .id = id, .name = name, .length = null })
+ .execute(env.repo);
+ }
+ }
+
+ for (params.getT(.object, "artists").?.items()) |artist| {
+ const id = try std.fmt.parseInt(i64, artist.key, 10);
+
+ const artist_query = try jetzig.database.Query(.Artist)
+ .find(id).execute(env.repo);
+
+ if (artist_query == null) {
+ const name = try artist.value.coerce([]const u8);
+ try jetzig.database.Query(.Artist)
+ .insert(.{ .id = id, .name = name })
+ .execute(env.repo);
+ }
+ }
+
+ for (params.getT(.object, "albumsongs").?.items()) |as| {
+ const id = try std.fmt.parseInt(i64, as.key, 10);
+
+ const as_query = try jetzig.database.Query(.Albumsong)
+ .find(id).execute(env.repo);
+
+ if (as_query == null) {
+ const track_id = @as(i64, @intCast(as.value.getT(.integer, "song").?));
+ const album_id = @as(i64, @intCast(as.value.getT(.integer, "album").?));
+ try jetzig.database.Query(.Albumsong)
+ .insert(.{ .id = id, .song_id = track_id, .album_id = album_id })
+ .execute(env.repo);
+ }
+
+ const scrobbles = as.value.getT(.array, "scrobbles").?;
+ for (scrobbles.items()) |date| {
+ try jetzig.database.Query(.Scrobble).insert(.{ .albumsong = id, .datetime = date })
+ .execute(env.repo);
+ }
+ }
+
+ for (params.getT(.object, "artistalbums").?.items()) |aa| {
+ const id = try std.fmt.parseInt(i64, aa.key, 10);
+
+ const aa_query = try jetzig.database.Query(.Artistalbum)
+ .find(id).execute(env.repo);
+
+ if (aa_query == null) {
+ const artist_id = @as(i64, @intCast(aa.value.getT(.integer, "artist").?));
+ const album_id = @as(i64, @intCast(aa.value.getT(.integer, "album").?));
+ try jetzig.database.Query(.Artistalbum)
+ .insert(.{ .id = id, .artist_id = artist_id, .album_id = album_id })
+ .execute(env.repo);
+ }
+ }
+
+ for (params.getT(.object, "albumsongsartists").?.items()) |asa| {
+ const id = try std.fmt.parseInt(i64, asa.key, 10);
+
+ const asa_query = try jetzig.database.Query(.Albumsongsartist)
+ .find(id).execute(env.repo);
+
+ if (asa_query == null) {
+ const albumsong_id = @as(i64, @intCast(asa.value.getT(.integer, "albumsong").?));
+ const artist_id = @as(i64, @intCast(asa.value.getT(.integer, "artist").?));
+ try jetzig.database.Query(.Albumsongsartist)
+ .insert(.{ .id = id, .albumsong_id = albumsong_id, .artist_id = artist_id })
+ .execute(env.repo);
+ }
+ }
+
+ //for ((params.getT(.object, "albumsongsartists")).?.items(.object)) |asa| {
+ // const id = try std.fmt.parseInt(i64, asa.key, 10);
+ // const albumsong_id = asa.value.getT(.integer, "albumsong");
+ // const track_artist_id = asa.value.getT(.integer, "artist");
+
+ // const albumsongartist = try jetzig.database.Query(.Albumsongsartist)
+ // .find(id)
+ // .select(.{.id}).execute(env.repo);
+
+ // if (albumsongartist == null) {
+ // var artist_id = try jetzig.database.Query(.Artist)
+ // .find(track_artist_id)
+ // .select(.{.id}).execute(env.repo);
+ //
+ // if (artist_id == null) {
+ // const artist = params.chain(.{"artists",})
+ // artist_id = try jetzig.database.Query(.Artist)
+ // .insert(.{ .id = track_artist_hash, .name = scrobble_track_artist, .disambiguation = null })
+ // .execute(env.repo);
+ // }
+ // }
+ //}
+}
diff --git a/src/app/middleware/DemoMiddleware.zig b/src/app/middleware/DemoMiddleware.zig
deleted file mode 100644
index a6758d2..0000000
--- a/src/app/middleware/DemoMiddleware.zig
+++ /dev/null
@@ -1,65 +0,0 @@
-/// Demo middleware. Assign middleware by declaring `pub const middleware` in the
-/// `jetzig_options` defined in your application's `src/main.zig`.
-///
-/// Middleware is called before and after the request, providing full access to the active
-/// request, allowing you to execute any custom code for logging, tracking, inserting response
-/// headers, etc.
-///
-/// This middleware is configured in the demo app's `src/main.zig`:
-///
-/// ```
-/// pub const jetzig_options = struct {
-/// pub const middleware: []const type = &.{@import("app/middleware/DemoMiddleware.zig")};
-/// };
-/// ```
-const std = @import("std");
-const jetzig = @import("jetzig");
-
-/// Define any custom data fields you want to store here. Assigning to these fields in the `init`
-/// function allows you to access them in various middleware callbacks defined below, where they
-/// can also be modified.
-my_custom_value: []const u8,
-
-const Self = @This();
-
-/// Initialize middleware.
-pub fn init(request: *jetzig.http.Request) !*Self {
- var middleware = try request.allocator.create(Self);
- middleware.my_custom_value = "initial value";
- return middleware;
-}
-
-/// Invoked immediately after the request is received but before it has started processing.
-/// Any calls to `request.render` or `request.redirect` will prevent further processing of the
-/// request, including any other middleware in the chain.
-pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void {
- try request.server.logger.DEBUG(
- "[DemoMiddleware:afterRequest] my_custom_value: {s}",
- .{self.my_custom_value},
- );
- self.my_custom_value = @tagName(request.method);
-}
-
-/// Invoked immediately before the response renders to the client.
-/// The response can be modified here if needed.
-pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void {
- try request.server.logger.DEBUG(
- "[DemoMiddleware:beforeResponse] my_custom_value: {s}, response status: {s}",
- .{ self.my_custom_value, @tagName(response.status_code) },
- );
-}
-
-/// Invoked immediately after the response has been finalized and sent to the client.
-/// Response data can be accessed for logging, but any modifications will have no impact.
-pub fn afterResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void {
- _ = self;
- _ = response;
- try request.server.logger.DEBUG("[DemoMiddleware:afterResponse] response completed", .{});
-}
-
-/// Invoked after `afterResponse` is called. Use this function to do any clean-up.
-/// Note that `request.allocator` is an arena allocator, so any allocations are automatically
-/// freed before the next request starts processing.
-pub fn deinit(self: *Self, request: *jetzig.http.Request) void {
- request.allocator.destroy(self);
-}
diff --git a/src/app/views/albums.zig b/src/app/views/albums.zig
index 634f414..03046b3 100644
--- a/src/app/views/albums.zig
+++ b/src/app/views/albums.zig
@@ -1,155 +1,33 @@
const std = @import("std");
const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery;
+const TableRow = @import("../../types.zig").TableRow;
+const HyperlinkData = @import("../../types.zig").HyperlinkData;
+const queries = @import("../../queries.zig");
pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
- var albums_view = try root.put("albums", .array);
- const albums = try jetzig.database.Query(.Album)
- .select(.{ .id, .name })
- .include(.albumartists, .{ .select = .{.artist_id} })
- .include(.scrobbles, .{ .select = .{.id} })
- .orderBy(.{ .name = .asc })
- .all(request.repo);
- //const albums = try request.repo.all(query);
- for (albums) |album| {
- var album_view = try albums_view.append(.object);
+ const albums = try queries.entityQueryResult(request, queries.loadQuery(.album, .entities), .{});
+ try root.put("albums", albums);
- var artist_infos = try album_view.put("artist_info", .array);
- for (album.albumartists) |artist| {
- var artist_info = try artist_infos.append(.object);
- const artist_data = try jetzig.database.Query(.Artist)
- .find(artist.artist_id)
- .select(.{ .id, .name })
- .execute(request.repo);
- try artist_info.put("name", artist_data.?.name);
- try artist_info.put("id", artist_data.?.id);
- }
-
- try album_view.put("name", album.name);
- try album_view.put("url", album.id);
- try album_view.put("scrobbles", (album.scrobbles).len);
- }
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
- const album = try jetzig.database.Query(.Album)
- .find(id)
- .select(.{ .id, .name })
- .execute(request.repo);
var root = try request.data(.object);
- try root.put("album", album.?.name);
- var songs_view = try root.put("songs", .array);
- const query = jetzig.database.Query(.Albumsong)
- .select(.{.id})
- .include(.song, .{ .select = .{ .name, .id } })
- .join(.inner, .album)
- .where(.{ .album = .{ .id = id } });
- const songs = try request.repo.all(query);
- for (songs) |song| {
- const scrobbles = try jetzig.database.Query(.Scrobble)
- .where(.{ .song_id = song.song.id })
- .count()
- .execute(request.repo);
- var song_view = try songs_view.append(.object);
- try song_view.put("name", song.song.name);
- try song_view.put("url", song.song.id);
- try song_view.put("scrobbles", scrobbles);
- }
+ 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, .get_songs), .{id});
+ try root.put("songs", songs);
+
+ const firstlast = try queries.entityQueryResult(request, queries.loadQuery(.album, .firstlast), .{id});
+ try root.put("firstlast", firstlast);
+
+ const timescale = try queries.entityQueryResult(request, queries.loadQuery(.album, .timescale), .{id});
+ try root.put("yearly", timescale);
+
return request.render(.ok);
}
-
-pub fn new(request: *jetzig.Request) !jetzig.View {
- return request.render(.ok);
-}
-
-pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn post(request: *jetzig.Request) !jetzig.View {
- return request.render(.created);
-}
-
-pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-test "index" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/album", .{});
- try response.expectStatus(.ok);
-}
-
-test "get" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/album/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "new" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/album/new", .{});
- try response.expectStatus(.ok);
-}
-
-test "edit" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/album/example-id/edit", .{});
- try response.expectStatus(.ok);
-}
-
-test "post" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.POST, "/album", .{});
- try response.expectStatus(.created);
-}
-
-test "put" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.PUT, "/album/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "patch" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.PATCH, "/album/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "delete" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.DELETE, "/album/example-id", .{});
- try response.expectStatus(.ok);
-}
diff --git a/src/app/views/albums/get.zmpl b/src/app/views/albums/get.zmpl
index 8084038..7e089e7 100644
--- a/src/app/views/albums/get.zmpl
+++ b/src/app/views/albums/get.zmpl
@@ -1,19 +1,22 @@
+@zig {
+ const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
+ const columns: ColumnChoices = &.{.song, .scrobbles};
+}
+
@partial partials/header
-{{.album}}
-
-
-Name
-@for (.songs) |song| {
-
- {{song.name}}
- {{song.scrobbles}}
-
-}
-
+{{.album.album_name}}
+
+{{.album.scrobbles}} scrobbles ({{.album.rank}} place)
+{{.album.song_num}} songs
+@partial partials/firstlast_listens(firstlast: .firstlast)
+Yearly Performance
+@partial partials/timescale(range: .yearly)
+Songs
+@partial partials/newtable(T: ColumnChoices, table_data: .songs, columns: columns)
\ No newline at end of file
diff --git a/src/app/views/albums/index.zmpl b/src/app/views/albums/index.zmpl
index 2c259a6..43ff1de 100644
--- a/src/app/views/albums/index.zmpl
+++ b/src/app/views/albums/index.zmpl
@@ -1,40 +1,15 @@
+@zig {
+ const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
+ const columns: ColumnChoices = &.{.album, .artistlist, .scrobbles};
+}
+
-
@partial partials/header
Albums
-
-
-
-Name
-Artist(s)
-Scrobbles
-
-
-
-@for (.albums) |album| {
-
- {{album.name}}
-
- @for (album.get("artist_info").?) |ai| {
- {{ai.name}}
- }
-
- {{album.scrobbles}}
-
-}
-
-
-
-
+@partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: columns)
\ No newline at end of file
diff --git a/src/app/views/artists.zig b/src/app/views/artists.zig
index 78058a7..3ec8eba 100644
--- a/src/app/views/artists.zig
+++ b/src/app/views/artists.zig
@@ -1,141 +1,37 @@
const std = @import("std");
const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery;
+const TableRow = @import("../../types.zig").TableRow;
+const dateFmt = @import("../../date_fmt.zig").dateFmt;
+const ordinalFmt = @import("../../ordinal_fmt.zig").ordinalFmt;
+const queries = @import("../../queries.zig");
pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
- var artists_view = try root.put("artists", .array);
- const artists = try jetzig.database.Query(.Artist)
- .select(.{ .id, .name })
- .include(.scrobbleartists, .{ .select = .{.id} })
- .orderBy(.{ .name = .asc })
- .all(request.repo);
- for (artists) |artist| {
- var artist_view = try artists_view.append(.object);
- try artist_view.put("name", artist.name);
- try artist_view.put("url", artist.id);
- try artist_view.put("scrobbles", (artist.scrobbleartists).len);
- }
+ const artists = try queries.entityQueryResult(request, queries.loadQuery(.artist, .entities), .{});
+
+ try root.put("artists", artists);
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
- const artist = try jetzig.database.Query(.Artist)
- .find(id)
- .select(.{ .id, .name })
- .execute(request.repo);
var root = try request.data(.object);
- try root.put("artist", artist.?.name);
- var albums_view = try root.put("albums", .array);
- const query = jetzig.database.Query(.Albumartist)
- .select(.{.id})
- .include(.album, .{ .select = .{ .name, .id } })
- .join(.inner, .artist)
- .where(.{ .artist = .{ .id = id } });
- const albums = try request.repo.all(query);
- for (albums) |album| {
- const scrobbles = try jetzig.database.Query(.Scrobble)
- .where(.{ .album_id = album.album.id })
- .count()
- .execute(request.repo);
- var album_view = try albums_view.append(.object);
- try album_view.put("name", album.album.name);
- try album_view.put("url", album.album.id);
- try album_view.put("scrobbles", scrobbles);
- }
+ 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, .get_albums), .{id});
+ try root.put("albums", albums);
+
+ const appears = try queries.entityQueryResult(request, queries.loadQuery(.artist, .appears), .{id});
+ try root.put("appears", appears);
+
+ const firstlast = try queries.entityQueryResult(request, queries.loadQuery(.artist, .firstlast), .{id});
+ try root.put("firstlast", firstlast);
+
+ const timescale = try queries.entityQueryResult(request, queries.loadQuery(.artist, .timescale), .{id});
+ try root.put("yearly", timescale);
+
return request.render(.ok);
}
-
-pub fn new(request: *jetzig.Request) !jetzig.View {
- return request.render(.ok);
-}
-
-pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn post(request: *jetzig.Request) !jetzig.View {
- return request.render(.created);
-}
-
-pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-test "index" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/artist", .{});
- try response.expectStatus(.ok);
-}
-
-test "get" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/artist/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "new" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/artist/new", .{});
- try response.expectStatus(.ok);
-}
-
-test "edit" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/artist/example-id/edit", .{});
- try response.expectStatus(.ok);
-}
-
-test "post" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.POST, "/artist", .{});
- try response.expectStatus(.created);
-}
-
-test "put" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.PUT, "/artist/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "patch" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.PATCH, "/artist/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "delete" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.DELETE, "/artist/example-id", .{});
- try response.expectStatus(.ok);
-}
diff --git a/src/app/views/artists/delete.zmpl b/src/app/views/artists/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/artists/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/artists/edit.zmpl b/src/app/views/artists/edit.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/artists/edit.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/artists/get.zmpl b/src/app/views/artists/get.zmpl
index 911d2c3..9f1ff07 100644
--- a/src/app/views/artists/get.zmpl
+++ b/src/app/views/artists/get.zmpl
@@ -1,31 +1,28 @@
+@zig {
+ const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
+ const columns: ColumnChoices = &.{.album, .scrobbles};
+}
+
-
@partial partials/header
-{{.artist}}
-
-
-
-Name Scrobbles
-
-
-
-@for (.albums) |album| {
-
- {{album.name}}
- {{album.scrobbles}}
-
-}
-
-
-
-
+{{.artist.artist_name}}
+
+
{{.artist.scrobbles}} scrobbles ({{.artist.rank}} place)
+
{{.artist.song_num}} songs
+
{{.artist.album_num}} albums
+
+@partial partials/timescale(range: .yearly)
+
+@partial partials/firstlast_listens(firstlast: .firstlast)
+Albums
+@partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: columns)
+
+Albums Featured On
+@partial partials/newtable(T: ColumnChoices, table_data: .appears, columns: columns)
+
\ No newline at end of file
diff --git a/src/app/views/artists/index.zmpl b/src/app/views/artists/index.zmpl
index 6854e07..0648c91 100644
--- a/src/app/views/artists/index.zmpl
+++ b/src/app/views/artists/index.zmpl
@@ -1,34 +1,15 @@
+@zig {
+ const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
+ const columns: ColumnChoices = &.{.artist, .scrobbles};
+}
+
-
@partial partials/header
Artists
-
-
-
-Name
-Scrobbles
-
-
-
-@for (.artists) |artist| {
-
- {{artist.name}}
- {{artist.scrobbles}}
-
-}
-
-
-
-
+@partial partials/newtable(T: ColumnChoices, table_data: .artists, columns: columns)
\ No newline at end of file
diff --git a/src/app/views/artists/new.zmpl b/src/app/views/artists/new.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/artists/new.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/artists/patch.zmpl b/src/app/views/artists/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/artists/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/artists/post.zmpl b/src/app/views/artists/post.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/artists/post.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/artists/put.zmpl b/src/app/views/artists/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/artists/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/collection.zig b/src/app/views/collection.zig
index 8125efd..1b0b502 100644
--- a/src/app/views/collection.zig
+++ b/src/app/views/collection.zig
@@ -11,26 +11,3 @@ pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig
_ = id;
return request.render(.ok);
}
-
-pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- return request.render(.created);
-}
-
-pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
diff --git a/src/app/views/collection/delete.zmpl b/src/app/views/collection/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/collection/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/collection/patch.zmpl b/src/app/views/collection/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/collection/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/collection/post.zmpl b/src/app/views/collection/post.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/collection/post.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/collection/put.zmpl b/src/app/views/collection/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/collection/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/concerts.zig b/src/app/views/concerts.zig
index 8125efd..1b0b502 100644
--- a/src/app/views/concerts.zig
+++ b/src/app/views/concerts.zig
@@ -11,26 +11,3 @@ pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig
_ = id;
return request.render(.ok);
}
-
-pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- return request.render(.created);
-}
-
-pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
diff --git a/src/app/views/concerts/delete.zmpl b/src/app/views/concerts/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/concerts/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/concerts/patch.zmpl b/src/app/views/concerts/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/concerts/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/concerts/post.zmpl b/src/app/views/concerts/post.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/concerts/post.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/concerts/put.zmpl b/src/app/views/concerts/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/concerts/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/search.zig b/src/app/views/groups.zig
similarity index 81%
rename from src/app/views/search.zig
rename to src/app/views/groups.zig
index be5f7e0..85505c8 100644
--- a/src/app/views/search.zig
+++ b/src/app/views/groups.zig
@@ -43,7 +43,7 @@ test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
- const response = try app.request(.GET, "/search", .{});
+ const response = try app.request(.GET, "/groups", .{});
try response.expectStatus(.ok);
}
@@ -51,7 +51,7 @@ test "get" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
- const response = try app.request(.GET, "/search/example-id", .{});
+ const response = try app.request(.GET, "/groups/example-id", .{});
try response.expectStatus(.ok);
}
@@ -59,7 +59,7 @@ test "new" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
- const response = try app.request(.GET, "/search/new", .{});
+ const response = try app.request(.GET, "/groups/new", .{});
try response.expectStatus(.ok);
}
@@ -67,7 +67,7 @@ test "edit" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
- const response = try app.request(.GET, "/search/example-id/edit", .{});
+ const response = try app.request(.GET, "/groups/example-id/edit", .{});
try response.expectStatus(.ok);
}
@@ -75,7 +75,7 @@ test "post" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
- const response = try app.request(.POST, "/search", .{});
+ const response = try app.request(.POST, "/groups", .{});
try response.expectStatus(.created);
}
@@ -83,7 +83,7 @@ test "put" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
- const response = try app.request(.PUT, "/search/example-id", .{});
+ const response = try app.request(.PUT, "/groups/example-id", .{});
try response.expectStatus(.ok);
}
@@ -91,7 +91,7 @@ test "patch" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
- const response = try app.request(.PATCH, "/search/example-id", .{});
+ const response = try app.request(.PATCH, "/groups/example-id", .{});
try response.expectStatus(.ok);
}
@@ -99,6 +99,6 @@ test "delete" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
- const response = try app.request(.DELETE, "/search/example-id", .{});
+ const response = try app.request(.DELETE, "/groups/example-id", .{});
try response.expectStatus(.ok);
}
diff --git a/src/app/views/albums/delete.zmpl b/src/app/views/groups/delete.zmpl
similarity index 100%
rename from src/app/views/albums/delete.zmpl
rename to src/app/views/groups/delete.zmpl
diff --git a/src/app/views/albums/edit.zmpl b/src/app/views/groups/edit.zmpl
similarity index 100%
rename from src/app/views/albums/edit.zmpl
rename to src/app/views/groups/edit.zmpl
diff --git a/src/app/views/rules/get.zmpl b/src/app/views/groups/get.zmpl
similarity index 100%
rename from src/app/views/rules/get.zmpl
rename to src/app/views/groups/get.zmpl
diff --git a/src/app/views/groups/index.zmpl b/src/app/views/groups/index.zmpl
new file mode 100644
index 0000000..1522924
--- /dev/null
+++ b/src/app/views/groups/index.zmpl
@@ -0,0 +1,11 @@
+
+
+
+
+@partial partials/header
+
+Merge Songs
+
+
\ No newline at end of file
diff --git a/src/app/views/albums/new.zmpl b/src/app/views/groups/new.zmpl
similarity index 100%
rename from src/app/views/albums/new.zmpl
rename to src/app/views/groups/new.zmpl
diff --git a/src/app/views/albums/patch.zmpl b/src/app/views/groups/patch.zmpl
similarity index 100%
rename from src/app/views/albums/patch.zmpl
rename to src/app/views/groups/patch.zmpl
diff --git a/src/app/views/albums/post.zmpl b/src/app/views/groups/post.zmpl
similarity index 100%
rename from src/app/views/albums/post.zmpl
rename to src/app/views/groups/post.zmpl
diff --git a/src/app/views/albums/put.zmpl b/src/app/views/groups/put.zmpl
similarity index 100%
rename from src/app/views/albums/put.zmpl
rename to src/app/views/groups/put.zmpl
diff --git a/src/app/views/lists.zig b/src/app/views/lists.zig
index 8125efd..aecf0dc 100644
--- a/src/app/views/lists.zig
+++ b/src/app/views/lists.zig
@@ -16,21 +16,3 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
-
-pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
diff --git a/src/app/views/lists/delete.zmpl b/src/app/views/lists/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/lists/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/lists/patch.zmpl b/src/app/views/lists/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/lists/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/lists/put.zmpl b/src/app/views/lists/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/lists/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/partials/_firstlast_listens.zmpl b/src/app/views/partials/_firstlast_listens.zmpl
new file mode 100644
index 0000000..3cf3ac9
--- /dev/null
+++ b/src/app/views/partials/_firstlast_listens.zmpl
@@ -0,0 +1,11 @@
+@args firstlast: *ZmplValue
+
+@zig {
+ const songs = firstlast.items(.array);
+}
+
+
\ No newline at end of file
diff --git a/src/app/views/partials/_header.zmpl b/src/app/views/partials/_header.zmpl
index bf80e53..3f9089d 100644
--- a/src/app/views/partials/_header.zmpl
+++ b/src/app/views/partials/_header.zmpl
@@ -1,7 +1,11 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/app/views/partials/_history.zmpl b/src/app/views/partials/_history.zmpl
deleted file mode 100644
index e69de29..0000000
diff --git a/src/app/views/partials/_newtable.zmpl b/src/app/views/partials/_newtable.zmpl
new file mode 100644
index 0000000..f9dc618
--- /dev/null
+++ b/src/app/views/partials/_newtable.zmpl
@@ -0,0 +1,75 @@
+@args T: type, table_data: *ZmplValue, columns: T
+
+
+
+
+
+@zig {
+ for (columns) |header| {
+ switch (header) {
+ .song => {
+ Song
+ },
+ .album => {
+ Album
+ },
+ .artist => {
+ Artist
+ },
+ .artistlist => {
+ Artist(s)
+ },
+ .scrobbles => {
+ Scrobbles
+ },
+ .date => {
+ Date
+ }
+ }
+ }
+}
+
+
+
+@zig {
+ const array = table_data.items(.array);
+ for (array) |row| {
+
+ for (columns) |header| {
+ switch (header) {
+ .song => {
+
+ {{row.song.name}}
+
+ },
+ .album => {
+
+ {{row.album.name}}
+
+ },
+ .artist => {
+
+ {{row.artist.name}}
+
+ },
+ .artistlist => {
+
+ @for (row.get("artistlist").?) |artist| {
+ {{artist.name}}
+ }
+
+ },
+ .scrobbles => {
+ {{row.scrobbles}}
+ },
+ .date =>{
+ {{row.date}}
+ }
+ }
+ }
+
+ }
+}
+
+
+
\ No newline at end of file
diff --git a/src/app/views/partials/_random.zmpl b/src/app/views/partials/_random.zmpl
deleted file mode 100644
index e69de29..0000000
diff --git a/src/app/views/partials/_recent.zmpl b/src/app/views/partials/_recent.zmpl
deleted file mode 100644
index e69de29..0000000
diff --git a/src/app/views/partials/_table.zmpl b/src/app/views/partials/_table.zmpl
deleted file mode 100644
index 8ea394d..0000000
--- a/src/app/views/partials/_table.zmpl
+++ /dev/null
@@ -1,18 +0,0 @@
-@args table_data: *ZmplValue, table_headers: *ZmplValue
-
-
-
-@for (table_headers) |text| {
- {{text}}
-}
-
-
- @for (table_data) |value| {
-
- {{value.track}}
- {{value.artist}}
- {{value.album}}
- {{value.date}}
-
- }
-
\ No newline at end of file
diff --git a/src/app/views/partials/_timescale.zmpl b/src/app/views/partials/_timescale.zmpl
new file mode 100644
index 0000000..24ef925
--- /dev/null
+++ b/src/app/views/partials/_timescale.zmpl
@@ -0,0 +1,20 @@
+@args range: *ZmplValue
+
+
+
+
+
+ Year
+ Scrobbles
+
+
+
+ @for (range) |itm| {
+
+ {{itm.date}}:
+ {{itm.scrobbles}}
+
+ }
+
+
+
\ No newline at end of file
diff --git a/src/app/views/ratings.zig b/src/app/views/ratings.zig
index 8125efd..aecf0dc 100644
--- a/src/app/views/ratings.zig
+++ b/src/app/views/ratings.zig
@@ -16,21 +16,3 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
-
-pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
diff --git a/src/app/views/ratings/delete.zmpl b/src/app/views/ratings/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/ratings/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/ratings/patch.zmpl b/src/app/views/ratings/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/ratings/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/ratings/put.zmpl b/src/app/views/ratings/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/ratings/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/rules.zig b/src/app/views/rules.zig
index a2a222e..eb85a72 100644
--- a/src/app/views/rules.zig
+++ b/src/app/views/rules.zig
@@ -4,101 +4,38 @@ const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}
-
-pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn new(request: *jetzig.Request) !jetzig.View {
- return request.render(.ok);
-}
-
-pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
pub fn post(request: *jetzig.Request) !jetzig.View {
+ const params = try request.params();
+
+ std.log.debug("{s}", .{try params.toJson()});
+
+ var job = try request.job("process_rule");
+
+ _ = try job.params.put("name", params.get("rule-title"));
+ _ = try job.params.put("cond_req", params.get("cond-req"));
+
+ var conditionals = try job.params.put("conditionals", .array);
+ inline for (0..5) |i| {
+ if (!std.mem.eql(u8, "", params.getT(.string, comptime std.fmt.comptimePrint("match-txt{}", .{i})).?)) {
+ //if (params.getT(.string, comptime std.fmt.comptimePrint("match-txt{}", .{i})) != null) {
+ var cond = try conditionals.append(.object);
+ try cond.put("match_on", params.get(comptime std.fmt.comptimePrint("match-on{}", .{i})));
+ try cond.put("match_cond", params.get(comptime std.fmt.comptimePrint("match-cond{}", .{i})));
+ try cond.put("match_txt", params.get(comptime std.fmt.comptimePrint("match-txt{}", .{i})));
+ }
+ }
+
+ var actions = try job.params.put("actions", .array);
+ inline for (0..5) |i| {
+ if (!std.mem.eql(u8, "", params.getT(.string, comptime std.fmt.comptimePrint("action-txt{}", .{i})).?)) {
+ //if (params.getT(.string, comptime std.fmt.comptimePrint("action-txt{}", .{i})) != null) {
+ var act = try actions.append(.object);
+ try act.put("action", params.get(comptime std.fmt.comptimePrint("action{}", .{i})));
+ try act.put("action_on", params.get(comptime std.fmt.comptimePrint("action-on{}", .{i})));
+ try act.put("action_txt", params.get(comptime std.fmt.comptimePrint("action-txt{}", .{i})));
+ }
+ }
+ try job.schedule();
+
return request.render(.created);
}
-
-pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-
-test "index" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/rules", .{});
- try response.expectStatus(.ok);
-}
-
-test "get" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/rules/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "new" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/rules/new", .{});
- try response.expectStatus(.ok);
-}
-
-test "edit" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/rules/example-id/edit", .{});
- try response.expectStatus(.ok);
-}
-
-test "post" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.POST, "/rules", .{});
- try response.expectStatus(.created);
-}
-
-test "put" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.PUT, "/rules/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "patch" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.PATCH, "/rules/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "delete" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.DELETE, "/rules/example-id", .{});
- try response.expectStatus(.ok);
-}
diff --git a/src/app/views/rules/delete.zmpl b/src/app/views/rules/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/rules/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/rules/edit.zmpl b/src/app/views/rules/edit.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/rules/edit.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/rules/index.zmpl b/src/app/views/rules/index.zmpl
index 76457d0..5661f97 100644
--- a/src/app/views/rules/index.zmpl
+++ b/src/app/views/rules/index.zmpl
@@ -1,3 +1,62 @@
-
- Content goes here
-
+
+
+
+
+
+@partial partials/header
+Rules
+Rules allow you change the default Scrobble import behavior based on provided criteria.
+Add a rule below.
+
+
+
+Current rules:
+
+
\ No newline at end of file
diff --git a/src/app/views/rules/new.zmpl b/src/app/views/rules/new.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/rules/new.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/rules/patch.zmpl b/src/app/views/rules/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/rules/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/rules/put.zmpl b/src/app/views/rules/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/rules/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/scrobbles.zig b/src/app/views/scrobbles.zig
index ebda828..754a7c8 100644
--- a/src/app/views/scrobbles.zig
+++ b/src/app/views/scrobbles.zig
@@ -1,64 +1,12 @@
const std = @import("std");
const jetzig = @import("jetzig");
+const queries = @import("../../queries.zig");
pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
- var scrobbles_view = try root.put("scrobbles", .array);
- const query = jetzig.database.Query(.Scrobble)
- .select(.{ .id, .date })
- .include(.song, .{ .select = .{ .id, .name } })
- .include(.album, .{ .select = .{ .id, .name } })
- .include(.scrobbleartists, .{ .select = .{.artist_id} })
- .orderBy(.{ .date = .desc });
- const scrobbles = try request.repo.all(query);
- for (scrobbles) |scrobble| {
- var scrobble_view = try scrobbles_view.append(.object);
- var artist_infos = try scrobble_view.put("artist_info", .array);
- for (scrobble.scrobbleartists) |artist| {
- var artist_info = try artist_infos.append(.object);
- const artist_data = try jetzig.database.Query(.Artist)
- .find(artist.artist_id)
- .select(.{ .id, .name })
- .execute(request.repo);
- try artist_info.put("name", artist_data.?.name);
- try artist_info.put("id", artist_data.?.id);
- }
+ const scrobbles = try queries.entityQueryResult(request, queries.loadQuery(.scrobble, .entities), .{});
+ try root.put("scrobbles", scrobbles);
- try scrobble_view.put("song_name", scrobble.song.name);
- try scrobble_view.put("song_id", scrobble.song.id);
- try scrobble_view.put("album_name", scrobble.album.name);
- try scrobble_view.put("album_id", scrobble.album.id);
- try scrobble_view.put("date", scrobble.date);
- }
- return request.render(.ok);
-}
-
-pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = id;
- _ = data;
- return request.render(.ok);
-}
-
-pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- return request.render(.created);
-}
-
-pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
return request.render(.ok);
}
diff --git a/src/app/views/scrobbles/delete.zmpl b/src/app/views/scrobbles/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/scrobbles/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/scrobbles/get.zmpl b/src/app/views/scrobbles/get.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/scrobbles/get.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/scrobbles/index.zmpl b/src/app/views/scrobbles/index.zmpl
index 377f50f..c3d6759 100644
--- a/src/app/views/scrobbles/index.zmpl
+++ b/src/app/views/scrobbles/index.zmpl
@@ -1,42 +1,15 @@
+@zig {
+ const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
+ const columns: ColumnChoices = &.{.song, .artistlist, .album, .date};
+}
+
-
@partial partials/header
Scrobbles
-
-
-
+@partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns)
\ No newline at end of file
diff --git a/src/app/views/scrobbles/patch.zmpl b/src/app/views/scrobbles/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/scrobbles/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/scrobbles/post.zmpl b/src/app/views/scrobbles/post.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/scrobbles/post.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/scrobbles/put.zmpl b/src/app/views/scrobbles/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/scrobbles/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/search/delete.zmpl b/src/app/views/search/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/search/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/search/edit.zmpl b/src/app/views/search/edit.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/search/edit.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/search/get.zmpl b/src/app/views/search/get.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/search/get.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/search/index.zmpl b/src/app/views/search/index.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/search/index.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/search/new.zmpl b/src/app/views/search/new.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/search/new.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/search/patch.zmpl b/src/app/views/search/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/search/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/search/post.zmpl b/src/app/views/search/post.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/search/post.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/search/put.zmpl b/src/app/views/search/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/search/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/songs.zig b/src/app/views/songs.zig
index 6c134b1..b634534 100644
--- a/src/app/views/songs.zig
+++ b/src/app/views/songs.zig
@@ -1,129 +1,40 @@
const std = @import("std");
const jetzig = @import("jetzig");
+const queries = @import("../../queries.zig");
pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
- var songs_view = try root.put("songs", .array);
- const songs = try jetzig.database.Query(.Song)
- .select(.{ .id, .name })
- .include(.songartists, .{ .select = .{.artist_id} })
- .include(.scrobbles, .{ .select = .{.id} })
- .orderBy(.{ .name = .asc })
- .all(request.repo);
- for (songs) |song| {
- var song_view = try songs_view.append(.object);
+ const htmx_query = (try request.queryParams()).getT(.string, "s");
+
+ try root.put("htmx", htmx_query != null);
+
+ const songs = if (htmx_query) |name|
+ try queries.entityQueryResult(request, queries.loadQuery(.song, .entities_by_name), .{name})
+ else
+ try queries.entityQueryResult(request, queries.loadQuery(.song, .entities), .{});
+
+ try root.put("songs", songs);
- var artist_infos = try song_view.put("artist_info", .array);
- for (song.songartists) |artist| {
- var artist_info = try artist_infos.append(.object);
- const artist_data = try jetzig.database.Query(.Artist)
- .find(artist.artist_id)
- .select(.{ .id, .name })
- .execute(request.repo);
- try artist_info.put("name", artist_data.?.name);
- try artist_info.put("id", artist_data.?.id);
- }
- try song_view.put("name", song.name);
- try song_view.put("url", song.id);
- try song_view.put("scrobbles", (song.scrobbles).len);
- }
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
+ var root = try request.data(.object);
+
+ 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, .get_scrobbles), .{id});
+ try root.put("scrobbles", scrobbles);
+
+ 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);
+
+ const timescale = try queries.entityQueryResult(request, queries.loadQuery(.song, .timescale), .{id});
+ try root.put("yearly", timescale);
return request.render(.ok);
}
-
-pub fn new(request: *jetzig.Request) !jetzig.View {
- return request.render(.ok);
-}
-
-pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn post(request: *jetzig.Request) !jetzig.View {
- return request.render(.created);
-}
-
-pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View {
- _ = id;
- return request.render(.ok);
-}
-
-test "index" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/song", .{});
- try response.expectStatus(.ok);
-}
-
-test "get" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/song/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "new" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/song/new", .{});
- try response.expectStatus(.ok);
-}
-
-test "edit" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.GET, "/song/example-id/edit", .{});
- try response.expectStatus(.ok);
-}
-
-test "post" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.POST, "/song", .{});
- try response.expectStatus(.created);
-}
-
-test "put" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.PUT, "/song/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "patch" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.PATCH, "/song/example-id", .{});
- try response.expectStatus(.ok);
-}
-
-test "delete" {
- var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
- defer app.deinit();
-
- const response = try app.request(.DELETE, "/song/example-id", .{});
- try response.expectStatus(.ok);
-}
diff --git a/src/app/views/songs/delete.zmpl b/src/app/views/songs/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/songs/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/songs/edit.zmpl b/src/app/views/songs/edit.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/songs/edit.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/songs/get.zmpl b/src/app/views/songs/get.zmpl
index 76457d0..1ceddef 100644
--- a/src/app/views/songs/get.zmpl
+++ b/src/app/views/songs/get.zmpl
@@ -1,3 +1,32 @@
-
-
Content goes here
+@zig {
+ const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
+ const columns: ColumnChoices = &.{.song, .artistlist, .album, .date};
+}
+
+
+
+
+
+
+@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)
+
+
+
Rating
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/views/songs/index.zmpl b/src/app/views/songs/index.zmpl
index a0a1128..862b753 100644
--- a/src/app/views/songs/index.zmpl
+++ b/src/app/views/songs/index.zmpl
@@ -1,40 +1,17 @@
+@zig {
+ const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
+ const columns: ColumnChoices = &.{.song, .album, .artistlist, .scrobbles};
+}
+
-
+ @if (! $.htmx)
@partial partials/header
Songs
-
-
-
-Name
-Artists(s)
-Scrobbles
-
-
-
-@for (.songs) |song| {
-
- {{song.name}}
-
- @for (song.get("artist_info").?) |ai| {
- {{ai.name}}
- }
-
- {{song.scrobbles}}
-
-}
-
-
-
-
+ @end
+@partial partials/newtable(T: ColumnChoices, table_data: .songs, columns: columns)
\ No newline at end of file
diff --git a/src/app/views/songs/new.zmpl b/src/app/views/songs/new.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/songs/new.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/songs/patch.zmpl b/src/app/views/songs/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/songs/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/songs/post.zmpl b/src/app/views/songs/post.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/songs/post.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/songs/put.zmpl b/src/app/views/songs/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/songs/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/upload.zig b/src/app/views/upload.zig
index 53f3b27..f712ba4 100644
--- a/src/app/views/upload.zig
+++ b/src/app/views/upload.zig
@@ -1,135 +1,158 @@
const std = @import("std");
const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery;
-const ScrobbleTypes = @import("../../types.zig");
const zeit = @import("zeit");
+const rules = @import("../../apply_rule.zig");
+const Data = @import("../../types.zig");
+const Utils = @import("../../date_fmt.zig");
+const Client = std.http.Client;
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
-pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
pub fn post(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
- if (try request.file("upload")) |file| {
- const params = try request.params();
- const source = try std.fmt.parseInt(u8, params.get("t").?.string.value, 10); // This param is required in HTML
- const before_limiter: bool = if (params.get("bbool")) |_| true else false;
- const after_limiter: bool = if (params.get("abool")) |_| true else false;
+ const params = try request.params();
+ const rule_file = try (std.fs.cwd().openFile("rules.json", .{ .mode = .read_only }) catch |err| switch (err) {
+ error.FileNotFound => std.fs.cwd().createFile("rules.json", .{ .read = true }),
+ else => err,
+ });
- var scrobbles_view = try root.put("scrobbles", .array);
- var job = try request.job("process_scrobbles");
- var scrobbles_data = try job.params.put("scrobbles", .array);
+ defer rule_file.close();
+ const rule_file_content = try rule_file.readToEndAlloc(request.allocator, 16_000_000);
+ const rule_list = std.json.parseFromSliceLeaky([]Data.Rule, request.allocator, rule_file_content, .{}) catch null;
+ var job = try request.job("process_scrobbles2");
+ const source = params.getT(.integer, "t").?; // This param is required in HTML
- var skipped_tracks: u64 = 0;
- var limited_tracks: u64 = 0;
+ const latest_date = if (params.getT(.boolean, "adv-opt")) |_| blk: {
+ const date = params.getT(.string, "latest-date").?;
+ break :blk try zeit.Time.fromISO8601(date);
+ } else (try zeit.instant(.{ .source = .now })).time();
- // 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| {
- // We can short-circuit on the limiter bools
- if ((before_limiter or after_limiter) and (scrobble.date > before_limiting_date or scrobble.date < after_limiting_date)) continue :appends;
- var value = try scrobbles_data.append(.object);
+ const earliest_date = if (params.getT(.boolean, "adv-opt")) |_| blk: {
+ const date = params.getT(.string, "earliest-date").?;
+ break :blk try zeit.Time.fromISO8601(date);
+ } else (try zeit.instant(.{ .source = .{ .unix_timestamp = 0 } })).time();
- // 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(scrobble, f.name)));
- }
- // Note sure why this works for ZMPL, but not for jobs.
- 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 (try zeit.instant(.{})).time();
- const after_limiting_date: zeit.Time = if (after_limiter) (try zeit.Time.fromISO8601(params.get("a").?.string.value)) else (try zeit.instant(.{ .source = .{ .unix_nano = 0 } })).time();
- 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.ms_played < 30_000 and (scrobble.reason_end == null or !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;
- }
+ const earliest_timestamp = earliest_date.instant().unixTimestamp();
+ const latest_timestamp = latest_date.instant().unixTimestamp();
- 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;
- }
+ var view_params = try root.put("scrobbles", .array);
- // 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 };
+ const imported_scrobbles = switch (source) {
+ 0, 1 => (try std.json.parseFromSliceLeaky(Data.ScrobbleArray, request.allocator, if (try request.file("upload")) |file| file.content else unreachable, .{ .ignore_unknown_fields = true })).scrobbles,
+ 2 => blk: {
+ const user_agent: []const u8 = "Zuletzt/0.0.1";
+ var client = Client{ .allocator = request.allocator };
+ var lastfm_response_buffer = std.ArrayList(u8).init(request.allocator);
+ var scrobble_buffer = std.ArrayList(Data.Scrobble).init(request.allocator);
- var value = try scrobbles_data.append(.object);
+ const username = if (params.getT(.string, "username")) |un| un else "VAOTM";
- // 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,
+ const mp_query: []const u8 = try std.fmt.allocPrint(request.allocator, "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user={s}&api_key=b0c410a48a6078a651e0832699e3cd41&from={}&to={}&page={}&limit=1000&format=json", .{ username, earliest_timestamp, latest_timestamp, 0 });
+ const mp_r = try client.fetch(.{ .response_storage = .{ .dynamic = &lastfm_response_buffer }, .location = .{ .url = mp_query }, .method = .GET, .headers = .{ .user_agent = .{ .override = user_agent } } });
+ std.log.debug("Max page query: {}", .{mp_r});
+ const parsed_mp_response = try std.json.parseFromSliceLeaky(Data.LastFMWeb, request.allocator, (try lastfm_response_buffer.toOwnedSlice()), .{ .ignore_unknown_fields = true });
+
+ var page: usize = 1;
+ const max_pages: usize = try std.fmt.parseInt(usize, parsed_mp_response.recenttracks.@"@attr".totalPages, 10);
+
+ while (true) : (page += 1) {
+ if (page > max_pages) break;
+ const query: []const u8 = try std.fmt.allocPrint(request.allocator, "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user={s}&api_key=b0c410a48a6078a651e0832699e3cd41&from={}&to={}&page={}&limit=1000&format=json", .{ username, earliest_timestamp, latest_timestamp, page });
+ const r = try client.fetch(.{ .response_storage = .{ .dynamic = &lastfm_response_buffer }, .location = .{ .url = query }, .method = .GET, .headers = .{ .user_agent = .{ .override = user_agent } } });
+ std.log.debug("{}: {}", .{ page, r });
+ const response_string = try lastfm_response_buffer.toOwnedSlice();
+ const parsed_lastfm_response = try std.json.parseFromSliceLeaky(Data.ScrobbleArray, request.allocator, response_string, .{ .ignore_unknown_fields = true });
+ try scrobble_buffer.appendSlice(parsed_lastfm_response.scrobbles);
+ }
+
+ break :blk scrobble_buffer.items;
+ },
+ else => unreachable,
+ };
+
+ var artists = try job.params.put("artists", .object);
+ var albums = try job.params.put("albums", .object);
+ var tracks = try job.params.put("tracks", .object);
+ var artistalbums = try job.params.put("artistalbums", .object);
+ var albumsongs = try job.params.put("albumsongs", .object);
+ var albumsongsartists = try job.params.put("albumsongsartists", .object);
+
+ for (imported_scrobbles) |scrobble| {
+ if (scrobble.date > latest_timestamp * 1_000_000 or scrobble.date < earliest_timestamp * 1_000_000) continue;
+ const complete_scrobble = if (rule_list) |rl| try rules.applyScrobbleRule(request.allocator, scrobble, rl) else scrobble;
+
+ const row = try Utils.scrobbleToRow(request.allocator, complete_scrobble);
+
+ try view_params.append(row);
+ var stored_artist_hashes = std.ArrayList(u64).init(request.allocator);
+
+ var album_hash_string = std.ArrayList(u8).init(request.allocator);
+ for (complete_scrobble.artists_album) |artist| {
+ try album_hash_string.appendSlice(artist);
+ const artist_hash = std.hash.Fnv1a_64.hash(artist);
+ try stored_artist_hashes.append(artist_hash);
+ const signed_hash_string = try std.fmt.allocPrint(request.allocator, "{}", .{@as(i64, @bitCast(artist_hash))});
+ if (artists.get(signed_hash_string) == null) try artists.put(signed_hash_string, artist);
}
- 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);
- try upload_table.append("Track");
- try upload_table.append("Artist");
- try upload_table.append("Album");
- try upload_table.append("Date");
+ try album_hash_string.appendSlice(complete_scrobble.album);
+ const album_hash = std.hash.Fnv1a_64.hash(album_hash_string.items);
+ const signed_album_hash_string = try std.fmt.allocPrint(request.allocator, "{}", .{@as(i64, @bitCast(album_hash))});
+ if (albums.get(signed_album_hash_string) == null) try albums.put(signed_album_hash_string, complete_scrobble.album);
+
+ for (stored_artist_hashes.items) |artist_hash| {
+ const artistalbum_hash = pair(artist_hash, album_hash);
+ const signed_artistalbums_hash_string = try std.fmt.allocPrint(request.allocator, "{}", .{@as(i64, @bitCast(artistalbum_hash))});
+ if (tracks.get(signed_artistalbums_hash_string) == null) {
+ var artistalbum = try artistalbums.put(signed_artistalbums_hash_string, .object);
+ try artistalbum.put("artist", @as(i64, @bitCast(artist_hash)));
+ try artistalbum.put("album", @as(i64, @bitCast(album_hash)));
+ }
+ }
+
+ var track_hash_string = std.ArrayList(u8).init(request.allocator);
+ try track_hash_string.appendSlice(complete_scrobble.album);
+ try track_hash_string.appendSlice(complete_scrobble.track);
+ const track_hash = std.hash.Fnv1a_64.hash(track_hash_string.items);
+ const signed_track_hash_string = try std.fmt.allocPrint(request.allocator, "{}", .{@as(i64, @bitCast(track_hash))});
+ if (tracks.get(signed_track_hash_string) == null) try tracks.put(signed_track_hash_string, complete_scrobble.track);
+
+ const albumsong_hash = pair(album_hash, track_hash);
+ const signed_albumsong_hash_string = try std.fmt.allocPrint(request.allocator, "{}", .{@as(i64, @bitCast(albumsong_hash))});
+ if (albumsongs.get(signed_albumsong_hash_string)) |albumsong| {
+ var albumsong_scrobbles = albumsong.get("scrobbles");
+ try albumsong_scrobbles.?.append(complete_scrobble.date);
+ } else {
+ var albumsong = try albumsongs.put(signed_albumsong_hash_string, .object);
+ try albumsong.put("album", @as(i64, @bitCast(album_hash)));
+ try albumsong.put("song", @as(i64, @bitCast(track_hash)));
+ var albumsong_scrobbles = try albumsong.put("scrobbles", .array);
+ try albumsong_scrobbles.append(complete_scrobble.date);
+ }
+
+ for (complete_scrobble.artists_track) |artist| {
+ const artist_hash = std.hash.Fnv1a_64.hash(artist);
+ const signed_artist_hash_string = try std.fmt.allocPrint(request.allocator, "{}", .{@as(i64, @bitCast(artist_hash))});
+ if (artists.get(signed_artist_hash_string) == null) try artists.put(signed_artist_hash_string, artist);
+ const albumsongsartist_hash = pair(albumsong_hash, artist_hash);
+ const signed_albumsongsartist_hash_string = try std.fmt.allocPrint(request.allocator, "{}", .{albumsongsartist_hash});
+ if (albumsongsartists.get(signed_albumsongsartist_hash_string) == null) {
+ var albumsongartist = try albumsongsartists.put(signed_albumsongsartist_hash_string, .object);
+ try albumsongartist.put("albumsong", @as(i64, @bitCast(albumsong_hash)));
+ try albumsongartist.put("artist", @as(i64, @bitCast(artist_hash)));
+ }
+ }
+ }
+ try job.schedule();
return request.render(.created);
}
-pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
-}
-
-pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
- _ = data;
- _ = id;
- return request.render(.ok);
+fn pair(a: u64, b: u64) u64 {
+ return @divFloor((a +% b) *% (a +% b +% 1) +% b, 2);
}
diff --git a/src/app/views/upload/delete.zmpl b/src/app/views/upload/delete.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/upload/delete.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/upload/get.zmpl b/src/app/views/upload/get.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/upload/get.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/upload/index.zmpl b/src/app/views/upload/index.zmpl
index 9043a5b..420e08c 100644
--- a/src/app/views/upload/index.zmpl
+++ b/src/app/views/upload/index.zmpl
@@ -9,13 +9,18 @@
diff --git a/src/app/views/upload/patch.zmpl b/src/app/views/upload/patch.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/upload/patch.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/app/views/upload/post.zmpl b/src/app/views/upload/post.zmpl
index 176f094..91c5347 100644
--- a/src/app/views/upload/post.zmpl
+++ b/src/app/views/upload/post.zmpl
@@ -1,15 +1,16 @@
+@zig {
+ const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
+ const columns: ColumnChoices = &.{.song, .artistlist, .album, .date};
+}
+
-
@partial partials/header
File Uploaded Successfully
-
Scrobbles Added
-
-@partial partials/table(table_data: .scrobbles, table_headers: .upload_table, table_context: .context)
-
+@partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns)
\ No newline at end of file
diff --git a/src/app/views/upload/put.zmpl b/src/app/views/upload/put.zmpl
deleted file mode 100644
index 76457d0..0000000
--- a/src/app/views/upload/put.zmpl
+++ /dev/null
@@ -1,3 +0,0 @@
-
- Content goes here
-
diff --git a/src/apply_rule.zig b/src/apply_rule.zig
new file mode 100644
index 0000000..fc4c0bc
--- /dev/null
+++ b/src/apply_rule.zig
@@ -0,0 +1,71 @@
+const std = @import("std");
+const Rule = @import("./types.zig").Rule;
+const Data = @import("./types.zig");
+
+// Wrapper for containsAtLeast to make the switch below to work
+fn containsWrapper(haystack: []const u8, needle: []const u8) bool {
+ return std.mem.containsAtLeast(u8, haystack, 1, needle);
+}
+
+fn eqlWrapper(haystack: []const u8, needle: []const u8) bool {
+ return std.mem.eql(u8, haystack, needle);
+}
+
+pub fn applyScrobbleRule(allocator: std.mem.Allocator, scrobble: Data.Scrobble, rules: []Rule) !Data.Scrobble {
+ var output_scrobble = scrobble;
+
+ for (rules) |rule| {
+ var match_found: bool = switch (rule.cond_req) {
+ .any => false,
+ .all => true,
+ };
+ for (rule.conditionals) |cond| {
+ const match_fn: *const fn ([]const u8, []const u8) bool = switch (cond.match_cond) {
+ .is => eqlWrapper,
+ .contains => containsWrapper,
+ };
+ switch (rule.cond_req) {
+ .any => switch (cond.match_on) {
+ inline .album, .track => |on| match_found = match_found or match_fn(@field(scrobble, @tagName(on)), cond.match_txt),
+ inline .artists_album, .artists_track => |on| {
+ for (@field(scrobble, @tagName(on))) |artist| match_found = match_found or match_fn(artist, cond.match_txt);
+ },
+ },
+ .all => switch (cond.match_on) {
+ inline .album, .track => |on| match_found = match_found and match_fn(@field(scrobble, @tagName(on)), cond.match_txt),
+ inline .artists_album, .artists_track => |on| {
+ for (@field(scrobble, @tagName(on))) |artist| match_found = match_found and match_fn(artist, cond.match_txt);
+ },
+ },
+ }
+ }
+ if (match_found) {
+ for (rule.actions) |act| {
+ switch (act.action) {
+ .add => {
+ var al = std.ArrayList([]const u8).init(allocator);
+ switch (act.action_on) {
+ .album, .track => unreachable,
+ inline else => |on| {
+ try al.appendSlice(@field(output_scrobble, @tagName(on)));
+ try al.append(act.action_txt);
+ const list = try al.toOwnedSlice();
+ @field(output_scrobble, @tagName(on)) = list;
+ },
+ }
+ },
+ .replace => switch (act.action_on) {
+ inline .album, .track => |on| @field(output_scrobble, @tagName(on)) = act.action_txt,
+ inline .artists_album, .artists_track => |on| {
+ const artist = try allocator.alloc([]const u8, 1);
+ artist[0] = act.action_txt;
+ @field(output_scrobble, @tagName(on)) = artist;
+ },
+ },
+ }
+ }
+ }
+ }
+
+ return output_scrobble;
+}
diff --git a/src/date_fmt.zig b/src/date_fmt.zig
new file mode 100644
index 0000000..7e44da7
--- /dev/null
+++ b/src/date_fmt.zig
@@ -0,0 +1,35 @@
+const std = @import("std");
+const zeit = @import("zeit");
+const Data = @import("types.zig");
+
+pub fn dateFmt(allocator: std.mem.Allocator, epoch: i64) ![]const u8 {
+ var date = std.ArrayList(u8).init(allocator);
+ try (try zeit.instant(.{ .source = .{ .unix_timestamp = @divFloor(epoch, 1_000_000) } })).time().strftime(date.writer(), "%d %b %Y, %H:%M");
+ return date.items;
+}
+
+pub fn dateCompare(self: *[]const u8, earliest: []const u8, latest: []const u8) bool {
+ const range = if (self.len == 19) 4 else 2;
+ const time = std.fmt.parseInt(u32, self[0..range], 10) catch 0;
+ const etime = std.fmt.parseInt(u32, earliest[0..range], 10) catch 0;
+ const ltime = std.fmt.parseInt(u32, latest[0..range], 10) catch 0;
+
+ if (time != etime and time != ltime) {
+ return (time > etime and time < ltime);
+ } else {
+ return dateCompare(self[range + 1 ..], earliest[range + 1 ..], latest[range + 1 ..]);
+ }
+}
+
+pub fn scrobbleToRow(allocator: std.mem.Allocator, scrobble: Data.Scrobble) !Data.TableRow {
+ var artistlist = std.ArrayList(Data.HyperlinkData).init(allocator);
+ for (scrobble.artists_track) |a| {
+ try artistlist.append(Data.HyperlinkData{ .name = a, .id = 0 });
+ }
+ return Data.TableRow{
+ .song = .{ .name = scrobble.track, .id = 0 },
+ .artistlist = artistlist.items,
+ .album = .{ .name = scrobble.album, .id = 0 },
+ .date = try dateFmt(allocator, scrobble.date),
+ };
+}
diff --git a/src/main.zig b/src/main.zig
index 1cab920..cdc9bca 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -14,10 +14,13 @@ pub const jetzig_options = struct {
// htmx middleware skips layouts when `HX-Target` header is present and issues
// `HX-Redirect` instead of a regular HTTP redirect when `request.redirect` is called.
jetzig.middleware.HtmxMiddleware,
- // Demo middleware included with new projects. Remove once you are familiar with Jetzig's
- // middleware system.
+ // Demo middleware included with new projects. Remove once you are familiar with Jetzig's
+ // middleware system.
};
+ // This is currently the largest number of parameters one can have in a rule
+ pub const max_multipart_form_fields = 42;
+
// Maximum bytes to allow in request body.
pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 24);
diff --git a/src/ordinal_fmt.zig b/src/ordinal_fmt.zig
new file mode 100644
index 0000000..6b126ab
--- /dev/null
+++ b/src/ordinal_fmt.zig
@@ -0,0 +1,16 @@
+const std = @import("std");
+//const log = std.math.log10;
+
+pub fn ordinalFmt(allocator: std.mem.Allocator, ord: isize) ![]const u8 {
+ const buff = try allocator.alloc(u8, 3 + @as(usize, @intFromFloat(@floor(@log10(@as(f64, @floatFromInt(ord)))))));
+ const ind: []const u8 = switch (@mod(ord, 100)) {
+ 11, 12, 13 => "th",
+ else => switch (@mod(ord, 10)) {
+ 1 => "st",
+ 2 => "nd",
+ 3 => "rd",
+ else => "th",
+ },
+ };
+ return std.fmt.bufPrint(buff, "{}{s}", .{ ord, ind });
+}
diff --git a/src/queries.zig b/src/queries.zig
new file mode 100644
index 0000000..fcc5f07
--- /dev/null
+++ b/src/queries.zig
@@ -0,0 +1,440 @@
+// Probably the worst code in the project
+const jetzig = @import("jetzig");
+const TableRow = @import("types.zig").TableRow;
+const HyperlinkData = @import("types.zig").HyperlinkData;
+const std = @import("std");
+
+pub fn entityQueryResult(request: *jetzig.Request, query: GeneratedQuery, args: anytype) !*jetzig.Data.Value {
+ //var result = try request.repo.executeSql(query.query, args);
+ //
+ var result = try request.repo.connection.?.postgresql.connection.queryOpts(query.query, args, .{ .allocator = request.allocator, .column_names = true });
+
+ defer result.deinit();
+
+ var Data = jetzig.Data.init(request.allocator);
+
+ var artist_list = std.ArrayList(HyperlinkData).init(request.allocator);
+
+ if (query.query_type == .entity_info) {
+ var out: *jetzig.Data.Value = try Data.object();
+ const entity = try (try result.next()).?.to(EntityInfoResult, .{ .dupe = true, .allocator = request.allocator, .map = .name });
+ try out.put("entity_info", entity);
+ try result.drain();
+ return out.get("entity_info").?;
+ }
+
+ var out: *jetzig.Data.Value = try Data.array();
+ var mapper = result.mapper(UnifiedResult, .{ .dupe = true, .allocator = request.allocator });
+
+ blk: while (try mapper.next()) |entity| {
+ if (entity.artist_id) |_| {
+ const last_artist = artist_list.getLastOrNull();
+ try artist_list.append(.{ .name = entity.artist_name.?, .id = entity.artist_id.? });
+ if (last_artist) |la| {
+ if (la.id == entity.artist_id) continue :blk;
+ }
+ }
+
+ try out.append(TableRow{
+ .artist = if (entity.artist_id) |_| .{ .id = entity.artist_id.?, .name = entity.artist_name.? } else null,
+ .album = if (entity.album_id) |_| .{ .id = entity.album_id.?, .name = entity.album_name.? } else null,
+ .song = if (entity.song_id) |_| .{ .id = entity.song_id.?, .name = entity.song_name.? } else null,
+ .artistlist = if (artist_list.getLastOrNull()) |_| try artist_list.toOwnedSlice() else null,
+ .scrobbles = if (entity.scrobbles) |scrobbles| scrobbles else null,
+ .date = if (entity.date) |date| date else null,
+ });
+ }
+
+ return out;
+}
+
+const EntityType = enum { scrobble, song, album, artist };
+const QueryTypeEnum = enum { firstlast, timescale, entities, get_songs, get_albums, get_scrobbles, appears, entity_info, datestreak, entities_by_name };
+
+const GeneratedQuery = struct {
+ entity: EntityType,
+ query_type: QueryTypeEnum,
+ query: []const u8,
+};
+
+const UnifiedResult = struct {
+ album_name: ?[]const u8 = null,
+ album_id: ?i64 = null,
+ song_name: ?[]const u8 = null,
+ song_id: ?i64 = null,
+ artist_name: ?[]const u8 = null,
+ artist_id: ?i64 = null,
+ scrobbles: ?i64 = null,
+ date: ?[]const u8 = null,
+};
+
+const EntityInfoResult = struct {
+ album_name: ?[]const u8 = null,
+ album_id: ?i64 = null,
+ song_name: ?[]const u8 = null,
+ song_id: ?i64 = null,
+ artist_name: ?[]const u8 = null,
+ artist_id: ?i64 = null,
+ scrobbles: ?i64 = null,
+ date: ?[]const u8 = null,
+ rank: []const u8,
+ song_num: ?i64 = null,
+ album_num: ?i64 = null,
+};
+
+pub fn loadQuery(entity: EntityType, query_type: QueryTypeEnum) GeneratedQuery {
+ return GeneratedQuery{
+ .entity = entity,
+ .query_type = query_type,
+ .query = switch (query_type) {
+ .firstlast =>
+ //.ResultType = FirstlastResult,
+ switch (entity) {
+ .scrobble => unreachable,
+ .song =>
+ \\(SELECT songs.name AS song_name, songs.id AS song_id, TO_CHAR(scrobbles.datetime,'YYYY-MM-DD HH24:MI:SS') AS date
+ \\FROM albumsongs
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\INNER JOIN scrobbles ON albumsongs.id = scrobbles.albumsong
+ \\WHERE albumsongs.song_id = $1
+ \\ORDER BY scrobbles.datetime ASC
+ \\LIMIT 1)
+ \\
+ \\UNION ALL
+ \\
+ \\(SELECT songs.name AS song_name, songs.id AS song_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date
+ \\FROM albumsongs
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\INNER JOIN scrobbles ON albumsongs.id = scrobbles.albumsong
+ \\WHERE albumsongs.song_id = $1
+ \\ORDER BY scrobbles.datetime DESC
+ \\LIMIT 1)
+ ,
+
+ .album =>
+ \\(SELECT songs.name AS song_name, songs.id AS song_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date
+ \\FROM albumsongs
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\INNER JOIN scrobbles ON albumsongs.id = scrobbles.albumsong
+ \\INNER JOIN albums ON albums.id = albumsongs.album_id
+ \\WHERE albums.id = $1
+ \\ORDER BY scrobbles.datetime ASC
+ \\LIMIT 1)
+ \\
+ \\UNION ALL
+ \\
+ \\(SELECT songs.name AS song_name, songs.id AS song_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date
+ \\FROM albumsongs
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\INNER JOIN scrobbles ON albumsongs.id = scrobbles.albumsong
+ \\INNER JOIN albums ON albums.id = albumsongs.album_id
+ \\WHERE albums.id = $1
+ \\ORDER BY scrobbles.datetime DESC
+ \\LIMIT 1)
+ ,
+
+ .artist =>
+ \\(SELECT songs.name AS song_name, songs.id AS song_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date
+ \\FROM albumsongs
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\INNER JOIN scrobbles ON albumsongs.id = scrobbles.albumsong
+ \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id
+ \\WHERE albumsongsartists.artist_id = $1
+ \\ORDER BY scrobbles.datetime ASC
+ \\LIMIT 1)
+ \\
+ \\UNION ALL
+ \\
+ \\(SELECT songs.name AS song_name, songs.id AS song_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date
+ \\FROM albumsongs
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\INNER JOIN scrobbles ON albumsongs.id = scrobbles.albumsong
+ \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id
+ \\WHERE albumsongsartists.artist_id = $1
+ \\ORDER BY scrobbles.datetime DESC
+ \\LIMIT 1)
+ ,
+ },
+
+ .timescale =>
+ //.ResultType = TimescaleResult,
+ switch (entity) {
+ .scrobble => unreachable,
+ .song =>
+ \\SELECT TO_CHAR(date_trunc('year', datetime), 'YYYY') AS date, COUNT(*) as scrobbles
+ \\FROM scrobbles
+ \\INNER JOIN albumsongs ON albumsongs.id = scrobbles.albumsong
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\WHERE songs.id = $1
+ \\GROUP BY date
+ \\ORDER BY date ASC;
+ ,
+
+ .album =>
+ \\SELECT TO_CHAR(date_trunc('year', datetime), 'YYYY') AS date, COUNT(*) as scrobbles
+ \\FROM scrobbles
+ \\INNER JOIN albumsongs ON albumsongs.id = scrobbles.albumsong
+ \\INNER JOIN albums ON albums.id = albumsongs.album_id
+ \\WHERE albums.id = $1
+ \\GROUP BY date
+ \\ORDER BY date ASC;
+ ,
+
+ .artist =>
+ \\SELECT y.year AS date, COALESCE(DT.scrobbles, 0) AS scrobbles
+ \\FROM (SELECT GENERATE_SERIES(2016,date_part('year',now())::int)::text) AS y(year)
+ \\LEFT JOIN (SELECT TO_CHAR(date_trunc('year', datetime), 'YYYY') AS date, COUNT(*) as scrobbles
+ \\FROM scrobbles
+ \\INNER JOIN albumsongs ON albumsongs.id = scrobbles.albumsong
+ \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id
+ \\INNER JOIN artists ON artists.id = albumsongsartists.artist_id
+ \\WHERE artists.id = $1
+ \\GROUP BY date
+ \\ORDER BY date ASC) AS DT
+ \\ON DT.date = y.year;
+ ,
+ },
+
+ .entities =>
+ //.ResultType = EntitiesResult,
+ switch (entity) {
+ .scrobble =>
+ \\SELECT songs.name AS song_name, songs.id AS song_id, albums.name AS album_name, albums.id AS album_id, artists.name AS artist_name, artists.id AS artist_id, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD HH24:MI:SS') AS date
+ \\FROM albumsongs
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\INNER JOIN albums ON albums.id = albumsongs.album_id
+ \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id
+ \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id
+ \\INNER JOIN artists ON artists.id = albumsongsartists.artist_id
+ \\ORDER BY scrobbles.datetime ASC
+ ,
+ .song =>
+ \\SELECT songs.name AS song_name, songs.id AS song_id, albums.name AS album_name, albums.id AS album_id, artists.name AS artist_name, artists.id AS artist_id, COUNT(scrobbles) AS scrobbles
+ \\FROM albumsongs
+ \\INNER JOIN songs ON albumsongs.song_id = songs.id
+ \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id
+ \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id
+ \\INNER JOIN artists ON artists.id = albumsongsartists.artist_id
+ \\INNER JOIN albums ON albums.id = albumsongs.album_id
+ \\GROUP BY songs.id, albums.id, artists.id
+ \\ORDER BY scrobbles DESC, songs.name ASC
+ ,
+ .album =>
+ \\SELECT albums.name AS album_name, albums.id AS album_id, artists.name AS artist_name, artists.id AS artist_id, COUNT(scrobbles) AS scrobbles
+ \\FROM albumsongs
+ \\INNER JOIN albums ON albumsongs.album_id = albums.id
+ \\INNER JOIN scrobbles ON albumsongs.id = scrobbles.albumsong
+ \\INNER JOIN artistalbums ON artistalbums.album_id = albums.id
+ \\INNER JOIN artists ON artists.id = artistalbums.artist_id
+ \\GROUP BY albums.id, artists.id
+ \\ORDER BY scrobbles DESC
+ ,
+ .artist =>
+ \\SELECT artists.name AS artist_name, artists.id AS artist_id, COUNT(scrobbles) AS scrobbles
+ \\FROM albumsongsartists
+ \\INNER JOIN artists ON albumsongsartists.artist_id = artists.id
+ \\INNER JOIN albumsongs ON albumsongsartists.albumsong_id = albumsongs.id
+ \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id
+ \\GROUP BY artists.id
+ \\ORDER BY scrobbles DESC;
+ ,
+ },
+
+ .appears =>
+ // Not sure how I feel about this one
+ switch (entity) {
+ .scrobble, .song, .album => unreachable,
+ .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
+ \\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 albumsongsartists.artist_id = $1 AND artistalbums.artist_id != $1
+ \\GROUP BY albums.id
+ \\ORDER BY scrobbles DESC;
+ ,
+ },
+
+ .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
+ \\INNER JOIN songs ON albumsongs.song_id = songs.id
+ \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id
+ \\WHERE albumsongs.album_id = $1
+ \\GROUP BY songs.id
+ \\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
+ \\INNER JOIN albumsongs ON albumsongs.album_id = albums.id
+ \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id
+ \\WHERE artistalbums.artist_id = $1
+ \\GROUP BY albums.id
+ \\ORDER BY scrobbles DESC
+ ,
+ },
+
+ .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 =>
+ \\SELECT * FROM
+ \\(SELECT *, TO_CHAR(ROW_NUMBER() OVER (ORDER BY scrobbles DESC),'FM99999th') AS rank FROM
+ \\(SELECT songs.name AS song_name, songs.id AS song_id, COUNT(scrobbles) AS scrobbles
+ \\FROM albumsongs
+ \\INNER JOIN songs ON albumsongs.song_id = songs.id
+ \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id
+ \\GROUP BY songs.id))
+ \\WHERE song_id = $1
+ ,
+ .album =>
+ \\SELECT album_name, t.album_id, artists.name AS artist_name, artists.id AS artist_id, song_num, scrobbles, rank FROM
+ \\(SELECT *, TO_CHAR(ROW_NUMBER() OVER (ORDER BY scrobbles DESC),'FM9999th') AS rank FROM
+ \\(SELECT albums.name AS album_name, albums.id AS album_id, COUNT(DISTINCT songs.id) AS song_num, COUNT(scrobbles) AS scrobbles
+ \\FROM albumsongs
+ \\INNER JOIN albums ON albumsongs.album_id = albums.id
+ \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\GROUP BY albums.id)) AS t
+ \\INNER JOIN artistalbums ON artistalbums.album_id = t.album_id
+ \\INNER JOIN artists ON artists.id = artistalbums.artist_id
+ \\WHERE t.album_id = $1
+ ,
+ .artist =>
+ \\SELECT * FROM
+ \\(SELECT *, TO_CHAR(ROW_NUMBER() OVER (ORDER BY scrobbles DESC),'FM9999th') AS rank FROM
+ \\(SELECT artists.name AS artist_name, artists.id AS artist_id, COUNT(scrobbles) AS scrobbles, COUNT(DISTINCT albums.id) AS album_num, COUNT(DISTINCT songs.id) AS song_num
+ \\FROM albumsongsartists
+ \\INNER JOIN artists ON albumsongsartists.artist_id = artists.id
+ \\INNER JOIN albumsongs ON albumsongsartists.albumsong_id = albumsongs.id
+ \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id
+ \\INNER JOIN albums ON albums.id = albumsongs.album_id
+ \\INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\GROUP BY artists.id))
+ \\WHERE artist_id = $1
+ ,
+ },
+
+ .datestreak => switch (entity) {
+ .song =>
+ \\SELECT maxseq AS streak, FORMAT('%s - %s', ds, de) AS date FROM (SELECT MAX(numdays) AS maxseq, ds, de
+ \\FROM (SELECT grp, MIN(datetime::date) AS ds, MAX(datetime::date) AS de,
+ \\COUNT(DISTINCT datetime::date) AS numdays
+ \\FROM (SELECT scrobbles.datetime,
+ \\((datetime::date - '1970-01-01'::date) - DENSE_RANK() OVER (PARTITION BY songs.id ORDER BY datetime::date)) AS grp
+ \\FROM scrobbles INNER JOIN albumsongs ON albumsongs.id = albumsong INNER JOIN songs ON songs.id = albumsongs.song_id
+ \\WHERE songs.id = $1) scrobbles
+ \\GROUP BY grp
+ \\) scrobbles
+ \\GROUP BY ds, de)
+ \\ORDER BY maxseq DESC;
+ ,
+ .artist =>
+ \\SELECT maxseq AS streak, FORMAT('%s - %s' ,ds,de) AS date FROM (SELECT MAX(numdays) AS maxseq, ds, de
+ \\FROM (SELECT grp, MIN(datetime::date) AS ds, MAX(datetime::date) AS de,
+ \\COUNT(DISTINCT datetime::date) AS numdays
+ \\FROM (SELECT scrobbles.datetime,
+ \\((scrobbles.datetime::date - '1970-01-01'::date) - DENSE_RANK() OVER (PARTITION BY artists.id ORDER BY scrobbles.datetime::date)) AS grp
+ \\FROM scrobbles INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = scrobbles.albumsong INNER JOIN artists ON artists.id = albumsongsartists.artist_id
+ \\WHERE artists.id = $1) scrobbles
+ \\GROUP BY grp
+ \\) scrobbles
+ \\GROUP BY ds, de)
+ \\ORDER BY maxseq DESC;
+ ,
+ .album =>
+ \\SELECT maxseq AS streak, FORMAT('%s - %s', ds, de) AS date FROM (SELECT MAX(numdays) AS maxseq, ds, de
+ \\FROM (SELECT grp, MIN(datetime::date) AS ds, MAX(datetime::date) AS de,
+ \\COUNT(DISTINCT datetime::date) AS numdays
+ \\FROM (SELECT scrobbles.datetime,
+ \\((datetime::date - '1970-01-01'::date) - DENSE_RANK() OVER (PARTITION BY albums.id ORDER BY datetime::date)) AS grp FROM scrobbles INNER JOIN albumsongs ON albumsongs.id = albumsong INNER JOIN albums ON albums.id = albumsongs.album_id
+ \\WHERE albums.id = $1) scrobbles
+ \\GROUP BY grp
+ \\) scrobbles
+ \\GROUP BY ds, de)
+ \\ORDER BY maxseq DESC;
+ ,
+ .scrobble => unreachable,
+ },
+ .entities_by_name => switch (entity) {
+ .song =>
+ \\SELECT songs.name AS song_name, songs.id AS song_id, albums.name AS album_name, albums.id AS album_id, artists.name AS artist_name, artists.id AS artist_id, COUNT(scrobbles) AS scrobbles
+ \\FROM albumsongs
+ \\INNER JOIN songs ON albumsongs.song_id = songs.id
+ \\INNER JOIN scrobbles ON scrobbles.albumsong = albumsongs.id
+ \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id
+ \\INNER JOIN artists ON artists.id = albumsongsartists.artist_id
+ \\INNER JOIN albums ON albums.id = albumsongs.album_id
+ \\WHERE LOWER(songs.name) LIKE LOWER($1)
+ \\GROUP BY songs.id, albums.id, artists.id
+ \\ORDER BY songs.name ASC, scrobbles DESC;
+ ,
+ else => unreachable,
+ },
+ },
+ };
+}
diff --git a/src/types.zig b/src/types.zig
index 55bca1c..222d898 100644
--- a/src/types.zig
+++ b/src/types.zig
@@ -1,38 +1,213 @@
+const std = @import("std");
const zeit = @import("zeit");
-pub const LastFMScrobble = struct {
+pub const ImportedScrobbles = union(ScrobbleSources) {
+ LastFMStats: []IgnorantScrobble,
+ LastFMWeb: []LastFMWebScrobble,
+ Spotify: []SpotifyScrobble,
+};
+
+const Litmus = struct {
+ username: ?[]const u8 = null,
+ ts: ?[]const u8 = null,
+ recenttracks: ?struct {
+ track: []LastFMWebScrobble,
+ @"@attr": LastFMWebQueryInfo,
+ } = null,
+};
+
+const ScrobbleSources = enum {
+ LastFMStats,
+ LastFMWeb,
+ Spotify,
+};
+
+pub const IgnorantScrobble = struct {
track: []const u8,
artist: []const u8,
+ album: []const u8 = "Not Provided",
+ //albumId: []const u8,
+ date: i64,
+};
+
+pub const Scrobble = struct {
+ track: []const u8,
+ artists_track: []const []const u8,
album: []const u8 = "",
- date: i128,
+ artists_album: []const []const u8,
+ date: i64,
+};
+
+pub const ScrobbleArray = struct {
+ scrobbles: []Scrobble,
+
+ // This is an abuse of the jsonParse function. I don't like the idea of doing it, but I really like the results
+ // (or at least I will, assuming it works)
+ pub fn jsonParse(allocator: std.mem.Allocator, source: *std.json.Scanner, options: std.json.ParseOptions) !ScrobbleArray {
+ while (try source.peekNextTokenType() != .end_of_document) try source.skipValue();
+ const litmus_test = try std.json.parseFromSliceLeaky(Litmus, allocator, source.input, .{ .ignore_unknown_fields = true });
+
+ if (litmus_test.username != null) { // LastFMStats
+ const lastfm_file = try std.json.parseFromSliceLeaky(LastFMStats, allocator, source.input, options);
+ var scrobble_buffer = try allocator.alloc(Scrobble, lastfm_file.scrobbles.len);
+ for (lastfm_file.scrobbles, 0..lastfm_file.scrobbles.len) |scrobble, i| {
+ const artist = try allocator.alloc([]const u8, 1);
+ artist[0] = scrobble.artist;
+ scrobble_buffer[i] = Scrobble{
+ .album = scrobble.album,
+ .artists_album = artist,
+ .track = scrobble.track,
+ .artists_track = artist,
+ .date = scrobble.date * 1_000,
+ };
+ }
+ return ScrobbleArray{ .scrobbles = scrobble_buffer };
+ }
+
+ if (litmus_test.ts != null) { // Spotify
+ const spotify = try std.json.parseFromSliceLeaky([]SpotifyScrobble, allocator, source.input, options);
+ var scrobble_buffer = try allocator.alloc(Scrobble, spotify.len);
+ for (spotify, 0..spotify.len) |scrobble, i| {
+ const artist = try allocator.alloc([]const u8, 1);
+ artist[0] = scrobble.master_metadata_album_artist_name.?;
+ scrobble_buffer[i] = Scrobble{
+ .album = scrobble.master_metadata_album_album_name.?,
+ .artists_album = artist,
+ .track = scrobble.master_metadata_track_name.?,
+ .artists_track = artist,
+ .date = (zeit.instant(.{ .source = .{ .iso8601 = scrobble.ts } }) catch return error.OutOfMemory).unixTimestamp() * 1_000_000,
+ };
+ }
+ return ScrobbleArray{ .scrobbles = scrobble_buffer };
+ }
+
+ if (litmus_test.recenttracks != null) { // LastFM API
+ const lastfm_web = try std.json.parseFromSliceLeaky(LastFMWeb, allocator, source.input, options);
+ var scrobble_buffer = try allocator.alloc(Scrobble, lastfm_web.recenttracks.track.len);
+ for (lastfm_web.recenttracks.track, 0..lastfm_web.recenttracks.track.len) |scrobble, i| {
+ const artist = try allocator.alloc([]const u8, 1);
+ artist[0] = scrobble.artist.@"#text";
+ scrobble_buffer[i] = Scrobble{
+ .album = if (scrobble.album) |album| album.@"#text" else "Not Provided",
+ .artists_album = artist,
+ .track = scrobble.name,
+ .artists_track = artist,
+ .date = (std.fmt.parseInt(i64, scrobble.date.?.uts, 10) catch return error.OutOfMemory) * 1_000_000,
+ };
+ }
+ return ScrobbleArray{ .scrobbles = scrobble_buffer };
+ }
+
+ return error.UnexpectedToken;
+ }
};
// From lastfmstats.com
-pub const LastFM = struct { username: []const u8, scrobbles: []LastFMScrobble };
+pub const LastFMStats = struct { username: []const u8, scrobbles: []IgnorantScrobble };
// 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 {
ts: []const u8,
- username: []const u8,
- platform: []const u8,
+ //username: []const u8,
+ //platform: []const u8,
ms_played: u64,
- conn_country: []const u8,
- ip_addr_decrypted: ?[]const u8,
- user_agent_decrypted: ?[]const u8,
+ //conn_country: []const u8,
+ //ip_addr_decrypted: ?[]const u8,
+ //user_agent_decrypted: ?[]const u8,
master_metadata_track_name: ?[]const u8,
master_metadata_album_artist_name: ?[]const u8,
master_metadata_album_album_name: ?[]const u8,
- spotify_track_uri: ?[]const u8,
- episode_name: ?[]const u8,
- episode_show_name: ?[]const u8,
- spotify_episode_uri: ?[]const u8,
+ //spotify_track_uri: ?[]const u8,
+ //episode_name: ?[]const u8,
+ //episode_show_name: ?[]const u8,
+ //spotify_episode_uri: ?[]const u8,
reason_start: []const u8,
reason_end: ?[]const u8,
- shuffle: bool,
+ //shuffle: bool,
skipped: ?bool,
- offline: bool,
+ //offline: bool,
offline_timestamp: u64,
- incognito_mode: ?bool,
+ //incognito_mode: ?bool,
+};
+
+pub const LastFMWeb = struct {
+ recenttracks: struct {
+ track: []LastFMWebScrobble,
+ @"@attr": LastFMWebQueryInfo,
+ },
+};
+
+pub const LastFMWebHyperlinkData = struct {
+ mbid: []const u8,
+ @"#text": []const u8,
+};
+
+pub const LastFMWebScrobble = struct {
+ artist: LastFMWebHyperlinkData,
+ album: ?LastFMWebHyperlinkData = null,
+ name: []const u8,
+ mbid: ?[]const u8 = null,
+ image: []struct {
+ size: []const u8,
+ @"#text": []const u8,
+ },
+ date: ?struct {
+ uts: []const u8,
+ @"#text": []const u8,
+ } = null,
+ @"@attr": ?struct {
+ nowplaying: []const u8,
+ } = null,
+ url: []const u8,
+};
+
+pub const LastFMWebQueryInfo = struct {
+ perPage: []const u8,
+ totalPages: []const u8,
+ page: []const u8,
+ user: []const u8,
+ total: []const u8,
+};
+
+pub const Rule = struct {
+ name: []const u8,
+ cond_req: enum { any, all },
+ conditionals: []struct {
+ match_on: enum { artists_album, artists_track, album, track },
+ match_cond: enum { is, contains },
+ match_txt: []const u8,
+ },
+ actions: []struct {
+ action: enum { replace, add },
+ action_on: enum { artists_album, album, artists_track, track },
+ action_txt: []const u8,
+ },
+};
+
+// Can't import types in .zmpl files, so defining this here
+// doesn't really do much (except maybe in the .zig file for views?)
+//pub const HeaderTypes = []enum {
+// song,
+// album,
+// artist,
+// artistlist,
+// scrobbles,
+// date,
+//};
+//
+
+pub const TableRow = struct {
+ song: ?HyperlinkData = null,
+ album: ?HyperlinkData = null,
+ artist: ?HyperlinkData = null,
+ artistlist: ?[]HyperlinkData = null,
+ scrobbles: ?i64 = null,
+ date: ?[]const u8 = null,
+};
+
+pub const HyperlinkData = struct {
+ name: []const u8,
+ id: i64,
};