Compare commits

...

10 Commits

Author SHA1 Message Date
f42985733f Add some varied default song covers 2023-09-24 01:33:55 +02:00
c6cc83018f Dockerfile from scratch 2023-09-24 01:32:46 +02:00
89cb0c475b 1.0.0 2023-09-23 23:51:07 +02:00
95dbb74b47 Add backend 2023-09-23 18:23:46 +02:00
a894717e52 Update dependencies 2023-09-23 16:45:16 +02:00
1092860b42 Add support for custom sublists 2023-09-23 15:44:36 +02:00
ec5daba300 Fix typo 2022-04-19 15:35:37 +02:00
3800b448e1 0.2.2 2022-04-19 15:15:00 +02:00
4a6bb957a1 Update dockerfile & add .dockerignore 2022-04-19 15:14:40 +02:00
666f87a5f3 Improve searching and search hints 2022-04-19 15:10:24 +02:00
72 changed files with 7334 additions and 456 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
Dockerfile
target

5
.gitignore vendored
View File

@ -1,2 +1,3 @@
/target
/dist
target
dist
.env

1855
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,3 @@
[package]
name = "singit2"
version = "0.2.1"
authors = ["Joakim Hulthe <joakim@hulthe.net"]
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
seed = "0.8.0"
#wasm-bindgen = "0.2.70"
serde = { version = "1", features = ['derive'] }
serde_json = "1"
anyhow = "*"
rand = "0.8.4"
[dependencies.css_typegen]
git = "https://github.com/hulthe/css_typegen.git"
branch = "master"
[workspace]
members = ["backend", "frontend"]
resolver = "2"

View File

