diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5301203..9cf4821 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,10 +1,9 @@ name: Build - on: push: branches: - main - - pjin-dev + - v1.2.0_dev pull_request: branches: - main @@ -28,10 +27,6 @@ jobs: - os: windows-latest platform: windows name: Windows - - - os: ubuntu-latest - platform: linux - name: Linux runs-on: ${{ matrix.os }} @@ -53,21 +48,6 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - - name: Install Linux dependencies - if: matrix.platform == 'linux' - run: | - sudo apt-get update - sudo apt-get install -y \ - libgtk-3-dev \ - libgstreamer1.0-dev \ - libgstreamer-plugins-base1.0-dev \ - clang \ - cmake \ - ninja-build \ - pkg-config \ - libblkid-dev \ - liblzma-dev - - name: Install Flet run: pip install flet==${{ env.FLET_VERSION }} @@ -83,56 +63,56 @@ jobs: run: | brew install create-dmg - create-dmg \ - --volname "TunesBack" \ - --window-pos 200 120 \ - --window-size 800 400 \ - --icon-size 100 \ - --icon "TunesBack.app" 200 190 \ - --hide-extension "TunesBack.app" \ - --app-drop-link 600 185 \ - "TunesBack-macOS-v${{ env.APP_VERSION }}.dmg" \ - "build/macos/TunesBack.app" || true - - # Fallback if create-dmg fails - if [ ! -f "TunesBack-macOS-v${{ env.APP_VERSION }}.dmg" ]; then - echo "create-dmg failed, using hdiutil..." - hdiutil create -volname "TunesBack" \ - -srcfolder "build/macos/TunesBack.app" \ - -ov -format UDZO \ - "TunesBack-macOS-v${{ env.APP_VERSION }}.dmg" + if [ -d "build/macos/TunesBack.app" ]; then + create-dmg \ + --volname "TunesBack" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "TunesBack.app" 200 190 \ + --hide-extension "TunesBack.app" \ + --app-drop-link 600 185 \ + "TunesBack-macOS-v${{ env.APP_VERSION }}-Universal.dmg" \ + "build/macos/TunesBack.app" || true + + if [ ! -f "TunesBack-macOS-v${{ env.APP_VERSION }}-Universal.dmg" ]; then + echo "create-dmg failed, using hdiutil..." + hdiutil create -volname "TunesBack" \ + -srcfolder "build/macos/TunesBack.app" \ + -ov -format UDZO \ + "TunesBack-macOS-v${{ env.APP_VERSION }}-Universal.dmg" + fi + else + echo "Error: TunesBack.app not found in build/macos/" + exit 1 fi - # ========== Windows: Create Installer ========== + # ========== Windows: Create Installer ========== - name: Create Windows Installer if: matrix.platform == 'windows' shell: pwsh run: | - # Install Inno Setup via Chocolatey choco install innosetup -y - - # Wait for installation to complete Start-Sleep -Seconds 5 - # Create installer script $issContent = @" [Setup] AppName=TunesBack AppVersion=${{ env.APP_VERSION }} - AppPublisher=Your Name + AppPublisher=Mooseses DefaultDirName={autopf}\TunesBack DefaultGroupName=TunesBack UninstallDisplayIcon={app}\tunesback.exe Compression=lzma2 SolidCompression=yes OutputDir=. - OutputBaseFilename=TunesBack-Windows-v${{ env.APP_VERSION }} + OutputBaseFilename=TunesBack-Windows-v${{ env.APP_VERSION }}-x64 ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible WizardStyle=modern [Files] - Source: "build\windows\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs + Source: "build\windows\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] Name: "{group}\TunesBack"; Filename: "{app}\tunesback.exe" @@ -147,41 +127,21 @@ jobs: "@ $issContent | Out-File -FilePath "installer.iss" -Encoding ASCII - - # Find ISCC.exe (Chocolatey install path) - $isccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" - - # Compile installer - & $isccPath "installer.iss" - - # ========== Linux: Create Tarball ========== - - name: Package Linux app - if: matrix.platform == 'linux' - run: | - cd build/linux - tar -czf ../../TunesBack-Linux-v${{ env.APP_VERSION }}.tar.gz * + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "installer.iss" # ========== Upload Artifacts ========== - name: Upload macOS artifact if: matrix.platform == 'macos' uses: actions/upload-artifact@v4 with: - name: TunesBack-macOS-v${{ env.APP_VERSION }} - path: TunesBack-macOS-v${{ env.APP_VERSION }}.dmg + name: TunesBack-macOS-v${{ env.APP_VERSION }}-Universal + path: TunesBack-macOS-v${{ env.APP_VERSION }}-Universal.dmg retention-days: 0 - name: Upload Windows artifact if: matrix.platform == 'windows' uses: actions/upload-artifact@v4 with: - name: TunesBack-Windows-v${{ env.APP_VERSION }} - path: TunesBack-Windows-v${{ env.APP_VERSION }}.exe - retention-days: 0 - - - name: Upload Linux artifact - if: matrix.platform == 'linux' - uses: actions/upload-artifact@v4 - with: - name: TunesBack-Linux-v${{ env.APP_VERSION }} - path: TunesBack-Linux-v${{ env.APP_VERSION }}.tar.gz + name: TunesBack-Windows-v${{ env.APP_VERSION }}-x64 + path: TunesBack-Windows-v${{ env.APP_VERSION }}-x64.exe retention-days: 0 diff --git a/README.md b/README.md index 4bb0619..62b1f2f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **The replay statistics for the rest of us.** -*The "Year-In-Review" experience for iTunes and iPod users.* +*Year-in-review for iTunes and iPod users.* [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) ![Build Status](https://github.com/mooseses/TunesBack/actions/workflows/build.yml/badge.svg?branch=main) @@ -27,63 +27,262 @@ -## 🎵 Why TunesBack? +## Why TunesBack? Spotify has Wrapped. Apple Music has Replay. But what about iTunes and iPod users? -**TunesBack fills that gap.** Streaming services provide year-end analytics, but local music libraries don't... until now. TunesBack brings that experience to your local library: track listening habits, discover top artists, albums and songs, and see how your taste evolves over time. +TunesBack brings year-end analytics to your local music library. Track your listening habits, see your top artists, albums and songs, and discover how your taste evolves over time. -Works with both iTunes and Apple Music libraries. Cross-platform support for **Windows, macOS, and Linux**. +Works with iTunes and Apple Music libraries on **Windows, macOS, and Linux**. -Powered by [libpytunes](https://github.com/liamks/libpytunes) for robust iTunes XML parsing. +Powered by [libpytunes](https://github.com/liamks/libpytunes) for iTunes XML parsing. -## ✨ Features +## Features -- **Compare periods** or analyze single snapshots (XML files be properly named. See guide below) -- **Top Artists, Albums & Songs** with customizable rankings (5-100 items) -- **Flexible display**: Hours/minutes, sort by time/plays -- **Beautiful dashboard** with dark/light mode -- **100% private**: All processing happens locally on your machine +- **Compare periods** or analyze single snapshots (XML files must be properly named: see guide below) +- **Top Artists, Albums & Songs** with customizable rankings (5–100 items) +- Sort with **Genres, New Finds, Skipped Tracks & Years** +- **Album art** displayed alongside track details +- **Network share support**: seamlessly works with UNC paths (Windows) and GVFS/CIFS mounts (Linux) +- **Wrapped cards:** shareable Spotify-style visual summaries +- **Listening Age:** calculates the "era" of music you gravitate toward +- **Flexible display:** switch between hours/minutes, sort by time or plays +- **Dark and light mode** +- **Fully offline:** all processing happens on your machine -## 🚀 Quick Start +## Wrapped Cards + +Generate shareable images summarizing your listening habits. + +
+ + + + + + + + + + + + + +
+
+ +**Available cards:** +- **Top Songs & Albums**: ranked lists with album artwork +- **Top Genres**: typographic genre tiles +- **Top Song**: hero card with large cover art +- **Minutes Listened**: total listening time +- **Listening Age**: your calculated musical age +- **Summary**: overview combining top artist, stats, and genres + +The order of items follows the "Ranked By" setting. Re-generate after changing the ranking. + +> More card types to come! + +## Listening Age + +TunesBack computes a single-number summary called the "Listening Age" to describe the era that most strongly characterizes your listening habits. The metric is inspired by the psychological "reminiscence bump", which is the tendency for music encountered during one's formative years to retain stronger emotional significance. + +The following chart explains its methodology: + +
+ Listening Age Algorithm + Listening Age Algorithm +
+ + +#### How the metric is calculated (algorithmic summary): + +- Aggregate play counts by each track's release year. +- Slide a 5-year window across the years in your data and compute the total plays for each window. +- Select the 5-year window with the highest total plays (the "peak era"). +- Take the center year of that window and assume the listener was 18 at that midpoint. +- Calculate Listening Age as: current year − (midpoint year − 18). + +Formula: + +``` +Listening Age = Current Year - (Peak Era Midpoint - 18) +``` + +Example: + +- Peak window: 2010–2014 → midpoint 2012 +- Assumed birth year: 2012 − 18 = 1994 +- Listening Age (in 2025): 2025 − 1994 = 31 + +Notes and interpretation: + +- A Listening Age lower than your chronological age indicates a preference for more recent releases. +- A Listening Age higher than your chronological age indicates a preference for older material. +- The method uses a fixed 5-year window and a formative age constant of 18; these parameters reflect a balance between sensitivity and robustness in typical music libraries. + +Implementation details: the algorithm (see `listening_age_algorithm.py`) filters out invalid years, sums plays per year, evaluates every 5-year window, selects the highest-sum window, and computes the final age as shown above. + +## New Music Finding + +TunesBack uses a multi-stage validation process to accurately identify genuinely new additions to your library, distinguishing them from pre-existing tracks that were simply unplayed. + +### Classification Criteria + +When comparing two library snapshots, a track qualifies as a **New Find** only if **all** of the following conditions are met: + +1. **Play Count Increase**: The track shows increased play activity (`diff_count > 0`) in the more recent snapshot. +2. **No Persistent ID Match**: The track's unique identifier (Persistent ID) does not exist in the baseline library, this confirms it wasn't already present. +3. **Date Added Verification** (comparison mode): If a baseline date exists, the track's `Date Added` timestamp must fall after that date. + +#### Single Snapshot Mode + +When analyzing a single library export (no baseline for comparison), a track is classified as a "New Find" if: +- It has at least one play (`count > 0`) +- No matching Persistent ID exists from a previous reference (i.e., no prior play history) + +### Why Persistent ID? + +iTunes assigns each track a unique **Persistent ID** that remains constant even if the file is renamed, moved, or re-tagged. By checking for ID matches between snapshots, TunesBack can: +- Detect re-imported tracks (same song added again with a new ID) +- Avoid false positives from tracks that existed but were never played +- Accurately track genuinely new library additions + +### Decision Flow + +```mermaid +flowchart TD + A[Track in New Snapshot] --> B{Play count
increased?} + B -->|No| X[❌ Not a New Find] + B -->|Yes| C{Comparison mode
AND has Date Added?} + C -->|Yes| D{Date Added >
baseline date?} + C -->|No| E{Old play count
== 0?} + D -->|No| X + D -->|Yes| F{Persistent ID
exists in old library?} + E -->|No| X + E -->|Yes| F + F -->|Yes| X + F -->|No| Y[✅ New Find] +``` + +### Algorithm Summary + +``` +IF play_count_increased THEN + IF comparison_mode AND has_date_added THEN + new_find = (date_added > baseline_date) AND (no_persistent_id_match) + ELSE + new_find = (old_play_count == 0) AND (no_persistent_id_match) +``` + +Implementation details: see the `calculate_diff()` and `process_stats()` methods in `main.py`. + +## Album Art & Network Share Support + +TunesBack extracts album artwork directly from your audio files using **embedded metadata tags** and features **intelligent path resolution for network shares**. This ensures artwork displays even when files are stored on remote servers or have been moved. + +### How it works + +1. **Direct extraction**: Uses [Mutagen](https://mutagen.readthedocs.io/) to read embedded artwork from audio file tags (ID3, MP4, FLAC, etc.) +2. **Smart path resolution for network shares**: Automatically handles: + - **Windows UNC paths**: Converts `file://server/share/...` to `\\server\share\...` + - **Linux GNOME/GVFS**: Auto-detects mounts at `/run/user//gvfs/smb-share:...` + - **Linux KDE/kio-fuse**: Auto-detects Dolphin mounts at `/run/user//kio-fuse-*/smb/...` + - **macOS network volumes**: Resolves `/Volumes/...` paths + - **Hostname ↔ IP resolution**: Works even when XML uses hostname but mount uses IP + - **Moved files**: Works as long as the new path is accessible +3. **No configuration needed**: Network paths are resolved automatically across all platforms + + +### Troubleshooting missing artwork + +If album art doesn't appear: + +1. **Enable Debug Logging** in the app settings +2. Check the log file for path resolution errors: + - **macOS:** `~/Library/Logs/TunesBack/tunesback.log` + - **Windows:** `%LOCALAPPDATA%\TunesBack\Logs\tunesback.log` + - **Linux:** `~/.cache/TunesBack/logs/tunesback.log` +3. Verify your audio files have embedded artwork: + ```bash + # macOS/Linux + ffprobe -v error -show_entries format_tags=title -of default=noprint_wrappers=1 "song.mp3" + + # Or use any audio tagger to check + ``` +4. For network shares, ensure they're mounted before starting TunesBack + +### Network share setup + +**Windows:** +- Map network drive or use UNC paths directly +- XML paths like `file://server/share/Music/...` are automatically converted + +**Linux:** +- **KDE/Dolphin**: Simply browse to the network share in Dolphin before launching TunesBack +- **GNOME/Nautilus**: Connect via Files → Other Locations → Connect to Server +- **Manual mount**: + ```bash + sudo mount -t cifs //server/share /mnt/music -o username=user + ``` + + +## Quick Start ### 1. Run TunesBack **Download from** [Releases](https://github.com/mooseses/TunesBack/releases) -> **Note**: On first launch, macOS may show a security warning. Go to **System Settings → Privacy & Security** and click "Open Anyway" +| Platform | Format | +|----------|--------| +| Windows | Installer (`.exe`) | +| macOS | Disk image (`.dmg`) | + +> **macOS:** You may need to go to **System Settings → Privacy & Security** and click "Open Anyway" the first time. + +### Linux + +Linux users should run from source: -**From source**: ```bash git clone https://github.com/mooseses/TunesBack.git - cd TunesBack - +python3 -m venv venv +source venv/bin/activate pip install -r requirements.txt - python main.py ``` +**Requirements:** Python 3.10+ + +**Or run from source on any platform:** + +```bash +git clone https://github.com/mooseses/TunesBack.git +cd TunesBack +pip install -r requirements.txt +python main.py +``` ### 2. Export Your Library -1. Open iTunes/Music → **File** → **Library** → **Export Library** -2. Save as `.xml` with a date in filename (e.g., `2025-12-01.xml`) -3. Export again later to compare! +1. In iTunes or Music, go to **File → Library → Export Library** +2. Save the file with a date in the name (e.g., `2025-12-01.xml`) +3. Export again later to compare snapshots ### 3. Analyze -1. Click **Select Folder** and choose your XML files location -2. Pick date range or single snapshot +1. Click **Select Folder** and point to your XML files +2. Choose a date range or a single snapshot 3. Click **Generate Recap** +4. Use the pencil icon to show or hide tabs +5. Click **Generate Wrapped** to create shareable cards -## 📁 File Naming Guide +## File Naming -**How TunesBack Parses Dates** +TunesBack extracts dates from filenames automatically. It displays them as `YYYY-MM-DD` in the app. -TunesBack uses **fuzzy date parsing** to automatically extract dates from your XML filenames and displays them as `YYYY-MM-DD` in the app. - -### Recommended Formats +### Recommended formats ``` 2025-12-01.xml @@ -95,41 +294,56 @@ Dec-01-2025.xml Library_2025_12_01_backup.xml ``` -**Best Practice**: Use ISO format `YYYY-MM-DD.xml` or include month names to avoid confusion. +Use ISO format (`YYYY-MM-DD.xml`) or include the month name to avoid ambiguity. + +### These won't work reliably -### These examples are too ambiguous or won't work: +- `library.xml`: no date +- `v2.1.3-export.xml`: version numbers get confused with dates +- `backup.xml`: no date +- `01-12-2025.xml`: could be January 12 or December 1 +- `12-01-2025.xml`: same issue -- `library.xml` (no date) -- `v2.1.3-export.xml` (version numbers confused with dates) -- `backup.xml` (no date information) -- `01-12-2025.xml` → Could be Jan 12 or Dec 1 -- `12-01-2025.xml` → Could be Dec 1 or Jan 12 +### Automating exports -### 💡 Pro Tip: Automate Your Exports +You can schedule a task to copy your library XML to a snapshots folder: -Set up a scheduled task (cron/Task Scheduler) to automatically copy and rename your iTunes Library XML to a snapshots folder weekly/monthly: +**macOS/Linux (cron):** -**macOS/Linux:** ```bash -# Add to crontab: Run monthly on the 1st at midnight +# Monthly on the 1st at midnight 0 0 1 * * cp ~/Music/iTunes/iTunes\ Library.xml ~/Music/Snapshots/$(date +\%Y-\%m-\%d).xml ``` -**Windows PowerShell (Task Scheduler):** +**Windows (PowerShell via Task Scheduler):** + ```powershell $date = Get-Date -Format "yyyy-MM-dd" Copy-Item "$env:USERPROFILE\Music\iTunes\iTunes Library.xml" "$env:USERPROFILE\Music\Snapshots\$date.xml" ``` -You can also integrate this with cloud-based iTunes Library XML parsers like [this one](https://gist.github.com/ddelange/46d5a4c8c9897abb0d3d407938d3702a) to sync playlists to Plex while backing up snapshots. +## Known Limitations + +- **Album art** is extracted from embedded tags. iTunes' "Get Album Art" feature stores artwork separately and won't be picked up. +- **Moved libraries**: if your music files have moved and the XML paths are outdated, artwork won't load. Enable debug logging to investigate. +- **Network shares**: path resolution works best when shares are already mounted. +- **CJK text**: font fallback for Asian characters in Wrapped cards is limited. + +Enable "Debug Logging" in the app to see why artwork might be missing. + +## Tech Stack + +- [Flet](https://flet.dev/): Python UI framework +- [libpytunes](https://github.com/liamks/libpytunes): iTunes XML parser +- [pandas](https://pandas.pydata.org/): data analysis +- [Pillow](https://pillow.readthedocs.io/): image generation +- [mutagen](https://mutagen.readthedocs.io/): audio metadata extraction +- [python-dateutil](https://dateutil.readthedocs.io/): date parsing -## 🛠️ Tech Stack +## Feedback -- **[Flet](https://flet.dev/)** - Modern Python UI framework -- **[libpytunes](https://github.com/liamks/libpytunes)** - iTunes XML parser -- **[pandas](https://pandas.pydata.org/)** - Data analysis and aggregation -- **[python-dateutil](https://dateutil.readthedocs.io/)** - Fuzzy date parsing +Found a bug or have a suggestion? [Open an issue](https://github.com/mooseses/TunesBack/issues/new). ## License -Distributed under the GPL-3.0 License. See `LICENSE` for details. +GPL-3.0. See `LICENSE` for details. diff --git a/Thumbs.db b/Thumbs.db new file mode 100644 index 0000000..3c4d0f0 Binary files /dev/null and b/Thumbs.db differ diff --git a/assets/Thumbs.db b/assets/Thumbs.db index 66a0c55..2c84da3 100644 Binary files a/assets/Thumbs.db and b/assets/Thumbs.db differ diff --git a/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Black.otf b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Black.otf new file mode 100644 index 0000000..1842bb6 Binary files /dev/null and b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Black.otf differ diff --git a/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-BlackItalic.otf b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-BlackItalic.otf new file mode 100644 index 0000000..3434785 Binary files /dev/null and b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-BlackItalic.otf differ diff --git a/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Bold.otf b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Bold.otf new file mode 100644 index 0000000..c532c1a Binary files /dev/null and b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Bold.otf differ diff --git a/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Book.otf b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Book.otf new file mode 100644 index 0000000..0c65af2 Binary files /dev/null and b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Book.otf differ diff --git a/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-BookItalic.otf b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-BookItalic.otf new file mode 100644 index 0000000..b028251 Binary files /dev/null and b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-BookItalic.otf differ diff --git a/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Light.otf b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Light.otf new file mode 100644 index 0000000..7e2c375 Binary files /dev/null and b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Light.otf differ diff --git a/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Medium.otf b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Medium.otf new file mode 100644 index 0000000..8cc5b03 Binary files /dev/null and b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-Medium.otf differ diff --git a/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-MediumItalic.otf b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-MediumItalic.otf new file mode 100644 index 0000000..2928694 Binary files /dev/null and b/assets/fonts/Spotify-Circular-Font/CircularSpotifyText-MediumItalic.otf differ diff --git a/assets/fonts/wrap_year.ttf b/assets/fonts/wrap_year.ttf new file mode 100644 index 0000000..c00de27 Binary files /dev/null and b/assets/fonts/wrap_year.ttf differ diff --git a/assets/screenshots/Thumbs.db b/assets/screenshots/Thumbs.db new file mode 100644 index 0000000..4863b94 Binary files /dev/null and b/assets/screenshots/Thumbs.db differ diff --git a/assets/screenshots/dashboard.png b/assets/screenshots/dashboard.png index 0466e50..91e8d88 100644 Binary files a/assets/screenshots/dashboard.png and b/assets/screenshots/dashboard.png differ diff --git a/assets/screenshots/mainUI.png b/assets/screenshots/mainUI.png index 67fc8c0..6070d14 100644 Binary files a/assets/screenshots/mainUI.png and b/assets/screenshots/mainUI.png differ diff --git a/assets/spo_icon_dark.png b/assets/spo_icon_dark.png new file mode 100644 index 0000000..527caa8 Binary files /dev/null and b/assets/spo_icon_dark.png differ diff --git a/assets/spo_icon_light.png b/assets/spo_icon_light.png new file mode 100644 index 0000000..b4c3681 Binary files /dev/null and b/assets/spo_icon_light.png differ diff --git a/generate_wrapped.py b/generate_wrapped.py new file mode 100644 index 0000000..f3acd69 --- /dev/null +++ b/generate_wrapped.py @@ -0,0 +1,806 @@ +import sys +import os +import math +import random +import datetime +import logging +from typing import List, Dict, Any, Tuple, Optional, Set + +from PIL import Image, ImageDraw, ImageFont, ImageChops + +# --- CONFIGURATION --- + +WIDTH, HEIGHT = 1080, 1920 +CENTER_X = WIDTH // 2 +MARGIN_X = 60 +PATTERN_SIZE = (800, 975) +FOOTER_URL = "mooseses/TunesBack" + +class Colors: + DARK_BG = "#292929" + LIGHT_BG = "#efefeb" + PLACEHOLDER = "#333333" + TEXT_GREY = "#B3B3B3" + + @staticmethod + def get_age_color(age: int) -> str: + if age < 30: return "#96c90d" + if age < 40: return "#dbb603" + if age < 50: return "#9891fe" + if age < 80: return "#fe4635" + return "#f1a2bd" + +# --- ASSET MANAGEMENT --- + +class AssetManager: + """Centralizes access to Fonts and Images with Frozen App Support.""" + + _fonts = { + 'black': "CircularSpotifyText-Black.otf", + 'bold': "CircularSpotifyText-Bold.otf", + 'medium': "CircularSpotifyText-Medium.otf", + 'book': "CircularSpotifyText-Book.otf", + 'light': "CircularSpotifyText-Light.otf" + } + + # Common system fonts available on Linux (in order of preference) + _linux_fallback_fonts = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", # Arch Linux path + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + ] + + _font_cache = {} # Cache loaded fonts to avoid repeated file access + _base_path_cache = None # Cache the resolved base path + + @staticmethod + def get_base_path(): + """ + Returns the correct base path for assets whether running from source + or as a compiled executable (PyInstaller/Flet/AppImage). + """ + # Return cached path if available + if AssetManager._base_path_cache is not None: + return AssetManager._base_path_cache + + possible_paths = [] + + # Check APPDIR environment variable (set by AppImage at runtime) + appdir = os.environ.get('APPDIR') + if appdir: + possible_paths.append(os.path.join(appdir, "usr", "bin", "assets")) + + if getattr(sys, 'frozen', False): + # Running as compiled app + if hasattr(sys, '_MEIPASS'): + # PyInstaller extracts to sys._MEIPASS + possible_paths.append(os.path.join(sys._MEIPASS, "assets")) + + # Flet packages assets alongside the Python scripts + script_dir = os.path.dirname(os.path.abspath(__file__)) + possible_paths.append(os.path.join(script_dir, "assets")) + + # Standard source directory path (works for both frozen and non-frozen) + possible_paths.append(os.path.join(os.path.dirname(__file__), "assets")) + + # Return the first path that exists and contains fonts + for path in possible_paths: + fonts_path = os.path.join(path, "fonts", "Spotify-Circular-Font") + if os.path.isdir(fonts_path): + AssetManager._base_path_cache = path + return path + + # Fallback with warning + default = os.path.join(os.path.dirname(__file__), "assets") + logging.warning(f"Fonts not found in expected locations, using: {default}") + AssetManager._base_path_cache = default + return default + + @classmethod + def get_font_path(cls, weight: str): + # Helper to construct full font path + filename = cls._fonts.get(weight, cls._fonts['book']) + return os.path.join(cls.get_base_path(), "fonts", "Spotify-Circular-Font", filename) + + @classmethod + def _get_fallback_font(cls, size: int) -> ImageFont.FreeTypeFont: + """Try to load a system TrueType font as fallback (Linux-friendly).""" + # Check cache first + cache_key = ('fallback', size) + if cache_key in cls._font_cache: + return cls._font_cache[cache_key] + + # Try Linux system fonts + for font_path in cls._linux_fallback_fonts: + if os.path.exists(font_path): + try: + font = ImageFont.truetype(font_path, size) + cls._font_cache[cache_key] = font + return font + except Exception: + continue + + # Try Pillow 10.0+ default (returns TrueType font) + try: + font = ImageFont.load_default(size=size) + cls._font_cache[cache_key] = font + return font + except TypeError: + # Older Pillow doesn't support size parameter + pass + + # Last resort: basic default (may not support all text operations) + logging.warning("No TrueType fallback font found, text rendering may be limited") + return ImageFont.load_default() + + @classmethod + def get_font(cls, size: int, weight: str = 'book') -> ImageFont.FreeTypeFont: + # Check cache first + cache_key = (weight, size) + if cache_key in cls._font_cache: + return cls._font_cache[cache_key] + + try: + path = cls.get_font_path(weight) + font = ImageFont.truetype(path, size) + cls._font_cache[cache_key] = font + return font + except OSError as e: + logging.warning(f"Font not found at {path}: {e}") + return cls._get_fallback_font(size) + + @classmethod + def get_icon(cls, is_light_theme: bool) -> Image.Image: + filename = "spo_icon_dark.png" if is_light_theme else "spo_icon_light.png" + path = os.path.join(cls.get_base_path(), filename) + if os.path.exists(path): + return Image.open(path).convert("RGBA") + logging.warning(f"Icon not found at {path}") + return None + +# --- DRAWING UTILITIES --- + +class DrawUtils: + @staticmethod + def _safe_textlength(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont) -> float: + """Safely get text length, with fallback for bitmap fonts.""" + try: + return draw.textlength(text, font) + except AttributeError: + # Fallback for bitmap fonts that don't support textlength + try: + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0] + except Exception: + # Last resort: estimate based on character count + return len(text) * 10 + + @staticmethod + def _safe_textbbox(draw: ImageDraw.ImageDraw, xy: Tuple[int, int], text: str, + font: ImageFont.FreeTypeFont, anchor: str = None, + stroke_width: int = 0) -> Tuple[int, int, int, int]: + """Safely get text bounding box, with fallback for bitmap fonts.""" + try: + return draw.textbbox(xy, text, font=font, anchor=anchor, stroke_width=stroke_width) + except Exception: + # Fallback: estimate bounding box + try: + w = DrawUtils._safe_textlength(draw, text, font) + h = 20 # Default height estimate + if hasattr(font, 'size'): + h = font.size + elif hasattr(font, 'getbbox'): + bbox = font.getbbox(text) + if bbox: + h = bbox[3] - bbox[1] + return (xy[0], xy[1], xy[0] + int(w), xy[1] + int(h)) + except Exception: + return (xy[0], xy[1], xy[0] + len(text) * 10, xy[1] + 20) + + @staticmethod + def truncate(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont, max_width: float) -> str: + if DrawUtils._safe_textlength(draw, text, font) <= max_width: + return text + ellipsis = "..." + target = max_width - DrawUtils._safe_textlength(draw, ellipsis, font) + while len(text) > 0 and DrawUtils._safe_textlength(draw, text, font) > target: + text = text[:-1] + return text + ellipsis + + @staticmethod + def draw_flat_text(target_img: Image.Image, xy: Tuple[int, int], text: str, + font: ImageFont.FreeTypeFont, fill: Any, stretch_factor: float = 1.3, + anchor: str = None, stroke_width: int = 0, stroke_fill: Any = None, + force_width: int = None, kerning: int = 0) -> Tuple[int, int]: + """ + Renders text. Draws directly for standard text to preserve quality. + Uses an intermediate layer for manual kerning or stretching. + """ + # Path 1: Standard High-Quality Text (No effects) + if kerning == 0 and stretch_factor == 1.0 and force_width is None: + draw = ImageDraw.Draw(target_img) + try: + draw.text(xy, text, font=font, fill=fill, anchor=anchor, stroke_width=stroke_width, stroke_fill=stroke_fill) + except TypeError: + # Fallback for fonts that don't support anchor/stroke + draw.text(xy, text, font=font, fill=fill) + bbox = DrawUtils._safe_textbbox(draw, xy, text, font, anchor, stroke_width) + return bbox[2]-bbox[0], bbox[3]-bbox[1] + + # Path 2: Custom Text (Stretched/Tightened) + dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1))) + + # Calculate Dimensions + if kerning == 0: + w_raw = DrawUtils._safe_textlength(dummy, text, font) + else: + w_raw = 0 + char_widths = [] + for char in text: + cw = DrawUtils._safe_textlength(dummy, char, font) + char_widths.append(cw) + w_raw += cw + kerning + w_raw = max(1, w_raw - kerning if w_raw > 0 else 1) + + bbox = DrawUtils._safe_textbbox(dummy, (0, 0), text, font, stroke_width=stroke_width) + h_raw = bbox[3] - bbox[1] + 20 + + # Render Layer + txt_layer = Image.new("RGBA", (max(1, int(w_raw)), max(1, h_raw)), (0, 0, 0, 0)) + d = ImageDraw.Draw(txt_layer) + + try: + if kerning == 0: + d.text((0, 0), text, font=font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill) + else: + cx = 0 + for i, char in enumerate(text): + d.text((cx, 0), char, font=font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill) + cx += char_widths[i] + kerning + except TypeError: + # Fallback for fonts that don't support stroke + if kerning == 0: + d.text((0, 0), text, font=font, fill=fill) + else: + cx = 0 + for i, char in enumerate(text): + d.text((cx, 0), char, font=font, fill=fill) + cx += char_widths[i] + kerning + + # Stretch & Paste + new_w = force_width if force_width else max(1, int(w_raw * stretch_factor)) + stretched = txt_layer.resize((int(new_w), max(1, h_raw)), resample=Image.BICUBIC) + + x, y = xy + if anchor: + if "m" in anchor[0]: x -= new_w // 2 + elif "r" in anchor[0]: x -= new_w + if "m" in anchor[1]: y -= h_raw // 2 + elif "b" in anchor[1]: y -= h_raw + + target_img.paste(stretched, (int(x), int(y)), stretched) + return new_w, h_raw + +# --- PATTERN GENERATORS --- + +class PatternGenerator: + @staticmethod + def _apply_wave(img: Image.Image, amp=30, freq=0.01) -> Image.Image: + out = Image.new("RGB", img.size, (255, 255, 255)) + pix_in, pix_out = img.load(), out.load() + w, h = img.size + for y in range(h): + shift = int(amp * math.sin(y * freq)) + for x in range(w): + pix_out[x, y] = pix_in[(x - shift) % w, y] + return out + + @staticmethod + def weezer() -> Image.Image: + img = Image.new("RGB", PATTERN_SIZE, Colors.DARK_BG) + draw = ImageDraw.Draw(img) + rw, rh = PATTERN_SIZE[0] / 17, PATTERN_SIZE[1] / 3 + for r in range(3): + for c in range(17): + if (r + c) % 2 == 0: + draw.rectangle([c*rw, r*rh, c*rw+rw+1, r*rh+rh+1], fill=Colors.LIGHT_BG) + return img + + @staticmethod + def paramore() -> Image.Image: + img = Image.new("RGB", PATTERN_SIZE, Colors.DARK_BG) + draw = ImageDraw.Draw(img) + cols, rows, r = 5, 6, 40 + sp_x, sp_y = (PATTERN_SIZE[0]-2*r)/(cols-1), (PATTERN_SIZE[1]-2*r)/(rows-1) + for row in range(rows): + y = r + row * sp_y + for col in range(cols): + draw.ellipse([r+col*sp_x-r, y-r, r+col*sp_x+r, y+r], fill=Colors.LIGHT_BG) + if row < rows - 1: + for ci in range(cols - 1): + xi, yi = r + ci*sp_x + sp_x/2, y + sp_y/2 + draw.ellipse([xi-r, yi-r, xi+r, yi+r], fill=Colors.LIGHT_BG) + return img + + @staticmethod + def sabrina() -> Image.Image: + img = Image.new("RGB", PATTERN_SIZE, Colors.LIGHT_BG) + draw = ImageDraw.Draw(img) + cx, cy, curr_r = 860, PATTERN_SIZE[1] // 2, 1000 + is_blk = True + while curr_r > 0: + fill = Colors.DARK_BG if is_blk else Colors.LIGHT_BG + draw.ellipse([cx-curr_r, cy-curr_r, cx+curr_r, cy+curr_r], fill=fill) + curr_r -= 1000/24 + is_blk = not is_blk + return img + + @staticmethod + def jessie() -> Image.Image: + base = Image.new("RGB", PATTERN_SIZE, Colors.LIGHT_BG) + draw = ImageDraw.Draw(base) + sz = 100 + for r in range(PATTERN_SIZE[1]//sz+2): + for c in range(PATTERN_SIZE[0]//sz+2): + if (r+c) % 2 == 1: + draw.rectangle([c*sz, r*sz, c*sz+sz, r*sz+sz], fill=Colors.DARK_BG) + return PatternGenerator._apply_wave(base, 100, 0.008) + + @staticmethod + def geometric_illusion_v2() -> Image.Image: + w, h = 260, 130 + base = Image.new("RGB", (w, h), Colors.LIGHT_BG) + inv = Image.new("RGB", (w, h), Colors.DARK_BG) + db, di = ImageDraw.Draw(base), ImageDraw.Draw(inv) + + sw, period = 38, 78 + for x in range(-55 % period - period, w, period): + db.rectangle([x, 0, x + sw, h], fill=Colors.DARK_BG) + di.rectangle([x, 0, x + sw, h], fill=Colors.LIGHT_BG) + + mask = Image.new("L", (w, h), 0) + dm = ImageDraw.Draw(mask) + cx, cy, tx, ty = -w*0.4, -h*2.6, w*0.35, h*0.2 + r = math.hypot(tx - cx, ty - cy) + thick = sw * 1.8 + dm.ellipse([cx - (r+thick/2), cy - (r+thick/2), cx + (r+thick/2), cy + (r+thick/2)], fill=255) + dm.ellipse([cx - (r-thick/2), cy - (r-thick/2), cx + (r-thick/2), cy + (r-thick/2)], fill=0) + return Image.composite(inv, base, mask) + + @staticmethod + def op_art_ovals(scale: float = 1.0) -> Image.Image: + W, H = 460, 700 + c_bg, c_fg = Colors.LIGHT_BG, Colors.DARK_BG + sw = 55 + + base = Image.new("RGB", (W, H), c_bg) + inv = Image.new("RGB", (W, H), c_fg) + db, di = ImageDraw.Draw(base), ImageDraw.Draw(inv) + + for x in range(sw, W, sw * 2): + db.rectangle([x, 0, min(x + sw, W), H], fill=c_fg) + di.rectangle([x, 0, min(x + sw, W), H], fill=c_bg) + + mask = Image.new("L", (W, H), 0) + dm = ImageDraw.Draw(mask) + + ox, oy = W + 390, H // 2 - 35 + orx, ory = 850, 430 + dm.ellipse([ox - orx, oy - ory, ox + orx, oy + ory], fill=255) + + ix, iy = W + 570, H // 2 - 35 + irx, iry = 905, 360 + dm.ellipse([ix - irx, iy - iry, ix + irx, iy + iry], fill=0) + + final = Image.composite(inv, base, mask) + if scale != 1.0: + final = final.resize((int(W * scale), int(H * scale)), resample=Image.BICUBIC) + return final + +# --- CARD RENDERER --- + +class CardRenderer: + def __init__(self, bg_color: str = Colors.DARK_BG): + self.img = Image.new("RGBA", (WIDTH, HEIGHT), bg_color) + self.draw = ImageDraw.Draw(self.img) + self.bg_color = bg_color + self.is_light = (bg_color == Colors.LIGHT_BG) + + def add_header(self, text: str, y_pos: int = 115, size: int = 60, bg_box: str = None, text_col: str = None): + font = AssetManager.get_font(size, 'black') + col = text_col if text_col else (Colors.DARK_BG if bg_box else (Colors.DARK_BG if self.is_light else Colors.LIGHT_BG)) + + if bg_box: + dummy = ImageDraw.Draw(Image.new("L", (1,1))) + box_w = DrawUtils._safe_textlength(dummy, text, font) * 1.3 + 60 + box_h = 90 + bx = (WIDTH - box_w) // 2 + self.draw.rectangle((bx, y_pos, bx + box_w, y_pos + box_h), fill=bg_box) + y_pos += (box_h // 2) + 12 + DrawUtils.draw_flat_text(self.img, (CENTER_X, y_pos), text, font, col, 1.3, "mm", kerning=-2) + else: + DrawUtils.draw_flat_text(self.img, (CENTER_X, y_pos), text, font, col, 1.3, "mt", kerning=-2) + + def add_footer(self): + font = AssetManager.get_font(50, 'bold') + color = Colors.DARK_BG if self.is_light else Colors.TEXT_GREY + + logo = AssetManager.get_icon(self.is_light) + if logo: + logo = logo.resize((105, 105), Image.Resampling.LANCZOS) + self.img.paste(logo, (60, HEIGHT - 150), logo) + + w = DrawUtils._safe_textlength(self.draw, FOOTER_URL, font) + self.draw.text((WIDTH - w - 60, HEIGHT - 120), FOOTER_URL, font=font, fill=color) + + def get_image(self) -> Image.Image: + self.add_footer() + return self.img + +# --- CARD GENERATION LOGIC --- + +def draw_top_albums(items: List[Dict]) -> Image.Image: + card = CardRenderer(Colors.LIGHT_BG) + card.add_header("My Top Albums", y_pos=80, bg_box=Colors.DARK_BG, text_col="#ddba13") + + # Background & Grid + cy, gap = 280, 10 + eff_margin = MARGIN_X + 20 + total_w = WIDTH - (eff_margin * 2) + w_big = (total_w - gap) // 2 + w_small = (total_w - 2*gap) // 3 + + pat = Image.new("RGBA", (WIDTH, HEIGHT), (0,0,0,0)) + pd = ImageDraw.Draw(pat) + sp_x, r = WIDTH / 5, 60 + for row in range(8): + y = cy + row * (w_big + w_small + gap) / 7 + count, offset = (5, sp_x/2) if row % 2 == 0 else (6, 0) + for c in range(count): + cx = c * sp_x + offset + pd.ellipse([cx-r, y-r, cx+r, y+r], fill=Colors.DARK_BG) + card.img.paste(pat, (0, 0), pat) + + card.draw.rectangle((eff_margin, cy, eff_margin + total_w, cy + w_big + gap + w_small), fill=Colors.DARK_BG) + coords = [ + (eff_margin, cy, w_big, w_big), (eff_margin + w_big + gap, cy, w_big, w_big), + (eff_margin, cy + w_big + gap, w_small, w_small), + (eff_margin + w_small + gap, cy + w_big + gap, w_small, w_small), + (eff_margin + (w_small + gap)*2, cy + w_big + gap, w_small, w_small) + ] + + for i, (x, y, w, h) in enumerate(coords): + if i >= len(items): break + if img := items[i].get('image'): + card.img.paste(img.resize((int(w), int(h))), (int(x), int(y))) + else: + card.draw.rectangle((x, y, x+w, y+h), fill=Colors.PLACEHOLDER) + + ts = 60 + card.draw.rectangle((x+w-ts, y+h-ts, x+w, y+h), fill=Colors.LIGHT_BG) + DrawUtils.draw_flat_text(card.img, (x+w-ts/2, y+h-ts/2), str(i+1), AssetManager.get_font(40, 'black'), Colors.DARK_BG, 1.3, "mm", kerning=0) + + # List + ly = cy + w_big + gap + w_small + 100 + for i, item in enumerate(items[:5]): + y = ly + (i * 120) + DrawUtils.draw_flat_text(card.img, (eff_margin, y), str(i+1), AssetManager.get_font(50, 'black'), Colors.DARK_BG, 1.3, "lt", kerning=0) + + nm = DrawUtils.truncate(card.draw, item['name'], AssetManager.get_font(45, 'black'), 750) + DrawUtils.draw_flat_text(card.img, (eff_margin + 60, y), nm, AssetManager.get_font(45, 'black'), Colors.DARK_BG, 1.2, "lt", kerning=0) + + sub = DrawUtils.truncate(card.draw, item.get('sub', ''), AssetManager.get_font(35), 750) + card.draw.text((eff_margin + 60, y+55), sub, font=AssetManager.get_font(35), fill="#444") + + return card.get_image() + +def draw_top_genres(genres: List[str]) -> Image.Image: + card = CardRenderer(Colors.LIGHT_BG) + + # Background + pat = Image.new("RGBA", (WIDTH, HEIGHT), (0,0,0,0)) + pd = ImageDraw.Draw(pat) + for x, y, r in [(850, 1100, 60), (980, 1250, 70), (800, 1350, 50), (1020, 1450, 65), (900, 1600, 55), (750, 1700, 45), (950, 950, 55)]: + pd.ellipse((x-r, y-r, x+r, y+r), fill=Colors.DARK_BG) + for x, y, r in [(700, 1450, 50), (850, 1550, 60), (650, 1650, 45), (800, 1750, 55), (950, 1700, 65), (1050, 1600, 50)]: + pd.ellipse((x-r, y-r, x+r, y+r), fill="#fe4635") + pd.arc((100, 1750, 900, 1950), 180, 360, fill=Colors.DARK_BG, width=3) + pd.line((300, 1850, 1000, 1700), fill=Colors.DARK_BG, width=3) + card.img.paste(pat, (0,0), pat) + + # Bubbles + box_w, box_x = 780, 220 + assets = [] + f_src = AssetManager.get_font(300, 'black') + genre_kerning = -2 + + for g in genres[:5]: + d = ImageDraw.Draw(Image.new("RGBA", (1,1))) + + # Manual width calc + w_text = 0 + char_widths = [] + for char in g: + cw = DrawUtils._safe_textlength(d, char, f_src) + char_widths.append(cw) + w_text += cw + genre_kerning + w_text = max(1, w_text - genre_kerning if w_text > 0 else 1) + + bb = DrawUtils._safe_textbbox(d, (0,0), g, f_src) + h = bb[3]-bb[1]+20 + + txt = Image.new("RGBA", (int(w_text), h), (0,0,0,0)) + d_txt = ImageDraw.Draw(txt) + + cx = 0 + for i, char in enumerate(g): + d_txt.text((cx, 0), char, font=f_src, fill=Colors.LIGHT_BG) + cx += char_widths[i] + genre_kerning + + squished = txt.resize((int(w_text*1.3), h), resample=Image.BICUBIC) + target_w = box_w - 10 + ratio = target_w / squished.width + new_h = int(h * ratio) + if new_h > 230: + ratio = 230 / h + target_w = int(squished.width * ratio) + new_h = 230 + + final_txt = squished.resize((int(target_w), int(new_h)), resample=Image.BICUBIC) + box = Image.new("RGBA", (int(target_w + 10 if new_h == 230 else box_w), int(new_h + 60)), Colors.DARK_BG) + box.paste(final_txt, (5, 30), final_txt) + assets.append(box) + + # Layout + gap_size = 35 + DrawUtils.draw_flat_text(card.img, (CENTER_X, (HEIGHT - sum(a.height + gap_size for a in assets)) // 2 - 100), "My Top Genres", AssetManager.get_font(60, 'black'), Colors.DARK_BG, 1.3, "mt", kerning=-2) + + cy = (HEIGHT - sum(a.height + gap_size for a in assets)) // 2 + 20 + for i, box in enumerate(assets): + DrawUtils.draw_flat_text(card.img, (box_x - 30, cy + box.height//2), str(i+1), AssetManager.get_font(100, 'black'), Colors.DARK_BG, 1.4, "rm", kerning=-2) + card.img.paste(box, (box_x, int(cy))) + cy += box.height + gap_size + + return card.get_image() + +def draw_listening_age(age: int, label: str) -> Image.Image: + card = CardRenderer(Colors.LIGHT_BG) + DrawUtils.draw_flat_text(card.img, (CENTER_X, 550), "My Listening Age", AssetManager.get_font(60, 'black'), Colors.DARK_BG, 1.3, "mm", kerning=-2) + DrawUtils.draw_flat_text(card.img, (CENTER_X, 900), str(age), AssetManager.get_font(500, 'black'), Colors.get_age_color(age), 1.4, "mm", stroke_width=4, stroke_fill=Colors.DARK_BG, kerning=0) + + # Era Text + cur = datetime.datetime.now().year + peak = (cur - age) + 18 + dec = (peak // 10) * 10 + prefix = "Early" if peak%10 < 4 else ("Mid" if peak%10 < 7 else "Late") + txt_parts = [("Since I was into music from ", 'book'), ("the ", 'book'), (f"{prefix} ", 'black'), (f"{str(dec)[-2:]}s", 'black')] + + total_w = sum(DrawUtils._safe_textlength(card.draw, t, AssetManager.get_font(45, w)) for t, w in txt_parts) + cx = (WIDTH - total_w) / 2 + for txt, weight in txt_parts: + font = AssetManager.get_font(45, weight) + DrawUtils.draw_flat_text(card.img, (cx, 1300), txt, font, Colors.DARK_BG, 1.0, "lm", kerning=0) + cx += DrawUtils._safe_textlength(card.draw, txt, font) + + return card.get_image() + +def draw_top_songs(items: List[Dict]) -> Image.Image: + card = CardRenderer(Colors.DARK_BG) + pat = PatternGenerator.geometric_illusion_v2().resize((int(260*1.9), int(130*1.9)), Image.Resampling.LANCZOS) + card.img.paste(pat, (0,0)) + card.add_header("My Top Songs", y_pos=310, bg_box=Colors.LIGHT_BG) + + sy = 570 + for i, item in enumerate(items[:5]): + y = sy + (i * 230) + DrawUtils.draw_flat_text(card.img, (120, y), str(i+1), AssetManager.get_font(100, 'black'), Colors.LIGHT_BG, 1.3, "mm", kerning=0) + + if item.get('image'): + card.img.paste(item['image'].resize((180, 180)), (220, y - 90)) + else: + card.draw.rectangle((220, y - 90, 400, y + 90), fill=Colors.PLACEHOLDER) + + nm = DrawUtils.truncate(card.draw, item['name'], AssetManager.get_font(55, 'black'), (WIDTH - 490)/1.15) + DrawUtils.draw_flat_text(card.img, (440, y - 20), nm, AssetManager.get_font(55, 'black'), Colors.LIGHT_BG, 1.15, "lm", kerning=-2) + + sub = DrawUtils.truncate(card.draw, item.get('sub', ''), AssetManager.get_font(40), WIDTH - 490) + card.draw.text((440, y + 45), sub, font=AssetManager.get_font(40), fill=Colors.LIGHT_BG) + + return card.get_image() + +def draw_top_song_single(item: Dict) -> Image.Image: + card = CardRenderer(Colors.DARK_BG) + pat = PatternGenerator.op_art_ovals(scale=1.3) + card.img.paste(pat, (WIDTH - pat.width, 0)) + + sz, y = 655, 245 + if item.get('image'): + card.img.paste(item['image'].resize((sz, sz)), ((WIDTH-sz)//2, y)) + else: + card.draw.rectangle(((WIDTH-sz)//2, y, (WIDTH+sz)//2, y+sz), fill=Colors.PLACEHOLDER) + + DrawUtils.draw_flat_text(card.img, (CENTER_X, y+sz+150), "My Top Song", AssetManager.get_font(50, 'black'), Colors.LIGHT_BG, 1.4, "mm", kerning=-2) + + safe_w = WIDTH - 120 + nm = DrawUtils.truncate(card.draw, item['name'], AssetManager.get_font(90, 'black'), safe_w / 1.4) + DrawUtils.draw_flat_text(card.img, (CENTER_X, y+sz+280), nm, AssetManager.get_font(90, 'black'), Colors.LIGHT_BG, 1.4, "mm", kerning=-2) + + sub = DrawUtils.truncate(card.draw, item.get('sub', ''), AssetManager.get_font(50), safe_w) + card.draw.text((CENTER_X, y+sz+380), sub, font=AssetManager.get_font(50), fill=Colors.LIGHT_BG, anchor="mm") + + DrawUtils.draw_flat_text(card.img, (CENTER_X, y+sz+500), "Total Plays", AssetManager.get_font(40, 'medium'), Colors.LIGHT_BG, 1.0, "mm", kerning=0) + val = f"{int(item.get('count', 0)):,}" + DrawUtils.draw_flat_text(card.img, (CENTER_X, y+sz+580), val, AssetManager.get_font(80, 'black'), Colors.LIGHT_BG, 1.3, "mm", kerning=0) + + return card.get_image() + +def draw_minutes_card(minutes: int) -> Image.Image: + card = CardRenderer(Colors.DARK_BG) + DrawUtils.draw_flat_text(card.img, (CENTER_X, 600), "My Minutes Listened", AssetManager.get_font(60, 'black'), Colors.LIGHT_BG, 1.3, "mm", kerning=-2) + + val_str = f"{minutes:,}" + digits = len(str(minutes)) + dynamic_width = min(WIDTH, int((WIDTH / 4) * max(2, digits))) + + DrawUtils.draw_flat_text(card.img, (CENTER_X, 850), val_str, AssetManager.get_font(300, 'black'), + "#9690fd", 1.0, "mm", stroke_width=5, stroke_fill=Colors.LIGHT_BG, + force_width=dynamic_width, kerning=0) + + DrawUtils.draw_flat_text(card.img, (CENTER_X, 1050), f"That's {int(minutes / 1440)} days.", AssetManager.get_font(50, 'medium'), Colors.LIGHT_BG, 1.0, "mm", kerning=0) + return card.get_image() + +def draw_summary_card(data: Dict[str, Any]) -> Image.Image: + # Pattern + p_name = random.choice(["weezer", "paramore", "sabrina", "jessie"]) + is_dark = p_name in ["weezer", "paramore"] + card = CardRenderer(Colors.DARK_BG if is_dark else Colors.LIGHT_BG) + card.img.paste(getattr(PatternGenerator, p_name)(), (WIDTH - 800, 0)) + c_txt, c_sub = (Colors.LIGHT_BG, "#BBB") if is_dark else (Colors.DARK_BG, "#444") + + # Background Year + yr = data.get('year_label', "2025") + f_yr = AssetManager.get_font(290, 'black') + + dummy = ImageDraw.Draw(Image.new("L", (1,1))) + chars = [] + x = 16 + for c in yr: + w = DrawUtils._safe_textlength(dummy, c, f_yr) + chars.append((c, x, w)) + x += w - 47 + + mask = Image.new("L", (max(1, int(x + 50)), 350), 0) + dm = ImageDraw.Draw(mask) + for c, cx, _ in chars: dm.text((cx, 10), c, font=f_yr, fill=255) + + dilated = mask.copy() + dd = ImageDraw.Draw(dilated) + for c, cx, _ in chars: + try: + dd.text((cx, 10), c, font=f_yr, fill=255, stroke_width=6, stroke_fill=255) + except TypeError: + # Fallback for fonts that don't support stroke + dd.text((cx, 10), c, font=f_yr, fill=255) + outline = ImageChops.subtract(dilated, mask) + + fill_col = Colors.get_age_color(data.get('age_data', {}).get('age', 25)) + final_yr = Image.new("RGBA", mask.size, (0,0,0,0)) + df = ImageDraw.Draw(final_yr) + final_yr.paste(c_txt, (0,0), outline) + for c, cx, _ in chars: df.text((cx, 10), c, font=f_yr, fill=fill_col) + + try: + bbox = final_yr.getbbox() + if bbox: + final_yr = final_yr.crop(bbox) + except Exception as e: + logging.warning(f"Could not crop year image: {e}") + + final_yr = final_yr.resize((max(1, int(final_yr.width * 1.6)), max(1, final_yr.height)), Image.BICUBIC) + rotated = final_yr.rotate(90, expand=True) + card.img.paste(rotated, (10, 0), rotated) + + # Top Artist + ax, ay, asz = 220, 110, 750 + card.draw.rectangle((ax-6, ay-6, ax+asz+6, ay+asz+6), fill=c_txt) + if art := data.get('top_artist', {}).get('image'): + card.img.paste(art.resize((asz, asz)), (ax, ay)) + else: + card.draw.rectangle((ax, ay, ax+asz, ay+asz), fill="#222") + + # Lists + y = ay + asz + 180 + def draw_list(x, title, items): + card.draw.text((x, y), title, font=AssetManager.get_font(40, 'medium'), fill=c_sub) + for i, item in enumerate(items[:5]): + nm = DrawUtils.truncate(card.draw, item['name'], AssetManager.get_font(40, 'bold'), 450) + card.draw.text((x, y+60+i*55), f"{i+1} {nm}", font=AssetManager.get_font(40, 'bold'), fill=c_txt) + + draw_list(MARGIN_X, "Top Artists", data.get('top_artists_list', [])) + draw_list(CENTER_X + 30, "Top Songs", data.get('top_songs', [])) + + # Stats + ys = y + 420 + card.draw.text((MARGIN_X, ys), "Minutes Listened", font=AssetManager.get_font(40, 'medium'), fill=c_sub) + card.draw.text((MARGIN_X, ys+60), f"{data.get('total_minutes',0):,}", font=AssetManager.get_font(80, 'black'), fill=c_txt) + card.draw.text((CENTER_X + 30, ys), "Top Genre", font=AssetManager.get_font(40, 'medium'), fill=c_sub) + card.draw.text((CENTER_X + 30, ys+60), data.get('genres', ["Unknown"])[0], font=AssetManager.get_font(80, 'black'), fill=c_txt) + + return card.get_image() + +# --- ORCHESTRATOR --- + +def generate_card_stack(data: Dict[str, Any]) -> List[Image.Image]: + cards = [] + + if data.get('top_albums'): + logging.info("Generating: Top Albums") + img = draw_top_albums(data['top_albums']) + img.info['card_name'] = "Top_Albums" + cards.append(img) + + if data.get('genres'): + logging.info("Generating: Genres") + img = draw_top_genres(data['genres']) + img.info['card_name'] = "Top_Genres" + cards.append(img) + + if data.get('age_data'): + logging.info("Generating: Listening Age") + img = draw_listening_age(data['age_data']['age'], data['age_data']['label']) + img.info['card_name'] = "Listening_Age" + cards.append(img) + + if data.get('top_songs'): + logging.info("Generating: Top Songs List") + img1 = draw_top_songs(data['top_songs']) + img1.info['card_name'] = "Top_Songs_List" + cards.append(img1) + + logging.info("Generating: Top Song Single") + img2 = draw_top_song_single(data['top_songs'][0]) + img2.info['card_name'] = "Top_Song_Single" + cards.append(img2) + + if data.get('total_minutes'): + logging.info("Generating: Minutes Listened") + img = draw_minutes_card(data['total_minutes']) + img.info['card_name'] = "Minutes_Listened" + cards.append(img) + + logging.info("Generating: Summary Card") + img = draw_summary_card(data) + img.info['card_name'] = "Summary" + cards.append(img) + + return cards + +def save_card_stack(images: List[Image.Image], output_path: str, indices: Optional[Set[int]] = None) -> int: + if not os.path.exists(output_path): + os.makedirs(output_path) + + count = 0 + save_indices = indices if indices is not None else range(len(images)) + + for i in save_indices: + if i < len(images): + try: + img = images[i] + base_name = img.info.get('card_name', f"Wrapped_{i+1}") + ts = datetime.datetime.now().strftime("%H%M%S") + filename = f"TunesBack_{base_name}_{ts}.jpg" + full_path = os.path.join(output_path, filename) + img.convert("RGB").save(full_path, "JPEG", quality=95) + logging.info(f"Saved: {full_path}") + count += 1 + except Exception as e: + logging.error(f"Failed to save image {i}: {e}") + + return count \ No newline at end of file diff --git a/listening_age_algorithm.py b/listening_age_algorithm.py new file mode 100644 index 0000000..7511bdb --- /dev/null +++ b/listening_age_algorithm.py @@ -0,0 +1,64 @@ +"""Listening Age calculation based on the Reminiscence Bump theory.""" +from datetime import datetime + + +def calculate_listening_age(plays_per_year: dict, current_year: int = None) -> int: + """ + Calculates Listening Age based on the "Reminiscence Bump" theory. + It identifies the 5-year span with the highest play count (the "formative era") + and calculates how old the user would be today if they were ~18 during that era. + + Args: + plays_per_year (dict): A dictionary mapping release year to play count. + e.g. {2022: 50, 2010: 12} + current_year (int): The current year for the calculation context. + Defaults to the actual current year. + + Returns: + int: The calculated Listening Age. + """ + if not plays_per_year: + return 0 + + if current_year is None: + current_year = datetime.now().year + + # Filter out invalid years (e.g., 0 or future years if data is messy) + valid_data = {y: c for y, c in plays_per_year.items() if 1900 < y <= current_year + 1} + + if not valid_data: + return 0 + + min_year = min(valid_data.keys()) + max_year = max(valid_data.keys()) + + # Constants + WINDOW_SIZE = 5 + FORMATIVE_AGE_CONSTANT = 18 # Age where musical taste peaks + + # Find the "Peak Era" (Moving Window Sum) + max_play_count = 0 + best_start_year = min_year + + # Optimization: Iterate only years that exist as keys to skip gaps, + # but range is safer for the window logic. + for start_year in range(min_year, max_year + 1): + # Sum plays for the window [start_year, start_year + 4] + current_window_sum = 0 + for i in range(WINDOW_SIZE): + current_window_sum += valid_data.get(start_year + i, 0) + + if current_window_sum > max_play_count: + max_play_count = current_window_sum + best_start_year = start_year + + # Determine Center Year of the Era + # e.g., If window is 2015-2019, center is 2017 + center_era_year = best_start_year + (WINDOW_SIZE // 2) + + # Calculate Age + # Logic: If center year is 2017, and you were 18 then, you were born in 1999. + musical_birth_year = center_era_year - FORMATIVE_AGE_CONSTANT + listening_age = current_year - musical_birth_year + + return listening_age \ No newline at end of file diff --git a/main.py b/main.py index 5248a01..908d992 100644 --- a/main.py +++ b/main.py @@ -1,48 +1,349 @@ +from __future__ import annotations + import sys +import os +import threading +import traceback +import datetime +import base64 +import platform +import urllib.parse +import urllib.request +import logging +import subprocess from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent / "libpytunes")) -from libpytunes import Library +from typing import Optional, Tuple, Dict, List, Set, Any +from dataclasses import dataclass, field +from collections import defaultdict +from io import BytesIO +# --- Third-party Imports --- import flet as ft -import os -import traceback import pandas as pd -from collections import defaultdict +import mutagen +from PIL import Image from dateutil import parser -import threading -from typing import Optional, Tuple, Dict, List -from xml.etree.ElementTree import ParseError +# --- Local Imports --- +sys.path.insert(0, str(Path(__file__).parent / "libpytunes")) +try: + from libpytunes import Library +except ImportError: + print("Error: libpytunes not found. Please ensure the submodule is present.") + Library = None + +import generate_wrapped +import listening_age_algorithm + +# ========================================== +# CONFIGURATION & THEME +# ========================================== + +APP_NAME = "TunesBack" +IS_WINDOWS = platform.system() == "Windows" +IS_MACOS = platform.system() == "Darwin" +IS_LINUX = platform.system() == "Linux" class Theme: SIDEBAR_BG = "surfaceVariant" CONTENT_BG = "background" CARD_BG = "secondaryContainer" SUBTEXT = "outline" + PAD_LEFT = 50 PAD_RIGHT = 30 KPI_HEIGHT = 140 - SIDEBAR_WIDTH = 300 + SIDEBAR_WIDTH = 320 BUTTON_HEIGHT = 50 - TOGGLE_WIDTH = 260 - - -MS_TO_HOURS = 3.6e6 -MS_TO_MINS = 60000 -UNIT_HOURS = "Hours" -UNIT_MINUTES = "Minutes" - + TOGGLE_WIDTH = 280 + + MS_TO_HOURS = 3.6e6 + MS_TO_MINS = 60000 + MS_TO_DAYS = 8.64e7 + + UNIT_HOURS = "Hours" + UNIT_MINUTES = "Minutes" + UNIT_DAYS = "Days" + +# ========================================== +# LOGGING SETUP +# ========================================== + +class StreamToLogger(object): + def __init__(self, logger, log_level=logging.INFO): + self.logger = logger + self.log_level = log_level + + def write(self, buf): + for line in buf.rstrip().splitlines(): + self.logger.log(self.log_level, line.rstrip()) + + def flush(self): + pass + +def get_log_path() -> str: + system = platform.system() + if system == "Windows": + base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~")) + log_dir = os.path.join(base, APP_NAME, "Logs") + elif system == "Darwin": + log_dir = os.path.expanduser(f"~/Library/Logs/{APP_NAME}") + else: + log_dir = os.path.expanduser(f"~/.cache/{APP_NAME}/logs") + + os.makedirs(log_dir, exist_ok=True) + return os.path.join(log_dir, "tunesback.log") + +def configure_dynamic_logging(enable: bool, mode: str = 'w'): + logger = logging.getLogger() + + for handler in logger.handlers[:]: + logger.removeHandler(handler) + handler.close() + + if enable: + try: + log_file = get_log_path() + file_handler = logging.FileHandler(log_file, mode=mode, encoding='utf-8') + formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s') + file_handler.setFormatter(formatter) + + logger.addHandler(file_handler) + logger.setLevel(logging.INFO) + + sys.stdout = StreamToLogger(logger, logging.INFO) + sys.stderr = StreamToLogger(logger, logging.ERROR) + + action = "Overwriting" if mode == 'w' else "Appending to" + logging.info(f"--- LOG START ({action} log file) ---") + except Exception as e: + print(f"Failed to setup logging: {e}") + else: + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + logger.addHandler(logging.NullHandler()) + logger.setLevel(logging.CRITICAL) + +# ========================================== +# DATA MODELS +# ========================================== + +@dataclass +class EntityStats: + count: int = 0 + time: float = 0.0 + skip: int = 0 + added: Optional[datetime.datetime] = None + location: Optional[str] = None + year: Optional[int] = None + pids: Set[str] = field(default_factory=set) + +@dataclass +class LibraryData: + artists: Dict[str, EntityStats] = field(default_factory=lambda: defaultdict(EntityStats)) + albums: Dict[Tuple[str, str], EntityStats] = field(default_factory=lambda: defaultdict(EntityStats)) + songs: Dict[Tuple[str, str], EntityStats] = field(default_factory=lambda: defaultdict(EntityStats)) + genres: Dict[str, EntityStats] = field(default_factory=lambda: defaultdict(EntityStats)) + years: Dict[int, EntityStats] = field(default_factory=lambda: defaultdict(EntityStats)) + master_pid_map: Dict[str, Dict] = field(default_factory=dict) + total_time: float = 0.0 + error: Optional[str] = None + +# ========================================== +# UTILITIES +# ========================================== + +def resolve_path(file_uri: str) -> Optional[str]: + if not file_uri: + return None + + try: + parsed = urllib.parse.urlparse(file_uri) + host = parsed.netloc + local_path = urllib.request.url2pathname(parsed.path) + + if IS_WINDOWS and host and host.lower() != 'localhost': + if not local_path.startswith("\\"): + local_path = "\\" + local_path + path = f"\\\\{host}{local_path}" + return path + + # Linux network share handling (GVFS/kio-fuse) + if IS_LINUX and host and host.lower() != 'localhost': + clean_path = local_path.lstrip(os.sep) + parts = clean_path.split(os.sep) + + if parts: + share_name_from_uri = parts[0] + rest_of_path = os.sep.join(parts[1:]) if len(parts) > 1 else "" + + # URL-decode share name and path + share_name_decoded = urllib.parse.unquote(share_name_from_uri) + rest_of_path_decoded = urllib.parse.unquote(rest_of_path) + + # Normalize for matching + share_name_normalized = share_name_decoded.lower() + share_name_encoded = urllib.parse.quote(share_name_normalized, safe='') + + uid = os.getuid() + gvfs_base = f"/run/user/{uid}/gvfs" + candidates = [] + + # Scan GVFS mounts (GNOME/Nautilus) + if os.path.isdir(gvfs_base): + try: + for mount_name in os.listdir(gvfs_base): + if mount_name.startswith("smb-share:"): + mount_lower = mount_name.lower() + mount_share_decoded = urllib.parse.unquote(mount_lower) + + # Match share name + share_match = False + for share_variant in [share_name_encoded, share_name_normalized]: + if f",share={share_variant}" in mount_lower or f":share={share_variant}" in mount_lower: + share_match = True + break + if f",share={share_name_normalized}" in mount_share_decoded: + share_match = True + break + + if share_match: + for rpath in [rest_of_path, rest_of_path_decoded]: + candidate = os.path.join(gvfs_base, mount_name, rpath) + if os.path.exists(candidate): + return candidate + if candidate not in candidates: + candidates.append(candidate) + except OSError: + pass + + # Fallback: exact host match + share_variants = [share_name_encoded, share_name_normalized] + for share in share_variants: + for rpath in [rest_of_path, rest_of_path_decoded]: + gvfs_path = os.path.join(gvfs_base, f"smb-share:server={host},share={share}", rpath) + if gvfs_path not in candidates: + candidates.append(gvfs_path) + # Also try with lowercase host + gvfs_path_lower = os.path.join(gvfs_base, f"smb-share:server={host.lower()},share={share}", rpath) + if gvfs_path_lower not in candidates: + candidates.append(gvfs_path_lower) + + # Legacy ~/.gvfs location + legacy_gvfs = os.path.expanduser("~/.gvfs") + if os.path.isdir(legacy_gvfs): + for share in share_variants: + for rpath in [rest_of_path, rest_of_path_decoded]: + candidates.append(os.path.join(legacy_gvfs, f"{share} on {host}", rpath)) + + # KDE kio-fuse support (Dolphin) + kio_fuse_base = f"/run/user/{uid}" + try: + for entry in os.listdir(kio_fuse_base): + if entry.startswith("kio-fuse-"): + kio_smb_base = os.path.join(kio_fuse_base, entry, "smb") + if os.path.isdir(kio_smb_base): + for kio_host in os.listdir(kio_smb_base): + kio_host_path = os.path.join(kio_smb_base, kio_host) + if os.path.isdir(kio_host_path): + for kio_share in os.listdir(kio_host_path): + if kio_share.lower() == share_name_decoded.lower(): + for rpath in [rest_of_path, rest_of_path_decoded]: + candidate = os.path.join(kio_host_path, kio_share, rpath) + if os.path.exists(candidate): + return candidate + if candidate not in candidates: + candidates.append(candidate) + except OSError: + pass + + # Standard mount points + for mount_base in ["/media", f"/media/{os.environ.get('USER', '')}", "/mnt"]: + if os.path.isdir(mount_base): + candidates.append(os.path.join(mount_base, *parts)) + + for c in candidates: + if os.path.exists(c): + return c + + # Return the first GVFS candidate even if it doesn't exist (for logging purposes) + if candidates: + return candidates[0] + + if IS_MACOS: + is_network = (host and host.lower() != 'localhost') or file_uri.startswith("//") + if is_network: + clean_path = local_path.lstrip(os.sep) + parts = clean_path.split(os.sep) + + candidates = [local_path] + if parts: + candidates.append(os.path.join("/Volumes", *parts)) + if len(parts) > 1: + candidates.append(os.path.join("/Volumes", *parts[1:])) + + likely_share = parts[0] + if likely_share and os.path.isdir("/Volumes"): + for vol in os.listdir("/Volumes"): + if vol.startswith(likely_share): + candidates.append(os.path.join("/Volumes", vol, *parts[1:])) + + for c in candidates: + if os.path.exists(c): + return c + + if parts: return os.path.join("/Volumes", *parts) + + return local_path if os.path.exists(local_path) else local_path -def get_files_in_folder(folder_path: str) -> List[Dict]: - """Scans folder for XML files and parses dates from filenames.""" + except Exception as e: + logging.warning(f"Path resolution error for {file_uri}: {e}") + return None + +def extract_art_from_file(file_uri: str) -> Optional[Image.Image]: + path = resolve_path(file_uri) + + if not path: + return None + + if not os.path.exists(path): + logging.warning(f"ArtExtraction: File not found: {path}") + return None + + try: + f = mutagen.File(path) + if f is None: return None + + art_data = None + if hasattr(f, 'tags') and f.tags: + for key in f.tags.keys(): + if key.startswith('APIC:'): + art_data = f.tags[key].data + break + elif key == 'covr': + art_data = f.tags['covr'][0] + break + + if not art_data and hasattr(f, 'pictures') and f.pictures: + art_data = f.pictures[0].data + + if art_data: + return Image.open(BytesIO(art_data)).convert("RGBA") + + except Exception as ex: + logging.warning(f"ArtExtraction: Exception for {path}: {ex}") + + return None + +def get_xml_files_in_folder(folder_path: str) -> List[Dict]: valid_files = [] if not os.path.isdir(folder_path): return valid_files - + + logging.info(f"Scanning directory: {folder_path}") + for f in os.listdir(folder_path): - if not f.endswith('.xml'): - continue - + if not f.endswith('.xml'): continue try: dt = parser.parse(os.path.splitext(f)[0], fuzzy=True) valid_files.append({ @@ -51,363 +352,561 @@ def get_files_in_folder(folder_path: str) -> List[Dict]: 'path': os.path.join(folder_path, f), 'file': f }) - except (ValueError, OverflowError, parser.ParserError): + except: continue - + valid_files.sort(key=lambda x: x['date']) - + seen = defaultdict(int) for item in valid_files: seen[item['label']] += 1 if seen[item['label']] > 1: item['label'] = f"{item['label']} ({seen[item['label']]})" - + + logging.info(f"Found {len(valid_files)} valid XML snapshots.") return valid_files - -def parse_xml_library(xml_path: str) -> Tuple[Optional[Dict], Optional[Dict], float, Optional[str]]: - """Parses iTunes XML and aggregates play data.""" - try: - lib = Library(xml_path) - except FileNotFoundError: - return None, None, 0, f"File not found: {xml_path}" - except ParseError as e: - return None, None, 0, f"Invalid XML format: {str(e)}" - except PermissionError: - return None, None, 0, f"Permission denied: Cannot read {xml_path}" - except Exception as e: - return None, None, 0, f"Failed to parse library: {str(e)}" - - artist_data = defaultdict(lambda: defaultdict(lambda: {'count': 0, 'time': 0.0})) - song_data = defaultdict(lambda: {'count': 0, 'time': 0.0}) - total_time_ms = 0.0 - - for song in lib.songs.values(): - if not song.play_count or not song.length or song.podcast or song.movie or song.has_video: - continue - - artist = song.album_artist or song.artist or "Unknown" - specific_artist = song.artist or "Unknown" - album = song.album or "Unknown" - song_name = song.name or "Unknown" - - play_ms = song.play_count * song.length - total_time_ms += play_ms - - artist_data[artist][album]['count'] += song.play_count - artist_data[artist][album]['time'] += play_ms - - song_data[(song_name, specific_artist)]['count'] += song.play_count - song_data[(song_name, specific_artist)]['time'] += play_ms - - return artist_data, song_data, total_time_ms, None - - -def calculate_stats(old_res: Tuple, new_res: Tuple, unit: str = UNIT_HOURS) -> Tuple: - """Calculates growth stats between two snapshots.""" - divisor = MS_TO_MINS if unit == UNIT_MINUTES else MS_TO_HOURS - old_artists, old_songs, old_total, _ = old_res - new_artists, new_songs, new_total, _ = new_res - - diff_total = (new_total - old_total) / divisor - diff_plays = sum(item['count'] for item in new_songs.values()) - sum(item['count'] for item in old_songs.values()) - - artist_list, album_list, song_list = [], [], [] - - for artist, albums in new_artists.items(): - for album, stats in albums.items(): - old_stats = old_artists.get(artist, {}).get(album, {'time': 0.0, 'count': 0}) - diff_time = stats['time'] - old_stats['time'] - diff_count = stats['count'] - old_stats['count'] - - if diff_time > 0: - found = next((i for i in artist_list if i['Artist'] == artist), None) - if found: - found['Value'] += diff_time / divisor - found['Count'] += diff_count +# ========================================== +# ANALYTICS ENGINE +# ========================================== + +class LibraryAnalytics: + @staticmethod + def split_artists(artist_str: str) -> List[str]: + if not artist_str: return ["Unknown"] + temp = artist_str.replace(',', '|').replace('&', '|') + parts = [p.strip() for p in temp.split('|') if p.strip()] + return parts if parts else ["Unknown"] + + @staticmethod + def parse_date(date_val) -> Optional[datetime.datetime]: + if not date_val: return None + if isinstance(date_val, datetime.datetime): return date_val + try: + return parser.parse(str(date_val)) + except: + return None + + @staticmethod + def _update_stat(stats_obj: EntityStats, plays: int, play_ms: float, skips: int, + pid: str, location: str, date_added: datetime.datetime, year: int): + stats_obj.count += plays + stats_obj.time += play_ms + stats_obj.skip += skips + + if location and (not stats_obj.location or plays > 0): + stats_obj.location = location + if date_added: + stats_obj.added = date_added + if year: + stats_obj.year = year + if pid: + stats_obj.pids.add(pid) + + @classmethod + def parse_xml(cls, xml_path: str) -> LibraryData: + logging.info(f"LibraryAnalytics: Starting XML parse for: {xml_path}") + data = LibraryData() + + if Library is None: + data.error = "libpytunes library missing." + logging.error(data.error) + return data + + try: + lib = Library(xml_path) + logging.info("LibraryAnalytics: XML loaded into memory. Processing songs...") + except Exception as e: + msg = f"Failed to parse XML: {str(e)}" + logging.error(msg) + data.error = msg + return data + + count_items, skipped_items = 0, 0 + + for song in lib.songs.values(): + if song.podcast or song.movie or song.has_video: + skipped_items += 1 + continue + + count_items += 1 + plays = song.play_count or 0 + skips = song.skip_count or 0 + length = song.length or 0 + play_ms = plays * length + + if plays == 0 and skips == 0 and not song.date_added: + continue + + raw_artist = song.artist or "Unknown" + album_artist = song.album_artist or raw_artist + album = song.album or "Unknown" + song_name = song.name or "Unknown" + genre = song.genre + pid = song.persistent_id + date_added = cls.parse_date(song.date_added) + + data.total_time += play_ms + if pid: + data.master_pid_map[pid] = {'count': plays, 'time': play_ms, 'skip': skips} + + cls._update_stat(data.songs[(song_name, raw_artist)], plays, play_ms, skips, pid, song.location, date_added, song.year) + cls._update_stat(data.albums[(album, album_artist)], plays, play_ms, skips, pid, song.location, date_added, song.year) + + for ind_art in cls.split_artists(raw_artist): + cls._update_stat(data.artists[ind_art], plays, play_ms, skips, pid, song.location, date_added, song.year) + + if genre: + cls._update_stat(data.genres[genre], plays, play_ms, skips, pid, song.location, date_added, song.year) + if song.year and song.year > 1900: + cls._update_stat(data.years[song.year], plays, play_ms, skips, pid, song.location, date_added, song.year) + + logging.info(f"Parsed Items: {count_items} | Skipped: {skipped_items}") + logging.info(f"Stats: Songs: {len(data.songs)} | Artists: {len(data.artists)} | Albums: {len(data.albums)}") + return data + + @staticmethod + def calculate_diff(new_stats: EntityStats, old_master_map: Dict[str, Dict], + old_entity_stats: Optional[EntityStats] = None) -> Dict[str, Any]: + old_count, old_time, old_skip = 0, 0.0, 0 + + if old_entity_stats: + old_count = old_entity_stats.count + old_time = old_entity_stats.time + old_skip = old_entity_stats.skip + else: + for pid in new_stats.pids: + if pid in old_master_map: + old_data = old_master_map[pid] + old_count += old_data['count'] + old_time += old_data['time'] + old_skip += old_data['skip'] + + return { + 'diff_time': new_stats.time - old_time, + 'diff_count': new_stats.count - old_count, + 'diff_skip': new_stats.skip - old_skip, + 'old_count_ref': old_count, + 'has_id_match': old_count > 0 + } + + @classmethod + def process_stats(cls, new_lib: LibraryData, old_lib: Optional[LibraryData] = None, + start_date: Optional[datetime.datetime] = None, + unit: str = Theme.UNIT_HOURS, current_year: int = 2025) -> Tuple: + + logging.info("LibraryAnalytics: Starting differential analysis...") + + divisor = Theme.MS_TO_HOURS + if unit == Theme.UNIT_MINUTES: divisor = Theme.MS_TO_MINS + elif unit == Theme.UNIT_DAYS: divisor = Theme.MS_TO_DAYS + + old_master = old_lib.master_pid_map if old_lib else {} + old_total_time = old_lib.total_time if old_lib else 0.0 + + diff_total = (new_lib.total_time - old_total_time) / divisor + new_plays_total = sum(s.count for s in new_lib.songs.values()) + old_plays_total = sum(s.count for s in old_lib.songs.values()) if old_lib else 0 + diff_plays = new_plays_total - old_plays_total + + logging.info(f"Stats: Time Growth: {diff_total:.2f} {unit} | Play Count Diff: {diff_plays}") + + results = defaultdict(list) + + def _process_category(category_dict, old_category_dict, list_key, label_keys): + for key, stats in category_dict.items(): + old_stats_obj = old_category_dict.get(key) if old_category_dict else None + diffs = cls.calculate_diff(stats, old_master, old_stats_obj) + + row = { + 'Value': diffs['diff_time'] / divisor, + 'Count': int(diffs['diff_count']), + 'Location': stats.location + } + + if isinstance(label_keys, list): + for i, k in enumerate(label_keys): + row[k] = key[i] if isinstance(key, tuple) else key else: - artist_list.append({'Artist': artist, 'Value': diff_time / divisor, 'Count': diff_count}) - - album_list.append({'Album': album, 'Artist': artist, 'Value': diff_time / divisor, 'Count': diff_count}) - - for key, stats in new_songs.items(): - name, artist = key - old_stats = old_songs.get(key, {'time': 0.0, 'count': 0}) - diff_time = stats['time'] - old_stats['time'] - diff_count = stats['count'] - old_stats['count'] - - if diff_time > 0: - song_list.append({'Song': name, 'Artist': artist, 'Value': diff_time / divisor, 'Count': diff_count}) - - return diff_total, diff_plays, pd.DataFrame(artist_list), pd.DataFrame(album_list), pd.DataFrame(song_list) - - -def calculate_single_stats(res: Tuple, unit: str = UNIT_HOURS) -> Tuple: - """Calculates stats for a single snapshot.""" - divisor = MS_TO_MINS if unit == UNIT_MINUTES else MS_TO_HOURS - artists, songs, total_time_ms, _ = res - - total_val = total_time_ms / divisor - total_plays = sum(item['count'] for item in songs.values()) - - artist_list, album_list, song_list = [], [], [] - - for artist, albums in artists.items(): - for album, stats in albums.items(): - val = stats['time'] / divisor - count = stats['count'] - - found = next((i for i in artist_list if i['Artist'] == artist), None) - if found: - found['Value'] += val - found['Count'] += count - else: - artist_list.append({'Artist': artist, 'Value': val, 'Count': count}) - - album_list.append({'Album': album, 'Artist': artist, 'Value': val, 'Count': count}) - - for key, stats in songs.items(): - name, artist = key - song_list.append({'Song': name, 'Artist': artist, 'Value': stats['time'] / divisor, 'Count': stats['count']}) - - return total_val, total_plays, pd.DataFrame(artist_list), pd.DataFrame(album_list), pd.DataFrame(song_list) + row[label_keys] = key + + if diffs['diff_time'] > 0: + results[list_key].append(row) + + if list_key == 'song': + if diffs['diff_skip'] > 0: + row_skip = row.copy() + row_skip['Value'] = diffs['diff_skip'] + results['skip'].append(row_skip) + + if diffs['diff_count'] > 0: + is_new = False + if start_date and stats.added: + added_dt = stats.added.replace(tzinfo=None) if stats.added.tzinfo else stats.added + start_dt_naive = start_date.replace(tzinfo=None) if start_date.tzinfo else start_date + if added_dt > start_dt_naive and not diffs['has_id_match']: + is_new = True + elif diffs['old_count_ref'] == 0 and not diffs['has_id_match']: + is_new = True + + if is_new: + results['new'].append(row) + + _process_category(new_lib.artists, old_lib.artists if old_lib else None, 'art', 'Artist') + _process_category(new_lib.albums, old_lib.albums if old_lib else None, 'alb', ['Album', 'Artist']) + _process_category(new_lib.songs, old_lib.songs if old_lib else None, 'song', ['Song', 'Artist']) + _process_category(new_lib.genres, old_lib.genres if old_lib else None, 'gen', 'Genre') + _process_category(new_lib.years, old_lib.years if old_lib else None, 'year', 'Year') + + genre_df = pd.DataFrame(results['gen']) + top_genres = genre_df.sort_values('Count', ascending=False).head(10)['Genre'].tolist() if not genre_df.empty else [] + + plays_per_year = {y: stats.count for y, stats in new_lib.years.items() if stats.count > 0} + calculated_age = listening_age_algorithm.calculate_listening_age(plays_per_year=plays_per_year, current_year=current_year) + + logging.info(f"Analysis Complete. Calculated Listening Age: {calculated_age}") + + return ( + diff_total, diff_plays, + pd.DataFrame(results['art']), pd.DataFrame(results['alb']), + pd.DataFrame(results['song']), pd.DataFrame(results['gen']), + pd.DataFrame(results['new']), pd.DataFrame(results['skip']), + pd.DataFrame(results['year']), + top_genres, calculated_age + ) +# ========================================== +# UI HELPER FUNCTIONS +# ========================================== def create_slider_row(label: str, slider: ft.Slider) -> ft.Row: return ft.Row([ - ft.Text(label, size=12, weight="bold", width=50), + ft.Text(label, size=12, width=50), ft.Container(content=slider, expand=True) ], alignment="center", spacing=5) - def create_kpi_card(content: ft.Control) -> ft.Container: return ft.Container( content=content, bgcolor=Theme.CARD_BG, padding=15, border_radius=12, expand=1, alignment=ft.alignment.center, height=Theme.KPI_HEIGHT ) +def draw_list_item(rank: int, label: str, sub_label: str, value: float, count: int, + unit: str, color: str, is_skip_list: bool=False, art_src_b64: str = None, + is_circular: bool = False) -> ft.Container: + + stats_text = f"{int(value)} skips" if is_skip_list else f"{value:,.1f} {unit} • {int(count)} plays" + + left_row = [ft.Text(f"{rank}.", weight="bold", color="primary", width=30, size=16)] + + if art_src_b64: + radius = 50 if is_circular else 8 + left_row.append(ft.Image(src_base64=art_src_b64, width=45, height=45, border_radius=radius, fit=ft.ImageFit.COVER)) + + left_row.append( + ft.Column([ + ft.Text(str(label), weight="bold", size=15, overflow="ellipsis", no_wrap=True), + ft.Text(sub_label, size=12, color=Theme.SUBTEXT, overflow="ellipsis", no_wrap=True) if sub_label else ft.Container() + ], spacing=2, expand=True) + ) -def draw_list_item(rank: int, label: str, sub_label: str, value: float, count: int, unit: str, color: str) -> ft.Container: - stats = f"{value:,.1f} {unit} • {int(count)} plays" return ft.Container( content=ft.Row([ - ft.Row([ - ft.Text(f"{rank}.", weight="bold", color="primary", width=30, size=16), - ft.Column([ - ft.Text(label, weight="bold", size=15, overflow="ellipsis", no_wrap=True), - ft.Text(sub_label, size=12, color=Theme.SUBTEXT, overflow="ellipsis", no_wrap=True) if sub_label else ft.Container() - ], spacing=2, expand=True), - ], expand=True), + ft.Row(left_row, expand=True, spacing=10), ft.Container( - content=ft.Text(stats, weight="bold", size=13), + content=ft.Text(stats_text, weight="bold", size=13), bgcolor="secondaryContainer", padding=ft.padding.symmetric(horizontal=10, vertical=5), border_radius=10 ) ], alignment="spaceBetween"), - padding=15, bgcolor="surface", border_radius=10, border=ft.border.all(1, "outlineVariant") + padding=10, bgcolor="surface", border_radius=10, border=ft.border.all(1, "outlineVariant") ) +# ========================================== +# MAIN APPLICATION CONTROLLER +# ========================================== class TunesBackApp: def __init__(self, page: ft.Page): self.page = page - - import platform as plat - system = plat.system().lower() - flet_platform = str(page.platform).lower() if page.platform else "" - self.is_macos = system == "darwin" or "darwin" in flet_platform or "macos" in flet_platform - + self.files = [] self.full_labels = [] - self.data_frames = {"art": pd.DataFrame(), "alb": pd.DataFrame(), "song": pd.DataFrame()} + self.data_frames = {k: pd.DataFrame() for k in ["art", "alb", "song", "gen", "new", "skip", "year"]} + self.wrapped_data = {"genres": [], "age": 0} self.cached_sorted = {} - self.raw_res_start = None - self.raw_res_end = None + self.art_cache = {} + self.generated_images = [] + self.selected_indices = set() + + self.lib_start: Optional[LibraryData] = None + self.lib_end: Optional[LibraryData] = None self.is_compare_mode = False + self.analysis_thread: Optional[threading.Thread] = None self.cancel_analysis = False + + self.current_tab = "song" + self.visible_tabs = {"song", "alb", "art", "gen"} + self.all_tabs_config = [ + ("song", "Songs", "music_note"), + ("alb", "Albums", "album"), + ("art", "Artists", "mic"), + ("gen", "Genres", "category"), + ("new", "New Finds", "new_releases"), + ("skip", "Skipped", "fast_forward"), + ("year", "Years", "calendar_month") + ] + + self._build_ui() - self._init_controls() - self._build_layout() + # --- UI INITIALIZATION REGION --- - def _init_controls(self): - self.txt_path = ft.Text("", size=11, color="error", overflow="ellipsis", max_lines=2, visible=False) - self.txt_file_count = ft.Text("", size=11, color="primary", weight="bold", visible=False) - self.btn_select = ft.ElevatedButton("Select Folder", icon="folder", width=float("inf"), - height=Theme.BUTTON_HEIGHT, on_click=self.open_file_picker) + def _build_ui(self): + self._init_sidebar_controls() + self._init_main_view_controls() + self._init_modals() + self._layout_structure() - self.dd_start = ft.Dropdown(label="Start Date / Single Library", text_size=14, label_style=ft.TextStyle(size=14), - dense=True, border="outline", disabled=True, width=Theme.TOGGLE_WIDTH, on_change=self.on_start_changed) - self.dd_end = ft.Dropdown(label="End Date", text_size=14, label_style=ft.TextStyle(size=14), - dense=True, border="outline", disabled=True, expand=True) + def _init_sidebar_controls(self): + self.txt_path = ft.Text("", size=11, color=Theme.SUBTEXT, overflow="ellipsis", max_lines=2, visible=False) + self.txt_file_count = ft.Text("", size=11, color="primary", weight="bold", visible=False) + self.btn_select = ft.ElevatedButton("Select Folder", icon="folder", width=float("inf"), height=Theme.BUTTON_HEIGHT, on_click=self.open_file_picker) + + self.dd_start = ft.Dropdown( + label="Start Date / Single Library", text_size=14, label_style=ft.TextStyle(size=14), + dense=True, border="outline", border_color=Theme.SUBTEXT, border_radius=10, + disabled=True, width=Theme.TOGGLE_WIDTH, on_change=self.on_start_changed + ) + self.dd_end = ft.Dropdown( + label="End Date", text_size=14, label_style=ft.TextStyle(size=14), + dense=True, border="outline", border_color=Theme.SUBTEXT, border_radius=10, + disabled=True, expand=True + ) self.cb_compare = ft.Checkbox(label="Compare", value=True, disabled=True, on_change=self.on_compare_changed) - + self.sl_art = ft.Slider(min=5, max=100, value=15, divisions=19, label="{value}", disabled=True) self.sl_alb = ft.Slider(min=5, max=100, value=15, divisions=19, label="{value}", disabled=True) self.sl_song = ft.Slider(min=5, max=100, value=25, divisions=19, label="{value}", disabled=True) - + self.sl_gen = ft.Slider(min=5, max=50, value=10, divisions=9, label="{value}", disabled=True) + self.sl_year = ft.Slider(min=5, max=50, value=10, divisions=9, label="{value}", disabled=True) + + self.cb_album_art = ft.Checkbox(label="Show Album Art (Slow)", value=False, disabled=True) + self.cb_logging = ft.Checkbox(label="Enable Debug Logging", value=False) + self.btn_open_logs = ft.IconButton(icon="EXIT_TO_APP", icon_size=20, tooltip="Open Log Folder", on_click=self.open_log_folder) + self.seg_unit = ft.SegmentedButton( - selected={UNIT_HOURS}, - segments=[ft.Segment(value=UNIT_HOURS, label=ft.Text(UNIT_HOURS)), ft.Segment(value=UNIT_MINUTES, label=ft.Text(UNIT_MINUTES))], - disabled=True, allow_multiple_selection=False, on_change=self.on_unit_changed, width=Theme.TOGGLE_WIDTH + selected={Theme.UNIT_HOURS}, + segments=[ft.Segment(value=v, label=ft.Text(l)) for v, l in [(Theme.UNIT_HOURS, "Hrs"), (Theme.UNIT_MINUTES, "Mins"), (Theme.UNIT_DAYS, "Days")]], + disabled=True, on_change=self.on_unit_changed, width=Theme.TOGGLE_WIDTH ) self.seg_sort = ft.SegmentedButton( - selected={"time"}, - segments=[ft.Segment(value="time", label=ft.Text("Time")), ft.Segment(value="count", label=ft.Text("Plays"))], - disabled=True, allow_multiple_selection=False, on_change=self.update_results_ui, width=Theme.TOGGLE_WIDTH + selected={"count"}, + segments=[ft.Segment(value="count", label=ft.Text("Plays")), ft.Segment(value="time", label=ft.Text("Time"))], + disabled=True, on_change=self.update_results_ui, width=Theme.TOGGLE_WIDTH ) - self.btn_run = ft.ElevatedButton("Generate Recap", icon="bar_chart", - style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8), padding=20), - expand=True, disabled=True, on_click=self.run_analysis) - self.btn_cancel = ft.ElevatedButton("Cancel", icon="close", color="error", - style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8), padding=20), - expand=True, visible=False, on_click=self.cancel_analysis_handler) + self.btn_run = ft.ElevatedButton("Generate Recap", icon="bar_chart", style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8), padding=20), expand=True, disabled=True, on_click=self.run_analysis) + self.btn_cancel = ft.ElevatedButton("Cancel", icon="close", color="error", style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8), padding=20), expand=True, visible=False, on_click=self.cancel_analysis_handler) self.btn_reset = ft.IconButton(icon="refresh", tooltip="Reset View", disabled=True, on_click=self.reset_view) self.btn_theme = ft.IconButton(icon="light_mode", icon_size=18, tooltip="Toggle Theme", on_click=self.toggle_theme) + self.file_picker = ft.FilePicker(on_result=self.on_folder_picked) + self.dir_picker = ft.FilePicker(on_result=self.on_save_wrapped_dir) + self.page.overlay.extend([self.file_picker, self.dir_picker]) + + def _init_main_view_controls(self): self.btn_minimize = ft.IconButton(icon="remove", icon_size=14, on_click=self.minimize_app) self.btn_close = ft.IconButton(icon="close", icon_size=14, on_click=self.close_app) + self.txt_app_title = ft.Text("TunesBack", weight="bold", size=14, color=Theme.SUBTEXT, opacity=0, animate_opacity=300) - self.file_picker = ft.FilePicker(on_result=self.on_folder_picked) - self.page.overlay.append(self.file_picker) - - self.tabs_main = ft.Tabs( - selected_index=0, animation_duration=300, indicator_color="primary", label_color="primary", - tabs=[ft.Tab(text="Top Artists", icon="mic"), ft.Tab(text="Top Albums", icon="album"), ft.Tab(text="Top Songs", icon="music_note")], - on_change=self.update_results_ui + self.tabs_row = ft.Row(spacing=0, scroll="auto", expand=True) + self.btn_wrapped = ft.ElevatedButton( + content=ft.Row([ft.Icon("auto_awesome"), ft.Text("Generate Wrapped Cards")], alignment="center", spacing=10), + bgcolor="tertiary", color="onTertiary", style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=10)), height=40, visible=False, on_click=self.start_wrapped_generation ) - self.list_results = ft.ListView(expand=True, spacing=10, padding=20) - + self.btn_edit_tabs = ft.IconButton(icon="edit", tooltip="Edit Visible Tabs", icon_size=20, visible=False, on_click=self.open_tab_editor) + self.list_results = ft.ListView(expand=True, spacing=10, padding=ft.padding.symmetric(horizontal=10, vertical=10)) + self.kpi_growth = ft.Text("0.0", size=32, weight="bold", color="primary") - self.kpi_growth_u = ft.Text(f"{UNIT_HOURS} Growth", size=14, weight="bold", color=Theme.SUBTEXT) + self.kpi_growth_u = ft.Text(f"{Theme.UNIT_HOURS} Growth", size=14, weight="bold", color=Theme.SUBTEXT) self.kpi_plays = ft.Text("0", size=32, weight="bold", color="primary") self.kpi_plays_u = ft.Text("New Plays", size=14, weight="bold", color=Theme.SUBTEXT) - self.card_vals = [ft.Text("-", size=16, weight="bold", overflow="ellipsis", text_align="center", no_wrap=True) for _ in range(3)] self.card_subs = [ft.Text("-", size=11, color=Theme.SUBTEXT, text_align="center") for _ in range(3)] - - self.txt_app_title = ft.Text("TunesBack", weight="bold", size=14, color=Theme.SUBTEXT, opacity=0, animate_opacity=300) + self.txt_loading_status = ft.Text("Crunching numbers...", size=16, weight="bold", color=Theme.SUBTEXT) + def _init_modals(self): + self.tab_editor_col = ft.Column() + self.tab_editor_modal = ft.Container( + content=ft.Container( + content=ft.Column([ + ft.Text("Select Tabs to Display", size=18, weight="bold"), + self.tab_editor_col, + ft.Row([ft.TextButton("Close", on_click=lambda e: self.toggle_tab_editor(False)), ft.FilledButton("Save", on_click=self.save_tab_preferences)], alignment="end") + ], spacing=20, tight=True), + padding=25, bgcolor="surface", border_radius=12, width=350, shadow=ft.BoxShadow(blur_radius=20, color="black") + ), bgcolor=ft.Colors.with_opacity(0.6, "black"), alignment=ft.alignment.center, visible=False, expand=True + ) + self.modal_title = ft.Text("Error", weight="bold", size=20) - self.modal_text = ft.Text("Something went wrong.") + self.modal_text = ft.Text("Msg") self.modal_container = ft.Container( content=ft.Container( content=ft.Column([ self.modal_title, self.modal_text, ft.Row([ft.TextButton("OK", on_click=lambda e: self.toggle_modal(False))], alignment="end") ], spacing=10, tight=True), - padding=25, bgcolor="surface", border_radius=12, width=350, - shadow=ft.BoxShadow(blur_radius=20, color=ft.Colors.with_opacity(0.5, "black")) - ), - bgcolor=ft.Colors.with_opacity(0.6, "black"), alignment=ft.alignment.center, visible=False, expand=True + padding=25, bgcolor="surface", border_radius=12, width=350, shadow=ft.BoxShadow(blur_radius=20, color=ft.Colors.with_opacity(0.5, "black")) + ), bgcolor=ft.Colors.with_opacity(0.6, "black"), alignment=ft.alignment.center, visible=False, expand=True + ) + + self.wrapped_grid = ft.Row(scroll="auto", expand=True, spacing=30) + self.wrapped_modal = ft.Container( + content=ft.Container( + content=ft.Column([ + ft.Container(content=self.wrapped_grid, expand=True), + ft.Row([ + ft.TextButton("Close", on_click=lambda e: self.toggle_wrapped_modal(False)), + ft.Row([ + ft.OutlinedButton("Save Selected", icon="download", on_click=lambda e: self.save_images(selected_only=True)), + ft.OutlinedButton("Save All", icon="download", on_click=lambda e: self.save_images(selected_only=False)) + ]) + ], alignment="spaceBetween") + ], spacing=10), + padding=20, bgcolor="surface", border_radius=15, width=1200, height=580, shadow=ft.BoxShadow(blur_radius=30, color="black") + ), bgcolor=ft.Colors.with_opacity(0.85, "black"), alignment=ft.alignment.center, visible=False, expand=True ) - def _build_layout(self): + def _layout_structure(self): + self._render_tabs() + self.view_welcome = ft.Container( content=ft.Column([ - ft.Text("👋", size=60), - ft.Text("Welcome to TunesBack", size=24, weight="bold"), + ft.Text("👋", size=60), ft.Text("Welcome to TunesBack", size=24, weight="bold"), ft.Text("Select a folder containing your library snapshots to begin.", size=14, color=Theme.SUBTEXT), ], horizontal_alignment="center", alignment="center", spacing=10), alignment=ft.alignment.center, expand=True, visible=True, padding=ft.padding.only(bottom=60) ) self.view_loading = ft.Container( - content=ft.Column([ft.ProgressRing(width=50, height=50, stroke_width=4), self.txt_loading_status], - horizontal_alignment="center", alignment="center", spacing=20), + content=ft.Column([ft.ProgressRing(width=50, height=50, stroke_width=4), self.txt_loading_status], horizontal_alignment="center", alignment="center", spacing=20), alignment=ft.alignment.center, expand=True, visible=False, padding=ft.padding.only(bottom=60) ) - kpi_cards = ft.Row([ - create_kpi_card(ft.Column([ft.Text("Top Artist", size=10, color=Theme.SUBTEXT), self.card_vals[0], self.card_subs[0]], - horizontal_alignment="center", alignment="center", spacing=2)), - create_kpi_card(ft.Column([ft.Text("Top Album", size=10, color=Theme.SUBTEXT), self.card_vals[1], self.card_subs[1]], - horizontal_alignment="center", alignment="center", spacing=2)), - create_kpi_card(ft.Column([ft.Text("Top Song", size=10, color=Theme.SUBTEXT), self.card_vals[2], self.card_subs[2]], - horizontal_alignment="center", alignment="center", spacing=2)), + kpi_row = ft.Row([ + create_kpi_card(ft.Column([ft.Text("Top Artist", size=10, color=Theme.SUBTEXT), self.card_vals[0], self.card_subs[0]], horizontal_alignment="center", alignment=ft.MainAxisAlignment.CENTER, spacing=2)), + create_kpi_card(ft.Column([ft.Text("Top Album", size=10, color=Theme.SUBTEXT), self.card_vals[1], self.card_subs[1]], horizontal_alignment="center", alignment=ft.MainAxisAlignment.CENTER, spacing=2)), + create_kpi_card(ft.Column([ft.Text("Top Song", size=10, color=Theme.SUBTEXT), self.card_vals[2], self.card_subs[2]], horizontal_alignment="center", alignment=ft.MainAxisAlignment.CENTER, spacing=2)), ], spacing=15, expand=True) header_row = ft.Row([ - ft.Container( - content=ft.Column([ - ft.Column([self.kpi_growth, self.kpi_growth_u], spacing=0), - ft.Column([self.kpi_plays, self.kpi_plays_u], spacing=0) - ], alignment="spaceBetween", spacing=0), - height=Theme.KPI_HEIGHT, alignment=ft.alignment.center_left - ), - ft.Container(width=40), - kpi_cards + ft.Container(content=ft.Column([ft.Column([self.kpi_growth, self.kpi_growth_u], spacing=0), ft.Column([self.kpi_plays, self.kpi_plays_u], spacing=0)], alignment="spaceBetween", spacing=0), height=Theme.KPI_HEIGHT, alignment=ft.alignment.center_left), + ft.Container(width=40), kpi_row ], alignment="start", vertical_alignment="center") - results_box = ft.Container( - content=self.list_results, - bgcolor=ft.Colors.with_opacity(0.05, "black"), - border_radius=10, border=ft.border.all(1, "outlineVariant"), expand=True - ) - self.view_dash = ft.Column([ - ft.Container( - padding=ft.padding.only(left=Theme.PAD_LEFT, right=Theme.PAD_RIGHT), - content=ft.Column([ft.Container(height=10), header_row, ft.Container(height=20), self.tabs_main, ft.Container(height=10)]) - ), - ft.Container(padding=ft.padding.only(left=Theme.PAD_LEFT, right=20, bottom=20), content=results_box, expand=True) + ft.Container(padding=ft.padding.only(left=Theme.PAD_LEFT, right=Theme.PAD_RIGHT), + content=ft.Column([ft.Container(height=10), header_row, ft.Container(height=20), + ft.Row([ft.Row([self.tabs_row, self.btn_edit_tabs], spacing=5, expand=True), self.btn_wrapped], alignment="spaceBetween", vertical_alignment="center"), + ft.Container(height=10)])), + ft.Container(padding=ft.padding.only(left=Theme.PAD_LEFT, right=Theme.PAD_RIGHT, bottom=10), + content=ft.Container(content=self.list_results, bgcolor=ft.Colors.with_opacity(0.05, "black"), border_radius=10, border=ft.border.all(1, "outlineVariant"), expand=True), expand=True) ], expand=True, visible=False, spacing=0) - sidebar_content = [] - if self.is_macos: - sidebar_content.append(ft.Container(height=10)) - - sidebar_content.extend([ - ft.Row([ft.Text("SETTINGS", size=12, weight="bold", color=Theme.SUBTEXT), self.btn_theme], alignment="spaceBetween"), - ft.Divider(), - ft.Text("Source", size=12, weight="bold"), - self.btn_select, self.txt_path, self.txt_file_count, - ft.Divider(), - ft.Text("Period", size=12, weight="bold"), - ft.Column([self.dd_start, ft.Row([self.dd_end, self.cb_compare], alignment="spaceBetween", vertical_alignment="center")], spacing=20), - ft.Divider(), - ft.Text("Number of Items To Show", size=12, weight="bold"), - ft.Column([create_slider_row("Artists", self.sl_art), create_slider_row("Albums", self.sl_alb), create_slider_row("Songs", self.sl_song)], spacing=2), - ft.Divider(), - ft.Text("Units & Ranking", size=12, weight="bold"), - ft.Column([ - ft.Text("Time Unit", size=11, color=Theme.SUBTEXT), self.seg_unit, - ft.Text("Rank By", size=11, color=Theme.SUBTEXT), self.seg_sort - ], spacing=5), - ft.Container(height=20), - ft.Stack([ft.Row([self.btn_run, self.btn_reset], spacing=10), self.btn_cancel]) - ]) - - sidebar = ft.Container( - width=Theme.SIDEBAR_WIDTH, - bgcolor=Theme.SIDEBAR_BG, - padding=0, - content=ft.Column([ft.Container(padding=20, content=ft.Column(sidebar_content, spacing=10))], scroll="auto") + self.exp_sliders = ft.Container( + border=ft.border.all(1, Theme.SUBTEXT), border_radius=10, opacity=0.5, + content=ft.ExpansionTile( + title=ft.Container(content=ft.Text("Item Limits", size=14), padding=ft.padding.only(left=5)), + controls=[ft.Container(content=ft.Column([ + create_slider_row("Artists", self.sl_art), create_slider_row("Albums", self.sl_alb), + create_slider_row("Songs", self.sl_song), create_slider_row("Genres", self.sl_gen), + create_slider_row("Years", self.sl_year) + ], spacing=-5), padding=ft.padding.only(bottom=10, left=10, right=10))], + initially_expanded=False, tile_padding=ft.padding.symmetric(horizontal=5), shape=ft.RoundedRectangleBorder(radius=10), collapsed_shape=ft.RoundedRectangleBorder(radius=10), bgcolor=ft.Colors.TRANSPARENT + ) ) - custom_controls = ft.Row([self.btn_minimize, self.btn_close], spacing=0) if not self.is_macos else ft.Container() - header_top_padding = 30 if self.is_macos else 20 + sidebar_content = ft.Column([ + ft.Row([ft.Text("SETTINGS", size=12, weight="bold", color=Theme.SUBTEXT), self.btn_theme], alignment="spaceBetween"), ft.Divider(), + ft.Text("Source", size=12, weight="bold"), self.btn_select, self.txt_path, self.txt_file_count, ft.Divider(), + ft.Text("Period", size=12, weight="bold"), ft.Column([self.dd_start, ft.Row([self.dd_end, self.cb_compare], alignment="spaceBetween", vertical_alignment="center")], spacing=20), ft.Divider(), + self.exp_sliders, self.cb_album_art, ft.Row([self.cb_logging, self.btn_open_logs], spacing=0, alignment=ft.MainAxisAlignment.START), ft.Divider(), + ft.Text("Units & Ranking", size=12, weight="bold"), ft.Column([ft.Text("Time Unit", size=11, color=Theme.SUBTEXT), self.seg_unit, ft.Text("Rank By", size=11, color=Theme.SUBTEXT), self.seg_sort], spacing=5), + ft.Container(height=20), ft.Stack([ft.Row([self.btn_run, self.btn_reset], spacing=10), self.btn_cancel]), + ], spacing=10) + + sidebar_padding = ft.padding.only(left=20, right=20, top=30 if IS_MACOS else 20, bottom=20) + sidebar = ft.Container(width=Theme.SIDEBAR_WIDTH, bgcolor=Theme.SIDEBAR_BG, content=ft.Column([ft.Container(padding=sidebar_padding, content=sidebar_content)], scroll="auto")) window_header = ft.WindowDragArea(content=ft.Container( - content=ft.Row([self.txt_app_title, custom_controls], alignment="spaceBetween"), - padding=ft.padding.only(left=Theme.PAD_LEFT, right=20, top=header_top_padding, bottom=10), - bgcolor=Theme.CONTENT_BG + content=ft.Row([self.txt_app_title, ft.Row([self.btn_minimize, self.btn_close], spacing=0) if not IS_MACOS else ft.Container()], alignment="spaceBetween"), + padding=ft.padding.only(left=Theme.PAD_LEFT, right=20, top=30 if IS_MACOS else 20, bottom=10), bgcolor=Theme.CONTENT_BG )) - content_area = ft.Container( - expand=True, bgcolor=Theme.CONTENT_BG, padding=0, - content=ft.Column([ft.Stack([self.view_welcome, self.view_dash, self.view_loading], expand=True)], spacing=0) - ) + self.main_layout = ft.Container(content=ft.Stack([ + ft.Row([sidebar, ft.Container(content=ft.Column([window_header, ft.Container(expand=True, bgcolor=Theme.CONTENT_BG, padding=0, content=ft.Column([ft.Stack([self.view_welcome, self.view_dash, self.view_loading], expand=True)], spacing=0))], spacing=0), expand=True)], expand=True, spacing=0, vertical_alignment="stretch"), + self.modal_container, self.wrapped_modal, self.tab_editor_modal + ]), expand=True) + + # --- TAB LOGIC REGION --- + + def _create_custom_tab_button(self, key, label, icon): + is_selected = (self.current_tab == key) + style = ft.ButtonStyle(shape=ft.StadiumBorder()) + btn_cls = ft.FilledButton if is_selected else ft.OutlinedButton + return ft.Container(content=btn_cls(text=label, icon=icon, style=style, on_click=lambda e: self.on_custom_tab_clicked(key)), padding=ft.padding.only(right=10)) + + def _render_tabs(self): + visible_tab_configs = [t for t in self.all_tabs_config if t[0] in self.visible_tabs] + self.tabs_row.controls = [self._create_custom_tab_button(k, l, i) for k, l, i in visible_tab_configs] + if self.current_tab not in self.visible_tabs and visible_tab_configs: + self.current_tab = visible_tab_configs[0][0] + + def on_custom_tab_clicked(self, key): + self.current_tab = key + self._render_tabs() + self.tabs_row.update() + self.update_results_ui(None) - self.main_layout = ft.Container( - content=ft.Stack([ - ft.Row([sidebar, ft.Container(content=ft.Column([window_header, content_area], spacing=0), expand=True)], - expand=True, spacing=0, vertical_alignment="stretch"), - self.modal_container - ]), - expand=True - ) + # --- MODAL LOGIC REGION --- + + def open_tab_editor(self, e): + def create_cb(key, label): + disabled = (key == "new" and not self.cb_compare.value) + return ft.Checkbox(label=label, value=(key in self.visible_tabs), data=key, disabled=disabled) + + self.tab_editor_col.controls = [create_cb(k, l) for k, l, _ in self.all_tabs_config] + self.toggle_tab_editor(True) + + def toggle_tab_editor(self, show): + self.tab_editor_modal.visible = show + self.page.update() + + def save_tab_preferences(self, e): + new_set = {cb.data for cb in self.tab_editor_col.controls if cb.value} + if not new_set: + self.page.snack_bar = ft.SnackBar(ft.Text("At least one tab must be visible.")) + self.page.snack_bar.open = True + self.page.update() + return + self.visible_tabs = new_set + self._render_tabs() + self.tabs_row.update() + self.update_results_ui(None) + self.toggle_tab_editor(False) + + def toggle_modal(self, show: bool, title: str = "", msg: str = ""): + self.modal_container.visible = show + self.modal_title.value = title + self.modal_text.value = msg + self.page.update() + + def toggle_wrapped_modal(self, show: bool): + self.wrapped_modal.visible = show + self.page.update() + + # --- ACTION HANDLERS REGION --- def minimize_app(self, e): self.page.window.minimized = True @@ -416,27 +915,34 @@ def minimize_app(self, e): def close_app(self, e): self.page.window.close() - def toggle_modal(self, show: bool, title: str = "", msg: str = ""): - self.modal_container.visible = show - if show: - self.modal_title.value = title - self.modal_text.value = msg - self.page.update() + def open_log_folder(self, e): + log_dir = os.path.dirname(get_log_path()) + try: + logging.info(f"User requested to open log folder: {log_dir}") + if IS_WINDOWS: os.startfile(log_dir) + elif IS_MACOS: subprocess.run(["open", log_dir]) + else: subprocess.run(["xdg-open", log_dir]) + except Exception as ex: + logging.error(f"Failed to open log folder: {ex}") + self.page.snack_bar = ft.SnackBar(ft.Text(f"Could not open log folder: {ex}")) + self.page.snack_bar.open = True + self.page.update() def open_file_picker(self, e): self.file_picker.get_directory_path() def on_folder_picked(self, e: ft.FilePickerResultEvent): - if not e.path: + if not e.path: + logging.info("Folder picker cancelled.") return - + + logging.info(f"User selected folder: {e.path}") self.txt_path.value = e.path self.txt_path.color = Theme.SUBTEXT self.txt_path.visible = True self.txt_file_count.visible = True - - self.files = get_files_in_folder(e.path) - + self.files = get_xml_files_in_folder(e.path) + if self.files: self.full_labels = [f['label'] for f in self.files] opts = [ft.dropdown.Option(lbl) for lbl in self.full_labels] @@ -444,100 +950,91 @@ def on_folder_picked(self, e: ft.FilePickerResultEvent): self.dd_end.options = [ft.dropdown.Option(lbl) for lbl in self.full_labels] self.dd_start.value = opts[0].key self.dd_end.value = opts[-1].key if len(opts) > 1 else None - - if len(self.files) == 1: - self.txt_file_count.value = "Found 1 snapshot. Comparison disabled." - self.cb_compare.value = False - self.cb_compare.disabled = True - self.dd_end.value = None - self.dd_end.disabled = True - else: - self.txt_file_count.value = f"Found {len(self.files)} snapshots" - self.cb_compare.value = True - self.cb_compare.disabled = False - self.dd_end.disabled = False - - for c in [self.dd_start, self.btn_run, self.sl_art, self.sl_alb, self.sl_song, self.seg_unit, self.seg_sort]: + + is_single = len(self.files) == 1 + self.cb_compare.value = not is_single + self.cb_compare.disabled = is_single + self.dd_end.disabled = is_single + self.txt_file_count.value = "Found 1 snapshot, comparison disabled" if is_single else f"Found {len(self.files)} snapshots" + + # Enable controls visually + self.exp_sliders.opacity = 1.0 + + for c in [self.dd_start, self.btn_run, self.sl_art, self.sl_alb, self.sl_song, self.sl_gen, self.sl_year, self.seg_unit, self.seg_sort, self.cb_album_art]: c.disabled = False - self.on_start_changed(None) else: self.txt_path.value = "No dated XML files found." self.txt_path.color = "error" - self.txt_file_count.value = "" - + self.txt_file_count.visible = False + logging.warning("No XML files found in selected folder.") self.page.update() def on_compare_changed(self, e): + logging.info(f"Compare mode changed to: {self.cb_compare.value}") self.dd_end.disabled = not self.cb_compare.value - if not self.cb_compare.value: - self.dd_end.value = None + + if self.cb_compare.value: + self.dd_end.value = self.dd_end.options[-1].key if self.dd_end.options else None else: if self.dd_end.options: - available_options = [opt.key for opt in self.dd_end.options] - if available_options: - self.dd_end.value = available_options[-1] + self.dd_start.value = self.dd_end.options[-1].key + self.dd_start.update() + self.dd_end.value = None + self.page.update() def on_start_changed(self, e): - if not self.full_labels: - return + if not self.full_labels: return new_end_opts = [ft.dropdown.Option(lbl) for lbl in self.full_labels if lbl != self.dd_start.value] self.dd_end.options = new_end_opts - - if self.dd_end.value == self.dd_start.value or self.dd_end.value not in [opt.key for opt in new_end_opts]: - if self.cb_compare.value and new_end_opts: - self.dd_end.value = new_end_opts[-1].key - else: - self.dd_end.value = None - + if self.cb_compare.value and new_end_opts and (not self.dd_end.value or self.dd_end.value == self.dd_start.value): + self.dd_end.value = new_end_opts[-1].key self.dd_end.update() def on_unit_changed(self, e): - if self.raw_res_start is None: - return - - unit = list(self.seg_unit.selected)[0] + if self.lib_start: self._calculate_and_refresh() - if self.is_compare_mode and self.raw_res_end is not None: - val_main, val_plays, df_art, df_alb, df_song = calculate_stats(self.raw_res_start, self.raw_res_end, unit) - else: - val_main, val_plays, df_art, df_alb, df_song = calculate_single_stats(self.raw_res_start, unit) + def toggle_theme(self, e): + self.page.theme_mode = 'light' if self.page.theme_mode == 'dark' else 'dark' + self.btn_theme.icon = 'dark_mode' if self.page.theme_mode == 'light' else 'light_mode' + self.page.update() - self.data_frames = {"art": df_art, "alb": df_alb, "song": df_song} + def reset_view(self, e): + logging.info("Resetting view state.") + self.view_welcome.visible = True + self.view_dash.visible = False + self.btn_reset.disabled = True + self.txt_app_title.opacity = 0 + self.data_frames = {k: pd.DataFrame() for k in ["art", "alb", "song", "gen", "new", "skip", "year"]} self.cached_sorted.clear() - - self.kpi_growth.value = f"{val_main:,.1f}" - self.kpi_plays.value = f"{int(val_plays):,}" - - self.update_kpi_units() - self._update_top_cards(unit) - self.update_results_ui(None) + self.art_cache.clear() + self.lib_start = None + self.lib_end = None self.page.update() - def update_kpi_units(self): - unit = list(self.seg_unit.selected)[0] - - if self.is_compare_mode: - self.kpi_growth_u.value = f"{unit} Growth" - self.kpi_plays_u.value = "New Plays" - else: - self.kpi_growth_u.value = f"Total {unit}" - self.kpi_plays_u.value = "Total Plays" + # --- ANALYSIS LOGIC REGION --- def cancel_analysis_handler(self, e): self.cancel_analysis = True + logging.warning("Analysis cancellation requested by user.") self.txt_loading_status.value = "Cancelling..." self.page.update() def run_analysis(self, e): - if not self.dd_start.value or (self.cb_compare.value and not self.dd_end.value): - return - + if not self.dd_start.value: return + + # Set default visible tabs based on analysis mode + self.visible_tabs = {"song", "alb", "art", "new"} if self.cb_compare.value else {"song", "alb", "art", "gen"} + self._render_tabs() + self.tabs_row.update() + + configure_dynamic_logging(self.cb_logging.value, mode='w') + try: start_file = next(f for f in self.files if f['label'] == self.dd_start.value) - if self.cb_compare.value: + if not self.dd_end.value: return end_file = next(f for f in self.files if f['label'] == self.dd_end.value) if start_file['date'] > end_file['date']: self.toggle_modal(True, "Invalid Range", "Start Date cannot be after End Date.") @@ -546,6 +1043,7 @@ def run_analysis(self, e): self.toggle_modal(True, "Error", "Selected file not found.") return + logging.info("Starting Analysis Thread.") self.cancel_analysis = False self.analysis_thread = threading.Thread(target=self._run_analysis_thread, daemon=True) self.analysis_thread.start() @@ -553,99 +1051,140 @@ def run_analysis(self, e): def _run_analysis_thread(self): try: start_file = next(f for f in self.files if f['label'] == self.dd_start.value) - + self._set_loading_state(True) self.txt_loading_status.value = "Parsing first library..." self.page.update() + + logging.info(f"Parsing main library: {start_file['path']}") + self.lib_start = LibraryAnalytics.parse_xml(start_file['path']) + if self.lib_start.error: raise Exception(self.lib_start.error) + if self.cancel_analysis: return self._set_loading_state(False) - res_start = parse_xml_library(start_file['path']) - - if self.cancel_analysis: - self._set_loading_state(False) - return - - if res_start[3]: - self.toggle_modal(True, "Parse Error", res_start[3]) - self._set_loading_state(False) - return - - self.raw_res_start = res_start self.is_compare_mode = self.cb_compare.value + self.lib_end = None - unit = list(self.seg_unit.selected)[0] - - if self.cb_compare.value: + if self.is_compare_mode: end_file = next(f for f in self.files if f['label'] == self.dd_end.value) self.txt_loading_status.value = "Parsing second library..." self.page.update() - - res_end = parse_xml_library(end_file['path']) - - if self.cancel_analysis: - self._set_loading_state(False) - return - - if res_end[3]: - self.toggle_modal(True, "Parse Error", res_end[3]) - self._set_loading_state(False) - return - - self.raw_res_end = res_end - + + logging.info(f"Parsing comparison library: {end_file['path']}") + self.lib_end = LibraryAnalytics.parse_xml(end_file['path']) + if self.lib_end.error: raise Exception(self.lib_end.error) + if self.cancel_analysis: return self._set_loading_state(False) + self.txt_loading_status.value = "Calculating differences..." - self.page.update() - - val_main, val_plays, df_art, df_alb, df_song = calculate_stats(res_start, res_end, unit) else: - self.raw_res_end = None self.txt_loading_status.value = "Calculating stats..." - self.page.update() - - val_main, val_plays, df_art, df_alb, df_song = calculate_single_stats(res_start, unit) - - if self.cancel_analysis: - self._set_loading_state(False) - return - - self.data_frames = {"art": df_art, "alb": df_alb, "song": df_song} - self.cached_sorted.clear() - - self.kpi_growth.value = f"{val_main:,.1f}" - self.kpi_plays.value = f"{int(val_plays):,}" + self.page.update() - self.update_kpi_units() - self._update_top_cards(unit) - self.update_results_ui(None) + self._calculate_and_refresh() + + if self.cb_album_art.value: + self._preload_artwork() + self.update_results_ui(None) + self._set_loading_state(False) except Exception as ex: - error_msg = f"Unexpected error: {str(ex)}" - print(traceback.format_exc()) - self.toggle_modal(True, "Error", error_msg) + logging.error(f"Critical Analysis Error: {ex}") + logging.error(traceback.format_exc()) + + self.toggle_modal(True, "Error", str(ex)) self._set_loading_state(False) + def _preload_artwork(self): + self.txt_loading_status.value = "Pre-loading album artwork..." + self.page.update() + logging.info("Starting album art pre-caching...") + + unique_locations = set() + + for key in ["song", "alb", "new", "skip", "art"]: + if self.data_frames[key].empty: continue + + limit = 10 + if key == "alb": limit = int(self.sl_alb.value) + elif key == "art": limit = int(self.sl_art.value) + else: limit = int(self.sl_song.value) + + df_top = self.data_frames[key].sort_values("Count", ascending=False).head(limit) + if "Location" in df_top.columns: + unique_locations.update(df_top["Location"].dropna().unique()) + + cache_count = 0 + for loc in unique_locations: + if self.cancel_analysis: break + if loc not in self.art_cache: + pil_img = extract_art_from_file(loc) + if pil_img: + pil_img.thumbnail((100, 100)) + buffered = BytesIO() + pil_img.save(buffered, format="PNG") + self.art_cache[loc] = base64.b64encode(buffered.getvalue()).decode() + cache_count += 1 + logging.info(f"Pre-caching complete. Cached {cache_count} new images.") + + def _calculate_and_refresh(self): + start_file = next((f for f in self.files if f['label'] == self.dd_start.value), None) + start_date = start_file['date'] if start_file else None + end_year = self.files[-1]['date'].year if self.files else 2025 + unit = list(self.seg_unit.selected)[0] + + stats = LibraryAnalytics.process_stats( + new_lib=self.lib_start if not self.is_compare_mode else self.lib_end, + old_lib=self.lib_start if self.is_compare_mode else None, + start_date=start_date, + unit=unit, + current_year=end_year + ) + + (val_main, val_plays, df_art, df_alb, df_song, df_gen, df_new, df_skip, df_year, top_genres, age) = stats + + # Map artist images to their best album cover + if not df_alb.empty and not df_art.empty: + best_covers = df_alb.sort_values('Count', ascending=False).drop_duplicates('Artist') + cover_map = dict(zip(best_covers['Artist'], best_covers['Location'])) + df_art['Location'] = df_art['Artist'].map(cover_map).fillna(df_art['Location']) + + self.wrapped_data = {"genres": top_genres, "age": age} + self.data_frames = {"art": df_art, "alb": df_alb, "song": df_song, "gen": df_gen, "new": df_new, "skip": df_skip, "year": df_year} + self.cached_sorted.clear() + + prefix = f"{unit} Growth" if self.is_compare_mode else f"Total {unit}" + self.kpi_growth.value = f"{val_main:,.1f}" + self.kpi_growth_u.value = prefix + self.kpi_plays.value = f"{int(val_plays):,}" + self.kpi_plays_u.value = "New Plays" if self.is_compare_mode else "Total Plays" + + self._update_top_cards(unit) + self.update_results_ui(None) + self.page.update() + def _set_loading_state(self, is_loading: bool): self.view_loading.visible = is_loading self.view_welcome.visible = False self.view_dash.visible = not is_loading and bool(self.data_frames["art"].size) - + if not is_loading and not self.data_frames["art"].size: self.view_welcome.visible = True - + self.btn_run.visible = not is_loading self.btn_cancel.visible = is_loading self.btn_reset.disabled = is_loading self.txt_app_title.opacity = 0 if is_loading else 1 - + self.btn_wrapped.visible = not is_loading + self.btn_edit_tabs.visible = not is_loading + self.btn_wrapped.content = ft.Row([ft.Icon("auto_awesome"), ft.Text("Generate Wrapped Cards")], alignment="center", spacing=10) + if not is_loading: self.txt_loading_status.value = "Crunching numbers..." - self.page.update() def _update_top_cards(self, unit: str): dfs = [self.data_frames["art"], self.data_frames["alb"], self.data_frames["song"]] keys = ["Artist", "Album", "Song"] - for i, df in enumerate(dfs): if df.empty: self.card_vals[i].value = "-" @@ -656,71 +1195,177 @@ def _update_top_cards(self, unit: str): self.card_subs[i].value = f"{top['Value']:.1f} {unit} • {int(top['Count'])} plays" def update_results_ui(self, e): - idx = self.tabs_main.selected_index unit = list(self.seg_unit.selected)[0] sort_mode = list(self.seg_sort.selected)[0] - - configs = [ - (self.data_frames["art"], int(self.sl_art.value), "cyan", "Artist", None), - (self.data_frames["alb"], int(self.sl_alb.value), "purple", "Album", "Artist"), - (self.data_frames["song"], int(self.sl_song.value), "pink", "Song", "Artist") - ] - - df, limit, color, main_col, sub_col = configs[idx] + + configs = { + "art": (self.data_frames["art"], int(self.sl_art.value), "cyan", "Artist", None, False, True), + "alb": (self.data_frames["alb"], int(self.sl_alb.value), "purple", "Album", "Artist", False, False), + "song": (self.data_frames["song"], int(self.sl_song.value), "pink", "Song", "Artist", False, False), + "gen": (self.data_frames["gen"], int(self.sl_gen.value), "teal", "Genre", None, False, False), + "new": (self.data_frames["new"], int(self.sl_song.value), "orange", "Song", "Artist", False, False), + "skip": (self.data_frames["skip"], int(self.sl_song.value), "red", "Song", "Artist", True, False), + "year": (self.data_frames["year"], int(self.sl_year.value), "blue_grey", "Year", None, False, False), + } + + if self.current_tab not in configs: + if self.visible_tabs: + self.current_tab = [t[0] for t in self.all_tabs_config if t[0] in self.visible_tabs][0] + else: return + + df, limit, color, main_col, sub_col, is_skip, is_circular = configs.get(self.current_tab, configs["art"]) self.list_results.controls.clear() - + if df.empty: self.list_results.controls.append(ft.Text("No data to display.", italic=True)) else: - cache_key = (idx, sort_mode) + cache_key = (self.current_tab, sort_mode) if cache_key not in self.cached_sorted: - sort_col = 'Value' if sort_mode == 'time' else 'Count' + sort_col = 'Value' if sort_mode == 'time' or self.current_tab == 'skip' else 'Count' + if self.current_tab == 'year': sort_col = 'Year' self.cached_sorted[cache_key] = df.sort_values(sort_col, ascending=False) - + df_sorted = self.cached_sorted[cache_key].head(limit) - + show_art = self.cb_album_art.value and self.current_tab in ["song", "alb", "new", "art"] + for i, row in enumerate(df_sorted.itertuples(), 1): lbl = getattr(row, main_col) sub = getattr(row, sub_col) if sub_col else "" - self.list_results.controls.append(draw_list_item(i, lbl, sub, getattr(row, "Value"), getattr(row, "Count"), unit, color)) + art_b64 = self.art_cache.get(getattr(row, "Location", None)) if show_art else None + self.list_results.controls.append( + draw_list_item(i, lbl, sub, getattr(row, "Value"), getattr(row, "Count"), unit, color, is_skip, art_src_b64=art_b64, is_circular=is_circular) + ) self.list_results.update() - def reset_view(self, e): - self.view_welcome.visible = True - self.view_dash.visible = False - self.btn_reset.disabled = True - self.txt_app_title.opacity = 0 - self.data_frames = {"art": pd.DataFrame(), "alb": pd.DataFrame(), "song": pd.DataFrame()} - self.cached_sorted.clear() - self.raw_res_start = None - self.raw_res_end = None - self.is_compare_mode = False - self.page.update() + # --- WRAPPED GENERATION REGION --- - def toggle_theme(self, e): - self.page.theme_mode = 'light' if self.page.theme_mode == 'dark' else 'dark' - self.btn_theme.icon = 'dark_mode' if self.page.theme_mode == 'light' else 'light_mode' - self.page.update() + def start_wrapped_generation(self, e): + self.btn_wrapped.content = ft.Container(content=ft.ProgressRing(width=20, height=20, stroke_width=2, color="onTertiary"), alignment=ft.alignment.center) + self.btn_wrapped.update() + configure_dynamic_logging(self.cb_logging.value, mode='a') + threading.Thread(target=self._generate_wrapped_thread, daemon=True).start() + def _generate_wrapped_thread(self): + logging.info("Starting Wrapped Generation...") + sort_mode = list(self.seg_sort.selected)[0] + sort_col = 'Value' if sort_mode == 'time' else 'Count' + current_unit = list(self.seg_unit.selected)[0] + + min_multiplier = 60.0 if current_unit == Theme.UNIT_HOURS else (1440.0 if current_unit == Theme.UNIT_DAYS else 1.0) + + top_songs = self.data_frames['song'].sort_values(sort_col, ascending=False).head(5).to_dict('records') + top_albums = self.data_frames['alb'].sort_values(sort_col, ascending=False).head(5).to_dict('records') + top_artists_list = self.data_frames['art'].sort_values(sort_col, ascending=False).head(5).to_dict('records') + top_artists = self.data_frames['art'].sort_values(sort_col, ascending=False).head(1).to_dict('records') + + total_minutes = int(self.data_frames['song']['Value'].sum() * min_multiplier) + + def get_pil_art(path): + return extract_art_from_file(path) + + wrapped_context = { + 'top_songs': [{'name': s['Song'], 'sub': s['Artist'], 'image': get_pil_art(s.get('Location')), 'count': s['Count']} for s in top_songs], + 'top_albums': [{'name': a['Album'], 'sub': a['Artist'], 'image': get_pil_art(a.get('Location')), 'minutes': int(a['Value'] * min_multiplier)} for a in top_albums], + 'top_artists_list': [{'name': a['Artist'], 'image': get_pil_art(a.get('Location'))} for a in top_artists_list], + 'genres': self.wrapped_data.get('genres', []), + 'total_minutes': total_minutes + } + + # Determine year label from appropriate date + lbl = self.dd_end.value if self.is_compare_mode and self.dd_end.value else self.dd_start.value + wrapped_context['year_label'] = lbl.split("-")[0] if lbl else "2025" + + start_label = self.dd_start.value.split(" ")[0] if self.dd_start.value else "Unknown" + end_label = self.dd_end.value.split(" ")[0] if (self.is_compare_mode and self.dd_end.value) else "Present" + + wrapped_context['age_data'] = { + 'age': self.wrapped_data.get('age', 0), + 'label': f"During {start_label} to {end_label}" + } + + if top_artists: + ta = top_artists[0] + wrapped_context['top_artist'] = { + 'name': ta['Artist'], + 'minutes': int(ta['Value'] * min_multiplier), + 'image': get_pil_art(ta.get('Location')) + } + + logging.info("Context prepared. Invoking generator...") + + self.generated_images = generate_wrapped.generate_card_stack(wrapped_context) + + logging.info("Wrapped Generation Complete.") + self._show_wrapped_ui() + + def _show_wrapped_ui(self): + self.selected_indices.clear() + controls = [] + + for i, img in enumerate(self.generated_images): + buffered = BytesIO() + img.convert("RGB").save(buffered, format="JPEG", quality=100) + b64_str = base64.b64encode(buffered.getvalue()).decode() + + def on_check(e, idx=i): + if e.control.value: self.selected_indices.add(idx) + else: self.selected_indices.discard(idx) + + controls.append(ft.Column([ + ft.Container(content=ft.Image(src_base64=b64_str, fit=ft.ImageFit.CONTAIN, border_radius=10), height=450), + ft.Checkbox(on_change=on_check) + ], horizontal_alignment="center", spacing=5)) + + self.wrapped_grid.controls = controls + self.btn_wrapped.content = ft.Row([ft.Icon("auto_awesome"), ft.Text("Generate Wrapped Cards")], alignment="center", spacing=10) + self.btn_wrapped.update() + self.toggle_wrapped_modal(True) + + def save_images(self, selected_only: bool): + self.dir_picker.data = selected_only + self.dir_picker.get_directory_path() + + def on_save_wrapped_dir(self, e: ft.FilePickerResultEvent): + if not e.path or not self.generated_images: return + try: + selected_only = self.dir_picker.data + indices = self.selected_indices if selected_only else None + + if selected_only and not indices: + self.page.snack_bar = ft.SnackBar(ft.Text("No images selected!")) + self.page.snack_bar.open = True + self.page.update() + return + + count = generate_wrapped.save_card_stack(self.generated_images, e.path, indices) + + self.page.snack_bar = ft.SnackBar(ft.Text(f"Saved {count} images successfully!")) + self.page.snack_bar.open = True + self.page.update() + except Exception as ex: + logging.error(f"Save failed: {ex}") + +# ========================================== +# APP ENTRY POINT +# ========================================== def main(page: ft.Page): - page.title = "TunesBack" + page.title = APP_NAME page.theme_mode = "system" page.padding = 0 page.window.min_width = 900 page.window.min_height = 600 - + is_linux = page.platform == "linux" page.window.frameless = False page.window.title_bar_hidden = True page.window.title_bar_buttons_hidden = is_linux page.window.bgcolor = ft.Colors.TRANSPARENT page.bgcolor = ft.Colors.TRANSPARENT - + app = TunesBackApp(page) page.add(app.main_layout) - if __name__ == "__main__": - ft.app(target=main) + ft.app(target=main) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ab2b25c..f96444b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,16 @@ [project] name = "tunesback" -version = "1.1.0" +version = "1.2.0" description = "Analyze your iTunes listening history" authors = [{name = "Peter Jin"}] requires-python = ">=3.11" dependencies = [ - "flet>=0.28.3", + "flet==0.28.3", "pandas", - "python-dateutil" + "python-dateutil", + "mutagen", + "Pillow", + "six>=1.11.0" ] [tool.flet] @@ -16,6 +19,8 @@ copyright = "Copyright © 2025 Peter Jin" [tool.flet.app] assets_dir = "assets" +icon = "icon.png" +modules = ["libpytunes"] [tool.flet.macos] -entitlement."com.apple.security.files.user-selected.read-only" = true +entitlement."com.apple.security.files.user-selected.read-only" = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4784b93..126ce79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ -flet +flet==0.28.3 pandas python-dateutil +mutagen +Pillow git+https://github.com/liamks/libpytunes.git#egg=libpytunes \ No newline at end of file