diff --git a/.gitignore b/.gitignore index 70b0239..76f5b26 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ static/ src/app/database/data.db-journal src/app/database/old_migrations/ src/lib -src/app/scripts/ \ No newline at end of file +src/app/scripts/ +rules.json \ No newline at end of file diff --git a/README.md b/README.md index b62b576..903f88f 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,85 @@ -# Zuletzt +# Zuletzt **Zuletzt** gives you the statistics of your music listening habits. -Inspired by [Last.fm](https://last.fm), [Maloja](https://github.com/krateng/maloja), and [Lastfmstats.com](https://www.lastfmstats.com). +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. +**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. 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. + +## Quirks +Zuletzt does not assume any two songs are the same song unless they +share the exact same metadata. However, there are plenty of situations where a +song might appear on more than one album (consider a greatest hits album). +Thus, a song which was played on one album 30 times, and also played on a +different album 20 times, would not receive the credit of being played a total +of 50 times. To resolve this, Zuletzt lets the user specify that these two +songs are the same. This is, however, different from SongGroups. SongGroups, +while superficially providing very similar functionality, does not permanently +combine the statistics of the two songs, but will show their combined +statistics anyways. This is useful if, for example, one song is a remix of +another - they are, in reality, different songs, but there is a clear +connection between them, and it may be interesting to see what their combined +statistics are. The decision to merge songs or make a SongGroup, or neither, is +left to the user, but the general thought is: +- If they're the *exact* same song, merge them, and the data becomes more + accurate for that song +- If one is somehow remixed/covered/altered in some way, make a SongGroup, and + see the combined info *as if* you had merged them. + ## To-Do List: - [ ] Entity statistics - [x] See all artists under "/artists" - [ ] List all songs on artist page, with respective album - [x] List all albums on artist page - [x] Include number of plays for each + - [x] List albums features on - [x] See all albums under "/albums" - [x] See all songs from album - [x] Include number of plays + - [x] Include name of artist(s) + - [ ] Include artists features on each song - [x] See all songs under "/songs" - - [ ] Include respective artist(s) + - [x] Include respective artist(s) - [ ] Include respective album[^10] - [x] Include number of plays - [ ] Create disambiguation pages @@ -38,7 +95,7 @@ Licensed under MIT. - [ ] Import from Discogs[^2] - [ ] Import listening history - [x] From Lastfmstats.com (.json file)[^3] - - [ ] From Last.fm (authentication) + - [x] From Last.fm (authentication) - [x] From Spotify (.json file) - [ ] From other streaming services[^4] - [ ] "Unofficial scrobbles"[^9] @@ -49,7 +106,8 @@ Licensed under MIT. - [ ] Genres - [ ] Owned - [ ] Holiday -- [ ] [MusicBrainz integration](https://musicbrainz.org/doc/libmusicbrainz)[^11] +- [ ] [MusicBrainz + integration](https://musicbrainz.org/doc/libmusicbrainz)[^11] - [ ] Concerts - [ ] Import from Setlist.fm[^5] - [ ] Ratings @@ -57,28 +115,63 @@ Licensed under MIT. - [ ] Rank songs - [ ] Custom statistics[^7] - [ ] "Playlists"[^8] +- [ ] First launch setup -[^1]: I do not intend to exactly replicate all the statistics Lastfmstats.com provides, but I would at least like to give the user the option to see those kinds of statistics, or generate them themselves (see 7). +[^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). -[^2]: I do not intend to provide the level of granularity that Discogs provides, but a simple toggle that means "I own some version of this release" is all that is necessary. +[^2]: I do not intend to provide the level of granularity that Discogs +provides, but a simple toggle that means "I own some version of this release" +is all that is necessary. -[^3]: I have not investigated any other service for downloading your listening history from Last.fm, but providing the listening history as a JSON rather than a CSV is highly preferred. I may eventually provide my own way of downloading Last.fm data as a JSON, but I would prefer to allow users to enter their username, or authenticate, and avoid needing to upload a file altogether. +[^3]: I have not investigated any other service for downloading your listening +history from Last.fm, but providing the listening history as a JSON rather than +a CSV is highly preferred. I may eventually provide my own way of downloading +Last.fm data as a JSON, but I would prefer to allow users to enter their +username, or authenticate, and avoid needing to upload a file altogether. -[^4]: I only intend to allow imports from Last.fm and Spotify at the moment because those are the only data sources I currently rely on. To that extent, I imagine I could import from other sources as well fairly easily, although I do not know what their data dumps look like. +[^4]: I only intend to allow imports from Last.fm and Spotify at the moment +because those are the only data sources I currently rely on. To that extent, I +imagine I could import from other sources as well fairly easily, although I do +not know what their data dumps look like. -[^5]: I only intend to allow imports from Setlist.fm at the moment because that is the only data source I currently rely on. +[^5]: I only intend to allow imports from Setlist.fm at the moment because that +is the only data source I currently rely on. -[^6]: RYM has the most data, and once it has an API, will be the only user-driven review site that *has* an API. In this context, "integration" simply means displaying the critic score and user score next to the album. You will be able to write reviews and ranks songs/albums(/artists?), but not for them to be published to RYM. +[^6]: RYM has the most data, and once it has an API, will be the only +user-driven review site that *has* an API. In this context, "integration" +simply means displaying the critic score and user score next to the album. You +will be able to write reviews and ranks songs/albums(/artists?), but not for +them to be published to RYM. -[^7]: I envision something akin to the Custom Reports from [Actual Budget](https://github.com/actualbudget/actual) that will allow users to create their own ways of rating/ranking songs/albums, and view their listening habits. +[^7]: I envision something akin to the Custom Reports from [Actual +Budget](https://github.com/actualbudget/actual) that will allow users to create +their own ways of rating/ranking songs/albums, and view their listening habits. -[^8]: Misleading title, but same functionality as "Lists" on AlbumOfTheYear, although I would like to allow albums and songs to appear on the same list. +[^8]: Misleading title, but same functionality as "Lists" on AlbumOfTheYear, +although I would like to allow albums and songs to appear on the same list. -[^9]: This is a working title, but I have sources (iPods) that provide a play count, but no play dates, so I can't list them among my usual Scrobbles. However, I would still like to display that information along with everything else, so I would like to provide a way of entering this data into a separate category that can be toggled to display alongside "official" Scrobbles. +[^9]: This is a working title, but I have sources (iPods) that provide a play +count, but no play dates, so I can't list them among my usual Scrobbles. +However, I would still like to display that information along with everything +else, so I would like to provide a way of entering this data into a separate +category that can be toggled to display alongside "official" Scrobbles. [^10]: Would probably select the album with the most scrobbles -[^11]: I probably don't understand it well enough, but it appears that I should be able to do this using `@cImport` and/or `translate-c` on the original MusicBrainz source, but it's not all clear to me on how that would work yet. This is a necessary step for what I have planned however, so we'll see where it goes. **Update 3/25/25:** A Zig implementation for what Zuletzt requires (and *only* what Zuletzt requires) has been (mostly) written. +[^11]: I probably don't understand it well enough, but it appears that I should +be able to do this using `@cImport` and/or `translate-c` on the original +MusicBrainz source, but it's not all clear to me on how that would work yet. +This is a necessary step for what I have planned however, so we'll see where it +goes. **Update 3/25/25:** A Zig implementation for what Zuletzt requires (and +*only* what Zuletzt requires) has been (mostly) written. ## Contributing -I am a math student who is interested in programming. I will not be writing quality code. That said, Zuletzt is something that, at the moment, I am very excited about making, and using to relearn some things about programming. Unless contributions are given in the form of code review, or some kind of constructive criticism, it's not likely that I accept pull requests. The project is, however, licensed under the MIT License, so feel free to do what you like with it in your own way. +I am a math student who is interested in programming. I will +not be writing quality code. That said, Zuletzt is something that, at the +moment, I am very excited about making, and using to relearn some things about +programming. Unless contributions are given in the form of code review, or some +kind of constructive criticism, it's not likely that I accept pull requests. +The project is, however, licensed under the MIT License, so feel free to do +what you like with it in your own way. diff --git a/build.zig b/build.zig index 3bf89aa..3b17d0b 100644 --- a/build.zig +++ b/build.zig @@ -12,6 +12,8 @@ pub fn build(b: *std.Build) !void { .optimize = optimize, }); + exe.use_llvm = true; + // Example dependency: // const zig_time_dep = b.dependency("zeit", .{}); diff --git a/build.zig.zon b/build.zig.zon index 823b42c..5db05e3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -17,12 +17,12 @@ // internet connectivity. .dependencies = .{ .jetzig = .{ - .url = "https://github.com/jetzig-framework/jetzig/archive/2c52792217b9441ed5e91d67e7ec5a8959285307.tar.gz", - .hash = "jetzig-0.0.0-IpAgLbMeDwDYRqWu0OJixGbcDUoUbfAN0xGe1xsYRHTj", + .url = "https://github.com/jetzig-framework/jetzig/archive/695664bc10e6e73389ee2fe7fbcaedc27fa8adc9.tar.gz", + .hash = "jetzig-0.0.0-IpAgLSFeDwBEhfEaDBo0JqhRbvQA3ScbWjssBqhJHFmb", }, .zeit = .{ - .url = "https://github.com/rockorager/zeit/archive/refs/heads/main.tar.gz", - .hash = "zeit-0.6.0-5I6bk5daAgC-P60TjxRqW0bYknfCGxJp-03eS9UjGrO7", + .url = "https://github.com/rockorager/zeit/archive/refs/tags/v0.6.0.tar.gz", + .hash = "zeit-0.0.0-5I6bk_pZAgB03N1p1GmVOZ--gOFwwQSRKj1UXb5tnaKS", }, }, .paths = .{ diff --git a/common_queries.md b/common_queries.md deleted file mode 100644 index 833d2c0..0000000 --- a/common_queries.md +++ /dev/null @@ -1,164 +0,0 @@ -Get all albums from specified artist: -```sql -SELECT artists.name, albums.name -FROM "Albumartists" -INNER JOIN artists -ON "Albumartists".artist_id = artists.id -INNER JOIN albums -ON "Albumartists".album_id = albums.id -WHERE artists.name = {ARTIST}; -``` - -Get all songs from specified artist: -```sql -SELECT artists.name, songs.name -FROM "Songartists" -INNER JOIN artists -ON "Songartists".artist_id = artists.id -INNER JOIN songs -ON "Songartists".song_id = songs.id -WHERE artists.name = {ARTIST}; -``` - -Get all songs from any album of the specified name: -```sql -SELECT songs.name -FROM "Albumsongs" -INNER JOIN albums -ON "Albumsongs".album_id = albums.id -INNER JOIN songs -ON "Albumsongs".song_id = songs.id -WHERE albums.name = {ALBUM}; -``` - -Sort all songs by plays (does not list artist or album): -```sql -SELECT songs.name, COUNT(scrobbles.song_id) AS scount -FROM songs, scrobbles -WHERE songs.id = scrobbles.song_id -GROUP BY songs.id -ORDER BY scount DESC; -``` - -Sort all songs by plays, and include artist: -```sql -SELECT songs.name, artists.name, COUNT(scrobbles.song_id) AS scount -FROM scrobbles, "Songartists" -INNER JOIN artists -ON "Songartists".artist_id = artists.id -INNER JOIN songs -ON "Songartists".song_id = songs.id -WHERE songs.id = scrobbles.song_id -GROUP BY songs.id, artists.id -ORDER BY scount DESC; -``` - -Sort all songs by plays, and include artist and album: -```sql -SELECT songs.name, artists.name, albums.name, COUNT(scrobbles.song_id) AS scount -FROM scrobbles CROSS JOIN "Songartists" CROSS JOIN "Albumsongs" -INNER JOIN artists -ON "Songartists".artist_id = artists.id -INNER JOIN songs -ON "Songartists".song_id = songs.id AND "Albumsongs".song_id = songs.id -INNER JOIN albums -ON "Albumsongs".album_id = albums.id -WHERE songs.id = scrobbles.song_id -GROUP BY songs.id, artists.id, albums.id -ORDER BY scount DESC; -``` - -Sort all albums by plays, and include artist: -```sql -SELECT albums.name, artists.name, COUNT(scrobbles.album_id) AS scount -FROM scrobbles, "Albumartists" -INNER JOIN albums -ON "Albumartists".album_id = albums.id -INNER JOIN artists -ON "Albumartists".artist_id = artists.id -WHERE albums.id = scrobbles.album_id -GROUP BY artists.id, albums.id -ORDER BY scount DESC; -``` - -Sort all artists by plays: -```sql -SELECT artists.name, COUNT(scrobbles.id) AS scount -FROM artists, "Scrobbleartists" -INNER JOIN scrobbles -ON scrobbles.id = "Scrobbleartists".scrobble_id -WHERE "Scrobbleartists".artist_id = artists.id -GROUP BY artists.id -ORDER BY scount DESC; -``` - -Sort all artists by alphabetical order, and include the first time you listened to that artist: -```sql -SELECT artists.name, MIN(scrobbles.date) -FROM "Scrobbleartists" -INNER JOIN artists -ON "Scrobbleartists".artist_id = artists.id -INNER JOIN scrobbles -ON "Scrobbleartists".scrobble_id = scrobbles.id -GROUP BY artists.id -ORDER BY artists.name ASC; -``` - -Sort all songs by alphabetical order, and include the first time you listened to that song: -```sql -SELECT songs.name, MIN(scrobbles.date) -FROM scrobbles -INNER JOIN songs -ON scrobbles.song_id = songs.id -GROUP BY songs.id -ORDER BY songs.name ASC; -``` - -Sort all albums by alphabetical order, and include the first time you listened to that album: -```sql -SELECT albums.name, MIN(scrobbles.date) -FROM scrobbles -INNER JOIN albums -ON scrobbles.album_id = albums.id -GROUP BY albums.id -ORDER BY albums.name ASC; -``` - -Select all songs by specified artists, include the number of plays of each song, and sort by plays: -```sql -SELECT songs.name, COUNT(scrobbles.song_id) as count -FROM songs, "Scrobbleartists" -INNER JOIN artists -ON "Scrobbleartists".artist_id = artists.id -INNER JOIN scrobbles -ON "Scrobbleartists".scrobble_id = scrobbles.id -WHERE songs.id = scrobbles.song_id AND artists.name = {ARTIST} -GROUP BY songs.id -ORDER BY count DESC; -``` - -Select all albums by specified artist, include the number of plays of each album, and sort by plays: -```sql -SELECT albums.name, COUNT(scrobbles.song_id) as count -FROM albums, "Scrobbleartists" -INNER JOIN artists -ON "Scrobbleartists".artist_id = artists.id -INNER JOIN scrobbles -ON "Scrobbleartists".scrobble_id = scrobbles.id -WHERE albums.id = scrobbles.album_id AND artists.name = {ARTIST} -GROUP BY albums.id -ORDER BY count DESC; -``` - -Select all songs from an album specified by an ID, and sort by plays -```sql -SELECT songs.name, COUNT(scrobbles.song_id) AS count -FROM "Albumsongs" -INNER JOIN songs -ON songs.id = "Albumsongs".song_id -INNER JOIN scrobbles -ON scrobbles.song_id = "Albumsongs".song_id -WHERE "Albumsongs".album_id = {ALBUM_ID} -GROUP BY songs.id -ORDER BY count DESC; -``` \ No newline at end of file diff --git a/config/database.zig b/config/database.zig index 361129d..4042ff0 100644 --- a/config/database.zig +++ b/config/database.zig @@ -15,7 +15,7 @@ pub const database = .{ .port = 5432, .username = "postgres", .password = "postgres", - .database = "zuletzt_dev", + .database = "zuletzt_rsql", .pool_size = 16, }, diff --git a/src/app/database/Schema.zig b/src/app/database/Schema.zig index 231c283..a7118d4 100644 --- a/src/app/database/Schema.zig +++ b/src/app/database/Schema.zig @@ -4,7 +4,7 @@ pub const Album = jetquery.Model( @This(), "albums", struct { - id: i32, + id: i64, name: []const u8, length: ?f32, created_at: jetquery.DateTime, @@ -12,91 +12,19 @@ pub const Album = jetquery.Model( }, .{ .relations = .{ - .masteralbum = jetquery.belongsTo(.Masteralbum, .{}), - .scrobbles = jetquery.hasMany(.Scrobble, .{}), - .ratings = jetquery.hasMany(.Rating, .{}), - .aliases = jetquery.hasMany(.Alias, .{}), .albumsongs = jetquery.hasMany(.Albumsong, .{}), - .albumartists = jetquery.hasMany(.Albumartist, .{}), + .artistalbums = jetquery.hasMany(.Artistalbum, .{}), }, }, ); -pub const Alias = jetquery.Model( +pub const Albumsong = jetquery.Model( @This(), - "aliases", + "albumsongs", struct { - id: i32, - reference_id: i32, - alias: []const u8, - created_at: jetquery.DateTime, - updated_at: jetquery.DateTime, - }, - .{}, -); - -pub const Artist = jetquery.Model( - @This(), - "artists", - struct { - id: i32, - name: []const u8, - descriptive_string: []const u8, - created_at: jetquery.DateTime, - updated_at: jetquery.DateTime, - }, - .{ - .relations = .{ - .scrobbleartists = jetquery.hasMany(.Scrobbleartist, .{}), - .aliases = jetquery.hasMany(.Alias, .{}), - .artistsongs = jetquery.hasMany(.Songartist, .{}), - .mastersongs = jetquery.hasMany(.Mastersong, .{}), - .artistalbums = jetquery.hasMany(.Albumartist, .{}), - .masteralbums = jetquery.hasMany(.Masteralbum, .{}), - }, - }, -); - -pub const Masteralbum = jetquery.Model( - @This(), - "masteralbums", - struct { - id: i32, - name: []const u8, - created_at: jetquery.DateTime, - updated_at: jetquery.DateTime, - }, - .{ - .relations = .{ - .albums = jetquery.hasMany(.Album, .{}), - }, - }, -); - -pub const Mastersong = jetquery.Model( - @This(), - "mastersongs", - struct { - id: i32, - name: []const u8, - created_at: jetquery.DateTime, - updated_at: jetquery.DateTime, - }, - .{ - .relations = .{ - .songs = jetquery.hasMany(.Song, .{}), - }, - }, -); - -pub const Rating = jetquery.Model( - @This(), - "ratings", - struct { - id: i32, - reference_id: i32, - score: f32, - date: jetquery.DateTime, + id: i64, + song_id: i64, + album_id: i64, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, @@ -104,27 +32,80 @@ pub const Rating = jetquery.Model( .relations = .{ .song = jetquery.belongsTo(.Song, .{}), .album = jetquery.belongsTo(.Album, .{}), + .scrobbles = jetquery.hasMany(.Scrobble, .{ .foreign_key = "albumsong" }), + .albumsongsartists = jetquery.hasMany(.Albumsongsartist, .{}), + }, + }, +); + +pub const Albumsongsartist = jetquery.Model( + @This(), + "albumsongsartists", + struct { + id: i64, + albumsong_id: i64, + artist_id: i64, + created_at: jetquery.DateTime, + updated_at: jetquery.DateTime, + }, + .{ + .relations = .{ + .albumsong = jetquery.belongsTo(.Albumsong, .{}), .artist = jetquery.belongsTo(.Artist, .{}), }, }, ); +pub const Artistalbum = jetquery.Model( + @This(), + "artistalbums", + struct { + id: i64, + album_id: i64, + artist_id: i64, + created_at: jetquery.DateTime, + updated_at: jetquery.DateTime, + }, + .{ + .relations = .{ + .album = jetquery.belongsTo(.Album, .{}), + .artist = jetquery.belongsTo(.Artist, .{}), + }, + }, +); + +pub const Artist = jetquery.Model( + @This(), + "artists", + struct { + id: i64, + name: []const u8, + disambiguation: ?[]const u8, + created_at: jetquery.DateTime, + updated_at: jetquery.DateTime, + }, + .{ + .relations = .{ + .albumsongsartists = jetquery.hasMany(.Albumsongsartist, .{}), + .artistalbums = jetquery.hasMany(.Artistalbum, .{}), + .artistsongs = jetquery.hasMany(.Artistsong, .{}), + }, + }, +); + pub const Scrobble = jetquery.Model( @This(), "scrobbles", struct { id: i32, - song_id: i32, - album_id: i32, - date: jetquery.DateTime, + albumsong: i64, + datetime: jetquery.DateTime, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, .{ .relations = .{ - .song = jetquery.belongsTo(.Song, .{}), - .album = jetquery.belongsTo(.Album, .{}), - .scrobbleartists = jetquery.hasMany(.Scrobbleartist, .{}), + .albumsong = jetquery.belongsTo(.Albumsong, .{ .foreign_key = "albumsong" }), }, }, ); @@ -133,7 +114,7 @@ pub const Song = jetquery.Model( @This(), "songs", struct { - id: i32, + id: i64, name: []const u8, length: ?f32, hidden: bool, @@ -142,84 +123,83 @@ pub const Song = jetquery.Model( }, .{ .relations = .{ - .mastersong = jetquery.belongsTo(.Mastersong, .{}), - .scrobbles = jetquery.hasMany(.Scrobble, .{}), - .ratings = jetquery.hasMany(.Rating, .{}), - .aliases = jetquery.hasMany(.Alias, .{}), - .songartists = jetquery.hasMany(.Songartist, .{}), .albumsongs = jetquery.hasMany(.Albumsong, .{}), + .artistsongs = jetquery.hasMany(.Artistsong, .{}), }, }, ); -pub const Albumartist = jetquery.Model( +pub const Artistsong = jetquery.Model( @This(), - "Albumartists", + "artistsongs", struct { - id: i32, - album_id: i32, - artist_id: i32, + id: i64, + artist_id: i64, + song_id: i64, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, .{ .relations = .{ - .album = jetquery.belongsTo(.Album, .{}), .artist = jetquery.belongsTo(.Artist, .{}), - }, - }, -); - -pub const Songartist = jetquery.Model( - @This(), - "Songartists", - struct { - id: i32, - song_id: i32, - artist_id: i32, - created_at: jetquery.DateTime, - updated_at: jetquery.DateTime, - }, - .{ - .relations = .{ - .song = jetquery.belongsTo(.Song, .{}), - .artist = jetquery.belongsTo(.Artist, .{}), - }, - }, -); - -pub const Albumsong = jetquery.Model( - @This(), - "Albumsongs", - struct { - id: i32, - album_id: i32, - song_id: i32, - created_at: jetquery.DateTime, - updated_at: jetquery.DateTime, - }, - .{ - .relations = .{ - .album = jetquery.belongsTo(.Album, .{}), .song = jetquery.belongsTo(.Song, .{}), }, }, ); -pub const Scrobbleartist = jetquery.Model( +pub const Albumrating = jetquery.Model( @This(), - "Scrobbleartists", + "albumratings", struct { id: i32, - scrobble_id: i32, - artist_id: i32, + album: i64, + rating: ?i16, + rating_text: ?[]const u8, + date: jetquery.DateTime, created_at: jetquery.DateTime, updated_at: jetquery.DateTime, }, .{ .relations = .{ - .scrobble = jetquery.belongsTo(.Scrobble, .{}), - .artist = jetquery.belongsTo(.Artist, .{}), + .album = jetquery.belongsTo(.Album, .{ .foreign_key = "album" }), + }, + }, +); + +pub const Artistrating = jetquery.Model( + @This(), + "artistratings", + struct { + id: i32, + artist: i64, + rating: ?i16, + rating_text: ?[]const u8, + date: jetquery.DateTime, + created_at: jetquery.DateTime, + updated_at: jetquery.DateTime, + }, + .{ + .relations = .{ + .artist = jetquery.belongsTo(.Artist, .{ .foreign_key = "artist" }), + }, + }, +); + +pub const Songrating = jetquery.Model( + @This(), + "songratings", + struct { + id: i32, + song: i64, + rating: ?i16, + rating_text: ?[]const u8, + date: jetquery.DateTime, + created_at: jetquery.DateTime, + updated_at: jetquery.DateTime, + }, + .{ + .relations = .{ + .albumsong = jetquery.belongsTo(.Albumsong, .{ .foreign_key = "song" }), }, }, ); diff --git a/src/app/database/migrations/2025-02-17_22-38-41_create_aliases.zig b/src/app/database/migrations/2025-02-17_22-38-41_create_aliases.zig deleted file mode 100644 index 11cbb70..0000000 --- a/src/app/database/migrations/2025-02-17_22-38-41_create_aliases.zig +++ /dev/null @@ -1,20 +0,0 @@ -const std = @import("std"); -const jetquery = @import("jetquery"); -const t = jetquery.schema.table; - -pub fn up(repo: anytype) !void { - try repo.createTable( - "aliases", - &.{ - t.primaryKey("id", .{}), - t.column("reference_id", .integer, .{}), - t.column("alias", .string, .{}), - t.timestamps(.{}), - }, - .{}, - ); -} - -pub fn down(repo: anytype) !void { - try repo.dropTable("aliases", .{}); -} diff --git a/src/app/database/migrations/2025-02-17_22-38-43_create_masteralbums.zig b/src/app/database/migrations/2025-02-17_22-38-43_create_masteralbums.zig deleted file mode 100644 index 69e82de..0000000 --- a/src/app/database/migrations/2025-02-17_22-38-43_create_masteralbums.zig +++ /dev/null @@ -1,19 +0,0 @@ -const std = @import("std"); -const jetquery = @import("jetquery"); -const t = jetquery.schema.table; - -pub fn up(repo: anytype) !void { - try repo.createTable( - "masteralbums", - &.{ - t.primaryKey("id", .{}), - t.column("name", .string, .{}), - t.timestamps(.{}), - }, - .{}, - ); -} - -pub fn down(repo: anytype) !void { - try repo.dropTable("masteralbums", .{}); -} diff --git a/src/app/database/migrations/2025-02-17_22-38-44_create_mastersongs.zig b/src/app/database/migrations/2025-02-17_22-38-44_create_mastersongs.zig deleted file mode 100644 index 050a467..0000000 --- a/src/app/database/migrations/2025-02-17_22-38-44_create_mastersongs.zig +++ /dev/null @@ -1,19 +0,0 @@ -const std = @import("std"); -const jetquery = @import("jetquery"); -const t = jetquery.schema.table; - -pub fn up(repo: anytype) !void { - try repo.createTable( - "mastersongs", - &.{ - t.primaryKey("id", .{}), - t.column("name", .string, .{}), - t.timestamps(.{}), - }, - .{}, - ); -} - -pub fn down(repo: anytype) !void { - try repo.dropTable("mastersongs", .{}); -} diff --git a/src/app/database/migrations/2025-02-19_18-03-51_create_albumartists.zig b/src/app/database/migrations/2025-02-19_18-03-51_create_albumartists.zig deleted file mode 100644 index b0e4f54..0000000 --- a/src/app/database/migrations/2025-02-19_18-03-51_create_albumartists.zig +++ /dev/null @@ -1,20 +0,0 @@ -const std = @import("std"); -const jetquery = @import("jetquery"); -const t = jetquery.schema.table; - -pub fn up(repo: anytype) !void { - try repo.createTable( - "Albumartists", - &.{ - t.primaryKey("id", .{}), - t.column("album_id", .integer, .{}), - t.column("artist_id", .integer, .{}), - t.timestamps(.{}), - }, - .{}, - ); -} - -pub fn down(repo: anytype) !void { - try repo.dropTable("Albumartists", .{}); -} diff --git a/src/app/database/migrations/2025-02-19_18-04-22_create_songsartists.zig b/src/app/database/migrations/2025-02-19_18-04-22_create_songsartists.zig deleted file mode 100644 index a509d7a..0000000 --- a/src/app/database/migrations/2025-02-19_18-04-22_create_songsartists.zig +++ /dev/null @@ -1,20 +0,0 @@ -const std = @import("std"); -const jetquery = @import("jetquery"); -const t = jetquery.schema.table; - -pub fn up(repo: anytype) !void { - try repo.createTable( - "Songartists", - &.{ - t.primaryKey("id", .{}), - t.column("song_id", .integer, .{}), - t.column("artist_id", .integer, .{}), - t.timestamps(.{}), - }, - .{}, - ); -} - -pub fn down(repo: anytype) !void { - try repo.dropTable("Songartists", .{}); -} diff --git a/src/app/database/migrations/2025-02-19_18-46-48_create_albumsongs.zig b/src/app/database/migrations/2025-02-19_18-46-48_create_albumsongs.zig deleted file mode 100644 index 1865c2e..0000000 --- a/src/app/database/migrations/2025-02-19_18-46-48_create_albumsongs.zig +++ /dev/null @@ -1,20 +0,0 @@ -const std = @import("std"); -const jetquery = @import("jetquery"); -const t = jetquery.schema.table; - -pub fn up(repo: anytype) !void { - try repo.createTable( - "Albumsongs", - &.{ - t.primaryKey("id", .{}), - t.column("album_id", .integer, .{}), - t.column("song_id", .integer, .{}), - t.timestamps(.{}), - }, - .{}, - ); -} - -pub fn down(repo: anytype) !void { - try repo.dropTable("Albumsongs", .{}); -} diff --git a/src/app/database/migrations/2025-02-21_14-24-31_create_scrobbleartists.zig b/src/app/database/migrations/2025-02-21_14-24-31_create_scrobbleartists.zig deleted file mode 100644 index 2125a87..0000000 --- a/src/app/database/migrations/2025-02-21_14-24-31_create_scrobbleartists.zig +++ /dev/null @@ -1,20 +0,0 @@ -const std = @import("std"); -const jetquery = @import("jetquery"); -const t = jetquery.schema.table; - -pub fn up(repo: anytype) !void { - try repo.createTable( - "Scrobbleartists", - &.{ - t.primaryKey("id", .{}), - t.column("scrobble_id", .integer, .{}), - t.column("artist_id", .integer, .{}), - t.timestamps(.{}), - }, - .{}, - ); -} - -pub fn down(repo: anytype) !void { - try repo.dropTable("Scrobbleartists", .{}); -} diff --git a/src/app/database/migrations/2025-02-17_22-38-47_create_songs.zig b/src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig similarity index 89% rename from src/app/database/migrations/2025-02-17_22-38-47_create_songs.zig rename to src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig index 9a52b6b..e8ae1d6 100644 --- a/src/app/database/migrations/2025-02-17_22-38-47_create_songs.zig +++ b/src/app/database/migrations/2025-04-07_14-31-14_create_songs.zig @@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void { try repo.createTable( "songs", &.{ - t.primaryKey("id", .{}), + t.primaryKey("id", .{ .type = .bigint }), t.column("name", .string, .{}), t.column("length", .float, .{ .optional = true }), t.column("hidden", .boolean, .{}), diff --git a/src/app/database/migrations/2025-02-17_22-38-40_create_albums.zig b/src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig similarity index 89% rename from src/app/database/migrations/2025-02-17_22-38-40_create_albums.zig rename to src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig index d706cfe..86b6184 100644 --- a/src/app/database/migrations/2025-02-17_22-38-40_create_albums.zig +++ b/src/app/database/migrations/2025-04-07_14-31-45_create_albums.zig @@ -6,7 +6,7 @@ pub fn up(repo: anytype) !void { try repo.createTable( "albums", &.{ - t.primaryKey("id", .{}), + t.primaryKey("id", .{ .type = .bigint }), t.column("name", .string, .{}), t.column("length", .float, .{ .optional = true }), t.timestamps(.{}), diff --git a/src/app/database/migrations/2025-04-07_14-34-39_create_albumsongs.zig b/src/app/database/migrations/2025-04-07_14-34-39_create_albumsongs.zig new file mode 100644 index 0000000..96c3063 --- /dev/null +++ b/src/app/database/migrations/2025-04-07_14-34-39_create_albumsongs.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const jetquery = @import("jetquery"); +const t = jetquery.schema.table; + +pub fn up(repo: anytype) !void { + try repo.createTable( + "albumsongs", + &.{ + t.primaryKey("id", .{ .type = .bigint }), + t.column("song_id", .bigint, .{ .reference = .{ "songs", "id" } }), + t.column("album_id", .bigint, .{ .reference = .{ "albums", "id" } }), + t.timestamps(.{}), + }, + .{}, + ); +} + +pub fn down(repo: anytype) !void { + try repo.dropTable("albumsongs", .{}); +} diff --git a/src/app/database/migrations/2025-02-17_22-38-46_create_scrobbles.zig b/src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig similarity index 72% rename from src/app/database/migrations/2025-02-17_22-38-46_create_scrobbles.zig rename to src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig index 9764d99..c3bcd12 100644 --- a/src/app/database/migrations/2025-02-17_22-38-46_create_scrobbles.zig +++ b/src/app/database/migrations/2025-04-07_14-35-53_create_scrobbles.zig @@ -7,9 +7,8 @@ pub fn up(repo: anytype) !void { "scrobbles", &.{ t.primaryKey("id", .{}), - t.column("song_id", .integer, .{}), - t.column("album_id", .integer, .{}), - t.column("date", .datetime, .{}), + t.column("albumsong", .bigint, .{ .reference = .{ "albumsongs", "id" } }), + t.column("datetime", .datetime, .{}), t.timestamps(.{}), }, .{}, diff --git a/src/app/database/migrations/2025-02-17_22-38-42_create_artists.zig b/src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig similarity index 74% rename from src/app/database/migrations/2025-02-17_22-38-42_create_artists.zig rename to src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig index 2c92de4..97c5bfe 100644 --- a/src/app/database/migrations/2025-02-17_22-38-42_create_artists.zig +++ b/src/app/database/migrations/2025-04-07_14-38-02_create_artists.zig @@ -6,9 +6,9 @@ pub fn up(repo: anytype) !void { try repo.createTable( "artists", &.{ - t.primaryKey("id", .{}), + t.primaryKey("id", .{ .type = .bigint }), t.column("name", .string, .{}), - t.column("descriptive_string", .string, .{}), + t.column("disambiguation", .string, .{ .optional = true }), t.timestamps(.{}), }, .{}, diff --git a/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.zig b/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.zig new file mode 100644 index 0000000..3355196 --- /dev/null +++ b/src/app/database/migrations/2025-04-07_14-39-09_create_albumsongsartists.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const jetquery = @import("jetquery"); +const t = jetquery.schema.table; + +pub fn up(repo: anytype) !void { + try repo.createTable( + "albumsongsartists", + &.{ + t.primaryKey("id", .{ .type = .bigint }), + t.column("albumsong_id", .bigint, .{ .reference = .{ "albumsongs", "id" } }), + t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }), + t.timestamps(.{}), + }, + .{}, + ); +} + +pub fn down(repo: anytype) !void { + try repo.dropTable("albumsongsartists", .{}); +} diff --git a/src/app/database/migrations/2025-04-07_14-40-17_create_artistalbums.zig b/src/app/database/migrations/2025-04-07_14-40-17_create_artistalbums.zig new file mode 100644 index 0000000..3c3ea7f --- /dev/null +++ b/src/app/database/migrations/2025-04-07_14-40-17_create_artistalbums.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const jetquery = @import("jetquery"); +const t = jetquery.schema.table; + +pub fn up(repo: anytype) !void { + try repo.createTable( + "artistalbums", + &.{ + t.primaryKey("id", .{ .type = .bigint }), + t.column("album_id", .bigint, .{ .reference = .{ "albums", "id" } }), + t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }), + t.timestamps(.{}), + }, + .{}, + ); +} + +pub fn down(repo: anytype) !void { + try repo.dropTable("artistalbums", .{}); +} diff --git a/src/app/database/migrations/2025-06-06_19-46-32_create_artistsongs.zig b/src/app/database/migrations/2025-06-06_19-46-32_create_artistsongs.zig new file mode 100644 index 0000000..7d0a6c1 --- /dev/null +++ b/src/app/database/migrations/2025-06-06_19-46-32_create_artistsongs.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const jetquery = @import("jetquery"); +const t = jetquery.schema.table; + +pub fn up(repo: anytype) !void { + try repo.createTable( + "artistsongs", + &.{ + t.primaryKey("id", .{ .type = .bigint }), + t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }), + t.column("song_id", .bigint, .{ .reference = .{ "songs", "id" } }), + t.timestamps(.{}), + }, + .{}, + ); +} + +pub fn down(repo: anytype) !void { + try repo.dropTable("artistsongs", .{}); +} diff --git a/src/app/database/migrations/2025-02-17_22-38-45_create_ratings.zig b/src/app/database/migrations/2025-06-22_18-32-57_create_songratings.zig similarity index 56% rename from src/app/database/migrations/2025-02-17_22-38-45_create_ratings.zig rename to src/app/database/migrations/2025-06-22_18-32-57_create_songratings.zig index d7ac939..d11d6a8 100644 --- a/src/app/database/migrations/2025-02-17_22-38-45_create_ratings.zig +++ b/src/app/database/migrations/2025-06-22_18-32-57_create_songratings.zig @@ -4,11 +4,12 @@ const t = jetquery.schema.table; pub fn up(repo: anytype) !void { try repo.createTable( - "ratings", + "songratings", &.{ t.primaryKey("id", .{}), - t.column("reference_id", .integer, .{}), - t.column("score", .float, .{}), + t.column("song", .bigint, .{ .reference = .{ "songs", "id" } }), + t.column("rating", .smallint, .{ .optional = true }), + t.column("rating_text", .text, .{ .optional = true }), t.column("date", .datetime, .{}), t.timestamps(.{}), }, @@ -17,5 +18,5 @@ pub fn up(repo: anytype) !void { } pub fn down(repo: anytype) !void { - try repo.dropTable("ratings", .{}); + try repo.dropTable("songratings", .{}); } diff --git a/src/app/database/migrations/2025-06-22_18-33-34_create_albumratings.zig b/src/app/database/migrations/2025-06-22_18-33-34_create_albumratings.zig new file mode 100644 index 0000000..ecb31a4 --- /dev/null +++ b/src/app/database/migrations/2025-06-22_18-33-34_create_albumratings.zig @@ -0,0 +1,22 @@ +const std = @import("std"); +const jetquery = @import("jetquery"); +const t = jetquery.schema.table; + +pub fn up(repo: anytype) !void { + try repo.createTable( + "albumratings", + &.{ + t.primaryKey("id", .{}), + t.column("album", .bigint, .{ .reference = .{ "albums", "id" } }), + t.column("rating", .smallint, .{ .optional = true }), + t.column("rating_text", .text, .{ .optional = true }), + t.column("date", .datetime, .{}), + t.timestamps(.{}), + }, + .{}, + ); +} + +pub fn down(repo: anytype) !void { + try repo.dropTable("albumratings", .{}); +} diff --git a/src/app/database/migrations/2025-06-22_18-34-00_create_artistratings.zig b/src/app/database/migrations/2025-06-22_18-34-00_create_artistratings.zig new file mode 100644 index 0000000..d47f130 --- /dev/null +++ b/src/app/database/migrations/2025-06-22_18-34-00_create_artistratings.zig @@ -0,0 +1,22 @@ +const std = @import("std"); +const jetquery = @import("jetquery"); +const t = jetquery.schema.table; + +pub fn up(repo: anytype) !void { + try repo.createTable( + "artistratings", + &.{ + t.primaryKey("id", .{}), + t.column("artist", .bigint, .{ .reference = .{ "artists", "id" } }), + t.column("rating", .smallint, .{ .optional = true }), + t.column("rating_text", .text, .{ .optional = true }), + t.column("date", .datetime, .{}), + t.timestamps(.{}), + }, + .{}, + ); +} + +pub fn down(repo: anytype) !void { + try repo.dropTable("artistratings", .{}); +} diff --git a/src/app/jobs/add_album.zig b/src/app/jobs/add_album.zig new file mode 100644 index 0000000..a5d46a5 --- /dev/null +++ b/src/app/jobs/add_album.zig @@ -0,0 +1,31 @@ +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; + const artists = params.getT(.array, "artists").?.items(); + const album_id = try (params.get("album_hash").?).coerce(u64); + + for (artists) |artist| { + const artist_name = try artist.coerce([]const u8); + const artist_id = std.hash.Fnv1a_64.hash(artist_name); + const paired = @as(i64, @bitCast(@mod(@divFloor((artist_id +% album_id) *% (artist_id +% album_id +% 1), 2) +% album_id, std.math.maxInt(u64)))); + const aa_query = try jetzig.database.Query(.Artistalbum) + .find(paired).execute(env.repo); + + if (aa_query == null) { + try jetzig.database.Query(.Artistalbum) + .insert(.{ .id = paired, .artist_id = @as(i64, @bitCast(artist_id)), .album_id = @as(i64, @bitCast(album_id)) }) + .execute(env.repo); + } + } +} diff --git a/src/app/jobs/add_artist.zig b/src/app/jobs/add_artist.zig new file mode 100644 index 0000000..06b5b25 --- /dev/null +++ b/src/app/jobs/add_artist.zig @@ -0,0 +1,25 @@ +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; + const artist = params.getT(.string, "artist").?; + const id = @as(i64, @bitCast(std.hash.Fnv1a_64.hash(artist))); + const artist_query = try jetzig.database.Query(.Artist) + .find(id).execute(env.repo); + + if (artist_query == null) { + try jetzig.database.Query(.Artist) + .insert(.{ .id = id, .name = artist }) + .execute(env.repo); + } +} diff --git a/src/app/jobs/add_song.zig b/src/app/jobs/add_song.zig new file mode 100644 index 0000000..6f7c504 --- /dev/null +++ b/src/app/jobs/add_song.zig @@ -0,0 +1,34 @@ +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; + //const album = params.getT(.string, "album").?; + const as_id = try (params.get("as_hash").?).coerce(u64); + const album_artists = params.getT(.array, "album_artists").?.items(); + // Will use this eventually, but not now + // const track_artists = params.getT(.array,"track_artists"); + + for (album_artists) |artist| { + const artist_name = try artist.coerce([]const u8); + const artist_id = std.hash.Fnv1a_64.hash(artist_name); + const asa_id = @as(i64, @bitCast(@mod(@divFloor((as_id +% artist_id) *% (as_id +% artist_id +% 1), 2) +% artist_id, std.math.maxInt(u64)))); + const asa_query = try jetzig.database.Query(.Albumsongsartist) + .find(asa_id).execute(env.repo); + + if (asa_query == null) { + try jetzig.database.Query(.Albumsongsartist) + .insert(.{ .id = asa_id, .albumsong_id = @as(i64, @bitCast(as_id)), .artist_id = @as(i64, @bitCast(artist_id)) }) + .execute(env.repo); + } + } +} diff --git a/src/app/jobs/process_rule.zig b/src/app/jobs/process_rule.zig new file mode 100644 index 0000000..6beb880 --- /dev/null +++ b/src/app/jobs/process_rule.zig @@ -0,0 +1,53 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); +const Data = @import("../../types.zig"); + +pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void { + _ = env; + + const rule = try std.json.parseFromSliceLeaky(Data.Rule, allocator, try params.toJson(), .{ .ignore_unknown_fields = true }); + + const file_read: std.fs.File = std.fs.cwd().openFile("rules.json", .{}) catch |read_err| switch (read_err) { + error.FileNotFound => { + const file = std.fs.cwd().createFile("rules.json", .{ .read = true, .exclusive = true }) catch |write_err| switch (write_err) { + error.PathAlreadyExists => unreachable, + else => { + std.log.debug("{any} while writing file", .{write_err}); + return; + }, + }; + const out_rules = &[_]Data.Rule{rule}; + const out = try std.json.stringifyAlloc(allocator, out_rules, .{}); + try file.writeAll(out); + file.close(); + return; + }, + else => { + std.log.debug("{any} while reading file", .{read_err}); + return; + }, + }; + + var rules = std.ArrayList(Data.Rule).init(allocator); + + const file_content = try file_read.readToEndAlloc(allocator, 16_000_000); + file_read.close(); + + const file_write: std.fs.File = try std.fs.cwd().openFile("rules.json", .{ .mode = .write_only }); + if (file_content.len == 0) { + const out_rules = &[_]Data.Rule{rule}; + const out = try std.json.stringifyAlloc(allocator, out_rules, .{}); + try file_write.writeAll(out); + file_write.close(); + return; + } + const content: []Data.Rule = try std.json.parseFromSliceLeaky([]Data.Rule, allocator, file_content, .{}); + try rules.appendSlice(content); + try rules.append(rule); + + const out = try std.json.stringifyAlloc(allocator, rules.items, .{}); + + try file_write.writeAll(out); + file_write.close(); + return; +} diff --git a/src/app/jobs/process_scrobbles.zig b/src/app/jobs/process_scrobbles.zig deleted file mode 100644 index 1314465..0000000 --- a/src/app/jobs/process_scrobbles.zig +++ /dev/null @@ -1,131 +0,0 @@ -const std = @import("std"); -const jetzig = @import("jetzig"); -const jetquery = @import("jetzig").jetquery; -const Scrobble = @import("../../types.zig").LastFMScrobble; -const lastfm = @import("../../types.zig").LastFM; - -// 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; - //_ = env; - - if (params.getT(.array, "scrobbles")) |scrobbles| { - for (scrobbles.items()) |item| { - //const fixed_date: u32 = @as(u32, item.getT(.integer, "date").?); - const scrobble: Scrobble = .{ .track = item.getT(.string, "track").?, .artist = item.getT(.string, "artist").?, .album = item.getT(.string, "album") orelse "", .date = @as(u64, @bitCast(@as(i64, @truncate(item.getT(.integer, "date").? * 1000)))) }; - - // Make hashes - //const album_hash = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(scrobble.album))); - //const artist_hash = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(scrobble.artist))); - //const song_hash = @as(i32, @bitCast(std.hash.Fnv1a_32.hash(scrobble.track))); - - // 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))); - - // 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.) - - // 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. - - // Artist: Artist hash. If two artists have the same name, - // then a descriptive string can be provided to - // differentiate after the fact, or in a rule. - - //var album_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; - } - - 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; - } - - 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); - } - - 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 new file mode 100644 index 0000000..8b3ae66 --- /dev/null +++ b/src/app/jobs/process_scrobbles2.zig @@ -0,0 +1,131 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); + +// The `run` function for a job is invoked every time the job is processed by a queue worker +// (or by the Jetzig server if the job is processed in-line). +// +// Arguments: +// * allocator: Arena allocator for use during the job execution process. +// * params: Params assigned to a job (from a request, values added to response data). +// * env: Provides the following fields: +// - logger: Logger attached to the same stream as the Jetzig server. +// - environment: Enum of `{ production, development }`. +pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void { + _ = allocator; + + for (params.getT(.object, "tracks").?.items()) |track| { + const id = try std.fmt.parseInt(i64, track.key, 10); + + const track_query = try jetzig.database.Query(.Song) + .find(id).execute(env.repo); + + if (track_query == null) { + const name = try track.value.coerce([]const u8); + try jetzig.database.Query(.Song) + .insert(.{ .id = id, .name = name, .length = null, .hidden = false }) + .execute(env.repo); + } + } + + for (params.getT(.object, "albums").?.items()) |album| { + const id = try std.fmt.parseInt(i64, album.key, 10); + + const album_query = try jetzig.database.Query(.Album) + .find(id).execute(env.repo); + + if (album_query == null) { + const name = try album.value.coerce([]const u8); + try jetzig.database.Query(.Album) + .insert(.{ .id = id, .name = name, .length = null }) + .execute(env.repo); + } + } + + for (params.getT(.object, "artists").?.items()) |artist| { + const id = try std.fmt.parseInt(i64, artist.key, 10); + + const artist_query = try jetzig.database.Query(.Artist) + .find(id).execute(env.repo); + + if (artist_query == null) { + const name = try artist.value.coerce([]const u8); + try jetzig.database.Query(.Artist) + .insert(.{ .id = id, .name = name }) + .execute(env.repo); + } + } + + for (params.getT(.object, "albumsongs").?.items()) |as| { + const id = try std.fmt.parseInt(i64, as.key, 10); + + const as_query = try jetzig.database.Query(.Albumsong) + .find(id).execute(env.repo); + + if (as_query == null) { + const track_id = @as(i64, @intCast(as.value.getT(.integer, "song").?)); + const album_id = @as(i64, @intCast(as.value.getT(.integer, "album").?)); + try jetzig.database.Query(.Albumsong) + .insert(.{ .id = id, .song_id = track_id, .album_id = album_id }) + .execute(env.repo); + } + + const scrobbles = as.value.getT(.array, "scrobbles").?; + for (scrobbles.items()) |date| { + try jetzig.database.Query(.Scrobble).insert(.{ .albumsong = id, .datetime = date }) + .execute(env.repo); + } + } + + for (params.getT(.object, "artistalbums").?.items()) |aa| { + const id = try std.fmt.parseInt(i64, aa.key, 10); + + const aa_query = try jetzig.database.Query(.Artistalbum) + .find(id).execute(env.repo); + + if (aa_query == null) { + const artist_id = @as(i64, @intCast(aa.value.getT(.integer, "artist").?)); + const album_id = @as(i64, @intCast(aa.value.getT(.integer, "album").?)); + try jetzig.database.Query(.Artistalbum) + .insert(.{ .id = id, .artist_id = artist_id, .album_id = album_id }) + .execute(env.repo); + } + } + + for (params.getT(.object, "albumsongsartists").?.items()) |asa| { + const id = try std.fmt.parseInt(i64, asa.key, 10); + + const asa_query = try jetzig.database.Query(.Albumsongsartist) + .find(id).execute(env.repo); + + if (asa_query == null) { + const albumsong_id = @as(i64, @intCast(asa.value.getT(.integer, "albumsong").?)); + const artist_id = @as(i64, @intCast(asa.value.getT(.integer, "artist").?)); + try jetzig.database.Query(.Albumsongsartist) + .insert(.{ .id = id, .albumsong_id = albumsong_id, .artist_id = artist_id }) + .execute(env.repo); + } + } + + //for ((params.getT(.object, "albumsongsartists")).?.items(.object)) |asa| { + // const id = try std.fmt.parseInt(i64, asa.key, 10); + // const albumsong_id = asa.value.getT(.integer, "albumsong"); + // const track_artist_id = asa.value.getT(.integer, "artist"); + + // const albumsongartist = try jetzig.database.Query(.Albumsongsartist) + // .find(id) + // .select(.{.id}).execute(env.repo); + + // if (albumsongartist == null) { + // var artist_id = try jetzig.database.Query(.Artist) + // .find(track_artist_id) + // .select(.{.id}).execute(env.repo); + // + // if (artist_id == null) { + // const artist = params.chain(.{"artists",}) + // artist_id = try jetzig.database.Query(.Artist) + // .insert(.{ .id = track_artist_hash, .name = scrobble_track_artist, .disambiguation = null }) + // .execute(env.repo); + // } + // } + //} +} diff --git a/src/app/middleware/DemoMiddleware.zig b/src/app/middleware/DemoMiddleware.zig deleted file mode 100644 index a6758d2..0000000 --- a/src/app/middleware/DemoMiddleware.zig +++ /dev/null @@ -1,65 +0,0 @@ -/// Demo middleware. Assign middleware by declaring `pub const middleware` in the -/// `jetzig_options` defined in your application's `src/main.zig`. -/// -/// Middleware is called before and after the request, providing full access to the active -/// request, allowing you to execute any custom code for logging, tracking, inserting response -/// headers, etc. -/// -/// This middleware is configured in the demo app's `src/main.zig`: -/// -/// ``` -/// pub const jetzig_options = struct { -/// pub const middleware: []const type = &.{@import("app/middleware/DemoMiddleware.zig")}; -/// }; -/// ``` -const std = @import("std"); -const jetzig = @import("jetzig"); - -/// Define any custom data fields you want to store here. Assigning to these fields in the `init` -/// function allows you to access them in various middleware callbacks defined below, where they -/// can also be modified. -my_custom_value: []const u8, - -const Self = @This(); - -/// Initialize middleware. -pub fn init(request: *jetzig.http.Request) !*Self { - var middleware = try request.allocator.create(Self); - middleware.my_custom_value = "initial value"; - return middleware; -} - -/// Invoked immediately after the request is received but before it has started processing. -/// Any calls to `request.render` or `request.redirect` will prevent further processing of the -/// request, including any other middleware in the chain. -pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { - try request.server.logger.DEBUG( - "[DemoMiddleware:afterRequest] my_custom_value: {s}", - .{self.my_custom_value}, - ); - self.my_custom_value = @tagName(request.method); -} - -/// Invoked immediately before the response renders to the client. -/// The response can be modified here if needed. -pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { - try request.server.logger.DEBUG( - "[DemoMiddleware:beforeResponse] my_custom_value: {s}, response status: {s}", - .{ self.my_custom_value, @tagName(response.status_code) }, - ); -} - -/// Invoked immediately after the response has been finalized and sent to the client. -/// Response data can be accessed for logging, but any modifications will have no impact. -pub fn afterResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { - _ = self; - _ = response; - try request.server.logger.DEBUG("[DemoMiddleware:afterResponse] response completed", .{}); -} - -/// Invoked after `afterResponse` is called. Use this function to do any clean-up. -/// Note that `request.allocator` is an arena allocator, so any allocations are automatically -/// freed before the next request starts processing. -pub fn deinit(self: *Self, request: *jetzig.http.Request) void { - request.allocator.destroy(self); -} diff --git a/src/app/views/albums.zig b/src/app/views/albums.zig index 634f414..1a3c799 100644 --- a/src/app/views/albums.zig +++ b/src/app/views/albums.zig @@ -1,155 +1,62 @@ 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"); +const decode = @import("../../date_fmt.zig").urlDecode; pub fn index(request: *jetzig.Request) !jetzig.View { var root = try request.data(.object); - var albums_view = try root.put("albums", .array); - const albums = try jetzig.database.Query(.Album) - .select(.{ .id, .name }) - .include(.albumartists, .{ .select = .{.artist_id} }) - .include(.scrobbles, .{ .select = .{.id} }) - .orderBy(.{ .name = .asc }) - .all(request.repo); - //const albums = try request.repo.all(query); - for (albums) |album| { - var album_view = try albums_view.append(.object); + const albums = try queries.entityQueryResult(request, queries.loadQuery(.album, .entities), .{}); + try root.put("albums", albums); - var artist_infos = try album_view.put("artist_info", .array); - for (album.albumartists) |artist| { - var artist_info = try artist_infos.append(.object); - const artist_data = try jetzig.database.Query(.Artist) - .find(artist.artist_id) - .select(.{ .id, .name }) - .execute(request.repo); - try artist_info.put("name", artist_data.?.name); - try artist_info.put("id", artist_data.?.id); - } - - try album_view.put("name", album.name); - try album_view.put("url", album.id); - try album_view.put("scrobbles", (album.scrobbles).len); - } return request.render(.ok); } pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { - const album = try jetzig.database.Query(.Album) - .find(id) - .select(.{ .id, .name }) - .execute(request.repo); var root = try request.data(.object); - try root.put("album", album.?.name); - var songs_view = try root.put("songs", .array); - const query = jetzig.database.Query(.Albumsong) - .select(.{.id}) - .include(.song, .{ .select = .{ .name, .id } }) - .join(.inner, .album) - .where(.{ .album = .{ .id = id } }); - const songs = try request.repo.all(query); - for (songs) |song| { - const scrobbles = try jetzig.database.Query(.Scrobble) - .where(.{ .song_id = song.song.id }) - .count() - .execute(request.repo); - var song_view = try songs_view.append(.object); - try song_view.put("name", song.song.name); - try song_view.put("url", song.song.id); - try song_view.put("scrobbles", scrobbles); - } + const id_int = blk: { + const rn = try decode(request.allocator, id); + // Try to find the song by name + const queried_albums = try jetzig.database.Query(.Album).select(.{.id}).where(.{ .name = rn }).all(request.repo); + if (queried_albums.len == 0) { + // Either we've been given an id in the db, or the song doesn't exist + break :blk std.fmt.parseInt(i64, id, 10) catch return request.fail(.not_found); + } else if (queried_albums.len == 1) { + // It can only be one song + break :blk queried_albums[0].id; + } else { + // It could be a variety of songs + const albums = try queries.entityQueryResult(request, comptime queries.loadQuery(.album, .entities_by_name), .{rn}); + try root.put("name", rn); + try root.put("albums", albums); + try root.put("disambiguation", true); + return request.render(.ok); + } + }; + const album = try queries.entityQueryResult(request, comptime queries.loadQuery(.album, .entity_info), .{id_int}); + try root.put("album", album); + + const scrobbles = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .get_scrobbles), .{id_int}); + try root.put("scrobbles", scrobbles); + + const songs = try queries.entityQueryResult(request, comptime queries.loadQuery(.album, .get_songs), .{id_int}); + try root.put("songs", songs); + + const firstlast = try queries.entityQueryResult(request, comptime queries.loadQuery(.album, .firstlast), .{id_int}); + try root.put("firstlast", firstlast); + + const timescale = try queries.entityQueryResult(request, comptime queries.loadQuery(.album, .timescale), .{id_int}); + try root.put("yearly", timescale); + + const ratings = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .get_ratings), .{id_int}); + try root.put("reviews", ratings); + + //const peak = try queries.entityQueryResult(request, comptime queries.loadQuery(.album, .peak), .{id_int}); + //try root.put("peak", peak); + return request.render(.ok); } - -pub fn new(request: *jetzig.Request) !jetzig.View { - return request.render(.ok); -} - -pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn post(request: *jetzig.Request) !jetzig.View { - return request.render(.created); -} - -pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -test "index" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/album", .{}); - try response.expectStatus(.ok); -} - -test "get" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/album/example-id", .{}); - try response.expectStatus(.ok); -} - -test "new" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/album/new", .{}); - try response.expectStatus(.ok); -} - -test "edit" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/album/example-id/edit", .{}); - try response.expectStatus(.ok); -} - -test "post" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.POST, "/album", .{}); - try response.expectStatus(.created); -} - -test "put" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.PUT, "/album/example-id", .{}); - try response.expectStatus(.ok); -} - -test "patch" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.PATCH, "/album/example-id", .{}); - try response.expectStatus(.ok); -} - -test "delete" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.DELETE, "/album/example-id", .{}); - try response.expectStatus(.ok); -} diff --git a/src/app/views/albums/get.zmpl b/src/app/views/albums/get.zmpl index 8084038..7f40eb5 100644 --- a/src/app/views/albums/get.zmpl +++ b/src/app/views/albums/get.zmpl @@ -1,19 +1,61 @@ +@zig { + const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; + const columns: ColumnChoices = &.{.song, .scrobbles}; + const dis_columns: ColumnChoices = &.{.album, .artistlist, .scrobbles}; +} + + @partial partials/header -

