diff --git a/.gitignore b/.gitignore index 76f5b26..70b0239 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,4 @@ static/ src/app/database/data.db-journal src/app/database/old_migrations/ src/lib -src/app/scripts/ -rules.json \ No newline at end of file +src/app/scripts/ \ No newline at end of file diff --git a/README.md b/README.md index 06f45c2..b62b576 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Inspired by [Last.fm](https://last.fm), [Maloja](https://github.com/krateng/maloja), and [Lastfmstats.com](https://www.lastfmstats.com). + **Z**uletzt is written with [**Z**ig](https://github.com/ziglang/zig) and [Jetzig](https://github.com/jetzig-framework/jetzig) as a means of learning the language, reintroducing myself to programming, and combining the functionality of the aforementioned inspirations. @@ -11,49 +12,17 @@ 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" - - [x] Include respective artist(s) + - [ ] Include respective artist(s) - [ ] Include respective album[^10] - [x] Include number of plays - [ ] Create disambiguation pages @@ -69,7 +38,7 @@ If two artists have the same name, they are necessarily listed as the same artis - [ ] Import from Discogs[^2] - [ ] Import listening history - [x] From Lastfmstats.com (.json file)[^3] - - [x] From Last.fm (authentication) + - [ ] From Last.fm (authentication) - [x] From Spotify (.json file) - [ ] From other streaming services[^4] - [ ] "Unofficial scrobbles"[^9] @@ -88,7 +57,6 @@ If two artists have the same name, they are necessarily listed as the same artis - [ ] Rank songs - [ ] Custom statistics[^7] - [ ] "Playlists"[^8] -- [ ] First launch setup [^1]: I do not intend to exactly replicate all the statistics Lastfmstats.com provides, but I would at least like to give the user the option to see those kinds of statistics, or generate them themselves (see 7). diff --git a/build.zig.zon b/build.zig.zon index 5db05e3..823b42c 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/695664bc10e6e73389ee2fe7fbcaedc27fa8adc9.tar.gz", - .hash = "jetzig-0.0.0-IpAgLSFeDwBEhfEaDBo0JqhRbvQA3ScbWjssBqhJHFmb", + .url = "https://github.com/jetzig-framework/jetzig/archive/2c52792217b9441ed5e91d67e7ec5a8959285307.tar.gz", + .hash = "jetzig-0.0.0-IpAgLbMeDwDYRqWu0OJixGbcDUoUbfAN0xGe1xsYRHTj", }, .zeit = .{ - .url = "https://github.com/rockorager/zeit/archive/refs/tags/v0.6.0.tar.gz", - .hash = "zeit-0.0.0-5I6bk_pZAgB03N1p1GmVOZ--gOFwwQSRKj1UXb5tnaKS", + .url = "https://github.com/rockorager/zeit/archive/refs/heads/main.tar.gz", + .hash = "zeit-0.6.0-5I6bk5daAgC-P60TjxRqW0bYknfCGxJp-03eS9UjGrO7", }, }, .paths = .{ diff --git a/common_queries.md b/common_queries.md new file mode 100644 index 0000000..833d2c0 --- /dev/null +++ b/common_queries.md @@ -0,0 +1,164 @@ +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 4042ff0..361129d 100644 --- a/config/database.zig +++ b/config/database.zig @@ -15,7 +15,7 @@ pub const database = .{ .port = 5432, .username = "postgres", .password = "postgres", - .database = "zuletzt_rsql", + .database = "zuletzt_dev", .pool_size = 16, }, diff --git a/src/app/database/Schema.zig b/src/app/database/Schema.zig index 0a30a19..231c283 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: i64, + id: i32, name: []const u8, length: ?f32, created_at: jetquery.DateTime, @@ -12,19 +12,91 @@ 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, .{}), - .artistalbums = jetquery.hasMany(.Artistalbum, .{}), + .albumartists = jetquery.hasMany(.Albumartist, .{}), }, }, ); -pub const Albumsong = jetquery.Model( +pub const Alias = jetquery.Model( @This(), - "albumsongs", + "aliases", struct { - id: i64, - song_id: i64, - album_id: i64, + 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, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, @@ -32,80 +104,27 @@ pub const Albumsong = 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: i64, - albumsong: i64, - datetime: jetquery.DateTime, + id: i32, + song_id: i32, + album_id: i32, + date: jetquery.DateTime, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, .{ .relations = .{ - .albumsong = jetquery.belongsTo(.Albumsong, .{ .foreign_key = "albumsong" }), + .song = jetquery.belongsTo(.Song, .{}), + .album = jetquery.belongsTo(.Album, .{}), + .scrobbleartists = jetquery.hasMany(.Scrobbleartist, .{}), }, }, ); @@ -114,7 +133,7 @@ pub const Song = jetquery.Model( @This(), "songs", struct { - id: i64, + id: i32, name: []const u8, length: ?f32, hidden: bool, @@ -123,26 +142,84 @@ 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 Artistsong = jetquery.Model( +pub const Albumartist = jetquery.Model( @This(), - "artistsongs", + "Albumartists", struct { - id: i64, - artist_id: i64, - song_id: i64, + id: i32, + album_id: i32, + artist_id: i32, 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-04-07_14-31-45_create_albums.zig b/src/app/database/migrations/2025-02-17_22-38-40_create_albums.zig similarity index 89% rename from src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig rename to src/app/database/migrations/2025-02-17_22-38-40_create_albums.zig index 86b6184..d706cfe 100644 --- a/src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig +++ b/src/app/database/migrations/2025-02-17_22-38-40_create_albums.zig @@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void { try repo.createTable( "albums", &.{ - t.primaryKey("id", .{ .type = .bigint }), + t.primaryKey("id", .{}), t.column("name", .string, .{}), t.column("length", .float, .{ .optional = true }), t.timestamps(.{}), 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 new file mode 100644 index 0000000..11cbb70 --- /dev/null +++ b/src/app/database/migrations/2025-02-17_22-38-41_create_aliases.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( + "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-04-07_14-38-02_create_artists.zig b/src/app/database/migrations/2025-02-17_22-38-42_create_artists.zig similarity index 74% rename from src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig rename to src/app/database/migrations/2025-02-17_22-38-42_create_artists.zig index 97c5bfe..2c92de4 100644 --- a/src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig +++ b/src/app/database/migrations/2025-02-17_22-38-42_create_artists.zig @@ -6,9 +6,9 @@ pub fn up(repo: anytype) !void { try repo.createTable( "artists", &.{ - t.primaryKey("id", .{ .type = .bigint }), + t.primaryKey("id", .{}), t.column("name", .string, .{}), - t.column("disambiguation", .string, .{ .optional = true }), + t.column("descriptive_string", .string, .{}), t.timestamps(.{}), }, .{}, 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 new file mode 100644 index 0000000..69e82de --- /dev/null +++ b/src/app/database/migrations/2025-02-17_22-38-43_create_masteralbums.zig @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..050a467 --- /dev/null +++ b/src/app/database/migrations/2025-02-17_22-38-44_create_mastersongs.zig @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..d7ac939 --- /dev/null +++ b/src/app/database/migrations/2025-02-17_22-38-45_create_ratings.zig @@ -0,0 +1,21 @@ +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-04-07_14-35-53_create_scrobbles.zig b/src/app/database/migrations/2025-02-17_22-38-46_create_scrobbles.zig similarity index 72% rename from src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig rename to src/app/database/migrations/2025-02-17_22-38-46_create_scrobbles.zig index c3bcd12..9764d99 100644 --- a/src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig +++ b/src/app/database/migrations/2025-02-17_22-38-46_create_scrobbles.zig @@ -7,8 +7,9 @@ pub fn up(repo: anytype) !void { "scrobbles", &.{ t.primaryKey("id", .{}), - t.column("albumsong", .bigint, .{ .reference = .{ "albumsongs", "id" } }), - t.column("datetime", .datetime, .{}), + t.column("song_id", .integer, .{}), + t.column("album_id", .integer, .{}), + t.column("date", .datetime, .{}), t.timestamps(.{}), }, .{}, diff --git a/src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig b/src/app/database/migrations/2025-02-17_22-38-47_create_songs.zig similarity index 89% rename from src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig rename to src/app/database/migrations/2025-02-17_22-38-47_create_songs.zig index e8ae1d6..9a52b6b 100644 --- a/src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig +++ b/src/app/database/migrations/2025-02-17_22-38-47_create_songs.zig @@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void { try repo.createTable( "songs", &.{ - t.primaryKey("id", .{ .type = .bigint }), + t.primaryKey("id", .{}), t.column("name", .string, .{}), t.column("length", .float, .{ .optional = true }), t.column("hidden", .boolean, .{}), 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 new file mode 100644 index 0000000..b0e4f54 --- /dev/null +++ b/src/app/database/migrations/2025-02-19_18-03-51_create_albumartists.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( + "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 new file mode 100644 index 0000000..a509d7a --- /dev/null +++ b/src/app/database/migrations/2025-02-19_18-04-22_create_songsartists.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( + "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 new file mode 100644 index 0000000..1865c2e --- /dev/null +++ b/src/app/database/migrations/2025-02-19_18-46-48_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", .{}), + 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 new file mode 100644 index 0000000..2125a87 --- /dev/null +++ b/src/app/database/migrations/2025-02-21_14-24-31_create_scrobbleartists.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( + "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-04-07_14-34-39_create_albumsongs.zig b/src/app/database/migrations/2025-04-07_14-34-39_create_albumsongs.zig deleted file mode 100644 index 96c3063..0000000 --- a/src/app/database/migrations/2025-04-07_14-34-39_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", .{ .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-04-07_14-39-09_create_albumsongsartists.zig b/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.zig deleted file mode 100644 index 3355196..0000000 --- a/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.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( - "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 deleted file mode 100644 index 3c3ea7f..0000000 --- a/src/app/database/migrations/2025-04-07_14-40-17_create_artistalbums.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( - "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 deleted file mode 100644 index 7d0a6c1..0000000 --- a/src/app/database/migrations/2025-06-06_19-46-32_create_artistsongs.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( - "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 deleted file mode 100644 index 6beb880..0000000 --- a/src/app/jobs/process_rule.zig +++ /dev/null @@ -1,53 +0,0 @@ -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 770bde7..1314465 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 Data = @import("../../types.zig"); -const rules = @import("../../apply_rule.zig"); +const Scrobble = @import("../../types.zig").LastFMScrobble; +const lastfm = @import("../../types.zig").LastFM; // 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,121 +14,118 @@ const rules = @import("../../apply_rule.zig"); // - 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)))) }; - // Probably want to include artist name here, but not sure how to yet + // 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))); - const track_artists = item.getT(.array, "artists_track").?.items(); - const album_artists = item.getT(.array, "artists_album").?.items(); + // 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))); - 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); + // 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.) - 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").?)), - }; + // 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. - var album_hash_string = std.ArrayList(u8).init(allocator); - var track_hash_string = std.ArrayList(u8).init(allocator); + // 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. - // 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))); + //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; } - 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 (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; } - 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); - } + 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); } - 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); + 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); } } + + // 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 deleted file mode 100644 index 8b3ae66..0000000 --- a/src/app/jobs/process_scrobbles2.zig +++ /dev/null @@ -1,131 +0,0 @@ -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 new file mode 100644 index 0000000..a6758d2 --- /dev/null +++ b/src/app/middleware/DemoMiddleware.zig @@ -0,0 +1,65 @@ +/// 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 03046b3..634f414 100644 --- a/src/app/views/albums.zig +++ b/src/app/views/albums.zig @@ -1,33 +1,155 @@ 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); - const albums = try queries.entityQueryResult(request, queries.loadQuery(.album, .entities), .{}); - try root.put("albums", albums); + for (albums) |album| { + var album_view = try albums_view.append(.object); + 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 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); - + 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); + } 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/groups/delete.zmpl b/src/app/views/albums/delete.zmpl similarity index 100% rename from src/app/views/groups/delete.zmpl rename to src/app/views/albums/delete.zmpl diff --git a/src/app/views/groups/edit.zmpl b/src/app/views/albums/edit.zmpl similarity index 100% rename from src/app/views/groups/edit.zmpl rename to src/app/views/albums/edit.zmpl diff --git a/src/app/views/albums/get.zmpl b/src/app/views/albums/get.zmpl index 7e089e7..8084038 100644 --- a/src/app/views/albums/get.zmpl +++ b/src/app/views/albums/get.zmpl @@ -1,22 +1,19 @@ -@zig { - const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; - const columns: ColumnChoices = &.{.song, .scrobbles}; -} - @partial partials/header -

{{.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) +

{{.album}}

+ + + +@for (.songs) |song| { + + + + +} +
Name
{{song.name}}{{song.scrobbles}}
\ No newline at end of file diff --git a/src/app/views/albums/index.zmpl b/src/app/views/albums/index.zmpl index 43ff1de..2c259a6 100644 --- a/src/app/views/albums/index.zmpl +++ b/src/app/views/albums/index.zmpl @@ -1,15 +1,40 @@ -@zig { - const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; - const columns: ColumnChoices = &.{.album, .artistlist, .scrobbles}; -} - + @partial partials/header

Albums

-@partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: columns) + + + + + + + + + +@for (.albums) |album| { + + + + + +} + +
NameArtist(s)Scrobbles
{{album.name}} + @for (album.get("artist_info").?) |ai| { + {{ai.name}} + } + {{album.scrobbles}}
+ + \ No newline at end of file diff --git a/src/app/views/groups/new.zmpl b/src/app/views/albums/new.zmpl similarity index 100% rename from src/app/views/groups/new.zmpl rename to src/app/views/albums/new.zmpl diff --git a/src/app/views/groups/patch.zmpl b/src/app/views/albums/patch.zmpl similarity index 100% rename from src/app/views/groups/patch.zmpl rename to src/app/views/albums/patch.zmpl diff --git a/src/app/views/groups/post.zmpl b/src/app/views/albums/post.zmpl similarity index 100% rename from src/app/views/groups/post.zmpl rename to src/app/views/albums/post.zmpl diff --git a/src/app/views/groups/put.zmpl b/src/app/views/albums/put.zmpl similarity index 100% rename from src/app/views/groups/put.zmpl rename to src/app/views/albums/put.zmpl diff --git a/src/app/views/artists.zig b/src/app/views/artists.zig index 3ec8eba..78058a7 100644 --- a/src/app/views/artists.zig +++ b/src/app/views/artists.zig @@ -1,37 +1,141 @@ 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); - const artists = try queries.entityQueryResult(request, queries.loadQuery(.artist, .entities), .{}); - - try root.put("artists", artists); + 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); + } 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 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); - + 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); + } 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/groups/get.zmpl b/src/app/views/artists/delete.zmpl similarity index 100% rename from src/app/views/groups/get.zmpl rename to src/app/views/artists/delete.zmpl diff --git a/src/app/views/artists/edit.zmpl b/src/app/views/artists/edit.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/artists/edit.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/artists/get.zmpl b/src/app/views/artists/get.zmpl index 9f1ff07..911d2c3 100644 --- a/src/app/views/artists/get.zmpl +++ b/src/app/views/artists/get.zmpl @@ -1,28 +1,31 @@ -@zig { - const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; - const columns: ColumnChoices = &.{.album, .scrobbles}; -} - + @partial partials/header -

