Compare commits

..

No commits in common. "rawsql" and "main" have entirely different histories.
rawsql ... main

127 changed files with 1772 additions and 2527 deletions

3
.gitignore vendored
View file

@ -6,5 +6,4 @@ static/
src/app/database/data.db-journal
src/app/database/old_migrations/
src/lib
src/app/scripts/
rules.json
src/app/scripts/

133
README.md
View file

@ -1,85 +1,28 @@
# 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"
- [x] Include respective artist(s)
- [ ] Include respective artist(s)
- [ ] Include respective album[^10]
- [x] Include number of plays
- [ ] Create disambiguation pages
@ -95,7 +38,7 @@ left to the user, but the general thought is:
- [ ] Import from Discogs[^2]
- [ ] Import listening history
- [x] From Lastfmstats.com (.json file)[^3]
- [x] From Last.fm (authentication)
- [ ] From Last.fm (authentication)
- [x] From Spotify (.json file)
- [ ] From other streaming services[^4]
- [ ] "Unofficial scrobbles"[^9]
@ -106,8 +49,7 @@ left to the user, but the general thought is:
- [ ] 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
@ -115,63 +57,28 @@ left to the user, but the general thought is:
- [ ] 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.

View file

@ -12,8 +12,6 @@ pub fn build(b: *std.Build) !void {
.optimize = optimize,
});
exe.use_llvm = true;
// Example dependency:
//
const zig_time_dep = b.dependency("zeit", .{});

View file

