mpvp-report/plugin/commands/mpvp_report
Ari Archer 821c1ebdc8
add pyproject.toml and improve wording
Signed-off-by: Ari Archer <ari.web.xyz@gmail.com>
2023-02-13 01:14:49 +02:00

426 lines
12 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""mpvp_report -- report collected MPV data"""
import os
import sys
from getpass import getuser as get_username
from html import escape as escape_html
from typing import Any, Callable, Dict, List, Tuple, Union
from warnings import filterwarnings as filter_warnings
import ujson # type: ignore
from css_html_js_minify import css_minify, html_minify # type: ignore
def eprint(text: str) -> None:
print(text, file=sys.stderr)
def parse_song(song: str) -> Tuple[str, str]:
basename: str = os.path.splitext(os.path.basename(song))[0]
return basename[:-14], basename[-12:-1]
HOME: str = os.path.expanduser("~")
CONFIG_FILE: str = os.path.join(HOME, os.path.join(".config", "mpvp.json"))
CONFIG: Dict[str, Any] = {
"song-mapping": {},
"styles": "~/.config/mpvp.css",
"script": "~/.config/mpvp.js",
"song-name-delims": [
"",
"-",
",",
"feat.",
".",
"&",
],
"yt-url": "https://ari-web.xyz/yt/watch?v=%s",
"raw-json": True,
}
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as config:
CONFIG.update(ujson.load(config))
else:
eprint(f"warning : config {CONFIG_FILE!r} does not exist, making one")
with open(CONFIG_FILE, "w") as config:
ujson.dump(CONFIG, config, indent=4)
class UnknownMusicArtistError(BaseException):
"""raised when there is an unknown music artist"""
def fsplit_song(song_name: str) -> str:
for delim in CONFIG["song-name-delims"]:
song_name = song_name.split(delim, maxsplit=1)[0]
return song_name.strip()
def get_artist_from_song(song: str) -> str:
song = song.lower()
if song not in CONFIG["song-mapping"] and any(
delim in song for delim in CONFIG["song-name-delims"]
):
return fsplit_song(song)
else:
if song in CONFIG["song-mapping"]:
return CONFIG["song-mapping"][song].lower()
raise UnknownMusicArtistError(f"No handled artist for song: {song!r}")
def sort_dict(d: Dict[Any, Any], key: Callable[..., int]) -> Dict[Any, Any]:
return {k: v for k, v in sorted(d.items(), key=key, reverse=True)}
def get_artists_from_played(
played: Dict[str, List[Union[int, str]]]
) -> Dict[str, List[int]]:
artists: Dict[str, List[int]] = {}
for song in played:
artist = get_artist_from_song(song)
if artist not in artists:
artists[artist] = [0, 0]
artists[artist][0] += 1
artists[artist][1] += played[song][0] # type: ignore
return sort_dict(artists, lambda item: sum(item[1]))
def get_played(data: List[Tuple[str, str]]) -> Dict[str, List[Union[int, str]]]:
played: Dict[str, List[Union[int, str]]] = {}
for song, yt_id in data:
if song not in played:
played[song] = [0, yt_id, get_artist_from_song(song)]
played[song][0] += 1 # type: ignore
return sort_dict(played, lambda item: item[1][0])
def parse_data(data: List[Tuple[str, str]]) -> Dict[str, Any]:
print("parsing the loaded data into usable JSON data")
played: Dict[str, List[Union[int, str]]] = get_played(data)
return {
"total": len(data),
"played": played,
"artists": get_artists_from_played(played),
}
def human(word: str, count: int) -> str:
return f"{word}s" if count > 1 else word
def get_styles() -> str:
if not os.path.exists(CONFIG["styles"]):
return ""
print("minifying and injecting CSS")
with open(CONFIG["styles"], "r") as styles:
return f"<style>{css_minify(styles.read())}</style>"
def get_script() -> str:
if not os.path.exists(CONFIG["script"]):
return ""
print("injecting javascript")
# JS minification is broken with js_css_html_minify
with open(CONFIG["styles"], "r") as js:
return f"""<script type="text/javascript">
<!--//--><![CDATA[//><!--
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this page.
*
* Copyright (C) 2022 Nobody
*
* The JavaScript code in this page is free software: you can redistribute
* it and/or modify it under the terms of the GNU General Public License
* (GNU GPL) as published by the Free Software Foundation, either version 3
* of the License, or (at your option) any later version. The code is
* distributed WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU GPL
* for more details.
*
* As additional permission under GNU GPL version 3 section 7, you may
* distribute non-source (e.g., minimized or compacted) forms of that code
* without the copy of the GNU GPL normally required by section 4, provided
* you include this license notice and a URL through which recipients can
* access the Corresponding Source.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
//--><!]]>
</script>
<script defer>{js.read()}</script>"""
def get_yt(yt_id: str) -> str:
return CONFIG["yt-url"] % yt_id
def generate_html_report(data: Dict[str, Any]) -> str:
songs = artists = ""
top_song = top_artist = None
print("generating song list")
for played_song_name, played_song_meta in data["played"].items():
if top_song is None:
top_song = (played_song_name, played_song_meta)
e_song_name: str = escape_html(played_song_name)
played_times_html: str = (
f"<li>played times : <code>{played_song_meta[0]}</code></li>"
if played_song_meta[0] > 1
else ""
)
songs += f"""<li>
<details>
<summary>{e_song_name}</summary>
<ul>
<li>URL : <a href="{get_yt(played_song_meta[1])}">{e_song_name}</a></li>
<li>artist : <code>{played_song_meta[2]}</code></li>
{played_times_html}
</ul>
</details>
</li>"""
print("generating artist list")
for artist, artist_stats in data["artists"].items():
if top_artist is None:
top_artist = (artist, artist_stats)
artists_played_songs_html: str = (
f"""played songs : <code>{artist_stats[0]}</code>
<ul><li>{human('repeat', artist_stats[1])} : <code>{artist_stats[1]}</code></li></ul>"""
if artist_stats[0] > 1
else "no major stats"
)
artists += f"""<li>
<details>
<summary>{escape_html(artist)}</summary>
<ul><li>{artists_played_songs_html}</li></ul>
</details>
</li>"""
print("verifying top data")
if top_song is None or top_artist is None:
eprint(
f"""
invalid top song or artist
top song : {top_song!r}
top artist : {top_artist!r}
"""
)
sys.exit(1)
print("getting and escaping username")
user: str = escape_html(get_username())
raw_json: str = ""
if CONFIG["raw-json"]:
print("generating raw json")
raw_json = """<section id="json">
<h2>raw JSON data</h2>
<details>
<summary>expand for the raw data</summary>
<pre><code>{ujson.dumps(data, indent=4)}</code></pre>
</details>
</section>"""
print("Minifying and generating HTML")
return html_minify(
f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MPV song report for {user}</title>
<meta name="description" content="HTML song report for {user}" />
<meta
name="keywords"
content="sort, report, music, music report, listen, song, mpv, mpv.io, player, {user}"
/>
<meta
name="robots"
content="follow, index, max-snippet:-1, max-video-preview:-1, max-image-preview:large"
/>
<meta property="og:type" content="article" />
<meta
name="robots"
content="follow, index, max-snippet:-1, max-video-preview:-1, max-image-preview:large"
/>
<meta name="generator" content="static mpvp_report generator : https://ari-web.xyz/gh/mpvp-report" />
{get_styles()}
</head>
<body>
<article>
<header>
<h1>MPV song listening report for {user}</h1>
<hr />
</header>
<section id="stats">
<h2>stats</h2>
<ul>
<li>
songs played : <code>{data["total"]}</code>
<ul><li>unique songs : <code>{len(data["played"])}</code></li></ul>
</li>
<li>artists : <code>{len(data["artists"])}</code></li>
</ul>
</section>
<section id="top">
<h2>top stats</h2>
<ul>
<li>
top song :
<code>{top_song[0]}</code>
(<a href="{get_yt(top_song[1][1])}">URL here</a>)
by
<code>{get_artist_from_song(top_song[0])}</code>
with
<code>{top_song[1][0]}</code>
{human("play", top_song[1][0])}
</li>
<li>
top artist :
<code>{top_artist[0]}</code>
with
<code>{top_artist[1][0]}</code>
{human("song", top_artist[1][0])}
played and <code>{top_artist[1][1]}</code>
{human(f"repeat", top_artist[1][0])}
</li>
</ul>
</section>
<section id="songs">
<h2>songs</h2>
<details>
<summary>expand for the list of songs</summary>
<ol>{songs}</ol>
</details>
</section>
<section id="artists">
<h2>artists</h2>
<details>
<summary>expand for the list of artists</summary>
<ol>{artists}</ol>
</details>
</section>
{raw_json}
</article>
{get_script()}
</body>
</html>"""
)
def main() -> int:
"""entry / main function"""
if len(sys.argv) < 2:
eprint(
f"""
song format for full functionality : artist name - song name [youTube ID]
>> please supply the output html path, for example :
$ {sys.argv[0]} index.html
"""
)
return 1
print("expanding paths in config")
for key in (
"styles",
"script",
):
CONFIG[key] = os.path.expanduser(CONFIG[key])
mpvp_file: str = os.environ.get("MPVP_FILE", f"{os.path.join(HOME, '.mpvp')}")
data: List[Tuple[str, str]] = []
print(
f"""
MPVP_FILE: {mpvp_file!r}
OUTPUT: {sys.argv[1]!r}
"""
)
print("collecting and parsing MPV data")
with open(mpvp_file, "r") as mpv_data:
for line_idx, line in enumerate(mpv_data):
if '"data"' not in line:
eprint(f"warning : invalid MPV data at line {line_idx}: {line}")
continue
data.append(parse_song(ujson.loads(line)["data"]))
print("validating data")
if not data:
eprint("not enough data to report")
return 1
print(f"generating {sys.argv[1]!r}")
with open(sys.argv[1], "w") as html:
html.write(generate_html_report(parse_data(data)))
print(f"generated report : {sys.argv[1]!r}")
return 0
if __name__ == "__main__":
assert main.__annotations__.get("return") is int, "main() should return an integer"
filter_warnings("error", category=Warning)
raise SystemExit(main())