{{.album}}

- - - -@for (.songs) |song| { - - - - +@if ($.disambiguation) +

{{.name}} (disambiguation)

+@partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: dis_columns) +@else + +@zig { + const reviews = try zmpl.coerceArray(".reviews"); } -
Name
{{song.name}}{{song.scrobbles}}
+
+

{{.album.album_name}}

+

{{.album.artist_name}}

+
+ +
+
+ @if ($.album.is_tie) +
{{.album.scrobbles}} scrobbles ({{.album.rank}} place, tied)
+ @else +
{{.album.scrobbles}} scrobbles ({{.album.rank}} place)
+ @end +
{{.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) +
+
+

Rating

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

Albums

- - - - - - - - - -@for (.albums) |album| { - - - - - -} - -
NameArtist(s)Scrobbles
{{album.name}} - @for (album.get("artist_info").?) |ai| { - {{ai.name}} - } - {{album.scrobbles}}
- - +@partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: columns) \ No newline at end of file diff --git a/src/app/views/artists.zig b/src/app/views/artists.zig index 78058a7..3136def 100644 --- a/src/app/views/artists.zig +++ b/src/app/views/artists.zig @@ -1,141 +1,61 @@ 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"); +const decode = @import("../../date_fmt.zig").urlDecode; pub fn index(request: *jetzig.Request) !jetzig.View { var root = try request.data(.object); - var artists_view = try root.put("artists", .array); - const artists = try jetzig.database.Query(.Artist) - .select(.{ .id, .name }) - .include(.scrobbleartists, .{ .select = .{.id} }) - .orderBy(.{ .name = .asc }) - .all(request.repo); - for (artists) |artist| { - var artist_view = try artists_view.append(.object); - try artist_view.put("name", artist.name); - try artist_view.put("url", artist.id); - try artist_view.put("scrobbles", (artist.scrobbleartists).len); - } + const artists = try queries.entityQueryResult(request, queries.loadQuery(.artist, .entities), .{}); + + try root.put("artists", artists); return request.render(.ok); } pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { - const artist = try jetzig.database.Query(.Artist) - .find(id) - .select(.{ .id, .name }) - .execute(request.repo); var root = try request.data(.object); - try root.put("artist", artist.?.name); - var albums_view = try root.put("albums", .array); - const query = jetzig.database.Query(.Albumartist) - .select(.{.id}) - .include(.album, .{ .select = .{ .name, .id } }) - .join(.inner, .artist) - .where(.{ .artist = .{ .id = id } }); - const albums = try request.repo.all(query); - for (albums) |album| { - const scrobbles = try jetzig.database.Query(.Scrobble) - .where(.{ .album_id = album.album.id }) - .count() - .execute(request.repo); - var album_view = try albums_view.append(.object); - try album_view.put("name", album.album.name); - try album_view.put("url", album.album.id); - try album_view.put("scrobbles", scrobbles); - } + const id_int = blk: { + const rn = try decode(request.allocator, id); + // Try to find the song by name + const queried_artists = try jetzig.database.Query(.Artist).select(.{.id}).where(.{ .name = rn }).all(request.repo); + if (queried_artists.len == 0) { + // Either we've been given an id in the db, or the song doesn't exist + break :blk std.fmt.parseInt(i64, id, 10) catch return request.fail(.not_found); + } else if (queried_artists.len == 1) { + // It can only be one song + break :blk queried_artists[0].id; + } else { + // It could be a variety of songs + const artists = try queries.entityQueryResult(request, comptime queries.loadQuery(.artist, .entities_by_name), .{rn}); + try root.put("name", rn); + try root.put("artists", artists); + try root.put("disambiguation", true); + return request.render(.ok); + } + }; + + const artist = try queries.entityQueryResult(request, comptime queries.loadQuery(.artist, .entity_info), .{id_int}); + try root.put("artist", artist); + + const albums = try queries.entityQueryResult(request, comptime queries.loadQuery(.artist, .get_albums), .{id_int}); + try root.put("albums", albums); + + const appears = try queries.entityQueryResult(request, comptime queries.loadQuery(.artist, .appears), .{id_int}); + try root.put("appears", appears); + + const firstlast = try queries.entityQueryResult(request, comptime queries.loadQuery(.artist, .firstlast), .{id_int}); + try root.put("firstlast", firstlast); + + const timescale = try queries.entityQueryResult(request, comptime queries.loadQuery(.artist, .timescale), .{id_int}); + try root.put("yearly", timescale); + + //const peak = try queries.entityQueryResult(request, comptime queries.loadQuery(.artist, .peak), .{id_int}); + //try root.put("peak", peak); + return request.render(.ok); } - -pub fn new(request: *jetzig.Request) !jetzig.View { - return request.render(.ok); -} - -pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn post(request: *jetzig.Request) !jetzig.View { - return request.render(.created); -} - -pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -test "index" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/artist", .{}); - try response.expectStatus(.ok); -} - -test "get" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/artist/example-id", .{}); - try response.expectStatus(.ok); -} - -test "new" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/artist/new", .{}); - try response.expectStatus(.ok); -} - -test "edit" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/artist/example-id/edit", .{}); - try response.expectStatus(.ok); -} - -test "post" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.POST, "/artist", .{}); - try response.expectStatus(.created); -} - -test "put" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.PUT, "/artist/example-id", .{}); - try response.expectStatus(.ok); -} - -test "patch" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.PATCH, "/artist/example-id", .{}); - try response.expectStatus(.ok); -} - -test "delete" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.DELETE, "/artist/example-id", .{}); - try response.expectStatus(.ok); -} diff --git a/src/app/views/artists/delete.zmpl b/src/app/views/artists/delete.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/artists/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/artists/edit.zmpl b/src/app/views/artists/edit.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/artists/edit.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/artists/get.zmpl b/src/app/views/artists/get.zmpl index 911d2c3..89663f8 100644 --- a/src/app/views/artists/get.zmpl +++ b/src/app/views/artists/get.zmpl @@ -1,31 +1,39 @@ +@zig { + const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; + const columns: ColumnChoices = &.{.album, .scrobbles}; + const dis_columns: ColumnChoices = &.{.artist, .scrobbles}; +} + - @partial partials/header -