@ -1,11 +1,12 @@
##################
### BASE STAGE ###
##################
FROM rust:1.57 as base
FROM rust:1.72.1 as base
# Install build dependencies
RUN cargo install --locked trunk strip_cargo_version
RUN rustup target add wasm32-unknown-unknown
RUN rustup target add x86_64-unknown-linux-musl
WORKDIR /app
RUN mkdir frontend backend common
@ -16,6 +17,9 @@ RUN mkdir frontend backend common
FROM base AS strip-version
COPY Cargo.lock Cargo.toml ./
COPY frontend/Cargo.toml ./frontend/
COPY backend/Cargo.toml ./backend/
#COPY common/Cargo.toml ./common/
RUN strip_cargo_version
###################
@ -23,18 +27,50 @@ RUN strip_cargo_version
###################
FROM base AS build
RUN cargo init --lib .
RUN cargo init --lib frontend
RUN cargo init --bin backend
RUN cargo init --lib common
COPY --from=strip-version /app/frontend/Cargo.toml /app/frontend/
COPY --from=strip-version /app/backend/Cargo.toml /app/backend/
#COPY --from=strip-version /app/common/Cargo.toml /app/common/
COPY --from=strip-version /app/Cargo.toml /app/Cargo.lock /app/
WORKDIR /app/backend
RUN cargo build --release --target x86_64-unknown-linux-musl
WORKDIR /app/frontend
RUN cargo build --release --target wasm32-unknown-unknown
WORKDIR /app
COPY . .
WORKDIR /app/backend
RUN cargo build --release --target x86_64-unknown-linux-musl
WORKDIR /app/frontend
RUN trunk build --release
########################
### PRODUCTION STAGE ###
########################
FROM nginx:alpine
FROM scratch
COPY --from=base /app/dist/* /usr/share/nginx/html/
# Default logging level
ENV RUST_LOG="info"
ENV COVERS_DIR="/covers"
VOLUME /covers
WORKDIR /
# Copy application binary
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/singit_srv /usr/local/bin/singit_srv
# Copy static web files
COPY --from=build /app/frontend/dist /dist
# Copy database migrations
COPY backend/migrations /migrations
ENTRYPOINT ["singit_srv"]

1
backend/.env.example Normal file
View File

@ -0,0 +1 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres

2014
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
backend/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "singit_srv"
version = "1.0.0"
authors = ["Joakim Hulthe <joakim@hulthe.net"]
edition = "2021"
[dependencies]
actix-files = "0.6.2"
actix-web = { version = "4.4.0", default-features = false, features = ["macros"] }
clap = { version = "4.4.4", features = ["derive", "env"] }
diesel = "2.1.1"
diesel-async = { version = "0.4.1", features = ["postgres", "deadpool"] }
dotenv = "0.15.0"
env_logger = "0.10.0"
eyre = "0.6.8"
log = "0.4.20"
serde = { version = "1.0.188", default-features = false, features = ["derive", "std"] }

9
backend/diesel.toml Normal file
View File

@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId"]
[migrations_directory]
dir = "migrations"

1
backend/dist Symbolic link
View File

@ -0,0 +1 @@
../frontend/dist

0
backend/migrations/.keep Normal file
View File

View File

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,2 @@
DROP TABLE song;

View File

@ -0,0 +1,13 @@
CREATE TABLE song(
song_hash TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
artist TEXT NOT NULL,
cover TEXT,
language TEXT,
video TEXT,
year TEXT,
genre TEXT,
bpm TEXT NOT NULL,
duet_singer_1 TEXT,
duet_singer_2 TEXT
);

View File

@ -0,0 +1,2 @@
DROP TABLE custom_list_entry;
DROP TABLE custom_list;

View File

@ -0,0 +1,10 @@
CREATE TABLE custom_list (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE custom_list_entry (
list_id SERIAL REFERENCES custom_list(id),
song_hash TEXT NOT NULL REFERENCES song(song_hash),
PRIMARY KEY (list_id, song_hash)
);

View File

@ -0,0 +1,253 @@
insert into custom_list(id, name) VALUES (1, 'tux');
insert into custom_list_entry(list_id, song_hash) VALUES
(1, '415e7f2a9ca15306f462493dea011328'),
(1, 'c2ada59d4465e891565ed6480c95402b'),
(1, 'b64e1a6f02bc3dbc9cf42ec51465f8a4'),
(1, '61611e2ae7da9f5fa307c677aa768749'),
(1, '331b7741323bb299558d2889662bc90c'),
(1, '83c8eb8a644c01085406611a54c746b3'),
(1, '5305707bae7a4072d6f72d2c83c6fe19'),
(1, '2a405748c627c30bfdf029361d09618c'),
(1, '24bf0d992eb9bfe83f0355574e52cd66'),
(1, '128d3a0b6109b61e7c5266942e04e45a'),
(1, '4cda857d6a4d1365a98f0f4b0f6292da'),
(1, '8fd5f78019ef7cdfa595d5c51a147ba7'),
(1, '138a95285762edcfb7916b9aef329083'),
(1, '8e9c0d935b93569858817a4ae9513875'),
(1, 'b865f24ced688b6932c6131d644f7975'),
(1, 'f83893a4322289818328b42468728314'),
(1, '92d04dbea98658cd07d9b2c29356ab52'),
(1, '9e2975f1cb67bd0ff76c83e9d2b3c7a4'),
(1, 'cf9eaf021b3d78cd2491be771abbe13b'),
(1, '3a04e1eec93c224af777c7f951dd2d98'),
(1, 'b20b9c8f2bdb43086ec9d46b9a6bada0'),
(1, '85ad23ce6d40d27656a9cbd54e3f6ab4'),
(1, 'ab32d01ba3d2a557c51c7b26f08f61f0'),
(1, '989118dd28898b84962858ec44e2f7e2'),
(1, 'bf7a666e3d37a1d7388ed838bb6f020a'),
(1, 'd30cf94ad68c50f660a31bcf637c76bd'),
(1, '053161d0a98c27ea4a9ee5e0e404e848'),
(1, 'c9cf0764a339f79b783087db605fe8f0'),
(1, 'd5c4c3513cb8869643f42e1350474ad3'),
(1, '0a8a99dc8ffafaf837f0755fa2e54009'),
(1, 'fcea59940c64e1257ce9264254ba8e03'),
(1, '63c18435bbeef45e6fafc947ca5b1ff5'),
(1, '6620e5b50f4f1ee7358bbe0d609edfe2'),
(1, '265a22dc97ee56e2e1a4ce525f3e79c2'),
(1, '9adb1f7ec660e0f2643cd151fad69b82'),
(1, '6443a7047288aab14ee4694ebe87b79b'),
(1, '80a356c273b7d52049a23e91e36aa9f0'),
(1, '0f73fa30f7f20d5235dcac8eabff472d'),
(1, '5a4b69e047a949744a81715a83f20ee3'),
(1, '3407c6f6ee7b1970c094b0c3144bf54a'),
(1, '9a4fbaee92151f345680abe2fc9ee747'),
(1, '1a049f2fe3a59d840c5157600c8743b5'),
(1, 'd6f4b7dd36e9ddb4e654d2d6836ff15e'),
(1, 'be090ec6b8d3dcd206c29c2620b673d6'),
(1, '2de6e630dc402b3f1dd565e25376fa6e'),
(1, '8527cc85dfb71405e0c509d60d530fa1'),
(1, '495b964e442823eb695e6e73d59ed100'),
(1, '09bfeb3ac6fc284b0ef8ab47c055e2b0'),
(1, 'a5f09a3e3aae08d5576d8c24656ea1da'),
(1, 'e7110b6577a733df0ef96559c72fdc3a'),
(1, '61a8dacb968a51d010af72cd9ba0fcc7'),
(1, '126c7aa03977ba6179c14d1286d95987'),
(1, '7bafa94e8b192112fcc13285733fed21'),
(1, '55efe418039d6b4c1a0a3033a505a6d5'),
(1, 'd6cd321817e8d2ed7ebb2a7282d7ba08'),
(1, '56f7f540f406a8f4feabdc976ffc02a5'),
(1, '5bd19f7edc9a5aceb165bf982777fbef'),
(1, 'e8f3382284dd8f3c14f74e543cbe1365'),
(1, '8b1bef88f60a945f23695a98ab461a32'),
(1, '34340558fe5254ec8d632b28412c1df6'),
(1, '133c04455080e1e700ea9168eb6e9154'),
(1, '40b939fea1cc7bca69f94ba98a9a8966'),
(1, '17110ec3c9490c2d7212bac578178f82'),
(1, '4013a6b73babbe4ddb7c94308f607981'),
(1, '606aa3c2fd587ba49e06e2ba96634d3e'),
(1, 'f3ddf171f30f886bb65f2eb9b5e9b631'),
(1, 'b441c2178c3ea7b470905972a8542a45'),
(1, '8c0bcc859b936e07125a8a1496fc5cff'),
(1, '37903e02cd6f096b7c18130ef322f245'),
(1, '246a6ba918b31591b8a3bb39e89e57ca'),
(1, 'df864a07605f68f58d454991035887ad'),
(1, '4407108e5fcce7999b4ed6a02235829e'),
(1, '8504e0862ec12cca41caa1f78681ec03'),
(1, '75a3f6fbf821d473bf17025e043a5964'),
(1, '0bc6e109374f04db38ec70d292371d19'),
(1, '5fb96e336c571545a9e3a1afa0ce25c2'),
(1, 'def1a11db7a5899c9b0ba357fc71a37f'),
(1, '581e463cee62d8adafd84b13deeb507d'),
(1, '503a857d480a4ee4c30b132b80e895d1'),
(1, 'cf60d6a32e7a1256f1e69b518fe158ff'),
(1, '980817c5a574eb50f3ab26f390b84b4f'),
(1, '2aa8f43167062239fa955a6fd5bf209b'),
(1, '16318fbae71adefaf80991849a181d11'),
(1, '43fd446c68528a56931f755525b8bafc'),
(1, 'abf514722acd55a48f5c1ec8badd899d'),
(1, 'fb3dd61449de05c2b84d3d75cf346d81'),
(1, '55a069e51045adab0adf4d81ca36011e'),
(1, '5694db971c54e0872defef4a300343f9'),
(1, 'd67f527b382394095a3ca40ac8449904'),
(1, 'db52ed089f2008181515e3f8be54bf90'),
(1, 'f2fffd34cef9a1878754640688487c82'),
(1, 'd6ddd5009d865979febbfb36bc2d99b7'),
(1, '362c2a15b7e8aaf8f1dfefd717fe7e29'),
(1, 'b2c13e3d09706d5fde0243a44058f81c'),
(1, '37d139647cca8827ad1205695a3daa33'),
(1, 'c5732364b28b208d22e0f4cab71b5907'),
(1, '236197f68795dea908d97fa61d8bb1c5'),
(1, 'f54e7bc914d7c5fab66b371399acfd41'),
(1, 'c2563f5a17677676ec45a0d487a2a871'),
(1, '08f30c15ce2135cdf575e71aa9fadee8'),
(1, 'ebd1f487cdb42b7d444b23c68a79918b'),
(1, 'ae2739faa6e4b4c9de6962071be9fec6'),
(1, '29c04079f0a8cdd22d95ff605847ccce'),
(1, '3a0ffffb65779005d55f8d8f3d0a7091'),
(1, '14c06f589bb0a772387888641b52454b'),
(1, 'e0f3f3e0afff4e71d8a2e3a959a8effa'),
(1, '2b843c3751dde9cfadfd392cac900487'),
(1, '8c876d8344b468cfb89a5cc91c4a5218'),
(1, '15917f7b815dce661db415ee1c488813'),
(1, '54fb8b43f54e36e9a6a546d3c45aa4cf'),
(1, 'a9430cf8d75c891c6c27ca6750faee8e'),
(1, 'eb50f5eee7f570e976ab423fb44edde9'),
(1, 'ed48d149f7ffa48d8c7eed5270f48949'),
(1, '6fa37a33a1baea4875c9e342f8091dc3'),
(1, '9ce446680bea61864df9d9fec5d54a93'),
(1, 'f7ea025406d2645f86da52dde64807d0'),
(1, '114fb59b3fee8ebf8c36fd2bf7e59a55'),
(1, 'c1729df7dc9452a496c7be7f043ce88d'),
(1, '92db823664bfa36f7fdde309fe69eb26'),
(1, 'df833bc0ae6fd919e0db280a8f0f5b9e'),
(1, '7d88370d2be9063ef347351781e3ad38'),
(1, '1dbcab635439ff5d3432407fedb94da1'),
(1, 'c79b580b5ba99bdf4669a61379747740'),
(1, 'ce3722d28640ab845396d37933c66b1a'),
(1, 'dd55c007590f65a011febd5c5ec17262'),
(1, 'bb51d1b9fb2ca91fab7e63066f797c97'),
(1, '597e0dc97c39604cbac73a887f89bf94'),
(1, '2ed7517269cece052f7cbe4d8ec6f50c'),
(1, '9c4adad2919051e6fe586354b9af132b'),
(1, '2f9cc67c261ee31a0ecdd154168a987a'),
(1, 'bb1a82b02f0de5a90fcb44edd2cccdf9'),
(1, '9ec7df7febfce66dc43941e2c259904b'),
(1, '1fd38f24222630b30cca1978e2acdb85'),
(1, '9d051739db959a5dfb5b98890b4e6ae6'),
(1, '9ae26319e307759e6e27c85ddff8f5fa'),
(1, '3e3ac2c042b18fcc7f9610f654778740'),
(1, '92aeb6900e0e06603dd846889c5d8ded'),
(1, '096d2a19ddf0911a66c4fe4b41f992c7'),
(1, 'cde2d982ab2caee5f0715c341bcae991'),
(1, 'aa545b027a6fa39e5c278fc366ad9016'),
(1, '1c5b8e85a66a415e39d996b967f92e67'),
(1, '1b4673e2376ecfa02bae442cdc5b3c90'),
(1, '9e542ff1cb9fb17f5f7a60af1fe9e66b'),
(1, '8aa0b348759b2c56e17074b65a50f04a'),
(1, '4931f242aeb4b1b641d813c6a2fbfadd'),
(1, '2e3cfa5057486d0759cee8ee477a1678'),
(1, '40444256345769c7a911e8710f0f5733'),
(1, '54f85dd1796f5a8f5797c3965e8736f4'),
(1, 'd8b2250234456105c3f1452afcffbfd5'),
(1, '038e1f5299c2e7c4c87b54f655429666'),
(1, '2ba74b69dc4671aa0fa705306c7cf072'),
(1, 'fef173c794fec61b65089321673d0370'),
(1, '366eff873b9665852f08b3d134dfd87c'),
(1, '1bdc4632029c88612f481ab75ebc4062'),
(1, 'b3acb1a516ee6949dd0f764200e80287'),
(1, '00d59e67b764386960d8b423567cf9bc'),
(1, 'b78fa5394c788492c5ddcfdbdfe4ae15'),
(1, 'e8a7d9cb2aca242bf3f0c59640ddd620'),
(1, '6059a22be14860cff767d679c98ed1df'),
(1, 'b23ce25c9b6d789953b43d6a7f6f9266'),
(1, '0499534b7a0545e81354fd6e814f4ad3'),
(1, '77ea72cb592979d8aa617f1a06181600'),
(1, '482de10e7a8d0688dd9ac27eef4cddb8'),
(1, '570b9443265e8614befdb3c84bf66095'),
(1, '5f572916554e6053ee9a59c2fbadb7c7'),
(1, '79a9316e23134dc7632c51ec305af756'),
(1, '4656a71e7802cff7757cc3e4a9281927'),
(1, 'f986b0490311d17c909d8b8cceb6756d'),
(1, '11c183d403657cafd9fe8f22921686cc'),
(1, 'cbbf9ec130e723aa24e600d16471570b'),
(1, 'e9a45f7858204bfc7b9ae196c3d9d7a7'),
(1, '47097d94df0cb3b5f4c281e4d1077ce5'),
(1, '09241a765e64d1f2280a888f255add7a'),
(1, 'fb8f0d2dd52a205aa367d3f1b549f9c1'),
(1, '97f260fceebdedcdc9f5ab99fef8a78e'),
(1, '86a91d11239bc96df9c917c99bb19050'),
(1, 'a1bac8f28c3378e56d0485cc880098d2'),
(1, 'f563a6a7d1a20fc4dddca1dab785bd1f'),
(1, '49159657f8644de0ea1c2dca29b11482'),
(1, 'd94065d725bbdeedeebe1c5a8693873f'),
(1, '03563f756de4a637b2bfa6c33b9ba3cd'),
(1, '97b51c9df42b8e8754a6127073b6cf82'),
(1, '865a527fc94bf609d2a86e6e7c2ca481'),
(1, 'cb877f34c2a65534a458bf54ea823f08'),
(1, '9d3b3d092d27b3ed0b76281225f98bf7'),
(1, 'b27e8ad3b0d538abe256169cb3363b4a'),
(1, '51c3819eb7ced02c46c22acda2c74205'),
(1, '7ba6a2f608295d678aea4195b46a0219'),
(1, 'db9785d69f04ab00266814b8e992da91'),
(1, '2db8d58d0f3062f9d7e1259492652c1d'),
(1, 'e2065c855899f8ed49e6ce89162683e8'),
(1, '1319c9749c2ffdc6832e5aa98b54e659'),
(1, 'fd34b1095c30bde6ce7aab8ec0683bb7'),
(1, '7ad81d348b958714dea07ece9fd522c2'),
(1, '2fb87de8954d03f91e6771846e2a8d6f'),
(1, '6597cb67e75e5991ceb2afe448db3c35'),
(1, '6c2619df4ae1e9377d47116d88eeeb5d'),
(1, 'd25074818bd0a543482f55ff21330676'),
(1, '8adf0a8c2be11dac6ec490bfebcc8d30'),
(1, 'b1b6e78d3e26e1aed5a408ddb975547b'),
(1, '1c8437de40bafcd6ba379ac53f1b8169'),
(1, 'bb03494e04ca2cd3918de70f74498fcf'),
(1, 'e00752cbe957fb707c6a6ac574452cd5'),
(1, 'e1360e0256b6ce58c3f8591bb93a70dc'),
(1, 'fd6c48fc6006b8276d12f8859daec350'),
(1, '75b513f7b143b20a53c9a6d415bb02d1'),
(1, 'ed2eff479f6e1333c83561125f8e64b9'),
(1, '90a269caf890d1fae8fe57dc4ed19e56'),
(1, 'b553b566dbb94a712641f346db436ad4'),
(1, '76f9f24634d3590cbdc3dbfc2d84cb6c'),
(1, '30c94206e5aac9e6936c42a2f60cc426'),
(1, '692915e5a20c90c4c0d73f3db9c1154a'),
(1, '836c2fa67a8d62c3b8c04bccd2ae31be'),
(1, 'a514416bfa9ec0a46b98de23194398a4'),
(1, '6abd9038780dd1f005df9fc31feb1560'),
(1, 'e35237b0b1595f75b6e641669d67cfce'),
(1, 'cbd15cc24b2892beb3444780fdbb19be'),
(1, '26c91da37713481245b08bd74565f06b'),
(1, '95cbbc0c9529592bee188dd2f3e7b8c7'),
(1, 'f5d7f236ed25c850ec5e2f462eed7c4f'),
(1, 'e5d7e266169ff20581413412b970c66c'),
(1, 'e1e60351f1d5c9b62ad373e6343ccb78'),
(1, '8c4a8899f75122dece210f306fc50de7'),
(1, '70ec05034e36531f8cc15a995ee2a1ca'),
(1, '1a9f9cbdf1adedcba49592fb48fb23f1'),
(1, '1369618c25eb074761f2f9dd12cd082e'),
(1, 'bcdd94a0c273aa1b0700de7e5e4213d0'),
(1, '65248f0a7165ab82ee03e312f20526e7'),
(1, '4c494372395a62e213ebecfe4dff643b'),
(1, 'c270582f05844d3adef31a226386100f'),
(1, '97047972139aa66b73ad3af35ca45b44'),
(1, '40242bfc27f4b1a1af097bf88cc9619d'),
(1, '604cabd06bd3b31fc5b8dd3802fcde2c'),
(1, '687dc02afb37f19b29ceb2339eb52676'),
(1, '581294986a59b3403d05de69c6be96d7'),
(1, '38ec3bbef46ad213aef4731fe049f5f2'),
(1, 'b50c594fc7e5603d5178b33109f6fb02'),
(1, '7988cb4fd66dd5acf3c8e4ed87a1ec5f'),
(1, '53a805ec4780ff7641acdfe374373e80'),
(1, '226b3556a1804e38029fd237a66b1db0'),
(1, 'b36725f672acde360df83bb877028d73'),
(1, '6ba01a6240c18488040f9385de557081'),
(1, '3b35edddcab0cac1aec5615dc86cd1f8'),
(1, '8a9d0cf19bd5111f45b75ac99ef298e8'),
(1, '1fa8e36ea62adfd1dbe8943a5cbd7457'),
(1, '38304b8fd2f1552ee09b14be3c1d3f2e'),
(1, 'acc5ce8a0c88cb4c5f74140e36f83e1f'),
(1, '948fb19cc466d95cd9fc7188ad5411bf'),
(1, 'c5024d345c3453b92b627bfc212dbf0e'),
(1, 'ef5ca491b2c6298026a4eb425ec63c6b'),
(1, '9b661821b671d44f7221a4a36ab80d8d');

2144
backend/mock/songs.sql Normal file

File diff suppressed because it is too large Load Diff

23
backend/src/db.rs Normal file
View File

@ -0,0 +1,23 @@
use diesel_async::{
pooled_connection::{deadpool::Pool, AsyncDieselConnectionManager},
AsyncPgConnection,
};
use log::info;
use crate::Opt;
pub type DbPool = Pool<AsyncPgConnection>;
pub async fn setup(opt: &Opt) -> eyre::Result<DbPool> {
let manager = AsyncDieselConnectionManager::new(&opt.database_url);
info!("setting up database pool");
let pool = Pool::builder(manager).build()?;
info!("testing database connection");
let _db_test = pool.get().await?;
// TODO: migrations
Ok(pool)
}

178
backend/src/main.rs Normal file
View File

@ -0,0 +1,178 @@
mod db;
mod schema;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use actix_files::NamedFile;
use actix_web::{
get,
middleware::Logger,
web::{self, Json},
App, HttpRequest, HttpServer, Responder,
};
use clap::Parser;
use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper};
use diesel_async::RunQueryDsl;
use dotenv::dotenv;
use serde::{Deserialize, Serialize};
use crate::db::DbPool;
#[derive(
Serialize,
Deserialize,
Debug,
Clone,
Default,
PartialEq,
Eq,
PartialOrd,
Ord,
Queryable,
Selectable,
)]
#[diesel(table_name = crate::schema::song)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Song {
pub song_hash: String,
pub title: String,
pub artist: String,
pub cover: Option<String>,
pub language: Option<String>,
pub video: Option<String>,
pub year: Option<String>,
pub genre: Option<String>,
pub bpm: String,
#[serde(rename = "duetsingerp1")]
pub duet_singer_1: Option<String>,
#[serde(rename = "duetsingerp2")]
pub duet_singer_2: Option<String>,
}
#[derive(Serialize, Deserialize, Queryable, Selectable, Debug, Clone, Default)]
#[diesel(table_name = crate::schema::custom_list)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct CustomList {
id: i32,
name: String,
}
#[get("/")]
async fn root() -> actix_web::Result<NamedFile> {
let path: &Path = "dist/index.html".as_ref();
Ok(NamedFile::open(path)?)
}
async fn index(req: HttpRequest) -> actix_web::Result<NamedFile> {
let path: PathBuf = req.match_info().query("filename").parse().unwrap();
let path = Path::new("dist").join(path);
Ok(NamedFile::open(path)?)
}
#[get("/images/songs/{image}")]
async fn song_image(
path: web::Path<String>,
opt: web::Data<Arc<Opt>>,
) -> actix_web::Result<NamedFile> {
let image = path.into_inner();
let path = opt.covers_dir.join(image);
Ok(NamedFile::open(path)?)
}
#[get("/songs")]
async fn songs(pool: web::Data<DbPool>) -> impl Responder {
use schema::song::dsl::*;
let mut db = pool.get().await.unwrap();
let songs = song.select(Song::as_select()).load(&mut db).await.unwrap();
Json(songs)
}
#[get("/custom/lists")]
async fn custom_lists(pool: web::Data<DbPool>) -> impl Responder {
use schema::custom_list::dsl::*;
let mut db = pool.get().await.unwrap();
let lists: Vec<String> = custom_list.select(name).load(&mut db).await.unwrap();
Json(lists)
}
#[get("/custom/list/{list}")]
async fn custom_list(pool: web::Data<DbPool>, path: web::Path<String>) -> impl Responder {
use schema::custom_list::dsl::*;
use schema::custom_list_entry::dsl::*;
let mut db = pool.get().await.unwrap();
let list: CustomList = custom_list
.select(CustomList::as_select())
.filter(name.eq(&*path))
.get_result(&mut db)
.await
.unwrap();
let list_entries: Vec<String> = custom_list_entry
.select(song_hash)
.filter(list_id.eq(list.id))
.load(&mut db)
.await
.unwrap();
Json(list_entries)
}
#[derive(Parser)]
pub struct Opt {
/// Address to bind to.
#[clap(short, long, env = "BIND_ADDRESS", default_value = "0.0.0.0")]
address: String,
/// Port to bind to.
#[clap(short, long, env = "BIND_PORT", default_value = "8080")]
port: u16,
/// Postgresql URL.
#[clap(short, long, env = "DATABASE_URL")]
database_url: String,
/// Directory where song covers are stored.
#[clap(short, long, env = "COVERS_DIR")]
covers_dir: PathBuf,
}
#[actix_web::main]
async fn main() -> eyre::Result<()> {
dotenv().ok();
let opt = Arc::new(Opt::parse());
env_logger::init();
let db_pool = db::setup(&opt).await?;
let app = {
let opt = Arc::clone(&opt);
move || {
let logger = Logger::default();
App::new()
.wrap(logger)
.app_data(web::Data::new(db_pool.clone()))
.app_data(web::Data::new(Arc::clone(&opt)))
.service(root)
.service(songs)
.service(song_image)
.service(custom_list)
.service(custom_lists)
.route("/{filename:.*}", web::get().to(index))
}
};
HttpServer::new(app)
.bind((opt.address.as_str(), opt.port))?
.run()
.await?;
Ok(())
}

36
backend/src/schema.rs Normal file
View File

@ -0,0 +1,36 @@
// @generated automatically by Diesel CLI.
diesel::table! {
custom_list (id) {
id -> Int4,
name -> Text,
}
}
diesel::table! {
custom_list_entry (list_id, song_hash) {
list_id -> Int4,
song_hash -> Text,
}
}
diesel::table! {
song (song_hash) {
song_hash -> Text,
title -> Text,
artist -> Text,
cover -> Nullable<Text>,
language -> Nullable<Text>,
video -> Nullable<Text>,
year -> Nullable<Text>,
genre -> Nullable<Text>,
bpm -> Text,
duet_singer_1 -> Nullable<Text>,
duet_singer_2 -> Nullable<Text>,
}
}
diesel::joinable!(custom_list_entry -> custom_list (list_id));
diesel::joinable!(custom_list_entry -> song (song_hash));
diesel::allow_tables_to_appear_in_same_query!(custom_list, custom_list_entry, song,);

687
frontend/Cargo.lock generated Normal file
View File

@ -0,0 +1,687 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea5d730647d4fadd988536d06fecce94b7b4f2a7efdae548f1cf4b63205518ab"
dependencies = [
"memchr",
]
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "bumpalo"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
[[package]]
name = "bytes"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "css_typegen"
version = "0.2.0"
source = "git+https://github.com/hulthe/css_typegen.git?branch=master#ed1c4b1b7c8bf19e1ce045cafa12f3886041134c"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 1.0.109",
]
[[package]]
name = "csv"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626ae34994d3d8d668f4269922248239db4ae42d538b14c398b74a52208e8086"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "csv-core"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90"
dependencies = [
"memchr",
]
[[package]]
name = "enclose"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1056f553da426e9c025a662efa48b52e62e0a3a7648aa2d15aeaaf7f0d329357"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "futures"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c"
[[package]]
name = "futures-executor"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964"
[[package]]
name = "futures-macro"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.37",
]
[[package]]
name = "futures-sink"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e"
[[package]]
name = "futures-task"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65"
[[package]]
name = "futures-util"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "gloo-console"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261"
dependencies = [
"gloo-utils 0.2.0",
"js-sys",
"serde",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-events"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-file"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7"
dependencies = [
"futures-channel",
"gloo-events",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-net"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils 0.2.0",
"http",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "gloo-utils"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "http"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
[[package]]
name = "js-sys"
version = "0.3.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.148"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b"
[[package]]
name = "log"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
[[package]]
name = "memchr"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c"
[[package]]
name = "once_cell"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "pin-project"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.37",
]
[[package]]
name = "pin-project-lite"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]]
name = "proc-macro2"
version = "1.0.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "regex"
version = "1.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
[[package]]
name = "ryu"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
[[package]]
name = "seed"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c0e296ea0569d20467e9a1df3cb6ed66ce3b791a7eaf1e1110ae231f75e2b46"
dependencies = [
"enclose",
"futures",
"getrandom",
"gloo-file",
"gloo-timers",
"gloo-utils 0.1.7",
"indexmap",
"js-sys",
"rand",
"uuid",
"version_check",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "serde"
version = "1.0.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.37",
]
[[package]]
name = "serde_json"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "singit2"
version = "0.2.2"
dependencies = [
"css_typegen",
"csv",
"gloo-console",
"gloo-net",
"rand",
"seed",
"serde",
"serde_json",
"thiserror",
"wasm-bindgen",
]
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7303ef2c05cd654186cb250d29049a24840ca25d2747c25c0381c8d9e2f582e8"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d6d7a740b8a666a7e828dd00da9c0dc290dff53154ea77ac109281de90589b7"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.37",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "uuid"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
dependencies = [
"getrandom",
]
[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.37",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.37",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
[[package]]
name = "web-sys"
version = "0.3.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b"
dependencies = [
"js-sys",
"wasm-bindgen",
]

20
frontend/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "singit_web"
version = "1.0.0"
authors = ["Joakim Hulthe <joakim@hulthe.net"]
edition = "2021"
[dependencies]
seed = "0.10.0"
serde = { version = "1.0.0", features = ["derive"] }
serde_json = "1.0.0"
rand = "0.8.5"
gloo-console = "0.3.0"
gloo-net = "0.4.0"
csv = "1.2.2"
thiserror = "1.0.48"
wasm-bindgen = "0.2.87"
[dependencies.css_typegen]
git = "https://github.com/hulthe/css_typegen.git"
branch = "master"

26
frontend/build.rs Normal file
View File

@ -0,0 +1,26 @@
use std::fs;
use std::path::Path;
use std::io;
fn main() {
index_default_covers().expect("index default covers");
}
/// Index all pngs in static/images/default_covers and expose the list in the build as an
/// environment variable DEFAULT_SONG_COVERS. This list includes the path to the images so the
/// frontend can fetch them.
fn index_default_covers() -> io::Result<()> {
let mut files = vec![];
for dir in fs::read_dir("static/images/default_covers")? {
let path = dir?.path();
if path.extension().and_then(|s| s.to_str()) == Some("png") {
let path = Path::new("/").join(path.strip_prefix("static").unwrap());
files.push(path.to_string_lossy().to_string());
}
}
let list = files.join(",");
println!("cargo:rustc-env=DEFAULT_SONG_COVERS={list}");
Ok(())
}

View File

@ -1,45 +1,81 @@
use crate::css::C;
use crate::custom_list::{fetch_custom_song_list, fetch_custom_song_list_index, CustomLists};
use crate::fetch::fetch_list_of;
use crate::fuzzy::FuzzyScore;
use crate::query::ParsedQuery;
use crate::song::Song;
use anyhow::anyhow;
use gloo_console::error;
use rand::seq::SliceRandom;
use rand::thread_rng;
use seed::app::cmds::timeout;
use seed::browser::util::document;
use seed::prelude::*;
use seed::{attrs, button, div, empty, error, img, input, p, span, C, IF};
use seed::{attrs, button, div, empty, img, input, p, span, C, IF};
use std::cmp::Reverse;
use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use web_sys::Element;
pub struct Model {
songs: Vec<(Reverse<FuzzyScore>, Song)>,
/// The search string
/// Custom song lists, lazily loaded.
custom_lists: CustomLists,
/// The search string.
query: String,
/// The number of songs currently in view. Goes up when the user scrolls down.
/// The number of songs currently in the dom. Goes up when the user scrolls down.
shown_songs: usize,
/// The number of songs that didn't match the search critera
/// The number of songs that didn't match the search critera.
hidden_songs: usize,
/// Whether we're filtering by video
/// Whether we're filtering by video.
filter_video: bool,
/// Whether we're filtering by duets
/// Whether we're filtering by duets.
filter_duets: bool,
query_placeholder: String,
query_placeholder_len: usize,
autotyper: Option<CmdHandle>,
/// URLs of some defaults for songs with missing cover art.
default_song_covers: Vec<&'static str>,
}
#[derive(Default)]
pub enum Loading<T> {
/// The resource has not started loading.
#[default]
NotLoaded,
/// The resource is currently loading.
InProgress,
/// The resource has loaded.
Loaded(T),
}
const SCROLL_THRESHOLD: usize = 50;
const INITIAL_ELEM_COUNT: usize = 100;
pub enum Msg {
/// Loaded songs.
Songs(Vec<Song>),
/// Loaded custom song index.
CustomSongLists(Vec<String>),
/// Loaded custom song list.
CustomSongList {
list: String,
song_hashes: HashSet<String>,
},
/// The user entered something into the search field
Search(String),
@ -60,15 +96,16 @@ pub enum Msg {
}
pub fn init(_url: Url, orders: &mut impl Orders<Msg>) -> Model {
let mut songs: Vec<Song> =
serde_json::from_str(include_str!("../static/songs.json")).expect("parse songs");
songs.shuffle(&mut thread_rng());
orders.perform_cmd(fetch_songs());
orders.perform_cmd(fetch_custom_song_list_index());
// get list of default song covers. see build.rs
const DEFAULT_SONG_COVERS: &str = env!("DEFAULT_SONG_COVERS");
let default_song_covers = DEFAULT_SONG_COVERS.split(',').collect();
Model {
songs: songs
.into_iter()
.map(|song| (Default::default(), song))
.collect(),
songs: vec![],
custom_lists: Default::default(),
query: String::new(),
hidden_songs: 0,
shown_songs: INITIAL_ELEM_COUNT,
@ -77,18 +114,15 @@ pub fn init(_url: Url, orders: &mut impl Orders<Msg>) -> Model {
query_placeholder: String::from("Sök"),
query_placeholder_len: 0,
autotyper: Some(orders.perform_cmd_with_handle(timeout(500, || Msg::Autotyper))),
default_song_covers,
}
}
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::Search(query) => {
fn update_song_list(model: &mut Model, orders: &mut impl Orders<Msg>) {
model.hidden_songs = 0;
model.shown_songs = INITIAL_ELEM_COUNT;
scroll_to_top();
model.query = query;
if model.query.is_empty() {
model.filter_duets = false;
model.filter_video = false;
@ -98,9 +132,16 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
model.filter_duets = query.duet == Some(true);
model.filter_video = query.video == Some(true);
if let Some(name) = query.list {
if let Some(l @ Loading::NotLoaded) = model.custom_lists.get_mut(name) {
orders.perform_cmd(fetch_custom_song_list(name.to_string()));
*l = Loading::InProgress;
}
}
// calculate search scores & sort list
for (score, song) in model.songs.iter_mut() {
let new_score = song.fuzzy_compare(&query);
let new_score = song.fuzzy_compare(&query, &model.custom_lists);
if new_score < Default::default() {
model.hidden_songs += 1;
}
@ -109,6 +150,35 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}
model.songs.sort_unstable();
}
}
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::Songs(songs) => {
model.songs = songs
.into_iter()
.map(|song| (Default::default(), song))
.collect();
}
Msg::CustomSongLists(lists) => {
model.custom_lists = lists
.into_iter()
.map(|list| (list, Loading::NotLoaded))
.collect();
}
Msg::CustomSongList { list, song_hashes } => {
let query = ParsedQuery::parse(&model.query);
let update_list = query.list == Some(&list);
*model.custom_lists.entry(list).or_default() = Loading::Loaded(song_hashes);
if update_list {
update_song_list(model, orders);
}
}
Msg::Search(query) => {
model.query = query;
update_song_list(model, orders);
}
Msg::ToggleVideo => {
let mut query = ParsedQuery::parse(&model.query);
@ -135,12 +205,9 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
autotype_song(model, orders);
}
Msg::Scroll => {
let (scroll, max_scroll) = match get_scroll() {
Ok(v) => v,
Err(e) => {
error!(e);
let Some((scroll, max_scroll)) = get_scroll() else {
error!("Failed to get song list element by id:", SONG_LIST_ID);
return;
}
};
let scroll_left: i32 = max_scroll - scroll;
@ -171,6 +238,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}
pub fn view(model: &Model) -> Vec<Node<Msg>> {
let song_card = |song: &Song| -> Node<Msg> {
div![
C![C.song_item],
@ -178,13 +246,30 @@ pub fn view(model: &Model) -> Vec<Node<Msg>> {
C![C.song_item_cover],
match song.cover {
Some(_) => attrs! {At::Src => format!("/images/songs/{}.png", song.song_hash)},
None => attrs! {At::Src => "/images/default_cover.png"},
None => {
// use a DefaultHasher to turn the song_hash string into a number we can
// use to give the song a psuedo-random default cover.
let mut hasher = DefaultHasher::new();
song.song_hash.hash(&mut hasher);
let hash = hasher.finish() as usize;
let cover_i = hash % model.default_song_covers.len();
let cover = model.default_song_covers[cover_i];
attrs! { At::Src => cover }
}
},
],
div![
C![C.song_item_info],
div![C![C.song_item_title], song.title.to_string()],
div![C![C.song_item_artist], song.artist.to_string()],
div![C![C.song_item_title], &song.title],
div![
C![C.song_item_artist],
span![&song.artist],
if let Some(year) = song.year.as_ref() {
span![" (", year, ")"]
} else {
empty![]
}
],
],
div![
C![C.song_gizmos],
@ -276,6 +361,20 @@ pub fn view(model: &Model) -> Vec<Node<Msg>> {
]
}
async fn fetch_songs() -> Option<Msg> {
let mut songs: Vec<Song> = match fetch_list_of("/songs").await {
Ok(response) => response,
Err(e) => {
error!("Error fetching songs:", e);
return None;
}
};
songs.shuffle(&mut thread_rng());
Some(Msg::Songs(songs))
}
pub fn autotype_song(model: &mut Model, orders: &mut impl Orders<Msg>) {
let (_, song) = &model.songs[0];
model.query_placeholder = ParsedQuery::random(song, &mut thread_rng()).to_string();
@ -285,22 +384,20 @@ pub fn autotype_song(model: &mut Model, orders: &mut impl Orders<Msg>) {
const SONG_LIST_ID: &str = "song_list";
fn get_song_list_element() -> anyhow::Result<Element> {
document()
.get_element_by_id(SONG_LIST_ID)
.ok_or_else(|| anyhow!("Failed to access song list element"))
fn get_song_list_element() -> Option<Element> {
document().get_element_by_id(SONG_LIST_ID)
}
fn scroll_to_top() {
if let Ok(elem) = get_song_list_element() {
if let Some(elem) = get_song_list_element() {
elem.scroll_to_with_x_and_y(0.0, 0.0);
}
}
fn get_scroll() -> anyhow::Result<(i32, i32)> {
fn get_scroll() -> Option<(i32, i32)> {
let list = get_song_list_element()?;
let scroll = list.scroll_top();
let height = list.client_height();
let max = (list.scroll_height() - height).max(0);
Ok((scroll, max))
Some((scroll, max))
}

View File

@ -4,4 +4,4 @@ use css_typegen::css_typegen;
// Generate rust types for css-classes.
// Used for autocompletion and extra compile-time checks.
css_typegen!("static/styles");
css_typegen!("frontend/static/styles");

View File

@ -0,0 +1,34 @@
use std::collections::{HashMap, HashSet};
use gloo_console::error;
use crate::{
app::{Loading, Msg},
fetch::fetch_list_of,
};
pub type CustomLists = HashMap<String, Loading<HashSet<String>>>;
pub async fn fetch_custom_song_list_index() -> Option<Msg> {
let custom_lists: Vec<String> = match fetch_list_of("/custom/lists").await {
Ok(response) => response,
Err(e) => {
error!("Failed fetching custom song list index:", e);
return None;
}
};
Some(Msg::CustomSongLists(custom_lists))
}
pub async fn fetch_custom_song_list(list: String) -> Option<Msg> {
let song_hashes: HashSet<String> = match fetch_list_of(format!("/custom/list/{list}")).await {
Ok(response) => response.into_iter().collect(),
Err(e) => {
error!("Failed fetching custom song list:", e);
return None;
}
};
Some(Msg::CustomSongList { list, song_hashes })
}

70
frontend/src/fetch.rs Normal file
View File

@ -0,0 +1,70 @@
use std::io::Cursor;
use gloo_net::http::{Request, Response};
use serde::de::DeserializeOwned;
use wasm_bindgen::{JsError, JsValue};
const HTTP_ACCEPT: &str = concat!("text/csv, ", "application/json;q=0.9");
#[derive(Debug, thiserror::Error)]
pub enum FetchError {
/// The request returned a non-2XX status code.
#[error("server responded with {code} {text}")]
Status { code: u16, text: String },
/// The response contained an unrecognized or missing content type.
#[error("unknown content type {0:?}")]
UnknownContentType(Option<String>),
/// Another error occured.
#[error("{0}")]
Other(#[from] gloo_net::Error),
#[error("error deserializing csv: {0}")]
Csv(#[from] csv::Error),
}
impl From<FetchError> for JsValue {
fn from(e: FetchError) -> Self {
JsError::new(&e.to_string()).into()
}
}
/// Perform a GET request.
pub async fn fetch(url: impl AsRef<str>) -> Result<Response, FetchError> {
let response = Request::get(url.as_ref())
.header("accept", HTTP_ACCEPT)
.send()
.await?;
if !response.ok() {
return Err(FetchError::Status {
code: response.status(),
text: response.status_text(),
});
}
Ok(response)
}
/// Perform a GET request and try to deserialize the response as a `Vec<T>`.
pub async fn fetch_list_of<T: DeserializeOwned>(
url: impl AsRef<str>,
) -> Result<Vec<T>, FetchError> {
let response = fetch(url.as_ref()).await?;
let headers = response.headers();
let content_type = headers.get("Content-Type").map(|s| s.to_lowercase());
match content_type.as_deref() {
Some("text/csv") => {
let text = response.text().await?;
let reader = csv::Reader::from_reader(Cursor::new(text)).into_deserialize();
let list = reader
.map(|r| r.map_err(FetchError::from))
.collect::<Result<_, _>>()?;
Ok(list)
}
Some("application/json") => Ok(response.json().await?),
_ => Err(FetchError::UnknownContentType(content_type)),
}
}

View File

@ -1,13 +1,13 @@
mod app;
mod css;
mod custom_list;
mod fetch;
mod fuzzy;
mod query;
mod song;
use seed::prelude::wasm_bindgen;
use seed::App;
#[wasm_bindgen(start)]
pub fn start() {
fn main() {
App::start("app", app::init, app::update, app::view);
}

View File

@ -11,10 +11,10 @@ pub struct ParsedQuery<'a> {
pub plain: Option<Cow<'a, str>>,
/// Query a specific title
pub title: Option<&'a str>,
pub title: Option<Cow<'a, str>>,
/// Query a specific artist
pub artist: Option<&'a str>,
pub artist: Option<Cow<'a, str>>,
/// Whether the song is a duet
pub duet: Option<bool>,
@ -30,6 +30,9 @@ pub struct ParsedQuery<'a> {
/// Query from a specifc year
pub year: Option<&'a str>,
/// Query songs from the specified custom list.
pub list: Option<&'a str>,
}
impl<'a> ParsedQuery<'a> {
@ -43,13 +46,14 @@ impl<'a> ParsedQuery<'a> {
for (k, v) in kvs {
match k {
"title" => parsed.title = Some(v),
"artist" => parsed.artist = Some(v),
"title" => parsed.title = Some(Cow::Borrowed(v)),
"artist" => parsed.artist = Some(Cow::Borrowed(v)),
"duet" => parsed.duet = parse_bool(v),
"video" => parsed.video = parse_bool(v),
"lang" => parsed.language = Some(v),
"genre" => parsed.genre = Some(v),
"year" => parsed.year = Some(v),
"list" => parsed.list = Some(v),
_ => {}
}
}
@ -59,8 +63,16 @@ impl<'a> ParsedQuery<'a> {
/// Generate a parsed query with a few random fields matching a song
pub fn random<R: Rng>(song: &'a Song, rng: &mut R) -> Self {
let until_space =
|s: &'a str| -> &'a str { s.trim().split_whitespace().next().unwrap_or("") };
let until_space = |s: &'a str| -> &'a str { s.split_whitespace().next().unwrap_or("") };
let join_spaces = |s: &'a str| -> Cow<'a, str> {
let s = s.trim();
if s.contains(char::is_whitespace) {
s.replace(char::is_whitespace, "").into()
} else {
Cow::Borrowed(s)
}
};
let mut primary_fields: [&dyn Fn(Self) -> Self; 4] = [
&|query| Self {
@ -72,11 +84,11 @@ impl<'a> ParsedQuery<'a> {
..query
},
&|query| Self {
title: Some(until_space(&song.title)),
title: Some(join_spaces(&song.title)),
..query
},
&|query| Self {
artist: Some(until_space(&song.artist)),
artist: Some(join_spaces(&song.artist)),
..query
},
];
@ -110,8 +122,8 @@ impl<'a> ParsedQuery<'a> {
fn parse_bool(s: &str) -> Option<bool> {
match s {
"true" | "yes" => Some(true),
"false" | "no" => Some(false),
"true" | "yes" | "y" => Some(true),
"false" | "no" | "n" => Some(false),
_ => None,
}
}
@ -128,7 +140,7 @@ fn extract_plain(s: &str) -> Option<Cow<str>> {
a
});
plain.is_empty().not().then(|| Cow::Owned(plain))
plain.is_empty().not().then_some(Cow::Owned(plain))
}
fn extract_key_values(s: &str) -> impl Iterator<Item = (&str, &str)> {
@ -164,6 +176,7 @@ impl Display for ParsedQuery<'_> {
w("lang:", display(&self.language))?;
w("genre:", display(&self.genre))?;
w("year:", display(&self.year))?;
w("list:", display(&self.list))?;
Ok(())
}

View File

@ -1,3 +1,5 @@
use crate::app::Loading;
use crate::custom_list::CustomLists;
use crate::fuzzy::{self, FuzzyScore};
use crate::query::ParsedQuery;
use serde::Deserialize;
@ -27,22 +29,21 @@ impl Song {
.zip(self.duet_singer_2.as_deref())
}
pub fn fuzzy_compare(&self, query: &ParsedQuery) -> FuzzyScore {
let bad = || -1;
pub fn fuzzy_compare(&self, query: &ParsedQuery, custom_lists: &CustomLists) -> FuzzyScore {
let bad: FuzzyScore = -1;
let filter_strs = |query: Option<&str>, item: Option<&str>| {
if let Some(query) = query {
match item {
Some(item) => {
let score = fuzzy::compare(item.chars(), query.chars());
if score < fuzzy::max_score(query) / 2 {
return false;
}
}
None => return false,
score == fuzzy::max_score(query)
}
None => false,
}
} else {
true
}
};
let filter_bool =
@ -57,7 +58,17 @@ impl Song {
];
if !filters.iter().all(|f| f()) {
return bad();
return bad;
}
if let Some(list) = query.list {
let Some(Loading::Loaded(list)) = custom_lists.get(list) else {
return bad;
};
if !list.contains(&self.song_hash) {
return bad;
}
}
let mut score = FuzzyScore::default();
@ -67,12 +78,12 @@ impl Song {
score = max(title_score, artist_score);
}
if let Some(title) = query.title {
if let Some(title) = &query.title {
let new_score = fuzzy::compare(self.title.chars(), title.chars());
score = max(score, new_score);
}
if let Some(artist) = query.artist {
if let Some(artist) = &query.artist {
let new_score = fuzzy::compare(self.artist.chars(), artist.chars());
score = max(score, new_score);
}

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB