Compare commits

...
Sign in to create a new pull request.

105 commits
main ... rawsql

Author SHA1 Message Date
5f451868af scrobbleIngest
The code sucks. The functionality isn't quite what we hoped for. Dates are formatted incorrectly. There are practically no comments. It's not modular whatsoever.  I lost several years of my life trying to make this work.

LGTM
2025-07-19 18:25:10 -04:00
2a42e07df0 Cleanup 2025-07-14 18:04:46 -04:00
b0f7884f84 Move rule loading in upload.zig into a function 2025-07-14 17:10:59 -04:00
280cba2f9a Switch to expectParams() rather than params()
Makes some code nicer, particularly date parsing
2025-07-14 14:04:22 -04:00
682eebc951 Create buffer for signed hashes rather than using arraylist
Also fixes bug with artistalbums hash. i64 will only ever take up 20 characters
2025-07-14 14:03:20 -04:00
cd8c798bd4 Fix incorrect pairing function 2025-07-14 14:01:48 -04:00
6aac0bff2b Remove .string branch if using .alloc_always 2025-07-09 12:09:45 -04:00
902fcd4447 Create own parsing function
I have dreamt of this for a long time. It is a minor optimization to be honest, but I previously had to choose between ugly code (what I had just prior to this) or looping through the data twice (slow). This parses the data and puts it into my intermediary type directly, along with relevant information about whetehr or not the scrobble is valid, and then I only need to loop over it once with "nice enough" code. There is still more I can do. My ultimate goal is to remove the looping entirely, and verify the data as it's being parsed, queuing up much smaller jobs that handle the individual entities (albums, artistsongs, etc.) as they come, rather than collecting it all and running it at once. I can get over the problem of needing to wit for all the LastFM responses that way as well. Furthermore, the data checking in the function expects a rather rigid structure that I'm not certain I can guarantee, but I'm pretty sure it'll stay that way. In any case, it may behoove me to make it more dynamic at some point. In any case, I am very excited about this change, and I hope I can continue improving upon it.
2025-07-07 17:13:48 -04:00
8af6341f95 Switch to defined constant when converting between s/ms/ns 2025-07-07 17:07:14 -04:00
c95ac51e05 Prevent upload from crashing if 500 is received from LastFM 2025-07-07 17:03:08 -04:00
6fe885132a Do not use peak
I only tested it on small datasets, and it wasn't so bad, but with my whole LastFM dataset, it is very bad
2025-07-07 16:40:56 -04:00
0dec52af01 Type rank 2025-07-07 16:09:30 -04:00
851aec3a97 I learned about std.math.maxInt 2025-07-07 12:14:27 -04:00
15e72ea326 Revert entity name in url redirect in all cases and create disambiguation pages for all entities
Useless for artists right now
2025-07-03 12:23:57 -04:00
7b1fc6dd71 Include track name in disambiguation 2025-07-03 00:09:31 -04:00
da9934ae1e Create disambiguation for songs
This was way easier than I expected, but I am rather unhappy with some things now. In particular, the GET page is pretty gross. I think there are some things I can do, but I'm not 100% confident. Maybe I'll bring some things up to bob once I have a better picture, but I really want to try to clean up my code
2025-07-02 23:27:39 -04:00
12722f282d Revert forced redirect after id in url and begin disambiguation page
At first, this was a nightmare. Now, I think I have a good idea about how to do disambiguation pages
2025-07-02 23:05:57 -04:00
b0727e77e1 Addd peak query and tie detection for rank
Also begins friends query for songs
2025-07-02 19:37:28 -04:00
f9718f3a37 Make loadQuery comptime
Will eventually do this for all views
2025-07-02 16:11:37 -04:00
9fa90ff129 Add check if there is a tie in scrobble count 2025-07-02 16:10:55 -04:00
5739f89e0d Write "friends" query
Will tell you which songs you listen to the most on the days you listen to some specified song
2025-06-28 00:19:48 -04:00
29041044e7 Remove unnecessary null checks 2025-06-27 00:31:47 -04:00
0b7efc3420 Begin album reviews
Album reviews would ideally allow you to rate tracks at the same time, so we'll have to work on that next. Also, disambiguation pages are becoming more and more necessary (Little Talks in inaccessible atm) Preferably, we start working on the `INDEX` for `/ratings` as well, and maybe use a unified language for these things (is it review, rating, rating_text, score,...?)
2025-06-24 00:05:25 -04:00
9f27fad235 Add section on SongGroups
They don't exist yet, but I was trying to decide in my head if there was a meaningful difference between emrging two songs, and a SongGroup, and I decided they are indeed different, but really only in a small way
2025-06-23 17:02:08 -04:00
6f6aaecb8f Create rating interface on songs view
If no ratings are present, provide a textbox to make a rating. If a rating is present, show the rating. Eventually, there will be a button that allows an additional rating to be made, and the ability to delete ratings
2025-06-23 16:50:13 -04:00
996022fe5f Make rating data optional
Use HTML to enfore at least one of the two fields has a value, but I don't want to require both
2025-06-23 16:47:46 -04:00
b7e625dd98 Start ratings
This is actually fantastic, I'm really happy with how this has worked so far. My only concern for the future is how posting reviews from the `/ratings` path might work, since it's currently designed around posting reviews from the song page itself, but I think some HTMX and/or JS wil alleviate any problems I run into
2025-06-23 10:18:17 -04:00
f292368947 Song name in url string 2025-06-23 10:15:17 -04:00
93da50652a Remove unnecessary else 2025-06-23 10:14:25 -04:00
77a9c24dab Create ratings tables 2025-06-22 14:37:10 -04:00
9c90c683c6 Temporary fix to keep using LLVM 2025-06-22 14:36:59 -04:00
2d7d2835fd Proof of concept artist name in url string
Some of my favorite code in the project. Just need a disambiguation page, and we're in business here
2025-06-18 02:11:30 -04:00
0b07947b8a Create urlDecode function for redirects 2025-06-18 02:10:51 -04:00
36873053bc Make new scrobble processing function
This uses the zmpl data as a hash map to check if we've already checked the db for some song/album/artist/etc. and now only checks once per entity/assoc. table entry to speed things up. Previously, for each scrobble, we checked if its metadata appeared in the respective table, regardless of whether or not we've scrobbled that albumsong before. So, a song like Starless had to be checked (at the time of writing) ~180 times, but is now only checked once. Similarly, Wilco was checked ~3000+ times, as Hurry Up, We're Dreaming was cheked ~700 times.