{{.artist}}

- - - - - - - -@for (.albums) |album| { - - - - -} - -
NameScrobbles
{{album.name}}{{album.scrobbles}}
- - +@if ($.disambiguation) +

{{.name}} (disambiguation)

+@partial partials/newtable(T: ColumnChoices, table_data: .artists, columns: dis_columns) +@else + +

{{.artist.artist_name}}

+
+ @if ($.artist.is_tie) +
{{.artist.scrobbles}} scrobbles ({{.artist.rank}} place, tied)
+ @else +
{{.artist.scrobbles}} scrobbles ({{.artist.rank}} place)
+ @end +
{{.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) + +@end \ No newline at end of file diff --git a/src/app/views/artists/index.zmpl b/src/app/views/artists/index.zmpl index 6854e07..0648c91 100644 --- a/src/app/views/artists/index.zmpl +++ b/src/app/views/artists/index.zmpl @@ -1,34 +1,15 @@ +@zig { + const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; + const columns: ColumnChoices = &.{.artist, .scrobbles}; +} + - @partial partials/header

Artists

- - - - - - - - -@for (.artists) |artist| { - - - - -} - -
NameScrobbles
{{artist.name}}{{artist.scrobbles}}
- - +@partial partials/newtable(T: ColumnChoices, table_data: .artists, columns: columns) \ No newline at end of file diff --git a/src/app/views/artists/new.zmpl b/src/app/views/artists/new.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/artists/new.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/artists/patch.zmpl b/src/app/views/artists/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/artists/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/artists/post.zmpl b/src/app/views/artists/post.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/artists/post.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/artists/put.zmpl b/src/app/views/artists/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/artists/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/collection.zig b/src/app/views/collection.zig index 8125efd..1b0b502 100644 --- a/src/app/views/collection.zig +++ b/src/app/views/collection.zig @@ -11,26 +11,3 @@ pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig _ = id; return request.render(.ok); } - -pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - return request.render(.created); -} - -pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} diff --git a/src/app/views/collection/delete.zmpl b/src/app/views/collection/delete.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/collection/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/collection/patch.zmpl b/src/app/views/collection/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/collection/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/collection/post.zmpl b/src/app/views/collection/post.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/collection/post.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/collection/put.zmpl b/src/app/views/collection/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/collection/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/concerts.zig b/src/app/views/concerts.zig index 8125efd..1b0b502 100644 --- a/src/app/views/concerts.zig +++ b/src/app/views/concerts.zig @@ -11,26 +11,3 @@ pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig _ = id; return request.render(.ok); } - -pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - return request.render(.created); -} - -pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} diff --git a/src/app/views/concerts/delete.zmpl b/src/app/views/concerts/delete.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/concerts/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/concerts/patch.zmpl b/src/app/views/concerts/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/concerts/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/concerts/post.zmpl b/src/app/views/concerts/post.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/concerts/post.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/concerts/put.zmpl b/src/app/views/concerts/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/concerts/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/search.zig b/src/app/views/groups.zig similarity index 81% rename from src/app/views/search.zig rename to src/app/views/groups.zig index be5f7e0..85505c8 100644 --- a/src/app/views/search.zig +++ b/src/app/views/groups.zig @@ -43,7 +43,7 @@ test "index" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.GET, "/search", .{}); + const response = try app.request(.GET, "/groups", .{}); try response.expectStatus(.ok); } @@ -51,7 +51,7 @@ test "get" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.GET, "/search/example-id", .{}); + const response = try app.request(.GET, "/groups/example-id", .{}); try response.expectStatus(.ok); } @@ -59,7 +59,7 @@ test "new" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.GET, "/search/new", .{}); + const response = try app.request(.GET, "/groups/new", .{}); try response.expectStatus(.ok); } @@ -67,7 +67,7 @@ test "edit" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.GET, "/search/example-id/edit", .{}); + const response = try app.request(.GET, "/groups/example-id/edit", .{}); try response.expectStatus(.ok); } @@ -75,7 +75,7 @@ test "post" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.POST, "/search", .{}); + const response = try app.request(.POST, "/groups", .{}); try response.expectStatus(.created); } @@ -83,7 +83,7 @@ test "put" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.PUT, "/search/example-id", .{}); + const response = try app.request(.PUT, "/groups/example-id", .{}); try response.expectStatus(.ok); } @@ -91,7 +91,7 @@ test "patch" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.PATCH, "/search/example-id", .{}); + const response = try app.request(.PATCH, "/groups/example-id", .{}); try response.expectStatus(.ok); } @@ -99,6 +99,6 @@ test "delete" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.DELETE, "/search/example-id", .{}); + const response = try app.request(.DELETE, "/groups/example-id", .{}); try response.expectStatus(.ok); } diff --git a/src/app/views/albums/delete.zmpl b/src/app/views/groups/delete.zmpl similarity index 100% rename from src/app/views/albums/delete.zmpl rename to src/app/views/groups/delete.zmpl diff --git a/src/app/views/albums/edit.zmpl b/src/app/views/groups/edit.zmpl similarity index 100% rename from src/app/views/albums/edit.zmpl rename to src/app/views/groups/edit.zmpl diff --git a/src/app/views/rules/get.zmpl b/src/app/views/groups/get.zmpl similarity index 100% rename from src/app/views/rules/get.zmpl rename to src/app/views/groups/get.zmpl diff --git a/src/app/views/groups/index.zmpl b/src/app/views/groups/index.zmpl new file mode 100644 index 0000000..1522924 --- /dev/null +++ b/src/app/views/groups/index.zmpl @@ -0,0 +1,11 @@ + + + + +@partial partials/header + +