{{.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) - +

{{.artist}}

+ + + + + + + +@for (.albums) |album| { + + + + +} + +
NameScrobbles
{{album.name}}{{album.scrobbles}}
+ + \ No newline at end of file diff --git a/src/app/views/artists/index.zmpl b/src/app/views/artists/index.zmpl index 0648c91..6854e07 100644 --- a/src/app/views/artists/index.zmpl +++ b/src/app/views/artists/index.zmpl @@ -1,15 +1,34 @@ -@zig { - const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; - const columns: ColumnChoices = &.{.artist, .scrobbles}; -} - + @partial partials/header

Artists

-@partial partials/newtable(T: ColumnChoices, table_data: .artists, columns: columns) + + + + + + + + +@for (.artists) |artist| { + + + + +} + +
NameScrobbles
{{artist.name}}{{artist.scrobbles}}
+ + \ No newline at end of file diff --git a/src/app/views/artists/new.zmpl b/src/app/views/artists/new.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/artists/new.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/artists/patch.zmpl b/src/app/views/artists/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/artists/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/artists/post.zmpl b/src/app/views/artists/post.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/artists/post.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/artists/put.zmpl b/src/app/views/artists/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/artists/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/collection.zig b/src/app/views/collection.zig index 1b0b502..8125efd 100644 --- a/src/app/views/collection.zig +++ b/src/app/views/collection.zig @@ -11,3 +11,26 @@ 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 new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/collection/delete.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/collection/patch.zmpl b/src/app/views/collection/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/collection/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/collection/post.zmpl b/src/app/views/collection/post.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/collection/post.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/collection/put.zmpl b/src/app/views/collection/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/collection/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/concerts.zig b/src/app/views/concerts.zig index 1b0b502..8125efd 100644 --- a/src/app/views/concerts.zig +++ b/src/app/views/concerts.zig @@ -11,3 +11,26 @@ 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 new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/concerts/delete.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/concerts/patch.zmpl b/src/app/views/concerts/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/concerts/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/concerts/post.zmpl b/src/app/views/concerts/post.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/concerts/post.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/concerts/put.zmpl b/src/app/views/concerts/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/concerts/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/groups/index.zmpl b/src/app/views/groups/index.zmpl deleted file mode 100644 index 1522924..0000000 --- a/src/app/views/groups/index.zmpl +++ /dev/null @@ -1,11 +0,0 @@ - - - - -@partial partials/header - -

Merge Songs

-
- -
-
\ No newline at end of file diff --git a/src/app/views/lists.zig b/src/app/views/lists.zig index aecf0dc..8125efd 100644 --- a/src/app/views/lists.zig +++ b/src/app/views/lists.zig @@ -16,3 +16,21 @@ 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 new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/lists/delete.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/lists/patch.zmpl b/src/app/views/lists/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/lists/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/lists/put.zmpl b/src/app/views/lists/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/lists/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/partials/_firstlast_listens.zmpl b/src/app/views/partials/_firstlast_listens.zmpl deleted file mode 100644 index 3cf3ac9..0000000 --- a/src/app/views/partials/_firstlast_listens.zmpl +++ /dev/null @@ -1,11 +0,0 @@ -@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 3f9089d..bf80e53 100644 --- a/src/app/views/partials/_header.zmpl +++ b/src/app/views/partials/_header.zmpl @@ -1,11 +1,7 @@ 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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/views/partials/_newtable.zmpl b/src/app/views/partials/_newtable.zmpl deleted file mode 100644 index f9dc618..0000000 --- a/src/app/views/partials/_newtable.zmpl +++ /dev/null @@ -1,75 +0,0 @@ -@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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/views/partials/_recent.zmpl b/src/app/views/partials/_recent.zmpl new file mode 100644 index 0000000..e69de29 diff --git a/src/app/views/partials/_table.zmpl b/src/app/views/partials/_table.zmpl new file mode 100644 index 0000000..8ea394d --- /dev/null +++ b/src/app/views/partials/_table.zmpl @@ -0,0 +1,18 @@ +@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 deleted file mode 100644 index 24ef925..0000000 --- a/src/app/views/partials/_timescale.zmpl +++ /dev/null @@ -1,20 +0,0 @@ -@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 aecf0dc..8125efd 100644 --- a/src/app/views/ratings.zig +++ b/src/app/views/ratings.zig @@ -16,3 +16,21 @@ 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 new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/ratings/delete.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/ratings/patch.zmpl b/src/app/views/ratings/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/ratings/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/ratings/put.zmpl b/src/app/views/ratings/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/ratings/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/rules.zig b/src/app/views/rules.zig index eb85a72..a2a222e 100644 --- a/src/app/views/rules.zig +++ b/src/app/views/rules.zig @@ -4,38 +4,101 @@ 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 new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/rules/delete.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/rules/edit.zmpl b/src/app/views/rules/edit.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/rules/edit.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/rules/get.zmpl b/src/app/views/rules/get.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/rules/get.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/rules/index.zmpl b/src/app/views/rules/index.zmpl index 5661f97..76457d0 100644 --- a/src/app/views/rules/index.zmpl +++ b/src/app/views/rules/index.zmpl @@ -1,62 +1,3 @@ - - - - - -@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 +
+ Content goes here +
diff --git a/src/app/views/rules/new.zmpl b/src/app/views/rules/new.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/rules/new.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/rules/patch.zmpl b/src/app/views/rules/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/rules/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/rules/put.zmpl b/src/app/views/rules/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/rules/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/scrobbles.zig b/src/app/views/scrobbles.zig index 754a7c8..ebda828 100644 --- a/src/app/views/scrobbles.zig +++ b/src/app/views/scrobbles.zig @@ -1,12 +1,64 @@ 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); - const scrobbles = try queries.entityQueryResult(request, queries.loadQuery(.scrobble, .entities), .{}); - try root.put("scrobbles", scrobbles); + 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); + } + 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 new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/scrobbles/delete.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/scrobbles/get.zmpl b/src/app/views/scrobbles/get.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/scrobbles/get.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/scrobbles/index.zmpl b/src/app/views/scrobbles/index.zmpl index c3d6759..377f50f 100644 --- a/src/app/views/scrobbles/index.zmpl +++ b/src/app/views/scrobbles/index.zmpl @@ -1,15 +1,42 @@ -@zig { - const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; - const columns: ColumnChoices = &.{.song, .artistlist, .album, .date}; -} - + @partial partials/header