@ -17,12 +17,12 @@
// internet connectivity.
.dependencies = .{
.jetzig = .{
.url = "https://github.com/jetzig-framework/jetzig/archive/695664bc10e6e73389ee2fe7fbcaedc27fa8adc9.tar.gz",
.hash = "jetzig-0.0.0-IpAgLSFeDwBEhfEaDBo0JqhRbvQA3ScbWjssBqhJHFmb",
.url = "https://github.com/jetzig-framework/jetzig/archive/2c52792217b9441ed5e91d67e7ec5a8959285307.tar.gz",
.hash = "jetzig-0.0.0-IpAgLbMeDwDYRqWu0OJixGbcDUoUbfAN0xGe1xsYRHTj",
},
.zeit = .{
.url = "https://github.com/rockorager/zeit/archive/refs/tags/v0.6.0.tar.gz",
.hash = "zeit-0.0.0-5I6bk_pZAgB03N1p1GmVOZ--gOFwwQSRKj1UXb5tnaKS",
.url = "https://github.com/rockorager/zeit/archive/refs/heads/main.tar.gz",
.hash = "zeit-0.6.0-5I6bk5daAgC-P60TjxRqW0bYknfCGxJp-03eS9UjGrO7",
},
},
.paths = .{

164
common_queries.md Normal file
View file

@ -0,0 +1,164 @@
Get all albums from specified artist:
```sql
SELECT artists.name, albums.name
FROM "Albumartists"
INNER JOIN artists
ON "Albumartists".artist_id = artists.id
INNER JOIN albums
ON "Albumartists".album_id = albums.id
WHERE artists.name = {ARTIST};
```
Get all songs from specified artist:
```sql
SELECT artists.name, songs.name
FROM "Songartists"
INNER JOIN artists
ON "Songartists".artist_id = artists.id
INNER JOIN songs
ON "Songartists".song_id = songs.id
WHERE artists.name = {ARTIST};
```
Get all songs from any album of the specified name:
```sql
SELECT songs.name
FROM "Albumsongs"
INNER JOIN albums
ON "Albumsongs".album_id = albums.id
INNER JOIN songs
ON "Albumsongs".song_id = songs.id
WHERE albums.name = {ALBUM};
```
Sort all songs by plays (does not list artist or album):
```sql
SELECT songs.name, COUNT(scrobbles.song_id) AS scount
FROM songs, scrobbles
WHERE songs.id = scrobbles.song_id
GROUP BY songs.id
ORDER BY scount DESC;
```
Sort all songs by plays, and include artist:
```sql
SELECT songs.name, artists.name, COUNT(scrobbles.song_id) AS scount
FROM scrobbles, "Songartists"
INNER JOIN artists
ON "Songartists".artist_id = artists.id
INNER JOIN songs
ON "Songartists".song_id = songs.id
WHERE songs.id = scrobbles.song_id
GROUP BY songs.id, artists.id
ORDER BY scount DESC;
```
Sort all songs by plays, and include artist and album:
```sql
SELECT songs.name, artists.name, albums.name, COUNT(scrobbles.song_id) AS scount
FROM scrobbles CROSS JOIN "Songartists" CROSS JOIN "Albumsongs"
INNER JOIN artists
ON "Songartists".artist_id = artists.id
INNER JOIN songs
ON "Songartists".song_id = songs.id AND "Albumsongs".song_id = songs.id
INNER JOIN albums
ON "Albumsongs".album_id = albums.id
WHERE songs.id = scrobbles.song_id
GROUP BY songs.id, artists.id, albums.id
ORDER BY scount DESC;
```
Sort all albums by plays, and include artist:
```sql
SELECT albums.name, artists.name, COUNT(scrobbles.album_id) AS scount
FROM scrobbles, "Albumartists"
INNER JOIN albums
ON "Albumartists".album_id = albums.id
INNER JOIN artists
ON "Albumartists".artist_id = artists.id
WHERE albums.id = scrobbles.album_id
GROUP BY artists.id, albums.id
ORDER BY scount DESC;
```
Sort all artists by plays:
```sql
SELECT artists.name, COUNT(scrobbles.id) AS scount
FROM artists, "Scrobbleartists"
INNER JOIN scrobbles
ON scrobbles.id = "Scrobbleartists".scrobble_id
WHERE "Scrobbleartists".artist_id = artists.id
GROUP BY artists.id
ORDER BY scount DESC;
```
Sort all artists by alphabetical order, and include the first time you listened to that artist:
```sql
SELECT artists.name, MIN(scrobbles.date)
FROM "Scrobbleartists"
INNER JOIN artists
ON "Scrobbleartists".artist_id = artists.id
INNER JOIN scrobbles
ON "Scrobbleartists".scrobble_id = scrobbles.id
GROUP BY artists.id
ORDER BY artists.name ASC;
```
Sort all songs by alphabetical order, and include the first time you listened to that song:
```sql
SELECT songs.name, MIN(scrobbles.date)
FROM scrobbles
INNER JOIN songs
ON scrobbles.song_id = songs.id
GROUP BY songs.id
ORDER BY songs.name ASC;
```
Sort all albums by alphabetical order, and include the first time you listened to that album:
```sql
SELECT albums.name, MIN(scrobbles.date)
FROM scrobbles
INNER JOIN albums
ON scrobbles.album_id = albums.id
GROUP BY albums.id
ORDER BY albums.name ASC;
```
Select all songs by specified artists, include the number of plays of each song, and sort by plays:
```sql
SELECT songs.name, COUNT(scrobbles.song_id) as count
FROM songs, "Scrobbleartists"
INNER JOIN artists
ON "Scrobbleartists".artist_id = artists.id
INNER JOIN scrobbles
ON "Scrobbleartists".scrobble_id = scrobbles.id
WHERE songs.id = scrobbles.song_id AND artists.name = {ARTIST}
GROUP BY songs.id
ORDER BY count DESC;
```
Select all albums by specified artist, include the number of plays of each album, and sort by plays:
```sql
SELECT albums.name, COUNT(scrobbles.song_id) as count
FROM albums, "Scrobbleartists"
INNER JOIN artists
ON "Scrobbleartists".artist_id = artists.id
INNER JOIN scrobbles
ON "Scrobbleartists".scrobble_id = scrobbles.id
WHERE albums.id = scrobbles.album_id AND artists.name = {ARTIST}
GROUP BY albums.id
ORDER BY count DESC;
```
Select all songs from an album specified by an ID, and sort by plays
```sql
SELECT songs.name, COUNT(scrobbles.song_id) AS count
FROM "Albumsongs"
INNER JOIN songs
ON songs.id = "Albumsongs".song_id
INNER JOIN scrobbles
ON scrobbles.song_id = "Albumsongs".song_id
WHERE "Albumsongs".album_id = {ALBUM_ID}
GROUP BY songs.id
ORDER BY count DESC;
```

View file

@ -15,7 +15,7 @@ pub const database = .{
.port = 5432,
.username = "postgres",
.password = "postgres",
.database = "zuletzt_rsql",
.database = "zuletzt_dev",
.pool_size = 16,
},

View file

@ -4,7 +4,7 @@ pub const Album = jetquery.Model(
@This(),
"albums",
struct {
id: i64,
id: i32,
name: []const u8,
length: ?f32,
created_at: jetquery.DateTime,
@ -12,19 +12,91 @@ pub const Album = jetquery.Model(
},
.{
.relations = .{
.masteralbum = jetquery.belongsTo(.Masteralbum, .{}),
.scrobbles = jetquery.hasMany(.Scrobble, .{}),
.ratings = jetquery.hasMany(.Rating, .{}),
.aliases = jetquery.hasMany(.Alias, .{}),
.albumsongs = jetquery.hasMany(.Albumsong, .{}),
.artistalbums = jetquery.hasMany(.Artistalbum, .{}),
.albumartists = jetquery.hasMany(.Albumartist, .{}),
},
},
);
pub const Albumsong = jetquery.Model(
pub const Alias = jetquery.Model(
@This(),
"albumsongs",
"aliases",
struct {
id: i64,
song_id: i64,
album_id: i64,
id: i32,
reference_id: i32,
alias: []const u8,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{},
);
pub const Artist = jetquery.Model(
@This(),
"artists",
struct {
id: i32,
name: []const u8,
descriptive_string: []const u8,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.scrobbleartists = jetquery.hasMany(.Scrobbleartist, .{}),
.aliases = jetquery.hasMany(.Alias, .{}),
.artistsongs = jetquery.hasMany(.Songartist, .{}),
.mastersongs = jetquery.hasMany(.Mastersong, .{}),
.artistalbums = jetquery.hasMany(.Albumartist, .{}),
.masteralbums = jetquery.hasMany(.Masteralbum, .{}),
},
},
);
pub const Masteralbum = jetquery.Model(
@This(),
"masteralbums",
struct {
id: i32,
name: []const u8,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.albums = jetquery.hasMany(.Album, .{}),
},
},
);
pub const Mastersong = jetquery.Model(
@This(),
"mastersongs",
struct {
id: i32,
name: []const u8,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.songs = jetquery.hasMany(.Song, .{}),
},
},
);
pub const Rating = jetquery.Model(
@This(),
"ratings",
struct {
id: i32,
reference_id: i32,
score: f32,
date: jetquery.DateTime,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
@ -32,80 +104,27 @@ pub const Albumsong = jetquery.Model(
.relations = .{
.song = jetquery.belongsTo(.Song, .{}),
.album = jetquery.belongsTo(.Album, .{}),
.scrobbles = jetquery.hasMany(.Scrobble, .{ .foreign_key = "albumsong" }),
.albumsongsartists = jetquery.hasMany(.Albumsongsartist, .{}),
},
},
);
pub const Albumsongsartist = jetquery.Model(
@This(),
"albumsongsartists",
struct {
id: i64,
albumsong_id: i64,
artist_id: i64,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.albumsong = jetquery.belongsTo(.Albumsong, .{}),
.artist = jetquery.belongsTo(.Artist, .{}),
},
},
);
pub const Artistalbum = jetquery.Model(
@This(),
"artistalbums",
struct {
id: i64,
album_id: i64,
artist_id: i64,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.album = jetquery.belongsTo(.Album, .{}),
.artist = jetquery.belongsTo(.Artist, .{}),
},
},
);
pub const Artist = jetquery.Model(
@This(),
"artists",
struct {
id: i64,
name: []const u8,
disambiguation: ?[]const u8,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.albumsongsartists = jetquery.hasMany(.Albumsongsartist, .{}),
.artistalbums = jetquery.hasMany(.Artistalbum, .{}),
.artistsongs = jetquery.hasMany(.Artistsong, .{}),
},
},
);
pub const Scrobble = jetquery.Model(
@This(),
"scrobbles",
struct {
id: i32,
albumsong: i64,
datetime: jetquery.DateTime,
song_id: i32,
album_id: i32,
date: jetquery.DateTime,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.albumsong = jetquery.belongsTo(.Albumsong, .{ .foreign_key = "albumsong" }),
.song = jetquery.belongsTo(.Song, .{}),
.album = jetquery.belongsTo(.Album, .{}),
.scrobbleartists = jetquery.hasMany(.Scrobbleartist, .{}),
},
},
);
@ -114,7 +133,7 @@ pub const Song = jetquery.Model(
@This(),
"songs",
struct {
id: i64,
id: i32,
name: []const u8,
length: ?f32,
hidden: bool,
@ -123,83 +142,84 @@ pub const Song = jetquery.Model(
},
.{
.relations = .{
.mastersong = jetquery.belongsTo(.Mastersong, .{}),
.scrobbles = jetquery.hasMany(.Scrobble, .{}),
.ratings = jetquery.hasMany(.Rating, .{}),
.aliases = jetquery.hasMany(.Alias, .{}),
.songartists = jetquery.hasMany(.Songartist, .{}),
.albumsongs = jetquery.hasMany(.Albumsong, .{}),
.artistsongs = jetquery.hasMany(.Artistsong, .{}),
},
},
);
pub const Artistsong = jetquery.Model(
pub const Albumartist = jetquery.Model(
@This(),
"artistsongs",
"Albumartists",
struct {
id: i64,
artist_id: i64,
song_id: i64,
id: i32,
album_id: i32,
artist_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.album = jetquery.belongsTo(.Album, .{}),
.artist = jetquery.belongsTo(.Artist, .{}),
},
},
);
pub const Songartist = jetquery.Model(
@This(),
"Songartists",
struct {
id: i32,
song_id: i32,
artist_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.song = jetquery.belongsTo(.Song, .{}),
.artist = jetquery.belongsTo(.Artist, .{}),
},
},
);
pub const Albumsong = jetquery.Model(
@This(),
"Albumsongs",
struct {
id: i32,
album_id: i32,
song_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.album = jetquery.belongsTo(.Album, .{}),
.song = jetquery.belongsTo(.Song, .{}),
},
},
);
pub const Albumrating = jetquery.Model(
pub const Scrobbleartist = jetquery.Model(
@This(),
"albumratings",
"Scrobbleartists",
struct {
id: i32,
album: i64,
rating: ?i16,
rating_text: ?[]const u8,
date: jetquery.DateTime,
scrobble_id: i32,
artist_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.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" }),
.scrobble = jetquery.belongsTo(.Scrobble, .{}),
.artist = jetquery.belongsTo(.Artist, .{}),
},
},
);