The only problem now is the way it was implemented. Obviously, copying and pasting those huge chunks of code isn't very nice looking. ATM, I don't really care, and I'm more happy about the overall speed increase, as well as the readability increase of the job. However, I don't want to leave it like that. The way I see it, I have two options: either create a funcion which does this, or I can do something even better, which is create a jsonParse function, which, if my thought process works, would remove the need for an intermediary source type, meaning we no longer need to switch on that type, which means we can just have one for loop that does everything, which would mean we just need to have that code in one place.

Also not entirely happy with the code concerning all the conversions to i64s and []const u8s, but I think I have to.
2025-06-11 20:08:13 -04:00
df8f01525e Merge remote-tracking branch 'refs/remotes/origin/rawsql' into rawsql 2025-06-11 09:28:14 -04:00
2f420bc5ce Testing with groups and htmx 2025-06-11 09:26:12 -04:00
6a1c822420 Add entities_by_name query
Will probably be used for disambiguation pages (among other things, but disambiguation pages are coming up soon)
2025-06-11 09:25:40 -04:00
a8a4ed27c4 Make process_scrobbles vars more readable, and change hashing 2025-06-11 09:22:38 -04:00
1e4a271b8d Update README 2025-06-11 09:22:06 -04:00
85552f39c1 Add Artistsongs table
Whether or not a song is covered, there was an original artist who originally performed the song. The only issue is that an Artistsongs table will almost be the exact same as the Albumsongsartists table, since most songs aren't covered. So, it may be better not to populate that table by default, and then if two albumsongs with different artists share the same song, then fill the Artistsongs table.
2025-06-09 21:45:41 -04:00
162341fb5f Convert ids to i64
The birthday paradox is a real problem with the size of our datasets. i64 is the largest numerical value we can use, and there's a 0.1% chance of collision with ~2,000,000 values, so I feel pretty comfortable with this
2025-06-09 21:41:52 -04:00
c8f2ef57c8 Add some tyling to songs view
This can (will) be easily replicated for the other views, I just first tested it on songs. I think this looks much nicer, and I'll probably roll with a layout similar to this for the other views, with some minor adjustments for each particular view.
2025-06-06 15:55:20 -04:00
3ef17fcd46 Split entity_items and appears query into more granular queries
We can be a bit more specific about the information we get this way
2025-06-06 14:28:15 -04:00
adcaff34ea Fix dumb appears query for albums 2025-06-02 00:13:27 -04:00
566edf1818 Include artist(s) name in album GET view
This also makes the entity_info struct very similar to the UnifiedResult struct, so we'll probably see a merge at some point. Would be nice if we used the fields from the entity_info result more commonly.
2025-05-31 15:48:30 -04:00
906ba6d2e5 Update header partial and remoev table partial
Long overdue
2025-05-31 14:47:52 -04:00
3777b818e3 Create view for groups
One of the largest components that makes zuletzt unique - implementing groups the way MusicBrainz has (release groups in particular). I thought for a while that I would just connect songs via a shared ID, but for remixes and such, I don't think they should be so tightly coupled. This also gives the user freedom for how they want to do the grouping (a remix can be included in the group if they choose to, or it may not). This will allow someone to see a combined scrobble number for an album with, for example, a regular release, a deluxe release, and an anniversary release, in addition to the individual releases. This will complicate SQL queries rather significantly I imagine, and I'm not sure what the interface for creating/deleting groups will be (although it will likely be easier when I have full use of TS), but it's a necessity for the project.
2025-05-31 14:45:45 -04:00
a314fd447d Fix LastFM API scrobble parsing when song is currently playing 2025-05-31 14:31:15 -04:00
c57bf18627 Update queries
Adds datestreak query, provides the number of songs/albums when relevant, and provides timescale with all years, regardless of the number of plays (defaults to 0)
2025-05-31 13:39:03 -04:00
d81681e698 Move scrobble rank from firstlast partial to view.
Eventually moving this to its own partial (probably)
2025-05-31 13:37:34 -04:00
62590fee37 Made queries.zig look significantly nicer
There's a little bit of weird stuff happening, but holy cannoli, that's so much easier to maintain and parse
2025-05-29 19:39:51 -04:00
d638fa66c5 Create GET function for a song view 2025-05-29 15:33:10 -04:00
3ff973e193 Use queries.zig in scrobbles view 2025-05-26 11:15:51 -04:00
f59eec79a8 Removed inline else from upload.zig
If I can figure out a way to get an array of a union instead of a union of arrays, we're in business to make this even better, but this is fine right now. The inline else was just a dumb way to keep the for on the outside
2025-05-26 11:15:19 -04:00
aab61631a3 Directly append complete_scrobble in upload.zig
Thanks bob :)
2025-05-25 16:16:18 -04:00
7f3778e82f Move SQL logic to separate function
Idk if this makes any sense, and I don't really like the code atm, but the view .zig files lookk nicer?
2025-05-24 13:59:28 -04:00
09f542e26e Add timescale partial
Bad name, idk what else to call it
2025-05-24 13:58:31 -04:00
1734e6a4bb Fix date formatting in scrobble view 2025-05-20 16:29:53 -04:00
d6a638bf27 Merge remote-tracking branch 'refs/remotes/origin/rawsql' into rawsql 2025-05-20 15:10:49 -04:00
a2a739bc9c Refactor upload.zig
I have been unhappy with the branches, but didn't quite know what to do about it. THis feels much nicer. Also fixes datetime stuff with jetquery.  The HTML element parsing isn't quite where I want it to be, but it works for the time being.
2025-05-20 15:07:51 -04:00
6494bbdf60 Remove Rules type 2025-05-20 09:33:15 -04:00
4c759433d2 Update README.md 2025-05-16 05:06:59 +00:00
614607ae71 Fix LastFM uploadig error
I figured it out; if you have a song currently being played, then it doesn't have a date
2025-05-16 01:05:32 -04:00
5697f95355 Fix LastFM uploading errors
Not sure which of these actually made it work, will probably work backwards at some point to reverse engineer it
2025-05-15 20:23:53 -04:00
89e98c7a47 Allow uploads from LastFM API
Very slow at the moment. Look into ways to speed this up
2025-05-15 20:23:12 -04:00
f69ffb2b37 Move upload.zig to the new table partial 2025-05-15 20:22:34 -04:00
52fefc9ba5 Create dateCompare function
Will eventually try to move away from zeit. Don't need all of it's functionality as long as SQL can format dates
2025-05-15 20:22:09 -04:00
4991bac9a4 Add LastFM scrobble type
In preparation for importing via LastFM api
2025-05-15 15:39:21 -04:00
c42b8d24dd Fix typo 2025-05-15 15:37:21 -04:00
365b9dbf11 Switch to using newtable partial for all tables
Will be renamed eventually, don't care right now. Also cleans up a lot of code I wasn't particularly happy about
2025-05-13 14:24:14 -04:00
153ea869e0 Work on making partials for views 2025-05-08 18:17:45 -04:00
4758885c68 Keep cleaning 2025-05-05 13:09:16 -04:00
9ffc45b207 Delete common_queries.md
No longer relevant
2025-05-05 11:15:30 -04:00
94cc6e3bd5 Remove unused views and functions 2025-05-05 11:06:52 -04:00
c574885f8d Get rid of unused views 2025-05-05 10:37:18 -04:00
762a4fd51e Create partial for view agnostic table 2025-05-02 10:00:47 -04:00
3345b20f1f Make an ordinal formatting funcrion
I am hungry
2025-04-29 00:38:20 -04:00
78e416eeaf Add more information to scrobbles views and refactor artists view 2025-04-28 23:06:21 -04:00
8138e5ccf2 Create dateFmt function
It's easier to keep the date as an epoch in PostgreSQL to do comparisons, but I always want to show it to the user as a formatted date
2025-04-28 23:03:24 -04:00
ae85f94ddb Switch dates from u64 to i64
PostgreSQL only uses signed ints, so this makes things much easier
2025-04-28 23:01:34 -04:00
cb89a3e6f3 Switch dates from i128 to u64
I was making them unnecessarily large by accidentally storing them as microseconds instead of milliseconds. Might be able to get away with seconds in the future
2025-04-28 21:37:08 -04:00
65136a44d6 Add more information to artists view, songs view, and format dates correctly in scrobbles view 2025-04-27 23:58:50 -04:00
01fe10f045 Fix limit on rule parameters and fix segfault in applyScrobbleRule
For sure this time
2025-04-27 16:27:03 -04:00
18d4df0a5c Fix albums not being hashed correctly
Also provides more actions for rules, but they don't seem to work...
2025-04-27 15:48:47 -04:00
5e58e81ca7 Fix album artist parsing in process_scrobbles 2025-04-27 14:28:39 -04:00
9df8f9ea12 Fix segfault in applyScrobbleRule
Thanks bob :)
2025-04-27 10:41:42 -04:00
be8c1191b0 Clean 2025-04-24 09:34:34 -04:00
0631ded115 Work on add artist action in rules
Really close to having it work, but there seems to be an error when uploading files, which causes particularly annoying problems on WSL when testing, so I'm commiting and trying on my desktop.
2025-04-23 19:32:32 -04:00
e9c72041a5 Allow multiple conditions in rules.
Scrobble processing appears noticeably slower (according to the logs), so I think rules are going to be something to optimize later. Fortunately, they shouldn't need to be applied too often
2025-04-22 13:50:39 -04:00
77170a1e28 Move Scrobble rule application to upload.zig
They couldn't see the changes made by rules after uploading Scrobbles which made it seem like the rules weren't being applied. Also makes Album rules easier to apply I believe.
2025-04-21 16:41:40 -04:00
87a2fe2d34 Complete preliminary find and replace rules
Tested by replacing AJR with John Van Derwood. Need to test on albums and artists, as well as matching on one piece of metadata, and replacing another
2025-04-21 12:23:20 -04:00
445ca45fa9 Begin rule application
The more I think about this, the more I think it's gonna be super slow and bad. There must bve a good way of doing this, but I'm not sure how...
2025-04-21 00:17:16 -04:00
baf9ef38a4 Simplify file creation branch of process_rule.zig
Still not quite where I want it, but definitely better than what it was
2025-04-19 15:36:51 -04:00
5383b69eb6 Allow reading and writing rules.json
I like the idea of letting the user write to a file themselves for rules, but I think this is going to significantly slow things down. Will probably switch to SQL table at some point. Also very hardcoded for my purposes. ALSO  the code looks bad, I think there must be a better way...
2025-04-19 15:01:30 -04:00
18cdb48b53 Begin rules 2025-04-18 21:29:00 -04:00
ff8cdabbf1 Cleanup 2025-04-17 15:28:00 -04:00
387493d3c0 Change typedef of prev_artist_infos
Feeling much better about my choices this time around
2025-04-17 15:17:10 -04:00
4d63844def Make artist retrieval apart of main query
This feels bad or wrong somehow, but it do be working tho
2025-04-17 15:05:44 -04:00
2c4af0b378 Include artist column for albums
I'm convinced there's a better way of doing this, but this is all I can think of right now
2025-04-17 14:05:15 -04:00
41ab0dc888 Remove artists column from views
I kinda just didn't want to deal with it while implementing the raw sql. Bringing it back is my next priority, but I want to do the searching in a nice way, and I'm not sure how to do that yet
2025-04-17 00:26:56 -04:00
27358fe217 Implement db searches using raw sql 2025-04-17 00:24:48 -04:00
09d4453665 Fix various issues with process_scrobbles
I use the ins_ variables an unnecessary amount I think, I need to take a closer look at it, and give them better names
2025-04-17 00:24:16 -04:00
3f69183b6f Create new Schema from migrations 2025-04-17 00:23:12 -04:00
64038079d8 Update process_scrobbles.zig to fit new db 2025-04-07 15:44:52 -04:00
0537ef7db2 she QUERY on my DATA so she's BASEd 2025-04-07 10:44:28 -04:00
127 changed files with 2528 additions and 1773 deletions