Scrobbles

-@partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns) + + + + + + + + + + +@for (.scrobbles) |scrobble| { + + + + + + +} + +
SongArtist(s)AlbumDate
{{scrobble.song_name}} + @for (scrobble.get("artist_info").?) |ai| { + {{ai.name}} + } + {{scrobble.album_name}}{{scrobble.date}}
+ + \ No newline at end of file diff --git a/src/app/views/scrobbles/patch.zmpl b/src/app/views/scrobbles/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/scrobbles/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/scrobbles/post.zmpl b/src/app/views/scrobbles/post.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/scrobbles/post.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/scrobbles/put.zmpl b/src/app/views/scrobbles/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/scrobbles/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/groups.zig b/src/app/views/search.zig similarity index 81% rename from src/app/views/groups.zig rename to src/app/views/search.zig index 85505c8..be5f7e0 100644 --- a/src/app/views/groups.zig +++ b/src/app/views/search.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, "/groups", .{}); + const response = try app.request(.GET, "/search", .{}); 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, "/groups/example-id", .{}); + const response = try app.request(.GET, "/search/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, "/groups/new", .{}); + const response = try app.request(.GET, "/search/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, "/groups/example-id/edit", .{}); + const response = try app.request(.GET, "/search/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, "/groups", .{}); + const response = try app.request(.POST, "/search", .{}); 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, "/groups/example-id", .{}); + const response = try app.request(.PUT, "/search/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, "/groups/example-id", .{}); + const response = try app.request(.PATCH, "/search/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, "/groups/example-id", .{}); + const response = try app.request(.DELETE, "/search/example-id", .{}); try response.expectStatus(.ok); } diff --git a/src/app/views/search/delete.zmpl b/src/app/views/search/delete.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/search/delete.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/search/edit.zmpl b/src/app/views/search/edit.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/search/edit.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/search/get.zmpl b/src/app/views/search/get.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/search/get.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/search/index.zmpl b/src/app/views/search/index.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/search/index.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/search/new.zmpl b/src/app/views/search/new.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/search/new.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/search/patch.zmpl b/src/app/views/search/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/search/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/search/post.zmpl b/src/app/views/search/post.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/search/post.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/search/put.zmpl b/src/app/views/search/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/search/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/songs.zig b/src/app/views/songs.zig index b634534..6c134b1 100644 --- a/src/app/views/songs.zig +++ b/src/app/views/songs.zig @@ -1,40 +1,129 @@ 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); - 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); + for (songs) |song| { + var song_view = try songs_view.append(.object); + 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 { - 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); + _ = 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 { + 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 new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/songs/delete.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/songs/edit.zmpl b/src/app/views/songs/edit.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/songs/edit.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/songs/get.zmpl b/src/app/views/songs/get.zmpl index 1ceddef..76457d0 100644 --- a/src/app/views/songs/get.zmpl +++ b/src/app/views/songs/get.zmpl @@ -1,32 +1,3 @@ -@zig { - const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; - const columns: ColumnChoices = &.{.song, .artistlist, .album, .date}; -} - - - - - - -@partial partials/header -
-

{{.song.song_name}}

+
+ Content goes here
- -
-
-
{{.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 862b753..a0a1128 100644 --- a/src/app/views/songs/index.zmpl +++ b/src/app/views/songs/index.zmpl @@ -1,17 +1,40 @@ -@zig { - const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; - const columns: ColumnChoices = &.{.song, .album, .artistlist, .scrobbles}; -} - + - @if (! $.htmx) @partial partials/header

Songs

- @end -@partial partials/newtable(T: ColumnChoices, table_data: .songs, columns: columns) + + + + + + + + + +@for (.songs) |song| { + + + + + +} + +
NameArtists(s)Scrobbles
{{song.name}} + @for (song.get("artist_info").?) |ai| { + {{ai.name}} + } + {{song.scrobbles}}
+ + \ No newline at end of file diff --git a/src/app/views/songs/new.zmpl b/src/app/views/songs/new.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/songs/new.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/songs/patch.zmpl b/src/app/views/songs/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/songs/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/songs/post.zmpl b/src/app/views/songs/post.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/songs/post.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/songs/put.zmpl b/src/app/views/songs/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/songs/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/upload.zig b/src/app/views/upload.zig index f712ba4..53f3b27 100644 --- a/src/app/views/upload.zig +++ b/src/app/views/upload.zig @@ -1,158 +1,135 @@ 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); - 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, - }); + 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; - 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 scrobbles_view = try root.put("scrobbles", .array); + var job = try request.job("process_scrobbles"); + var scrobbles_data = try job.params.put("scrobbles", .array); - 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(); + var skipped_tracks: u64 = 0; + var limited_tracks: u64 = 0; - 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(); + // 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_timestamp = earliest_date.instant().unixTimestamp(); - const latest_timestamp = latest_date.instant().unixTimestamp(); + // 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; + } - var view_params = try root.put("scrobbles", .array); + 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; + } - 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); + // 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 username = if (params.getT(.string, "username")) |un| un else "VAOTM"; + var value = try scrobbles_data.append(.object); - 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 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))); - } + // This is so unnecessary, probably useful once I start doing Spotify integration though + inline for (std.meta.fields(ScrobbleTypes.LastFMScrobble)) |f| { + try value.put(f.name, @as(f.type, @field(formatted_scrobble, f.name))); + } + // Note sure why this works for ZMPL, but not for jobs. + try scrobbles_view.append(formatted_scrobble); + } + }, + else => unreachable, } + try job.schedule(); + std.log.debug("Skipped {} tracks", .{skipped_tracks}); + std.log.debug("Filtered {} tracks", .{limited_tracks}); } - try job.schedule(); + + 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"); return request.render(.created); } -fn pair(a: u64, b: u64) u64 { - return @divFloor((a +% b) *% (a +% b +% 1) +% b, 2); +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/upload/delete.zmpl b/src/app/views/upload/delete.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/upload/delete.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/upload/get.zmpl b/src/app/views/upload/get.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/upload/get.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/upload/index.zmpl b/src/app/views/upload/index.zmpl index 420e08c..9043a5b 100644 --- a/src/app/views/upload/index.zmpl +++ b/src/app/views/upload/index.zmpl @@ -9,18 +9,13 @@
- - - - + +
Last.fm Spotify - Last.fm (WebAuth) - - Advanced Options - Limit to Scrobbles before: - Limit to Scrobbles after: + Limit to Scrobbles before: + Limit to Scrobbles after:
diff --git a/src/app/views/upload/patch.zmpl b/src/app/views/upload/patch.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/upload/patch.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/app/views/upload/post.zmpl b/src/app/views/upload/post.zmpl index 91c5347..176f094 100644 --- a/src/app/views/upload/post.zmpl +++ b/src/app/views/upload/post.zmpl @@ -1,16 +1,15 @@ -@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/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns) + +@partial partials/table(table_data: .scrobbles, table_headers: .upload_table, table_context: .context) + \ No newline at end of file diff --git a/src/app/views/upload/put.zmpl b/src/app/views/upload/put.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/src/app/views/upload/put.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/apply_rule.zig b/src/apply_rule.zig deleted file mode 100644 index fc4c0bc..0000000 --- a/src/apply_rule.zig +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 7e44da7..0000000 --- a/src/date_fmt.zig +++ /dev/null @@ -1,35 +0,0 @@ -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 cdc9bca..1cab920 100644 --- a/src/main.zig +++ b/src/main.zig @@ -14,13 +14,10 @@ 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 deleted file mode 100644 index 6b126ab..0000000 --- a/src/ordinal_fmt.zig +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index fcc5f07..0000000 --- a/src/queries.zig +++ /dev/null @@ -1,440 +0,0 @@ -// 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 222d898..55bca1c 100644 --- a/src/types.zig +++ b/src/types.zig @@ -1,213 +1,38 @@ -const std = @import("std"); const zeit = @import("zeit"); -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 { +pub const LastFMScrobble = 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 = "", - 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; - } + date: i128, }; // From lastfmstats.com -pub const LastFMStats = struct { username: []const u8, scrobbles: []IgnorantScrobble }; +pub const LastFM = struct { username: []const u8, scrobbles: []LastFMScrobble }; // I derived whether or not these values were optional from searching // the respective fields for null in Vim, so there may be some fields // that can be optional that I haven't run into yet pub const SpotifyScrobble = struct { 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, -}; - -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, + incognito_mode: ?bool, };