Merge Songs

+
+ +
+
\ No newline at end of file diff --git a/src/app/views/albums/new.zmpl b/src/app/views/groups/new.zmpl similarity index 100% rename from src/app/views/albums/new.zmpl rename to src/app/views/groups/new.zmpl diff --git a/src/app/views/albums/patch.zmpl b/src/app/views/groups/patch.zmpl similarity index 100% rename from src/app/views/albums/patch.zmpl rename to src/app/views/groups/patch.zmpl diff --git a/src/app/views/albums/post.zmpl b/src/app/views/groups/post.zmpl similarity index 100% rename from src/app/views/albums/post.zmpl rename to src/app/views/groups/post.zmpl diff --git a/src/app/views/albums/put.zmpl b/src/app/views/groups/put.zmpl similarity index 100% rename from src/app/views/albums/put.zmpl rename to src/app/views/groups/put.zmpl diff --git a/src/app/views/lists.zig b/src/app/views/lists.zig index 8125efd..aecf0dc 100644 --- a/src/app/views/lists.zig +++ b/src/app/views/lists.zig @@ -16,21 +16,3 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { _ = data; return request.render(.created); } - -pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} diff --git a/src/app/views/lists/delete.zmpl b/src/app/views/lists/delete.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/lists/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/lists/patch.zmpl b/src/app/views/lists/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/lists/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/lists/put.zmpl b/src/app/views/lists/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/lists/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/partials/_firstlast_listens.zmpl b/src/app/views/partials/_firstlast_listens.zmpl new file mode 100644 index 0000000..3cf3ac9 --- /dev/null +++ b/src/app/views/partials/_firstlast_listens.zmpl @@ -0,0 +1,11 @@ +@args firstlast: *ZmplValue + +@zig { + const songs = firstlast.items(.array); +} + +
+First listen: {{songs[0].song.name}} ({{songs[0].date}}) +
+Most recent listen: {{songs[1].song.name}} ({{songs[1].date}}) +
\ No newline at end of file diff --git a/src/app/views/partials/_header.zmpl b/src/app/views/partials/_header.zmpl index bf80e53..3f9089d 100644 --- a/src/app/views/partials/_header.zmpl +++ b/src/app/views/partials/_header.zmpl @@ -1,7 +1,11 @@ Zuletzt +Artists +Albums +Songs Scrobbles Concerts Collection Ratings Lists +Groups
\ No newline at end of file diff --git a/src/app/views/partials/_history.zmpl b/src/app/views/partials/_history.zmpl deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/views/partials/_newtable.zmpl b/src/app/views/partials/_newtable.zmpl new file mode 100644 index 0000000..f9dc618 --- /dev/null +++ b/src/app/views/partials/_newtable.zmpl @@ -0,0 +1,75 @@ +@args T: type, table_data: *ZmplValue, columns: T + +
+ + + +@zig { + for (columns) |header| { + switch (header) { + .song => { + + }, + .album => { + + }, + .artist => { + + }, + .artistlist => { + + }, + .scrobbles => { + + }, + .date => { + + } + } + } +} + + + +@zig { + const array = table_data.items(.array); + for (array) |row| { + + for (columns) |header| { + switch (header) { + .song => { + + }, + .album => { + + }, + .artist => { + + }, + .artistlist => { + + }, + .scrobbles => { + + }, + .date =>{ + + } + } + } + + } +} + +
SongAlbumArtistArtist(s)ScrobblesDate
+ {{row.song.name}} + + {{row.album.name}} + + {{row.artist.name}} + + @for (row.get("artistlist").?) |artist| { + {{artist.name}} + } + {{row.scrobbles}}{{row.date}}
+
\ No newline at end of file diff --git a/src/app/views/partials/_random.zmpl b/src/app/views/partials/_random.zmpl deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/views/partials/_recent.zmpl b/src/app/views/partials/_recent.zmpl deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/views/partials/_table.zmpl b/src/app/views/partials/_table.zmpl deleted file mode 100644 index 8ea394d..0000000 --- a/src/app/views/partials/_table.zmpl +++ /dev/null @@ -1,18 +0,0 @@ -@args table_data: *ZmplValue, table_headers: *ZmplValue - - - -@for (table_headers) |text| { - -} - - - @for (table_data) |value| { - - - - - - - } -
{{text}}
{{value.track}}{{value.artist}}{{value.album}}{{value.date}}
\ No newline at end of file diff --git a/src/app/views/partials/_timescale.zmpl b/src/app/views/partials/_timescale.zmpl new file mode 100644 index 0000000..24ef925 --- /dev/null +++ b/src/app/views/partials/_timescale.zmpl @@ -0,0 +1,20 @@ +@args range: *ZmplValue + +
+ + + + + + + + + @for (range) |itm| { + + + + + } + +
YearScrobbles
{{itm.date}}:{{itm.scrobbles}}
+
\ No newline at end of file diff --git a/src/app/views/ratings.zig b/src/app/views/ratings.zig index 8125efd..aecf0dc 100644 --- a/src/app/views/ratings.zig +++ b/src/app/views/ratings.zig @@ -16,21 +16,3 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { _ = data; return request.render(.created); } - -pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} diff --git a/src/app/views/ratings/albums.zig b/src/app/views/ratings/albums.zig new file mode 100644 index 0000000..a57adfa --- /dev/null +++ b/src/app/views/ratings/albums.zig @@ -0,0 +1,14 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); +pub fn post(request: *jetzig.Request) !jetzig.View { + var root = try request.data(.object); + const params = try request.params(); + const id = params.getT(.integer, "album_id").?; + const score = if (params.getT(.integer, "score")) |score| @as(i16, @truncate(score)) else null; + const review = params.getT(.string, "review"); + try jetzig.database.Query(.Albumrating).insert(.{ .album = id, .rating = score, .rating_text = review, .date = @divFloor(request.start_time, 1_000) }).execute(request.repo); + try root.put("score", score); + try root.put("review", review); + + return request.render(.created); +} diff --git a/src/app/views/ratings/albums/post.zmpl b/src/app/views/ratings/albums/post.zmpl new file mode 100644 index 0000000..ca54fd7 --- /dev/null +++ b/src/app/views/ratings/albums/post.zmpl @@ -0,0 +1 @@ + {{.score}}: {{.review}} (Today) \ No newline at end of file diff --git a/src/app/views/ratings/delete.zmpl b/src/app/views/ratings/delete.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/ratings/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/ratings/patch.zmpl b/src/app/views/ratings/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/ratings/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/ratings/put.zmpl b/src/app/views/ratings/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/ratings/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/ratings/songs.zig b/src/app/views/ratings/songs.zig new file mode 100644 index 0000000..e77c2ac --- /dev/null +++ b/src/app/views/ratings/songs.zig @@ -0,0 +1,14 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); +pub fn post(request: *jetzig.Request) !jetzig.View { + var root = try request.data(.object); + const params = try request.params(); + const id = params.getT(.integer, "song_id").?; + const score = if (params.getT(.integer, "score")) |score| @as(i16, @truncate(score)) else null; + const review = params.getT(.string, "review"); + try jetzig.database.Query(.Songrating).insert(.{ .song = id, .rating = score, .rating_text = review, .date = @divFloor(request.start_time, 1_000) }).execute(request.repo); + try root.put("score", score); + try root.put("review", review); + + return request.render(.created); +} diff --git a/src/app/views/ratings/songs/post.zmpl b/src/app/views/ratings/songs/post.zmpl new file mode 100644 index 0000000..ca54fd7 --- /dev/null +++ b/src/app/views/ratings/songs/post.zmpl @@ -0,0 +1 @@ + {{.score}}: {{.review}} (Today) \ No newline at end of file diff --git a/src/app/views/rules.zig b/src/app/views/rules.zig index a2a222e..eb85a72 100644 --- a/src/app/views/rules.zig +++ b/src/app/views/rules.zig @@ -4,101 +4,38 @@ const jetzig = @import("jetzig"); pub fn index(request: *jetzig.Request) !jetzig.View { return request.render(.ok); } - -pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn new(request: *jetzig.Request) !jetzig.View { - return request.render(.ok); -} - -pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - pub fn post(request: *jetzig.Request) !jetzig.View { + const params = try request.params(); + + std.log.debug("{s}", .{try params.toJson()}); + + var job = try request.job("process_rule"); + + _ = try job.params.put("name", params.get("rule-title")); + _ = try job.params.put("cond_req", params.get("cond-req")); + + var conditionals = try job.params.put("conditionals", .array); + inline for (0..5) |i| { + if (!std.mem.eql(u8, "", params.getT(.string, comptime std.fmt.comptimePrint("match-txt{}", .{i})).?)) { + //if (params.getT(.string, comptime std.fmt.comptimePrint("match-txt{}", .{i})) != null) { + var cond = try conditionals.append(.object); + try cond.put("match_on", params.get(comptime std.fmt.comptimePrint("match-on{}", .{i}))); + try cond.put("match_cond", params.get(comptime std.fmt.comptimePrint("match-cond{}", .{i}))); + try cond.put("match_txt", params.get(comptime std.fmt.comptimePrint("match-txt{}", .{i}))); + } + } + + var actions = try job.params.put("actions", .array); + inline for (0..5) |i| { + if (!std.mem.eql(u8, "", params.getT(.string, comptime std.fmt.comptimePrint("action-txt{}", .{i})).?)) { + //if (params.getT(.string, comptime std.fmt.comptimePrint("action-txt{}", .{i})) != null) { + var act = try actions.append(.object); + try act.put("action", params.get(comptime std.fmt.comptimePrint("action{}", .{i}))); + try act.put("action_on", params.get(comptime std.fmt.comptimePrint("action-on{}", .{i}))); + try act.put("action_txt", params.get(comptime std.fmt.comptimePrint("action-txt{}", .{i}))); + } + } + try job.schedule(); + return request.render(.created); } - -pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - - -test "index" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/rules", .{}); - try response.expectStatus(.ok); -} - -test "get" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/rules/example-id", .{}); - try response.expectStatus(.ok); -} - -test "new" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/rules/new", .{}); - try response.expectStatus(.ok); -} - -test "edit" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/rules/example-id/edit", .{}); - try response.expectStatus(.ok); -} - -test "post" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.POST, "/rules", .{}); - try response.expectStatus(.created); -} - -test "put" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.PUT, "/rules/example-id", .{}); - try response.expectStatus(.ok); -} - -test "patch" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.PATCH, "/rules/example-id", .{}); - try response.expectStatus(.ok); -} - -test "delete" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.DELETE, "/rules/example-id", .{}); - try response.expectStatus(.ok); -} diff --git a/src/app/views/rules/delete.zmpl b/src/app/views/rules/delete.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/rules/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/rules/edit.zmpl b/src/app/views/rules/edit.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/rules/edit.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/rules/index.zmpl b/src/app/views/rules/index.zmpl index 76457d0..5661f97 100644 --- a/src/app/views/rules/index.zmpl +++ b/src/app/views/rules/index.zmpl @@ -1,3 +1,62 @@ -
- Content goes here -
+ + + + + +@partial partials/header +

Rules

+Rules allow you change the default Scrobble import behavior based on provided criteria. +Add a rule below. +

+
+ + +
+Match + +conditonals. +
+If +@for (0..5) |i| { + + + + + + + +
+} +then +@for (0..5) |i| { + + + with + +
+} + +
+ +Current rules: + + \ No newline at end of file diff --git a/src/app/views/rules/new.zmpl b/src/app/views/rules/new.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/rules/new.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/rules/patch.zmpl b/src/app/views/rules/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/rules/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/rules/put.zmpl b/src/app/views/rules/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/rules/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/scrobbles.zig b/src/app/views/scrobbles.zig index ebda828..754a7c8 100644 --- a/src/app/views/scrobbles.zig +++ b/src/app/views/scrobbles.zig @@ -1,64 +1,12 @@ const std = @import("std"); const jetzig = @import("jetzig"); +const queries = @import("../../queries.zig"); pub fn index(request: *jetzig.Request) !jetzig.View { var root = try request.data(.object); - var scrobbles_view = try root.put("scrobbles", .array); - const query = jetzig.database.Query(.Scrobble) - .select(.{ .id, .date }) - .include(.song, .{ .select = .{ .id, .name } }) - .include(.album, .{ .select = .{ .id, .name } }) - .include(.scrobbleartists, .{ .select = .{.artist_id} }) - .orderBy(.{ .date = .desc }); - const scrobbles = try request.repo.all(query); - for (scrobbles) |scrobble| { - var scrobble_view = try scrobbles_view.append(.object); - var artist_infos = try scrobble_view.put("artist_info", .array); - for (scrobble.scrobbleartists) |artist| { - var artist_info = try artist_infos.append(.object); - const artist_data = try jetzig.database.Query(.Artist) - .find(artist.artist_id) - .select(.{ .id, .name }) - .execute(request.repo); - try artist_info.put("name", artist_data.?.name); - try artist_info.put("id", artist_data.?.id); - } + const scrobbles = try queries.entityQueryResult(request, queries.loadQuery(.scrobble, .entities), .{}); + try root.put("scrobbles", scrobbles); - try scrobble_view.put("song_name", scrobble.song.name); - try scrobble_view.put("song_id", scrobble.song.id); - try scrobble_view.put("album_name", scrobble.album.name); - try scrobble_view.put("album_id", scrobble.album.id); - try scrobble_view.put("date", scrobble.date); - } - return request.render(.ok); -} - -pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = id; - _ = data; - return request.render(.ok); -} - -pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - return request.render(.created); -} - -pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; - return request.render(.ok); -} - -pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; - _ = id; return request.render(.ok); } diff --git a/src/app/views/scrobbles/delete.zmpl b/src/app/views/scrobbles/delete.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/scrobbles/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/scrobbles/get.zmpl b/src/app/views/scrobbles/get.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/scrobbles/get.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/scrobbles/index.zmpl b/src/app/views/scrobbles/index.zmpl index 377f50f..c3d6759 100644 --- a/src/app/views/scrobbles/index.zmpl +++ b/src/app/views/scrobbles/index.zmpl @@ -1,42 +1,15 @@ +@zig { + const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; + const columns: ColumnChoices = &.{.song, .artistlist, .album, .date}; +} + - @partial partials/header