View file

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

View file

@ -0,0 +1,20 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"aliases",
&.{
t.primaryKey("id", .{}),
t.column("reference_id", .integer, .{}),
t.column("alias", .string, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("aliases", .{});
}

View file

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

View file

@ -0,0 +1,19 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"masteralbums",
&.{
t.primaryKey("id", .{}),
t.column("name", .string, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("masteralbums", .{});
}

View file

@ -0,0 +1,19 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"mastersongs",
&.{
t.primaryKey("id", .{}),
t.column("name", .string, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("mastersongs", .{});
}

View file

@ -4,12 +4,11 @@ const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"songratings",
"ratings",
&.{
t.primaryKey("id", .{}),
t.column("song", .bigint, .{ .reference = .{ "songs", "id" } }),
t.column("rating", .smallint, .{ .optional = true }),
t.column("rating_text", .text, .{ .optional = true }),
t.column("reference_id", .integer, .{}),
t.column("score", .float, .{}),
t.column("date", .datetime, .{}),
t.timestamps(.{}),
},
@ -18,5 +17,5 @@ pub fn up(repo: anytype) !void {
}
pub fn down(repo: anytype) !void {
try repo.dropTable("songratings", .{});
try repo.dropTable("ratings", .{});
}

View file

@ -7,8 +7,9 @@ pub fn up(repo: anytype) !void {
"scrobbles",
&.{
t.primaryKey("id", .{}),
t.column("albumsong", .bigint, .{ .reference = .{ "albumsongs", "id" } }),
t.column("datetime", .datetime, .{}),
t.column("song_id", .integer, .{}),
t.column("album_id", .integer, .{}),
t.column("date", .datetime, .{}),
t.timestamps(.{}),
},
.{},

View file

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

View file

@ -0,0 +1,20 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"Albumartists",
&.{
t.primaryKey("id", .{}),
t.column("album_id", .integer, .{}),
t.column("artist_id", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("Albumartists", .{});
}

View file

@ -0,0 +1,20 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"Songartists",
&.{
t.primaryKey("id", .{}),
t.column("song_id", .integer, .{}),
t.column("artist_id", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("Songartists", .{});
}

View file

@ -0,0 +1,20 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"Albumsongs",
&.{
t.primaryKey("id", .{}),
t.column("album_id", .integer, .{}),
t.column("song_id", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("Albumsongs", .{});
}

View file

@ -0,0 +1,20 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"Scrobbleartists",
&.{
t.primaryKey("id", .{}),
t.column("scrobble_id", .integer, .{}),
t.column("artist_id", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("Scrobbleartists", .{});
}

View file

@ -1,20 +0,0 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"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", .{});
}

View file

@ -1,20 +0,0 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"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", .{});
}

View file

@ -1,20 +0,0 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"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", .{});
}

View file

@ -1,20 +0,0 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"artistsongs",
&.{
t.primaryKey("id", .{ .type = .bigint }),
t.column("artist_id", .bigint, .{ .reference = .{ "artists", "id" } }),
t.column("song_id", .bigint, .{ .reference = .{ "songs", "id" } }),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("artistsongs", .{});
}

View file

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

View file

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

View file

@ -1,31 +0,0 @@
const std = @import("std");
const jetzig = @import("jetzig");
// The `run` function for a job is invoked every time the job is processed by a queue worker
// (or by the Jetzig server if the job is processed in-line).
//
// Arguments:
// * allocator: Arena allocator for use during the job execution process.
// * params: Params assigned to a job (from a request, values added to response data).
// * env: Provides the following fields:
// - logger: Logger attached to the same stream as the Jetzig server.
// - environment: Enum of `{ production, development }`.
pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
_ = allocator;
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);
}
}
}

View file

@ -1,25 +0,0 @@
const std = @import("std");
const jetzig = @import("jetzig");
// The `run` function for a job is invoked every time the job is processed by a queue worker
// (or by the Jetzig server if the job is processed in-line).
//
// Arguments:
// * allocator: Arena allocator for use during the job execution process.
// * params: Params assigned to a job (from a request, values added to response data).
// * env: Provides the following fields:
// - logger: Logger attached to the same stream as the Jetzig server.
// - environment: Enum of `{ production, development }`.
pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
_ = allocator;
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);
}
}

View file

@ -1,34 +0,0 @@
const std = @import("std");
const jetzig = @import("jetzig");
// The `run` function for a job is invoked every time the job is processed by a queue worker
// (or by the Jetzig server if the job is processed in-line).
//
// Arguments:
// * allocator: Arena allocator for use during the job execution process.
// * params: Params assigned to a job (from a request, values added to response data).
// * env: Provides the following fields:
// - logger: Logger attached to the same stream as the Jetzig server.
// - environment: Enum of `{ production, development }`.
pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
_ = allocator;
//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);
}
}
}

View file

