diff --git a/.github/changelog.md b/.github/changelog.md
index 6f31445..08d4790 100644
--- a/.github/changelog.md
+++ b/.github/changelog.md
@@ -1,10 +1,23 @@
-`v1.3.1` is a patch release to fix issue #149
+This version adds a few new features and minor bug fixes.
-# Bug fix
-✅ `ValueError: Decompressed Data Too Large` error when processing images with large album covers.
+# What's new?
----
+1. Synced lyrics support #126
+2. Lyrics finder plugin (experimental)
+3. Context option to search artist or album on streaming platforms
-_See [changelog for `v1.3.0`](https://github.com/swing-opensource/swingmusic/releases/tag/v1.3.0) for all main changes since `v1.2.0`._
+### Lyrics support
-Have fun!
\ No newline at end of file
+You can now sing along your music with the new lyrics feature. Click the lyrics button in the bottom bar to view lyrics.
+
+### Lyrics finder plugin
+
+This experimental will find synced lyrics for you. Go to `settings > plugins` to start using it. When lyrics are found, they will be saved to a lrc file in the same directory as the playing track.
+
+# Bug fixes
+1. Blank page on safari #155
+2. Telemetry has been removed #153
+3. Seeking before playing will maintain the position when playback starts
+4. A forgotten few
+
+_PS: I also attempted to add a cool fullscreen standby view, but I couldn't animate the images when going to the next/prev track, so I ditched it_
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2ef7fcb..a95ce62 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -72,8 +72,9 @@ jobs:
run: |
python -m poetry run python manage.py --build
env:
- POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }}
+ PLUGIN_LYRICS_AUTHORITY: ${{ secrets.PLUGIN_LYRICS_AUTHORITY }}
+ PLUGIN_LYRICS_ROOT_URL: ${{ secrets.PLUGIN_LYRICS_ROOT_URL }}
- name: Verify Linux build success
if: matrix.os == 'ubuntu-20.04'
run: |
@@ -102,7 +103,7 @@ jobs:
name: win32
path: dist/swingmusic.exe
retention-days: 1
-
+
release:
name: Create New Release
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index 93d7fe9..5470e37 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
Swing Music
-v1.3.1
+v1.4.0
**[Download](https://swingmusic.vercel.app/downloads) •
Support Development • [Browse Docs](https://swingmusic.vercel.app/guide/introduction.html) • [Screenshots](https://swingmusic.vercel.app)
**
diff --git a/app/api/__init__.py b/app/api/__init__.py
index d9945ae..2474e46 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -6,6 +6,7 @@ from flask import Flask
from flask_compress import Compress
from flask_cors import CORS
+from .plugins import lyrics as lyrics_plugin
from app.api import (
album,
artist,
@@ -17,6 +18,8 @@ from app.api import (
search,
send_file,
settings,
+ lyrics,
+ plugins,
)
@@ -43,5 +46,10 @@ def create_api():
app.register_blueprint(imgserver.api)
app.register_blueprint(settings.api)
app.register_blueprint(colors.api)
+ app.register_blueprint(lyrics.api)
+
+ # Plugins
+ app.register_blueprint(plugins.api)
+ app.register_blueprint(lyrics_plugin.api)
return app
diff --git a/app/api/album.py b/app/api/album.py
index b0bf059..c9f1815 100644
--- a/app/api/album.py
+++ b/app/api/album.py
@@ -44,7 +44,7 @@ def get_album_tracks_and_info():
album = AlbumStore.get_album_by_hash(albumhash)
if album is None:
- return error_msg, 204
+ return error_msg, 404
tracks = TrackStore.get_tracks_by_albumhash(albumhash)
@@ -52,7 +52,7 @@ def get_album_tracks_and_info():
return error_msg, 404
if len(tracks) == 0:
- return error_msg, 204
+ return error_msg, 404
def get_album_genres(tracks: list[Track]):
genres = set()
diff --git a/app/api/artist.py b/app/api/artist.py
index 2893987..235653c 100644
--- a/app/api/artist.py
+++ b/app/api/artist.py
@@ -1,8 +1,8 @@
"""
Contains all the artist(s) routes.
"""
-import random
import math
+import random
from datetime import datetime
from flask import Blueprint, request
@@ -15,29 +15,15 @@ from app.serializers.track import serialize_tracks
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
-from app.telemetry import Telemetry
-from app.utils.threading import background
api = Blueprint("artist", __name__, url_prefix="/")
-ARTIST_VISIT_COUNT = 0
-
-
-@background
-def send_event():
- global ARTIST_VISIT_COUNT
- ARTIST_VISIT_COUNT += 1
-
- if ARTIST_VISIT_COUNT % 5 == 0:
- Telemetry.send_artist_visited()
-
@api.route("/artist/", methods=["GET"])
def get_artist(artisthash: str):
"""
Get artist data.
"""
- send_event()
limit = request.args.get("limit")
if limit is None:
@@ -211,7 +197,6 @@ def get_similar_artists(artisthash: str):
if artist is None:
return {"error": "Artist not found"}, 404
- # result = LastFMStore.get_similar_artists_for(artist.artisthash)
result = fmdb.get_similar_artists_for(artist.artisthash)
if result is None:
diff --git a/app/api/folder.py b/app/api/folder.py
index 54f9d08..deafdcf 100644
--- a/app/api/folder.py
+++ b/app/api/folder.py
@@ -68,14 +68,24 @@ def get_all_drives(is_win: bool = False):
"""
Returns a list of all the drives on a Windows machine.
"""
- drives = psutil.disk_partitions()
+ drives = psutil.disk_partitions(all=True)
drives = [d.mountpoint for d in drives]
if is_win:
drives = [win_replace_slash(d) for d in drives]
else:
- remove = ["/boot", "/boot/efi", "/tmp"]
- drives = [d for d in drives if d not in remove]
+ remove = (
+ "/boot",
+ "/tmp",
+ "/snap",
+ "/var",
+ "/sys",
+ "/proc",
+ "/etc",
+ "/run",
+ "/dev",
+ )
+ drives = [d for d in drives if not d.startswith(remove)]
return drives
@@ -94,8 +104,6 @@ def list_folders():
req_dir = "$root"
if req_dir == "$root":
- # req_dir = settings.USER_HOME_DIR
- # if is_win:
return {
"folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)]
}
diff --git a/app/api/lyrics.py b/app/api/lyrics.py
new file mode 100644
index 0000000..7e86ed4
--- /dev/null
+++ b/app/api/lyrics.py
@@ -0,0 +1,59 @@
+from flask import Blueprint, request
+
+from app.lib.lyrics import (
+ get_lyrics,
+ check_lyrics_file,
+ get_lyrics_from_duplicates,
+ get_lyrics_from_tags,
+)
+
+api = Blueprint("lyrics", __name__, url_prefix="")
+
+
+@api.route("/lyrics", methods=["POST"])
+def send_lyrics():
+ """
+ Returns the lyrics for a track
+ """
+ data = request.get_json()
+
+ filepath = data.get("filepath", None)
+ trackhash = data.get("trackhash", None)
+
+ if filepath is None or trackhash is None:
+ return {"error": "No filepath or trackhash provided"}, 400
+
+ is_synced = True
+ lyrics, copyright = get_lyrics(filepath)
+
+ if not lyrics:
+ lyrics, copyright = get_lyrics_from_duplicates(trackhash, filepath)
+
+ if not lyrics:
+ lyrics, is_synced, copyright = get_lyrics_from_tags(filepath)
+
+ if not lyrics:
+ return {"error": "No lyrics found"}
+
+ return {"lyrics": lyrics, "synced": is_synced, "copyright": copyright}, 200
+
+
+@api.route("/lyrics/check", methods=["POST"])
+def check_lyrics():
+ data = request.get_json()
+
+ filepath = data.get("filepath", None)
+ trackhash = data.get("trackhash", None)
+
+ if filepath is None or trackhash is None:
+ return {"error": "No filepath or trackhash provided"}, 400
+
+ exists = check_lyrics_file(filepath, trackhash)
+
+ if exists:
+ return {"exists": exists}, 200
+
+ exists = get_lyrics_from_tags(filepath, just_check=True)
+
+ return {"exists": exists}, 200
+
diff --git a/app/api/plugins/__init__.py b/app/api/plugins/__init__.py
new file mode 100644
index 0000000..b4661c6
--- /dev/null
+++ b/app/api/plugins/__init__.py
@@ -0,0 +1,42 @@
+from flask import Blueprint, request
+
+from app.db.sqlite.plugins import PluginsMethods
+
+
+api = Blueprint("plugins", __name__, url_prefix="/plugins")
+
+
+@api.route("/", methods=["GET"])
+def get_all_plugins():
+ plugins = PluginsMethods.get_all_plugins()
+
+ return {"plugins": plugins}
+
+
+@api.route("/setactive", methods=["GET"])
+def activate_deactivate_plugin():
+ name = request.args.get("plugin", None)
+ state = request.args.get("state", None)
+
+ if not name or not state:
+ return {"error": "Missing plugin or state"}, 400
+
+ PluginsMethods.plugin_set_active(name, int(state))
+
+ return {"message": "OK"}, 200
+
+
+@api.route("/settings", methods=["POST"])
+def update_plugin_settings():
+ data = request.get_json()
+
+ plugin = data.get("plugin", None)
+ settings = data.get("settings", None)
+
+ if not plugin or not settings:
+ return {"error": "Missing plugin or settings"}, 400
+
+ PluginsMethods.update_plugin_settings(plugin_name=plugin, settings=settings)
+ plugin = PluginsMethods.get_plugin_by_name(plugin)
+
+ return {"status": "success", "settings": plugin.settings}
diff --git a/app/api/plugins/lyrics.py b/app/api/plugins/lyrics.py
new file mode 100644
index 0000000..e1d01fb
--- /dev/null
+++ b/app/api/plugins/lyrics.py
@@ -0,0 +1,47 @@
+from flask import Blueprint, request
+from app.lib.lyrics import format_synced_lyrics
+
+from app.plugins.lyrics import Lyrics
+from app.utils.hashing import create_hash
+
+api = Blueprint("lyricsplugin", __name__, url_prefix="/plugins/lyrics")
+
+
+@api.route("/search", methods=["POST"])
+def search_lyrics():
+ data = request.get_json()
+
+ trackhash = data.get("trackhash", "")
+ title = data.get("title", "")
+ artist = data.get("artist", "")
+ album = data.get("album", "")
+ filepath = data.get("filepath", None)
+
+ finder = Lyrics()
+
+ data = finder.search_lyrics_by_title_and_artist(title, artist)
+
+ if not data:
+ return {"trackhash": trackhash, "lyrics": None}
+
+ perfect_match = data[0]
+
+ for track in data:
+ i_title = track["title"]
+ i_album = track["album"]
+
+ if create_hash(i_title) == create_hash(title) and create_hash(
+ i_album
+ ) == create_hash(album):
+ perfect_match = track
+
+ track_id = perfect_match["track_id"]
+ lrc = finder.download_lyrics(track_id, filepath)
+
+ if lrc is not None:
+ lines = lrc.split("\n")
+ lyrics = format_synced_lyrics(lines)
+
+ return {"trackhash": trackhash, "lyrics": lyrics}, 200
+
+ return {"trackhash": trackhash, "lyrics": lrc}, 200
diff --git a/app/api/settings.py b/app/api/settings.py
index 01973db..c854237 100644
--- a/app/api/settings.py
+++ b/app/api/settings.py
@@ -1,5 +1,6 @@
from flask import Blueprint, request
+from app.db.sqlite.plugins import PluginsMethods as pdb
from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.lib import populate
from app.lib.watchdogg import Watcher as WatchDog
@@ -11,7 +12,7 @@ from app.store.tracks import TrackStore
from app.utils.generators import get_random_str
from app.utils.threading import background
-api = Blueprint("settings", __name__, url_prefix="/")
+api = Blueprint("settings", __name__, url_prefix="")
def get_child_dirs(parent: str, children: list[str]):
@@ -160,6 +161,7 @@ def get_all_settings():
"""
settings = sdb.get_all_settings()
+ plugins = pdb.get_all_plugins()
key_list = list(mapp.keys())
s = {}
@@ -180,6 +182,7 @@ def get_all_settings():
root_dirs = sdb.get_root_dirs()
s["root_dirs"] = root_dirs
+ s['plugins'] = plugins
return {
"settings": s,
diff --git a/app/arg_handler.py b/app/arg_handler.py
index ededd66..11719a7 100644
--- a/app/arg_handler.py
+++ b/app/arg_handler.py
@@ -43,24 +43,27 @@ class HandleArgs:
print("https://www.youtube.com/watch?v=wZv62ShoStY")
sys.exit(0)
- lastfm_key = settings.Keys.LASTFM_API
- posthog_key = settings.Keys.POSTHOG_API_KEY
+ config_keys = [
+ "LASTFM_API_KEY",
+ "PLUGIN_LYRICS_AUTHORITY",
+ "PLUGIN_LYRICS_ROOT_URL",
+ ]
- if not lastfm_key:
- log.error("ERROR: LASTFM_API_KEY not set in environment")
- sys.exit(0)
+ lines = []
- if not posthog_key:
- log.error("ERROR: POSTHOG_API_KEY not set in environment")
- sys.exit(0)
+ for key in config_keys:
+ value = settings.Keys.get(key)
+
+ if not value:
+ log.error(f"ERROR: {key} not set in environment")
+ sys.exit(0)
+
+ lines.append(f'{key} = "{value}"\n')
try:
with open("./app/configs.py", "w", encoding="utf-8") as file:
# copy the api keys to the config file
- line1 = f'LASTFM_API_KEY = "{lastfm_key}"\n'
- line2 = f'POSTHOG_API_KEY = "{posthog_key}"\n'
- file.write(line1)
- file.write(line2)
+ file.writelines(lines)
_s = ";" if is_windows() else ":"
@@ -80,10 +83,8 @@ class HandleArgs:
finally:
# revert and remove the api keys for dev mode
with open("./app/configs.py", "w", encoding="utf-8") as file:
- line1 = "LASTFM_API_KEY = ''\n"
- line2 = "POSTHOG_API_KEY = ''\n"
- file.write(line1)
- file.write(line2)
+ lines = [f'{key} = ""\n' for key in config_keys]
+ file.writelines(lines)
sys.exit(0)
diff --git a/app/configs.py b/app/configs.py
index a021ec7..cbd6b84 100644
--- a/app/configs.py
+++ b/app/configs.py
@@ -1,2 +1,3 @@
-LASTFM_API_KEY = ''
-POSTHOG_API_KEY = ''
+LASTFM_API_KEY = ""
+PLUGIN_LYRICS_AUTHORITY = ""
+PLUGIN_LYRICS_ROOT_URL = ""
diff --git a/app/db/sqlite/__init__.py b/app/db/sqlite/__init__.py
index 9f65c16..43bd79c 100644
--- a/app/db/sqlite/__init__.py
+++ b/app/db/sqlite/__init__.py
@@ -3,7 +3,6 @@ This module contains the functions to interact with the SQLite database.
"""
import sqlite3
-from pathlib import Path
from sqlite3 import Connection as SqlConn
@@ -19,19 +18,4 @@ def create_tables(conn: SqlConn, sql_query: str):
"""
Executes the specifiend SQL file to create database tables.
"""
- # with open(sql_query, "r", encoding="utf-8") as sql_file:
conn.executescript(sql_query)
-
-
-def setup_search_db():
- """
- Creates the search database.
- """
- db = sqlite3.connect(":memory:")
- sql_file = "queries/fts5.sql"
-
- current_path = Path(__file__).parent.resolve()
- sql_path = current_path.joinpath(sql_file)
-
- with open(sql_path, "r", encoding="utf-8") as sql_file:
- db.executescript(sql_file.read())
diff --git a/app/db/sqlite/plugins/__init__.py b/app/db/sqlite/plugins/__init__.py
new file mode 100644
index 0000000..12f6efe
--- /dev/null
+++ b/app/db/sqlite/plugins/__init__.py
@@ -0,0 +1,93 @@
+import json
+
+from app.models.plugins import Plugin
+
+from ..utils import SQLiteManager
+
+
+def plugin_tuple_to_obj(plugin_tuple: tuple) -> Plugin:
+ return Plugin(
+ name=plugin_tuple[1],
+ description=plugin_tuple[2],
+ active=bool(plugin_tuple[3]),
+ settings=json.loads(plugin_tuple[4]),
+ )
+
+
+class PluginsMethods:
+ @classmethod
+ def insert_plugin(cls, plugin: Plugin):
+ """
+ Inserts one plugin into the database
+ """
+
+ sql = """INSERT OR IGNORE INTO plugins(
+ name,
+ description,
+ active,
+ settings
+ ) VALUES(?,?,?,?)
+ """
+
+ with SQLiteManager(userdata_db=True) as cur:
+ cur.execute(
+ sql,
+ (
+ plugin.name,
+ plugin.description,
+ int(plugin.active),
+ json.dumps(plugin.settings),
+ ),
+ )
+ lastrowid = cur.lastrowid
+
+ return lastrowid
+
+ @classmethod
+ def insert_lyrics_plugin(cls):
+ plugin = Plugin(
+ name="lyrics_finder",
+ description="Find lyrics from the internet",
+ active=False,
+ settings={"auto_download": False},
+ )
+ cls.insert_plugin(plugin)
+
+ @classmethod
+ def get_all_plugins(cls):
+ with SQLiteManager(userdata_db=True) as cur:
+ cur.execute("SELECT * FROM plugins")
+ plugins = cur.fetchall()
+ cur.close()
+
+ if plugins is not None:
+ return [plugin_tuple_to_obj(plugin) for plugin in plugins]
+
+ return []
+
+ @classmethod
+ def plugin_set_active(cls, name: str, state: int):
+ with SQLiteManager(userdata_db=True) as cur:
+ cur.execute("UPDATE plugins SET active=? WHERE name=?", (state, name))
+ cur.close()
+
+ @classmethod
+ def update_plugin_settings(cls, plugin_name: str, settings: dict):
+ with SQLiteManager(userdata_db=True) as cur:
+ cur.execute(
+ "UPDATE plugins SET settings=? WHERE name=?",
+ (json.dumps(settings), plugin_name),
+ )
+ cur.close()
+
+ @classmethod
+ def get_plugin_by_name(cls, name: str):
+ with SQLiteManager(userdata_db=True) as cur:
+ cur.execute("SELECT * FROM plugins WHERE name=?", (name,))
+ plugin = cur.fetchone()
+ cur.close()
+
+ if plugin is not None:
+ return plugin_tuple_to_obj(plugin)
+
+ return None
diff --git a/app/db/sqlite/queries.py b/app/db/sqlite/queries.py
index 317f11a..4b4eb19 100644
--- a/app/db/sqlite/queries.py
+++ b/app/db/sqlite/queries.py
@@ -41,6 +41,14 @@ CREATE TABLE IF NOT EXISTS lastfm_similar_artists (
similar_artists text NOT NULL,
UNIQUE (artisthash)
);
+
+CREATE TABLE IF NOT EXISTS plugins (
+ id integer PRIMARY KEY,
+ name text NOT NULL UNIQUE,
+ description text NOT NULL,
+ active integer NOT NULL DEFAULT 0,
+ settings text
+)
"""
CREATE_APPDB_TABLES = """
diff --git a/app/enums/album_versions.py b/app/enums/album_versions.py
index 1143a5a..6894a67 100644
--- a/app/enums/album_versions.py
+++ b/app/enums/album_versions.py
@@ -3,7 +3,7 @@ from enum import Enum
class AlbumVersionEnum(Enum):
"""
- Enum for album versions.
+ Enum that registers supported album versions.
"""
Explicit = ("explicit",)
@@ -40,7 +40,7 @@ class AlbumVersionEnum(Enum):
BONUS_EDITION = ("bonus",)
BONUS_TRACK = ("bonus track",)
- ORIGINAL = ("original",)
+ ORIGINAL = ("original", "og")
INTL_VERSION = ("international",)
UK_VERSION = ("uk version",)
US_VERSION = ("us version",)
@@ -59,4 +59,7 @@ class AlbumVersionEnum(Enum):
def get_all_keywords():
+ """
+ Returns a joint string of all album versions.
+ """
return "|".join("|".join(i.value) for i in AlbumVersionEnum)
diff --git a/app/lib/lyrics.py b/app/lib/lyrics.py
new file mode 100644
index 0000000..98a2a6a
--- /dev/null
+++ b/app/lib/lyrics.py
@@ -0,0 +1,176 @@
+from pathlib import Path
+from tinytag import TinyTag
+
+from app.store.tracks import TrackStore
+
+
+def split_line(line: str):
+ """
+ Split a lyrics line into time and lyrics
+ """
+ items = line.split("]")
+ time = items[0].removeprefix("[")
+ lyric = items[1] if len(items) > 1 else ""
+
+ return (time, lyric.strip())
+
+
+def convert_to_milliseconds(time: str):
+ """
+ Converts a lyrics time string into milliseconds.
+ """
+ try:
+ minutes, seconds = time.split(":")
+ except ValueError:
+ return 0
+
+ milliseconds = int(minutes) * 60 * 1000 + float(seconds) * 1000
+ return int(milliseconds)
+
+
+def format_synced_lyrics(lines: list[str]):
+ """
+ Formats synced lyrics into a list of dicts
+ """
+ lyrics = []
+
+ for line in lines:
+ # if line starts with [ and ends with ] .ie. ID3 tag, skip it
+ if line.startswith("[") and line.endswith("]"):
+ continue
+
+ # if line does not start with [ skip it
+ if not line.startswith("["):
+ continue
+
+ time, lyric = split_line(line)
+ milliseconds = convert_to_milliseconds(time)
+
+ lyrics.append({"time": milliseconds, "text": lyric})
+
+ return lyrics
+
+
+def get_lyrics_from_lrc(filepath: str):
+ with open(filepath, mode="r") as file:
+ lines = (f.removesuffix("\n") for f in file.readlines())
+ return format_synced_lyrics(lines)
+
+
+def get_lyrics_file_rel_to_track(filepath: str):
+ """
+ Finds the lyrics file relative to the track file
+ """
+ lyrics_path = Path(filepath).with_suffix(".lrc")
+
+ if lyrics_path.exists():
+ return lyrics_path
+
+
+def check_lyrics_file_rel_to_track(filepath: str):
+ """
+ Checks if the lyrics file exists relative to the track file
+ """
+ lyrics_path = Path(filepath).with_suffix(".lrc")
+
+ if lyrics_path.exists():
+ return True
+ else:
+ return False
+
+
+def get_lyrics(track_path: str):
+ """
+ Gets the lyrics for a track
+ """
+ lyrics_path = get_lyrics_file_rel_to_track(track_path)
+
+ if lyrics_path:
+ lyrics = get_lyrics_from_lrc(lyrics_path)
+ copyright = get_extras(track_path, ["copyright"])
+
+ return lyrics, copyright[0]
+ else:
+ return None, ""
+
+
+def get_lyrics_from_duplicates(trackhash: str, filepath: str):
+ """
+ Finds the lyrics from other duplicate tracks
+ """
+
+ for track in TrackStore.tracks:
+ if track.trackhash == trackhash and track.filepath != filepath:
+ lyrics, copyright = get_lyrics(track.filepath)
+
+ if lyrics:
+ return lyrics, copyright
+
+ return None, ""
+
+
+def check_lyrics_file(filepath: str, trackhash: str):
+ lyrics_exists = check_lyrics_file_rel_to_track(filepath)
+
+ if lyrics_exists:
+ return True
+
+ for track in TrackStore.tracks:
+ if track.trackhash == trackhash and track.filepath != filepath:
+ lyrics_exists = check_lyrics_file_rel_to_track(track.filepath)
+
+ if lyrics_exists:
+ return True
+
+ return False
+
+
+def test_is_synced(lyrics: list[str]):
+ """
+ Tests if the lyric lines passed are synced.
+ """
+ for line in lyrics:
+ time, _ = split_line(line)
+ milliseconds = convert_to_milliseconds(time)
+
+ if milliseconds != 0:
+ return True
+
+ return False
+
+
+def get_extras(filepath: str, keys: list[str]):
+ """
+ Get extra tags from an audio file.
+ """
+ try:
+ tags = TinyTag.get(filepath)
+ except Exception:
+ return [""] * len(keys)
+
+ extras = tags.extra
+
+ return [extras.get(key, "").strip() for key in keys]
+
+
+def get_lyrics_from_tags(filepath: str, just_check: bool = False):
+ """
+ Gets the lyrics from the tags of the track
+ """
+ lyrics, copyright = get_extras(filepath, ["lyrics", "copyright"])
+ lyrics = lyrics.replace("engdesc", "")
+ exists = bool(lyrics.replace("\n", "").strip())
+
+ if just_check:
+ return exists
+
+ if not exists:
+ return None, False, ""
+
+ lines = lyrics.split("\n")
+ synced = test_is_synced(lines[:15])
+
+ if synced:
+ return format_synced_lyrics(lines), synced, copyright
+
+ return lines, synced, copyright
diff --git a/app/migrations/v1_3_0/__init__.py b/app/migrations/v1_3_0/__init__.py
index d2f44d3..b1df7b2 100644
--- a/app/migrations/v1_3_0/__init__.py
+++ b/app/migrations/v1_3_0/__init__.py
@@ -11,8 +11,6 @@ from app.migrations.base import Migration
from app.settings import Paths
from app.utils.decorators import coroutine
from app.utils.hashing import create_hash
-from app.telemetry import Telemetry
-from app.utils.threading import background
# playlists table
# ---------------
@@ -25,11 +23,6 @@ from app.utils.threading import background
# 6: trackhashes
-@background
-def send_telemetry():
- Telemetry.send_app_installed()
-
-
class RemoveSmallThumbnailFolder(Migration):
"""
Removes the small thumbnail folder.
@@ -41,7 +34,6 @@ class RemoveSmallThumbnailFolder(Migration):
@staticmethod
def migrate():
- send_telemetry()
thumbs_sm_path = Paths.get_sm_thumb_path()
thumbs_lg_path = Paths.get_lg_thumb_path()
diff --git a/app/models/album.py b/app/models/album.py
index 3d09c45..52f54ab 100644
--- a/app/models/album.py
+++ b/app/models/album.py
@@ -159,6 +159,8 @@ class Album:
"""
return self.title.strip().endswith(" EP")
+ # TODO: check against number of tracks
+
def check_is_single(self, tracks: list[Track]):
"""
Checks if the album is a single.
diff --git a/app/models/plugins.py b/app/models/plugins.py
new file mode 100644
index 0000000..d3b55f8
--- /dev/null
+++ b/app/models/plugins.py
@@ -0,0 +1,10 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class Plugin:
+ name: str
+ description: str
+ active: bool
+ settings: dict
+
diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py
new file mode 100644
index 0000000..70a03d9
--- /dev/null
+++ b/app/plugins/__init__.py
@@ -0,0 +1,30 @@
+class Plugin:
+ """
+ Class that all plugins should inherit from
+ """
+
+ def __init__(self, name: str, description: str) -> None:
+ self.enabled = False
+ self.name = name
+ self.description = description
+
+ def set_active(self, state: bool):
+ self.enabled = state
+
+
+def plugin_method(func):
+ """
+ A decorator that prevents execution if the plugin is disabled.
+ Should be used on all plugin methods
+ """
+
+ def wrapper(*args, **kwargs):
+ plugin: Plugin = args[0]
+
+ if plugin.enabled:
+ return func(*args, **kwargs)
+ else:
+ return
+
+ return wrapper
+
diff --git a/app/plugins/lyrics.py b/app/plugins/lyrics.py
new file mode 100644
index 0000000..2cb2473
--- /dev/null
+++ b/app/plugins/lyrics.py
@@ -0,0 +1,215 @@
+import json
+import os
+import time
+from pathlib import Path
+from typing import List, Optional
+
+import requests
+
+from app.db.sqlite.plugins import PluginsMethods
+from app.plugins import Plugin, plugin_method
+from app.settings import Keys, Paths
+
+
+class LRCProvider:
+ """
+ Base class for all of the synced (LRC format) lyrics providers.
+ """
+
+ session = requests.Session()
+
+ def __init__(self) -> None:
+ self.session.headers.update(
+ {
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"
+ }
+ )
+
+ def get_lrc_by_id(self, track_id: str) -> Optional[str]:
+ """
+ Returns the synced lyrics of the song in lrc.
+
+ ### Arguments
+ - track_id: The ID of the track defined in the provider database. e.g. Spotify/Deezer track ID
+ """
+ raise NotImplementedError
+
+ def get_lrc(self, search_term: str) -> Optional[str]:
+ """
+ Returns the synced lyrics of the song in lrc.
+ """
+ raise NotImplementedError
+
+
+class LyricsProvider(LRCProvider):
+ """
+ Musixmatch provider class
+ """
+
+ ROOT_URL = Keys.PLUGIN_LYRICS_ROOT_URL
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.token = None
+ self.session.headers.update(
+ {
+ "authority": Keys.PLUGIN_LYRICS_AUTHORITY,
+ "cookie": "AWSELBCORS=0; AWSELB=0",
+ }
+ )
+
+ def _get(self, action: str, query: List[tuple]):
+ if action != "token.get" and self.token is None:
+ self._get_token()
+
+ query.append(("app_id", "web-desktop-app-v1.0"))
+ if self.token is not None:
+ query.append(("usertoken", self.token))
+
+ t = str(int(time.time() * 1000))
+ query.append(("t", t))
+
+ try:
+ url = self.ROOT_URL + action
+ except TypeError:
+ return None
+
+ try:
+ response = self.session.get(url, params=query)
+ except requests.exceptions.ConnectionError:
+ return None
+
+ if response is not None and response.ok:
+ return response
+
+ return None
+
+ def _get_token(self):
+ # Check if token is cached and not expired
+ plugin_path = Paths.get_lyrics_plugins_path()
+ token_path = os.path.join(plugin_path, "token.json")
+
+ current_time = int(time.time())
+
+ if os.path.exists(token_path):
+ with open(token_path, "r", encoding="utf-8") as token_file:
+ cached_token_data: dict = json.load(token_file)
+
+ cached_token = cached_token_data.get("token")
+ expiration_time = cached_token_data.get("expiration_time")
+
+ if cached_token and expiration_time and current_time < expiration_time:
+ self.token = cached_token
+ return
+
+ # Token not cached or expired, fetch a new token
+ res = self._get("token.get", [("user_language", "en")])
+
+ if res is None:
+ return
+
+ res = res.json()
+ if res["message"]["header"]["status_code"] == 401:
+ time.sleep(10)
+ return self._get_token()
+
+ new_token = res["message"]["body"]["user_token"]
+ expiration_time = current_time + 600 # 10 minutes expiration
+
+ # Cache the new token
+ self.token = new_token
+ token_data = {"token": new_token, "expiration_time": expiration_time}
+
+ os.makedirs(plugin_path, exist_ok=True)
+ with open(token_path, "w", encoding="utf-8") as token_file:
+ json.dump(token_data, token_file)
+
+ def get_lrc_by_id(self, track_id: str) -> Optional[str]:
+ res = self._get(
+ "track.subtitle.get", [("track_id", track_id), ("subtitle_format", "lrc")]
+ )
+
+ try:
+ res = res.json()
+ body = res["message"]["body"]
+ except AttributeError:
+ return None
+
+ if not body:
+ return None
+
+ return body["subtitle"]["subtitle_body"]
+
+ def get_lrc(self, title: str, artist: str) -> Optional[str]:
+ res = self._get(
+ "track.search",
+ [
+ ("q_track", title),
+ ("q_artist", artist),
+ ("page_size", "5"),
+ ("page", "1"),
+ ("f_has_lyrics", "1"),
+ ("s_track_rating", "desc"),
+ ("quorum_factor", "1.0"),
+ ],
+ )
+
+ try:
+ body = res.json()["message"]["body"]
+ except AttributeError:
+ return []
+
+ try:
+ tracks = body["track_list"]
+ except TypeError:
+ return []
+
+ return [
+ {
+ "track_id": t["track"]["track_id"],
+ "title": t["track"]["track_name"],
+ "artist": t["track"]["artist_name"],
+ "album": t["track"]["album_name"],
+ "image": t["track"]["album_coverart_100x100"],
+ }
+ for t in tracks
+ ]
+
+
+class Lyrics(Plugin):
+ def __init__(self) -> None:
+ plugin = PluginsMethods.get_plugin_by_name("lyrics_finder")
+
+ if not plugin:
+ return
+
+ name = plugin.name
+ description = plugin.description
+
+ super().__init__(name, description)
+
+ self.provider = LyricsProvider()
+
+ if plugin:
+ self.set_active(bool(int(plugin.active)))
+
+ @plugin_method
+ def search_lyrics_by_title_and_artist(self, title: str, artist: str):
+ return self.provider.get_lrc(title, artist)
+
+ @plugin_method
+ def download_lyrics(self, trackid: str, path: str):
+ lrc = self.provider.get_lrc_by_id(trackid)
+ is_valid = lrc is not None and lrc.replace("\n", "").strip() != ""
+
+ if not is_valid:
+ return None
+
+ path = Path(path).with_suffix(".lrc")
+
+ try:
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(lrc)
+ return lrc
+ except:
+ return lrc
diff --git a/app/plugins/register.py b/app/plugins/register.py
new file mode 100644
index 0000000..7ace226
--- /dev/null
+++ b/app/plugins/register.py
@@ -0,0 +1,5 @@
+from app.db.sqlite.plugins import PluginsMethods
+
+
+def register_plugins():
+ PluginsMethods.insert_lyrics_plugin()
diff --git a/app/requests/artists.py b/app/requests/artists.py
index 2da19c2..28737dc 100644
--- a/app/requests/artists.py
+++ b/app/requests/artists.py
@@ -1,19 +1,20 @@
"""
Requests related to artists
"""
+import urllib.parse
+
import requests
+from requests import ConnectionError, HTTPError, ReadTimeout
from app import settings
from app.utils.hashing import create_hash
-from requests import ConnectionError, HTTPError, ReadTimeout
-import urllib.parse
def fetch_similar_artists(name: str):
"""
Fetches similar artists from Last.fm
"""
- url = f"https://ws.audioscrobbler.com/2.0/?method=artist.getsimilar&artist={urllib.parse.quote_plus(name, safe='')}&api_key={settings.Keys.LASTFM_API}&format=json&limit=250"
+ url = f"https://ws.audioscrobbler.com/2.0/?method=artist.getsimilar&artist={urllib.parse.quote_plus(name, safe='')}&api_key={settings.Keys.LASTFM_API_KEY}&format=json&limit=250"
try:
response = requests.get(url, timeout=10)
diff --git a/app/settings.py b/app/settings.py
index 228c250..5e8d5f3 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -16,7 +16,7 @@ else:
class Release:
- APP_VERSION = "1.3.0"
+ APP_VERSION = "1.4.0"
class Paths:
@@ -83,6 +83,14 @@ class Paths:
def get_assets_path(cls):
return join(Paths.get_app_dir(), "assets")
+ @classmethod
+ def get_plugins_path(cls):
+ return join(Paths.get_app_dir(), "plugins")
+
+ @classmethod
+ def get_lyrics_plugins_path(cls):
+ return join(Paths.get_plugins_path(), "lyrics")
+
# defaults
class Defaults:
@@ -231,19 +239,25 @@ class TCOLOR:
class Keys:
# get last fm api key from os environment
- LASTFM_API = os.environ.get("LASTFM_API_KEY")
- POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY")
+ LASTFM_API_KEY = os.environ.get("LASTFM_API_KEY")
+ PLUGIN_LYRICS_AUTHORITY = os.environ.get("PLUGIN_LYRICS_AUTHORITY")
+ PLUGIN_LYRICS_ROOT_URL = os.environ.get("PLUGIN_LYRICS_ROOT_URL")
@classmethod
def load(cls):
if IS_BUILD:
- cls.LASTFM_API = configs.LASTFM_API_KEY
- cls.POSTHOG_API_KEY = configs.POSTHOG_API_KEY
+ cls.LASTFM_API_KEY = configs.LASTFM_API_KEY
+ cls.PLUGIN_LYRICS_AUTHORITY = configs.PLUGIN_LYRICS_AUTHORITY
+ cls.PLUGIN_LYRICS_ROOT_URL = configs.PLUGIN_LYRICS_ROOT_URL
cls.verify_keys()
@classmethod
def verify_keys(cls):
- if not cls.LASTFM_API:
+ if not cls.LASTFM_API_KEY:
print("ERROR: LASTFM_API_KEY not set in environment")
sys.exit(0)
+
+ @classmethod
+ def get(cls, key: str):
+ return getattr(cls, key, None)
\ No newline at end of file
diff --git a/app/setup/files.py b/app/setup/files.py
index 2399c2b..9686121 100644
--- a/app/setup/files.py
+++ b/app/setup/files.py
@@ -65,6 +65,8 @@ def create_config_dir() -> None:
dirs = [
"", # creates the config folder
"images",
+ "plugins",
+ "plugins/lyrics",
thumb_path,
small_thumb_path,
large_thumb_path,
diff --git a/app/telemetry.py b/app/telemetry.py
deleted file mode 100644
index fdc4c99..0000000
--- a/app/telemetry.py
+++ /dev/null
@@ -1,76 +0,0 @@
-import uuid as UUID
-
-from posthog import Posthog
-
-from app.logger import log
-from app.settings import Keys, Paths, Release
-from app.utils.hashing import create_hash
-from app.utils.network import has_connection
-
-
-class Telemetry:
- """
- Handles sending telemetry data to posthog.
- """
-
- user_id = ""
- off = False
-
- @classmethod
- def init(cls) -> None:
- try:
- cls.posthog = Posthog(
- project_api_key=Keys.POSTHOG_API_KEY,
- host="https://app.posthog.com",
- disable_geoip=False,
- )
-
- cls.create_userid()
- except AssertionError:
- cls.disable_telemetry()
-
- @classmethod
- def create_userid(cls):
- """
- Creates a unique user id for the user and saves it to a file.
- """
- uuid_path = Paths.get_app_dir() + "/userid.txt"
-
- try:
- with open(uuid_path, "r") as f:
- cls.user_id = f.read().strip()
- except FileNotFoundError:
- uuid = str(UUID.uuid4())
- cls.user_id = "user_" + create_hash(uuid, limit=15)
-
- with open(uuid_path, "w") as f:
- f.write(cls.user_id)
-
- @classmethod
- def disable_telemetry(cls):
- cls.off = True
-
- @classmethod
- def send_event(cls, event: str):
- """
- Sends an event to posthog.
- """
- if cls.off:
- return
-
- if has_connection():
- cls.posthog.capture(cls.user_id, event=f"v{Release.APP_VERSION}-{event}")
-
- @classmethod
- def send_app_installed(cls):
- """
- Sends an event to posthog when the app is installed.
- """
- cls.send_event("app-installed")
-
- @classmethod
- def send_artist_visited(cls):
- """
- Sends an event to posthog when an artist page is visited.
- """
- cls.send_event("artist-page-visited")
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..fc39774
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,9 @@
+#!/bin/zsh
+
+# builds the latest version of the client and server
+
+cd ../swingmusic-client
+yarn build --outDir ../swingmusic/client
+
+cd ../swingmusic
+poetry run python manage.py --build
\ No newline at end of file
diff --git a/manage.py b/manage.py
index b2c9af4..8885c2b 100644
--- a/manage.py
+++ b/manage.py
@@ -4,15 +4,15 @@ This file is used to run the application.
import logging
import mimetypes
import os
-
-import setproctitle
from flask import request
-from app.telemetry import Telemetry
+import setproctitle
+
from app.api import create_api
from app.arg_handler import HandleArgs
from app.lib.watchdogg import Watcher as WatchDog
from app.periodic_scan import run_periodic_scans
+from app.plugins.register import register_plugins
from app.settings import FLASKVARS, Keys
from app.setup import run_setup
from app.start_info_logger import log_startup_info
@@ -39,25 +39,31 @@ app = create_api()
app.static_folder = get_home_res_path("client")
-# @app.route("/", defaults={"path": ""})
@app.route("/")
def serve_client_files(path: str):
"""
Serves the static files in the client folder.
"""
- # js_or_css = path.endswith(".js") or path.endswith(".css")
- # if not js_or_css:
- # return app.send_static_file(path)
+ js_or_css = path.endswith(".js") or path.endswith(".css")
+ if not js_or_css:
+ return app.send_static_file(path)
- # gzipped_path = path + ".gz"
+ gzipped_path = path + ".gz"
+ user_agent = request.headers.get("User-Agent")
+
+ is_safari = user_agent.find("Safari") >= 0 and user_agent.find("Chrome") < 0
+
+ if is_safari:
+ return app.send_static_file(path)
+
+ accepts_gzip = request.headers.get("Accept-Encoding", "").find("gzip") >= 0
+
+ if accepts_gzip:
+ if os.path.exists(os.path.join(app.static_folder, gzipped_path)):
+ response = app.make_response(app.send_static_file(gzipped_path))
+ response.headers["Content-Encoding"] = "gzip"
+ return response
- # if request.headers.get("Accept-Encoding", "").find("gzip") >= 0:
- # if os.path.exists(os.path.join(app.static_folder, gzipped_path)):
- # response = app.make_response(app.send_static_file(gzipped_path))
- # response.headers["Content-Encoding"] = "gzip"
- # return response
- # else:
- # return app.send_static_file(path)
return app.send_static_file(path)
@@ -71,7 +77,6 @@ def serve_client():
@background
def bg_run_setup() -> None:
- run_setup()
run_periodic_scans()
@@ -80,18 +85,15 @@ def start_watchdog():
WatchDog().run()
-@background
-def init_telemetry():
- Telemetry.init()
-
-
def run_swingmusic():
Keys.load()
HandleArgs()
log_startup_info()
+ run_setup()
bg_run_setup()
+ register_plugins()
+
start_watchdog()
- init_telemetry()
setproctitle.setproctitle(
f"swingmusic - {FLASKVARS.FLASK_HOST}:{FLASKVARS.FLASK_PORT}"
diff --git a/poetry.lock b/poetry.lock
index 57ba0c8..0b7bd46 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand.
[[package]]
name = "altgraph"
@@ -48,17 +48,6 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-
tests = ["attrs[tests-no-zope]", "zope-interface"]
tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
-[[package]]
-name = "backoff"
-version = "2.2.1"
-description = "Function decoration for backoff and retry"
-optional = false
-python-versions = ">=3.7,<4.0"
-files = [
- {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"},
- {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"},
-]
-
[[package]]
name = "black"
version = "22.12.0"
@@ -777,17 +766,6 @@ files = [
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
-[[package]]
-name = "monotonic"
-version = "1.6"
-description = "An implementation of time.monotonic() for Python 2 & < 3.3"
-optional = false
-python-versions = "*"
-files = [
- {file = "monotonic-1.6-py2.py3-none-any.whl", hash = "sha256:68687e19a14f11f26d140dd5c86f3dba4bf5df58003000ed467e0e2a69bca96c"},
- {file = "monotonic-1.6.tar.gz", hash = "sha256:3a55207bcfed53ddd5c5bae174524062935efed17792e9de2ad0205ce9ad63f7"},
-]
-
[[package]]
name = "mypy-extensions"
version = "1.0.0"
@@ -975,29 +953,6 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
-[[package]]
-name = "posthog"
-version = "3.0.2"
-description = "Integrate PostHog into any python application."
-optional = false
-python-versions = "*"
-files = [
- {file = "posthog-3.0.2-py2.py3-none-any.whl", hash = "sha256:a8c0af6f2401fbe50f90e68c4143d0824b54e872de036b1c2f23b5abb39d88ce"},
- {file = "posthog-3.0.2.tar.gz", hash = "sha256:701fba6e446a4de687c6e861b587e7b7741955ad624bf34fe013c06a0fec6fb3"},
-]
-
-[package.dependencies]
-backoff = ">=1.10.0"
-monotonic = ">=1.5"
-python-dateutil = ">2.1"
-requests = ">=2.7,<3.0"
-six = ">=1.5"
-
-[package.extras]
-dev = ["black", "flake8", "flake8-print", "isort", "pre-commit"]
-sentry = ["django", "sentry-sdk"]
-test = ["coverage", "flake8", "freezegun (==0.3.15)", "mock (>=2.0.0)", "pylint", "pytest"]
-
[[package]]
name = "psutil"
version = "5.9.5"
@@ -1745,4 +1700,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<3.12"
-content-hash = "52427f2a27236efb5bcafec3d7db6d2e926dd908593bd595aae5446dfc75ea70"
+content-hash = "6b0eebfb7c29b88c87c31f6efc13229d17148c9643b6d9e37576e5a23e6c967c"
diff --git a/pyproject.toml b/pyproject.toml
index 3955426..f75350d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,7 +23,6 @@ pendulum = "^2.1.2"
flask-compress = "^1.13"
tabulate = "^0.9.0"
setproctitle = "^1.3.2"
-posthog = "^3.0.2"
[tool.poetry.dev-dependencies]
pylint = "^2.15.5"