Scrobbles

- - - - - - - - - - -@for (.scrobbles) |scrobble| { - - - - - - -} - -
SongArtist(s)AlbumDate
{{scrobble.song_name}} - @for (scrobble.get("artist_info").?) |ai| { - {{ai.name}} - } - {{scrobble.album_name}}{{scrobble.date}}
- - +@partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns) \ No newline at end of file diff --git a/src/app/views/scrobbles/patch.zmpl b/src/app/views/scrobbles/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/scrobbles/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/scrobbles/post.zmpl b/src/app/views/scrobbles/post.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/scrobbles/post.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/scrobbles/put.zmpl b/src/app/views/scrobbles/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/scrobbles/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/search/delete.zmpl b/src/app/views/search/delete.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/search/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/search/edit.zmpl b/src/app/views/search/edit.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/search/edit.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/search/get.zmpl b/src/app/views/search/get.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/search/get.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/search/index.zmpl b/src/app/views/search/index.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/search/index.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/search/new.zmpl b/src/app/views/search/new.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/search/new.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/search/patch.zmpl b/src/app/views/search/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/search/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/search/post.zmpl b/src/app/views/search/post.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/search/post.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/search/put.zmpl b/src/app/views/search/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/search/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/songs.zig b/src/app/views/songs.zig index 6c134b1..72d21ce 100644 --- a/src/app/views/songs.zig +++ b/src/app/views/songs.zig @@ -1,129 +1,67 @@ const std = @import("std"); const jetzig = @import("jetzig"); +const queries = @import("../../queries.zig"); +const decode = @import("../../date_fmt.zig").urlDecode; pub fn index(request: *jetzig.Request) !jetzig.View { var root = try request.data(.object); - var songs_view = try root.put("songs", .array); - const songs = try jetzig.database.Query(.Song) - .select(.{ .id, .name }) - .include(.songartists, .{ .select = .{.artist_id} }) - .include(.scrobbles, .{ .select = .{.id} }) - .orderBy(.{ .name = .asc }) - .all(request.repo); - for (songs) |song| { - var song_view = try songs_view.append(.object); + const htmx_query = (try request.queryParams()).getT(.string, "s"); + + try root.put("htmx", htmx_query != null); + + const songs = if (htmx_query) |name| + try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .entities_by_name), .{name}) + else + try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .entities), .{}); + + try root.put("songs", songs); - var artist_infos = try song_view.put("artist_info", .array); - for (song.songartists) |artist| { - var artist_info = try artist_infos.append(.object); - const artist_data = try jetzig.database.Query(.Artist) - .find(artist.artist_id) - .select(.{ .id, .name }) - .execute(request.repo); - try artist_info.put("name", artist_data.?.name); - try artist_info.put("id", artist_data.?.id); - } - try song_view.put("name", song.name); - try song_view.put("url", song.id); - try song_view.put("scrobbles", (song.scrobbles).len); - } return request.render(.ok); } pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; + var root = try request.data(.object); + const id_int = blk: { + const rn = try decode(request.allocator, id); + // Try to find the song by name + const queried_songs = try jetzig.database.Query(.Song).select(.{.id}).where(.{ .name = rn }).all(request.repo); + if (queried_songs.len == 0) { + // Either we've been given an id in the db, or the song doesn't exist + break :blk std.fmt.parseInt(i64, id, 10) catch return request.fail(.not_found); + } else if (queried_songs.len == 1) { + // It can only be one song + break :blk queried_songs[0].id; + } else { + // It could be a variety of songs + const songs = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .entities_by_name), .{rn}); + try root.put("name", rn); + try root.put("songs", songs); + try root.put("disambiguation", true); + return request.render(.ok); + } + }; + + const song = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .entity_info), .{id_int}); + try root.put("song", song); + + const scrobbles = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .get_scrobbles), .{id_int}); + try root.put("scrobbles", scrobbles); + + const albums = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .get_albums), .{id_int}); + try root.put("albums", albums); + + const firstlast = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .firstlast), .{id_int}); + try root.put("firstlast", firstlast); + + const timescale = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .timescale), .{id_int}); + try root.put("yearly", timescale); + + const ratings = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .get_ratings), .{id_int}); + try root.put("reviews", ratings); + + //const peak = try queries.entityQueryResult(request, comptime queries.loadQuery(.song, .peak), .{id_int}); + //try root.put("peak", peak); + return request.render(.ok); } - -pub fn new(request: *jetzig.Request) !jetzig.View { - return request.render(.ok); -} - -pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn post(request: *jetzig.Request) !jetzig.View { - return request.render(.created); -} - -pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View { - _ = id; - return request.render(.ok); -} - -test "index" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/song", .{}); - try response.expectStatus(.ok); -} - -test "get" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/song/example-id", .{}); - try response.expectStatus(.ok); -} - -test "new" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/song/new", .{}); - try response.expectStatus(.ok); -} - -test "edit" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.GET, "/song/example-id/edit", .{}); - try response.expectStatus(.ok); -} - -test "post" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.POST, "/song", .{}); - try response.expectStatus(.created); -} - -test "put" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.PUT, "/song/example-id", .{}); - try response.expectStatus(.ok); -} - -test "patch" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.PATCH, "/song/example-id", .{}); - try response.expectStatus(.ok); -} - -test "delete" { - var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); - defer app.deinit(); - - const response = try app.request(.DELETE, "/song/example-id", .{}); - try response.expectStatus(.ok); -} diff --git a/src/app/views/songs/delete.zmpl b/src/app/views/songs/delete.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/songs/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/songs/edit.zmpl b/src/app/views/songs/edit.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/songs/edit.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/songs/get.zmpl b/src/app/views/songs/get.zmpl index 76457d0..a9416ce 100644 --- a/src/app/views/songs/get.zmpl +++ b/src/app/views/songs/get.zmpl @@ -1,3 +1,59 @@ -
- Content goes here +@zig { + const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; + const columns: ColumnChoices = &.{.song, .artistlist, .album, .date}; + const dis_columns: ColumnChoices = &.{.song, .album, .artistlist, .scrobbles}; +} + + + + + + + +@partial partials/header +@if ($.disambiguation) +

{{.name}} (disambiguation)

+@partial partials/newtable(T: ColumnChoices, table_data: .songs, columns: dis_columns) +@else + +@zig { + const reviews = try zmpl.coerceArray(".reviews"); +} +
+

{{.song.song_name}}

+ +
+
+ @if ($.song.is_tie) +
{{.song.scrobbles}} scrobbles ({{.song.rank}} place, tied)
+ @else +
{{.song.scrobbles}} scrobbles ({{.song.rank}} place)
+ @end + @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

+
+ @zig { + if (reviews.len == 0) { +
+ + + +
+ } else { + for (reviews) |review| { + {{review.score}}: {{review.review}} ({{review.date}}) + } + } + } +
+
+@end + + \ No newline at end of file diff --git a/src/app/views/songs/index.zmpl b/src/app/views/songs/index.zmpl index a0a1128..862b753 100644 --- a/src/app/views/songs/index.zmpl +++ b/src/app/views/songs/index.zmpl @@ -1,40 +1,17 @@ +@zig { + const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; + const columns: ColumnChoices = &.{.song, .album, .artistlist, .scrobbles}; +} + - + @if (! $.htmx) @partial partials/header

Songs

- - - - - - - - - -@for (.songs) |song| { - - - - - -} - -
NameArtists(s)Scrobbles
{{song.name}} - @for (song.get("artist_info").?) |ai| { - {{ai.name}} - } - {{song.scrobbles}}
- - + @end +@partial partials/newtable(T: ColumnChoices, table_data: .songs, columns: columns) \ No newline at end of file diff --git a/src/app/views/songs/new.zmpl b/src/app/views/songs/new.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/songs/new.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/songs/patch.zmpl b/src/app/views/songs/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/songs/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/songs/post.zmpl b/src/app/views/songs/post.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/songs/post.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/songs/put.zmpl b/src/app/views/songs/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/songs/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/upload.zig b/src/app/views/upload.zig index 53f3b27..27b2e78 100644 --- a/src/app/views/upload.zig +++ b/src/app/views/upload.zig @@ -1,135 +1,64 @@ 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); + var view_params = try root.put("scrobbles", .array); + const UploadParams = struct { + source: enum { LFMW, LFMS, Spotify }, + earliest_date: ?[]const u8, + latest_date: ?[]const u8, + username: ?[]const u8, + }; - if (try request.file("upload")) |file| { - const params = try request.params(); - const source = try std.fmt.parseInt(u8, params.get("t").?.string.value, 10); // This param is required in HTML - const before_limiter: bool = if (params.get("bbool")) |_| true else false; - const after_limiter: bool = if (params.get("abool")) |_| true else false; + const params = (try request.expectParams(UploadParams)).?; - 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_ts = (try zeit.instant(.{ .source = if (params.latest_date) |ld| .{ .iso8601 = ld } else .now })).timestamp; + const earliest_ts = (try zeit.instant(.{ .source = if (params.earliest_date) |ed| .{ .iso8601 = ed } else .{ .unix_timestamp = 0 } })).timestamp; - var skipped_tracks: u64 = 0; - var limited_tracks: u64 = 0; + switch (params.source) { + .LFMS, .Spotify => { + const ctx = try Utils.scrobbleIngest(request, if (try request.file("upload")) |file| file.content else unreachable, .{}, null); + for (ctx.rows) |row| try view_params.append(row); + }, + .LFMW => { + 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); - // 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 username = if (params.username) |un| un else "VAOTM"; - // This is so unnecessary, probably useful once I start doing Spotify integration though - inline for (std.meta.fields(ScrobbleTypes.LastFMScrobble)) |f| { - try value.put(f.name, @as(f.type, @field(scrobble, f.name))); - } - // Note sure why this works for ZMPL, but not for jobs. - try scrobbles_view.append(scrobble); + var page: usize = 1; + + var ctx: ?Utils.IngestContext = null; + + while (true) : (page += 1) { + if (page > 91) 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, @divFloor(earliest_ts, std.time.ns_per_s), @divFloor(latest_ts, std.time.ns_per_s), 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 }); + if (@intFromEnum(r.status) == 500) { + page -= 1; + std.time.sleep(3 * std.time.ns_per_s); + continue; } - }, - 1 => { - const content: []ScrobbleTypes.SpotifyScrobble = try std.json.parseFromSliceLeaky([]ScrobbleTypes.SpotifyScrobble, request.allocator, file.content, .{}); - const before_limiting_date: zeit.Time = if (before_limiter) (try zeit.Time.fromISO8601(params.get("b").?.string.value)) else (try zeit.instant(.{})).time(); - const after_limiting_date: zeit.Time = if (after_limiter) (try zeit.Time.fromISO8601(params.get("a").?.string.value)) else (try zeit.instant(.{ .source = .{ .unix_nano = 0 } })).time(); - appends: for (content) |scrobble| { - // A LastFM Scrobble occurs when half a song has been played - // or the song plays for 4 minutes, whichever happens first. - // Spotify considers a song played if it plays for 30 seconds. - // Ideally, I would go with the LastFM convention, but Spotify - // history data only gives us so much information. I'm okay - // with the 30 second convention, but eventually I would prefer - // to get the song length from MusicBrainz and check if it meets - // the requirement. Until then, if it goes 30 seconds, or the - // reason_end field reads "trackdone", then it counts as a Scrobble. - // May consider giving user control to the minimum millisecond requirement. - if (scrobble.ms_played < 30_000 and (scrobble.reason_end == null or !std.mem.eql(u8, scrobble.reason_end.?, "trackdone"))) { - skipped_tracks += 1; - continue :appends; - } - // In the case where the artist is null, but there's other metadata, I could - // probably let the user edit it in themselves, although I'm not sure if that - // situation happens. - if (scrobble.master_metadata_album_artist_name == null or scrobble.master_metadata_track_name == null) { - skipped_tracks += 1; - continue :appends; - } + const response_string = try lastfm_response_buffer.toOwnedSlice(); + ctx = try Utils.scrobbleIngest(request, response_string, .{}, ctx); + } - 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; - } - - // 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 }; - - var value = try scrobbles_data.append(.object); - - // 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}); + for (ctx.?.rows) |row| try view_params.append(row); + }, } - - 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); } - -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 deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/upload/delete.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/upload/get.zmpl b/src/app/views/upload/get.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/upload/get.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/upload/index.zmpl b/src/app/views/upload/index.zmpl index 9043a5b..bdeaacb 100644 --- a/src/app/views/upload/index.zmpl +++ b/src/app/views/upload/index.zmpl @@ -9,14 +9,34 @@
- - + + + +
- Last.fm - Spotify - Limit to Scrobbles before: - Limit to Scrobbles after: + Last.fm + Spotify + Last.fm (WebAuth) + + Advanced Options +
+ + diff --git a/src/app/views/upload/patch.zmpl b/src/app/views/upload/patch.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/upload/patch.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/app/views/upload/post.zmpl b/src/app/views/upload/post.zmpl index 176f094..91c5347 100644 --- a/src/app/views/upload/post.zmpl +++ b/src/app/views/upload/post.zmpl @@ -1,15 +1,16 @@ +@zig { + const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date}; + const columns: ColumnChoices = &.{.song, .artistlist, .album, .date}; +} + - @partial partials/header