@ -1,53 +0,0 @@
const std = @import("std");
const jetzig = @import("jetzig");
const Data = @import("../../types.zig");
pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
_ = env;
const rule = try std.json.parseFromSliceLeaky(Data.Rule, allocator, try params.toJson(), .{ .ignore_unknown_fields = true });
const file_read: std.fs.File = std.fs.cwd().openFile("rules.json", .{}) catch |read_err| switch (read_err) {
error.FileNotFound => {
const file = std.fs.cwd().createFile("rules.json", .{ .read = true, .exclusive = true }) catch |write_err| switch (write_err) {
error.PathAlreadyExists => unreachable,
else => {
std.log.debug("{any} while writing file", .{write_err});
return;
},
};
const out_rules = &[_]Data.Rule{rule};
const out = try std.json.stringifyAlloc(allocator, out_rules, .{});
try file.writeAll(out);
file.close();
return;
},
else => {
std.log.debug("{any} while reading file", .{read_err});
return;
},
};
var rules = std.ArrayList(Data.Rule).init(allocator);
const file_content = try file_read.readToEndAlloc(allocator, 16_000_000);
file_read.close();
const file_write: std.fs.File = try std.fs.cwd().openFile("rules.json", .{ .mode = .write_only });
if (file_content.len == 0) {
const out_rules = &[_]Data.Rule{rule};
const out = try std.json.stringifyAlloc(allocator, out_rules, .{});
try file_write.writeAll(out);
file_write.close();
return;
}
const content: []Data.Rule = try std.json.parseFromSliceLeaky([]Data.Rule, allocator, file_content, .{});
try rules.appendSlice(content);
try rules.append(rule);
const out = try std.json.stringifyAlloc(allocator, rules.items, .{});
try file_write.writeAll(out);
file_write.close();
return;
}

View file

@ -0,0 +1,131 @@
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 });
// }
//}
}

View file

@ -1,131 +0,0 @@
const std = @import("std");
const jetzig = @import("jetzig");
// The `run` function for a job is invoked every time the job is processed by a queue worker
// (or by the Jetzig server if the job is processed in-line).
//
// Arguments:
// * allocator: Arena allocator for use during the job execution process.
// * params: Params assigned to a job (from a request, values added to response data).
// * env: Provides the following fields:
// - logger: Logger attached to the same stream as the Jetzig server.
// - environment: Enum of `{ production, development }`.
pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
_ = allocator;
for (params.getT(.object, "tracks").?.items()) |track| {
const id = try std.fmt.parseInt(i64, track.key, 10);
const track_query = try jetzig.database.Query(.Song)
.find(id).execute(env.repo);
if (track_query == null) {
const name = try track.value.coerce([]const u8);
try jetzig.database.Query(.Song)
.insert(.{ .id = id, .name = name, .length = null, .hidden = false })
.execute(env.repo);
}
}
for (params.getT(.object, "albums").?.items()) |album| {
const id = try std.fmt.parseInt(i64, album.key, 10);
const album_query = try jetzig.database.Query(.Album)
.find(id).execute(env.repo);
if (album_query == null) {
const name = try album.value.coerce([]const u8);
try jetzig.database.Query(.Album)
.insert(.{ .id = id, .name = name, .length = null })
.execute(env.repo);
}
}
for (params.getT(.object, "artists").?.items()) |artist| {
const id = try std.fmt.parseInt(i64, artist.key, 10);
const artist_query = try jetzig.database.Query(.Artist)
.find(id).execute(env.repo);
if (artist_query == null) {
const name = try artist.value.coerce([]const u8);
try jetzig.database.Query(.Artist)
.insert(.{ .id = id, .name = name })
.execute(env.repo);
}
}
for (params.getT(.object, "albumsongs").?.items()) |as| {
const id = try std.fmt.parseInt(i64, as.key, 10);
const as_query = try jetzig.database.Query(.Albumsong)
.find(id).execute(env.repo);
if (as_query == null) {
const track_id = @as(i64, @intCast(as.value.getT(.integer, "song").?));
const album_id = @as(i64, @intCast(as.value.getT(.integer, "album").?));
try jetzig.database.Query(.Albumsong)
.insert(.{ .id = id, .song_id = track_id, .album_id = album_id })
.execute(env.repo);
}
const scrobbles = as.value.getT(.array, "scrobbles").?;
for (scrobbles.items()) |date| {
try jetzig.database.Query(.Scrobble).insert(.{ .albumsong = id, .datetime = date })
.execute(env.repo);
}
}
for (params.getT(.object, "artistalbums").?.items()) |aa| {
const id = try std.fmt.parseInt(i64, aa.key, 10);
const aa_query = try jetzig.database.Query(.Artistalbum)
.find(id).execute(env.repo);
if (aa_query == null) {
const artist_id = @as(i64, @intCast(aa.value.getT(.integer, "artist").?));
const album_id = @as(i64, @intCast(aa.value.getT(.integer, "album").?));
try jetzig.database.Query(.Artistalbum)
.insert(.{ .id = id, .artist_id = artist_id, .album_id = album_id })
.execute(env.repo);
}
}
for (params.getT(.object, "albumsongsartists").?.items()) |asa| {
const id = try std.fmt.parseInt(i64, asa.key, 10);
const asa_query = try jetzig.database.Query(.Albumsongsartist)
.find(id).execute(env.repo);
if (asa_query == null) {
const albumsong_id = @as(i64, @intCast(asa.value.getT(.integer, "albumsong").?));
const artist_id = @as(i64, @intCast(asa.value.getT(.integer, "artist").?));
try jetzig.database.Query(.Albumsongsartist)
.insert(.{ .id = id, .albumsong_id = albumsong_id, .artist_id = artist_id })
.execute(env.repo);
}
}
//for ((params.getT(.object, "albumsongsartists")).?.items(.object)) |asa| {
// const id = try std.fmt.parseInt(i64, asa.key, 10);
// const albumsong_id = asa.value.getT(.integer, "albumsong");
// const track_artist_id = asa.value.getT(.integer, "artist");
// const albumsongartist = try jetzig.database.Query(.Albumsongsartist)
// .find(id)
// .select(.{.id}).execute(env.repo);
// if (albumsongartist == null) {
// var artist_id = try jetzig.database.Query(.Artist)
// .find(track_artist_id)
// .select(.{.id}).execute(env.repo);
//
// if (artist_id == null) {
// const artist = params.chain(.{"artists",})
// artist_id = try jetzig.database.Query(.Artist)
// .insert(.{ .id = track_artist_hash, .name = scrobble_track_artist, .disambiguation = null })
// .execute(env.repo);
// }
// }
//}
}

View file

