Compare commits

..

35 Commits

Author SHA1 Message Date
krateng
3ba27ffc37
Merge pull request #407 from noirscape/dateutil
Add a new config option to use a ZoneInfo timezone.
2025-02-17 16:37:42 +01:00
noirscape
300e2c1ff7 fix and/or mistake 2025-02-15 01:27:26 +01:00
noirscape
5c343053d9 replace utcnow calls
the utcnow function was deprecated in python 3.12 in favor of passing a timezone object into now() instead.
2025-02-15 01:25:21 +01:00
noirscape
7dab61e420 Add a new config option to use a ZoneInfo timezone.
The ZoneInfo class was added in python version 3.9, and the Alpine timezone package is already installed in the Containerfile as part of the runtime dependencies.

zoneinfo provides instances of the same timezone class already used for the existing OFFSET timezones (making it a drop-in accessible option), but implements them using an IANA timezone database (making it historically accurate, and hopefully, future accurate on new builds as well).

The implementation in this commit by default only overrides if LOCATION_TIMEZONE both hasn't been set and is in the installed tzdata package, making it fully backwards compatible with existing options.
2025-02-15 01:09:42 +01:00
krateng
5296960d68
Merge pull request #404 from jackwilsdon/fix-local-permissions
Fix permissions after copying initial local files
2025-02-14 05:20:18 +01:00
Jack Wilsdon
e060241acb Fix permissions after copying initial local files 2025-02-13 19:16:24 +00:00
krateng
c571ffbf07 Version bump 2025-02-10 04:29:15 +01:00
krateng
767a6bca26 Dev scripts 2025-02-10 03:58:14 +01:00
krateng
ffed0c29b0 Update pyproject.toml, GH-399 2025-02-10 03:58:04 +01:00
krateng
ca65813619
Merge pull request #399 from noirscape/patch-1
Read out mimetypes for cached images
2025-02-10 03:50:25 +01:00
noirscape
5926dc3307
its a runtime package 2025-02-09 17:50:17 +01:00
noirscape
811bc16a3f
Add libmagic to containerfile 2025-02-09 17:46:25 +01:00
noirscape
a4ec29dd4c
Add python-magic to requirements.txt 2025-02-09 17:44:49 +01:00
noirscape
a8293063a5
Read out mimetypes for cached images
On Firefox, because there is no mimetype set when serving the file, it is served up as raw HTML, causing cached images to not be served to the client (as the fetch is aborted early). It doesn't appear to be an issue in Google Chrome.

This commit fixes #349 .
2025-02-09 17:38:40 +01:00
krateng
126d155208 Implement import for ghan CSV files, fix GH-382 2025-02-05 19:31:09 +01:00
krateng
7f774f03c4 Include import in normal server startup, GH-393 2025-02-05 18:24:41 +01:00
krateng
cc64c894f0 Update remaining deprecated API URLs, GH-368 2025-02-03 04:04:48 +01:00
krateng
76c013e130 Merge remote-tracking branch 'origin/master' 2025-01-28 07:17:40 +01:00
krateng
9a6c51a36d Update search URL, probably fix GH-368 2025-01-28 07:16:46 +01:00
krateng
e3a578da2f
Merge pull request #395 from RealHypnoticOcelot/patch-1
Fix infinite scrobble loop with Navidrome
2025-01-27 04:36:08 +01:00
HypnoticOcelot
a851e36485
Update listenbrainz.py 2025-01-25 17:08:55 +00:00
HypnoticOcelot
0e928b4007
Update listenbrainz.py 2025-01-24 12:32:05 +00:00
krateng
63386b5ede Python and Alpine upgrade 2025-01-19 19:13:46 +01:00
krateng
9d21800eb9 Remove old setup.py (how was this still here?) 2025-01-19 05:53:34 +01:00
krateng
5a95d4e056 Remove file path context managers, GH-390 2025-01-19 05:53:08 +01:00
krateng
968bea14d9 Update development info 2025-01-19 03:31:54 +01:00
krateng
5e62ccc254 Actually remove install helpers 2025-01-19 03:10:43 +01:00
krateng
273713cdc4 Simpler dev testing with compose 2025-01-19 03:00:00 +01:00
krateng
f8b10ab68c Pin dependencies, close GH-390 2025-01-19 02:51:23 +01:00
krateng
922eae7b68 Remove manual install helpers 2025-01-19 02:36:12 +01:00
krateng
cf0a856040 Remove daemonization capabilities 2025-01-19 02:35:45 +01:00
krateng
26f26f36cb Add debounce timer to search, GH-370 2025-01-16 06:22:35 +01:00
krateng
1462883ab5 Alrighty 2025-01-16 05:07:43 +01:00
krateng
a0b83be095 Let's try this 2025-01-16 04:59:42 +01:00
krateng
2750241e61 Fix build (maybe) 2025-01-15 21:19:39 +01:00
46 changed files with 239 additions and 576 deletions

View File

@ -4,6 +4,7 @@ on:
push:
tags:
- 'v*'
- 'runaction-docker'
jobs:
push_to_registry:

View File

@ -4,11 +4,14 @@ on:
push:
tags:
- 'v*'
- 'runaction-pypi'
jobs:
publish_to_pypi:
name: Push Package to PyPI
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
@ -25,7 +28,4 @@ jobs:
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@b7f401de30cb6434a1e19f805ff006643653240e
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70

1
.gitignore vendored
View File