File Uploaded Successfully

-

Scrobbles Added

- -@partial partials/table(table_data: .scrobbles, table_headers: .upload_table, table_context: .context) - +@partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns) \ No newline at end of file diff --git a/src/app/views/upload/put.zmpl b/src/app/views/upload/put.zmpl deleted file mode 100644 index 76457d0..0000000 --- a/src/app/views/upload/put.zmpl +++ /dev/null @@ -1,3 +0,0 @@ -
- Content goes here -
diff --git a/src/apply_rule.zig b/src/apply_rule.zig new file mode 100644 index 0000000..0d357a6 --- /dev/null +++ b/src/apply_rule.zig @@ -0,0 +1,86 @@ +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), + .album_artists => { + for (scrobble.album_artists) |artist| match_found = match_found or match_fn(artist, cond.match_txt); + }, + .track_artists => { + if (scrobble.track_artists) |ta| { + for (ta) |a| match_found = match_found or match_fn(a, cond.match_txt); + } else match_found = false; + }, + }, + .all => switch (cond.match_on) { + inline .album, .track => |on| match_found = match_found and match_fn(@field(scrobble, @tagName(on)), cond.match_txt), + .album_artists => { + for (scrobble.album_artists) |artist| match_found = match_found and match_fn(artist, cond.match_txt); + }, + + .track_artists => { + if (scrobble.track_artists) |ta| { + for (ta) |a| match_found = match_found and match_fn(a, cond.match_txt); + } else match_found = false; + }, + }, + } + } + 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, + .album_artists => { + try al.appendSlice(scrobble.album_artists); + try al.append(act.action_txt); + output_scrobble.album_artists = try al.toOwnedSlice(); + }, + .track_artists => { + if (scrobble.track_artists) |ta| try al.appendSlice(ta); + try al.append(act.action_txt); + output_scrobble.track_artists = try al.toOwnedSlice(); + }, + } + }, + .replace => switch (act.action_on) { + inline .album, .track => |on| @field(output_scrobble, @tagName(on)) = act.action_txt, + inline .album_artists, .track_artists => |on| { + const artist = try allocator.alloc([]const u8, 1); + artist[0] = act.action_txt; + @field(output_scrobble, @tagName(on)) = artist; + }, + }, + } + } + } + } + + return output_scrobble; +} diff --git a/src/date_fmt.zig b/src/date_fmt.zig new file mode 100644 index 0000000..4c40a25 --- /dev/null +++ b/src/date_fmt.zig @@ -0,0 +1,474 @@ +const std = @import("std"); +const zeit = @import("zeit"); +const Data = @import("types.zig"); +const jetzig = @import("jetzig"); +const applyRule = @import("apply_rule.zig").applyScrobbleRule; + +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, std.time.ns_per_us) } })).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.track_artists orelse scrobble.album_artists) |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), + }; +} + +pub fn urlDecode(allocator: std.mem.Allocator, str: []const u8) ![]const u8 { + var decoded = std.ArrayList(u8).init(allocator); + var i: usize = 0; + while (i < str.len) : (i += 1) { + const v = str[i]; + if (v == '%') { + if (i + 2 < str.len) { + const hex = str[i + 1 .. i + 3]; + const char = try std.fmt.parseInt(u8, hex, 16); + try decoded.append(char); + i += 2; + } else return error.InvalidInput; + } else try decoded.append(v); + } + return decoded.toOwnedSlice(); +} + +const ScrobbleFields = enum { + date, // LastFM(Stats) timestamp + ts, // Spotify timestamp + name, // LastFM track name + track, // LastFMStats track name + master_metadata_track_name, // Spotify track name + artist, // LastFM(Stats) artist name + master_metadata_album_artist_name, // Spotify artist name + album, // LastFM(Stats) album name + master_metadata_album_album_name, // Spotify album name + ms_played, // Spotify playtime + reason_end, // Spotify reason end,1_000 + @"@attr", // LastFM now playing + irrelevant, // Not a field I care about +}; + +pub fn loadRules(allocator: std.mem.Allocator) !?[]Data.Rule { + const rule_file: std.fs.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, + }); + + defer rule_file.close(); + const rule_file_size = (try rule_file.stat()).size; + const rule_file_content = try rule_file.readToEndAlloc(allocator, rule_file_size); + return std.json.parseFromSliceLeaky([]Data.Rule, allocator, rule_file_content, .{}) catch null; +} + +/// Configuration for scrobble ingestion +const IngestConfig = struct { + /// The earliest date (a unix timestamp in nanoseconds) to accept a scrobble + earliest: ?i128 = null, + /// The latest date (a unix timestamp in nanoseconds) to accept a scrobble + latest: ?i128 = null, + /// The minimum number of milliseconds needed to accept a scrobble + /// Only affects Spotify scrobbles + minimum_playtime: i128 = 30_000, + /// The amount of metadata required to accept a scrobble. A track name is always required + /// - need_artist: Only an artist name is required to accept a scrobble + /// - need_album: Only an album name is required to accept a scrobble + /// - need_both: Both an artist name and an album name are required to accept a scrobble + /// - need_neither: No extra metadata is required to accept a scrobble + null_tolerance: enum { need_artist, need_album, need_both, need_neither } = .need_both, +}; + +pub const IngestContext = struct { + rows: []Data.TableRow, + map: ?std.StringHashMap(std.StringHashMap(std.StringHashMap(?std.BufSet))), +}; + +/// +pub fn scrobbleIngest(request: *jetzig.Request, input: []const u8, config: IngestConfig, context: ?IngestContext) !IngestContext { + const allocator = request.allocator; + var out = std.ArrayList(Data.TableRow).init(allocator); + var artists = if (context) |ctx| blk: { + try out.appendSlice(ctx.rows); + break :blk ctx.map.?; + } else std.StringHashMap(std.StringHashMap(std.StringHashMap(?std.BufSet))).init(allocator); + var scanner = std.json.Scanner.initCompleteInput(allocator, input); + defer scanner.deinit(); + + const rule_list = try loadRules(allocator); + + array: switch (try scanner.peekNextTokenType()) { + .array_begin => { + // Go into array + _ = try scanner.next(); + scrobble_array: while (try scanner.peekNextTokenType() != .array_end) { + var r: Data.UnifiedScrobble = undefined; + // Go into object + _ = try scanner.next(); + while (try scanner.peekNextTokenType() != .object_end) { + const key_token = try scanner.nextAlloc(allocator, .alloc_if_needed); + const field_name = std.meta.stringToEnum(ScrobbleFields, switch (key_token) { + inline .string, .allocated_string => |slice| slice, + else => { + std.log.debug("{any}", .{key_token}); + return error.UnexpectedToken; + }, + }) orelse .irrelevant; + switch (field_name) { + .@"@attr" => |d| { + r = undefined; + try skipScrobble(allocator, &scanner, key_token, d); + }, + .ts, .date => |d| { + freeAllocated(allocator, key_token); + const date: i64 = switch (d) { + .date => blk: { + // We can filter by date via the API, so we will always have results in the + // specified timeframe through LFMW + if (try scanner.peekNextTokenType() == .object_begin) { + // For now, try to just skip over the object_begin and assume the next field is uts + _ = try scanner.next(); + try scanner.skipValue(); + const lfw_date_token = try scanner.nextAlloc(allocator, .alloc_if_needed); + // Skip over human-readable date format and then leave + try scanner.skipValue(); + try scanner.skipValue(); + _ = try scanner.next(); + const lfw_date = try std.fmt.parseInt(i64, switch (lfw_date_token) { + inline .number, .allocated_number, .string, .allocated_string => |slice| slice, + else => return error.UnexpectedToken, + }, 10) * std.time.us_per_s; + freeAllocated(allocator, lfw_date_token); + break :blk lfw_date; + } else { + const lfs_date_token = try scanner.nextAlloc(allocator, .alloc_if_needed); + const lfs_date = try std.fmt.parseInt(i64, switch (lfs_date_token) { + inline .number, .allocated_number, .string, .allocated_string => |slice| slice, + else => return error.UnexpectedToken, + }, 10) * std.time.us_per_ms; + if ((config.earliest != null and lfs_date < config.earliest.?) or (config.latest != null and lfs_date > config.latest.?)) { + r = undefined; + try skipScrobble(allocator, &scanner, lfs_date_token, d); + } + freeAllocated(allocator, lfs_date_token); + break :blk lfs_date; + } + }, + .ts => blk: { + // This might need to be an alloc_always, but I'm gonna try if_needed first + const spotify_date_token = try scanner.nextAlloc(allocator, .alloc_if_needed); + const spotify_date = (try zeit.instant(.{ .source = .{ .iso8601 = switch (spotify_date_token) { + inline .string, .allocated_string => |slice| slice, + else => return error.UnexpectedToken, + } } })).timestamp; + if ((config.earliest != null and spotify_date < config.earliest.?) or (config.latest != null and spotify_date > config.latest.?)) { + r = undefined; + try skipScrobble(allocator, &scanner, spotify_date_token, d); + } + freeAllocated(allocator, spotify_date_token); + break :blk @as(i64, @truncate(@divFloor(spotify_date, std.time.us_per_ms))); + }, + else => unreachable, + }; + @field(r, "date") = date; + }, + .ms_played => { + freeAllocated(allocator, key_token); + const spotify_ms_played = try scanner.nextAlloc(allocator, .alloc_if_needed); + @field(r, "playtime") = try std.fmt.parseInt(u64, switch (spotify_ms_played) { + inline .number, .allocated_number, .string, .allocated_string => |slice| slice, + else => return error.UnexpectedToken, + }, 10); + freeAllocated(allocator, spotify_ms_played); + }, + .master_metadata_track_name, .track, .name => |d| { + freeAllocated(allocator, key_token); + const track = try scanner.nextAlloc(allocator, .alloc_always); + @field(r, "track") = switch (track) { + .allocated_string => |slice| slice, + .null => null, + else => return error.UnexpectedToken, + }; + if (r.track == null) { + r = undefined; + try skipScrobble(allocator, &scanner, track, d); + _ = try scanner.next(); + } + }, + .master_metadata_album_artist_name, .artist => { + freeAllocated(allocator, key_token); + const artist = if (try scanner.peekNextTokenType() == .object_begin) blk: { + // Skip object_begin, mbid key, mbid, and #text key + _ = try scanner.next(); + try scanner.skipValue(); + try scanner.skipValue(); + try scanner.skipValue(); + const lfw_artist_token = try scanner.nextAlloc(allocator, .alloc_always); + // Leave object + _ = try scanner.next(); + break :blk switch (lfw_artist_token) { + .allocated_string => |slice| slice, + else => return error.UnexpectedToken, + }; + } else blk: { + const artist_token = try scanner.nextAlloc(allocator, .alloc_always); + break :blk switch (artist_token) { + .allocated_string => |slice| slice, + .null => null, + else => return error.UnexpectedToken, + }; + }; + @field(r, "album_artist") = artist; + }, + .master_metadata_album_album_name, .album => { + freeAllocated(allocator, key_token); + const album = if (try scanner.peekNextTokenType() == .object_begin) blk: { + // Skip object_begin, mbid key, mbid, and #text key + _ = try scanner.next(); + try scanner.skipValue(); + try scanner.skipValue(); + try scanner.skipValue(); + const lfw_album_token = try scanner.nextAlloc(allocator, .alloc_always); + // Leave object + _ = try scanner.next(); + break :blk switch (lfw_album_token) { + .allocated_string => |slice| slice, + else => return error.UnexpectedToken, + }; + } else blk: { + const album_token = try scanner.nextAlloc(allocator, .alloc_always); + break :blk switch (album_token) { + .allocated_string => |slice| slice, + .null => null, + else => return error.UnexpectedToken, + }; + }; + @field(r, "album") = album; + }, + .reason_end => { + freeAllocated(allocator, key_token); + const reason_end = try scanner.nextAlloc(allocator, .alloc_always); + @field(r, "reason_end") = switch (reason_end) { + .allocated_string => |slice| slice, + .null => null, + else => return error.UnexpectedToken, + }; + }, + else => { + freeAllocated(allocator, key_token); + try scanner.skipValue(); + }, + } + } + // Exit object + _ = try scanner.next(); + + // Final checks + if (r.playtime != null and r.playtime.? < config.minimum_playtime and (r.reason_end == null or !std.mem.eql(u8, r.reason_end.?, "trackdone"))) continue :scrobble_array; + switch (config.null_tolerance) { + .need_neither => {}, + .need_both => if (r.album == null and r.album_artist == null) continue :scrobble_array, + .need_album => if (r.album == null) continue :scrobble_array, + .need_artist => if (r.album_artist == null) continue :scrobble_array, + } + var scr = Data.Scrobble{ + .track = r.track.?, + .album = r.album orelse "Unknown Album", + .track_artists = null, + .album_artists = &.{r.album_artist orelse "Unknown Artist"}, + .date = r.date, + }; + + if (rule_list) |rules| scr = try applyRule(allocator, scr, rules); + + // Try not to have an aneurysm (impossible challenge 2025) + const artist: []const u8, const single_artist_flag: bool = if (scr.album_artists.len == 1) .{ scr.album_artists[0], true } else blk: { + var combined = try std.ArrayListUnmanaged(u8).initCapacity(allocator, 0); + for (scr.album_artists) |aa| try combined.appendSlice(allocator, aa); + break :blk .{ try combined.toOwnedSlice(allocator), false }; + }; + + const premade_hashes = try scr.asHash(allocator); + + // I'm doing all the hashing in the jobs, meaning we hash more than we need to + // If I get bored maybe I'll work on storing them instead + const hm_artist_info = try artists.getOrPut(artist); + if (!hm_artist_info.found_existing) { + hm_artist_info.value_ptr.* = std.StringHashMap(std.StringHashMap(?std.BufSet)).init(allocator); + if (single_artist_flag) { + var add_artist = try request.job("add_artist"); + try add_artist.params.put("artist", artist); + try add_artist.schedule(); + } else { + for (scr.album_artists) |a| { + const hm_ind_artist_info = try artists.getOrPut(a); + if (!hm_ind_artist_info.found_existing) { + hm_ind_artist_info.value_ptr.* = std.StringHashMap(std.StringHashMap(?std.BufSet)).init(allocator); + var add_artist = try request.job("add_artist"); + try add_artist.params.put("artist", a); + try add_artist.schedule(); + } + } + } + } + const hm_album_info = try hm_artist_info.value_ptr.*.getOrPut(scr.album); + if (!hm_album_info.found_existing) { + const album_query = try jetzig.database.Query(.Album) + .find(@as(i64, @bitCast(premade_hashes[0]))).execute(request.repo); + + if (album_query == null) { + try jetzig.database.Query(.Album) + .insert(.{ .id = @as(i64, @bitCast(premade_hashes[0])), .name = scr.album, .length = null }) + .execute(request.repo); + } + hm_album_info.value_ptr.* = std.StringHashMap(?std.BufSet).init(allocator); + var add_album = try request.job("add_album"); + try add_album.params.put("album_hash", premade_hashes[0]); + try add_album.params.put("artists", scr.album_artists); + //if (!single_artist_flag) add_album.put("combined", artist); + try add_album.schedule(); + } + + const hm_song_info = try hm_album_info.value_ptr.*.getOrPut(scr.track); + if (!hm_song_info.found_existing) { + const track_query = try jetzig.database.Query(.Song) + .find(@as(i64, @bitCast(premade_hashes[1]))).execute(request.repo); + + if (track_query == null) { + try jetzig.database.Query(.Song) + .insert(.{ .id = @as(i64, @bitCast(premade_hashes[1])), .name = scr.track, .length = null, .hidden = false }) + .execute(request.repo); + } + const as_query = try jetzig.database.Query(.Albumsong) + .find(@as(i64, @bitCast(premade_hashes[2]))).execute(request.repo); + + if (as_query == null) { + try jetzig.database.Query(.Albumsong) + .insert(.{ .id = @as(i64, @bitCast(premade_hashes[2])), .song_id = @as(i64, @bitCast(premade_hashes[1])), .album_id = @as(i64, @bitCast(premade_hashes[0])) }) + .execute(request.repo); + } + hm_song_info.value_ptr.* = null; + var add_song = try request.job("add_song"); + try add_song.params.put("as_hash", premade_hashes[2]); + //add_song.put("album", scr.album); + if (scr.track_artists) |track_artists| { + try add_song.params.put("track_artists", track_artists); + if (hm_song_info.value_ptr.* == null) hm_song_info.value_ptr.* = std.BufSet.init(allocator); + for (track_artists) |ta| { + try hm_song_info.value_ptr.*.?.insert(ta); + const hm_ta_info = try artists.getOrPut(ta); + if (!hm_ta_info.found_existing) { + hm_ta_info.value_ptr.* = std.StringHashMap(std.StringHashMap(?std.BufSet)).init(allocator); + var add_artist = try request.job("add_artist"); + try add_artist.params.put("artist", ta); + try add_artist.schedule(); + } + } + } + try add_song.params.put("album_artists", scr.album_artists); + //if (!single_artist_flag) add_song.put("combined", artist); + try add_song.schedule(); + } + try jetzig.database.Query(.Scrobble).insert(.{ .albumsong = @as(i64, @bitCast(premade_hashes[2])), .datetime = scr.date }) + .execute(request.repo); + + const b = try scrobbleToRow(allocator, scr); + try out.append(b); + } + }, + // LastFM(stats) + .object_begin => { + _ = try scanner.next(); + find_array: while (true) { + const key_token = try scanner.nextAlloc(allocator, .alloc_if_needed); + const field_name = switch (key_token) { + inline .string, .allocated_string => |slice| slice, + else => return error.UnexpectedToken, + }; + if (!std.mem.eql(u8, field_name, "scrobbles") and !std.mem.eql(u8, field_name, "recenttracks")) { + freeAllocated(allocator, key_token); + try scanner.skipValue(); + } else { + freeAllocated(allocator, key_token); + break :find_array; + } + } + switch (try scanner.peekNextTokenType()) { + // LastFM Stats + .array_begin => continue :array .array_begin, + // LastFM + .object_begin => { + // Enter recenttracks + _ = try scanner.next(); + while (true) { + const key_token = try scanner.nextAlloc(allocator, .alloc_if_needed); + const field_name = switch (key_token) { + inline .string, .allocated_string => |slice| slice, + else => return error.UnexpectedToken, + }; + if (!std.mem.eql(u8, field_name, "track")) { + freeAllocated(allocator, key_token); + try scanner.skipValue(); + } else { + freeAllocated(allocator, key_token); + continue :array .array_begin; + } + } + }, + else => unreachable, + } + }, + else => return error.UnexpectedToken, + } + const scrobbles = try out.toOwnedSlice(); + return IngestContext{ .map = artists, .rows = scrobbles }; +} + +fn freeAllocated(allocator: std.mem.Allocator, token: std.json.Token) void { + switch (token) { + .allocated_number, .allocated_string => |slice| { + allocator.free(slice); + }, + else => {}, + } +} + +// Cantor Pairing Function +// https://en.wikipedia.org/wiki/Pairing_function + +fn skipScrobble(allocator: std.mem.Allocator, scanner: *std.json.Scanner, token: std.json.Token, field: ScrobbleFields) !void { + freeAllocated(allocator, token); + try scanner.skipUntilStackHeight(switch (field) { + // Spotify specific fields + .ts, .master_metadata_album_album_name, .master_metadata_album_artist_name, .master_metadata_track_name, .ms_played, .reason_end => 1, + // LastFM Stats specific field + .track => 2, + // LastFM Web specific fields + .name, .@"@attr" => 3, + // Fields shared by LastFM Stats and LastFM Web: album, artist, date (although date is never invalid for LastFM Web) + else => switch (scanner.stackHeight()) { + 5 => 3, // Five levels deep => LastFM Web (all of those fields are fortunately objects / same stack height) + 3 => 2, // Three levels deep => LastFM stats + else => unreachable, + }, + }); +} diff --git a/src/main.zig b/src/main.zig index 1cab920..cdc9bca 100644 --- a/src/main.zig +++ b/src/main.zig @@ -14,10 +14,13 @@ pub const jetzig_options = struct { // htmx middleware skips layouts when `HX-Target` header is present and issues // `HX-Redirect` instead of a regular HTTP redirect when `request.redirect` is called. jetzig.middleware.HtmxMiddleware, - // Demo middleware included with new projects. Remove once you are familiar with Jetzig's - // middleware system. + // Demo middleware included with new projects. Remove once you are familiar with Jetzig's + // middleware system. }; + // This is currently the largest number of parameters one can have in a rule + pub const max_multipart_form_fields = 42; + // Maximum bytes to allow in request body. pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 24); diff --git a/src/ordinal_fmt.zig b/src/ordinal_fmt.zig new file mode 100644 index 0000000..6b126ab --- /dev/null +++ b/src/ordinal_fmt.zig @@ -0,0 +1,16 @@ +const std = @import("std"); +//const log = std.math.log10; + +pub fn ordinalFmt(allocator: std.mem.Allocator, ord: isize) ![]const u8 { + const buff = try allocator.alloc(u8, 3 + @as(usize, @intFromFloat(@floor(@log10(@as(f64, @floatFromInt(ord))))))); + const ind: []const u8 = switch (@mod(ord, 100)) { + 11, 12, 13 => "th", + else => switch (@mod(ord, 10)) { + 1 => "st", + 2 => "nd", + 3 => "rd", + else => "th", + }, + }; + return std.fmt.bufPrint(buff, "{}{s}", .{ ord, ind }); +} diff --git a/src/queries.zig b/src/queries.zig new file mode 100644 index 0000000..c8c313b --- /dev/null +++ b/src/queries.zig @@ -0,0 +1,568 @@ +// 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"); +const ordinalFmt = @import("./ordinal_fmt.zig").ordinalFmt; + +pub fn entityQueryResult(request: *jetzig.Request, comptime query: GeneratedQuery, args: anytype) !*jetzig.Data.Value { + //var result = try request.repo.executeSql(query.query, args); + // + var Data = jetzig.Data.init(request.allocator); + + if (query.query_type == .peak) { + const id = switch (@TypeOf(args)) { + struct { i64 } => args[0], + else => unreachable, + }; + var result = try request.repo.connection.?.postgresql.connection.queryOpts(query.query, .{}, .{ .allocator = request.allocator, .column_names = true }); + var out: *jetzig.Data.Value = try Data.object(); + var mapper = result.mapper(PeakResult, .{ .dupe = true, .allocator = request.allocator }); + var rank: u64 = comptime std.math.maxInt(u64); + var date: []const u8 = undefined; + var scrobbles = std.AutoArrayHashMap(i64, u32).init(request.allocator); + + while (try mapper.next()) |scrobble| { + const res = try scrobbles.getOrPut(scrobble.eid); + if (res.found_existing) res.value_ptr.* += 1 else res.value_ptr.* = 1; + scrobbles.sort(PeakContext{ .keys = scrobbles.keys(), .vals = scrobbles.values(), .preferred = id }); + const idx = scrobbles.getIndex(id); + if (idx != null and idx.? <= rank) { + if (idx.? < rank) rank = idx.?; + date = scrobble.datetime; + } + } + try out.put("rank", ordinalFmt(request.allocator, @as(i64, @bitCast(rank + 1)))); + try out.put("date", date); + return out; + } + var result = try request.repo.connection.?.postgresql.connection.queryOpts(query.query, args, .{ .allocator = request.allocator, .column_names = true }); + + defer result.deinit(); + + 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 = entity.scrobbles, + .date = entity.date, + .score = entity.score, + .review = entity.review, + }); + } + + 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, get_ratings, friends, peak }; + +const PeakResult = struct { + eid: i64, + datetime: []const u8, +}; + +const PeakContext = struct { + keys: []i64, + vals: []u32, + preferred: i64, + pub fn lessThan(ctx: @This(), a_index: usize, b_index: usize) bool { + if (ctx.vals[a_index] == ctx.vals[b_index] and ctx.keys[a_index] == ctx.preferred) return true; + return ctx.vals[a_index] > ctx.vals[b_index]; + } +}; + +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, + score: ?i16 = null, + review: ?[]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, + is_tie: bool, +}; + +pub fn loadQuery(comptime entity: EntityType, comptime 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 => @compileError("Cannot specify scrobble for entity_info"), + .song => + \\WITH ranked AS ( + \\SELECT songs.name AS song_name, COUNT(songs.id) AS scrobbles, RANK() OVER ( ORDER BY COUNT(songs.id) DESC) AS rank, songs.id AS song_id + \\FROM scrobbles + \\INNER JOIN albumsongs ON albumsongs.id = scrobbles.albumsong + \\INNER JOIN songs ON songs.id = albumsongs.song_id + \\GROUP BY songs.id) + \\SELECT * FROM (SELECT song_name, scrobbles, TO_CHAR(rank,'FM9999th') AS rank, song_id, + \\( rank = lag(rank, 1, -1::bigint) OVER (ORDER BY rank) + \\OR rank = lead(rank, 1, -1::bigint) OVER (ORDER BY rank) + \\)AS is_tie + \\FROM ranked) WHERE song_id = $1; + , + .album => + \\WITH ranked AS ( + \\SELECT albums.name AS album_name, COUNT(albums.id) AS scrobbles, RANK() OVER ( ORDER BY COUNT(albums.id) DESC) AS rank, albums.id AS album_id, COUNT(DISTINCT songs.id) AS song_num + \\FROM scrobbles + \\INNER JOIN albumsongs ON albumsongs.id = scrobbles.albumsong + \\INNER JOIN albums ON albums.id = albumsongs.album_id + \\INNER JOIN songs ON songs.id = albumsongs.song_id + \\GROUP BY albums.id) + \\SELECT * FROM (SELECT album_name, scrobbles, TO_CHAR(rank,'FM9999th') AS rank, album_id, song_num, + \\( rank = lag(rank, 1, -1::bigint) OVER (ORDER BY rank) + \\OR rank = lead(rank, 1, -1::bigint) OVER (ORDER BY rank) + \\)AS is_tie + \\FROM ranked) WHERE album_id = $1; + , + .artist => + \\WITH ranked AS ( + \\SELECT artists.name AS artist_name, COUNT(artists.id) AS scrobbles, RANK() OVER ( ORDER BY COUNT(artists.id) DESC) AS rank, artists.id AS artist_id, COUNT(DISTINCT songs.id) AS song_num, COUNT(DISTINCT albums.id) AS album_num + \\FROM scrobbles + \\INNER JOIN albumsongs ON albumsongs.id = scrobbles.albumsong + \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = scrobbles.albumsong + \\INNER JOIN albums ON albums.id = albumsongs.album_id + \\INNER JOIN songs ON songs.id = albumsongs.song_id + \\INNER JOIN artists ON artists.id = albumsongsartists.artist_id + \\GROUP BY artists.id) + \\SELECT * FROM (SELECT artist_name, scrobbles, TO_CHAR(rank,'FM9999th') AS rank, artist_id, song_num, album_num, + \\( rank = lag(rank, 1, -1::bigint) OVER (ORDER BY rank) + \\OR rank = lead(rank, 1, -1::bigint) OVER (ORDER BY rank) + \\)AS is_tie + \\FROM ranked) 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; + , + .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 scrobbles.albumsong = albumsongs.id + \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = albumsongs.id + \\INNER JOIN artists ON artists.id = albumsongsartists.artist_id + \\WHERE LOWER(albums.name) LIKE LOWER($1) + \\GROUP BY albums.id, artists.id + \\ORDER BY albums.name ASC, scrobbles DESC; + , + .artist => + \\SELECT artists.name AS artist_name, artists.id AS artist_id, COUNT(scrobbles) AS scrobbles + \\FROM scrobbles + \\INNER JOIN albumsongsartists ON albumsongsartists.albumsong_id = scrobbles.albumsong + \\INNER JOIN artists ON artists.id = albumsongsartists.artist_id + \\WHERE LOWER(artists.name) LIKE LOWER($1) + \\GROUP BY artists.id + \\ORDER BY artists.name ASC, scrobbles DESC; + , + else => unreachable, + }, + .get_ratings => switch (entity) { + .song => + \\SELECT rating AS score, rating_text AS review, TO_CHAR(date, 'YYYY-MM-DD') AS date + \\FROM songratings + \\WHERE song = $1 + \\ORDER BY date DESC; + , + .album => + \\SELECT rating AS score, rating_text AS review, TO_CHAR(date, 'YYY-MM-DD') AS date + \\FROM albumratings + \\WHERE album = $1 + \\ORDER BY date DESC; + , + else => unreachable, + }, + .friends => switch (entity) { + .song => + \\SELECT name, COUNT(DISTINCT dt) AS days, COUNT(dt) AS plays + \\FROM ( + \\ SELECT scrobbles.albumsong, songs.name, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD') AS dt + \\ FROM scrobbles + \\ INNER JOIN albumsongs ON albumsongs.id = scrobbles.albumsong + \\ INNER JOIN songs ON songs.id = albumsongs.song_id + \\ WHERE TO_CHAR(datetime,'YYYY-MM-DD') IN ( + \\ SELECT DISTINCT TO_CHAR(datetime,'YYYY-MM-DD') + \\ FROM scrobbles + \\ WHERE albumsong = $1 + \\ ) + \\) GROUP BY albumsong, name ORDER BY days DESC, plays DESC, name ASC; + , + else => unreachable, + }, + .peak => switch (entity) { + .scrobble => @compileError("Cannot specify scrobble for peak"), + .song => + \\SELECT songs.id AS eid, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD') AS datetime FROM scrobbles + \\INNER JOIN albumsongs ON albumsongs.id = scrobbles.albumsong + \\INNER JOIN songs ON songs.id = albumsongs.song_id + \\ORDER BY datetime ASC; + , + .album => + \\SELECT albums.id AS eid, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD') AS datetime FROM scrobbles + \\INNER JOIN albumsongs ON albumsongs.id = scrobbles.albumsong + \\INNER JOIN albums ON albums.id = albumsongs.album_id + \\ORDER BY datetime ASC; + , + .artist => + \\SELECT artists.id AS eid, TO_CHAR(scrobbles.datetime, 'YYYY-MM-DD') AS datetime 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 + \\ORDER BY datetime ASC; + , + }, + }, + }; +} diff --git a/src/types.zig b/src/types.zig index 55bca1c..dd5fd20 100644 --- a/src/types.zig +++ b/src/types.zig @@ -1,38 +1,92 @@ -const zeit = @import("zeit"); +const std = @import("std"); -pub const LastFMScrobble = struct { +pub const UnifiedScrobble = struct { + track: ?[]const u8, + // These can be null per Spotify + //track_artist: ?[]const u8, // As far as I'm aware, there are no services that provide separate track/album artist lists + album: ?[]const u8, + album_artist: ?[]const u8, + date: i64, + // Relevant Spotify data + playtime: ?u64 = null, + reason_end: ?[]const u8 = null, +}; + +const ScrobbleSources = enum { + LastFMStats, + LastFMWeb, + Spotify, +}; + +fn hashAndSign(a: []const u8) !i64 { + return @as(i64, @bitCast(std.hash.Fnv1a_64.hash(a))); +} + +fn pair(a: u64, b: u64) u64 { + return @mod(@divFloor((a +% b) *% (a +% b +% 1), 2) +% b, std.math.maxInt(u64)); +} + +pub const Scrobble = struct { track: []const u8, - artist: []const u8, - album: []const u8 = "", - date: i128, + track_artists: ?[]const []const u8, + album: []const u8 = "Unknown Album", + album_artists: []const []const u8, + date: i64, + + pub fn asHash(self: *Scrobble, allocator: std.mem.Allocator) ![3]u64 { + var string_buf = try std.ArrayListUnmanaged(u8).initCapacity(allocator, 0); + + for (self.album_artists) |artist| try string_buf.appendSlice(allocator, artist); + + try string_buf.appendSlice(allocator, self.album); + const a = std.hash.Fnv1a_64.hash(string_buf.items); + + try string_buf.appendSlice(allocator, self.track); + const s = std.hash.Fnv1a_64.hash(try string_buf.toOwnedSlice(allocator)); + + return .{ a, s, pair(a, s) }; + } }; -// From lastfmstats.com -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, - ms_played: u64, - 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, - reason_start: []const u8, - reason_end: ?[]const u8, - shuffle: bool, - skipped: ?bool, - offline: bool, - offline_timestamp: u64, - incognito_mode: ?bool, +pub const Rule = struct { + name: []const u8, + cond_req: enum { any, all }, + conditionals: []struct { + match_on: enum { album_artists, track_artists, album, track }, + match_cond: enum { is, contains }, + match_txt: []const u8, + }, + actions: []struct { + action: enum { replace, add }, + action_on: enum { album_artists, album, track_artists, 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, + score: ?i16 = null, + review: ?[]const u8 = null, +}; + +pub const HyperlinkData = struct { + name: []const u8, + id: i64, };