@ -0,0 +1,65 @@
/// Demo middleware. Assign middleware by declaring `pub const middleware` in the
/// `jetzig_options` defined in your application's `src/main.zig`.
///
/// Middleware is called before and after the request, providing full access to the active
/// request, allowing you to execute any custom code for logging, tracking, inserting response
/// headers, etc.
///
/// This middleware is configured in the demo app's `src/main.zig`:
///
/// ```
/// pub const jetzig_options = struct {
/// pub const middleware: []const type = &.{@import("app/middleware/DemoMiddleware.zig")};
/// };
/// ```
const std = @import("std");
const jetzig = @import("jetzig");
/// Define any custom data fields you want to store here. Assigning to these fields in the `init`
/// function allows you to access them in various middleware callbacks defined below, where they
/// can also be modified.
my_custom_value: []const u8,
const Self = @This();
/// Initialize middleware.
pub fn init(request: *jetzig.http.Request) !*Self {
var middleware = try request.allocator.create(Self);
middleware.my_custom_value = "initial value";
return middleware;
}
/// Invoked immediately after the request is received but before it has started processing.
/// Any calls to `request.render` or `request.redirect` will prevent further processing of the
/// request, including any other middleware in the chain.
pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void {
try request.server.logger.DEBUG(
"[DemoMiddleware:afterRequest] my_custom_value: {s}",
.{self.my_custom_value},
);
self.my_custom_value = @tagName(request.method);
}
/// Invoked immediately before the response renders to the client.
/// The response can be modified here if needed.
pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void {
try request.server.logger.DEBUG(
"[DemoMiddleware:beforeResponse] my_custom_value: {s}, response status: {s}",
.{ self.my_custom_value, @tagName(response.status_code) },
);
}
/// Invoked immediately after the response has been finalized and sent to the client.
/// Response data can be accessed for logging, but any modifications will have no impact.
pub fn afterResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void {
_ = self;
_ = response;
try request.server.logger.DEBUG("[DemoMiddleware:afterResponse] response completed", .{});
}
/// Invoked after `afterResponse` is called. Use this function to do any clean-up.
/// Note that `request.allocator` is an arena allocator, so any allocations are automatically
/// freed before the next request starts processing.
pub fn deinit(self: *Self, request: *jetzig.http.Request) void {
request.allocator.destroy(self);
}

View file

@ -1,62 +1,155 @@
const std = @import("std");
const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery;
const TableRow = @import("../../types.zig").TableRow;
const HyperlinkData = @import("../../types.zig").HyperlinkData;
const queries = @import("../../queries.zig");
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);
const albums = try queries.entityQueryResult(request, queries.loadQuery(.album, .entities), .{});
try root.put("albums", albums);
for (albums) |album| {
var album_view = try albums_view.append(.object);
var artist_infos = try album_view.put("artist_info", .array);
for (album.albumartists) |artist| {
var artist_info = try artist_infos.append(.object);
const artist_data = try jetzig.database.Query(.Artist)
.find(artist.artist_id)
.select(.{ .id, .name })
.execute(request.repo);
try artist_info.put("name", artist_data.?.name);
try artist_info.put("id", artist_data.?.id);
}
try album_view.put("name", album.name);
try album_view.put("url", album.id);
try album_view.put("scrobbles", (album.scrobbles).len);
}
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
const album = try jetzig.database.Query(.Album)
.find(id)
.select(.{ .id, .name })
.execute(request.repo);
var root = try request.data(.object);
try root.put("album", album.?.name);
var songs_view = try root.put("songs", .array);
const query = jetzig.database.Query(.Albumsong)
.select(.{.id})
.include(.song, .{ .select = .{ .name, .id } })
.join(.inner, .album)
.where(.{ .album = .{ .id = id } });
const 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);
const songs = try request.repo.all(query);
for (songs) |song| {
const scrobbles = try jetzig.database.Query(.Scrobble)
.where(.{ .song_id = song.song.id })
.count()
.execute(request.repo);
var song_view = try songs_view.append(.object);
try song_view.put("name", song.song.name);
try song_view.put("url", song.song.id);
try song_view.put("scrobbles", scrobbles);
}
return request.render(.ok);
}
pub fn new(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}
pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request) !jetzig.View {
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/album", .{});
try response.expectStatus(.ok);
}
test "get" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/album/example-id", .{});
try response.expectStatus(.ok);
}
test "new" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/album/new", .{});
try response.expectStatus(.ok);
}
test "edit" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/album/example-id/edit", .{});
try response.expectStatus(.ok);
}
test "post" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.POST, "/album", .{});
try response.expectStatus(.created);
}
test "put" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PUT, "/album/example-id", .{});
try response.expectStatus(.ok);
}
test "patch" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PATCH, "/album/example-id", .{});
try response.expectStatus(.ok);
}
test "delete" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.DELETE, "/album/example-id", .{});
try response.expectStatus(.ok);
}

View file

@ -1,61 +1,19 @@
@zig {
const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
const columns: ColumnChoices = &.{.song, .scrobbles};
const dis_columns: ColumnChoices = &.{.album, .artistlist, .scrobbles};
}
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.5/dist/htmx.min.js" integrity="sha384-t4DxZSyQK+0Uv4jzy5B0QyHyWQD2GFURUmxKMBVww9+e2EJ0ei/vCvv7+79z0fkr" crossorigin="anonymous"></script>
<meta charset="UTF-8">
</head>
<body>
@partial partials/header
@if ($.disambiguation)
<h1>{{.name}} (disambiguation)</h1>
@partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: dis_columns)
@else
@zig {
const reviews = try zmpl.coerceArray(".reviews");
<h1>{{.album}}</h1>
<table>
<tr>
<th>Name</th>
@for (.songs) |song| {
<tr>
<td class=cell><a href="/songs/{{song.url}}">{{song.name}}</a></td>
<td class=cell>{{song.scrobbles}}</td>
</tr>
}
<div style="text-align:center">
<h1>{{.album.album_name}}</h1>
<h2><a href="/artists/{{.album.artist_id}}">{{.album.artist_name}}</a></h2>
</div>
<div style="display:flex;flex-direction:row;justify-content:space-evenly">
<div style="display:flex;flex-direction:column;align-self:left">
@if ($.album.is_tie)
<div>{{.album.scrobbles}} scrobbles ({{.album.rank}} place, tied)</div>
@else
<div>{{.album.scrobbles}} scrobbles ({{.album.rank}} place)</div>
@end
<div>{{.album.song_num}} songs</div>
@partial partials/firstlast_listens(firstlast: .firstlast)
<h3>Yearly Performance</h3>
@partial partials/timescale(range: .yearly)
<h2>Songs</h2>
@partial partials/newtable(T: ColumnChoices, table_data: .songs, columns: columns)
</div>
<div style="display:flex;flex-direction:column;align-self:right">
<h2>Rating</h2>
<div id="review-container">
@zig {
if (reviews.len == 0) {
<form>
<input type="number" name="score" id="score" style="width:50px;height:30px">
<textarea name="review" id="review" style="width:350px;height:100px"></textarea>
<button hx-post="/ratings/albums" hx-vals='{"album_id":"{{.album.album_id}}"}' hx-target="#review-container" style="width:50px;height:30px">Post</button>
</form>
} else {
for (reviews) |review| {
<b>{{review.score}}</b>: {{review.review}} ({{review.date}})
}
}
}
</div>
</div>
@end
</table>
</body>
</html>

View file

@ -1,15 +1,40 @@
@zig {
const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
const columns: ColumnChoices = &.{.album, .artistlist, .scrobbles};
}
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/simple-datatables@latest/dist/style.css" rel="stylesheet" type="text/css">
<meta charset="UTF-8">
</head>
<body>
@partial partials/header
<h1>Albums</h1>
@partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: columns)
<table id="myTable">
<thead>
<tr>
<th>Name</th>
<th>Artist(s)</th>
<th>Scrobbles</th>
</tr>
</thead>
</tbody>
@for (.albums) |album| {
<tr>
<td class=cell><a href="/albums/{{album.url}}">{{album.name}}</a></td>
<td class=cell>
@for (album.get("artist_info").?) |ai| {
<a href="/artists/{{ai.id}}">{{ai.name}}</a>
}
</td>
<td class=cell>{{album.scrobbles}}</td>
</tr>
}
</tbody>
</table>
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@latest" type="text/javascript"></script>
<script>
const dataTable = new simpleDatatables.DataTable("#myTable", {
searchable: false,
perPage: 50,
perPageSelect: [25,50,100],
});
</script>
</body>
</html>