@ -3,7 +3,6 @@
# environments / builds
.venv/*
testdata*
/dist
/build
/*.egg-info

View File

@ -1,36 +0,0 @@
# Contributor: Johannes Krattenmacher <maloja@dev.krateng.ch>
# Maintainer: Johannes Krattenmacher <maloja@dev.krateng.ch>
pkgname=maloja
pkgver=3.0.0-dev
pkgrel=0
pkgdesc="Self-hosted music scrobble database"
url="https://github.com/krateng/maloja"
arch="noarch"
license="GPL-3.0"
depends="python3 tzdata"
pkgusers=$pkgname
pkggroups=$pkgname
depends_dev="gcc g++ python3-dev libxml2-dev libxslt-dev libffi-dev libc-dev py3-pip linux-headers"
makedepends="$depends_dev"
source="
$pkgname-$pkgver.tar.gz::https://github.com/krateng/maloja/archive/refs/tags/v$pkgver.tar.gz
"
builddir="$srcdir"/$pkgname-$pkgver
build() {
cd $builddir
python3 -m build .
pip3 install dist/*.tar.gz
}
package() {
mkdir -p /etc/$pkgname || return 1
mkdir -p /var/lib/$pkgname || return 1
mkdir -p /var/cache/$pkgname || return 1
mkdir -p /var/logs/$pkgname || return 1
}
# TODO
sha512sums="a674eaaaa248fc2b315514d79f9a7a0bac6aa1582fe29554d9176e8b551e8aa3aa75abeebdd7713e9e98cc987e7bd57dc7a5e9a2fb85af98b9c18cb54de47bf7 $pkgname-${pkgver}.tar.gz"

View File

@ -1,4 +1,4 @@
FROM lsiobase/alpine:3.19 as base
FROM lsiobase/alpine:3.21 AS base
WORKDIR /usr/src/app
@ -29,6 +29,7 @@ RUN \
apk add --no-cache \
python3 \
py3-lxml \
libmagic \
tzdata && \
echo "" && \
echo "**** install pip dependencies ****" && \

View File

@ -9,49 +9,14 @@ Clone the repository and enter it.
## Environment
To avoid cluttering your system, consider using a [virtual environment](https://docs.python.org/3/tutorial/venv.html).
Your system needs several packages installed. For supported distributions, this can be done with e.g.
```console
sh ./install/install_dependencies_alpine.sh
```
For other distros, try to find the equivalents of the packages listed or simply check your error output.
Then install all Python dependencies with
```console
pip install -r requirements.txt
```
To avoid cluttering your system, consider using a [virtual environment](https://docs.python.org/3/tutorial/venv.html), or better yet run the included `docker-compose.yml` file.
Your IDE should let you run the file directly, otherwise you can execute `docker compose -f dev/docker-compose.yml -p maloja up --force-recreate --build`.
## Running the server
For development, you might not want to install maloja files all over your filesystem. Use the environment variable `MALOJA_DATA_DIRECTORY` to force all user files into one central directory - this way, you can also quickly change between multiple configurations.
Use the environment variable `MALOJA_DATA_DIRECTORY` to force all user files into one central directory - this way, you can also quickly change between multiple configurations.
You can quickly run the server with all your local changes with
```console
python3 -m maloja run
```
You can also build the package with
```console
pip install .
```
## Docker
You can also always build and run the server with
```console
sh ./dev/run_docker.sh
```
This will use the directory `testdata`.
## Further help

View File

@ -40,15 +40,8 @@ You can check [my own Maloja page](https://maloja.krateng.ch) as an example inst
## How to install
### Requirements
Maloja should run on any x86 or ARM machine that runs Python.
It is highly recommended to use **Docker** or **Podman**.
Your CPU should have a single core passmark score of at the very least 1500. 500 MB RAM should give you a decent experience, but performance will benefit greatly from up to 2 GB.
### Docker / Podman
To avoid issues with version / dependency mismatches, Maloja should only be used in **Docker** or **Podman**, not on bare metal.
I cannot offer any help for bare metal installations (but using venv should help).
Pull the [latest image](https://hub.docker.com/r/krateng/maloja) or check out the repository and use the included Containerfile.
@ -67,11 +60,7 @@ An example of a minimum run configuration to access maloja via `localhost:42010`
docker run -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
```
#### Linux Host
**NOTE:** If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) this DOES NOT apply to you.
If you are running Docker on a **Linux Host** you should specify `user:group` ids of the user who owns the folder on the host machine bound to `MALOJA_DATA_DIRECTORY` in order to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) the following DOES NOT apply to you, but if you are running **Docker** on a **Linux Host** you should specify `user:group` ids of the user who owns the folder on the host machine bound to `MALOJA_DATA_DIRECTORY` in order to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
To get the UID and GID for the current user run these commands from a terminal:
@ -84,33 +73,6 @@ The modified run command with these variables would look like:
docker run -e PUID=1000 -e PGID=1001 -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
```
### PyPI
You can install Maloja with
```console
pip install malojaserver
```
To make sure all dependencies are installed, you can also use one of the included scripts in the `install` folder.
### From Source
Clone this repository and enter the directory with
```console
git clone https://github.com/krateng/maloja
cd maloja
```
Then install all the requirements and build the package, e.g.:
```console
sh ./install/install_dependencies_alpine.sh
pip install -r requirements.txt
pip install .
```
### Extras
@ -123,30 +85,18 @@ Then install all the requirements and build the package, e.g.:
### Basic control
When not running in a container, you can run the application with `maloja run`. You can also run it in the background with
`maloja start` and `maloja stop`, but this might not be supported in the future.
When not running in a container, you can run the application with `maloja run`.
### Data
If you would like to import your previous scrobbles, use the command `maloja import *filename*`. This works on:
If you would like to import your previous scrobbles, copy them into the import folder in your data directory. This works on:
* a Last.fm export generated by [ghan64's website](https://lastfm.ghan.nl/export/)
* an official [Spotify data export file](https://www.spotify.com/us/account/privacy/)
* an official [ListenBrainz export file](https://listenbrainz.org/profile/export/)
* the export of another Maloja instance
⚠️ Never import your data while maloja is running. When you need to do import inside docker container start it in shell mode instead and perform import before starting the container as mentioned above.
```console
docker run -it --entrypoint sh -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
cd /mljdata
maloja import my_last_fm_export.csv
```
To backup your data, run `maloja backup`, optional with `--include_images`.
### Customization
* Have a look at the [available settings](settings.md) and specifiy your choices in `/etc/maloja/settings.ini`. You can also set each of these settings as an environment variable with the prefix `MALOJA_` (e.g. `MALOJA_SKIP_SETUP`).

3
dev/clear_testdata.sh Normal file
View File

@ -0,0 +1,3 @@
sudo rm -r ./testdata
mkdir ./testdata
chmod 777 ./testdata

13
dev/docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
services:
maloja:
build:
context: ..
dockerfile: ./Containerfile
ports:
- "42010:42010"
volumes:
- "./testdata:/data"
environment:
- "MALOJA_DATA_DIRECTORY=/data"
- "PUID=1000"
- "PGID=1000"

View File

@ -1,21 +0,0 @@
import toml
import os
with open("pyproject.toml") as filed:
data = toml.load(filed)
info = {
'name':data['project']['name'],
'license':"GPLv3",
'version':data['project']['version'],
'architecture':'all',
'description':'"' + data['project']['description'] + '"',
'url':'"' + data['project']['urls']['homepage'] + '"',
'maintainer':f"\"{data['project']['authors'][0]['name']} <{data['project']['authors'][0]['email']}>\"",
}
for target in ["apk","deb"]:
lcmd = f"fpm {' '.join(f'--{key} {info[key]}' for key in info)} -s python -t {target} . "
print(lcmd)
os.system(lcmd)

View File

@ -39,8 +39,19 @@ minor_release_name: "Nicole"
- "[Feature] Added option to show scrobbles on tile charts"
- "[Bugfix] Fixed Last.fm authentication"
3.2.3:
commit: "a7dcd3df8a6b051a1f6d0b7d10cc5af83502445c"
notes:
- "[Architecture] Upgraded doreah, significant rework of authentication"
- "[Bugfix] Fixed initial permission check"
- "[Bugfix] Fixed and updated various texts"
- "[Bugfix] Fixed moving tracks to different album"
3.2.4:
notes:
- "[Architecture] Removed daemonization capabilities"
- "[Architecture] Moved import to main server process"
- "[Feature] Implemented support for ghan's csv Last.fm export"
- "[Performance] Debounced search"
- "[Bugfix] Fixed stuck scrobbling from Navidrome"
- "[Bugfix] Fixed missing image mimetype"
- "[Technical] Pinned dependencies"
- "[Technical] Upgraded Python and Alpine"

View File

@ -1,2 +0,0 @@
docker build -t maloja . -f Containerfile
docker run --rm -p 42010:42010 -v $PWD/testdata:/mlj -e MALOJA_DATA_DIRECTORY=/mlj maloja

View File

@ -1,2 +0,0 @@
podman build -t maloja . -f Containerfile
podman run --rm -p 42010:42010 -v $PWD/testdata:/mlj -e MALOJA_DATA_DIRECTORY=/mlj maloja

View File

@ -1,36 +0,0 @@
# Contributor: Johannes Krattenmacher <maloja@dev.krateng.ch>
# Maintainer: Johannes Krattenmacher <maloja@dev.krateng.ch>
pkgname={{ tool.flit.module.name }}
pkgver={{ project.version }}
pkgrel=0
pkgdesc="{{ project.description }}"
url="{{ project.urls.homepage }}"
arch="noarch"
license="GPL-3.0"
depends="{{ tool.osreqs.alpine.run | join(' ') }}"
pkgusers=$pkgname
pkggroups=$pkgname
depends_dev="{{ tool.osreqs.alpine.build | join(' ') }}"
makedepends="$depends_dev"
source="
$pkgname-$pkgver.tar.gz::{{ project.urls.repository }}/archive/refs/tags/v$pkgver.tar.gz
"
builddir="$srcdir"/$pkgname-$pkgver
build() {
cd $builddir
python3 -m build .
pip3 install dist/*.tar.gz
}
package() {
mkdir -p /etc/$pkgname || return 1
mkdir -p /var/lib/$pkgname || return 1
mkdir -p /var/cache/$pkgname || return 1
mkdir -p /var/logs/$pkgname || return 1
}
# TODO
sha512sums="a674eaaaa248fc2b315514d79f9a7a0bac6aa1582fe29554d9176e8b551e8aa3aa75abeebdd7713e9e98cc987e7bd57dc7a5e9a2fb85af98b9c18cb54de47bf7 $pkgname-${pkgver}.tar.gz"

View File

@ -1,40 +0,0 @@
FROM alpine:3.15
# Python image includes two Python versions, so use base Alpine
# Based on the work of Jonathan Boeckel <jonathanboeckel1996@gmail.com>
WORKDIR /usr/src/app
# Install run dependencies first
RUN apk add --no-cache {{ tool.osreqs.alpine.run | join(' ') }}
# system pip could be removed after build, but apk then decides to also remove all its
# python dependencies, even if they are explicitly installed as python packages
# whut
RUN \
apk add py3-pip && \
pip install wheel
COPY ./requirements.txt ./requirements.txt
RUN \
apk add --no-cache --virtual .build-deps {{ tool.osreqs.alpine.build | join(' ') }} && \
pip install --no-cache-dir -r requirements.txt && \
apk del .build-deps
# no chance for caching below here
COPY . .
RUN pip install /usr/src/app
# Docker-specific configuration
# defaulting to IPv4 is no longer necessary (default host is dual stack)
ENV MALOJA_SKIP_SETUP=yes
ENV PYTHONUNBUFFERED=1
EXPOSE 42010
# use exec form for better signal handling https://docs.docker.com/engine/reference/builder/#entrypoint
ENTRYPOINT ["maloja", "run"]

View File

@ -1,4 +0,0 @@
{% include 'install/install_dependencies_alpine.sh.jinja' %}
apk add py3-pip
pip install wheel
pip install malojaserver

View File

@ -1,4 +0,0 @@
{% include 'install/install_dependencies_debian.sh.jinja' %}
apt install python3-pip
pip install wheel
pip install malojaserver

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
apk update
apk add \
{{ (tool.osreqs.alpine.build + tool.osreqs.alpine.run + tool.osreqs.alpine.opt) | join(' \\\n\t') }}

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
apt update
apt install \
{{ (tool.osreqs.debian.build + tool.osreqs.debian.run + tool.osreqs.debian.opt) | join(' \\\n\t') }}

View File

@ -1,17 +1,21 @@
"""
Create necessary files from sources of truth. Currently just the requirements.txt files.
"""
import toml
import os
import jinja2
env = jinja2.Environment(
loader=jinja2.FileSystemLoader('dev/templates'),
loader=jinja2.FileSystemLoader('./templates'),
autoescape=jinja2.select_autoescape(['html', 'xml']),
keep_trailing_newline=True
)
with open("pyproject.toml") as filed:
with open("../pyproject.toml") as filed:
data = toml.load(filed)
templatedir = "./dev/templates"
templatedir = "./templates"
for root,dirs,files in os.walk(templatedir):
@ -23,7 +27,7 @@ for root,dirs,files in os.walk(templatedir):
if not f.endswith('.jinja'): continue
srcfile = os.path.join(root,f)
trgfile = os.path.join(reldirpath,f.replace(".jinja",""))
trgfile = os.path.join("..", reldirpath,f.replace(".jinja",""))
template = env.get_template(relfilepath)

View File

@ -1,3 +1,7 @@
"""
Read the changelogs / version metadata and create all git tags
"""
import os
import subprocess as sp
import yaml

View File

@ -1,20 +0,0 @@
#!/usr/bin/env sh
apk update
apk add \
gcc \
g++ \
python3-dev \
libxml2-dev \
libxslt-dev \
libffi-dev \
libc-dev \
py3-pip \
linux-headers \
python3 \
py3-lxml \
tzdata \
vips
apk add py3-pip
pip install wheel
pip install malojaserver

View File

@ -1,9 +0,0 @@
#!/usr/bin/env sh
apt update
apt install \
python3-pip \
python3
apt install python3-pip
pip install wheel
pip install malojaserver

View File

@ -1,16 +0,0 @@
#!/usr/bin/env sh
apk update
apk add \
gcc \
g++ \
python3-dev \
libxml2-dev \
libxslt-dev \
libffi-dev \
libc-dev \
py3-pip \
linux-headers \
python3 \
py3-lxml \
tzdata \
vips

View File

@ -1,15 +0,0 @@
#!/usr/bin/env sh
pacman -Syu
pacman -S --needed \
gcc \
python3 \
libxml2 \
libxslt \
libffi \
glibc \
python-pip \
linux-headers \
python \
python-lxml \
tzdata \
libvips

View File

@ -1,5 +0,0 @@
#!/usr/bin/env sh
apt update
apt install \
python3-pip \
python3

View File

@ -26,77 +26,6 @@ def print_header_info():
#print("#####")
print()
def get_instance():
try:
return int(subprocess.check_output(["pgrep","-f","maloja$"]))
except Exception:
return None
def get_instance_supervisor():
try:
return int(subprocess.check_output(["pgrep","-f","maloja_supervisor"]))
except Exception:
return None
def restart():
if stop():
start()
else:
print(col["red"]("Could not stop Maloja!"))
def start():
if get_instance_supervisor() is not None:
print("Maloja is already running.")
else:
print_header_info()
setup()
try:
#p = subprocess.Popen(["python3","-m","maloja.server"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
sp = subprocess.Popen(["python3","-m","maloja","supervisor"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print(col["green"]("Maloja started!"))
port = conf.malojaconfig["PORT"]
print("Visit your server address (Port " + str(port) + ") to see your web interface. Visit /admin_setup to get started.")
print("If you're installing this on your local machine, these links should get you there:")
print("\t" + col["blue"]("http://localhost:" + str(port)))
print("\t" + col["blue"]("http://localhost:" + str(port) + "/admin_setup"))
return True
except Exception:
print("Error while starting Maloja.")
return False
def stop():
for attempt in [(signal.SIGTERM,2),(signal.SIGTERM,5),(signal.SIGKILL,3),(signal.SIGKILL,5)]:
pid_sv = get_instance_supervisor()
pid = get_instance()
if pid is None and pid_sv is None:
print("Maloja stopped!")
return True
if pid_sv is not None:
os.kill(pid_sv,attempt[0])
if pid is not None:
os.kill(pid,attempt[0])
time.sleep(attempt[1])
return False
print("Maloja stopped!")
return True
def onlysetup():
print_header_info()
setup()
@ -109,24 +38,6 @@ def run_server():
from . import server
server.run_server()
def run_supervisor():
setproctitle("maloja_supervisor")
while True:
log("Maloja is not running, starting...",module="supervisor")
try:
process = subprocess.Popen(
["python3", "-m", "maloja","run"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
log("Error starting Maloja: " + str(e),module="supervisor")
else:
try:
process.wait()
except Exception as e:
log("Maloja crashed: " + str(e),module="supervisor")
def debug():
os.environ["MALOJA_DEV_MODE"] = 'true'
conf.malojaconfig.load_environment()
@ -139,6 +50,7 @@ def print_info():
print(col['lightblue']("Log Directory: "),conf.dir_settings['logs'])
print(col['lightblue']("Network: "),f"Dual Stack, Port {conf.malojaconfig['port']}" if conf.malojaconfig['host'] == "*" else f"IPv{ip_address(conf.malojaconfig['host']).version}, Port {conf.malojaconfig['port']}")
print(col['lightblue']("Timezone: "),f"UTC{conf.malojaconfig['timezone']:+d}")
print(col['lightblue']("Location Timezone: "),conf.malojaconfig['location_timezone'])
print()
try:
from importlib.metadata import distribution
@ -173,11 +85,7 @@ def main(*args,**kwargs):
actions = {
# server
"start":start,
"restart":restart,
"stop":stop,
"run":run_server,
"supervisor":run_supervisor,
"debug":debug,
"setup":onlysetup,
# admin scripts

View File

@ -4,7 +4,7 @@
# you know what f*ck it
# this is hardcoded for now because of that damn project / package name discrepancy
# i'll fix it one day
VERSION = "3.2.3"
VERSION = "3.2.4"
HOMEPAGE = "https://github.com/krateng/maloja"

View File

@ -3,7 +3,7 @@ from ._exceptions import *
from .. import database
import datetime
from ._apikeys import apikeystore
from ..database.exceptions import DuplicateScrobble
from ..database.exceptions import DuplicateScrobble, DuplicateTimestamp
from ..pkg_global.conf import malojaconfig
@ -27,6 +27,7 @@ class Listenbrainz(APIHandler):
InvalidMethodException: (200, {"code": 200, "error": "Invalid Method"}),
MalformedJSONException: (400, {"code": 400, "error": "Invalid JSON document submitted."}),
DuplicateScrobble: (200, {"status": "ok"}),
DuplicateTimestamp: (409, {"error": "Scrobble with the same timestamp already exists."}),
Exception: (500, {"code": 500, "error": "Unspecified server error."})
}

View File

@ -1,6 +1,8 @@
# server
from bottle import request, response, FormsDict
from ..pkg_global import conf
# decorator that makes sure this function is only run in normal operation,
# not when we run a task that needs to access the database
@ -932,6 +934,9 @@ def get_predefined_rulesets(dbconn=None):
def start_db():
conf.AUX_MODE = True # that is, without a doubt, the worst python code you have ever seen
# Upgrade database
from .. import upgrade
upgrade.upgrade_db(sqldb.add_scrobbles)
@ -941,8 +946,16 @@ def start_db():
from . import associated
associated.load_associated_rules()
# import scrobbles
from ..proccontrol.tasks.import_scrobbles import import_scrobbles #lmao this codebase is so fucked
for f in os.listdir(data_dir['import']()):
if f != 'dummy':
import_scrobbles(data_dir['import'](f))
dbstatus['healthy'] = True
conf.AUX_MODE = False # but you have seen it
# inform time module about begin of scrobbling
try:
firstscrobble = sqldb.get_scrobbles(limit=1)[0]

View File

@ -1,16 +1,18 @@
from datetime import timezone, timedelta, date, time, datetime
from calendar import monthrange
import math
import zoneinfo
from abc import ABC, abstractmethod
from .pkg_global.conf import malojaconfig
OFFSET = malojaconfig["TIMEZONE"]
TIMEZONE = timezone(timedelta(hours=OFFSET))
LOCATION_TIMEZONE = malojaconfig["LOCATION_TIMEZONE"]
TIMEZONE = timezone(timedelta(hours=OFFSET)) if not LOCATION_TIMEZONE or LOCATION_TIMEZONE not in zoneinfo.available_timezones() else zoneinfo.ZoneInfo(LOCATION_TIMEZONE)
UTC = timezone.utc
FIRST_SCROBBLE = int(datetime.utcnow().replace(tzinfo=UTC).timestamp())
FIRST_SCROBBLE = int(datetime.now(UTC).timestamp())
def register_scrobbletime(timestamp):
global FIRST_SCROBBLE
@ -63,7 +65,7 @@ class MTRangeGeneric(ABC):
# whether we currently live or will ever again live in this range
def active(self):
return (self.last_stamp() > datetime.utcnow().timestamp())
return (self.last_stamp() > datetime.now(timezone.utc).timestamp())
def __contains__(self,timestamp):
return timestamp >= self.first_stamp() and timestamp <= self.last_stamp()
@ -111,7 +113,7 @@ class MTRangeGregorian(MTRangeSingular):
# whether we currently live or will ever again live in this range
# USE GENERIC SUPER METHOD INSTEAD
# def active(self):
# tod = datetime.datetime.utcnow().date()
# tod = datetime.datetime.now(timezone.utc).date()
# if tod.year > self.year: return False
# if self.precision == 1: return True
# if tod.year == self.year:
@ -328,7 +330,7 @@ class MTRangeComposite(MTRangeGeneric):
if self.since is None: return FIRST_SCROBBLE
else: return self.since.first_stamp()
def last_stamp(self):
#if self.to is None: return int(datetime.utcnow().replace(tzinfo=timezone.utc).timestamp())
#if self.to is None: return int(datetime.now(timezone.utc).timestamp())
if self.to is None: return today().last_stamp()
else: return self.to.last_stamp()
@ -421,8 +423,8 @@ def get_last_instance(category,current,target,amount):
str_to_time_range = {
**{s:callable for callable,strlist in currenttime_string_representations for s in strlist},
**{s:(lambda i=index:get_last_instance(thismonth,datetime.utcnow().month,i,12)) for index,strlist in enumerate(month_string_representations,1) for s in strlist},
**{s:(lambda i=index:get_last_instance(today,datetime.utcnow().isoweekday()+1%7,i,7)) for index,strlist in enumerate(weekday_string_representations,1) for s in strlist}
**{s:(lambda i=index:get_last_instance(thismonth,datetime.now(timezone.utc).month,i,12)) for index,strlist in enumerate(month_string_representations,1) for s in strlist},
**{s:(lambda i=index:get_last_instance(today,datetime.now(timezone.utc).isoweekday()+1%7,i,7)) for index,strlist in enumerate(weekday_string_representations,1) for s in strlist}
}

View File

@ -207,7 +207,8 @@ malojaconfig = Configuration(
"filters_remix":(tp.Set(tp.String()), "Remix Filters", ["Remix", "Remix Edit", "Short Mix", "Extended Mix", "Soundtrack Version"], "Filters used to recognize the remix artists in the title"),
"parse_remix_artists":(tp.Boolean(), "Parse Remix Artists", False),
"week_offset":(tp.Integer(), "Week Begin Offset", 0, "Start of the week for the purpose of weekly statistics. 0 = Sunday, 6 = Saturday"),
"timezone":(tp.Integer(), "UTC Offset", 0)
"timezone":(tp.Integer(), "UTC Offset", 0),
"location_timezone":(tp.String(), "Location Timezone", None)
},
"Web Interface":{
"default_range_startpage":(tp.Choice({'alltime':'All Time','year':'Year','month':"Month",'week':'Week'}), "Default Range for Startpage Stats", "year"),
@ -298,6 +299,7 @@ data_directories = {
"auth":pthj(dir_settings['state'],"auth"),
"backups":pthj(dir_settings['state'],"backups"),
"images":pthj(dir_settings['state'],"images"),
"import":pthj(dir_settings['state'],"import"),
"scrobbles":pthj(dir_settings['state']),
"rules":pthj(dir_settings['config'],"rules"),
"clients":pthj(dir_settings['config']),

View File

@ -34,9 +34,12 @@ def import_scrobbles(inputf):
filename = os.path.basename(inputf)
importfunc = None
if re.match(r"recenttracks-.*\.csv", filename):
typeid, typedesc = "lastfm", "Last.fm (ghan CSV)"
importfunc = parse_lastfm_ghan_csv
if re.match(r".*\.csv", filename):
typeid,typedesc = "lastfm", "Last.fm (benjaminbenben export)"
elif re.match(r".*\.csv", filename):
typeid,typedesc = "lastfm", "Last.fm (benjaminbenben CSV)"
importfunc = parse_lastfm
elif re.match(r"Streaming_History_Audio.+\.json", filename):
@ -65,8 +68,8 @@ def import_scrobbles(inputf):
importfunc = parse_rockbox
elif re.match(r"recenttracks-.*\.json", filename):
typeid, typedesc = "lastfm", "Last.fm (ghan export)"
importfunc = parse_lastfm_ghan
typeid, typedesc = "lastfm", "Last.fm (ghan JSON)"
importfunc = parse_lastfm_ghan_json
elif re.match(r".*\.json",filename):
try:
@ -83,8 +86,8 @@ def import_scrobbles(inputf):
return result
print(f"Parsing {col['yellow'](inputf)} as {col['cyan'](typedesc)} export")
print("This could take a while...")
print(f"Parsing {col['yellow'](inputf)} as {col['cyan'](typedesc)} export.")
print(col['red']("Please double-check if this is correct - if the import fails, the file might have been interpreted as the wrong type."))
timestamps = set()
scrobblebuffer = []
@ -154,21 +157,22 @@ def parse_spotify_lite_legacy(inputf):
inputf = pth.abspath(inputf)
inputfolder = pth.dirname(inputf)
filenames = re.compile(r'StreamingHistory[0-9]+\.json')
inputfiles = [os.path.join(inputfolder,f) for f in os.listdir(inputfolder) if filenames.match(f)]
#inputfiles = [os.path.join(inputfolder,f) for f in os.listdir(inputfolder) if filenames.match(f)]
inputfiles = [inputf]
if len(inputfiles) == 0:
print("No files found!")
return
#if len(inputfiles) == 0:
# print("No files found!")
# return
if inputfiles != [inputf]:
print("Spotify files should all be imported together to identify duplicates across the whole dataset.")
if not ask("Import " + ", ".join(col['yellow'](pth.basename(i)) for i in inputfiles) + "?",default=True):
inputfiles = [inputf]
print("Only importing", col['yellow'](pth.basename(inputf)))
#if inputfiles != [inputf]:
# print("Spotify files should all be imported together to identify duplicates across the whole dataset.")
# if not ask("Import " + ", ".join(col['yellow'](pth.basename(i)) for i in inputfiles) + "?",default=True):
# inputfiles = [inputf]
# print("Only importing", col['yellow'](pth.basename(inputf)))
for inputf in inputfiles:
print("Importing",col['yellow'](inputf),"...")
#print("Importing",col['yellow'](inputf),"...")
with open(inputf,'r') as inputfd:
data = json.load(inputfd)
@ -207,21 +211,22 @@ def parse_spotify_lite(inputf):
inputf = pth.abspath(inputf)
inputfolder = pth.dirname(inputf)
filenames = re.compile(r'Streaming_History_Audio.+\.json')
inputfiles = [os.path.join(inputfolder,f) for f in os.listdir(inputfolder) if filenames.match(f)]
#inputfiles = [os.path.join(inputfolder,f) for f in os.listdir(inputfolder) if filenames.match(f)]
inputfiles = [inputf]
if len(inputfiles) == 0:
print("No files found!")
return
#if len(inputfiles) == 0:
# print("No files found!")
# return
if inputfiles != [inputf]:
print("Spotify files should all be imported together to identify duplicates across the whole dataset.")
if not ask("Import " + ", ".join(col['yellow'](pth.basename(i)) for i in inputfiles) + "?",default=True):
inputfiles = [inputf]
print("Only importing", col['yellow'](pth.basename(inputf)))
#if inputfiles != [inputf]:
# print("Spotify files should all be imported together to identify duplicates across the whole dataset.")
# if not ask("Import " + ", ".join(col['yellow'](pth.basename(i)) for i in inputfiles) + "?",default=True):
# inputfiles = [inputf]
# print("Only importing", col['yellow'](pth.basename(inputf)))
for inputf in inputfiles:
print("Importing",col['yellow'](inputf),"...")
#print("Importing",col['yellow'](inputf),"...")
with open(inputf,'r') as inputfd:
data = json.load(inputfd)
@ -267,17 +272,18 @@ def parse_spotify(inputf):
inputf = pth.abspath(inputf)
inputfolder = pth.dirname(inputf)
filenames = re.compile(r'endsong_[0-9]+\.json')
inputfiles = [os.path.join(inputfolder,f) for f in os.listdir(inputfolder) if filenames.match(f)]
#inputfiles = [os.path.join(inputfolder,f) for f in os.listdir(inputfolder) if filenames.match(f)]
inputfiles = [inputf]
if len(inputfiles) == 0:
print("No files found!")
return
#if len(inputfiles) == 0:
# print("No files found!")
# return
if inputfiles != [inputf]:
print("Spotify files should all be imported together to identify duplicates across the whole dataset.")
if not ask("Import " + ", ".join(col['yellow'](pth.basename(i)) for i in inputfiles) + "?",default=True):
inputfiles = [inputf]
print("Only importing", col['yellow'](pth.basename(inputf)))
#if inputfiles != [inputf]:
# print("Spotify files should all be imported together to identify duplicates across the whole dataset.")
# if not ask("Import " + ", ".join(col['yellow'](pth.basename(i)) for i in inputfiles) + "?",default=True):
# inputfiles = [inputf]
# print("Only importing", col['yellow'](pth.basename(inputf)))
# we keep timestamps here as well to remove duplicates because spotify's export
# is messy - this is specific to this import type and should not be mixed with
@ -288,7 +294,7 @@ def parse_spotify(inputf):
for inputf in inputfiles:
print("Importing",col['yellow'](inputf),"...")
#print("Importing",col['yellow'](inputf),"...")
with open(inputf,'r') as inputfd:
data = json.load(inputfd)
@ -408,7 +414,7 @@ def parse_lastfm(inputf):
continue
def parse_lastfm_ghan(inputf):
def parse_lastfm_ghan_json(inputf):
with open(inputf, 'r') as inputfd:
data = json.load(inputfd)
@ -430,6 +436,21 @@ def parse_lastfm_ghan(inputf):
}, '')
def parse_lastfm_ghan_csv(inputf):
with open(inputf, 'r') as inputfd:
reader = csv.DictReader(inputfd)
for row in reader:
yield ('CONFIDENT_IMPORT', {
'track_title': row['track'],
'track_artists': row['artist'],
'track_length': None,
'album_name': row['album'],
'scrobble_time': int(row['uts']),
'scrobble_duration': None
}, '')
def parse_listenbrainz(inputf):
with open(inputf,'r') as inputfd:

View File

@ -3,6 +3,7 @@ import os
from threading import Thread
from importlib import resources
import time
from magic import from_file
# server stuff
@ -154,7 +155,8 @@ def static_image(pth):
@webserver.route("/cacheimages/<uuid>")
def static_proxied_image(uuid):
return static_file(uuid,root=data_dir['cache']('images'))
mimetype = from_file(os.path.join(data_dir['cache']('images'),uuid),True)
return static_file(uuid,root=data_dir['cache']('images'),mimetype=mimetype)
@webserver.route("/login")
def login():
@ -165,16 +167,16 @@ def login():
@webserver.route("/media/<name>.<ext>")
def static(name,ext):
assert ext in ["txt","ico","jpeg","jpg","png","less","js","ttf","css"]
with resources.files('maloja') / 'web' / 'static' as staticfolder:
response = static_file(ext + "/" + name + "." + ext,root=staticfolder)
staticfolder = resources.files('maloja') / 'web' / 'static'
response = static_file(ext + "/" + name + "." + ext,root=staticfolder)
response.set_header("Cache-Control", "public, max-age=3600")
return response
# new, direct reference
@webserver.route("/static/<path:path>")
def static(path):
with resources.files('maloja') / 'web' / 'static' as staticfolder:
response = static_file(path,root=staticfolder)
staticfolder = resources.files('maloja') / 'web' / 'static'
response = static_file(path,root=staticfolder)
response.set_header("Cache-Control", "public, max-age=3600")
return response

View File

@ -1,10 +1,10 @@
import os
import shutil
import stat
from importlib import resources
try:
from setuptools import distutils
except ImportError:
import distutils
from pathlib import PosixPath
from doreah.io import col, ask, prompt
from .pkg_global.conf import data_dir, dir_settings, malojaconfig, auth
@ -22,21 +22,33 @@ ext_apikeys = [
def copy_initial_local_files():
with resources.files("maloja") / 'data_files' as folder:
for cat in dir_settings:
if dir_settings[cat] is None:
continue
data_file_source = resources.files("maloja") / 'data_files'
for cat in dir_settings:
if dir_settings[cat] is None:
continue
if cat == 'config' and malojaconfig.readonly:
continue
if cat == 'config' and malojaconfig.readonly:
continue
# to avoid permission problems with the root dir
for subfolder in os.listdir(data_file_source / cat):
src = data_file_source / cat / subfolder
dst = PosixPath(dir_settings[cat]) / subfolder
if os.path.isdir(src):
shutil.copytree(src, dst, dirs_exist_ok=True)
# fix permissions (u+w)
for dirpath, _, filenames in os.walk(dst):
os.chmod(dirpath, os.stat(dirpath).st_mode | stat.S_IWUSR)
for filename in filenames:
filepath = os.path.join(dirpath, filename)
os.chmod(filepath, os.stat(filepath).st_mode | stat.S_IWUSR)
distutils.dir_util.copy_tree(os.path.join(folder,cat),dir_settings[cat],update=False)
charset = list(range(10)) + list("abcdefghijklmnopqrstuvwxyz") + list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
def randomstring(length=32):
import random
return "".join(str(random.choice(charset)) for _ in range(length))
def setup():
copy_initial_local_files()
@ -51,10 +63,11 @@ def setup():
print(f"\tCurrently not using a {col['red'](keyname)} for image display.")
elif key is None or key == "ASK":
if malojaconfig.readonly:
continue
promptmsg = f"\tPlease enter your {col['gold'](keyname)}. If you do not want to use one at this moment, simply leave this empty and press Enter."
key = prompt(promptmsg,types=(str,),default=False,skip=SKIP)
malojaconfig[k] = key
print(f"\tCurrently not using a {col['red'](keyname)} for image display - config is read only.")
else:
promptmsg = f"\tPlease enter your {col['gold'](keyname)}. If you do not want to use one at this moment, simply leave this empty and press Enter."
key = prompt(promptmsg,types=(str,),default=False,skip=SKIP)
malojaconfig[k] = key
else:
print(f"\t{col['lawngreen'](keyname)} found.")

View File

@ -15,7 +15,7 @@
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/newrule?", true);
xhttp.open("POST","/apis/mlj_1/newrule?", true);
xhttp.send(keys);
e = arguments[0];
line = e.parentNode;
@ -25,7 +25,7 @@
function fullrebuild() {
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/rebuild", true);
xhttp.open("POST","/apis/mlj_1/rebuild", true);
xhttp.send();
window.location = "/wait";

View File

@ -24,7 +24,7 @@
keys = "filename=" + encodeURIComponent(filename);
console.log(keys);
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/importrules", true);
xhttp.open("POST","/apis/mlj_1/importrules", true);
xhttp.send(keys);
e.innerHTML = e.innerHTML.replace("Add","Remove");
@ -36,7 +36,7 @@
keys = "remove&filename=" + encodeURIComponent(filename);
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/importrules", true);
xhttp.open("POST","/apis/mlj_1/importrules", true);
xhttp.send(keys);
e.innerHTML = e.innerHTML.replace("Remove","Add");

View File

@ -186,7 +186,7 @@ function search_manualscrobbling(searchfield) {
else {
xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = searchresult_manualscrobbling;
xhttp.open("GET","/api/search?max=5&query=" + encodeURIComponent(txt), true);
xhttp.open("GET","/apis/mlj_1/search?max=5&query=" + encodeURIComponent(txt), true);
xhttp.send();
}
}

View File

@ -1,17 +1,23 @@
var searches = []
var searches = [];
var debounceTimer;
function search(searchfield) {
txt = searchfield.value;
if (txt == "") {
reallyclear()
}
else {
xhttp = new XMLHttpRequest();
searches.push(xhttp)
xhttp.onreadystatechange = searchresult
xhttp.open("GET","/api/search?max=5&query=" + encodeURIComponent(txt), true);
xhttp.send();
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const txt = searchfield.value;
if (txt == "") {
reallyclear();
}
else {
const xhttp = new XMLHttpRequest();
searches.push(xhttp);
xhttp.onreadystatechange = searchresult
xhttp.open("GET","/apis/mlj_1/search?max=5&query=" + encodeURIComponent(txt), true);
xhttp.send();
}
}, 1000);
}

View File

@ -1,3 +1,3 @@
function upload(encodedentity,b64) {
neo.xhttprequest("/api/addpicture?" + encodedentity,{"b64":b64},"POST")
neo.xhttprequest("/apis/mlj_1/addpicture?" + encodedentity,{"b64":b64},"POST")
}

View File

@ -1,10 +1,10 @@
[project]
name = "malojaserver"
version = "3.2.3"
version = "3.2.4"
description = "Self-hosted music scrobble database"
readme = "./README.md"
requires-python = ">=3.11"
license = { file="./LICENSE" }
readme = "README.md"
requires-python = "==3.12.*"
license = { file="LICENSE" }
authors = [ { name="Johannes Krattenmacher", email="maloja@dev.krateng.ch" } ]
urls.repository = "https://github.com/krateng/maloja"
@ -19,33 +19,32 @@ classifiers = [
]
dependencies = [
"bottle>=0.12.16",
"waitress>=2.1.0",
"doreah>=2.0.1, <3",
"nimrodel>=0.8.0",
"setproctitle>=1.1.10",
#"pyvips>=2.1.16",
"jinja2>=3.0.0",
"lru-dict>=1.1.6",
"psutil>=5.8.0",
"sqlalchemy>=2.0",
"python-datauri>=1.1.0",
"requests>=2.27.1",
"setuptools>68.0.0",
"toml>=0.10.2",
"PyYAML>=6.0.1"
"bottle==0.13.*",
"waitress==3.0.*",
"doreah==2.0.*",
"nimrodel==0.8.*",
"setproctitle==1.3.*",
"jinja2==3.1.*",
"lru-dict==1.3.*",
"psutil==5.9.*",
"sqlalchemy==2.0",
"python-datauri==3.0.*",
"python-magic==0.4.*",
"requests==2.32.*",
"toml==0.10.*",
"PyYAML==6.0.*"
]
[project.optional-dependencies]
full = [
"pyvips>=2.1"
"pyvips==2.2.*"
]
[project.scripts]
maloja = "maloja.__main__:main"
[build-system]
requires = ["flit_core >=3.2,<4"]
requires = ["flit_core >=3.10,<4"]
build-backend = "flit_core.buildapi"
[tool.flit.module]
@ -66,7 +65,8 @@ build =[
run = [
"python3",
"py3-lxml",
"tzdata"
"tzdata",
"libmagic"
]
opt = [
"vips"

View File

@ -1,14 +1,15 @@
bottle>=0.12.16
waitress>=2.1.0
doreah>=2.0.1, <3
nimrodel>=0.8.0
setproctitle>=1.1.10
jinja2>=3.0.0
lru-dict>=1.1.6
psutil>=5.8.0
sqlalchemy>=2.0
python-datauri>=1.1.0
requests>=2.27.1
setuptools>68.0.0
toml>=0.10.2
PyYAML>=6.0.1
bottle==0.13.*
waitress==3.0.*
doreah==2.0.*
nimrodel==0.8.*
setproctitle==1.3.*
jinja2==3.1.*
lru-dict==1.3.*
psutil==5.9.*
sqlalchemy==2.0
python-datauri==3.0.*
python-magic==0.4.*
requests==2.32.*
toml==0.10.*
PyYAML==6.0.*

View File

@ -1,2 +1,2 @@
pyvips>=2.1
pyvips==2.2.*

View File

@ -68,6 +68,7 @@ Settings File | Environment Variable | Type | Description
`parse_remix_artists` | `MALOJA_PARSE_REMIX_ARTISTS` | Boolean | Parse Remix Artists
`week_offset` | `MALOJA_WEEK_OFFSET` | Integer | Start of the week for the purpose of weekly statistics. 0 = Sunday, 6 = Saturday
`timezone` | `MALOJA_TIMEZONE` | Integer | UTC Offset
`location_timezone` | `MALOJA_LOCATION_TIMEZONE` | String | Location Timezone (overrides `timezone`)
**Web Interface**
`default_range_startpage` | `MALOJA_DEFAULT_RANGE_STARTPAGE` | Choice | Default Range for Startpage Stats
`default_step_pulse` | `MALOJA_DEFAULT_STEP_PULSE` | Choice | Default Pulse Step

View File

@ -1,40 +0,0 @@
import setuptools
import toml
with open("pyproject.toml") as fd:
pkgdata = toml.load(fd)
projectdata = pkgdata['project']
# extract info
with open(projectdata['readme'], "r") as fh:
long_description = fh.read()
setuptools.setup(
name=projectdata['name'],
version=projectdata['version'],
author=projectdata['authors'][0]['name'],
author_email=projectdata['authors'][0]['email'],
description=projectdata["description"],
license="GPLv3",
long_description=long_description,
long_description_content_type="text/markdown",
url=projectdata['urls']['repository'],
packages=setuptools.find_packages("."),
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
],
python_requires=projectdata['requires-python'],
install_requires=projectdata['dependencies'],
package_data={'': ['*','*/*','*/*/*','*/*/*/*','*/*/.*','*/*/*/.*']},
include_package_data=True,
entry_points = {
'console_scripts':[
k + '=' + projectdata['scripts'][k] for k in projectdata['scripts']
]
}
)