426 lines
12 KiB
Python
Executable file
426 lines
12 KiB
Python
Executable file
#!/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())
|