View file

@ -1,61 +1,141 @@
const std = @import("std");
const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery;
const TableRow = @import("../../types.zig").TableRow;
//const dateFmt = @import("../../date_fmt.zig").dateFmt;
//const ordinalFmt = @import("../../ordinal_fmt.zig").ordinalFmt;
const queries = @import("../../queries.zig");
const decode = @import("../../date_fmt.zig").urlDecode;
pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
const artists = try queries.entityQueryResult(request, queries.loadQuery(.artist, .entities), .{});
try root.put("artists", artists);
var artists_view = try root.put("artists", .array);
const artists = try jetzig.database.Query(.Artist)
.select(.{ .id, .name })
.include(.scrobbleartists, .{ .select = .{.id} })
.orderBy(.{ .name = .asc })
.all(request.repo);
for (artists) |artist| {
var artist_view = try artists_view.append(.object);
try artist_view.put("name", artist.name);
try artist_view.put("url", artist.id);
try artist_view.put("scrobbles", (artist.scrobbleartists).len);
}
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
const artist = try jetzig.database.Query(.Artist)
.find(id)
.select(.{ .id, .name })
.execute(request.repo);
var root = try request.data(.object);
try root.put("artist", artist.?.name);
var albums_view = try root.put("albums", .array);
const query = jetzig.database.Query(.Albumartist)
.select(.{.id})
.include(.album, .{ .select = .{ .name, .id } })
.join(.inner, .artist)
.where(.{ .artist = .{ .id = id } });
const 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);
const albums = try request.repo.all(query);
for (albums) |album| {
const scrobbles = try jetzig.database.Query(.Scrobble)
.where(.{ .album_id = album.album.id })
.count()
.execute(request.repo);
var album_view = try albums_view.append(.object);
try album_view.put("name", album.album.name);
try album_view.put("url", album.album.id);
try album_view.put("scrobbles", scrobbles);
}
return request.render(.ok);
}
pub fn new(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}
pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request) !jetzig.View {
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/artist", .{});
try response.expectStatus(.ok);
}
test "get" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/artist/example-id", .{});
try response.expectStatus(.ok);
}
test "new" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/artist/new", .{});
try response.expectStatus(.ok);
}
test "edit" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/artist/example-id/edit", .{});
try response.expectStatus(.ok);
}
test "post" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.POST, "/artist", .{});
try response.expectStatus(.created);
}
test "put" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PUT, "/artist/example-id", .{});
try response.expectStatus(.ok);
}
test "patch" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PATCH, "/artist/example-id", .{});
try response.expectStatus(.ok);
}
test "delete" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.DELETE, "/artist/example-id", .{});
try response.expectStatus(.ok);
}

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -1,39 +1,31 @@
@zig {
const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
const columns: ColumnChoices = &.{.album, .scrobbles};
const dis_columns: ColumnChoices = &.{.artist, .scrobbles};
}
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/simple-datatables@latest/dist/style.css" rel="stylesheet" type="text/css">
<meta charset="UTF-8">
</head>
<body>
@partial partials/header
@if ($.disambiguation)
<h1>{{.name}} (disambiguation)</h1>
@partial partials/newtable(T: ColumnChoices, table_data: .artists, columns: dis_columns)
@else
<h1>{{.artist.artist_name}}</h1>
<div>
@if ($.artist.is_tie)
<div>{{.artist.scrobbles}} scrobbles ({{.artist.rank}} place, tied)</div>
@else
<div>{{.artist.scrobbles}} scrobbles ({{.artist.rank}} place)</div>
@end
<div>{{.artist.song_num}} songs</div>
<div>{{.artist.album_num}} albums</div>
</div>
@partial partials/timescale(range: .yearly)
<br>
@partial partials/firstlast_listens(firstlast: .firstlast)
<h2>Albums</h2>
@partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: columns)
<h2>Albums Featured On</h2>
@partial partials/newtable(T: ColumnChoices, table_data: .appears, columns: columns)
@end
<h1>{{.artist}}</h1>
<table id="myTable">
<thead>
<tr>
<th>Name</th><th>Scrobbles</th>
</tr>
</thead>
<tbody>
@for (.albums) |album| {
<tr>
<td class=cell><a href="/albums/{{album.url}}">{{album.name}}</a></td>
<td class=cell>{{album.scrobbles}}</td>
</tr>
}
</tbody>
</table>
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@latest" type="text/javascript"></script>
<script>
const dataTable = new simpleDatatables.DataTable("#myTable", {
searchable: false,
});
</script>
</body>
</html>

View file

