Compare commits

..

52 Commits

Author SHA1 Message Date
KD e98e055de0 Stream song (#164)
* Added crates and dependency features

* Added endpoint to stream songs

* Changed name of function

* Song stream endpoint is now available

* Endpoint is now working

* Added test

* Warning fixes

* Code formatting

* Added TODO for later

* Test fix

* Version bump
2025-07-27 14:20:42 -04:00
KD a8603361c0 Get coverart (#163)
* Added initial code to get coverart endpoint

* Test migration cleanup

* Updated icarus_models

* Made endpoint evailable

* Added test

* Version bump

* Code formatting
2025-07-26 14:12:03 -04:00
KD dbda9a3897 Get songs endpoint (#162)
* Added test_migrations directory

* Added skeleton endpoint to get songs

* Code formatting

* Added more code

* Added TODO

* Code formatting

* Updated album in album json file

* Changing directory name

* Test refactoring

* Forgot to include this

* Added test for getting songs and added function for test_migrations

* Adding test migrations

* Removing placeholder

* Renamed

* Renamed

* Fixed what caused test failure

* Created migration with cli command

Creating test_migrations/20250725213448_migration_name.sql

* Migration changes

* Removing migration

* More migration changes

* Made endpoint available

* Migration changes

* Got the test to work

* Cleaned up test

* Code formatting

* More cleanup

* Version bump
2025-07-25 18:20:28 -04:00
KD a987e438c4 Added flac files used for testing (#161)
* Added flac files used for testing

* Refactor test

* Added CoverArt file for test files

* Added album json file for testing

* Modifying test json file

* Modifying test file for consistency

* Test refactoring

* Test file changes

* Removing commented code

* Removing files used for testing

* Removing placeholder

* Code formatting
2025-07-25 16:09:01 -04:00
KD a150d34c6c Added release workflow (#160)
* Added release workflow

* Temp change

* File extension change

* Syntax change

* Removing that temp change
2025-07-22 21:59:20 -04:00
KD 34e292bea9 Link user id to song queue (#159)
* Added initial code for endpoint to link user id with song queue #156

* Updated songQueue to have a user_id field #156

* Added function to link user_id with songQueue record #156

* Added useful constant #156

* Finished adding code #156

Need to make the endpoint available and then write a test. Might have to modify existing tests

* Made link user id endpoint available #156

* Added TODOs for later

* Changing location of test code

* Code formatting

* Added test #156

* Formatted code

* Added user_id to SongQueue and added more related code

* Code formatting

* Fixing possible nil user_id field in SongQueue when calling a function

* Fixed another issue

* Made changes to function to also return user_id

* Making use of user_id in test requests

* Code formatting

* Updated workflow

* Forgot to include port

* Docker changes for port change

* Reverting port changes

* Cleaning up Dockerfile

* Version bump
2025-07-22 21:36:29 -04:00
KD c9bcea5f98 Version change (#158) 2025-07-15 16:15:51 -04:00
KD 6c9e4809a9 Fixing bug where message in response was unpopulated on successful requests (#157) 2025-07-15 15:10:51 -04:00
KD 4c5bd1ccde Updated icarus_models (#155) 2025-07-14 20:09:43 -04:00
KD 5a2639cb59 Added error checking for file creation in create metadata endpoint (#154)
* Added error checking for file creation in create metadata endpoint

* Code formatting

* Fixed create metadata endoint

* Added missing message in the response

* Cleanup

* Warning fixes

* Code formatting
2025-07-14 19:42:07 -04:00
KD fd9df41f51 Updated rust version in docker (#152) 2025-07-13 18:54:47 -04:00
KD 7fdea3491f Upgrading postgresql to 17.5 (#151)
Closes #150
2025-07-12 15:59:23 -04:00
KD 3cde57dca8 Refactoring (#149)
* Warning fixes

* More warning fixes

* Cargo.toml cleanup
2025-06-29 17:33:45 -04:00
KD 8c93429647 Update dependencies (#148)
* Updating Cargo.lock

* Updating axum

* Updated tokio, tokio-util, and tower-http

* Updated uuid and sqlx

* Updated icarus_models, icarus_envy, and icarus_meta

* Updated tempfile
2025-06-29 17:15:20 -04:00
KD 5721c387ea Rust std change (#147)
* Targeting rust 1.88

* Updating workflow
2025-06-29 17:03:59 -04:00
KD a624702813 Update song queue endpoint fix (#146)
* Bug fix that was causing errors

* Code formatting

* Removed commented code
2025-06-28 19:06:41 -04:00
KD 9c67bfd8a8 Update song queue changes (#145)
* Updated gitignore to disregard some .env files

.env.docker and .env.local. Primarily used for switching between local and docker development

* Changed PATCH /api/v2/song/queue/{id} endpoint

Modified the response body. Will just return a list of song queue id

* Adding some helpful info for the response body

* Tidying up and making some changes

* Code formatting
2025-06-28 16:45:25 -04:00
KD fbec3305b1 Reorganizing metadata queue code (#144)
* Reorganizing metadata queue code

* Function name change

* Test payload changes

* function parameter name change
2025-06-18 19:47:41 -04:00
KD fa792be9cc Fixed endpoint for getting coverart queue data (#143)
* Fixed endpoint for getting coverart queue data

* Modified test

* Corrected endpoint syntax

* Fixed test

* Some cleanup

* Formatting
2025-06-17 21:40:49 -04:00
KD 6f2e8d6e17 Update song queue flow (#142)
* Added new status and made changes to song queue status code

* Saving changes

* Got a test working

* Got another test working

* Got the last test working

* Code cleanup

* More minor changes

* More minor changes

* Minor changes
2025-06-07 14:47:42 -04:00
KD eb1f19a96e Refactoring tests (#141)
* Saving changes

* Got test working

* Removing commented code

* Code formatting

* Refactoring test

* Code refactoring

* More refactoring

* Code refactoring

* Last bit of tidying up
2025-06-04 20:40:41 -04:00
KD bb766acf4e Added icarus_envy (#140)
* Added icarus_envy

* Commenting out dotenvy crate

* Removing environment and keys modules

* Replacing with icarus_envy

* Making changes to test code

* Removing lingering references in tests

* Test fix

* Test fix

* Another test fix

* Rust formatting

* Removing commented crate

* Using v0.2.0 version of icarus_envy
2025-06-01 20:20:33 -04:00
KD 95688cccfa Including Cargo.lock in source control (#139)
* Removing cargo.lock from gitignore

* Adding cargo.lock
2025-06-01 19:15:33 -04:00
KD e2485cec44 Add sample docker env (#138)
* Added sample .env.docker file

* Updated sample .env file
2025-05-28 19:41:38 -04:00
KD df1562f5e7 Updated auth environment variables in docker (#137) 2025-05-27 16:59:20 -04:00
KD e00e9cf185 Wipe data from coverart queue (#136)
* Changes to coverartQueue migrations

* Added endpoint to wipe data from coverart queue

* Added constant for endpoint

* Endpoint is now available

* Added test

Need to complete

* Test is working

* Code formatting

* Removed TODO
2025-05-26 17:12:11 -04:00
KD f0d2b9de71 Wipe data from song queue (#135)
* Added TODOs for later

* Added endpoint to wipe data from song queue

* Migration changes

* Syntax error fix

* Added and linked endpoint

* Added test

* Warning fixes

* Code formatting
2025-05-25 21:12:17 -04:00
KD f0a0bee22b Create coverart (#134)
* Added migrations for coverart

* Test refactor

* Code formatting

* Added endpoint to create coverart

* Endpoint is available

* Migration changes to song table

* some fixes

* Added test

* Test refactor

* Code formatting

* Clippy warning fixes

* Code cleanup
2025-05-24 19:02:48 -04:00
KD 0b73c055aa Create metadata (#133)
* Added migration for metadata table used to store a song's metadata

* Added comment for new table

* Added endpoint to create metadata

* Filling out code

* Changed type of duration in request

* Reorganizing code

* Changing metadata table to song

* Added TODO for data type discrepancy for the future

* Fix migration syntax error

* Added types to request

* Added environment variable for root directory:

* Moving keys to its own module

* Moved environement related code to its own module

* Added function to get root_directory

* Added endpoint to create song

* Minor fixes

* Added test and refactored tests

* Ironing out some kinks

* Resolved test issue

Turns out I was using the wrong id value

* Added env variable in workflow

* Added TODO

* Updated env variable

* clippy warning fixes

* Making sure date_created is populated when returneD

* Code cleanup

* Added TODOs

* Uncommenting tests

* Code formatting
2025-05-23 20:12:15 -04:00
KD d4c025511d Updating song from queue (#132)
* Added endpoint - no code - for updating the song from the queue

* Added endpoint to update song from queue

* Endpoint is now available

* Fixed endoint uri

* Added tempfile crate for tests

* Test is working

* Test is operational

* Code formatting

* Code cleanup
2025-05-22 16:18:12 -04:00
KD b2a4faec76 Coverart queue fetch data (#131)
* Added coverart queue data fetch endpoint

* Added test

* Code formatting
2025-05-22 15:03:57 -04:00
KD 7d27aed314 Coverart queue fetch without data (#130)
* Added endpoint

* Endpoint is now accessible

* Moved request to its own function in tests

* Added test, but running into issues

* Fixing code causing test to fail

* Code formatting
2025-05-21 19:26:38 -04:00
KD 961f99b744 Update song queue status (#129)
* Changes to status definition

* Added endpoint to update song_queue status

* endpoint return fix

* Endpoint now available

* Added test

* Code formatting

* Clippy warning fix
2025-05-20 20:42:34 -04:00
KD b1f5c5f604 Link coverart with song queue (#128)
* Added initial code to link coverart id and song queue id

* Added more functionality

* Preparing test

* Finished test

* Cleanup

* Code formatting

* Clippy warning fix
2025-05-19 21:54:50 -04:00
KD ddb0bf27f8 Queue coverart (#127)
* Added new module

* Added new endpoint

* Formatting

* Migration changes

* Fix routing issue

* Fixed migration

* Added functionality and test

* Test change

* Cleanup

* Code formatting
2025-05-18 18:22:39 -04:00
KD d491767de4 Get metadata (#126)
* Added time crate

* Added endpoint to get metadata

* Formatting

* Added more code to get metadata

Having issues returning response

* Formatting

* Migration change

* Fix date issue

* Got it somewhat working

* Got things working

* Code fix

* Added test

* Formatting

* Test works

* Cleanup

* Migrations cleanup
2025-04-29 21:52:22 -04:00
KD 8755276b48 Fetch next queued song (#125)
* Added code

* Got it working

* Added test and refactored test code

* Removed comment

* Cleanup

* Code formatting
2025-04-26 18:38:17 -04:00
KD fd41d25c4c Test refactor (#124)
* Refactoring test

* Test refactor

* Added code to get response content

* More refactoring

* Code refactor
2025-04-26 16:56:09 -04:00
KD 5e3ca1861d Next queued song (#123)
* Added code to fetch next queue item

* Updated README

* Give it a go

* Minor docker compose change

* Added docker-compose defaults

* Code refactor

* Removed data from being fetched

* Added test

* Formatting
2025-04-26 16:11:46 -04:00
KD db7350b550 Added status to songQueue table (#122)
* Added status to songQueue table

* Added status

* Formatting

* Removed failed option for the status field for now
2025-04-24 21:11:45 -04:00
KD f823a99984 Queue meta data (#121)
* Added migrations

Added new table to store metadata

* Updated table

* Added code for new endpoint

* Updated module

* Code formatting:

* Added test code

* Fixed test

* Cleanup

* Code refactor
2025-04-23 20:50:07 -04:00
KD 2823452198 Added callers moulde (#120)
* Added callers moulde

* Code formatting

* Added uuid dependency

* Added queue_song endpoint

* Added endpoint to router

* Added dependencies for postgres

* Added migrations directory

* Added more code

* Added migrations

* Removed migration

* Docker fixes

* Updated gitignore

* Updated env sample file

* Endpoint url fix

* Updated env sample

* Updated docker file to copy migrations

* Might undo later

* Updated env sample and docker compose

* Updated cargo dependencies

* Saving changes

* Workflow fix

* Will need to take another look at the dependencies again

* Added code

* Test is not finished yet

* Warning fix for now

* Updated workflow to setup database

* Workflow fix

* Workflow fix attempt

* Warning fix

* Warning fix

* Updated workflow

* Added test files

Might change later

* Added more crates

* Added more test code

Not finished

* Formatting

* Close, but far

* I believe it is now working

* Formatting

* Added more crates

* Code cleanup

* Code reformatting

* Code cleanup

* Crate cleanup

* Cleanup
2025-04-22 20:08:24 -04:00
KD 0ba2d4e8b9 Updated workflow (#119) 2025-04-20 14:05:03 -04:00
KD 4fdd660988 Updated packages (#118)
* Updated tokio

* Updated serde packages

* Updated icarus related dependencies
2025-04-20 14:01:33 -04:00
KD b6fe9a070c Added Docker file (#117)
* Added Docker file

* Added docker files

* Updated docker

Added icarus_auth and second db

* Cleanup

* Docker changes

* Saving changes

Still running into issues with icarus_auth starting in docker. Must be related to credentials or url

* Changes to docker

* Got auth_api working

* Cleanup

* Docker cleanup

* Updated gitignore

* Added README

* Updated gitignore

* Added directory to ignore to dockerignore

* File name change
2025-04-13 14:53:05 -04:00
KD 6e4eeafe13 Added .env.sample file (#116)
* Added .env.sample file

* Adedd variables

* Using default info
2025-04-12 15:56:10 -04:00
KD 6f320acb7c V0.2 web api (#113)
* Adding dependencies

* Added web api code

* Some changes

* Code formatting

* Added dependencies

*   *

* Added workflow

* Changed branch to trigger workflows

* Added ssh key import

* Warning fixes
2025-03-30 15:00:07 -04:00
KD 9dd7ffa2b6 Updated gitignore 2025-03-28 19:04:35 -04:00
KD fbcbd41809 Added initial rust code 2025-03-28 19:03:51 -04:00
KD 01d315e25e Updating gitignore 2025-03-28 19:03:03 -04:00
KD caf9f08d8a Forgot to remove this files 2025-03-28 19:02:31 -04:00
KD 7227eb9420 Removing dotnet files 2025-03-28 19:00:05 -04:00
113 changed files with 7391 additions and 5256 deletions
-13
View File
@@ -1,13 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.7",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+21
View File
@@ -0,0 +1,21 @@
# Ignore build artifacts
target/
pkg/
# Ignore git directory
.git/
.github/
# Ignore environment files (configure via docker-compose instead)
.env*
# Ignore IDE/editor specific files
.idea/
.vscode/
# Ignore OS specific files
*.DS_Store
# Add any other files/directories you don't need in the image
# e.g., logs/, tmp/
+7
View File
@@ -0,0 +1,7 @@
SECRET_MAIN_KEY=refero34o8rfhfjn983thf39fhc943rf923n3h
ROOT_DIRECTORY=/home/icarus/mydata
POSTGRES_MAIN_USER=icarus
POSTGRES_MAIN_PASSWORD=password
POSTGRES_MAIN_DB=icarus_db
POSTGRES_MAIN_HOST=main_db
DATABASE_URL=postgres://${POSTGRES_MAIN_USER}:${POSTGRES_MAIN_PASSWORD}@${POSTGRES_MAIN_HOST}:5432/${POSTGRES_MAIN_DB}
+7
View File
@@ -0,0 +1,7 @@
SECRET_MAIN_KEY=refero34o8rfhfjn983thf39fhc943rf923n3h
ROOT_DIRECTORY=/home/icarus/mydata
POSTGRES_MAIN_USER=icarus
POSTGRES_MAIN_PASSWORD=password
POSTGRES_MAIN_DB=icarus_db
POSTGRES_MAIN_HOST=localhost
DATABASE_URL=postgres://${POSTGRES_MAIN_USER}:${POSTGRES_MAIN_PASSWORD}@${POSTGRES_MAIN_HOST}:5432/${POSTGRES_MAIN_DB}
-25
View File
@@ -1,25 +0,0 @@
name: .NET
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v2
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
+50
View File
@@ -0,0 +1,50 @@
name: Release Tagging
on:
push:
branches: [ "v0.2" ]
jobs:
release:
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Important for git describe --tags
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: 1.88.0
components: cargo
- name: Extract Version from Cargo.toml
id: version
run: |
VERSION=$(grep '^version = "' Cargo.toml | sed -E 's/version = "([^"]+)"/\1/')
PROJECT_COMMIT_HASH=$(git rev-parse HEAD | cut -c 1-10)
BRANCH_REF="${GITHUB_REF}"
BRANCH_NAME=$(echo "$BRANCH_REF" | cut -d '/' -f 3)
PROJECT_TAG_RELEASE="v$VERSION-$BRANCH_NAME-$PROJECT_COMMIT_HASH"
echo "::set-output name=project_tag_release::$PROJECT_TAG_RELEASE-961"
echo "Version: $VERSION"
echo "Hash: $PROJECT_COMMIT_HASH"
echo "Branch: $BRANCH_NAME"
echo "Tag Release: $PROJECT_TAG_RELEASE"
- name: Print version
id: print_version
run: |
echo "Printing version"
echo "Version: ${{ steps.version.outputs.project_tag_release }}"
- name: Create GitHub Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.version.outputs.project_tag_release }}
release_name: Release ${{ steps.version.outputs.project_tag_release }}
body: |
Release of version ${{ steps.version.outputs.project_tag_release }}
+77
View File
@@ -0,0 +1,77 @@
name: Rust CI
on:
push:
branches: [ "v0.2" ]
pull_request:
branches: [ "v0.2" ]
jobs:
build:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:17.5
env:
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Install Rust 1.88.0
uses: actions-rs/toolchain@v1
with:
toolchain: 1.88.0
components: clippy, rustfmt
override: true
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Install libpq
run: sudo apt-get install -y libpq-dev
- name: Setup test database
env:
DATABASE_URL: "postgres://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }}"
run: |
# Wait for PostgreSQL to be ready
for i in {1..10}; do
pg_isready -U ${{ secrets.POSTGRES_USER }} -d ${{ secrets.POSTGRES_DB }} && break
sleep 2
done
# Run database migrations (if you use sqlx migrations)
cargo install sqlx-cli --no-default-features --features native-tls,postgres
sqlx migrate run --database-url $DATABASE_URL
- name: Build
run: |
mkdir -p ~/.ssh
echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/gitea_deploy_key
chmod 600 ~/.ssh/gitea_deploy_key
ssh-keyscan ${{ vars.MYHOST }} >> ~/.ssh/known_hosts
eval $(ssh-agent -s)
ssh-add -v ~/.ssh/gitea_deploy_key
cargo build --verbose --release
- name: Run tests
env:
ROOT_DIRECTORY: "/tmp"
DATABASE_URL: "postgres://${{ secrets.POSTGRES_USER }}:${{ secrets.POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.POSTGRES_DB }}"
run: cargo test --verbose
- name: Run clippy
run: cargo clippy -- -D warnings
- name: Run rustfmt
run: cargo fmt --all -- --check
+8 -25
View File
@@ -1,26 +1,9 @@
################################################################################
# This .gitignore file was automatically created by Microsoft(R) Visual Studio.
################################################################################
# Added by cargo
/.vs/Icarus
.vscode/*
/bin/*
/bin/Debug/netcoreapp2.2
/Migrations
/obj
/obj/*
/Icarus.txt
/appsettings.json
/appsettings.Development.json
appsettings.Development.json
appsettings.json
Icarus/appsettings.Development.json
Icarus/appsettings.json
Icarus/bin
Icarus/obj
Models/bin
Models/obj
Migrations
Icarus.txt
Storage
.DS_Store
/target
.env
.env.docker
.env.local
.DS_STORE
Storage/
+8
View File
@@ -0,0 +1,8 @@
{
"dev": {
"migrations": "./migrations"
},
"test": {
"migrations": "./test_migrations"
}
}
-67
View File
@@ -1,67 +0,0 @@
# Contributing
When contributing to this repository, please first discuss the change you wish to make via issue,
email, or any other method with the owners of this repository before making a change.
Please note the project has a code of conduct, please follow it in all your interactions with the project.
## Code of Conduct
### Pledge
In the interest of fostering an open and welcoming environment, I as
aintainers pledge to making participation in this project and
this community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
### Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
### Maintainers Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## How You Can Contribute
The project is always looking for people to help in anyway they can. The following would be greatly appreciated:
* Developers
* Graphic Designers
* Documenters
### Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
Generated
+2778
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "icarus"
version = "0.1.96"
edition = "2024"
rust-version = "1.88"
[dependencies]
axum = { version = "0.8.4", features = ["multipart"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = { version = "1.0.140" }
tower = { version = "0.5.2", features = ["full"] }
tokio = { version = "1.45.1", features = ["full"] }
tokio-util = { version = "0.7.15", features = ["io"] }
tower-http = { version = "0.6.6", features = ["timeout"] }
tracing-subscriber = "0.3.19"
futures = { version = "0.3.31" }
mime_guess = { version = "2.0.5" }
uuid = { version = "1.17.0", features = ["v4", "serde"] }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-native-tls", "time", "uuid"] }
time = { version = "0.3.41", features = ["formatting", "macros", "parsing", "serde"] }
icarus_meta = { git = "ssh://git@git.kundeng.us/phoenix/icarus_meta.git", tag = "v0.3.0-devel-f4b71de969-680" }
icarus_models = { git = "ssh://git@git.kundeng.us/phoenix/icarus_models.git", tag = "v0.5.1-devel-1c5de9dc26-111" }
icarus_envy = { git = "ssh://git@git.kundeng.us/phoenix/icarus_envy.git", tag = "v0.3.0-devel-d73fba9899-006" }
[dev-dependencies]
common-multipart-rfc7578 = { version = "0.7.0" }
url = { version = "2.5.4" }
tempfile = { version = "3.20.0" }
+70
View File
@@ -0,0 +1,70 @@
# Stage 1: Build the application
FROM rust:1.88 as builder
# Set the working directory inside the container
WORKDIR /usr/src/app
# Install build dependencies if needed (e.g., git for cloning)
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl3 \
ca-certificates \
openssh-client git \
&& rm -rf /var/lib/apt/lists/*
# Create .ssh/ directory for internal dependencies
RUN mkdir -p -m 0700 ~/.ssh && \
echo "Host git.kundeng.us" >> ~/.ssh/config && \
echo " User git" >> ~/.ssh/config && \
chmod 600 ~/.ssh/config
# << --- ADD HOST KEY HERE --- >>
RUN ssh-keyscan git.kundeng.us >> ~/.ssh/known_hosts
# Copy Cargo manifests
COPY Cargo.toml Cargo.lock ./
# Build *only* dependencies to leverage Docker cache
# This dummy build caches dependencies as a separate layer
RUN --mount=type=ssh mkdir src && \
echo "fn main() {println!(\"if you see this, the build broke\")}" > src/main.rs && \
cargo build --release --quiet && \
rm -rf src target/release/deps/icarus* # Clean up dummy build artifacts (replace icarus)
# Copy the actual source code
COPY src ./src
# If you have other directories like `templates` or `static`, copy them too
COPY .env ./.env
COPY migrations ./migrations
# << --- SSH MOUNT ADDED HERE --- >>
# Build *only* dependencies to leverage Docker cache
# This dummy build caches dependencies as a separate layer
# Mount the SSH agent socket for this command
RUN --mount=type=ssh \
cargo build --release --quiet
# Stage 2: Create the final, smaller runtime image
# Use a minimal base image like debian-slim or even distroless for security/size
FROM ubuntu:24.04
# Install runtime dependencies if needed (e.g., SSL certificates)
RUN apt-get update && apt-get install -y ca-certificates libssl-dev libssl3 && rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /usr/local/bin
# Copy the compiled binary from the builder stage
# Replace 'icarus' with the actual name of your binary (usually the crate name)
COPY --from=builder /usr/src/app/target/release/icarus .
# Copy other necessary files like .env (if used for runtime config) or static assets
# It's generally better to configure via environment variables in Docker though
COPY --from=builder /usr/src/app/.env .
COPY --from=builder /usr/src/app/migrations ./migrations
# Expose the port your Axum app listens on (e.g., 3000 or 8000)
EXPOSE 3000
# Set the command to run your application
# Ensure this matches the binary name copied above
CMD ["./icarus"]
-22
View File
@@ -1,22 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Icarus", "Icarus\Icarus.csproj", "{EC7ADFAA-9CCA-40E4-B035-DC2ADF117CB4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{EC7ADFAA-9CCA-40E4-B035-DC2ADF117CB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EC7ADFAA-9CCA-40E4-B035-DC2ADF117CB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EC7ADFAA-9CCA-40E4-B035-DC2ADF117CB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EC7ADFAA-9CCA-40E4-B035-DC2ADF117CB4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
-19
View File
@@ -1,19 +0,0 @@
# Top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.cs]
# Use 'var' when possible
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Use expression body for methods
csharp_style_expression_bodied_methods = true:silent
@@ -1,26 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Icarus.Authorization;
namespace Icarus.Authorization.Handlers;
public class HasScopeHandler : AuthorizationHandler<HasScopeRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HasScopeRequirement requirement)
{
if (!context.User.HasClaim(c => c.Type == "scope" && c.Issuer == requirement.Issuer))
{
return Task.CompletedTask;
}
var scopes = context.User.FindFirst(c =>
c.Type == "scope" && c.Issuer == requirement.Issuer)!.Value.Split(' ');
if (scopes.Any(s => s == requirement.Scope))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
@@ -1,18 +0,0 @@
using System;
using Microsoft.AspNetCore.Authorization;
namespace Icarus.Authorization;
public class HasScopeRequirement : IAuthorizationRequirement
{
public string Issuer { get; }
public string Scope { get; }
public HasScopeRequirement(string scope, string issuer)
{
Scope = scope ?? throw new ArgumentNullException(nameof(scope));
Issuer = issuer ?? throw new ArgumentNullException(nameof(issuer));
}
}
-78
View File
@@ -1,78 +0,0 @@
using System;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.OpenSsl;
namespace Icarus.Certs;
public class SigningIssuerCertificate : IDisposable
{
private readonly System.Security.Cryptography.RSACryptoServiceProvider _rsa;
public SigningIssuerCertificate()
{
_rsa = new System.Security.Cryptography.RSACryptoServiceProvider();
}
public RsaSecurityKey GetIssuerSigningKey(string publicKeyPath)
{
var file = publicKeyPath;
var publicKey = System.IO.File.ReadAllText(file);
using (var reader = System.IO.File.OpenText(file))
{
var pem = new PemReader(reader);
var o = (RsaKeyParameters)pem.ReadObject();
var parameters = new System.Security.Cryptography.RSAParameters();
parameters.Modulus = o.Modulus.ToByteArray();
parameters.Exponent = o.Exponent.ToByteArray();
_rsa.ImportParameters(parameters);
}
return new RsaSecurityKey(_rsa);
}
public void Dispose()
{
_rsa?.Dispose();
}
}
public class SigningAudienceCertificate : IDisposable
{
private readonly System.Security.Cryptography.RSACryptoServiceProvider _rsa;
public SigningAudienceCertificate()
{
_rsa = new System.Security.Cryptography.RSACryptoServiceProvider();
}
public SigningCredentials GetAudienceSigningKey(string keyPath)
{
var file = keyPath;
var publicKey = System.IO.File.ReadAllText(file);
using (var reader = System.IO.File.OpenText(file))
{
var pem = new PemReader(reader);
var o = (RsaKeyParameters)pem.ReadObject();
var parameters = new System.Security.Cryptography.RSAParameters();
parameters.Modulus = o.Modulus.ToByteArray();
parameters.Exponent = o.Exponent.ToByteArray();
_rsa.ImportParameters(parameters);
}
return new SigningCredentials(
key: new RsaSecurityKey(_rsa),
algorithm: SecurityAlgorithms.RsaSha256);
}
public void Dispose()
{
_rsa?.Dispose();
}
}
-146
View File
@@ -1,146 +0,0 @@
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.Managers;
public class AlbumManager : BaseManager
{
#region Fields
private AlbumContext? _albumContext;
#endregion
#region Properties
#endregion
#region Constructors
public AlbumManager(IConfiguration config)
{
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
_albumContext = new AlbumContext(_connectionString!);
}
#endregion
#region Methods
public void SaveAlbumToDatabase(ref Song song)
{
_logger.Info("Starting process to save the album record of the song to the database");
var album = new Album();
album.Title = song.AlbumTitle;
album.AlbumArtist = song.Artist;
album.Year = song.Year!.Value;
var albumTitle = song.AlbumTitle;
var albumArtist = song.Artist;
var albumRetrieved = _albumContext!.Albums!.FirstOrDefault(alb => alb.Title!.Equals(albumTitle) && alb.AlbumArtist!.Equals(albumArtist));
if (albumRetrieved == null)
{
album.SongCount = 1;
_albumContext.Add(album);
_albumContext.SaveChanges();
Console.WriteLine($"Album Id {album.Id}");
}
else
{
album.Id = albumRetrieved.Id;
}
song.AlbumId = album.Id;
}
public void DeleteAlbumFromDatabase(Song song)
{
var album = _albumContext!.Albums!.FirstOrDefault(alb => alb.Title!.Equals(song.AlbumTitle));
if (album == null)
{
_logger.Info("Cannot delete the album record because it does not exist");
return;
}
DeleteAlbumFromDb(album);
}
public Album UpdateAlbumInDatabase(Song oldSong, Song newSong)
{
var albumRecord = _albumContext!.Albums!.FirstOrDefault(alb => alb.Title!.Equals(oldSong.AlbumTitle));
var oldAlbumTitle = oldSong.AlbumTitle;
var oldAlbumArtist = oldSong.Artist;
var newAlbumTitle = newSong.AlbumTitle;
var newAlbumArtist = newSong.Artist;
var info = string.Empty;
if (string.IsNullOrEmpty(newAlbumArtist))
newAlbumArtist = oldAlbumArtist;
if (string.IsNullOrEmpty(newAlbumTitle))
newAlbumTitle = oldAlbumTitle;
if ((string.IsNullOrEmpty(newAlbumTitle) && string.IsNullOrEmpty(newAlbumArtist) ||
oldAlbumTitle!.Equals(newAlbumTitle) && oldAlbumArtist!.Equals(newAlbumArtist)))
{
_logger.Info("No change to the song's album");
return albumRecord!;
}
info = "Change to the song's album";
_logger.Info(info);
var existingAlbumRecord = _albumContext.Albums!.FirstOrDefault(alb => alb.Title!.Equals(oldSong.AlbumTitle));
if (existingAlbumRecord == null)
{
_logger.Info("Creating new album record");
var newAlbumRecord = new Album
{
Title = newAlbumTitle,
AlbumArtist = newAlbumArtist,
Year = newSong.Year!.Value
};
_albumContext.Add(newAlbumRecord);
_albumContext.SaveChanges();
return newAlbumRecord;
}
else
{
_logger.Info("Updating existing album record");
existingAlbumRecord = _albumContext.Albums!.FirstOrDefault(alb => alb.Title!.Equals(newSong.AlbumTitle));
existingAlbumRecord!.AlbumArtist = newAlbumArtist;
_albumContext.Update(existingAlbumRecord);
_albumContext.SaveChanges();
return existingAlbumRecord;
}
}
private void DeleteAlbumFromDb(Album album)
{
if (SongsInAlbum(album) <= 1)
{
_albumContext!.Remove(album);
_albumContext.SaveChanges();
}
}
private int SongsInAlbum(Album album)
{
var sngContext = new SongContext(_connectionString!);
var songs = sngContext!.Songs!.Where(sng => sng.AlbumId == album.Id).ToList();
return songs.Count;
}
#endregion
}
@@ -1,128 +0,0 @@
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.Managers;
public class ArtistManager : BaseManager
{
#region Fields
private ArtistContext? _artistContext;
#endregion
#region Properties
#endregion
#region Constructors
public ArtistManager(IConfiguration config)
{
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
_artistContext = new ArtistContext(_connectionString!);
}
#endregion
#region Methods
public void SaveArtistToDatabase(ref Song song)
{
_logger.Info("Starting process to save the artist record of the song to the database");
var artist = new Artist
{
Name = song.Artist,
SongCount = 1
};
var artistRetrieved = _artistContext!.Artists.FirstOrDefault(art => art.Name!.Equals(artist.Name));
if (artistRetrieved == null)
{
artist.SongCount = 1;
_artistContext.Add(artist);
_artistContext.SaveChanges();
}
else
{
artist.Id = artistRetrieved.Id;
}
song.ArtistId = artist.Id;
}
public Artist UpdateArtistInDatabase(Song oldSongRecord, Song newSongRecord)
{
var oldArtistRecord = _artistContext!.Artists.FirstOrDefault(art => art.Name!.Equals(oldSongRecord.AlbumTitle));
var oldArtistName = oldArtistRecord!.Name;
var newArtistName = newSongRecord.Artist;
if (string.IsNullOrEmpty(newArtistName) || oldArtistName!.Equals(newArtistName))
{
_logger.Info("No change to the song's Artist");
return oldArtistRecord;
}
_logger.Info("Change to the song's record found");
if (oldArtistRecord.SongCount <= 1)
{
_logger.Info("Deleting artist record that no longer has any songs");
_artistContext.Remove(oldArtistRecord);
_artistContext.SaveChanges();
}
if (!(_artistContext.Artists.FirstOrDefault(art => art.Name!.Equals(oldSongRecord.AlbumTitle)) != null))
{
_logger.Info("Creating new artist record");
var newArtistRecord = new Artist
{
Name = newArtistName
};
_artistContext.Add(newArtistRecord);
_artistContext.SaveChanges();
return newArtistRecord;
}
else
{
_logger.Info("Updating existing artist record");
var existingArtistRecord = _artistContext.Artists.FirstOrDefault(art => art.Name!.Equals(newSongRecord.AlbumTitle));
_artistContext.Update(existingArtistRecord!);
_artistContext.SaveChanges();
return existingArtistRecord!;
}
}
public void DeleteArtistFromDatabase(Song song)
{
if (!(_artistContext!.Artists.FirstOrDefault(art => art.Name!.Equals(song.Artist)) != null))
{
_logger.Info("Cannot delete the artist record because it does not exist");
return;
}
var artist = _artistContext.Artists.FirstOrDefault(art => art.Name!.Equals(song.Artist));
if (SongsOfArtist(artist!) <= 1)
{
_artistContext!.Remove(artist!);
_artistContext.SaveChanges();
}
}
private int SongsOfArtist(Artist artist)
{
var sngContext = new SongContext(_connectionString!);
var songs = sngContext.Songs!.Where(sng => sng.ArtistId == artist.Id).ToList();
return songs.Count;
}
#endregion
}
@@ -1,13 +0,0 @@
using Microsoft.Extensions.Configuration;
using NLog;
namespace Icarus.Controllers.Managers;
public class BaseManager
{
#region Fields
protected static Logger _logger = NLog.Web.NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
protected IConfiguration? _config;
protected string? _connectionString;
#endregion
}
@@ -1,182 +0,0 @@
using Icarus.Constants;
using Icarus.Controllers.Utilities;
using Icarus.Database.Contexts;
using Icarus.Models;
namespace Icarus.Controllers.Managers;
public class CoverArtManager : BaseManager
{
#region Fields
private string? _rootCoverArtPath;
private CoverArtContext? _coverArtContext;
private byte[]? _stockCoverArt = null;
private const string? _filename = "CoverArt.png";
#endregion
#region Constructors
public CoverArtManager(IConfiguration config)
{
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
_rootCoverArtPath = _config.GetValue<string>("CoverArtPath");
Initialize();
}
#endregion
#region Methods
public void SaveCoverArtToDatabase(ref Song song, ref CoverArt coverArt)
{
_logger.Info("Saving cover art record to the database");
_coverArtContext!.Add(coverArt);
_coverArtContext.SaveChanges();
song.CoverArtId = coverArt.Id;
}
public void DeleteCoverArtFromDatabase(CoverArt coverArt)
{
_logger.Info("Attempting to delete cover art from the database");
_coverArtContext!.Attach(coverArt);
_coverArtContext.Remove(coverArt);
_coverArtContext.SaveChanges();
}
public void DeleteCoverArt(CoverArt coverArt)
{
try
{
var stockCoverArtPath = _rootCoverArtPath + _filename;
if (!string.Equals(stockCoverArtPath, coverArt.ImagePath(),
StringComparison.CurrentCultureIgnoreCase))
{
_logger.Info("Song does not contain the stock cover art");
File.Delete(coverArt.ImagePath());
_logger.Info("Cover art deleted from the filesystem");
}
else
{
_logger.Info("Song contains the stock cover art");
_logger.Info("Will not delete from from the filesystem");
}
}
catch (Exception ex)
{
var msg = ex.Message;
_logger.Error(msg, "An error occurred");
}
}
public CoverArt? SaveCoverArt(Song song)
{
try
{
var dirMgr = new DirectoryManager(_rootCoverArtPath!);
var defaultExtension = ".png";
dirMgr.CreateDirectory(song);
var coverArt = new CoverArt
{
SongTitle = song.Title!
};
var segment = coverArt.GenerateFilename(0);
coverArt.Directory = dirMgr.SongDirectory;
coverArt.Filename = segment + defaultExtension;
var metaData = new MetadataRetriever();
var imgBytes = metaData.RetrieveCoverArtBytes(song);
if (imgBytes != null)
{
_logger.Info("Saving cover art to the filesystem");
File.WriteAllBytes(coverArt.ImagePath(), imgBytes);
}
else
{
_logger.Info("Song has no cover art, applying stock cover art");
var coverArtFilePath = _rootCoverArtPath + $"{segment}{defaultExtension}";
coverArt.Directory = DirectoryPaths.CoverArtDirectory;
coverArt.Filename = DirectoryPaths.CoverArtFilename;
metaData.UpdateCoverArt(song, coverArt);
coverArt.Directory = this._rootCoverArtPath;
coverArt.Filename = $"{segment}{defaultExtension}";
File.WriteAllBytes(coverArt.ImagePath(), _stockCoverArt!);
}
coverArt.Type = metaData.CoverArtFileExtensionType(coverArt);
if (string.IsNullOrEmpty(coverArt.Type))
{
Console.WriteLine("File type is empty");
Console.WriteLine($"Directory: {coverArt.Directory}");
Console.WriteLine($"Filename: {coverArt.Filename}");
}
return coverArt;
}
catch (Exception ex)
{
var msg = ex.Message;
_logger.Error(msg, "An error occurred");
}
return null;
}
public CoverArt SaveCoverArt(IFormFile data, Song song)
{
var cover = new CoverArt { SongTitle = song.Title };
try
{
MetadataRetriever metaData = new MetadataRetriever();
var dirMgr = new DirectoryManager(_rootCoverArtPath!);
cover.Type = metaData.FileExtensionType(data);
var defaultExtension = "." + cover.Type;
dirMgr.CreateDirectory(song);
var segment = cover.GenerateFilename(0);
var imagePath = dirMgr.SongDirectory + segment + defaultExtension;
cover.Directory = dirMgr.SongDirectory;
cover.Filename = segment + defaultExtension;
using (var fileStream = new FileStream(cover.ImagePath(), FileMode.Create))
{
data.CopyTo(fileStream);
}
}
catch (Exception ex)
{
_logger.Error(ex.Message, "An error occurred");
}
return cover;
}
public CoverArt GetCoverArt(Song song)
{
var title = song.Title;
var cov = _coverArtContext!.CoverArtImages!.FirstOrDefault(cov => cov.SongTitle!.Equals(title));
return cov!;
}
private void Initialize()
{
_coverArtContext = new CoverArtContext(_connectionString!);
var path = DirectoryPaths.CoverArtDirectory + DirectoryPaths.CoverArtFilename;
if (System.IO.File.Exists(path))
_stockCoverArt = File.ReadAllBytes(path);
if (!File.Exists(_rootCoverArtPath + _filename))
{
File.WriteAllBytes(_rootCoverArtPath + _filename,
_stockCoverArt!);
Console.WriteLine("Copied Stock Cover Art");
}
}
#endregion
}
@@ -1,207 +0,0 @@
using Icarus.Models;
using Icarus.Types;
namespace Icarus.Controllers.Managers;
// NOTE: Do not use metadata for the song's metadata
public class DirectoryManager : BaseManager
{
#region Fields
private Song? _song;
private string? _rootSongDirectory;
private string? _songDirectory;
#endregion
#region Properties
public string? SongDirectory
{
get => _songDirectory;
set => _songDirectory = value;
}
#endregion
#region Constructors
public DirectoryManager(IConfiguration config, Song song)
{
_config = config;
_song = song;
Initialize();
}
public DirectoryManager(IConfiguration config)
{
_config = config;
Initialize();
}
public DirectoryManager(string rootDirectory)
{
_rootSongDirectory = rootDirectory;
}
#endregion
#region Methods
// Does not include extension
public static string GenerateFilename(int length)
{
string chars = Constants.DirectoryPaths.FILENAME_CHARACTERS;
var random = new Random();
return new string(Enumerable.Repeat(chars, length).Select(s =>
s[random.Next(s.Length)]).ToArray());
}
public static string GenerateDownloadFilename(int length, string extension, string title, bool? randomize)
{
if (randomize.HasValue && randomize.Value)
{
return GenerateFilename(length) + extension;
}
else
{
return title + extension;
}
}
public void CreateDirectory()
{
this.CreateDirectory(_song!);
}
public void CreateDirectory(Song song)
{
this._song = song;
try
{
_songDirectory = AlbumDirectory();
if (!Directory.Exists(_songDirectory))
{
Directory.CreateDirectory(_songDirectory);
Console.WriteLine($"The directory has been created");
}
Console.WriteLine($"The song will be saved in the following" +
$" directory: {_songDirectory}");
}
catch (Exception ex)
{
var msg = ex.Message;
_logger.Error(msg, "An error occurred");
}
}
public int DeleteEmptyDirectories(string? directory, int level)
{
var deleted = 0;
try
{
var curDir = directory;
for (var i = 0; i < level; i++)
{
if (!System.IO.Directory.Exists(curDir))
{
continue;
}
if (this.IsDirectoryEmpty(curDir))
{
System.IO.Directory.Delete(curDir);
deleted++;
}
var parentDirectory = System.IO.Directory.GetParent(curDir);
curDir = parentDirectory!.ToString();
}
}
catch (Exception ex)
{
var exMsg = ex.Message;
Console.WriteLine($"An error occurred {exMsg}");
}
return deleted;
}
public void DeleteEmptyDirectories(Song song)
{
try
{
var albumDirectory = AlbumDirectory(song);
var artistDirectory = ArtistDirectory(song);
if (Directory.Exists(albumDirectory) && IsDirectoryEmpty(albumDirectory))
{
Directory.Delete(albumDirectory);
_logger.Info("Album directory deleted");
}
if (Directory.Exists(artistDirectory) && IsDirectoryEmpty(artistDirectory))
{
Directory.Delete(artistDirectory);
_logger.Info("Artist directory deleted");
}
}
catch (Exception ex)
{
var msg = ex.Message;
_logger.Error(msg, "An error occurred");
}
}
private void Initialize(DirectoryType dirTypes = DirectoryType.Music)
{
switch (dirTypes)
{
case DirectoryType.Music:
_rootSongDirectory = _config!.GetValue<string>("RootMusicPath")!;
break;
case DirectoryType.CoverArt:
_rootSongDirectory = _config!.GetValue<string>("CoverArtPath")!;
break;
}
}
private bool IsDirectoryEmpty(string path) =>
!(Directory.EnumerateFileSystemEntries(path).Any());
private string AlbumDirectory()
{
return AlbumDirectory(_song!);
}
private string AlbumDirectory(Song song)
{
var directory = ArtistDirectory(song);
var segment = SerializeValue(song.AlbumTitle!);
directory += $@"{segment}/";
Console.WriteLine($"Album directory {directory}");
return directory;
}
private string ArtistDirectory(Song song)
{
var directory = _rootSongDirectory;
var segment = SerializeValue(song.Artist!);
directory += $@"{segment}/";
Console.WriteLine($"Artist directory {directory}");
return directory;
}
private string SerializeValue(string value)
{
const int length = 15;
const string chars = "ABCDEF0123456789";
var random = new Random();
var output = new string(Enumerable.Repeat(chars, length).Select(s =>
s[random.Next(s.Length)]).ToArray());
return output;
}
#endregion
}
-128
View File
@@ -1,128 +0,0 @@
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.Managers;
public class GenreManager : BaseManager
{
#region Fields
private GenreContext? _genreContext;
#endregion
#region Properties
#endregion
#region Constructors
public GenreManager(IConfiguration config)
{
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
_genreContext = new GenreContext(_connectionString!);
}
#endregion
#region Methods
public void SaveGenreToDatabase(ref Song song)
{
_logger.Info("Starting process to save the genre record of the song to the database");
var genre = new Genre
{
GenreName = song.Genre,
SongCount = 1
};
var genreName = song.Genre;
var genreRetrieved = _genreContext!.Genres!.FirstOrDefault(gnr => gnr.GenreName!.Equals(genreName));
if (genreRetrieved == null)
{
_genreContext.Add(genre);
_genreContext.SaveChanges();
}
else
{
genre.Id = genreRetrieved.Id;
}
song.GenreId = genre.Id;
}
public Genre UpdateGenreInDatabase(Song oldSongRecord, Song newSongRecord)
{
var oldGenreRecord = _genreContext!.Genres!.FirstOrDefault(gnr => gnr.GenreName!.Equals(oldSongRecord.Genre));
var oldGenreName = oldGenreRecord!.GenreName;
var newGenreName = newSongRecord.Genre;
if (string.IsNullOrEmpty(newGenreName) || oldGenreName!.Equals(newGenreName))
{
_logger.Info("No change to the song's Genre");
return oldGenreRecord;
}
_logger.Info("Change to the song's genre found");
if (oldGenreRecord.SongCount <= 1)
{
_logger.Info("Deleting genre record");
_genreContext.Remove(oldGenreRecord);
_genreContext.SaveChanges();
}
if (!(_genreContext.Genres!.FirstOrDefault(gnr => gnr.GenreName!.Equals(oldSongRecord.Genre)) != null))
{
_logger.Info("Creating new genre record");
var newGenreRecord = new Genre
{
GenreName = newGenreName
};
_genreContext.Add(newGenreRecord);
_genreContext.SaveChanges();
return newGenreRecord;
}
else
{
_logger.Info("Updating existing genre record");
var existingGenreRecord = _genreContext.Genres!.FirstOrDefault(gnr => gnr.GenreName!.Equals(oldGenreRecord.GenreName));
_genreContext.Update(existingGenreRecord!);
_genreContext.SaveChanges();
return existingGenreRecord!;
}
}
public void DeleteGenreFromDatabase(Song song)
{
if (!(_genreContext!.Genres!.FirstOrDefault(gnr => gnr.GenreName!.Equals(song.Genre)) != null))
{
_logger.Info("Cannot delete the genre record because it does not exist");
return;
}
var genre = _genreContext.Genres!.FirstOrDefault(gnr => gnr.GenreName!.Equals(song.Genre));
if (SongsInGenre(genre!) <= 1)
{
_genreContext.Remove(genre!);
_genreContext.SaveChanges();
}
}
private int SongsInGenre(Genre genre)
{
var sngContext = new SongContext(_connectionString!);
var songs = sngContext.Songs!.Where(sng => sng.GenreId == genre.Id).ToList();
return songs.Count;
}
#endregion
}
-479
View File
@@ -1,479 +0,0 @@
using NLog;
using Icarus.Controllers.Utilities;
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.Managers;
public class SongManager : BaseManager
{
#region Fields
private string? _tempDirectoryRoot;
private string? _archiveDirectoryRoot;
private string? _compressedSongFilename;
private string? _message;
private SongContext? _songContext;
#endregion
#region Properties
public string? ArchiveDirectoryRoot
{
get => _archiveDirectoryRoot;
set => _archiveDirectoryRoot = value;
}
public string? CompressedSongFilename
{
get => _compressedSongFilename;
set => _compressedSongFilename = value;
}
public string? Message
{
get => _message;
set => _message = value;
}
#endregion
#region Constructors
public SongManager(IConfiguration config)
{
_config = config;
Initialize();
}
public SongManager(IConfiguration config, string tempDirectoryRoot)
{
_config = config;
_tempDirectoryRoot = tempDirectoryRoot;
Initialize();
}
#endregion
#region Methods
public SongResult UpdateSong(Song song)
{
var result = new SongResult();
if (!DoesSongExist(song))
{
result.SongTitle = song.Title;
result.Message = "Song does not exist";
return result;
}
try
{
var oldSongRecord = _songContext!.RetrieveRecord(song);
song.Filename = oldSongRecord.Filename;
song.SongDirectory = oldSongRecord.SongDirectory;
MetadataRetriever updateMetadata = new MetadataRetriever();
updateMetadata.UpdateMetadata(song, oldSongRecord);
var updatedSong = updateMetadata.UpdatedSongRecord;
var albMgr = new AlbumManager(_config!);
var gnrMgr = new GenreManager(_config!);
var artMgr = new ArtistManager(_config!);
var updatedAlbum = albMgr.UpdateAlbumInDatabase(oldSongRecord, updatedSong!);
oldSongRecord.AlbumId = updatedAlbum.Id;
var updatedArtist = artMgr.UpdateArtistInDatabase(oldSongRecord, updatedSong!);
oldSongRecord.ArtistId = updatedArtist.Id;
var updatedGenre = gnrMgr.UpdateGenreInDatabase(oldSongRecord, updatedSong!);
oldSongRecord.GenreId = updatedGenre.Id;
UpdateSongInDatabase(ref oldSongRecord, ref updatedSong!, ref result);
DeleteEmptyDirectories(ref oldSongRecord, ref updatedSong);
}
catch (Exception ex)
{
var msg = ex.Message;
_logger.Error(msg, "An error occurred");
result.Message = $"An error occurred: {msg}";
result.SongTitle = song.Title;
}
return result;
}
public bool DeleteSongFromFileSystem(Song songMetaData)
{
bool successful = false;
try
{
var songPath = songMetaData.SongPath();
System.IO.File.Delete(songPath);
successful = !System.IO.File.Exists(songPath);
if (successful)
{
Console.WriteLine("Song successfully deleted");
}
DirectoryManager dirMgr = new DirectoryManager(_config!, songMetaData);
var deletedAmount = dirMgr.DeleteEmptyDirectories(songMetaData.SongDirectory, 1);
if (deletedAmount > 0)
{
Console.WriteLine($"{deletedAmount} directories deleted");
}
}
catch (Exception ex)
{
var exMsg = ex.Message;
}
return successful;
}
public bool DoesSongExist(Song song)
{
if (!_songContext!.DoesRecordExist(song))
{
return false;
}
return true;
}
public void DeleteSong(Song song)
{
try
{
if (DeleteSongFromFilesystem(song, true))
{
_logger.Error("Failed to delete the song");
throw new Exception("Failed to delete the song");
}
_logger.Info("Song deleted from the filesystem");
var coverMgr = new CoverArtManager(_config!);
var coverArt = coverMgr.GetCoverArt(song);
coverMgr.DeleteCoverArt(coverArt);
DeleteSongFromDatabase(song);
coverMgr.DeleteCoverArtFromDatabase(coverArt);
var accessLevelContext = new AccessLevelContext(_config!.GetConnectionString("DefaultConnection")!);
var accessLevel = accessLevelContext.AccessLevels!.FirstOrDefault(al => al.SongId == song.Id);
accessLevelContext.AccessLevels!.Remove(accessLevel!);
accessLevelContext.SaveChanges();
}
catch (Exception ex)
{
var msg = ex.Message;
_logger.Error(msg, "An error occurred");
}
}
public Song SaveFlacSongToFileSystem(IFormFile songFile, IFormFile coverArtData, Song song)
{
// Save temp song (Should already be saved to the filesystem by the time it gets to this method)
// Save cover art
// Update the song's metadata with the song object
// Save song to its final directory
// Save cover art to the database
// Save song to the database
var coverMgr = new CoverArtManager(_config!);
var coverArt = coverMgr.SaveCoverArt(coverArtData, song);
var meta = new Utilities.MetadataRetriever();
meta.UpdateMetadata(song, song);
DirectoryManager dirMgr = new DirectoryManager(_config!, song);
dirMgr.CreateDirectory();
var tempPath = song.SongPath();
song.SongDirectory = dirMgr.SongDirectory;
song.Filename = song.GenerateFilename(true, AudioFileExtensionsType.FLAC);
var filePath = song.SongPath();
_logger.Info($"Absolute song path: {filePath}");
this.MoveSongToFinalDestination(tempPath, filePath);
SaveSongToDatabase(song, coverArt);
return song;
}
private void MoveSongToFinalDestination(string sourcePath, string targetPath)
{
using (var fileStream = new FileStream(targetPath, FileMode.Create))
{
var songBytes = System.IO.File.ReadAllBytes(sourcePath);
try
{
if (System.IO.File.Exists(sourcePath) && System.IO.File.Exists(sourcePath) && fileStream.Length > 0)
{
System.IO.File.Delete(sourcePath);
_logger.Info("Deleted temp song from filesystem: {0}", sourcePath);
}
else
{
fileStream.Write(songBytes, 0, songBytes.Count());
_logger.Info("Saved song to filesystem: {0}", targetPath);
System.IO.File.Delete(sourcePath);
_logger.Info("Deleted temp song from filesystem: {0}", sourcePath);
}
}
catch (Exception ex)
{
var msg = ex.Message;
_logger.Error($"An error occurred: {msg}");
}
_logger.Info("Song successfully saved to filesystem");
}
}
public async Task<SongData> RetrieveSong(Song songMetaData)
{
var song = new SongData();
try
{
Console.WriteLine("Fetching song from filesystem");
song = await RetrieveSongFromFileSystem(songMetaData);
}
catch (Exception ex)
{
var exMsg = ex.Message;
Console.WriteLine($"An error occurred: {exMsg}");
}
return song;
}
private async Task<SongData> RetrieveSongFromFileSystem(Song details)
{
byte[] uncompressedSong = await System.IO.File.ReadAllBytesAsync(details.SongPath());
return new SongData
{
Data = uncompressedSong
};
}
public async Task<Song> SaveSongTemp(IFormFile songFile)
{
_logger.Info("Assigning song filename");
var song = new Song { SongDirectory = this._tempDirectoryRoot! };
var filename = await song.GenerateFilenameAsync(false) + "-" + songFile.FileName;
song.Filename = filename;
var songPath = song.SongPath();
using (var filestream = new FileStream(songPath, FileMode.Create))
{
_logger.Info("Saving temp song: {0}", songPath);
await songFile.CopyToAsync(filestream);
}
song.DateCreated = DateTime.Now;
return song;
}
public Icarus.Models.CreateFileResult Create(IFormFile file, string filePath, string prompt)
{
if (System.IO.File.Exists(filePath))
{
return CreateFileResult.AlreadyExists;
}
using (var filestream = new FileStream(filePath, FileMode.Create))
{
Console.WriteLine(prompt);
file.CopyTo(filestream);
if (System.IO.File.Exists(filePath))
{
return CreateFileResult.FileCreatedAndExists;
}
}
return 0;
}
private bool SongRecordChanged(Song currentSong, Song songUpdates)
{
var currentTitle = currentSong.Title;
var currentArtist = currentSong.Artist;
var currentAlbum = currentSong.AlbumTitle;
var currentGenre = currentSong.Genre;
var currentYear = currentSong.Year;
if (!currentTitle!.Equals(songUpdates.Title) || !currentArtist!.Equals(songUpdates.Artist) ||
!currentAlbum!.Equals(songUpdates.AlbumTitle) ||
!currentGenre!.Equals(songUpdates.Genre) || currentYear != songUpdates.Year)
return true;
return false;
}
private void DeleteEmptyDirectories(ref Song oldSong, ref Song updatedSong)
{
DirectoryManager mgr = new DirectoryManager(_config!);
_logger.Info("Checking to see if there are any directories to delete");
mgr.DeleteEmptyDirectories(oldSong);
}
private void Initialize()
{
try
{
_connectionString = _config!.GetConnectionString("DefaultConnection");
_songContext = new SongContext(_connectionString!);
}
catch (Exception ex)
{
Console.WriteLine($"Error Occurred: {ex.Message}");
}
}
private void SaveSongToDatabase(Song song, CoverArt? cover)
{
_logger.Info("Starting process to save the song to the database");
var albumMgr = new AlbumManager(this._config!);
var artistMgr = new ArtistManager(this._config!);
var genreMgr = new GenreManager(this._config!);
var coverMgr = new CoverArtManager(this._config!);
albumMgr.SaveAlbumToDatabase(ref song);
artistMgr.SaveArtistToDatabase(ref song);
genreMgr.SaveGenreToDatabase(ref song);
var info = "Saving Song to DB";
_logger.Info(info);
this._songContext!.Add(song);
this._songContext!.SaveChanges();
if (cover != null)
{
coverMgr.SaveCoverArtToDatabase(ref song, ref cover!);
}
var accessLevel = Icarus.Models.AccessLevel.DefaultLevel();
accessLevel.SongId = song.Id;
var accessLevelContext = new AccessLevelContext(this._connectionString!);
accessLevelContext.Add(accessLevel);
accessLevelContext.SaveChanges();
}
private bool DeleteSongFromFilesystem(Song song, bool deleteDirectory = false)
{
var songPath = song.SongPath();
_logger.Info("Deleting song from the filesystem");
try
{
System.IO.File.Delete(songPath);
DeleteEmptyDirectories(ref song, ref song);
}
catch (Exception ex)
{
var msg = ex.Message;
_logger.Error(msg, "An error occurred when attempting to delete the song from the filesystem");
return false;
}
return DoesSongExistOnFilesystem(song);
}
private bool DoesSongExistOnFilesystem(Song song)
{
if (!System.IO.File.Exists(song.SongPath()))
{
_logger.Info("Song does not exist on the filesystem");
return false;
}
_logger.Info("Song exists on the filesystem");
return true;
}
private void UpdateSongInDatabase(ref Song oldSongRecord, ref Song newSongRecord, ref SongResult result)
{
var updatedSongRecord = oldSongRecord;
var songContext = new SongContext(_connectionString!);
if (!SongRecordChanged(oldSongRecord, newSongRecord))
{
_logger.Info("No change to the song record");
return;
}
_logger.Info("Changes to song record found");
if (!string.IsNullOrEmpty(newSongRecord.Title))
updatedSongRecord.Title = newSongRecord.Title;
if (!string.IsNullOrEmpty(newSongRecord.AlbumTitle))
{
updatedSongRecord.AlbumTitle = newSongRecord.AlbumTitle;
}
if (!string.IsNullOrEmpty(newSongRecord.Artist))
{
updatedSongRecord.Artist = newSongRecord.Artist;
}
if (!string.IsNullOrEmpty(newSongRecord.Genre))
{
updatedSongRecord.Genre = newSongRecord.Genre;
Console.WriteLine("Genre changed");
Console.WriteLine($"{updatedSongRecord.Genre} {newSongRecord.Genre}");
}
if (newSongRecord.Year != null || newSongRecord.Year > 0)
updatedSongRecord.Year = newSongRecord.Year;
_logger.Info("Applied changes to song record");
_logger.Info("Saving song metadata to the database");
songContext.Update(updatedSongRecord);
songContext.SaveChanges();
newSongRecord = updatedSongRecord;
result.Message = "Successfully updated song";
result.SongTitle = updatedSongRecord.Title!;
}
private void DeleteSongFromDatabase(Song song)
{
_logger.Info("Starting process to delete records related to the song from the database");
var albumMgr = new AlbumManager(_config!);
var artistMgr = new ArtistManager(_config!);
var genreMgr = new GenreManager(_config!);
var sngContext = new SongContext(_connectionString!);
sngContext.Songs!.Remove(song);
sngContext.SaveChanges();
artistMgr.DeleteArtistFromDatabase(song);
albumMgr.DeleteAlbumFromDatabase(song);
genreMgr.DeleteGenreFromDatabase(song);
}
#endregion
}
-316
View File
@@ -1,316 +0,0 @@
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using RestSharp;
using Icarus.Models;
using Microsoft.VisualBasic;
namespace Icarus.Controllers.Managers;
#region Classes
public class TokenManager : BaseManager
{
#region Fields
private string? _clientId;
private string? _clientSecret;
private string? _privateKeyPath = string.Empty;
private string? _privateKey = string.Empty;
private string? _publicKeyPath = string.Empty;
private string? _publicKey = string.Empty;
private string? _audience;
private string? _grantType;
private string? _url;
private string? SUCCESSFUL_TOKEN_MESSAGE = "Successfully retrieved token";
#endregion
#region Properties
#endregion
#region Constructors
public TokenManager(IConfiguration config)
{
_config = config;
InitializeValues();
}
#endregion
#region Methods
public LoginResult RetrieveLoginResult(User user)
{
_logger.Info("Preparing Auth0 API request");
var client = new RestClient(_url!);
var request = new RestRequest("oauth/token", Method.POST);
var tokenRequest = RetrieveTokenRequest();
_logger.Info("Serializing token object into JSON");
var tokenObject = JsonConvert.SerializeObject(tokenRequest);
request.AddParameter("application/json; charset=utf-8",
tokenObject, ParameterType.RequestBody);
request.RequestFormat = DataFormat.Json;
_logger.Info("Sending request");
IRestResponse response = client.Execute(request);
_logger.Info("Response received");
_logger.Info("Deserializing response");
var tokenResult = JsonConvert
.DeserializeObject<TokenTierOne>(response.Content);
_logger.Info("Response deserialized");
return this.InitializeLoginResult(user, tokenResult!);
}
public LoginResult LoginSymmetric(User user)
{
var tokenResult = new TokenTierOne { TokenType = "JWT" };
var payload = Payload();
payload.Add(new Claim("user_id", user.Id.ToString(), ClaimValueTypes.Integer));
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_config!["JWT:Secret"]!);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new Claim[]
{
new Claim("user_id", user.Id.ToString(), ClaimValueTypes.Integer)
// Add more claims as needed
}),
Expires = DateTime.UtcNow.AddHours(1),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
Issuer = _config["Jwt:Issuer"], // Add this line
Audience = _config["Jwt:Audience"]
};
tokenResult.AccessToken = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
var expClaim = payload.FirstOrDefault(cl =>
{
return cl.Type.Equals("exp");
});
var expiredDate = DateTime.Parse(expClaim!.Value);
var exp = Math.Floor((expiredDate - DateTime.UnixEpoch).TotalSeconds);
tokenResult.Expiration = Convert.ToInt32(exp);
return this.InitializeLoginResult(user, tokenResult);
}
private LoginResult InitializeLoginResult(User user, TokenTierOne token)
{
return new LoginResult
{
UserId = user.Id,
Username = user.Username,
Token = token.AccessToken,
TokenType = token.TokenType,
Expiration = token.Expiration,
Message = SUCCESSFUL_TOKEN_MESSAGE
};
}
public bool CanAccessSong(string token, Song song, AccessLevel accessLevel)
{
if (accessLevel!.Level!.Equals(Models.AccessLevel.DefaultLevel().Level))
{
return true;
}
var tokenUserId = this.RetrieveUserIdFromToken(token);
if (tokenUserId == null)
{
return false;
}
return tokenUserId.Value == song.UserId;
}
public string? GetBearerToken(HttpContext context)
{
string authorizationHeader = context.Request.Headers["Authorization"]!;
if (!authorizationHeader.IsNullOrEmpty() && authorizationHeader.StartsWith("Bearer", StringComparison.OrdinalIgnoreCase))
{
string token = authorizationHeader.Substring("Bearer ".Length).Trim();
return token;
}
return null;
}
public Guid? RetrieveUserIdFromToken(string token)
{
var parsedToken = this.ContainsBearer(token) ? this.StripBearer(token) : token;
var tokenHandler = new JwtSecurityTokenHandler();
var readTok = tokenHandler.ReadJwtToken(parsedToken);
foreach (var item in readTok.Payload)
{
if (item.Key == "user_id")
{
if (item.Value != null)
{
var id = item.Value.ToString();
return Guid.Parse(id!);
}
else
{
return null;
}
}
}
return null;
}
private string StripBearer(string token)
{
var start = 6;
var strippedToken = token.Substring(start);
return Strings.Trim(strippedToken);
}
private bool ContainsBearer(string token)
{
return token.Contains("Bearer");
}
private string AllScopes()
{
var allScopes = new List<String>()
{
"download:songs",
"read:song_details",
"upload:songs",
"delete:songs",
"read:albums",
"read:artists",
"update:songs",
"stream:songs",
"read:genre",
"read:year",
"download:cover_art"
};
var scopes = string.Empty;
for (var i = 0; i < allScopes.Count; i++)
{
if (i == allScopes.Count - 1)
{
scopes += allScopes[i];
}
else
{
scopes += allScopes[i] + " ";
}
}
return scopes;
}
private List<Claim> Payload()
{
// TODO: Remove this hard coding
var expLimit = 30;
var currentDate = DateTime.Now;
var expiredDate = currentDate.AddMinutes(expLimit);
var issuer = "http://localhost:5002";
var audience = "http://localhost:5002";
var subject = _config!["JWT:Subject"];
var claim = new List<System.Security.Claims.Claim>()
{
new Claim("scope", AllScopes(), "string"),
new Claim(JwtRegisteredClaimNames.Exp, expiredDate.ToString()),
new Claim(JwtRegisteredClaimNames.Aud, audience),
new Claim(JwtRegisteredClaimNames.Iss, issuer),
new Claim(JwtRegisteredClaimNames.Sub, subject!),
new Claim(JwtRegisteredClaimNames.Iat, currentDate.ToString())
};
return claim;
}
[Obsolete("Deprecated function")]
private async Task<string> ReadKeyContent(string filepath)
{
return await System.IO.File.ReadAllTextAsync(filepath);
}
private TokenRequest RetrieveTokenRequest()
{
_logger.Info("Retrieving token object");
return new TokenRequest
{
ClientId = _clientId,
ClientSecret = _clientSecret,
Audience = _audience,
GrantType = _grantType
};
}
private void InitializeValues()
{
_logger.Info("Analyzing Auth0 information");
_clientId = _config!["Auth0:ClientId"];
_clientSecret = _config["Auth0:ClientSecret"];
_audience = _config["Auth0:ApiIdentifier"];
_grantType = "client_credentials";
_url = $"https://{_config["Auth0:Domain"]}";
}
#region Testing Methods
// For testing purposes
private void PrintCredentials()
{
Console.WriteLine("Auth0 credentials:");
Console.WriteLine($"Client Id: {_clientId}");
Console.WriteLine($"Client Secret: {_clientSecret}");
Console.WriteLine($"Audience: {_audience}");
Console.WriteLine($"Url: {_url}");
}
#endregion
#endregion
#region Classes
private class TokenRequest
{
[JsonProperty("client_id")]
public string? ClientId { get; set; }
[JsonProperty("client_secret")]
public string? ClientSecret { get; set; }
[JsonProperty("audience")]
public string? Audience { get; set; }
[JsonProperty("grant_type")]
public string? GrantType { get; set; }
}
private class TokenTierOne
{
[JsonProperty("access_token")]
public string? AccessToken { get; set; }
[JsonProperty("expires_in")]
public int Expiration { get; set; }
[JsonProperty("token_type")]
public string? TokenType { get; set; }
}
#endregion
}
#endregion
@@ -1,371 +0,0 @@
using NLog;
using TagLib;
using Icarus.Models;
namespace Icarus.Controllers.Utilities;
public class MetadataRetriever
{
#region Fields
private static NLog.Logger? _logger = NLog.Web.NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
private List<string>? _supportedAudioFileTypes = new List<string> { "wav", "flac" };
private List<string>? _supportedImageFileTypes = new List<string> { "jpeg", "jpg", "png" };
private Song? _updatedSong;
private string? _message;
private string? _title;
private string? _artist;
private string? _album;
private string? _genre;
private int _year;
private int _duration;
#endregion
#region Properties
public Song? UpdatedSongRecord
{
get => _updatedSong;
set => _updatedSong = value;
}
public string? Message
{
get => _message;
set => _message = value;
}
#endregion
#region Constructors
#endregion
#region Methods
public string CoverArtFileExtensionType(CoverArt cover)
{
Console.WriteLine("Retrieving CoverArt file extension type");
using (var fileStream = System.IO.File.OpenRead(cover.ImagePath()))
{
var isRecognizableType = FileTypeChecker.FileTypeValidator.IsTypeRecognizable(fileStream);
if (!isRecognizableType)
{
return string.Empty;
}
var fileType = FileTypeChecker.FileTypeValidator.GetFileType(fileStream);
Console.WriteLine($"Filetype: {fileType}");
return fileType.Extension;
}
}
public string FileExtensionType(IFormFile file)
{
using (var fileStream = file.OpenReadStream())
{
var isRecognizableType = FileTypeChecker.FileTypeValidator.IsTypeRecognizable(fileStream);
if (!isRecognizableType)
{
return string.Empty;
}
var fileType = FileTypeChecker.FileTypeValidator.GetFileType(fileStream);
Console.WriteLine($"Filetype: {fileType}");
Console.WriteLine($"Extension: {fileType.Extension}");
return fileType.Extension;
}
}
public string FileExtensionType(string path)
{
var extensionRaw = System.IO.Path.GetExtension(path);
if (extensionRaw[0] == '.')
{
return extensionRaw.Substring(1);
}
else
{
return extensionRaw;
}
}
public bool IsSupportedFile(IFormFile file)
{
var supportedTypes = this._supportedAudioFileTypes;
this._supportedImageFileTypes!.ForEach(t =>
{
if (!supportedTypes!.Contains(t))
{
supportedTypes.Add(t);
}
});
var extensionType = this.FileExtensionType(file).ToLower();
return supportedTypes!.Contains(extensionType);
}
public bool IsSupportedFile(string path)
{
var supportedTypes = this._supportedAudioFileTypes;
this._supportedImageFileTypes!.ForEach(t =>
{
if (!supportedTypes!.Contains(t))
{
supportedTypes.Add(t);
}
});
var extensionType = this.FileExtensionType(path).ToLower();
return supportedTypes!.Contains(extensionType);
}
public Song RetrieveMetaData(string filePath)
{
Song song = new Song();
try
{
TagLib.File fileTag = TagLib.File.Create(filePath);
_title = fileTag.Tag.Title;
_artist = string.Join("", fileTag.Tag.Performers);
_album = fileTag.Tag.Album;
_genre = string.Join("", fileTag.Tag.Genres);
_year = (int)fileTag.Tag.Year;
_duration = (int)fileTag.Properties.Duration.TotalSeconds;
var albumArtist = string.Join("", fileTag.Tag.AlbumArtists);
var track = (int)fileTag.Tag.Track;
var disc = (int)fileTag.Tag.Disc;
song.Title = _title;
song.Artist = _artist;
song.AlbumTitle = _album;
song.AlbumArtist = albumArtist;
song.Genre = _genre;
song.Year = _year;
song.Duration = _duration;
song.Track = track;
song.Disc = disc;
song.TrackCount = (int)fileTag.Tag.TrackCount;
song.DiscCount = (int)fileTag.Tag.DiscCount;
}
catch (Exception ex)
{
var msg = ex.Message;
Console.WriteLine("An error occurred in MetadataRetriever");
Console.WriteLine(msg);
_logger!.Error(msg, "An error occurred in MetadataRetriever");
}
return song;
}
public int RetrieveSongDuration(string filepath)
{
var fileTag = TagLib.File.Create(filepath);
var duration = (int)fileTag.Properties.Duration.TotalSeconds;
return duration;
}
public byte[] RetrieveCoverArtBytes(Song song)
{
try
{
Console.WriteLine("Fetching image");
var tag = TagLib.File.Create(song.SongPath());
if (tag.Tag.Pictures.Count() == 0)
{
return [];
}
return tag.Tag.Pictures[0].Data.Data;
}
catch (Exception ex)
{
var msg = ex.Message;
_logger!.Error(msg, "An error occurred in MetadataRetriever");
}
return [];
}
public void UpdateMetadata(Song updatedSong, Song oldSong)
{
try
{
InitializeUpdatedSong(oldSong);
var songValues = CheckSongValues(updatedSong);
PerformUpdate(updatedSong, songValues);
Message = "Successfully updated metadata";
}
catch (Exception ex)
{
var msg = ex.Message;
Console.WriteLine($"An error occurred: {msg}");
_logger!.Error(msg, "An error occurred");
Message = "Failed to update metadata";
}
}
public void UpdateCoverArt(Song song, CoverArt coverArt)
{
Console.WriteLine("Updating song's cover art");
var tag = TagLib.File.Create(song.SongPath());
var pics = tag.Tag.Pictures;
Array.Resize(ref pics, 1);
pics[0] = new Picture(coverArt.ImagePath())
{
Description = "Cover Art"
};
tag.Tag.Pictures = pics;
tag.Save();
}
private void PerformUpdate(Song updatedSong, SortedDictionary<string, bool> checkedValues)
{
var filePath = updatedSong.SongPath();
var title = updatedSong.Title;
var artist = updatedSong.Artist;
var album = updatedSong.AlbumTitle;
var genre = updatedSong.Genre;
var year = updatedSong.Year;
var albumArtist = updatedSong.AlbumArtist;
var track = updatedSong.Track;
var trackCount = updatedSong.TrackCount;
var disc = updatedSong.Disc;
var discCount = updatedSong.DiscCount;
TagLib.File fileTag = TagLib.File.Create(filePath);
try
{
Console.WriteLine($"Updating metadata of {title}");
_logger!.Info($"Updating metadata of {title}");
foreach (var key in checkedValues.Keys)
{
bool result = checkedValues[key];
if (!result)
switch (key.ToLower())
{
case "title":
_updatedSong!.Title = title;
fileTag.Tag.Title = title;
break;
case "artists":
_updatedSong!.Artist = artist;
fileTag.Tag.Performers = new[] { artist };
break;
case "album":
_updatedSong!.AlbumTitle = album;
fileTag.Tag.Album = album;
break;
case "genre":
_updatedSong!.Genre = genre;
fileTag.Tag.Genres = new[] { genre };
break;
case "year":
_updatedSong!.Year = year;
fileTag.Tag.Year = (uint)year!;
break;
case "albumartist":
_updatedSong!.AlbumArtist = albumArtist;
fileTag.Tag.AlbumArtists = new[] { albumArtist };
break;
case "track":
_updatedSong!.Track = track;
fileTag.Tag.Track = (uint)(track);
break;
case "trackcount":
_updatedSong!.TrackCount = trackCount;
fileTag.Tag.TrackCount = (uint)(trackCount);
break;
case "disc":
_updatedSong!.Disc = disc;
fileTag.Tag.Disc = (uint)(disc);
break;
case "disccount":
_updatedSong!.DiscCount = discCount;
fileTag.Tag.DiscCount = (uint)(discCount);
break;
}
}
fileTag.Save();
Console.WriteLine("Successfully updated metadata");
_logger.Info("Successfully updated metadata");
}
catch (Exception ex)
{
var msg = ex.Message;
Console.WriteLine($"An error occurred:\n{msg}");
_logger!.Error(msg, "An error occurred");
}
}
private void InitializeUpdatedSong(Song song)
{
_updatedSong = song;
}
private SortedDictionary<string, bool> CheckSongValues(Song song)
{
var songValues = new SortedDictionary<string, bool>();
Console.WriteLine("Checking for null data");
_logger!.Info("Checking for null data");
try
{
songValues["Title"] = String.IsNullOrEmpty(song.Title);
songValues["Artists"] = String.IsNullOrEmpty(song.Artist);
songValues["Album"] = String.IsNullOrEmpty(song.AlbumTitle);
songValues["Genre"] = String.IsNullOrEmpty(song.Genre);
songValues["AlbumArtist"] = String.IsNullOrEmpty(song.AlbumArtist);
songValues["Year"] = CheckIntField(song.Year);
songValues["Track"] = CheckIntField(song.Track);
songValues["TrackCount"] = CheckIntField(song.TrackCount);
songValues["Disc"] = CheckIntField(song.Disc);
songValues["DiscCount"] = CheckIntField(song.Disc);
Console.WriteLine("Checking for null data completed");
_logger.Info("Checking for null data completed");
}
catch (Exception ex)
{
var msg = ex.Message;
Console.WriteLine($"An error occurred: \n{msg}");
_logger.Error(msg, "An error occurred");
}
return songValues;
}
private bool CheckIntField(int? value)
{
if (value == null)
{
return true;
}
else if (value == 0)
{
return true;
}
return false;
}
#endregion
}
@@ -1,89 +0,0 @@
using System;
using System.Security.Cryptography;
using BCrypt.Net;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using NLog;
using Icarus.Models;
namespace Icarus.Controllers.Utilities;
public class PasswordEncryption
{
#region Fields
private static Logger _logger = NLog.LogManager.GetCurrentClassLogger();
#endregion
#region Properties
#endregion
#region Constructor
#endregion
#region Methods
public bool VerifyPassword(User user, string password)
{
try
{
var result = BCrypt.Net.BCrypt.Verify(password, user.Password);
return result;
}
catch (Exception ex)
{
var msg = ex.Message;
_logger.Error(msg, "An error occurred");
}
return false;
}
public string HashPassword(User user)
{
try
{
string hashedPassword = string.Empty;
hashedPassword = BCrypt.Net.BCrypt.HashPassword(user.Password);
_logger.Info("Successfully hashed password");
return hashedPassword;
}
catch (Exception ex)
{
var exMsg = ex.Message;
_logger.Error(exMsg, "An error occurred");
}
return string.Empty;
}
string GenerateHash(string password, byte[] salt)
{
string hashed = Convert.ToBase64String(KeyDerivation.Pbkdf2(
password: password, salt: salt,
prf: KeyDerivationPrf.HMACSHA1,
iterationCount: 10000,
numBytesRequested: 256 / 8));
return hashed;
}
byte[] GenerateSalt()
{
byte[] salt = new byte[128 / 8];
using (var rng = RandomNumberGenerator.Create())
rng.GetBytes(salt);
return salt;
}
#endregion
}
@@ -1,126 +0,0 @@
using Icarus.Models;
namespace Icarus.Controllers.Utilities;
public class SongCompression
{
#region Fields
string? _compressedSongFilename;
string? _tempDirectory;
byte[]? _uncompressedSong;
#endregion
#region Propterties
public string? CompressedSongFilename
{
get => _compressedSongFilename;
set => _compressedSongFilename = value;
}
#endregion
#region Constructors
public SongCompression()
{
}
public SongCompression(string tempDirectory)
{
_tempDirectory = tempDirectory;
}
public SongCompression(byte[] uncompressedSong)
{
_uncompressedSong = uncompressedSong;
}
#endregion
#region Methods
public async Task<SongData> RetrieveCompressedSong(Song song)
{
SongData songData = new SongData();
try
{
var archivePath = RetrieveCompressesSongPath(song);
Console.WriteLine($"Compressed song saved to: {archivePath}");
songData.Data = await System.IO.File.ReadAllBytesAsync(archivePath);
}
catch (Exception ex)
{
var exMsg = ex.Message;
Console.WriteLine($"An error ocurred: \n{exMsg}");
}
return songData;
}
public string RetrieveCompressesSongPath(Song songDetails)
{
string tmpZipFilePath = _tempDirectory + songDetails.Filename;
try
{
using (var fi = new FileStream(songDetails.SongPath(), FileMode.Open))
{
using (var z = new Ionic.Zlib.ZlibStream(fi, Ionic.Zlib.CompressionMode.Compress))
{
using (var tr = new FileStream(tmpZipFilePath, FileMode.CreateNew))
{
z.CopyTo(tr);
}
}
}
Console.WriteLine("Successfully compressed");
}
catch (Exception ex)
{
var exMsg = ex.Message;
Console.WriteLine("An error ocurred");
Console.WriteLine(exMsg);
}
if (songDetails.Filename!.Contains(Constants.FileExtensions.WAV_EXTENSION))
{
_compressedSongFilename = StripExtension(songDetails.Filename);
}
return tmpZipFilePath;
}
// Method not being used
public byte[] CompressedSong(byte[] uncompressedSong)
{
byte[]? compressedSong = null;
try
{
Console.WriteLine("Song has been successfully compressed");
}
catch (Exception ex)
{
var exMsg = ex.Message;
Console.WriteLine("An error ocurred:");
Console.WriteLine(exMsg);
}
return compressedSong!;
}
string StripExtension(string filename)
{
Console.WriteLine($"Before: {filename}");
int filenameLength = filename.Length;
Console.WriteLine($"Filename length {filenameLength}");
var endIndex = filenameLength - 1;
var startIndex = endIndex - 3;
Console.WriteLine($"Starting index {startIndex} and ending index {endIndex}");
var stripped = filename.Remove(startIndex, 4);
stripped += Constants.FileExtensions.ZIP_EXTENSION;
Console.WriteLine($"After {stripped}");
return stripped;
}
#endregion
}
@@ -1,131 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Icarus.Controllers.V1;
[Route("api/v1/accesslevel")]
[ApiController]
[Authorize]
public class AccessLevelController : BaseController
{
#region Fields
private readonly ILogger<AccessLevelController>? _logger;
private string? _connectionString;
#endregion
#region Properties
#endregion
#region Constructors
public AccessLevelController(ILogger<AccessLevelController> logger, IConfiguration config)
{
this._logger = logger;
this._config = config;
this._connectionString = this._config.GetConnectionString("DefaultConnection");
}
#endregion
#region HTTP Routes
[HttpGet]
public IActionResult GetAccessLevels(Guid? id, Guid? songId)
{
var accLevel = new Models.AccessLevel { };
var accessLevelContext = new Database.Contexts.AccessLevelContext(_connectionString!);
if (id != null)
{
accLevel = accessLevelContext.AccessLevels!.FirstOrDefault(al => al.Id == id);
}
else if (songId != null)
{
accLevel = accessLevelContext.GetAccessLevel(songId.Value);
}
var response = new GetAccessLevelsResponse { Data = new List<Models.AccessLevel>() };
if (accLevel?.Id.ToString().Length > 0)
{
response.Subject = "Successful";
response.Data.Add(accLevel);
return Ok(response);
}
else
{
response.Subject = "Failure";
return BadRequest(response);
}
}
[HttpPatch("{id}")]
public IActionResult UpdateAccessLevel(Guid id, [FromBody] Models.AccessLevel accessLevel)
{
var response = new UpdateAccessLevelResponse { Data = new List<Models.AccessLevel>() };
var targetLevel = accessLevel.Level;
if (targetLevel == null)
{
response.Subject = "No level provided";
return BadRequest(response);
}
else
{
if (!Models.AccessLevel.IsAccessLevelValid(targetLevel))
{
response.Subject = "Invalid level";
return BadRequest(response);
}
}
var accessLevelContext = new Database.Contexts.AccessLevelContext(this._connectionString!);
var fetchedAccLevel = accessLevelContext.AccessLevels!.FirstOrDefault(al => al.Id == id);
if (fetchedAccLevel == null)
{
response.Subject = "Nothing found";
return NotFound(response);
}
var fetchedLevel = fetchedAccLevel!.Level;
if (fetchedLevel!.Equals(targetLevel))
{
// No change
response.Subject = "No change";
response.Data.Add(fetchedAccLevel);
return Ok(response);
}
else
{
fetchedAccLevel.Level = targetLevel;
response.Subject = "Successful";
accessLevelContext.Update(fetchedAccLevel);
accessLevelContext.SaveChanges();
response.Data.Add(fetchedAccLevel);
return Ok(response);
}
}
#endregion
}
#region Responses
public class GetAccessLevelsResponse
{
#region Properties
[Newtonsoft.Json.JsonProperty("subject")]
public string? Subject { get; set; }
[Newtonsoft.Json.JsonProperty("data")]
public List<Models.AccessLevel>? Data { get; set; }
#endregion
}
public class UpdateAccessLevelResponse
{
#region Properties
[Newtonsoft.Json.JsonProperty("subject")]
public string? Subject { get; set; }
[Newtonsoft.Json.JsonProperty("data")]
public List<Models.AccessLevel>? Data { get; set; }
#endregion
}
#endregion
-65
View File
@@ -1,65 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.V1;
[Route("api/v1/album")]
[ApiController]
[Authorize]
public class AlbumController : BaseController
{
#region Fields
private readonly ILogger<AlbumController>? _logger;
private string? _connectionString;
#endregion
#region Properties
#endregion
#region Constructors
public AlbumController(ILogger<AlbumController> logger, IConfiguration config)
{
_logger = logger;
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
}
#endregion
#region HTTP Routes
[HttpGet]
public IActionResult GetAlbums()
{
var albumContext = new AlbumContext(_connectionString!);
var albums = albumContext.Albums!.ToList();
if (albums.Count > 0)
return Ok(albums);
else
return NotFound();
}
[HttpGet("{id}")]
public IActionResult GetAlbum(Guid id)
{
Album album = new Album { Id = id };
var albumContext = new AlbumContext(_connectionString!);
if (albumContext.DoesRecordExist(album))
{
album = albumContext.RetrieveRecord(album);
return Ok(album);
}
else
return NotFound();
}
#endregion
}
-67
View File
@@ -1,67 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.V1;
[Route("api/v1/artist")]
[ApiController]
[Authorize]
public class ArtistController : BaseController
{
#region Fields
private readonly ILogger<ArtistController>? _logger;
private string? _connectionString;
#endregion
#region Properties
#endregion
#region Constructors
public ArtistController(ILogger<ArtistController> logger, IConfiguration config)
{
_logger = logger;
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
}
#endregion
#region HTTP Routes
[HttpGet]
public IActionResult GetArtists()
{
var artistContext = new ArtistContext(_connectionString!);
var artists = artistContext.Artists.ToList();
if (artists.Count > 0)
return Ok(artists);
else
return NotFound();
}
[HttpGet("{id}")]
public IActionResult GetArtist(Guid id)
{
Artist artist = new Artist { Id = id };
var artistContext = new ArtistContext(_connectionString!);
if (artistContext.DoesRecordExist(artist))
{
artist = artistContext.RetrieveRecord(artist);
return Ok(artist);
}
else
{
return NotFound();
}
}
#endregion
}
-37
View File
@@ -1,37 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Icarus.Controllers.V1;
public class BaseController : ControllerBase
{
#region Fiends
protected IConfiguration? _config;
#endregion
#region Methods
[ApiExplorerSettings(IgnoreApi = true)]
[Obsolete("Asymmetric key signing for tokens have been deprecated")]
protected string ParseBearerTokenFromHeader()
{
var token = string.Empty;
const string tokenType = "Bearer";
const string otherTokenType = "Jwt";
var req = Request;
var auth = req.Headers.Authorization;
var val = auth.ToString();
if ((val.Contains(tokenType) || val.Contains(otherTokenType)) && val.Split(" ").Count() > 1)
{
var split = val.Split(" ");
token = split[1];
}
return token;
}
#endregion
}
@@ -1,92 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Icarus.Controllers.Managers;
using Icarus.Database.Contexts;
using Icarus.Models;
namespace Icarus.Controllers.V1;
[Route("api/v1/coverart")]
[ApiController]
[Authorize]
public class CoverArtController : BaseController
{
#region Fields
private readonly ILogger<CoverArtController>? _logger;
private string? _connectionString;
#endregion
#region Constructors
public CoverArtController(ILogger<CoverArtController> logger, IConfiguration config)
{
_logger = logger;
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
}
#endregion
#region HTTP Routes
[HttpGet]
public IActionResult GetCoverArts()
{
var coverArtContext = new CoverArtContext(_connectionString!);
var coverArtRecords = coverArtContext.CoverArtImages!.ToList();
if (coverArtRecords == null)
{
_logger!.LogInformation("No cover art records");
return NotFound();
}
else
{
_logger!.LogInformation("Found cover art records");
return Ok(coverArtRecords);
}
}
[HttpGet("{id}")]
public IActionResult GetCoverArt(Guid id)
{
var coverArt = new CoverArt { Id = id };
var coverArtContext = new CoverArtContext(_connectionString!);
coverArt = coverArtContext.RetrieveRecord(coverArt);
if (coverArt != null)
{
_logger!.LogInformation("Found cover art record");
var coverArtBytes = System.IO.File.ReadAllBytes(
coverArt.ImagePath());
return File(coverArtBytes, "application/x-msdownload",
coverArt.SongTitle);
}
else
{
_logger!.LogInformation("Cover art not found");
return NotFound();
}
}
[HttpGet("data/download/{id}")]
public async Task<IActionResult> Download(Guid id, [FromQuery] bool? randomizeFilename)
{
var songContext = new SongContext(_connectionString!);
var covMgr = new CoverArtManager(this._config!);
var songMetaData = songContext.RetrieveRecord(new Song { Id = id });
var c = covMgr.GetCoverArt(songMetaData);
var filename = DirectoryManager.GenerateDownloadFilename(10, Constants.FileExtensions.JPG_EXTENSION, songMetaData.Title!, randomizeFilename);
var data = await c.GetData();
return File(data, "application/x-msdownload", filename);
}
#endregion
}
-71
View File
@@ -1,71 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.V1;
[Route("api/v1/genre")]
[ApiController]
[Authorize]
public class GenreController : BaseController
{
#region Fields
private readonly ILogger<GenreController>? _logger;
private string? _connectionString;
#endregion
#region Properties
#endregion
#region Constructors
public GenreController(ILogger<GenreController> logger, IConfiguration config)
{
_logger = logger;
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
}
#endregion
#region HTTP Routes
[HttpGet]
public IActionResult GetGenres()
{
var genreStore = new GenreContext(_connectionString!);
var genres = genreStore!.Genres!.ToList();
if (genres.Count > 0)
{
return Ok(genres);
}
else
{
return NotFound(new List<Genre>());
}
}
[HttpGet("{id}")]
public IActionResult GetGenre(Guid id)
{
var genre = new Genre { Id = id };
var genreStore = new GenreContext(_connectionString!);
if (genreStore.DoesRecordExist(genre))
{
genre = genreStore.RetrieveRecord(genre);
return Ok(genre);
}
else
{
return NotFound(new Genre());
}
}
#endregion
}
-96
View File
@@ -1,96 +0,0 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Icarus.Controllers.Managers;
using Icarus.Controllers.Utilities;
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.V1;
[Route("api/v1/login")]
[ApiController]
public class LoginController : ControllerBase
{
#region Fields
private string? _connectionString;
private IConfiguration? _config;
private ILogger<LoginController> _logger;
#endregion
#region Properties
#endregion
#region Contructors
public LoginController(IConfiguration config, ILogger<LoginController> logger)
{
_logger = logger;
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
}
#endregion
#region HTTP endpoints
[HttpPost]
public IActionResult Login([FromBody] User user)
{
var context = new UserContext(_connectionString!);
_logger.LogInformation("Starting process of validating credentials");
var message = "Invalid credentials";
var password = user.Password;
var loginRes = new LoginResult
{
Username = user.Username
};
try
{
if (context.Users.FirstOrDefault(usr => usr.Username!.Equals(user.Username)) != null)
{
user = context.Users.FirstOrDefault(usr => usr.Username!.Equals(user.Username))!;
var validatePass = new PasswordEncryption();
var validated = validatePass.VerifyPassword(user!, password!);
if (!validated)
{
loginRes.Message = message;
_logger.LogInformation(message);
return Ok(loginRes);
}
_logger.LogInformation("Successfully validated user credentials");
TokenManager tk = new TokenManager(_config!);
loginRes = tk.LoginSymmetric(user!);
return Ok(loginRes);
}
else
{
loginRes.Message = message;
return NotFound(loginRes);
}
}
catch (Exception ex)
{
_logger.LogError("An error occurred: {0}", ex.Message);
_logger.LogError("Inner Exception: {0}", ex.InnerException!.Message);
}
return NotFound(loginRes);
}
#endregion
}
@@ -1,75 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Icarus.Controllers.Utilities;
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.V1;
[Route("api/v1/register")]
[ApiController]
public class RegisterController : ControllerBase
{
#region Fields
private IConfiguration? _config;
#endregion
#region Properties
#endregion
#region Constructor
public RegisterController(IConfiguration config)
{
_config = config;
}
#endregion
[HttpPost]
public IActionResult RegisterUser([FromBody] User user)
{
PasswordEncryption pe = new PasswordEncryption();
user.Password = pe.HashPassword(user);
user.EmailVerified = false;
user.Status = "Registered";
user.DateCreated = DateTime.Now;
UserContext? context = null;
try
{
var connString = _config!.GetConnectionString("DefaultConnection");
context = new UserContext(connString!);
context.Add(user);
context.SaveChanges();
}
catch (Exception ex)
{
var msg = ex.Message;
var stackTrace = ex.StackTrace;
Console.WriteLine($"An error occurred: {msg}");
}
var registerResult = new RegisterResult
{
Username = user.Username
};
if (context!.Users.FirstOrDefault(sng => sng.Username!.Equals(user.Username)) != null)
{
registerResult.Message = "Successful registration";
registerResult.SuccessfullyRegistered = true;
return Ok(registerResult);
}
else
{
registerResult.Message = "Registration failed";
registerResult.SuccessfullyRegistered = false;
return Ok(registerResult);
}
}
}
@@ -1,71 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Icarus.Controllers.Utilities;
using Icarus.Models;
using Icarus.Database.Contexts;
using Icarus.Controllers.Managers;
namespace Icarus.Controllers.V1;
[Route("api/v1/song/compressed/data")]
[ApiController]
[Authorize]
public class SongCompressedDataController : BaseController
{
#region Fields
private string? _connectionString;
private string? _songTempDir;
private string? _archiveDir;
#endregion
#region Properties
#endregion
#region Constructor
public SongCompressedDataController(IConfiguration config)
{
_config = config;
_songTempDir = _config.GetValue<string>("TemporaryMusicPath");
_archiveDir = _config.GetValue<string>("ArchivePath");
_connectionString = _config.GetConnectionString("DefaultConnection");
}
#endregion
#region API Routes
[HttpGet("{id}")]
public async Task<IActionResult> DownloadCompressedSong(Guid id, [FromQuery] bool? randomizeFilename)
{
var context = new SongContext(_connectionString!);
SongCompression cmp = new SongCompression(_archiveDir!);
Console.WriteLine($"Archive directory root: {_archiveDir}");
Console.WriteLine("Starting process of retrieving comrpessed song");
var sng = context.RetrieveRecord(new Song { Id = id });
var tokenManager = new TokenManager(this._config!);
var accLvlContext = new AccessLevelContext(this._connectionString!);
var accessLevel = accLvlContext.GetAccessLevel(sng.Id);
var token = tokenManager.GetBearerToken(HttpContext);
if (token == null || accessLevel == null)
{
return BadRequest();
}
if (!tokenManager.CanAccessSong(token, sng, accessLevel))
{
return BadRequest();
}
SongData song = await cmp.RetrieveCompressedSong(sng);
var filename = DirectoryManager.GenerateDownloadFilename(10, Constants.FileExtensions.ZIP_EXTENSION, sng.Title!, randomizeFilename);
return File(song.Data!, "application/x-msdownload", filename);
}
#endregion
}
-109
View File
@@ -1,109 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Icarus.Controllers.Managers;
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.V1;
[Route("api/v1/song")]
[ApiController]
[Authorize]
public class SongController : BaseController
{
#region Fields
private readonly ILogger<SongController>? _logger;
private string? _connectionString;
private SongManager? _songMgr;
#endregion
#region Properties
#endregion
#region Constructor
public SongController(IConfiguration config, ILogger<SongController> logger)
{
_config = config;
_logger = logger;
_connectionString = _config.GetConnectionString("DefaultConnection");
_songMgr = new SongManager(config);
}
#endregion
#region Methods
#region HTTP Endpoints
[HttpGet]
public IActionResult GetSongs()
{
Console.WriteLine("Attemtping to retrieve songs");
_logger!.LogInformation("Attempting to retrieve songs");
var context = new SongContext(_connectionString!);
var songs = context.Songs!.ToList();
if (songs.Count > 0)
{
return Ok(songs);
}
else
{
return NotFound();
}
}
[HttpGet("{id}")]
public IActionResult GetSong(Guid id)
{
var context = new SongContext(_connectionString!);
var song = context.RetrieveRecord(new Song { Id = id });
Console.WriteLine("Here");
if (song.Id.ToString().Length != 0)
return Ok(song);
else
return NotFound();
}
[HttpPut("{id}")]
public IActionResult UpdateSong(Guid id, [FromBody] Song song)
{
song.Id = id;
Console.WriteLine("Retrieving filepath of song");
_logger!.LogInformation("Retrieving filepath of song");
if (!_songMgr!.DoesSongExist(song))
{
return NotFound(new SongResult
{
Message = "Song does not exist"
});
}
var tokenManager = new TokenManager(this._config!);
var accLvlContext = new AccessLevelContext(this._connectionString!);
var accessLevel = accLvlContext.GetAccessLevel(song.Id);
var token = tokenManager.GetBearerToken(HttpContext);
if (token == null || accessLevel == null)
{
return BadRequest();
}
if (!tokenManager.CanAccessSong(token, song, accessLevel))
{
return BadRequest();
}
var songRes = _songMgr.UpdateSong(song);
return Ok(songRes);
}
#endregion
#endregion
}
-231
View File
@@ -1,231 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Icarus.Controllers.Managers;
using Icarus.Models;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.V1;
[Route("api/v1/song/data")]
[ApiController]
[Authorize]
public class SongDataController : BaseController
{
#region Fields
private string? _connectionString;
private ILogger<SongDataController>? _logger;
private SongManager? _songMgr;
private string? _songTempDir;
#endregion
#region Properties
#endregion
#region Constructor
public SongDataController(IConfiguration config, ILogger<SongDataController> logger)
{
_config = config;
_connectionString = _config.GetConnectionString("DefaultConnection");
_logger = logger;
_songTempDir = _config.GetValue<string>("TemporaryMusicPath");
_songMgr = new SongManager(config, _songTempDir!);
}
#endregion
[HttpGet("download/{id}")]
public IActionResult Download(Guid id, [FromQuery] bool? randomizeFilename)
{
var tokenManager = new TokenManager(this._config!);
var songContext = new SongContext(this._connectionString!);
var accLvlContext = new AccessLevelContext(this._connectionString!);
var songMetaData = songContext.RetrieveRecord(new Song { Id = id });
var accessLevel = accLvlContext.GetAccessLevel(songMetaData.Id);
var token = tokenManager.GetBearerToken(HttpContext);
if (token == null || accessLevel == null)
{
return BadRequest();
}
if (!tokenManager.CanAccessSong(token, songMetaData, accessLevel))
{
return BadRequest();
}
var song = _songMgr!.RetrieveSong(songMetaData).Result;
string filename;
switch (songMetaData.AudioType)
{
case "wav":
filename = DirectoryManager.GenerateDownloadFilename(10, Constants.FileExtensions.WAV_EXTENSION,
songMetaData.Title!, randomizeFilename);
break;
case "flac":
filename = DirectoryManager.GenerateDownloadFilename(10, Constants.FileExtensions.FLAC_EXTENSION,
songMetaData.Title!, randomizeFilename);
break;
default:
filename = DirectoryManager.GenerateDownloadFilename(10, Constants.FileExtensions.DEFAULT_AUDIO_EXTENSION,
songMetaData.Title!, randomizeFilename);
break;
}
return File(song.Data!, "application/x-msdownload", filename);
}
// NOTE: No longer being used
// Assumes that the song already has metadata such as
// Title
// Artist
// Album
// Genre
// Year
// Track
// Track count
// Disc
// Disc count
// Cover art
//
[HttpPost("upload"), DisableRequestSizeLimit]
[Obsolete("Support for this endoint is no longer supported")]
public IActionResult Upload([FromForm(Name = "file")] List<IFormFile> songData)
{
return new StatusCodeResult(StatusCodes.Status410Gone);
}
// The client is expected to send the file, metadata, and cover art separately.
// Any metadata already on the file will be overwritten with values from the metadata
// as well as the cover art
//
[HttpPost("upload/with/data")]
public IActionResult UploadWithData([FromForm] UploadSongWithDataForm up)
{
try
{
if (up.SongData!.Length > 0 && up.CoverArtData!.Length > 0 && !string.IsNullOrEmpty(up.SongFile))
{
var meta = new Utilities.MetadataRetriever();
var tmpSong = this._songMgr!.SaveSongTemp(up.SongData).Result;
if (!meta.IsSupportedFile(tmpSong.SongPath()) && !meta.IsSupportedFile(up.CoverArtData))
{
return BadRequest("Media is not supported");
}
else if (!meta.IsSupportedFile(tmpSong.SongPath()))
{
return BadRequest("Song is not supported");
}
else if (!meta.IsSupportedFile(up.CoverArtData))
{
return BadRequest("Cover art is not supported");
}
var song = Newtonsoft.Json.JsonConvert.DeserializeObject<Song>(up.SongFile);
var tokMgr = new TokenManager(this._config!);
var accessToken = Request.Headers["Authorization"];
var userId = tokMgr.RetrieveUserIdFromToken(accessToken!);
if (userId != null)
{
song!.UserId = userId.Value;
}
_logger!.LogInformation($"Song title: {song!.Title}");
song.Filename = tmpSong.Filename;
song.SongDirectory = tmpSong.SongDirectory;
song.DateCreated = tmpSong.DateCreated;
song.AudioType = meta.FileExtensionType(tmpSong.SongPath());
switch (song.AudioType)
{
case "wav":
var _ = _songMgr.DeleteSongFromFileSystem(tmpSong);
return BadRequest(new UploadSongWithDataResponse
{
Subject = "No longer supported",
Message = "No support for .wav files",
Songs = new List<Song>()
});
case "flac":
song = _songMgr.SaveFlacSongToFileSystem(up.SongData, up.CoverArtData, song);
break;
default:
return BadRequest();
}
return Ok(song);
}
}
catch (Exception ex)
{
_logger!.LogError(ex.Message, "An error occurred");
}
return BadRequest();
}
[HttpDelete("delete/{id}")]
public IActionResult DeleteSong(Guid id)
{
var songContext = new SongContext(_connectionString!);
var songMetaData = songContext.RetrieveRecord(new Song { Id = id });
var tokenManager = new TokenManager(this._config!);
var accLvlContext = new AccessLevelContext(this._connectionString!);
var accessLevel = accLvlContext.GetAccessLevel(songMetaData.Id);
var token = tokenManager.GetBearerToken(HttpContext);
if (token == null || accessLevel == null)
{
return BadRequest();
}
if (!tokenManager.CanAccessSong(token, songMetaData, accessLevel))
{
return BadRequest();
}
if (string.IsNullOrEmpty(songMetaData.Title))
{
_logger!.LogInformation("Song does not exist");
return NotFound("Song does not exist");
}
else
{
_logger!.LogInformation("Starting process of deleting song from the filesystem and database");
_songMgr!.DeleteSong(songMetaData);
return Ok(songMetaData);
}
}
public class UploadSongWithDataForm
{
[FromForm(Name = "file")]
public IFormFile? SongData { get; set; }
// NOTE: Think about making this optional and if it is not provided, use the stock cover art
[FromForm(Name = "cover")]
public IFormFile? CoverArtData { get; set; }
[FromForm(Name = "metadata")]
public string? SongFile { get; set; }
}
public class UploadSongWithDataResponse
{
#region Properties
[Newtonsoft.Json.JsonProperty("message")]
public string? Message { get; set; }
[Newtonsoft.Json.JsonProperty("subject")]
public string? Subject { get; set; }
[Newtonsoft.Json.JsonProperty("data")]
public List<Song>? Songs { get; set; }
#endregion
}
}
@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Icarus.Database.Contexts;
namespace Icarus.Controllers.V1;
[Route("api/v1/song/stream")]
[ApiController]
[Authorize]
public class SongStreamController : BaseController
{
#region Fields
private string? _connectionString;
private ILogger<SongStreamController>? _logger;
#endregion
#region Properties
#endregion
#region Constructor
public SongStreamController(ILogger<SongStreamController> logger, IConfiguration config)
{
this._logger = logger;
this._config = config;
this._connectionString = this._config.GetConnectionString("DefaultConnection");
}
#endregion
#region HTTP endpoints
[HttpGet("{id}")]
public async Task<IActionResult> StreamSong(Guid id)
{
var context = new SongContext(_config!.GetConnectionString("DefaultConnection")!);
var song = context.Songs!.FirstOrDefault(sng => sng.Id == id);
if (song == null)
{
return BadRequest();
}
var tokenManager = new Managers.TokenManager(this._config!);
var accLvlContext = new AccessLevelContext(this._connectionString!);
var accessLevel = accLvlContext.GetAccessLevel(song.Id);
var token = tokenManager.GetBearerToken(HttpContext);
if (token == null || accessLevel == null)
{
return BadRequest();
}
if (!tokenManager.CanAccessSong(token, song, accessLevel))
{
return BadRequest();
}
var stream = new FileStream(song!.SongPath(), FileMode.Open, FileAccess.Read);
stream.Position = 0;
var filename = song.Filename;
if (string.IsNullOrEmpty(song.Filename))
{
filename = song.GenerateFilename();
}
_logger!.LogInformation("Starting to stream song...>");
Console.WriteLine("Starting to streamsong...");
return await Task.Run(() =>
{
return File(stream, "application/octet-stream", filename);
});
}
#endregion
}
@@ -1,44 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace Icarus.Database.Contexts;
public class AccessLevelContext : DbContext
{
#region Properties
public DbSet<Models.AccessLevel>? AccessLevels { get; set; }
#endregion
#region Constructors
public AccessLevelContext(DbContextOptions<AccessLevelContext> options) : base(options) { }
public AccessLevelContext(string connString) : base(new DbContextOptionsBuilder<AccessLevelContext>()
.UseMySQL(connString).Options)
{
if (this.AccessLevels == null)
{
}
}
#endregion
#region Methods
public Models.AccessLevel? GetAccessLevel(Guid songId)
{
var accessLevel = this.AccessLevels!.FirstOrDefault(acc => acc.SongId == songId);
return accessLevel;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Models.AccessLevel>().ToTable("AccessLevel");
modelBuilder.Entity<Models.AccessLevel>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnType("binary(16)");
});
modelBuilder.Entity<Models.AccessLevel>().Property(m => m.Level).IsRequired(true);
}
#endregion
}
-41
View File
@@ -1,41 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Icarus.Models;
namespace Icarus.Database.Contexts;
public class AlbumContext : DbContext
{
public DbSet<Album>? Albums { get; set; }
public AlbumContext(DbContextOptions<AlbumContext> options) : base(options) { }
public AlbumContext(string connString) : base(new DbContextOptionsBuilder<AlbumContext>()
.UseMySQL(connString).Options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Album>()
.ToTable("Album");
modelBuilder.Entity<Album>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnType("binary(16)");
});
}
public Album RetrieveRecord(Album album)
{
var albm = Albums!.FirstOrDefault(alb => alb.Id == album.Id);
return albm!;
}
public bool DoesRecordExist(Album album)
{
return Albums!.FirstOrDefault(alb => alb.Id == album.Id) != null ? true : false;
}
}
-42
View File
@@ -1,42 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Icarus.Models;
namespace Icarus.Database.Contexts;
public class ArtistContext : DbContext
{
public DbSet<Artist> Artists { get; set; }
public ArtistContext(DbContextOptions<ArtistContext> options) : base(options) { }
public ArtistContext(string connString) : base(new DbContextOptionsBuilder<ArtistContext>()
.UseMySQL(connString).Options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Artist>()
.ToTable("Artist");
modelBuilder.Entity<Artist>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnType("binary(16)");
});
}
public Artist RetrieveRecord(Artist artist)
{
return Artists.FirstOrDefault(arst => arst.Id == artist.Id)!;
}
public bool DoesRecordExist(Artist artist)
{
return Artists.FirstOrDefault(arst => arst.Id == artist.Id) != null ? true : false;
}
}
@@ -1,41 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Icarus.Models;
namespace Icarus.Database.Contexts;
public class CoverArtContext : DbContext
{
#region Properties
public DbSet<CoverArt>? CoverArtImages { get; set; }
#endregion
public CoverArtContext(DbContextOptions<CoverArtContext> options) : base(options) { }
public CoverArtContext(string connString) : base(new DbContextOptionsBuilder<CoverArtContext>()
.UseMySQL(connString).Options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<CoverArt>()
.ToTable("CoverArt");
modelBuilder.Entity<CoverArt>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnType("binary(16)");
});
}
public CoverArt RetrieveRecord(CoverArt cover)
{
return CoverArtImages!.FirstOrDefault(cov => cov.Id == cover.Id)!;
}
public bool DoesRecordExist(CoverArt cover)
{
return CoverArtImages!.FirstOrDefault(cov => cov.Id == cover.Id) != null ? true : false;
}
}
-44
View File
@@ -1,44 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Icarus.Models;
namespace Icarus.Database.Contexts;
public class GenreContext : DbContext
{
#region Properties
public DbSet<Genre>? Genres { get; set; }
#endregion
public GenreContext(DbContextOptions<GenreContext> options) : base(options) { }
public GenreContext(string connString) : base(new DbContextOptionsBuilder<GenreContext>()
.UseMySQL(connString).Options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Genre>()
.ToTable("Genre");
modelBuilder.Entity<Genre>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnType("binary(16)");
});
}
public Genre RetrieveRecord(Genre genre)
{
var gnre = Genres!.FirstOrDefault(gnr => gnr.Id == genre.Id);
return gnre!;
}
public bool DoesRecordExist(Genre genre)
{
return Genres!.FirstOrDefault(gnr => gnr.Id == genre.Id) != null ? true : false;
}
}
-61
View File
@@ -1,61 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Icarus.Models;
namespace Icarus.Database.Contexts;
public class SongContext : DbContext
{
public DbSet<Song>? Songs { get; set; }
public SongContext(string connString) : base(new DbContextOptionsBuilder<SongContext>()
.UseMySQL(connString).Options)
{
}
public SongContext(DbContextOptions<SongContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Song>()
.ToTable("Song");
modelBuilder.Entity<Song>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnType("binary(16)");
});
modelBuilder.Entity<Song>()
.Property(s => s.Year)
.IsRequired(false);
modelBuilder.Entity<Song>()
.Property(s => s.GenreId)
.IsRequired(false);
modelBuilder.Entity<Song>()
.Property(s => s.ArtistId)
.IsRequired(false);
modelBuilder.Entity<Song>()
.Property(s => s.AlbumId)
.IsRequired(false);
modelBuilder.Entity<Song>()
.Property(s => s.CoverArtId)
.IsRequired(false);
}
public Song RetrieveRecord(Song song)
{
var sng = Songs!.FirstOrDefault(sng => sng.Id == song.Id);
return sng!;
}
public bool DoesRecordExist(Song song)
{
return Songs!.FirstOrDefault(sng => sng.Id == song.Id) != null ? true : false;
}
}
-50
View File
@@ -1,50 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Icarus.Models;
namespace Icarus.Database.Contexts;
public class UserContext : DbContext
{
public DbSet<User> Users { get; set; }
#region Constructors
public UserContext(DbContextOptions<UserContext> options) : base(options) { }
[ActivatorUtilitiesConstructor]
public UserContext(string connString) : base(new DbContextOptionsBuilder<UserContext>()
.UseMySQL(connString).Options)
{
}
#endregion
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>()
.ToTable("User");
modelBuilder.Entity<User>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.HasColumnType("binary(16)"); // **** Map Guid to BINARY(16) ****
});
modelBuilder.Entity<User>()
.Property(u => u.LastLogin).IsRequired(false);
modelBuilder.Entity<User>()
.Property(u => u.DateCreated).HasDefaultValue(DateTime.Now);
}
public User RetrieveRecord(User user)
{
return Users.FirstOrDefault(usr => usr.Id == user.Id)!;
}
public bool DoesRecordExist(User user)
{
return Users.FirstOrDefault(usr => usr.Id == user.Id) != null ? true : false;
}
}
-46
View File
@@ -1,46 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
<Version>0.1.40</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
<PackageReference Include="EntityFramework" Version="6.5.1" />
<PackageReference Include="File.TypeChecker" Version="4.1.1" />
<PackageReference Include="Iconic.Zlib.Netstandard" Version="1.0.0" />
<PackageReference Include="JWT" Version="11.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.14">
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="MySql.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog.Web.AspNetCore" Version="4.14.0" />
<PackageReference Include="RestSharp" Version="106.15.0" />
<PackageReference Include="SevenZip" Version="19.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.7.1" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
<PackageReference Include="taglib" Version="2.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Models\Models.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="nlog.config" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Images/Stock/*.*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

-110
View File
@@ -1,110 +0,0 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Icarus.Database.Contexts;
var builder = WebApplication.CreateBuilder(args);
var MAX_REQUEST_BODY_SIZE = 51200000000;
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Icarus", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme()
{
Name = "Authorization",
Scheme = "Bearer",
BearerFormat = "JWT",
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Description = "Bearer *Auth Token*",
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
new string[] {}
}
});
});
builder.Services.AddControllers();
builder.WebHost.UseKestrel(option =>
{
option.Limits.MaxRequestBodySize = MAX_REQUEST_BODY_SIZE;
});
var Configuration = builder.Configuration;
var connString = Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.SaveToken = true;
var audience = Configuration["JWT:Audience"];
var issuer = Configuration["JWT:Issuer"];
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidAudience = audience,
ValidIssuer = issuer,
IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(Configuration["JWT:Secret"]!))
};
});
builder.Services.AddDbContext<SongContext>(options => options.UseMySQL(connString!));
builder.Services.AddDbContext<AlbumContext>(options => options.UseMySQL(connString!));
builder.Services.AddDbContext<ArtistContext>(options => options.UseMySQL(connString!));
builder.Services.AddDbContext<UserContext>(options => options.UseMySQL(connString!));
builder.Services.AddDbContext<GenreContext>(options => options.UseMySQL(connString!));
builder.Services.AddDbContext<CoverArtContext>(options => options.UseMySQL(connString!));
builder.Services.AddDbContext<AccessLevelContext>(options => options.UseMySQL(connString!));
builder.Services.AddControllers()
.AddNewtonsoftJson();
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// NOTE: This should be enabled at some point
// app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
#pragma warning disable ASP0014
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
#pragma warning restore ASP0014
app.Run();
-31
View File
@@ -1,31 +0,0 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:20781",
"sslPort": 44394
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Icarus": {
"commandName": "Project",
"launchBrowser": true,
"dotnetRuneMessage": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5002",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
-9
View File
@@ -1,9 +0,0 @@
using System;
namespace Icarus.Types;
public enum CoverArtField
{
SongTitle = 0,
ImagePath
};
-9
View File
@@ -1,9 +0,0 @@
using System;
namespace Icarus.Types;
public enum DirectoryType
{
Music = 0,
CoverArt
};
-28
View File
@@ -1,28 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
},
"Auth0": {
"Domain": "[domain].auth0.com",
"ApiIdentifier": "https://[identifier]/api",
"ClientId": "",
"ClientSecret": ""
},
"JWT": {
"Issuer": "",
"Audience": "",
"Secret": "",
"Subject": ""
},
"ConnectionStrings": {
"DefaultConnection": "Server=;Database=;Uid=;Pwd=;"
},
"RootMusicPath": "/music/path/",
"TemporaryMusicPath": "/music/temp/path/",
"ArchivePath": "/archive/path/",
"CoverArtPath": "/cover/art/path/"
}
-28
View File
@@ -1,28 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft": "Information"
}
},
"AllowedHosts": "*",
"Auth0": {
"Domain": "[domain].auth0.com",
"ApiIdentifier": "https://[identifier]/api",
"ClientId": "",
"ClientSecret": ""
},
"JWT": {
"Issuer": "",
"Audience": "",
"Secret": "",
"Subject": ""
},
"ConnectionStrings": {
"DefaultConnection": "Server=;Database=;Uid=;Pwd=;"
},
"RootMusicPath": "/music/path/",
"TemporaryMusicPath": "/music/temp/path/",
"ArchivePath": "/archive/path/",
"CoverArtPath": "/cover/art/path/"
}
-33
View File
@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
autoReload="true"
internalLogLevel="Info"
internalLogFile="Icarus.txt">
<!-- enable asp.net core layout renderers -->
<extensions>
<add assembly="NLog.Web.AspNetCore"/>
</extensions>
<!-- the targets to write to -->
<targets>
<!-- write logs to file -->
<target xsi:type="File" name="allfile" fileName="Icarus-${shortdate}.log"
layout="${longdate}|${event-properties:item=EventId_Id}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />
<!-- another file log, only own logs. Uses some ASP.NET core renderers -->
<target xsi:type="File" name="ownFile-web" fileName="Icarus-${shortdate}.log"
layout="${longdate}|${event-properties:item=EventId_Id}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}|url: ${aspnet-request-url}|action: ${aspnet-mvc-action}" />
</targets>
<!-- rules to map from logger name to target -->
<rules>
<!--All logs, including from Microsoft-->
<logger name="*" minlevel="Trace" writeTo="allfile" />
<!--Skip non-critical Microsoft logs and so log only own logs-->
<logger name="Microsoft.*" maxlevel="Info" final="true" /> <!-- BlackHole without writeTo -->
<logger name="*" minlevel="Trace" writeTo="ownFile-web" />
</rules>
</nlog>
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Kun Deng
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
View File
-19
View File
@@ -1,19 +0,0 @@
# Top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.cs]
# Use 'var' when possible
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Use expression body for methods
csharp_style_expression_bodied_methods = true:silent
-53
View File
@@ -1,53 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Icarus.Models;
public class AccessLevel
{
#region Properties
[Newtonsoft.Json.JsonProperty("id")]
[Key]
public Guid Id { get; set; }
[Newtonsoft.Json.JsonProperty("level")]
public string? Level { get; set; }
[Newtonsoft.Json.JsonProperty("song_id")]
public Guid SongId { get; set; }
#endregion
#region Methods
public static AccessLevel DefaultLevel()
{
return new AccessLevel
{
Level = "Public"
};
}
public static AccessLevel PrivateLevel()
{
return new AccessLevel
{
Level = "Private"
};
}
public static bool IsAccessLevelValid(string level)
{
if (level.Equals(DefaultLevel().Level))
{
return true;
}
else if (level.Equals(PrivateLevel().Level))
{
return true;
}
else
{
return false;
}
}
#endregion
}
-25
View File
@@ -1,25 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
namespace Icarus.Models;
public class Album
{
#region Properties
[JsonProperty("id")]
[Key]
public Guid Id { get; set; }
[JsonProperty("title")]
public string? Title { get; set; }
[JsonProperty("album_artist")]
[Column("Artist")]
public string? AlbumArtist { get; set; }
[JsonProperty("song_count")]
[NotMapped]
public int SongCount { get; set; }
[JsonProperty("year")]
public int Year { get; set; }
#endregion
}
-21
View File
@@ -1,21 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
namespace Icarus.Models;
public class Artist
{
#region Properties
[JsonProperty("id")]
[Key]
public Guid Id { get; set; }
[JsonProperty("name")]
[Column("Artist")]
public string? Name { get; set; }
[JsonProperty("song_count")]
[NotMapped]
public int SongCount { get; set; }
#endregion
}
-9
View File
@@ -1,9 +0,0 @@
using Newtonsoft.Json;
namespace Icarus.Models;
public class BaseResult
{
[JsonProperty("message")]
public string? Message { get; set; }
}
-9
View File
@@ -1,9 +0,0 @@
namespace Icarus.Constants;
public class DirectoryPaths
{
public static string CoverArtDirectory => Directory.GetCurrentDirectory() + "/Images/Stock/";
public static string CoverArtFilename => "CoverArt.png";
public static string FILENAME_CHARACTERS = "ABCDEF0123456789";
public static int FILENAME_LENGTH = 25;
}
-22
View File
@@ -1,22 +0,0 @@
namespace Icarus.Constants;
public class FileExtensions
{
// Contains the default audio file extension with period at the beginning
public static string DEFAULT_AUDIO_EXTENSION = WAV_EXTENSION!;
// Contains file extension with period at the beginning
public static string MP3_EXTENSION = ".mp3";
// Contains file extension with period at the beginning
public static string WAV_EXTENSION = ".wav";
// Contains file extension with period at the beginning
public static string JPG_EXTENSION = ".jpg";
// Contains file extension with period at the beginning
public static string FLAC_EXTENSION = ".flac";
// Contains file extension with period at the beginning
public static string ZIP_EXTENSION = ".zip";
}
-53
View File
@@ -1,53 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
namespace Icarus.Models;
public class CoverArt
{
#region Properties
[JsonProperty("id")]
[Key]
public Guid Id { get; set; }
[JsonProperty("title")]
public string? SongTitle { get; set; }
[JsonIgnore]
public string? Directory { get; set; }
[JsonProperty("filename")]
public string? Filename { get; set; }
[JsonProperty("type")]
public string? Type { get; set; }
#endregion
#region Methods
public string ImagePath()
{
var fullPath = this.Directory;
if (fullPath![fullPath.Length - 1] != '/')
{
fullPath += "/";
}
fullPath += Filename;
return fullPath;
}
public string GenerateFilename(int flag)
{
int length = Constants.DirectoryPaths.FILENAME_LENGTH;
string chars = Constants.DirectoryPaths.FILENAME_CHARACTERS;
var random = new Random();
var filename = new string(Enumerable.Repeat(chars, length).Select(s =>
s[random.Next(s.Length)]).ToArray());
var extension = Constants.FileExtensions.JPG_EXTENSION;
return (flag == 0) ? filename : $"{filename}{extension}";
}
public async Task<byte[]> GetData() => await File.ReadAllBytesAsync(this.ImagePath());
#endregion
}
-8
View File
@@ -1,8 +0,0 @@
namespace Icarus.Models;
public enum CreateFileResult
{
Unknwon = 0,
AlreadyExists = 1,
FileCreatedAndExists = 2
}
-20
View File
@@ -1,20 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
namespace Icarus.Models;
public class Genre
{
#region Properties
[JsonProperty("id")]
public Guid Id { get; set; }
[JsonProperty("genre")]
[Column("Category")]
public string? GenreName { get; set; }
[JsonProperty("song_count")]
[NotMapped]
public int SongCount { get; set; }
#endregion
}
-19
View File
@@ -1,19 +0,0 @@
using Newtonsoft.Json;
namespace Icarus.Models;
public class LoginResult : BaseResult
{
#region Properties
[JsonProperty("user_id")]
public Guid UserId { get; set; }
[JsonProperty("username")]
public string? Username { get; set; }
[JsonProperty("token")]
public string? Token { get; set; }
[JsonProperty("token_type")]
public string? TokenType { get; set; }
[JsonProperty("expiration")]
public int Expiration { get; set; }
#endregion
}
-19
View File
@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PackageId>Icarus.Models</PackageId>
<Product>Icarus.Models</Product>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="newtonsoft.json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
-13
View File
@@ -1,13 +0,0 @@
using Newtonsoft.Json;
namespace Icarus.Models;
public class RegisterResult : BaseResult
{
#region Properties
[JsonProperty("username")]
public string? Username { get; set; }
[JsonProperty("successfully_registered")]
public bool SuccessfullyRegistered { get; set; }
#endregion
}
-179
View File
@@ -1,179 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
namespace Icarus.Models;
public class Song
{
#region Properties
[JsonProperty("id")]
[Key]
public Guid Id { get; set; }
[JsonProperty("title")]
public string? Title { get; set; }
[JsonProperty("album")]
[Column("Album")]
public string? AlbumTitle { get; set; }
[JsonProperty("artist")]
public string? Artist { get; set; }
[JsonProperty("album_artist")]
public string? AlbumArtist { get; set; }
[JsonProperty("year")]
public int? Year { get; set; }
[JsonProperty("genre")]
public string? Genre { get; set; }
[JsonProperty("duration")]
public int Duration { get; set; }
[JsonProperty("filename")]
public string? Filename { get; set; }
[JsonIgnore]
public string? SongDirectory { get; set; }
[JsonProperty("audio_type")]
public string? AudioType { get; set; }
[JsonProperty("track")]
public int Track { get; set; } = 0;
[JsonProperty("track_count")]
public int TrackCount { get; set; } = 0;
[JsonProperty("disc")]
public int Disc { get; set; } = 0;
[JsonProperty("disc_count")]
public int DiscCount { get; set; } = 0;
[JsonIgnore]
public Guid? AlbumId { get; set; }
[JsonIgnore]
public Guid? ArtistId { get; set; }
[JsonIgnore]
public Guid? GenreId { get; set; }
[JsonIgnore]
public Guid? CoverArtId { get; set; }
[JsonProperty("date_created")]
public DateTime DateCreated { get; set; }
[JsonProperty("user_id")]
public Guid UserId { get; set; }
#endregion
#region Constructors
#endregion
#region Methods
public void PrintMetadata()
{
Console.WriteLine("\n\nMetadata of the song:");
Console.WriteLine($"ID: {this.Id}");
Console.WriteLine($"Title: {this.Title}");
Console.WriteLine($"Artist: {this.Artist}");
Console.WriteLine($"Album: {this.AlbumTitle}");
Console.WriteLine($"Genre: {this.Genre}");
Console.WriteLine($"Year: {this.Year}");
Console.WriteLine($"Duration: {this.Duration}");
Console.WriteLine($"AlbumID: {this.AlbumId}");
Console.WriteLine($"ArtistID: {this.ArtistId}");
Console.WriteLine($"GenreID: {this.GenreId}");
Console.WriteLine($"Song Path: {this.SongPath()}");
Console.WriteLine($"Filename: {this.Filename}");
Console.WriteLine("\n");
}
public string SongPath()
{
var fullPath = SongDirectory;
if (fullPath![fullPath.Length - 1] != '/')
{
fullPath += "/";
}
fullPath += Filename;
return fullPath;
}
public string GenerateFilename(bool includeExtension = false, AudioFileExtensionsType flag = AudioFileExtensionsType.Default)
{
int length = Constants.DirectoryPaths.FILENAME_LENGTH;
string chars = Constants.DirectoryPaths.FILENAME_CHARACTERS;
var filename = this.Generate(length, chars);
var extension = this.DetermineFileExtension(flag);
return includeExtension ? $"{filename}{extension}" : filename;
}
public async Task<string> GenerateFilenameAsync(bool includeExtension = false, AudioFileExtensionsType flag = AudioFileExtensionsType.Default)
{
int length = Constants.DirectoryPaths.FILENAME_LENGTH;
string chars = Constants.DirectoryPaths.FILENAME_CHARACTERS;
var extension = this.DetermineFileExtension(flag);
var filename = await Task.Run(() =>
{
return this.Generate(length, chars);
});
return includeExtension ? $"{filename}{extension}" : filename;
}
public CreateSongResult Create(Microsoft.AspNetCore.Http.IFormFile file, string filePath, string prompt)
{
if (System.IO.File.Exists(filePath))
{
return CreateSongResult.AlreadyExists;
}
using (var filestream = new FileStream(filePath, FileMode.Create))
{
Console.WriteLine(prompt);
file.CopyTo(filestream);
if (System.IO.File.Exists(filePath))
{
return CreateSongResult.Created;
}
}
return CreateSongResult.NotCreated;
}
private string DetermineFileExtension(AudioFileExtensionsType flag)
{
switch (flag)
{
case AudioFileExtensionsType.Default:
return Constants.FileExtensions.DEFAULT_AUDIO_EXTENSION;
case AudioFileExtensionsType.WAV:
return Constants.FileExtensions.WAV_EXTENSION;
case AudioFileExtensionsType.FLAC:
return Constants.FileExtensions.FLAC_EXTENSION;
default:
return "";
}
}
private string Generate(int length, string chars)
{
var random = new Random();
var filename = new string(Enumerable.Repeat(chars, length).Select(s =>
s[random.Next(s.Length)]).ToArray());
return filename;
}
#endregion
}
#region Enums
public enum AudioFileExtensionsType
{
Default = 0,
WAV = 1,
FLAC = 2
}
public enum CreateSongResult
{
NotCreated = 0,
AlreadyExists = 1,
Created = 2
}
#endregion
-10
View File
@@ -1,10 +0,0 @@
namespace Icarus.Models;
public class SongData
{
#region Properties
public Guid ID { get; set; }
public byte[]? Data { get; set; }
public int SongID { get; set; }
#endregion
}
-13
View File
@@ -1,13 +0,0 @@
using Newtonsoft.Json;
namespace Icarus.Models;
public class SongResult
{
#region Properties
[JsonProperty("message")]
public string? Message { get; set; }
[JsonProperty("song_title")]
public string? SongTitle { get; set; }
#endregion
}
-47
View File
@@ -1,47 +0,0 @@
using Newtonsoft.Json;
namespace Icarus.Models;
public class Token
{
#region Properties
[JsonProperty("scope")]
public string? Scope { get; set; }
[JsonProperty("exp")]
public int Expiration { get; set; }
[JsonProperty("aud")]
public string? Audience { get; set; }
[JsonProperty("iss")]
public string? Issuer { get; set; }
[JsonProperty("iat")]
public int Issued { get; set; }
#endregion
#region Methods
public bool TokenExpired()
{
var currentDate = DateTime.Now;
var currentDateInSeconds = Math.Floor((currentDate - DateTime.UnixEpoch).TotalSeconds);
var result = (currentDateInSeconds >= Expiration) ? true : false;
return result;
}
public bool ContainsScope(string desiredScope)
{
var result = Scope!.Contains(desiredScope);
return result;
}
public bool Erroneous()
{
Func<string, bool> isEmpty = a => string.IsNullOrEmpty(a);
var result = (!isEmpty(Scope!) && !isEmpty(Audience!) && !isEmpty(Issuer!) &&
Expiration > 0 && Issued > 0) ? false : true;
return result;
}
#endregion
}
-54
View File
@@ -1,54 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
namespace Icarus.Models;
[Table("User")]
public class User
{
#region Properties
[JsonProperty("id")]
[Column("Id")]
[Key]
public Guid Id { get; set; }
[JsonProperty("username")]
public string? Username { get; set; }
[JsonProperty("password")]
public string? Password { get; set; }
[JsonProperty("email")]
public string? Email { get; set; }
[JsonProperty("phone")]
[Column("Phone")]
public string? Phone { get; set; }
[JsonProperty("firstname")]
public string? Firstname { get; set; }
[JsonProperty("lastname")]
public string? Lastname { get; set; }
[JsonProperty("email_verified")]
[NotMapped]
public bool EmailVerified { get; set; }
[JsonProperty("date_created")]
public DateTime DateCreated { get; set; }
[JsonProperty("status")]
public string? Status { get; set; }
[JsonProperty("last_login")]
public DateTime? LastLogin { get; set; }
[JsonIgnore]
[NotMapped]
public System.Collections.Generic.IEnumerable<string>? Roles { get; set; }
#endregion
#region Methods
public System.Collections.Generic.IEnumerable<System.Security.Claims.Claim> Claims()
{
var claims = new System.Collections.Generic.List<System.Security.Claims.Claim> { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, Username!) };
claims.AddRange(Roles!.Select(role => new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, role)));
return claims;
}
#endregion
}
+9 -147
View File
@@ -1,155 +1,17 @@
# Icarus
Icarus is a music streaming API Server that interacts with [Mear](https://github.com/kdeng00/mear).
### Interfacing With Icarus
One can interface with Icarus the music server either by:
* [Mear](https://github.com/kdeng00/mear) - Partially implemented (under development)
* [IcarusDownloadManager](https://github.com/kdeng00/IcarusDownloadManager)
## Built With
# Getting Started
## Docker
Make sure `icarus_auth` is located in the root of the parent directory if using docker.
* C# [.NET](https://dotnet.microsoft.com/) 8
* [MySql](https://www.nuget.org/packages/MySql.Data/)
* OpenSSL
* BCrypt.Net-Next
* Iconic.Zlib.Netstandard
* JWT
* Microsoft.AspNetCore.Authentication.JwtBearer
* Microsoft.AspNetCore.Mvc.NewtonsoftJson
* Microsoft.EntityFrameworkCore
* Microsoft.EntityFrameworkCore.Tools
* MySql.EntityFrameworkCore
* [Newtonsoft.Json](https://www.newtonsoft.com/json)
* NLog.Web.AspNetCpre
* Portable.BouncyCastle
* RestSharp
* SevenZip
* System.IdentityModel.Tokens.Jwt
* [TagLib#](https://github.com/mono/taglib-sharp)
Create a `.env` file for both projects - `icarus_auth` and `icarus` - in the root of each project.
## Getting started
There are several things that need to be completed to properly setup and secure the API.
This API uses OpenAPI Specification 3.0. After configuring the API, launch the software
and navigate your browser to https://localhost:5001/swagger to view the endpoints.
1. JWT Information
2. API filesystem paths
3. Database connection string
4. Migrations
### JWT Information
Configure JWT information. Notably the Secret
```Json
"JWT": {
"Issuer": "IcarusAPI",
"Audience": "IcarusAPIClient",
"Secret": "Manaiswhatyouthinkitis",
"Subject": "Authorization"
},
Build containers
```
docker compose build --ssh default api auth_api
```
Replace [domain] with the domain name that represent's your domain. Replace [identifier] with the identifer root name in the appsettings environment file. Not the friendly name but the root name of the identifier, omitting the http protocol and the *api* path.
```Json
"Auth0": {
"Domain": "[domain].auth0.com",
"ApiIdentifier": "https://[identifier]/api"
},
Bring it up
```
**Note**: The Auth0 section is likely to be changed or removed in future releases.
### API filesystem paths
For the purposes of properly uploading, downloading, updating, deleting, and streaming songs the API filesystem paths must be configured. What is meant by this is that the `RootMusicPath` directory where all music will be stored must exist as well as the `ArchivePath` and `TemporaryMusicPath` paths. An example on a Linux system:
```Json
{
"RootMusicPath": "/home/dev/null/music/",
"TemporaryMusicPath": "/home/dev/null/music/temp/",
"ArchivePath": "/home/dev/null/music/archive/",
"CoverArtPath": "/home/dev/null/music/coverart/"
}
docker compose up -d --force-recreate api auth_api
```
* RootMusicPath - Where music will be stored in the following convention: *`Artist/Album/Songs`*
* TemporaryMusicPath - Where music will be stored when uploding songs to the server until the metadata has been fully parsed and entered into the database. Upon completion the files will be deleted and moved to the appropriate path in the `RootMusicPath`
* ArchivePath - When downloading compressed songs this is the path where songs will be compressed prior to dataa being read into memory, deleting the compressed file, and sending the compressed file from memory to the client
* CoverArtPath - Root directory where cover art will be saved to
**Note**: The `TemporaryMusic` or `ArchivePath` does not have to be located in the `RootMusicPath`. Ensure that the permissions are properly set for all of the paths.
### Database connection string
In order for Database functionality to be operable, there must be a valid connection string and credentials with appropriate permissions. **At the moment there is only support for MySQL**. Depending on your environment `Release` or `Debug` you will need to edit the appsettings.json or appsettings.Development.json accordingly. An example of the fields to change are below:
```Json
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=my_db;Uid=admin;Pwd=toughpassword;"
}
}
```
* Server - The address or domain name of the MySQL server
* Database - The database name
* Uid - Username
* Password - Self-explanatory
The only requirement of the User is that the user should have full permissions to the database as well as permissions to create a database. Other than that, that is all that is required.
### Migrations
In order to utilize migrations the `dotnet-ef` tool needs to be installed. This can be done with the following command
```BASH
dotnet tool restore
```
Prior to starting the API, the Migrations must be applied. There are 6 tables with migrations being applied and thy are:
* Users
* Song
* Album
* Artist
* CoverArt
* Genre
There is a script for Linux systems to apply these migrations, it can be found in the [Scripts/Migrations/Linux](https://github.com/kdeng00/Icarus/blob/master/Scripts/Migrations/Linux/AddUpdate.sh) directory. Just merely execute:
```shell
scripts/Migrations/Linux/AddUpdate.sh
```
Or you can manually add the migrations like so for each migration:
```shell
dotnet dotnet-ef migrations Add InitialCreate --context UserContext
```
Then update the migrations to the database like so<sup>*</sup>:
```shell
dotnet dotnet-ef database update --context UserContext
```
All of the contexts can be found in Database/Contexts folder.
From this point the database has been successfully configured. Metadata and song filesystem locations can be saved.
<sup>*</sup> Will only need to execute this for UserContext and SongContext because the Song table has relational constraints with Album, Artist, Year, and Genre.
## Contributing
Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on the code of conduct, and the process for submitting pull requests to the project.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
-33
View File
@@ -1,33 +0,0 @@
echo "Adding migrations..."
echo "Adding User migration"
dotnet dotnet-ef migrations add User --context UserContext
echo "Adding Album migration"
dotnet dotnet-ef migrations add Album --context AlbumContext
echo "Adding Artist migration"
dotnet dotnet-ef migrations add Artist --context ArtistContext
echo "Adding Genre migration"
dotnet dotnet-ef migrations add Genre --context GenreContext
echo "Adding Cover art migration"
dotnet dotnet-ef migrations add CoverArt --context CoverArtContext
echo "Adding Song migration"
dotnet dotnet-ef migrations add Song --context SongContext
echo "Adding AccessLevel migration"
dotnet dotnet-ef migrations add AccessLevel --context AccessLevelContext
echo "Updating migrations.."
echo "Updating User migration"
dotnet dotnet-ef database update --context UserContext
echo "Updating Album migration"
dotnet dotnet-ef database update --context AlbumContext
echo "Updating Artist migration"
dotnet dotnet-ef database update --context ArtistContext
echo "Updating Genre migration"
dotnet dotnet-ef database update --context GenreContext
echo "Updating Cover art migration"
dotnet dotnet-ef database update --context CoverArtContext
echo "Updating Song migration"
dotnet dotnet-ef database update --context SongContext
echo "Updating AccessLevel migration"
dotnet dotnet-ef database update --context AccessLevelContext
-78
View File
@@ -1,78 +0,0 @@
CREATE DATABASE Icarus;
USE Icarus;
CREATE TABLE CoverArt (
Id INT NOT NULL AUTO_INCREMENT,
SongTitle TEXT NOT NULL,
ImagePath TEXT NOT NULL,
PRIMARY KEY (Id)
);
CREATE TABLE Album (
Id INT NOT NULL AUTO_INCREMENT,
Title TEXT NOT NULL,
Artist TEXT NOT NULL,
Year INT NOT NULL,
PRIMARY KEY (Id)
);
CREATE TABLE Artist (
Id INT NOT NULL AUTO_INCREMENT,
Artist TEXT NOT NULL,
PRIMARY KEY (Id)
);
CREATE TABLE Genre (
Id INT NOT NULL AUTO_INCREMENT,
Category TEXT NOT NULL,
PRIMARY KEY (Id)
);
CREATE TABLE Song (
Id INT NOT NULL AUTO_INCREMENT,
Title TEXT NOT NULL,
Artist TEXT NOT NULL,
Album TEXT NOT NULL,
AlbumArtist TEXT NOT NULL,
Genre TEXT NOT NULL,
Year INT NOT NULL,
Duration INT NOT NULL,
Track INT NOT NULL,
TrackCount INT NOT NULL,
Disc INT NOT NULL,
DiscCount INT NOT NULL,
SongDirectory TEXT NOT NULL,
Filename TEXT NOT NULL,
CoverArtId INT NOT NULL,
ArtistId INT NOT NULL,
AlbumId INT NOT NULL,
GenreId INT NOT NULL,
DateCreated DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (Id),
CONSTRAINT FK_Song_CoverArtId FOREIGN KEY (CoverArtId) REFERENCES CoverArt (Id),
CONSTRAINT FK_Song_ArtistId FOREIGN KEY (ArtistId) REFERENCES Artist (Id),
CONSTRAINT FK_Song_AlbumId FOREIGN KEY (AlbumId) REFERENCES Album (Id),
CONSTRAINT FK_Song_GenreId FOREIGN KEY (GenreId) REFERENCES Genre (Id)
);
CREATE TABLE User (
Id INT NOT NULL AUTO_INCREMENT,
Firstname TEXT NOT NULL,
Lastname TEXT NOT NULL,
Email TEXT NOT NULL,
Phone TEXT NOT NULL,
Username TEXT NOT NULL,
Password TEXT NOT NULL,
DateCreated DATETIME DEFAULT CURRENT_TIMESTAMP,
Status TEXT DEFAULT 'Active',
LastLogin DATETIME NULL,
PRIMARY KEY (Id)
);
-7
View File
@@ -1,7 +0,0 @@
delete from Album;
delete from Artist;
delete from CoverArt;
delete from Genre;
delete from CoverArt;
delete from Song;
delete from User;
-1
View File
@@ -1 +0,0 @@
select * from Song;
@@ -1,3 +0,0 @@
drop database Icarus;
create database Icarus;
-1
View File
@@ -1 +0,0 @@
drop database Icarus;
-19
View File
@@ -1,19 +0,0 @@
CREATE TABLE IF NOT EXISTS `__EFMigrationsHistory` (
`MigrationId` varchar(150) NOT NULL,
`ProductVersion` varchar(32) NOT NULL,
PRIMARY KEY (`MigrationId`)
);
START TRANSACTION;
CREATE TABLE `Artist` (
`Id` int NOT NULL AUTO_INCREMENT,
`Artist` longtext NULL,
PRIMARY KEY (`Id`)
);
INSERT INTO `__EFMigrationsHistory` (`MigrationId`, `ProductVersion`)
VALUES ('20240620205519_ModelIdChanges', '8.0.6');
COMMIT;
-137
View File
@@ -1,137 +0,0 @@
#!/usr/bin/zsh
#
# Working directory
# Output directory
# Folder name creation
# Sub-folder name creation
#
# Validation
# Check if WORK_DIR is an actual directory
# Check if OUTPUT_DIR exists
# Check if the Folder and Sub folder has been created
# Check if WORK_DIR contains .wav files
#
# Conversion
# Convert wav files to flac files
# Create folder and sub folder if it does not exist
# Move the converted flac files to the sub folder
#
# Final
# Check if OUTPUT_DIR has the folder structure. Delete if so
# Move the folder and sub folder to OUTPUT_DIR
if [[ $# -eq 0 ]]; then
my_text="No arguments provided. Provide four arguments:\n"
my_text+="script.zsh \"working_dir\" \"output_dir\" \"folder_name\" \"sub_folder_name\""
echo "$my_text"
exit
fi
if [[ ! -n "$2" ]]; then
echo "The \"output_dir\" has not been provided"
exit
fi
if [[ ! -n "$3" ]]; then
echo "The \"folder_name\" has not been provided"
exit
fi
if [[ ! -n "$4" ]]; then
echo "The \"sub_folder_name\" has not been provided"
exit
fi
WORK_DIR=$1
OUTPUT_DIR=$2
FOLDER=$3
SUB_FOLDER=$4
# Validation
if [[ ! -d $WORK_DIR ]]; then
echo "$WORK_DIR is not a directory"
exit
fi
if [[ ! -d $OUTPUT_DIR ]]; then
echo "$OUTPUT_DIR does not exist"
exit
fi
FOLDER_DIR="$OUTPUT_DIR/$FOLDER"
if [[ ! -d $FOLDER_DIR ]]; then
echo "$FOLDER_DIR directory does not exist. Creating directory"
mkdir $FOLDER_DIR
echo "Created $FOLDER_DIR directory"
fi
FOLDER_DIR="$FOLDER_DIR/$SUB_FOLDER"
if [[ ! -d $FOLDER_DIR ]]; then
echo "$FOLDER_DIR directory does not exist. Creating directory"
mkdir $FOLDER_DIR
echo "Created $FOLDER_DIR directory"
fi
# Find all files matching the pattern in the directory
matched_files=( "$WORK_DIR"/*.wav )
# Check if any files were found
if [[ ${#matched_files[@]} -gt 0 ]]; then
echo "Files matching the pattern exist in the directory:"
else
echo "No files matching the pattern were found."
exit
fi
echo "Working directory: $WORK_DIR"
echo "Output directory: $OUTPUT_DIR"
echo "Folder name: $FOLDER"
echo "Sub folder name: $SUB_FOLDER"
# Conversion
i=1
for file in "${matched_files[@]}"; do
file_output="track"
if [[ $i -lt 10 ]]; then
file_output="track0$i.flac"
else
file_output="track$i.flac"
fi
echo "$i file: $file"
output_file_path="$FOLDER_DIR/$file_output"
echo "Output file path: $output_file_path"
echo "Converting wav file to flac"
flac --best $file -o $output_file_path
ALBUM_FILE="$WORK_DIR/new-album.json"
TARGET_ALBUM_FILE="$FOLDER_DIR/album.json"
if [[ -f $ALBUM_FILE ]]; then
echo "Copying album file"
cp -a $ALBUM_FILE $TARGET_ALBUM_FILE
fi
matched_img_files=( "$WORK_DIR"/*.[j,J,p,P][p,P,n,N]* )
# Check if any files were found
if [[ ${#matched_img_files[@]} -gt 0 ]]; then
echo "Files matching the pattern exist in the directory:"
echo "Copying cover art file(s)"
for img_file in "${matched_img_files[@]}"; do
cp -a $img_file $FOLDER_DIR
done
fi
i=$((i + 1))
done
+112
View File
@@ -0,0 +1,112 @@
version: '3.8' # Use a recent version
services:
# --- Web API ---
api:
build: # Tells docker-compose to build the Dockerfile in the current directory
context: .
ssh: ["default"] # Uses host's SSH agent
container_name: icarus # Optional: Give the container a specific name
ports:
# Map host port 8000 to container port 3000 (adjust as needed)
# Format: "HOST_PORT:CONTAINER_PORT"
- "8000:3000"
env_file:
- .env
depends_on:
main_db:
condition: service_healthy
networks:
- main-api-network
restart: unless-stopped # Optional: Restart policy
# --- Web API auth ---
auth_api:
build:
context: ../icarus_auth # IMPORTANT: Relative path to the local checkout of your web API repo (containing the Dockerfile)
ssh: ["default"] # Uses host's SSH agent
dockerfile: Dockerfile # Optional: Specify if your Dockerfile has a non-standard name
container_name: auth_api
restart: unless-stopped
ports:
- "8001:3000" # Map host port 8000 to container port 8000 (adjust container port based on your app's EXPOSE in Dockerfile)
# environment:
# Environment variables your API needs, e.g., database connection
# Add other necessary environment variables
env_file:
- ../icarus_auth/.env
depends_on:
auth_db:
condition: service_healthy
networks:
- auth-api-network
# Optional: Mount local code for development (live reload)
# volumes:
# - ./path/to/your/web-api-repo:/app
# PostgreSQL Database Service
# --- icarus web api db ---
main_db:
image: postgres:17.5-alpine # Use an official Postgres image (Alpine variant is smaller)
container_name: icarus_db # Optional: Give the container a specific name
environment:
# These MUST match the user, password, and database name in the DATABASE_URL above
POSTGRES_USER: ${POSTGRES_MAIN_USER:-icarus}
POSTGRES_PASSWORD: ${POSTGRES_MAIN_PASSWORD:-password}
POSTGRES_DB: ${POSTGRES_MAIN_DB:-icarus_db}
volumes:
# Persist database data using a named volume
- postgres_data:/var/lib/postgresql/data
ports:
# Optional: Expose port 5432 ONLY if you need to connect directly from your host machine (e.g., for debugging)
- "5432:5432"
healthcheck:
# Checks if Postgres is ready to accept connections
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: always # Optional: Restart policy
networks:
- main-api-network
# --- icarus web auth api db ---
auth_db:
image: postgres:17.5-alpine # Use an official Postgres image (Alpine variant is smaller)
container_name: icarus_auth_db # Optional: Give the container a specific name
environment:
# These MUST match the user, password, and database name in the DATABASE_URL above
POSTGRES_USER: ${POSTGRES_AUTH_USER:-icarus_op}
POSTGRES_PASSWORD: ${POSTGRES_AUTH_PASSWORD:-password}
POSTGRES_DB: ${POSTGRES_AUTH_DB:-icarus_auth_db}
volumes:
# Persist database data using a named volume
- postgres_data_auth:/var/lib/postgresql/data
ports:
# Optional: Expose port 5432 ONLY if you need to connect directly from your host machine (e.g., for debugging)
- "5433:5432"
healthcheck:
# Checks if Postgres is ready to accept connections
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: always # Optional: Restart policy
networks:
- auth-api-network
# Define the named volume for data persistence
volumes:
postgres_data:
driver: local # Use the default local driver
postgres_data_auth:
driver: local # Use the default local driver
# Define the network (optional, but good practice)
networks:
main-api-network:
driver: bridge
auth-api-network:
driver: bridge
@@ -0,0 +1,62 @@
-- Add migration script here
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Table to store queued songs to process
CREATE TABLE IF NOT EXISTS "songQueue" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL,
status TEXT CHECK (status IN ('pending', 'ready', 'processing', 'done')),
data BYTEA NULL,
user_id UUID NULL
);
-- Table to store queued metadata
CREATE TABLE IF NOT EXISTS "metadataQueue" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
metadata jsonb NOT NULL,
created_at timestamptz DEFAULT now(),
song_queue_id UUID NOT NULL
);
-- Table to store queued coverart
CREATE TABLE IF NOT EXISTS "coverartQueue" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
data BYTEA NULL,
song_queue_id UUID NULL
);
-- Create an index for better query performance
CREATE INDEX metadata_queue_data_metadata ON "metadataQueue" USING gin (metadata);
-- Table to store a song's info
CREATE TABLE IF NOT EXISTS "song" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
artist TEXT NOT NULL,
album_artist TEXT NOT NULL,
album TEXT NOT NULL,
genre TEXT NOT NULL,
-- TODO: Address discrepancy of date and year at some point
-- date TEXT NOT NULL,
year INT NOT NULL,
track INT NOT NULL,
disc INT NOT NULL,
track_count INT NOT NULL,
disc_count INT NOT NULL,
duration INT NOT NULL,
audio_type TEXT NOT NULL,
date_created timestamptz DEFAULT now(),
filename TEXT NOT NULL,
directory TEXT NOT NULL,
user_id UUID NULL
-- TODO: Add coverart id later. This will allow multiple songs to be linked to a single cover art
);
CREATE TABLE IF NOT EXISTS "coverart" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
-- TODO: Separate path later
path TEXT NOT NULL,
song_id UUID NOT NULL
-- TODO: Add type later
);
+649
View File
@@ -0,0 +1,649 @@
// TODO: Separate queue and coverart endpoints
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct CoverArtQueue {
pub id: uuid::Uuid,
pub song_queue_id: uuid::Uuid,
}
pub mod request {
pub mod link {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Request {
pub coverart_id: uuid::Uuid,
pub song_queue_id: uuid::Uuid,
}
}
pub mod fetch_coverart_no_data {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Params {
pub id: Option<uuid::Uuid>,
pub song_queue_id: Option<uuid::Uuid>,
}
}
pub mod fetch_coverart_with_data {
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Params {
pub id: Option<uuid::Uuid>,
pub song_queue_id: Option<uuid::Uuid>,
}
}
pub mod create_coverart {
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Request {
pub song_id: uuid::Uuid,
pub coverart_queue_id: uuid::Uuid,
}
}
pub mod wipe_data_from_coverart_queue {
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Request {
pub coverart_queue_id: uuid::Uuid,
}
}
pub mod get_coverart {
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Params {
pub id: Option<uuid::Uuid>,
}
}
}
pub mod response {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<uuid::Uuid>,
}
pub mod link {
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Id {
pub coverart_id: uuid::Uuid,
pub song_queue_id: uuid::Uuid,
}
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<Id>,
}
}
pub mod fetch_coverart_no_data {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<super::super::CoverArtQueue>,
}
}
pub mod fetch_coverart_with_data {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<Vec<u8>>,
}
}
pub mod create_coverart {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<icarus_models::coverart::CoverArt>,
}
}
pub mod wipe_data_from_coverart_queue {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<uuid::Uuid>,
}
}
pub mod get_coverart {
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<icarus_models::coverart::CoverArt>,
}
}
}
pub mod db {
use sqlx::Row;
pub async fn insert(pool: &sqlx::PgPool, data: &Vec<u8>) -> Result<uuid::Uuid, sqlx::Error> {
let result = sqlx::query(
r#"
INSERT INTO "coverartQueue" (data) VALUES($1) RETURNING id;
"#,
)
.bind(data)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error inserting: {e:?}");
});
match result {
Ok(row) => {
let id: uuid::Uuid = row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap();
Ok(id)
}
Err(_err) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn update(
pool: &sqlx::PgPool,
coverart_id: &uuid::Uuid,
song_queue_id: &uuid::Uuid,
) -> Result<i32, sqlx::Error> {
let result = sqlx::query(
r#"
UPDATE "coverartQueue" SET song_queue_id = $1 WHERE id = $2;
"#,
)
.bind(song_queue_id)
.bind(coverart_id)
.execute(pool)
.await;
match result {
Ok(_) => Ok(0),
Err(_err) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn get_coverart_queue_with_id(
pool: &sqlx::PgPool,
id: &uuid::Uuid,
) -> Result<super::CoverArtQueue, sqlx::Error> {
let result = sqlx::query(
r#"
SELECT id, song_queue_id FROM "coverartQueue" WHERE id = $1;
"#,
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error querying data: {e:?}");
});
match result {
Ok(row) => Ok(super::CoverArtQueue {
id: row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
song_queue_id: row
.try_get("song_queue_id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
}),
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn get_coverart_queue_with_song_queue_id(
pool: &sqlx::PgPool,
song_queue_id: &uuid::Uuid,
) -> Result<super::CoverArtQueue, sqlx::Error> {
let result = sqlx::query(
r#"
SELECT id, song_queue_id FROM "coverartQueue" WHERE song_queue_id = $1;
"#,
)
.bind(song_queue_id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error querying data: {e:?}");
});
match result {
Ok(row) => Ok(super::CoverArtQueue {
id: row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
song_queue_id: row
.try_get("song_queue_id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
}),
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn get_coverart_queue_data_with_id(
pool: &sqlx::PgPool,
id: &uuid::Uuid,
) -> Result<Vec<u8>, sqlx::Error> {
let result = sqlx::query(
r#"
SELECT data FROM "coverartQueue" WHERE id = $1;
"#,
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error querying data: {e:?}");
});
match result {
Ok(row) => Ok(row
.try_get("data")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap()),
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn get_coverart_queue_data_with_song_queue_id(
pool: &sqlx::PgPool,
song_queue_id: &uuid::Uuid,
) -> Result<Vec<u8>, sqlx::Error> {
let result = sqlx::query(
r#"
SELECT data FROM "coverartQueue" WHERE song_queue_id = $1;
"#,
)
.bind(song_queue_id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error querying data: {e}");
});
match result {
Ok(row) => Ok(row
.try_get("data")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap()),
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn wipe_data(
pool: &sqlx::PgPool,
coverart_queue_id: &uuid::Uuid,
) -> Result<uuid::Uuid, sqlx::Error> {
let result = sqlx::query(
r#"
UPDATE "coverartQueue" SET data = NULL WHERE id = $1 RETURNING id;
"#,
)
.bind(coverart_queue_id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error updating query: {e}");
});
match result {
Ok(row) => Ok(row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap()),
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
}
pub mod cov_db {
use sqlx::Row;
pub async fn create(
pool: &sqlx::PgPool,
coverart: &icarus_models::coverart::CoverArt,
song_id: &uuid::Uuid,
) -> Result<uuid::Uuid, sqlx::Error> {
let result = sqlx::query(
r#"
INSERT INTO "coverart" (title, path, song_id) VALUES($1, $2, $3) RETURNING id;
"#,
)
.bind(&coverart.title)
.bind(&coverart.path)
.bind(song_id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error inserting: {e}");
});
match result {
Ok(row) => {
let id: uuid::Uuid = row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap();
Ok(id)
}
Err(_err) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn get_coverart(
pool: &sqlx::PgPool,
id: &uuid::Uuid,
) -> Result<icarus_models::coverart::CoverArt, sqlx::Error> {
let result = sqlx::query(
r#"
SELECT id, title, path, song_id FROM "coverart" WHERE id = $1;
"#,
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error querying data: {e:?}");
});
match result {
Ok(row) => Ok(icarus_models::coverart::CoverArt {
id: row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
title: row
.try_get("title")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
path: row
.try_get("path")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
data: Vec::new(),
song_id: row
.try_get("song_id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
}),
Err(_) => Err(sqlx::Error::RowNotFound),
}
}
}
pub mod endpoint {
use std::io::Write;
use axum::response::IntoResponse;
pub async fn queue(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
mut multipart: axum::extract::Multipart,
) -> (
axum::http::StatusCode,
axum::Json<super::response::Response>,
) {
let mut response = super::response::Response::default();
match multipart.next_field().await {
Ok(Some(field)) => {
let name = field.name().unwrap().to_string();
let file_name = field.file_name().unwrap().to_string();
let content_type = field.content_type().unwrap().to_string();
let data = field.bytes().await.unwrap();
let raw_data = data.to_vec();
println!(
"Received file '{}' (name = '{}', content-type = '{}', size = {})",
file_name,
name,
content_type,
data.len()
);
match super::db::insert(&pool, &raw_data).await {
Ok(id) => {
response.message = String::from("Successful");
response.data.push(id);
(axum::http::StatusCode::OK, axum::Json(response))
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
Ok(None) => (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)),
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
pub async fn link(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::Json(payload): axum::Json<super::request::link::Request>,
) -> (
axum::http::StatusCode,
axum::Json<super::response::link::Response>,
) {
let mut response = super::response::link::Response::default();
let id = payload.coverart_id;
let song_id = payload.song_queue_id;
match super::db::update(&pool, &id, &song_id).await {
Ok(_o) => {
response.data.push(super::response::link::Id {
song_queue_id: song_id,
coverart_id: id,
});
(axum::http::StatusCode::OK, axum::Json(response))
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
pub async fn fetch_coverart_no_data(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Query(params): axum::extract::Query<
super::request::fetch_coverart_no_data::Params,
>,
) -> (
axum::http::StatusCode,
axum::Json<super::response::fetch_coverart_no_data::Response>,
) {
let mut response = super::response::fetch_coverart_no_data::Response::default();
match params.id {
Some(id) => match super::db::get_coverart_queue_with_id(&pool, &id).await {
Ok(cover_art_queue) => {
response.message = String::from("Successful");
response.data.push(cover_art_queue);
(axum::http::StatusCode::OK, axum::Json(response))
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
},
_ => match params.song_queue_id {
Some(song_queue_id) => {
match super::db::get_coverart_queue_with_song_queue_id(&pool, &song_queue_id)
.await
{
Ok(cover_art_queue) => {
response.message = String::from("Successful");
response.data.push(cover_art_queue);
(axum::http::StatusCode::OK, axum::Json(response))
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
None => {
response.message = String::from("No valid id provided");
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
},
}
}
pub async fn fetch_coverart_with_data(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
) -> (axum::http::StatusCode, axum::response::Response) {
match super::db::get_coverart_queue_data_with_id(&pool, &id).await {
Ok(data) => {
let bytes = axum::body::Bytes::from(data);
let mut response = bytes.into_response();
let headers = response.headers_mut();
// TODO: Address this hard coding for the coverart content type
headers.insert(axum::http::header::CONTENT_TYPE, "image".parse().unwrap());
// TODO: Make the conent disposition more dynamic
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{id}.jpg\"")
.parse()
.unwrap(),
);
(axum::http::StatusCode::OK, response)
}
Err(_err) => (
axum::http::StatusCode::BAD_REQUEST,
axum::response::Response::default(),
),
}
}
pub async fn create_coverart(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::Json(payload): axum::Json<super::request::create_coverart::Request>,
) -> (
axum::http::StatusCode,
axum::Json<super::response::create_coverart::Response>,
) {
let mut response = super::response::create_coverart::Response::default();
let id = payload.coverart_queue_id;
match super::db::get_coverart_queue_data_with_id(&pool, &id).await {
Ok(data) => {
let song_id = payload.song_id;
match crate::callers::song::song_db::get_song(&pool, &song_id).await {
Ok(song) => {
let directory = icarus_envy::environment::get_root_directory().await;
let dir = std::path::Path::new(&directory);
// TODO: Make this random and the file extension should not be hard coded
let filename = format!("{}-coverart.jpeg", &song.filename[..8]);
let save_path = dir.join(&filename);
let path = String::from(save_path.to_str().unwrap());
let mut coverart =
icarus_models::coverart::init::init_coverart_only_path(path);
coverart.title = song.album.clone();
coverart.data = data;
let mut file = std::fs::File::create(&save_path).unwrap();
file.write_all(&coverart.data).unwrap();
match super::cov_db::create(&pool, &coverart, &song.id).await {
Ok(id) => {
// TODO: Populate song_id
coverart.id = id;
println!("Cover Art created");
response.message = String::from("Successful");
response.data.push(coverart);
(axum::http::StatusCode::OK, axum::Json(response))
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
pub async fn wipe_data_from_coverart_queue(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::Json(payload): axum::Json<super::request::wipe_data_from_coverart_queue::Request>,
) -> (
axum::http::StatusCode,
axum::Json<super::response::wipe_data_from_coverart_queue::Response>,
) {
let mut response = super::response::wipe_data_from_coverart_queue::Response::default();
let coverart_queue_id = payload.coverart_queue_id;
match super::db::get_coverart_queue_with_id(&pool, &coverart_queue_id).await {
Ok(coverart_queue) => match super::db::wipe_data(&pool, &coverart_queue.id).await {
Ok(id) => {
response.message = String::from("Success");
response.data.push(id);
(axum::http::StatusCode::OK, axum::Json(response))
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
},
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::NOT_FOUND, axum::Json(response))
}
}
}
pub async fn get_coverart(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Query(params): axum::extract::Query<super::request::get_coverart::Params>,
) -> (
axum::http::StatusCode,
axum::Json<super::response::get_coverart::Response>,
) {
let mut response = super::response::get_coverart::Response::default();
match params.id {
Some(id) => match super::cov_db::get_coverart(&pool, &id).await {
Ok(coverart) => {
response.data.push(coverart);
response.message = String::from(super::super::response::SUCCESSFUL);
(axum::http::StatusCode::OK, axum::Json(response))
}
Err(err) => {
response.message = err.to_string();
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
},
None => {
response.message = String::from("Invalid parameters");
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
}
}
}
}
+281
View File
@@ -0,0 +1,281 @@
// TODO: Explicitly make this module target queueing a song's metadata
pub mod request {
pub mod queue_metadata {
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize, Serialize, sqlx::FromRow)]
pub struct Request {
pub song_queue_id: uuid::Uuid,
pub album: String,
pub album_artist: String,
pub artist: String,
pub disc: i32,
pub disc_count: i32,
pub duration: i64,
pub genre: String,
pub title: String,
pub track: i32,
pub track_count: i32,
pub year: i32,
}
impl Request {
pub async fn to_json_value(&self) -> serde_json::Value {
serde_json::json!(
{
"song_queue_id": &self.song_queue_id,
"album": &self.album,
"album_artist": &self.album_artist,
"genre": &self.genre,
"year": &self.year,
"track_count": &self.track_count,
"disc_count": &self.disc_count,
"title": &self.title,
"artist": &self.artist,
"disc": &self.disc,
"track": &self.track,
"duration": &self.duration,
})
}
}
}
pub mod fetch_metadata {
#[derive(
Debug, Default, serde::Deserialize, serde::Serialize, sqlx::FromRow, sqlx::Decode,
)]
pub struct Params {
pub id: Option<uuid::Uuid>,
pub song_queue_id: Option<uuid::Uuid>,
}
}
}
pub mod response {
pub mod queue_metadata {
use serde::{Deserialize, Serialize};
#[derive(Default, Deserialize, Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<uuid::Uuid>,
}
}
pub mod fetch_metadata {
use serde::{Deserialize, Serialize};
#[derive(Default, Deserialize, Serialize)]
pub struct Response {
pub message: String,
pub data: Vec<crate::callers::metadata::metadata_queue::MetadataQueue>,
}
}
}
pub mod metadata_queue {
use sqlx::Row;
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct InsertedData {
pub id: uuid::Uuid,
}
#[derive(Debug, serde::Deserialize, serde::Serialize, sqlx::FromRow)]
pub struct MetadataQueue {
pub id: uuid::Uuid,
pub metadata: serde_json::Value,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
pub song_queue_id: uuid::Uuid,
}
pub async fn insert(
pool: &sqlx::PgPool,
metadata: &serde_json::Value,
song_queue_id: &uuid::Uuid,
) -> Result<uuid::Uuid, sqlx::Error> {
let result = sqlx::query(
r#"
INSERT INTO "metadataQueue" (metadata, song_queue_id) VALUES($1, $2) RETURNING id;
"#,
)
.bind(metadata)
.bind(song_queue_id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error inserting: {e}");
});
match result {
Ok(row) => {
let id: uuid::Uuid = row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap();
Ok(id)
}
Err(_err) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn get_with_song_queue_id(
pool: &sqlx::PgPool,
song_queue_id: &uuid::Uuid,
) -> Result<MetadataQueue, sqlx::Error> {
let result = sqlx::query(
r#"
SELECT * FROM "metadataQueue" WHERE song_queue_id = $1;
"#,
)
.bind(song_queue_id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error inserting: {e}");
});
match result {
Ok(row) => Ok(MetadataQueue {
id: row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
metadata: row
.try_get("metadata")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
created_at: row
.try_get("created_at")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
song_queue_id: row
.try_get("song_queue_id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
}),
Err(_err) => Err(sqlx::Error::RowNotFound),
}
}
pub async fn get_with_id(
pool: &sqlx::PgPool,
id: &uuid::Uuid,
) -> Result<MetadataQueue, sqlx::Error> {
let result = sqlx::query(
r#"
SELECT id, metadata, created_at, song_queue_id FROM "metadataQueue" WHERE id = $1;
"#,
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| {
eprintln!("Error inserting: {e}");
});
match result {
Ok(row) => {
let data: serde_json::Value = row
.try_get("metadata")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap();
Ok(MetadataQueue {
id: row
.try_get("id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
metadata: data,
created_at: row
.try_get("created_at")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
song_queue_id: row
.try_get("song_queue_id")
.map_err(|_e| sqlx::Error::RowNotFound)
.unwrap(),
})
}
Err(_err) => Err(sqlx::Error::RowNotFound),
}
}
}
pub mod endpoint {
use axum::{Json, http::StatusCode};
pub async fn queue_metadata(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
Json(payload): Json<super::request::queue_metadata::Request>,
) -> (StatusCode, Json<super::response::queue_metadata::Response>) {
let mut results: Vec<uuid::Uuid> = Vec::new();
let mut response = super::response::queue_metadata::Response::default();
let meta = payload.to_json_value().await;
match super::metadata_queue::insert(&pool, &meta, &payload.song_queue_id).await {
Ok(metadata_queue_id) => {
results.push(metadata_queue_id);
response.data = results;
response.message = if response.data.is_empty() {
String::from("Error")
} else {
String::from("Success")
};
(StatusCode::OK, Json(response))
}
Err(err) => {
response.message = err.to_string();
(StatusCode::BAD_REQUEST, Json(response))
}
}
}
pub async fn fetch_metadata(
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
axum::extract::Query(params): axum::extract::Query<super::request::fetch_metadata::Params>,
) -> (StatusCode, Json<super::response::fetch_metadata::Response>) {
let mut response = super::response::fetch_metadata::Response::default();
match params.id {
Some(id) => {
println!("Something works {id}");
match super::metadata_queue::get_with_id(&pool, &id).await {
Ok(item) => {
response.message = String::from("Successful");
response.data.push(item);
(StatusCode::OK, Json(response))
}
Err(err) => {
response.message = err.to_string();
(StatusCode::BAD_REQUEST, Json(response))
}
}
}
_ => match params.song_queue_id {
Some(song_queue_id) => {
println!("Song queue Id is probably not nil");
match super::metadata_queue::get_with_song_queue_id(&pool, &song_queue_id).await
{
Ok(item) => {
response.message = String::from("Successful");
response.data.push(item);
(StatusCode::OK, Json(response))
}
Err(err) => {
response.message = err.to_string();
(StatusCode::BAD_REQUEST, Json(response))
}
}
}
None => {
println!("What is going on?");
(StatusCode::BAD_REQUEST, Json(response))
}
},
}
}
}
+27
View File
@@ -0,0 +1,27 @@
pub mod coverart;
pub mod metadata;
pub mod song;
pub mod endpoints {
pub const QUEUESONG: &str = "/api/v2/song/queue";
pub const QUEUESONGLINKUSERID: &str = "/api/v2/song/queue/link";
pub const QUEUESONGDATA: &str = "/api/v2/song/queue/{id}";
pub const QUEUESONGUPDATE: &str = "/api/v2/song/queue/{id}";
pub const NEXTQUEUESONG: &str = "/api/v2/song/queue/next";
pub const QUEUEMETADATA: &str = "/api/v2/song/metadata/queue";
pub const QUEUECOVERART: &str = "/api/v2/coverart/queue";
pub const QUEUECOVERARTDATA: &str = "/api/v2/coverart/queue/data/{id}";
pub const QUEUECOVERARTLINK: &str = "/api/v2/coverart/queue/link";
pub const QUEUESONGDATAWIPE: &str = "/api/v2/song/queue/data/wipe";
pub const QUEUECOVERARTDATAWIPE: &str = "/api/v2/coverart/queue/data/wipe";
pub const CREATESONG: &str = "/api/v2/song";
pub const GETSONGS: &str = "/api/v2/song";
pub const STREAMSONG: &str = "/api/v2/song/stream/{id}";
pub const CREATECOVERART: &str = "/api/v2/coverart";
pub const GETCOVERART: &str = "/api/v2/coverart";
}
pub mod response {
pub const SUCCESSFUL: &str = "SUCCESSFUL";
}
+1062
View File
File diff suppressed because it is too large Load Diff
+2001
View File
File diff suppressed because it is too large Load Diff

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