3
.gitignore vendored
View file

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

133
README.md
View file

@ -1,28 +1,85 @@
# Zuletzt # Zuletzt
**Zuletzt** gives you the statistics of your music listening habits. **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
**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 [Jetzig](https://github.com/jetzig-framework/jetzig) as a means of learning the
language, reintroducing myself to programming, and combining language, reintroducing myself to programming, and combining the functionality
the functionality of the aforementioned inspirations. of the aforementioned inspirations.
Zuletzt means "last" in German. Zuletzt means "last" in German.
Licensed under MIT. 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: ## To-Do List:
- [ ] Entity statistics - [ ] Entity statistics
- [x] See all artists under "/artists" - [x] See all artists under "/artists"
- [ ] List all songs on artist page, with respective album - [ ] List all songs on artist page, with respective album
- [x] List all albums on artist page - [x] List all albums on artist page
- [x] Include number of plays for each - [x] Include number of plays for each
- [x] List albums features on
- [x] See all albums under "/albums" - [x] See all albums under "/albums"
- [x] See all songs from album - [x] See all songs from album
- [x] Include number of plays - [x] Include number of plays
- [x] Include name of artist(s)
- [ ] Include artists features on each song
- [x] See all songs under "/songs" - [x] See all songs under "/songs"
- [ ] Include respective artist(s) - [x] Include respective artist(s)
- [ ] Include respective album[^10] - [ ] Include respective album[^10]
- [x] Include number of plays - [x] Include number of plays
- [ ] Create disambiguation pages - [ ] Create disambiguation pages
@ -38,7 +95,7 @@ Licensed under MIT.
- [ ] Import from Discogs[^2] - [ ] Import from Discogs[^2]
- [ ] Import listening history - [ ] Import listening history
- [x] From Lastfmstats.com (.json file)[^3] - [x] From Lastfmstats.com (.json file)[^3]
- [ ] From Last.fm (authentication) - [x] From Last.fm (authentication)
- [x] From Spotify (.json file) - [x] From Spotify (.json file)
- [ ] From other streaming services[^4] - [ ] From other streaming services[^4]
- [ ] "Unofficial scrobbles"[^9] - [ ] "Unofficial scrobbles"[^9]
@ -49,7 +106,8 @@ Licensed under MIT.
- [ ] Genres - [ ] Genres
- [ ] Owned - [ ] Owned
- [ ] Holiday - [ ] Holiday
- [ ] [MusicBrainz integration](https://musicbrainz.org/doc/libmusicbrainz)[^11] - [ ] [MusicBrainz
integration](https://musicbrainz.org/doc/libmusicbrainz)[^11]
- [ ] Concerts - [ ] Concerts
- [ ] Import from Setlist.fm[^5] - [ ] Import from Setlist.fm[^5]
- [ ] Ratings - [ ] Ratings
@ -57,28 +115,63 @@ Licensed under MIT.
- [ ] Rank songs - [ ] Rank songs
- [ ] Custom statistics[^7] - [ ] Custom statistics[^7]
- [ ] "Playlists"[^8] - [ ] "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 [^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 ## 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,6 +12,8 @@ pub fn build(b: *std.Build) !void {
.optimize = optimize, .optimize = optimize,
}); });
exe.use_llvm = true;
// Example dependency: // Example dependency:
// //
const zig_time_dep = b.dependency("zeit", .{}); const zig_time_dep = b.dependency("zeit", .{});

View file

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

View file

@ -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;
```

View file

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

View file

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

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(
"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

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

View file

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

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(
"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

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

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", .{}),
t.column("album_id", .integer, .{}),
t.column("song_id", .integer, .{}),
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(
"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

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

View file

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

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

View file

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

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

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(
"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

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

View file

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

View file

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

View file

@ -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);
}
}
}

View file

@ -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);
}
}

34
src/app/jobs/add_song.zig Normal file
View file

@ -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);
}
}
}

View file

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

View file

@ -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 });
// }
//}
}

View file

@ -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);
// }
// }
//}
}

View file

@ -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);
}

View file

@ -1,155 +1,62 @@
const std = @import("std"); const std = @import("std");
const jetzig = @import("jetzig"); const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery; 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 { pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object); 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| { const albums = try queries.entityQueryResult(request, queries.loadQuery(.album, .entities), .{});
var album_view = try albums_view.append(.object); 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); return request.render(.ok);
} }
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { 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); 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); const id_int = blk: {
for (songs) |song| { const rn = try decode(request.allocator, id);
const scrobbles = try jetzig.database.Query(.Scrobble) // Try to find the song by name
.where(.{ .song_id = song.song.id }) const queried_albums = try jetzig.database.Query(.Album).select(.{.id}).where(.{ .name = rn }).all(request.repo);
.count() if (queried_albums.len == 0) {
.execute(request.repo); // Either we've been given an id in the db, or the song doesn't exist
var song_view = try songs_view.append(.object); break :blk std.fmt.parseInt(i64, id, 10) catch return request.fail(.not_found);
try song_view.put("name", song.song.name); } else if (queried_albums.len == 1) {
try song_view.put("url", song.song.id); // It can only be one song
try song_view.put("scrobbles", scrobbles); 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); 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,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};
}
<html> <html>
<head> <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"> <meta charset="UTF-8">
</head> </head>
<body> <body>
@partial partials/header @partial partials/header
<h1>{{.album}}</h1> @if ($.disambiguation)
<table> <h1>{{.name}} (disambiguation)</h1>
<tr> @partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: dis_columns)
<th>Name</th> @else
@for (.songs) |song| {
<tr> @zig {
<td class=cell><a href="/songs/{{song.url}}">{{song.name}}</a></td> const reviews = try zmpl.coerceArray(".reviews");
<td class=cell>{{song.scrobbles}}</td>
</tr>
} }
</table> <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
</body> </body>
</html> </html>

View file

@ -1,40 +1,15 @@
@zig {
const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
const columns: ColumnChoices = &.{.album, .artistlist, .scrobbles};
}
<html> <html>
<head> <head>
<link href="https://cdn.jsdelivr.net/npm/simple-datatables@latest/dist/style.css" rel="stylesheet" type="text/css">
<meta charset="UTF-8"> <meta charset="UTF-8">
</head> </head>
<body> <body>
@partial partials/header @partial partials/header
<h1>Albums</h1> <h1>Albums</h1>
<table id="myTable"> @partial partials/newtable(T: ColumnChoices, table_data: .albums, columns: columns)
<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> </body>
</html> </html>

View file

@ -1,141 +1,61 @@
const std = @import("std"); const std = @import("std");
const jetzig = @import("jetzig"); const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery; 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 { pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object); var root = try request.data(.object);
var artists_view = try root.put("artists", .array); const artists = try queries.entityQueryResult(request, queries.loadQuery(.artist, .entities), .{});
const artists = try jetzig.database.Query(.Artist)
.select(.{ .id, .name }) try root.put("artists", artists);
.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); return request.render(.ok);
} }
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { 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); 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); const id_int = blk: {
for (albums) |album| { const rn = try decode(request.allocator, id);
const scrobbles = try jetzig.database.Query(.Scrobble) // Try to find the song by name
.where(.{ .album_id = album.album.id }) const queried_artists = try jetzig.database.Query(.Artist).select(.{.id}).where(.{ .name = rn }).all(request.repo);
.count() if (queried_artists.len == 0) {
.execute(request.repo); // Either we've been given an id in the db, or the song doesn't exist
var album_view = try albums_view.append(.object); break :blk std.fmt.parseInt(i64, id, 10) catch return request.fail(.not_found);
try album_view.put("name", album.album.name); } else if (queried_artists.len == 1) {
try album_view.put("url", album.album.id); // It can only be one song
try album_view.put("scrobbles", scrobbles); 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); 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

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

View file

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

View file

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

View file

@ -1,34 +1,15 @@
@zig {
const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
const columns: ColumnChoices = &.{.artist, .scrobbles};
}
<html> <html>
<head> <head>
<link href="https://cdn.jsdelivr.net/npm/simple-datatables@latest/dist/style.css" rel="stylesheet" type="text/css">
<meta charset="UTF-8"> <meta charset="UTF-8">
</head> </head>
<body> <body>
@partial partials/header @partial partials/header
<h1>Artists</h1> <h1>Artists</h1>
<table id="myTable" class='table'> @partial partials/newtable(T: ColumnChoices, table_data: .artists, columns: columns)
<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> </body>
</html> </html>

View file

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

View file

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

View file

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

View file

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

View file

@ -11,26 +11,3 @@ pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig
_ = id; _ = id;
return request.render(.ok); 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

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

View file

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

View file

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

View file

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

View file

@ -11,26 +11,3 @@ pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig
_ = id; _ = id;
return request.render(.ok); 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

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
<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")); var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit(); defer app.deinit();
const response = try app.request(.GET, "/search", .{}); const response = try app.request(.GET, "/groups", .{});
try response.expectStatus(.ok); try response.expectStatus(.ok);
} }
@ -51,7 +51,7 @@ test "get" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit(); 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); try response.expectStatus(.ok);
} }
@ -59,7 +59,7 @@ test "new" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit(); defer app.deinit();
const response = try app.request(.GET, "/search/new", .{}); const response = try app.request(.GET, "/groups/new", .{});
try response.expectStatus(.ok); try response.expectStatus(.ok);
} }
@ -67,7 +67,7 @@ test "edit" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit(); 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); try response.expectStatus(.ok);
} }
@ -75,7 +75,7 @@ test "post" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit(); defer app.deinit();
const response = try app.request(.POST, "/search", .{}); const response = try app.request(.POST, "/groups", .{});
try response.expectStatus(.created); try response.expectStatus(.created);
} }
@ -83,7 +83,7 @@ test "put" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit(); 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); try response.expectStatus(.ok);
} }
@ -91,7 +91,7 @@ test "patch" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit(); 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); try response.expectStatus(.ok);
} }
@ -99,6 +99,6 @@ test "delete" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit(); 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); try response.expectStatus(.ok);
} }

View file

@ -0,0 +1,11 @@
<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,21 +16,3 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data; _ = data;
return request.render(.created); 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,3 +0,0 @@
<div>
<span>Content goes here</span>
</div>

View file

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

View file

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

View file

@ -0,0 +1,11 @@
@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,7 +1,11 @@
<a class="header-link" href="/">Zuletzt</a> <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="/scrobbles">Scrobbles</a>
<a class="header-link" href="/concerts">Concerts</a> <a class="header-link" href="/concerts">Concerts</a>
<a class="header-link" href="/collection">Collection</a> <a class="header-link" href="/collection">Collection</a>
<a class="header-link" href="/ratings">Ratings</a> <a class="header-link" href="/ratings">Ratings</a>
<a class="header-link" href="/lists">Lists</a> <a class="header-link" href="/lists">Lists</a>
<a class="header-link" href="/groups">Groups</a>
<hr> <hr>

View file

@ -0,0 +1,75 @@
@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

@ -1,18 +0,0 @@
@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

@ -0,0 +1,20 @@
@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,21 +16,3 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data; _ = data;
return request.render(.created); 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,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);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
}

View file

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

View file

@ -4,101 +4,38 @@ const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request) !jetzig.View { pub fn index(request: *jetzig.Request) !jetzig.View {
return request.render(.ok); 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 { 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); 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

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

View file

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

View file

@ -1,3 +1,62 @@
<div> <html>
<span>Content goes here</span> <head>
</div> <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>

View file

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

View file

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

View file

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

View file

@ -1,64 +1,12 @@
const std = @import("std"); const std = @import("std");
const jetzig = @import("jetzig"); const jetzig = @import("jetzig");
const queries = @import("../../queries.zig");
pub fn index(request: *jetzig.Request) !jetzig.View { pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object); 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); const scrobbles = try queries.entityQueryResult(request, queries.loadQuery(.scrobble, .entities), .{});
for (scrobble.scrobbleartists) |artist| { try root.put("scrobbles", scrobbles);
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); return request.render(.ok);
} }

View file

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

View file

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

View file

@ -1,42 +1,15 @@
@zig {
const ColumnChoices = []const enum{song, album, artist, artistlist, scrobbles, date};
const columns: ColumnChoices = &.{.song, .artistlist, .album, .date};
}
<html> <html>
<head> <head>
<link href="https://cdn.jsdelivr.net/npm/simple-datatables@latest/dist/style.css" rel="stylesheet" type="text/css">
<meta charset="UTF-8"> <meta charset="UTF-8">
</head> </head>
<body> <body>
@partial partials/header @partial partials/header
<h1>Scrobbles</h1> <h1>Scrobbles</h1>
<table id="myTable"> @partial partials/newtable(T: ColumnChoices, table_data: .scrobbles, columns: columns)
<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> </body>
</html> </html>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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