@ -1,15 +1,34 @@
@zig {
const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
const columns: ColumnChoices = &.{.artist, .scrobbles};
}
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/simple-datatables@latest/dist/style.css" rel="stylesheet" type="text/css">
<meta charset="UTF-8">
</head>
<body>
@partial partials/header
<h1>Artists</h1>
@partial partials/newtable(T: ColumnChoices, table_data: .artists, columns: columns)
<table id="myTable" class='table'>
<thead>
<tr>
<th>Name</th>
<th>Scrobbles</th>
</tr>
</thead>
<tbody>
@for (.artists) |artist| {
<tr>
<td class=cell><a href="/artists/{{artist.url}}">{{artist.name}}</a></td>
<td class=cell>{{artist.scrobbles}}</td>
</tr>
}
</tbody>
</table>
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@latest" type="text/javascript"></script>
<script>
const dataTable = new simpleDatatables.DataTable("#myTable", {
searchable: true,
perPage: 50,
perPageSelect: [25,50,100],
});
</script>
</body>
</html>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -11,3 +11,26 @@ pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -11,3 +11,26 @@ pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -1,11 +0,0 @@
<head>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
</head>
@partial partials/header
<h1>Merge Songs</h1>
<form hx-get="/songs" hx-target="#response-div">
<label>Song name <input name="s" type="text"></label>
</form>
<div id="response-div"></div>

View file

@ -16,3 +16,21 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -1,11 +0,0 @@
@args firstlast: *ZmplValue
@zig {
const songs = firstlast.items(.array);
}
<div>
First listen: <a href="/songs/{{songs[0].song.id}}">{{songs[0].song.name}}</a> ({{songs[0].date}})
<br>
Most recent listen: <a href="/songs/{{songs[1].song.id}}">{{songs[1].song.name}}</a> ({{songs[1].date}})
</div>

View file

@ -1,11 +1,7 @@
<a class="header-link" href="/">Zuletzt</a>
<a class="header-link" href="/artists">Artists</a>
<a class="header-link" href="/albums">Albums</a>
<a class="header-link" href="/songs">Songs</a>
<a class="header-link" href="/scrobbles">Scrobbles</a>
<a class="header-link" href="/concerts">Concerts</a>
<a class="header-link" href="/collection">Collection</a>
<a class="header-link" href="/ratings">Ratings</a>
<a class="header-link" href="/lists">Lists</a>
<a class="header-link" href="/groups">Groups</a>
<hr>

View file

View file

@ -1,75 +0,0 @@
@args T: type, table_data: *ZmplValue, columns: T
<div>
<table>
<thead>
<tr>
@zig {
for (columns) |header| {
switch (header) {
.song => {
<th>Song</th>
},
.album => {
<th>Album</th>
},
.artist => {
<th>Artist</th>
},
.artistlist => {
<th>Artist(s)</th>
},
.scrobbles => {
<th>Scrobbles</th>
},
.date => {
<th>Date</th>
}
}
}
}
</tr>
</thead>
<tbody>
@zig {
const array = table_data.items(.array);
for (array) |row| {
<tr>
for (columns) |header| {
switch (header) {
.song => {
<td class=cell>
<a href="/songs/{{row.song.id}}">{{row.song.name}}</a>
</td>
},
.album => {
<td class=cell>
<a href="/albums/{{row.album.id}}">{{row.album.name}}</a>
</td>
},
.artist => {
<td class=cell>
<a href="/artists/{{row.artist.id}}">{{row.artist.name}}</a>
</td>
},
.artistlist => {
<td class=cell>
@for (row.get("artistlist").?) |artist| {
<a href="/artists/{{artist.id}}">{{artist.name}}</a>
}
</td>
},
.scrobbles => {
<td class=cell>{{row.scrobbles}}</td>
},
.date =>{
<td class=cell>{{row.date}}</td>
}
}
}
</tr>
}
}
</tbody>
</table>
</div>

View file

View file

View file

@ -0,0 +1,18 @@
@args table_data: *ZmplValue, table_headers: *ZmplValue
<table>
<tr>
@for (table_headers) |text| {
<th>{{text}}</th>
}
</tr>
@for (table_data) |value| {
<tr>
<td class=cell>{{value.track}}</td>
<td class=cell>{{value.artist}}</td>
<td class=cell>{{value.album}}</td>
<td class=cell>{{value.date}}</td>
</tr>
}
</table>

View file

@ -1,20 +0,0 @@
@args range: *ZmplValue
<div>
<table>
<thead>
<tr>
<th>Year</th>
<th>Scrobbles</th>
</tr>
</thead>
<tbody>
@for (range) |itm| {
<tr>
<td>{{itm.date}}:</td>
<td>{{itm.scrobbles}}</td>
</tr>
}
</tbody>
</table>
</div>

View file

@ -16,3 +16,21 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

@ -1,14 +0,0 @@
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);
}

View file

@ -1 +0,0 @@
<b>{{.score}}</b>: {{.review}} (Today)

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -1,14 +0,0 @@
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);
}

View file

@ -1 +0,0 @@
<b>{{.score}}</b>: {{.review}} (Today)

View file

