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}}

- - - -@for (.songs) |song| { - - - - -} -
Name
{{song.name}}{{song.scrobbles}}
+

{{.album.album_name}}

+

{{.album.artist_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

- - - - - - - - - -@for (.albums) |album| { - - - - - -} - -
NameArtist(s)Scrobbles
{{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}}

- - - - - - - -@for (.albums) |album| { - - - - -} - -
NameScrobbles
{{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

- - - - - - - - -@for (.artists) |artist| { - - - - -} - -
NameScrobbles
{{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); +} + +
+First listen: {{songs[0].song.name}} ({{songs[0].date}}) +
+Most recent listen: {{songs[1].song.name}} ({{songs[1].date}}) +
\ 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 @@ Zuletzt +Artists +Albums +Songs Scrobbles Concerts Collection Ratings Lists +Groups
\ 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 => { + + }, + .album => { + + }, + .artist => { + + }, + .artistlist => { + + }, + .scrobbles => { + + }, + .date => { + + } + } + } +} + + + +@zig { + const array = table_data.items(.array); + for (array) |row| { + + for (columns) |header| { + switch (header) { + .song => { + + }, + .album => { + + }, + .artist => { + + }, + .artistlist => { + + }, + .scrobbles => { + + }, + .date =>{ + + } + } + } + + } +} + +
SongAlbumArtistArtist(s)ScrobblesDate
+ {{row.song.name}} + + {{row.album.name}} + + {{row.artist.name}} + + @for (row.get("artistlist").?) |artist| { + {{artist.name}} + } + {{row.scrobbles}}{{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| { - -} - - - @for (table_data) |value| { - - - - - - - } -
{{text}}
{{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 + +
+ + + + + + + + + @for (range) |itm| { + + + + + } + +
YearScrobbles
{{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. +

+
+ + +
+Match + +conditonals. +
+If +@for (0..5) |i| { + + + + + + + +
+} +then +@for (0..5) |i| { + + + with + +
+} + +
+ +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

- - - - - - - - - - -@for (.scrobbles) |scrobble| { - - - - - - -} - -
SongArtist(s)AlbumDate
{{scrobble.song_name}} - @for (scrobble.get("artist_info").?) |ai| { - {{ai.name}} - } - {{scrobble.album_name}}{{scrobble.date}}
- - +@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

- - - - - - - - - -@for (.songs) |song| { - - - - - -} - -
NameArtists(s)Scrobbles
{{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 @@
- - + + + +
Last.fm Spotify - Limit to Scrobbles before: - Limit to Scrobbles after: + Last.fm (WebAuth) + + Advanced Options + Limit to Scrobbles before: + Limit to Scrobbles after:
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, };