Merge branch 'testing'

It was poorly named, and frankly unnecessary
This commit is contained in:
mitteneer 2025-02-12 10:36:31 -05:00
commit 8f8043576f
71 changed files with 1133 additions and 48 deletions

3
.gitignore vendored
View file

@ -3,3 +3,6 @@ zig-out/
*.core
static/
.jetzig
src/app/database/data.db-journal
src/app/database/old_migrations/
src/lib/

View file

@ -10,4 +10,47 @@ the functionality of the aforementioned inspirations.
Zuletzt means "last" in German.
Licensed under MIT.
Licensed under MIT.
## To-Do List:
- [ ] Last.fm statistics
- [ ] Lastfmstats.com statistics[^1]
- [ ] Collections
- [ ] Import from Discogs[^2]
- [ ] Import listening history
- [ ] From Lastfmstats.com (.json file)[^3]
- [ ] From Last.fm (authentication)
- [ ] From Spotify (.json file)
- [ ] From other streaming services[^4]
- [ ] Import rules
- [ ] Simple find/replace
- [ ] User-defined regex
- [ ] Tags
- [ ] Genres
- [ ] MusicBrainz integration
- [ ] Concerts
- [ ] Import from Setlist.fm[^5]
- [ ] Ratings
- [ ] RYM integration[^6]
- [ ] Rank songs
- [ ] Custom statistics[^7]
- [ ] "Playlists"[^8]
[^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.
[^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.
[^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.
[^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.
## 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.

View file

@ -16,7 +16,7 @@ pub fn build(b: *std.Build) !void {
// All dependencies **must** be added to imports above this line.
try jetzig.jetzigInit(b, exe, .{});
try jetzig.jetzigInit(b, exe, .{ .zmpl_version = .v2 });
b.installArtifact(exe);

View file

@ -16,12 +16,8 @@
// internet connectivity.
.dependencies = .{
.jetzig = .{
.url = "https://github.com/jetzig-framework/jetzig/archive/dda433bb73000614482af10a277d47dc9d89600c.tar.gz",
.hash = "12202ce84b803a8b300c91d98afbc7c326298b55a23bf05cf603182e934b621008ec",
},
.iguanas = .{
.url = "https://github.com/jetzig-framework/iguanas/archive/89c2abf29de0bc31054a9a6feac5a6a83bab0459.tar.gz",
.hash = "12202fd319a5ab4e124b00e8ddea474d07c19c4e005d77b6c29fc44860904ea01a5c",
.url = "https://github.com/jetzig-framework/jetzig/archive/475ed269525624a67004594ddca44dc8ebea1919.tar.gz",
.hash = "1220bc060ba2320fa9fed8e554a8b692a93ef73fa3ab40617b9ed1d928d2029297fb",
},
},
.paths = .{

31
config/database.zig Normal file
View file

@ -0,0 +1,31 @@
pub const database = .{
.testing = .{
.adapter = .postgresql,
.hostname = "localhost",
.port = 5432,
.username = "postgres",
.password = "postgres",
.database = "zuletzt_testing",
.pool_size = 16,
},
.development = .{
.adapter = .postgresql,
.hostname = "localhost",
.port = 5432,
.username = "postgres",
.password = "postgres",
.database = "zuletzt_dev",
.pool_size = 16,
},
.production = .{
.adapter = .postgresql,
.hostname = "localhost",
.port = 5432,
.username = "postgres",
.password = "postgres",
.database = "zuletzt",
.pool_size = 16,
},
};

View file

@ -3,8 +3,28 @@
* <link rel="stylesheet" href="/styles.css" />
*
*/
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');
.message {
font-weight: bold;
.title {
font-family: 'Roboto';
font-size: 3rem;
font-weight: 500;
margin-left: 20px;
margin-right: 40px;
margin-top: 20px;
}
.header-link{
font-family: 'Roboto';
font-size: 1.5rem;
margin-right: 20px;
}
.cell {
font-family: 'Noto Sans'
}
#replaceMe{
font-family:'Courier New';
}

190
src/app/database/Schema.zig Normal file
View file

@ -0,0 +1,190 @@
const jetquery = @import("jetzig").jetquery;
pub const AlbumSong = jetquery.Model(
@This(),
"album_songs",
struct {
id: i32,
album_id: i32,
song_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{},
);
pub const Album = jetquery.Model(
@This(),
"albums",
struct {
id: i32,
title: []const u8,
song_num: i32,
length: f32,
play_count: i32,
holiday: bool,
compilation: bool,
deluxe: bool,
live: bool,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.scrobbles = jetquery.hasMany(.Scrobble, .{}),
.ratings = jetquery.hasMany(.Ratings, .{}),
.aliases = jetquery.hasMany(.Aliases, .{}),
.songs = jetquery.hasMany(.AlbumSongs, .{}),
.artists = jetquery.hasMany(.ArtistAlbums, .{}),
},
},
);
pub const ArtistAlbum = jetquery.Model(
@This(),
"artist_albums",
struct {
id: i32,
artist_id: i32,
album_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{},
);
pub const ArtistSong = jetquery.Model(
@This(),
"artist_songs",
struct {
id: i32,
artist_id: i32,
song_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{},
);
pub const Artist = jetquery.Model(
@This(),
"artists",
struct {
id: i32,
name: []const u8,
album_num: i32,
song_num: i32,
play_count: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.scrobbles = jetquery.hasMany(.Scrobble, .{}),
.aliases = jetquery.hasMany(.Aliases, .{}),
.concerts = jetquery.hasMany(.Concerts, .{}),
.songs = jetquery.hasMany(.ArtistSongs, .{}),
.albums = jetquery.hasMany(.ArtistAlbums, .{}),
},
},
);
pub const Scrobble = jetquery.Model(
@This(),
"scrobbles",
struct {
id: i32,
date: jetquery.DateTime,
song_id: i32,
album_id: ?i32,
artist_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.song = jetquery.belongsTo(.Song, .{}),
.album = jetquery.belongsTo(.Album, .{}),
.artist = jetquery.belongsTo(.Artist, .{}),
},
},
);
pub const Song = jetquery.Model(
@This(),
"songs",
struct {
id: i32,
title: []const u8,
length: f32,
hidden: bool,
holiday: bool,
play_count: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.scrobbles = jetquery.hasMany(.Scrobble, .{}),
.ratings = jetquery.hasMany(.Ratings, .{}),
.aliases = jetquery.hasMany(.Aliases, .{}),
.artists = jetquery.hasMany(.ArtistSongs, .{}),
.albums = jetquery.hasMany(.AlbumSongs, .{}),
},
},
);
pub const Alias = jetquery.Model(
@This(),
"aliases",
struct {
id: i32,
reference_id: i32,
alias: []const u8,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{},
);
pub const Concert = jetquery.Model(
@This(),
"concerts",
struct {
id: i32,
location: []const u8,
date: jetquery.DateTime,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{},
);
pub const Rating = jetquery.Model(
@This(),
"ratings",
struct {
id: i32,
reference_id: i32,
score: f32,
date: jetquery.DateTime,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{},
);
pub const RawScrobble = jetquery.Model(
@This(),
"raw_scrobbles",
struct {
id: i32,
track: []const u8,
artist: []const u8,
album: []const u8,
date: jetquery.DateTime,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{},
);

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(
"artists",
&.{
t.primaryKey("id", .{}),
t.column("name", .string, .{}),
t.column("album_num", .integer, .{}),
t.column("song_num", .integer, .{}),
t.column("play_count", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("artists", .{});
}

View file

@ -0,0 +1,23 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"songs",
&.{
t.primaryKey("id", .{}),
t.column("title", .string, .{}),
t.column("length", .float, .{}),
t.column("hidden", .boolean, .{}),
t.column("holiday", .boolean, .{}),
t.column("play_count", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("songs", .{});
}

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(
"artist_songs",
&.{
t.primaryKey("id", .{}),
t.column("artist_id", .integer, .{}),
t.column("song_id", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("artist_songs", .{});
}

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(
"artist_albums",
&.{
t.primaryKey("id", .{}),
t.column("artist_id", .integer, .{}),
t.column("album_id", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("artist_albums", .{});
}

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(
"album_songs",
&.{
t.primaryKey("id", .{}),
t.column("album_id", .integer, .{}),
t.column("song_id", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("album_songs", .{});
}

View file

@ -0,0 +1,26 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"albums",
&.{
t.primaryKey("id", .{}),
t.column("title", .string, .{}),
t.column("song_num", .integer, .{}),
t.column("length", .float, .{}),
t.column("play_count", .integer, .{}),
t.column("holiday", .boolean, .{}),
t.column("compilation", .boolean, .{}),
t.column("deluxe", .boolean, .{}),
t.column("live", .boolean, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("albums", .{});
}

View file

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

View file

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

View file

@ -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(
"concerts",
&.{
t.primaryKey("id", .{}),
t.column("location", .string, .{}),
t.column("date", .datetime, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("concerts", .{});
}

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(
"raw_scrobbles",
&.{
t.primaryKey("id", .{}),
t.column("track", .string, .{}),
t.column("artist", .string, .{}),
t.column("album", .string, .{}),
t.column("date", .datetime, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("raw_scrobbles", .{});
}

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(
"scrobbles",
&.{
t.primaryKey("id", .{}),
t.column("date", .datetime, .{}),
t.column("song_id", .integer, .{}),
t.column("album_id", .integer, .{ .optional = true }),
t.column("artist_id", .integer, .{}),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("scrobbles", .{});
}

View file

@ -0,0 +1,86 @@
const std = @import("std");
const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery;
//const time = @cImport({
// @cInclude("time.h");
//});
// 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;
_ = params;
//const memory = try allocator.alloc(u8, 19);
// Get all scrobbles from the RawScrobbles table
const query = jetzig.database.Query(.RawScrobble).select(.{});
const scrobbles = try env.repo.all(query);
defer env.repo.free(scrobbles);
for (scrobbles) |scrobble| {
//const date = [19]u8{};
//time.strftime{ date, 19, "%Y-%m-%d %H:%M:%D", scrobbles.date };
//time.strftime(memory, 19, "%Y-%m-%d %H:%M:%S", scrobbles.date);
//std.debug.print("{s}", .{memory});
// Make hashes
const album_hash = std.hash.Fnv1a_64.hash(scrobble.album);
const artist_hash = std.hash.Fnv1a_64.hash(scrobble.artist);
const song_hash = std.hash.Fnv1a_64.hash(scrobble.track);
var album_id: u64 = 0;
const song_id = (song_hash ^ artist_hash ^ album_hash) % 99999989;
if (artist_hash == album_hash) {
album_id = album_hash % 99999989;
} else {
album_id = (artist_hash ^ album_hash) % 99999989;
}
const artist_id = artist_hash % 99999989;
// ID start - I think we can use SERIAL for this
// We don't compare intermediate IDs to anything,
// so keeping it a SERIAL is probably fine
const artistalbum_offset = try jetzig.database.Query(.ArtistAlbum).select(.{}).count().execute(env.repo) orelse unreachable;
const albumsong_offset = try jetzig.database.Query(.AlbumSong).select(.{}).count().execute(env.repo) orelse unreachable;
const artistsong_offset = try jetzig.database.Query(.ArtistSong).select(.{}).count().execute(env.repo) orelse unreachable;
// Inserts
const artistalbum_insert = jetzig.database.Query(.ArtistAlbum).insert(.{ .id = 1 + artistalbum_offset, .artist_id = artist_id, .album_id = album_id });
const albumsong_insert = jetzig.database.Query(.AlbumSong).insert(.{ .id = 1 + albumsong_offset, .song_id = song_id, .album_id = album_id });
const artistsong_insert = jetzig.database.Query(.ArtistSong).insert(.{ .id = 1 + artistsong_offset, .artist_id = artist_id, .song_id = song_id });
const album_insert = jetzig.database.Query(.Album).insert(.{ .id = album_id, .title = scrobble.album, .song_num = 0, .length = 0.0, .play_count = 0, .holiday = false, .compilation = false, .deluxe = false, .live = false });
const artist_insert = jetzig.database.Query(.Artist).insert(.{ .id = artist_id, .name = scrobble.artist, .album_num = 0, .song_num = 0, .play_count = 0 });
const song_insert = jetzig.database.Query(.Song).insert(.{ .id = song_id, .title = scrobble.track, .length = 0.0, .hidden = false, .holiday = false, .play_count = 0 });
// Checks
const artistalbum_check = try jetzig.database.Query(.ArtistAlbum).where(.{ .{ .artist_id = artist_id }, .AND, .{ .album_id = album_id } }).count().execute(env.repo);
const albumsong_check = try jetzig.database.Query(.AlbumSong).where(.{ .{ .album_id = album_id }, .AND, .{ .song_id = song_id } }).count().execute(env.repo);
const artistsong_check = try jetzig.database.Query(.ArtistSong).where(.{ .{ .artist_id = artist_id }, .AND, .{ .song_id = song_id } }).count().execute(env.repo);
const album_check = try jetzig.database.Query(.Album).where(.{.{ .id = album_id }}).count().execute(env.repo);
const artist_check = try jetzig.database.Query(.Artist).where(.{.{ .id = artist_id }}).count().execute(env.repo);
const song_check = try jetzig.database.Query(.Song).where(.{.{ .id = song_id }}).count().execute(env.repo);
// Insert into Intermediate Tables
if (artistalbum_check == 0) try env.repo.execute(artistalbum_insert);
if (albumsong_check == 0) try env.repo.execute(albumsong_insert);
if (artistsong_check == 0) try env.repo.execute(artistsong_insert);
if (album_check == 0) try env.repo.execute(album_insert);
if (artist_check == 0) try env.repo.execute(artist_insert);
if (song_check == 0) try env.repo.execute(song_insert);
const scrobble_offset = try jetzig.database.Query(.Scrobble).select(.{}).count().execute(env.repo) orelse unreachable;
try jetzig.database.Query(.Scrobble).insert(.{ .id = scrobble_offset + 1, .song_id = song_id, .album_id = album_id, .artist_id = artist_id, .date = scrobble.date }).execute(env.repo);
}
// Clear RawScrobbles when done processing
try jetzig.database.Query(.RawScrobble).deleteAll().execute(env.repo);
}

View file

@ -0,0 +1,36 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
<html>
<body>
@partial partials/header
<div>
<span>Content goes here</span>
</div>
</body>
</html>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,36 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
<html>
<body>
@partial partials/header
<div>
<span>Content goes here</span>
</div>
</body>
</html>

View file

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

View file

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

View file

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

36
src/app/views/lists.zig Normal file
View file

@ -0,0 +1,36 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
<html>
<body>
@partial partials/header
<div>
<span>Content goes here</span>
</div>
</body>
</html>

View file

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

View file

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

View file

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

View file

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

View file

View file

View file

View file

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

View file

@ -0,0 +1,26 @@
<div>
<fieldset>
Top:
<input type="radio" label="Artist" name="q" value="artist" hx-get="/stats" hx-target="#update" hx-include="[name='t']" hx-swap="outerHTML" hx-trigger="click" checked>Artist</input>
<input type="radio" label="Album" name="q" value="album" hx-get="/stats" hx-target="#update" hx-include="[name='t']" hx-swap="outerHTML" hx-trigger="click">Album</input>
<input type="radio" label="Track" name="q" value="track" hx-get="/stats" hx-target="#update" hx-include="[name='t']" hx-swap="outerHTML" hx-trigger="click">Track</input>
</fieldset>
<fieldset>
of:
<input type="radio" label="Day" name="t" value="day" hx-get="/stats" hx-target="#update" hx-include="[name='q']" hx-swap="outerHTML" hx-trigger="click" checked>Day</input>
<input type="radio" label="Week" name="t" value="week" hx-get="/stats" hx-target="#update" hx-include="[name='q']" hx-swap="outerHTML" hx-trigger="click">Week</input>
<input type="radio" label="Month" name="t" value="month" hx-get="/stats" hx-target="#update" hx-include="[name='q']" hx-swap="outerHTML" hx-trigger="click">Month</input>
<input type="radio" label="3 Months" name="t" value="quarter" hx-get="/stats" hx-target="#update" hx-include="[name='q']" hx-swap="outerHTML" hx-trigger="click">3 Months</input>
<input type="radio" label="6 Months" name="t" value="half" hx-get="/stats" hx-target="#update" hx-include="[name='q']" hx-swap="outerHTML" hx-trigger="click">6 Months</input>
<input type="radio" label="Current Year" name="t" value="begin" hx-get="/stats" hx-target="#update" hx-include="[name='q']" hx-swap="outerHTML" hx-trigger="click">Current Year</input>
<input type="radio" label="365 Days" name="t" value="year" hx-get="/stats" hx-target="#update" hx-include="[name='q']" hx-swap="outerHTML" hx-trigger="click">365 days</input>
<input type="radio" label="All Time" name="t" value="all" hx-get="/stats" hx-target="#update" hx-include="[name='q']" hx-swap="outerHTML" hx-trigger="click">All Time</input>
</fieldset>
<tr>
<td colspan="3">
<b id="update">hyello</b>
</td>
</tr>
</div>

36
src/app/views/ratings.zig Normal file
View file

@ -0,0 +1,36 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
<html>
<body>
@partial partials/header
<div>
<span>Content goes here</span>
</div>
</body>
</html>

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
const std = @import("std");
const jetzig = @import("jetzig");
/// `src/app/views/root.zig` represents the root URL `/`
@ -18,7 +19,7 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
var root = try data.object();
// Add a string to the root object.
try root.put("message", data.string("Welcome to Jetzig!"));
try root.put("welcome_message", data.string("Welcome to Jetzig!"));
// Request params have the same type as a `data.object()` so they can be inserted them
// directly into the response data. Fetch `http://localhost:8080/?message=hello` to set the
@ -26,9 +27,7 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
// present.
const params = try request.params();
if (params.get("message")) |value| {
try root.put("message_param", value);
}
try root.put("message_param", params.get("message"));
// Set arbitrary response headers as required. `content-type` is automatically assigned for
// HTML, JSON responses.

View file

@ -1,18 +0,0 @@
// Renders the `message` response data value.
<h3 class="message text-[#39b54a]">{.message}</h3>
<div><img class="p-3 mx-auto" src="/jetzig.png" /></div>
<div>
<a href="https://github.com/jetzig-framework/zmpl">
<img class="p-3 m-3 mx-auto" src="/zmpl.png" />
</a>
</div>
<div>Visit <a class="font-bold text-[#39b54a]" href="https://jetzig.dev/">jetzig.dev</a> to get started.
<div>Join our Discord server and introduce yourself:</div>
<div>
<a class="font-bold text-[#39b54a]" href="https://discord.gg/eufqssz7X6">https://discord.gg/eufqssz7X6</a>
</div>
</div>

View file

@ -4,17 +4,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="text-center pt-10 m-auto">
// If present, renders the `message_param` response data value, add `?message=hello` to the
// URL to see the output:
<h2 class="param text-3xl text-[#f7931e]">{.message_param}</h2>
// Renders `src/app/views/root/_content.zmpl` with the same template data available:
<div>{^root/content}</div>
</div>
@partial partials/header
@partial partials/top
</body>
</html>

View file

@ -0,0 +1,36 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
<html>
<body>
@partial partials/header
<div>
<span>Content goes here</span>
</div>
</body>
</html>

View file

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

View file

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

View file

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

80
src/app/views/upload.zig Normal file
View file

@ -0,0 +1,80 @@
const std = @import("std");
const jetzig = @import("jetzig");
const jetquery = @import("jetzig").jetquery;
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request) !jetzig.View {
const Scrobble = struct {
track: []u8,
artist: []u8,
album: []u8,
date: u64,
};
const lastfm = struct {
username: []u8,
scrobbles: []Scrobble,
};
var root = try request.data(.object);
var job = try request.job("process_scrobbles");
var counter: u16 = 0;
if (try request.file("upload")) |file| {
const parsed = try std.json.parseFromSlice(lastfm, request.allocator, file.content, .{});
const history = parsed.value;
var scrobbles = try root.put("scrobbles", .array);
for (history.scrobbles) |scrobble| {
try scrobbles.append(scrobble);
//const song_hash: u64 = std.hash.Fnv1a_64.hash(scrobble.track) % 99999989;
//job.params.put(scrobble.song, song_hash);
//std.debug.print("{d}\n", .{song_hash});
const database_update = jetzig.database.Query(.RawScrobble)
.insert(.{ .id = counter, .track = scrobble.track, .album = scrobble.album, .artist = scrobble.artist, .date = (scrobble.date * 1000) });
try request.repo.execute(database_update);
counter += 1;
}
}
try job.schedule();
var upload_table = try root.put("upload_table", .array);
try upload_table.append("Track");
try upload_table.append("Artist");
try upload_table.append("Album");
try upload_table.append("Date");
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}

View file

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

View file

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

View file

@ -0,0 +1,22 @@
<html>
<head>
<link rel="stylesheet" href="styles.css">
</head>
<body>
@partial partials/header
<div>
<span>Upload Last.fm or Spotify history file here (in json format).</span>
</div>
<form action="/upload" enctype="multipart/form-data" method="POST">
<label>Filename</label>
<input type="text" name="description" />
<label>File</label>
<input type="file" name="upload" />
<input type="submit" value="Submit" />
<fieldset>
<input type="radio" name="t" label="Last.fm">Last.fm</input>
<input type="radio" name="t" label="Spotify">Spotify</input>
</fieldset>
</form>
</body>
</html>

View file

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

View file

@ -0,0 +1,15 @@
<html>
<head>
<link rel="stylesheet" href="styles.css">
<meta charset="UTF-8">
</head>
<body>
@partial partials/header
<h1>File Uploaded Successfully</h1>
<h2>Scrobbles Added</h2>
@partial partials/table(table_data: .scrobbles, table_headers: .upload_table, table_context: .context)
</body>
</html>

View file

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

View file

@ -1,11 +1,13 @@
const std = @import("std");
pub const jetzig = @import("jetzig");
const jetzig = @import("jetzig");
pub const routes = @import("routes");
const zmd = @import("zmd");
const builtin = @import("builtin");
pub const static = @import("static");
// Override default settings in `jetzig.config` here:
pub const jetzig_options = struct {
pub const Schema = @import("Schema");
/// Middleware chain. Add any custom middleware here, or use middleware provided in
/// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`).
pub const middleware: []const type = &.{
@ -14,11 +16,10 @@ pub const jetzig_options = struct {
jetzig.middleware.HtmxMiddleware,
// Demo middleware included with new projects. Remove once you are familiar with Jetzig's
// middleware system.
@import("app/middleware/DemoMiddleware.zig"),
};
// Maximum bytes to allow in request body.
// pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 16);
pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 24);
// Maximum filesize for `public/` content.
// pub const max_bytes_public_content: usize = std.math.pow(usize, 2, 20);
@ -70,13 +71,13 @@ pub const jetzig_options = struct {
"</ul>",
};
pub fn block(allocator: std.mem.Allocator, node: jetzig.zmd.Node) ![]const u8 {
pub fn block(allocator: std.mem.Allocator, node: zmd.Node) ![]const u8 {
return try std.fmt.allocPrint(allocator,
\\<pre class="w-1/2 font-mono mt-4 ms-3 bg-gray-900 p-2 text-white"><code class="language-{?s}">{s}</code></pre>
, .{ node.meta, node.content });
}
pub fn link(allocator: std.mem.Allocator, node: jetzig.zmd.Node) ![]const u8 {
pub fn link(allocator: std.mem.Allocator, node: zmd.Node) ![]const u8 {
return try std.fmt.allocPrint(allocator,
\\<a class="underline decoration-sky-500" href="{0s}" title={1s}>{1s}</a>
, .{ node.href.?, node.title.? });
@ -85,6 +86,36 @@ pub const jetzig_options = struct {
};
pub fn main() !void {
//var db = try sqlite.Db.init(.{
// .mode = sqlite.Db.Mode{ .File = "/home/swebb/Source/zuletzt/src/app/database/data.db" },
// .open_flags = .{
// .write = true,
// .create = true,
// },
// .threading_mode = .MultiThread,
//});
//const create =
// \\CREATE TABLE artists ('artist', 'plays')
//;
//const query =
// \\INSERT INTO artists ('artist', 'plays') VALUES (?,?)
//;
//var build = try db.prepare(create);
//defer build.deinit();
//try build.exec(.{},.{});
//var stmt = try db.prepare(query);
//defer stmt.deinit();
//try stmt.exec(.{}, .{
// .artist = "Wilco",
// .plays = 2500,
//});
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
const allocator = gpa.allocator();