@ -4,38 +4,101 @@ const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn new(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}
pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request) !jetzig.View {
const params = try request.params();
std.log.debug("{s}", .{try params.toJson()});
var job = try request.job("process_rule");
_ = try job.params.put("name", params.get("rule-title"));
_ = try job.params.put("cond_req", params.get("cond-req"));
var conditionals = try job.params.put("conditionals", .array);
inline for (0..5) |i| {
if (!std.mem.eql(u8, "", params.getT(.string, comptime std.fmt.comptimePrint("match-txt{}", .{i})).?)) {
//if (params.getT(.string, comptime std.fmt.comptimePrint("match-txt{}", .{i})) != null) {
var cond = try conditionals.append(.object);
try cond.put("match_on", params.get(comptime std.fmt.comptimePrint("match-on{}", .{i})));
try cond.put("match_cond", params.get(comptime std.fmt.comptimePrint("match-cond{}", .{i})));
try cond.put("match_txt", params.get(comptime std.fmt.comptimePrint("match-txt{}", .{i})));
}
}
var actions = try job.params.put("actions", .array);
inline for (0..5) |i| {
if (!std.mem.eql(u8, "", params.getT(.string, comptime std.fmt.comptimePrint("action-txt{}", .{i})).?)) {
//if (params.getT(.string, comptime std.fmt.comptimePrint("action-txt{}", .{i})) != null) {
var act = try actions.append(.object);
try act.put("action", params.get(comptime std.fmt.comptimePrint("action{}", .{i})));
try act.put("action_on", params.get(comptime std.fmt.comptimePrint("action-on{}", .{i})));
try act.put("action_txt", params.get(comptime std.fmt.comptimePrint("action-txt{}", .{i})));
}
}
try job.schedule();
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View {
_ = id;
return request.render(.ok);
}
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/rules", .{});
try response.expectStatus(.ok);
}
test "get" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/rules/example-id", .{});
try response.expectStatus(.ok);
}
test "new" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/rules/new", .{});
try response.expectStatus(.ok);
}
test "edit" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/rules/example-id/edit", .{});
try response.expectStatus(.ok);
}
test "post" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.POST, "/rules", .{});
try response.expectStatus(.created);
}
test "put" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PUT, "/rules/example-id", .{});
try response.expectStatus(.ok);
}
test "patch" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PATCH, "/rules/example-id", .{});
try response.expectStatus(.ok);
}
test "delete" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.DELETE, "/rules/example-id", .{});
try response.expectStatus(.ok);
}

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -1,62 +1,3 @@
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
@partial partials/header
<h1>Rules</h1>
Rules allow you change the default Scrobble import behavior based on provided criteria.
Add a rule below.
<br><br>
<form action="/rules" enctype="multipart/form-data" method="POST">
<label for="rule-title">Rule Name:</label>
<input type="text" name="rule-title" id="rule-title">
<br>
Match
<select name="cond-req" id="cond-req">
<option value="any">any</option>
<option value="all">all</option>
</select>
conditonals.
<br>
If
@for (0..5) |i| {
<select name="match-on{{i}}" id="match-on{{i}}">
<option value="artist">artist</option>
<option value="album">album</option>
<option value="track">song</option>
</select>
<select name="match-cond{{i}}" id="match-cond{{i}}">
<option value="is">is</option>
<option value="contains">contains</option>
<option value="matches">matches regex</option>
</select>
<input type="text" name="match-txt{{i}}" id="match-txt{{i}}">
<label for="case-sens">Toggle case sensitivity</label>
<input type="checkbox" name="case-sens{{i}}" id="case-sens{{i}}">
<label for="accent-sens">Toggle diacritic sensitivity</label>
<input type="checkbox" name="accent-sens{{i}}" id="accent-sens{{i}}">
<br>
}
then
@for (0..5) |i| {
<select name="action{{i}}" id="action{{i}}">
<option value="replace">replace</option>
<option value="add">add</option>
</select>
<select name="action-on{{i}}" id="action-on{{i}}">
<option value="artists_track">artist (song)</option>
<option value="artists_album">artist (album)</option>
<option value="album">album</option>
<option value="track">song</option>
</select>
with
<input type="text" name="action-txt{{i}}" id="action-txt{{i}}">
<br>
}
<button type="submit" value="Submit">Submit</button>
</form>
Current rules:
</html>
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -1,12 +1,64 @@
const std = @import("std");
const jetzig = @import("jetzig");
const queries = @import("../../queries.zig");
pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
var scrobbles_view = try root.put("scrobbles", .array);
const query = jetzig.database.Query(.Scrobble)
.select(.{ .id, .date })
.include(.song, .{ .select = .{ .id, .name } })
.include(.album, .{ .select = .{ .id, .name } })
.include(.scrobbleartists, .{ .select = .{.artist_id} })
.orderBy(.{ .date = .desc });
const scrobbles = try request.repo.all(query);
for (scrobbles) |scrobble| {
var scrobble_view = try scrobbles_view.append(.object);
const scrobbles = try queries.entityQueryResult(request, queries.loadQuery(.scrobble, .entities), .{});
try root.put("scrobbles", scrobbles);
var artist_infos = try scrobble_view.put("artist_info", .array);
for (scrobble.scrobbleartists) |artist| {
var artist_info = try artist_infos.append(.object);
const artist_data = try jetzig.database.Query(.Artist)
.find(artist.artist_id)
.select(.{ .id, .name })
.execute(request.repo);
try artist_info.put("name", artist_data.?.name);
try artist_info.put("id", artist_data.?.id);
}
try scrobble_view.put("song_name", scrobble.song.name);
try scrobble_view.put("song_id", scrobble.song.id);
try scrobble_view.put("album_name", scrobble.album.name);
try scrobble_view.put("album_id", scrobble.album.id);
try scrobble_view.put("date", scrobble.date);
}
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = id;
_ = data;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -1,15 +1,42 @@
@zig {
const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
const columns: ColumnChoices = &.{.song, .artistlist, .album, .date};
}
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/simple-datatables@latest/dist/style.css" rel="stylesheet" type="text/css">
<meta charset="UTF-8">
</head>
<body>
@partial partials/header
<h1>Scrobbles</h1>
@partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns)
<table id="myTable">
<thead>
<tr>
<th>Song</th>
<th>Artist(s)</th>
<th>Album</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (.scrobbles) |scrobble| {
<tr>
<td class=cell><a href="/songs/{{scrobble.song_id}}">{{scrobble.song_name}}</a></td>
<td class=cell>
@for (scrobble.get("artist_info").?) |ai| {
<a href="/artists/{{ai.id}}">{{ai.name}}</a>
}
</td>
<td class=cell><a href="/albums/{{scrobble.album_id}}">{{scrobble.album_name}}</a></td>
<td class=cell>{{scrobble.date}}</td>
</tr>
}
</tbody>
</table>
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@latest" type="text/javascript"></script>
<script>
const dataTable = new simpleDatatables.DataTable("#myTable", {
searchable: true,
perPage: 50,
perPageSelect: [25,50,100],
});
</script>
</body>
</html>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -43,7 +43,7 @@ test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/groups", .{});
const response = try app.request(.GET, "/search", .{});
try response.expectStatus(.ok);
}
@ -51,7 +51,7 @@ test "get" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/groups/example-id", .{});
const response = try app.request(.GET, "/search/example-id", .{});
try response.expectStatus(.ok);
}
@ -59,7 +59,7 @@ test "new" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/groups/new", .{});
const response = try app.request(.GET, "/search/new", .{});
try response.expectStatus(.ok);
}
@ -67,7 +67,7 @@ test "edit" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/groups/example-id/edit", .{});
const response = try app.request(.GET, "/search/example-id/edit", .{});
try response.expectStatus(.ok);
}
@ -75,7 +75,7 @@ test "post" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.POST, "/groups", .{});
const response = try app.request(.POST, "/search", .{});
try response.expectStatus(.created);
}
@ -83,7 +83,7 @@ test "put" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PUT, "/groups/example-id", .{});
const response = try app.request(.PUT, "/search/example-id", .{});
try response.expectStatus(.ok);
}
@ -91,7 +91,7 @@ test "patch" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PATCH, "/groups/example-id", .{});
const response = try app.request(.PATCH, "/search/example-id", .{});
try response.expectStatus(.ok);
}
@ -99,6 +99,6 @@ test "delete" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.DELETE, "/groups/example-id", .{});
const response = try app.request(.DELETE, "/search/example-id", .{});
try response.expectStatus(.ok);
}

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

View file

@ -0,0 +1,3 @@
<div>
<span>Content goes here</span>
</div>

Some files were not shown because too many files have changed in this diff Show more