Skip to content

Commit

Permalink
Merge pull request #22 from pSpitzner/Library-frontend-rework
Browse files Browse the repository at this point in the history
Merged rework of library, adding search and fixes for mobile
  • Loading branch information
pSpitzner authored Aug 1, 2024
2 parents a909d58 + 30170a0 commit d7dec60
Show file tree
Hide file tree
Showing 72 changed files with 7,970 additions and 1,033 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.conda/*
local_data/*
beets_flask/beets/*
tinker.ipynb
tinker/*
static/bootstrap/*
static/bootstrap-icons/*
docker-compose-custom.yaml
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.0.3] - 24-08-01

### Fixed
- default config: mandatory fields cannot be set in the yaml, or they
might persist although the user sets them. moved to config loading in python.
- tmux session now restarts on page load if it is not alive.
- navbar, tags, inbox are now more friendly for mobile
- folder paths are now better escaped for terminal imports

### Added
- Backend to get cover art from metadata of music files.
- Impoved library view (mobile friendly, and a browser header component)
- Library search

### Changed
- Simplified folder structure of frontend
- Removed `include_paths` option from config and library backend (most of the frontend needs some form of file paths. thus, the option was not / could not be respected consistently)

## [0.0.2] - 24-07-16

Expand All @@ -15,4 +32,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 0.0.1 - 24-05-22
- initial commit

[0.0.3]: https://github.com/pSpitzner/beets-flask/compare/v0.0.2...v0.0.3
[0.0.2]: https://github.com/pSpitzner/beets-flask/compare/v0.0.1...v0.0.2
36 changes: 27 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ This is the main idea with beets-flask: For all folders in your inbox, we genera
- Autogenerate previews before importing
- Import via GUI (if found matches are okay)
- Import via Web-Terminal using beets as you know it (to correct matches)
- Undo imports (uses web terminal)
- Undo imports
- Monitor multiple inboxes
- A basic library view
- A basic library view and search
- Most File/Tag actions sit in a context menu (right-click, or long-press on touch)

![demo gif](https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExcDZmZjJ0NzA0Z3h4Z2tycnBlMG1mbm9mMXFoMWM1bjJwdDBsOXR1NiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/Z3lL2fo5m6UNf85dZT/giphy.gif)
Expand All @@ -29,7 +29,8 @@ This is the main idea with beets-flask: For all folders in your inbox, we genera
- Clone the repo
- Adjust config files
- Place a folder with music files into your inbox
- Build and run `docker compose up --build`, check for problems
- Build and run `docker compose up --build`
- Check the webinterface, by default at `http://localhost:5001`
- Once happy, you can run the container as a daemon with `docker compose up -d --build`

### Config
Expand Down Expand Up @@ -90,19 +91,18 @@ gui:
library:
readonly: no
include_paths: yes
tags:
expand_tags: yes # for tag groups, on page load, show tag details?
recent_days: 14 # Number of days to consider for the "recent" tag group
order_by: "name" # how to sort tags within the trag groups: "name" (the album folder basename) | "date_created" | "date_modified"
terminal:
start_path: "/music/inbox" # the directory where to start new terminal sessions
inbox:
concat_nested_folders: yes # show multiple folders in one line if they only have one child
expand_files: no # on page load, show files in (album) folders, or collapse them
order_by: "name" # how to sort tags within the trag groups: "name" (the album folder basename) | "date_created" | "date_modified"
folders: # keep in mind to volume-map these folders in your docker-compose.yml
Inbox:
Expand All @@ -118,21 +118,27 @@ To access the tmux from the host:
```
docker exec -it beets-flask /usr/bin/tmux attach-session -t beets-socket-term
```
Beware, you can close the tmux session, and we have not yet implemented a way to restart it. (Just restart the container)

If you use iTerm on macOS and want to create a profile for connecting to the tmux session natively:
```
ssh -t yourserver "/usr/bin/docker exec -it beets-flask /usr/bin/tmux -CC new -A -s beets-socket-term"
```

## Roadmap

For the current state, there is a [KanBan board](https://github.com/users/pSpitzner/projects/2/views/1).

Major things that are planned:

- An actual library view, with search, covers and audio preview. The backend is likely up for the task already.
- Better library view, improved cover handling and audio preview.
- Push the image to dockerhub
- Mobile friendly
- Mobile friendly (started)


# Developing

The current state is pretty much a playground. Only essential features are included, but most tools are in place to easily add whatever you feel like.

## Tech Stack

- Backend:
Expand All @@ -153,7 +159,7 @@ Major things that are planned:

## Notes, Design Choices and Ideas

- The current docker-compose already creates the dev container:
- See [docker-compose-dev.yaml](/docker-compose-dev.yaml) to createe the dev container:
- maps `.repo` to edit the source from the host.
- runs `entrypoint_dev.sh`, starting redis workers, flask, and the vite dev server
- It seems that our vite dev setup **does not work with safari** because it uses CORS
Expand Down Expand Up @@ -202,3 +208,15 @@ The library view backend is adapted from the existing beets webplugin that is al
### Testing

We have started on a version of the container that runs some (backend) tests, but coverage is pretty non-existent.


### Convention for typescript imports

We have an eslint sorting rule for imports:

Order:
1. other modules/components
2. our modules/components
3. css (first others, then ours)

Try to use absolute paths with `@/` prefix if not in the same folder.
2 changes: 1 addition & 1 deletion backend/beets_flask/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.0.2'
__version__ = '0.0.3'
14 changes: 14 additions & 0 deletions backend/beets_flask/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"""

import confuse
import os
from beets_flask.utility import log
from beets import config

Expand All @@ -31,3 +32,16 @@
# running as module. but we should place the default config where confuse looks for it.
default_source = confuse.YamlSource("./configs/default.yaml", default=True)
config.add(default_source) # .add inserts with lowest priority

# add placeholders for required keys if they are not configred,
# so the docker container starts and can show some help.

if not os.path.exists("/home/beetle/.config/beets/config.yaml"):
config["directory"] = "/music/imported"

if len(config["gui"]["inbox"]["folders"].keys()) == 0:
config["gui"]["inbox"]["folders"]["Placeholder"] = {
"name": "Please check your config!",
"path": "/music/inbox",
"autotag": False,
}
122 changes: 98 additions & 24 deletions backend/beets_flask/routes/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import os
from pathlib import Path
from typing import Optional, TypedDict, cast
from io import BytesIO
from PIL import Image as PILImage
import time

from flask import (
Expand All @@ -38,6 +40,7 @@
from werkzeug.routing import BaseConverter, PathConverter

import beets.library
from mediafile import MediaFile # comes with the beets install
from beets import ui, util
from beets.ui import _open_library
from beets_flask.config import config
Expand All @@ -59,22 +62,28 @@ def _rep(obj, expand=False, minimal=False):
"""
out = dict(obj)

# For out client side, we want to have a consistent name for each kind of item.
# For our client side, we want to have a consistent name for each kind of item.
# for tracks its the title, for albums album name...
out["name"] = (
out.get("title", None) or out.get("album", None) or out.get("artist", None)
)

if minimal:
out = {k: v for k, v in out.items() if k in ["id", "name"]}

if isinstance(obj, beets.library.Item):
if minimal:
fields = [
"id",
"name",
"artist",
"albumartist",
"album",
"album_id",
"year",
"isrc",
]
out = {k: v for k, v in out.items() if k in fields}

if not minimal:
if config["gui"]["library"]["include_paths"].get(bool):
out["path"] = util.displayable_path(out["path"])
else:
del out["path"]
out["path"] = util.displayable_path(out["path"])

for key, value in out.items():
if isinstance(out[key], bytes):
Expand All @@ -90,11 +99,11 @@ def _rep(obj, expand=False, minimal=False):
return out

elif isinstance(obj, beets.library.Album):
if not minimal:
if config["gui"]["library"]["include_paths"].get(bool):
out["artpath"] = util.displayable_path(out["artpath"])
else:
del out["artpath"]
if minimal:
fields = ["id", "name", "albumartist", "year"]
out = {k: v for k, v in out.items() if k in fields}
else:
out["artpath"] = util.displayable_path(out["artpath"])

if expand:
out["items"] = [
Expand Down Expand Up @@ -218,8 +227,21 @@ def resource_query(name, patchable=False):

def make_responder(query_func):
def responder(queries):
# we set the route to use a path converter before us,
# so queries is a single string.
# edgecase: trailing escape character `\` would crash. we should
# also avoid this in the frontend.
if (
queries.endswith("\\")
and (len(queries) - len(queries.rstrip("\\"))) % 2 == 1
):
# only remove the last character if it is a single escape character
queries = queries[:-1]

entities = query_func(queries)

log.debug(queries)

if get_method() == "DELETE":
if config["gui"]["library"]["readonly"].get(bool):
return abort(405)
Expand Down Expand Up @@ -409,7 +431,7 @@ def item_file(item_id):
return response


@library_bp.route("/item/query/<query:queries>", methods=["GET", "DELETE", "PATCH"])
@library_bp.route("/item/query/<path:queries>", methods=["GET", "DELETE", "PATCH"])
@resource_query("items", patchable=True)
def item_query(queries):
return g.lib.items(queries)
Expand Down Expand Up @@ -453,21 +475,12 @@ def all_albums():
return g.lib.albums()


@library_bp.route("/album/query/<query:queries>", methods=["GET", "DELETE"])
@library_bp.route("/album/query/<path:queries>", methods=["GET", "DELETE"])
@resource_query("albums")
def album_query(queries):
return g.lib.albums(queries)


@library_bp.route("/album/<int:album_id>/art")
def album_art(album_id):
album = g.lib.get_album(album_id)
if album and album.artpath:
return send_file(album.artpath.decode())
else:
return abort(404)


@library_bp.route("/album/values/<string:key>")
def album_unique_field_values(key):
sort_key = request.args.get("sort_key", key)
Expand All @@ -487,6 +500,67 @@ def album_items(album_id):
return abort(404)


# ------------------------------------------------------------------------------------ #
# Artwork #
# ------------------------------------------------------------------------------------ #


@library_bp.route("/item/<int:item_id>/art")
def item_art(item_id):
log.debug(f"Item art query for '{item_id}'")
item: beets.library.Item = g.lib.get_item(item_id)
item_path = util.py3_path(item.path)
if not os.path.exists(item_path):
return abort(404, description="Media file not found")
mediafile = MediaFile(item_path)
if mediafile.art:
return _send_image(BytesIO(mediafile.art))
else:
abort(404, description="Item has no cover art")


@library_bp.route("/album/<int:album_id>/art")
def album_art(album_id):
log.debug(f"Art art query for album id '{album_id}'")
album = g.lib.get_album(album_id)
if album and album.artpath:
return _send_image(BytesIO(album.artpath.decode()))
elif album:
# Check the first item in the album for embedded cover art
try:
first_item: beets.library.Item = album.items()[0]
item_path = util.py3_path(first_item.path)
if not os.path.exists(item_path):
return abort(404, description="Media file not found")
mediafile = MediaFile(item_path)
if mediafile.art:
return _send_image(BytesIO(mediafile.art))
else:
return abort(404, description="Item has no cover art")
except:
return abort(500, description="Failed to get album items")

else:
return abort(404, description="No art for this album id, or id does not exist")


def _send_image(img_data: BytesIO):
max_size = (200, 200)
img = _resize(img_data, max_size)
response = make_response(send_file(img, mimetype="image/jpeg"))
response.headers["Cache-Control"] = "public, max-age=86400"
return response


def _resize(img_data: BytesIO, size: tuple[int, int]) -> BytesIO:
image = PILImage.open(img_data)
image.thumbnail(size)
image_io = BytesIO()
image.save(image_io, format="JPEG")
image_io.seek(0)
return image_io


# ------------------------------------------------------------------------------------ #
# Hierachical API: artist > album > track #
# ------------------------------------------------------------------------------------ #
Expand Down
Loading

0 comments on commit d7dec60

Please sign in to comment.